Compare commits

...

125 Commits

Author SHA1 Message Date
ospab cf14a4243c chore: release v0.4.4 on master 2026-08-08 21:37:55 +03:00
ospab 66368c9d0f fix(gui): helper task checked only its name, not the exe it points at
A Scheduled Task stores an absolute path. Checking that a task named
"OSTP TUN Helper" exists said nothing about whether its <Command> still points
at the helper we are about to run, and the paths do drift: a dev build
registers target\debug\ostp-tun-helper.exe, an installer registers Program
Files, and moving or reinstalling the app leaves the old path behind.

That failed silently in the worst way. schtasks /Run reports success for
merely ACCEPTING the launch request — a task whose exe no longer exists fails
afterwards, out of band, with nothing returned to us. So launch_as_admin
returned Ok, and the caller then sat in its 60-second connect loop before
reporting "Timeout connecting to helper." On every connect, permanently, with
no way out except deleting the task by hand.

The check now reads the registered <Command> back and compares it to the exe,
re-registering through the existing /F overwrite when they differ: one consent
prompt, once, instead of a permanent silent breakage.

The path is read via /Query /XML rather than /FO LIST /V because the list
format's field labels are localized — "Task To Run" is "Задача для запуска" on
a Russian Windows — while XML tag names are not. schtasks emits UTF-16LE with
a BOM there, which is decoded explicitly, with UTF-8 tolerated as a fallback.
Both paths are canonicalized before comparison so casing, `..` and 8.3 short
names do not read as a mismatch; a path that cannot be canonicalized no longer
exists, which is itself grounds to re-register.
2026-08-07 22:24:40 +03:00
ospab bc61b47817 fix(gui): the UAC-once change did not work and flashed consoles
Reported from v0.4.3: a consent prompt for schtasks, then 10-20 console windows
opening and closing, then STILL a prompt for the helper. Two defects of mine,
both in the change that was supposed to remove the repeated prompt.

Registration never succeeded. ShellExecuteW returns as soon as the elevated
process is LAUNCHED, not when it finishes, so the generated XML was deleted
while schtasks was still starting — it then had nothing to read. The task was
never created, so the code fell through to the direct elevated launch and the
user paid for two prompts to get what one used to do. Registration now goes
through PowerShell's Start-Process -Verb RunAs -Wait -PassThru, which actually
waits, lets the XML be deleted safely afterwards, and surfaces the real exit
code instead of it being inferred by polling. Arguments are passed as an array,
so the task name and XML path never touch a command line; verified the
generated script parses with a path containing an apostrophe, an ampersand and
spaces at once.

The flashing was every schtasks/reg/tasklist invocation: the GUI is a
windowed-subsystem binary, so each console child pops a window, and the
registration polled up to twenty times in a row. All of them now go through a
wrapper that sets CREATE_NO_WINDOW. This also silences flashes that predate
this feature — `tasklist` runs whenever the exclusions screen opens, and `reg`
on autostart changes.

Polling is gone with it: the exit code is authoritative, and the task's
presence is confirmed once rather than up to twenty times.

Also drops shell_execute_elevated, which this change had left with no callers.
2026-08-07 20:51:14 +03:00
ospab 5a33ed69c4 chore: release v0.4.3 on master 2026-08-07 17:39:19 +03:00
ospab c5e703c144 chore: release v0.4.3-beta.4 on beta 2026-08-07 17:35:15 +03:00
ospab 05f25155bd feat(gui): one UAC prompt per machine instead of one per connect
Enabling TUN raised a consent dialog every single time, because the GUI
elevated the helper with ShellExecuteW("runas") on each connect.

Registers a Scheduled Task with RunLevel=HighestAvailable the first time TUN
is used — that registration is the one prompt — and triggers the task on every
later connect. Running a task is not an elevation request, so Windows shows no
dialog for it. If the task is missing or cannot be triggered, the code falls
back to the original direct elevated launch, so this can only improve on the
old behaviour, never break it.

A task stores a FIXED command line, so the per-launch port and token cannot be
arguments. The helper gained --args-file and the GUI writes them to
%LOCALAPPDATA%\OSTP\helper-args.json immediately before triggering; the helper
deletes it after reading. That path keeps the token inside the trust boundary
it already had — the helper runs elevated but as the same user, and no other
user can read it, which a shared location would not guarantee.

Registered from an XML definition rather than /TR: the exe and args paths would
otherwise need quoting inside an already-quoted /TR value, escaped again
through ShellExecuteW, which breaks as soon as either contains a space — and
both do by default (Program Files, usernames with spaces). Validated that the
generated XML parses with a path containing both a space and an ampersand.
XML also lets DisallowStartIfOnBatteries=false and ExecutionTimeLimit=PT0S be
stated explicitly, without which a laptop would refuse to start the tunnel on
battery and Windows would kill it after three days.

Deliberately NOT a Windows service, contrary to what I suggested earlier. The
helper is one-shot by design: it force-exits after teardown because WinTun's
blocking receive otherwise keeps the adapter and its default route alive and
breaks the next connect. A persistent LocalSystem service would mean
restructuring that lifecycle for the same end-user result. The task also runs
with the user's own token rather than LocalSystem, which is less privilege for
the same outcome. A service is still the better answer if the tunnel should
come up before login — that is the one thing this cannot do.
2026-08-07 17:19:36 +03:00
ospab e6e0a7b28c chore: release v0.4.3-beta.3 on beta 2026-08-05 00:11:33 +03:00
ospab 8f0ffd08c0 feat(gui): TUN mode and autostart on Linux
Brings the Linux GUI up to parity with Windows. Four things blocked it, each
independently sufficient:

  - build.rs keyed the Windows-manifest step off cfg(windows), which in a build
    script describes the HOST. Cross-compiling the helper from Windows to Linux
    therefore took that branch and died with "Can only compile resource file
    when target_env is gnu or msvc". Now keyed off CARGO_CFG_TARGET_OS, with
    the cfg(windows) gate kept as a second check because winres is a host-
    resolved build-dependency and simply does not exist on a Linux host.
  - launch_as_admin was bail!("Windows only.") outside Windows. Implemented for
    Linux via pkexec, polkit's front-end, which raises a graphical auth prompt;
    sudo is unusable from a GUI with no terminal. Missing pkexec now names the
    package to install instead of failing opaquely.
  - The release workflow only built ostp-tun-helper in the Windows job, so the
    Linux package shipped without it. It is now built and placed next to the
    GUI binary, where find_helper_exe looks first.
  - set_autostart/get_autostart were no-ops off Windows. Implemented via XDG
    autostart (~/.config/autostart/ostp.desktop, honouring XDG_CONFIG_HOME),
    the direct equivalent of the HKCU Run key.

The helper's own code needed no changes — it already compiled for Linux once
the build script stopped rejecting it. list_running_processes already had a
Linux branch.

The token file is created 0600 on Linux: /tmp is world-readable there, unlike
the Windows temp dir, and that token authorises control of the privileged
helper.

Not verified on a live Linux desktop from here — the Tauri backend cannot be
compiled for Linux on a Windows host (GTK dev libraries), so the cfg(linux)
paths are reviewed rather than built. CI compiles them.
2026-08-04 01:08:33 +03:00
ospab 8a1426ecf5 fix(gui): honest TUN error on Linux, and a window that can be resized
Reported from the Linux GUI: it asked for "helper.exe" on Linux, and the
window was tiny.

The helper name had ".exe" hardcoded in every lookup path, so on Linux the
search could only ever fail. Fixing the name alone would have been misleading
though, because TUN mode does not work on Linux for a deeper reason:
launch_as_admin is `bail!("Windows only.")` outside Windows, and the release
workflow only builds ostp-tun-helper in the Windows GUI job. So the feature is
Windows-only, and the message now says exactly that and points at proxy mode,
instead of surfacing as a missing file named after a Windows executable —
which reads like a packaging mistake rather than an unimplemented feature. The
name is still resolved per-platform for when Linux elevation does land.

The window was 360x680 and `resizable: false`. Windows scales that by DPI, but
WebKitGTK on a HiDPI Linux display renders it close to raw pixels, giving a
postage-stamp window the user then could not resize. It is now resizable with
a sensible minimum, and .app-root caps and centres the column so a wider
window keeps the intended narrow layout instead of stretching the controls.
2026-08-04 00:56:28 +03:00
ospab e483af541f fix: post-suspend reconnect no longer strands the machine without internet
Two separate problems reported after waking a laptop: the app sits on
"connecting" forever, and there is NO working internet at all — not just no
tunnel. Plus typing in the GUI's exclusion fields lagged by seconds.

1. Resume reconnect retried forever (a regression I introduced when making the
   resume reconnect retry instead of firing once). handle_keepalive(force=true)
   deliberately skips the hard-timeout branch, and that branch is the one that
   releases the SystemProxyGuard. So a resume campaign that never succeeded
   also never gave up, and the system proxy stayed pointed at our local
   listener indefinitely — which kills all browser traffic, tunnel or not, and
   explains "no internet even from my ISP".

   Now bounded: after 45s of failed resume reconnects, hand back to the
   ordinary stall path, which restores the proxy (or, with kill switch on,
   keeps blocking deliberately). Measured on the wall clock, because Instant
   does not advance across suspend on Windows — QPC stops — so a monotonic
   deadline cannot bound anything that starts at wake. That same property is
   why the pre-existing 25s/180s stall checks never fired here either.

2. GUI froze while typing. Every debounced save (400ms, so it fires during
   natural pauses in typing) called set_autostart — a Windows registry write —
   even when the checkbox had not changed, and, while connected, wrote the
   config and ran reload_tunnel, tearing down and rebuilding the tunnel. Worst
   in the exclusion fields, which is exactly where it was reported.

   Autostart now applies only on change; the tunnel hot-reload only when a
   setting the tunnel actually reads has changed, on a 1.5s debounce so it
   lands after editing rather than between keystrokes. The cheap local save
   still runs on every keystroke.
2026-08-04 00:34:12 +03:00
ospab df1a14d15c chore: release v0.4.3-beta.2 on beta 2026-08-03 18:50:37 +03:00
ospab d915efc715 refactor(relay)!: forward transparently instead of re-authenticating clients
The relay authenticated clients itself, with an HMAC handshake and a
background job pulling the access-key list from the target server's management
API. That never worked with a real client and would not have been worth having
if it had.

It could not work: no OSTP client produces those credentials. The TCP path
required an HTTP request (`GET /stream` with `Authorization: Bearer`) and the
UDP path a `timestamp || HMAC` preamble, while the client sends junk frames
followed by length-prefixed frames, and an obfuscated Noise handshake. There
is no HMAC code in ostp-client at all, so every connection was rejected: TCP
answered 404, UDP dropped the datagram.

It was also weak where it applied. The HMAC covered only an 8-byte timestamp,
so a captured signature was a bearer token replayable from any address for the
clock-skew window, with no anti-replay set. And the HTTP handshake put a
literal `GET /stream` on the wire — a greppable signature in a protocol whose
premise is that nothing is recognisable.

Authentication now stays where it is cryptographically meaningful. The target
server already authenticates every session end-to-end via Noise with a PSK
derived from the access key and silently drops what fails; the relay adds
nothing by re-checking, and holding a copy of the key list on a forwarding box
is a liability. The relay makes no security decisions and says so.

What it does need is protection from being a resource sink, so this adds a
session cap, a connection cap, and a token-bucket admission limit on NEW
sessions only. It forwards to one fixed upstream and replies only to the
sender, so it is not a reflector: amplification is one.

Fixes a bug the new end-to-end test caught: upstream sockets were bound as
"[::]:0" and connected to a possibly-IPv4 upstream, which fails wherever
IPV6_V6ONLY defaults on — that is every deployment with an IPv4 target server.
The bind family now follows the resolved upstream.

upstream_api_url/token/sync_interval_secs are accepted and ignored so existing
relay configs keep parsing; the wizard and template no longer emit them, and
`ostp check` flags them as removable.
2026-08-03 18:49:57 +03:00
ospab b673219894 fix(server)!: never fall back to a direct connection when a rule says proxy
Reported: with an upstream proxy configured, Google flags the session because
the server's address and the proxy's exit address differ, and YouTube
geolocates to the server rather than the proxy exit. Both follow from the same
defect in three places — an outbound rule that says Proxy was being satisfied
by connecting DIRECTLY whenever the proxy could not be used:

  - router.rs, UdpSessionRouter::send_to: action Proxy with no UDP proxy
    established fell through to the direct socket.
  - outbound.rs, connect_target: any protocol string other than exactly
    "socks5"/"http" hit a `_ =>` arm that connected directly. A typo, or just
    "SOCKS5", silently un-proxied ALL TCP.
  - outbound.rs, connect_udp_target: non-socks5 upstream returned
    UdpProxySocket::Direct, with a comment noting HTTP cannot carry UDP —
    correct in itself, but the chosen fallback leaks.

The visible symptom is precisely what these produce: TCP goes through the
proxy while UDP (QUIC — which is what YouTube uses) leaves from the server,
so one session presents two exit addresses.

All three now fail closed. A rule asking for the proxy is never honoured by
sending in the clear: a dropped flow is visible and debuggable, a
deanonymising leak is neither. Protocol matching is also case-insensitive
now, and the SOCKS5 UDP ASSOCIATE failure warns unconditionally rather than
only under `debug`, which is why this could go unnoticed.

Behaviour change: with an HTTP upstream, or a broken SOCKS5 UDP ASSOCIATE,
UDP now fails instead of leaking. Where that is genuinely wanted, it must be
stated in the config as an explicit udp rule with action "direct".

Also carries the relay key-sync diagnostics: a 404 there means the URL is
missing the panel's secret webpath (the API is nested under it, not at /api),
which the previous bare "HTTP 404" gave no way to work out.
2026-08-03 17:57:42 +03:00
ospab a1c146aff3 fix(relay): explain the 404 — the API lives under the panel's secret webpath
A relay configured with upstream_api_url = "http://HOST:9090" fails every key
sync with a bare "API returned HTTP 404", which reads like the server is down
or the token is wrong. Neither is true: the management API is nested under the
target server's api.webpath (create_api_router mounts it at
"/{webpath}/api"), because that secret segment is what keeps the panel from
being discoverable by scanners. A bare host:port therefore resolves to a route
that does not exist and the token is never even looked at.

Nothing said so — the config template, the wizard prompt and the shipped
example all suggested exactly the host:port form that cannot work.

  - sync_keys now reports the full URL and, for 404 specifically, states that
    the webpath must be included and what the URL should look like. 401 is
    called out separately as a token mismatch, since the two are otherwise
    indistinguishable from the log.
  - The relay config template, the shipped example and the wizard prompt now
    show the path-bearing form, and the wizard warns when the URL entered has
    no path segment rather than letting it fail later.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

NOTE: existing installs are signed with a now-unreproducible random key, so
users must uninstall once more for THIS release. Every update after it works.
2026-07-30 19:54:31 +03:00
ospab a8aba8f4b8 feat(gui): green aura on the connected state
The palette already declared --c-green/--c-green-glow/--c-green-dim with the
comment "Green only for connected state", but the values were near-white
(#e8e8e8) and nothing referenced them - so a successful connection looked
identical to every other state. Gave the tokens real green values (per theme,
deeper on light so it stays legible), added a --c-green-rgb triple so the
translucent layers can be expressed from one source, and wired them into the
three things that signal "connected": the power button's border and glow, the
orbit rings around it, and the brand status dot.
2026-07-30 19:54:11 +03:00
ospab 2ede607027 chore: release v0.4.2-beta.5 on beta 2026-07-30 19:02:48 +03:00
ospab 0c69617725 fix(cli): trait-qualify Sha256::digest so it builds with or without the import
Follow-up to the v0.4.2-beta.3 CI break. Importing sha2::Digest fixed the
build there but the import reads as unused locally (different dependency
resolution), leaving a permanent warning in every build. Calling through
<sha2::Sha256 as sha2::Digest>::digest resolves the trait method
explicitly, so it compiles in both environments with no import and no
warning. Workspace now builds clean.
2026-07-30 14:41:16 +03:00
ospab 88e0634f09 fix: two independent causes of the tunnel freezing at 0 b/s
Both produce the same reported symptom - traffic stops dead, the session
itself looks fine, and only a manual reconnect recovers it.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Added noise unit tests for the .0/.1 -> send/recv role mapping and the
not-finished guard.
2026-07-11 21:14:15 +03:00
ospab 271a39c664 fix(icons): forcefully overwrite all android legacy and adaptive icons with logo_new.png 2026-07-11 20:59:45 +03:00
ospab 44f9067222 feat(icons): apply logo_new.png to all apps and watermarks 2026-07-11 00:58:44 +03:00
ospab dee0288f2a fix(release): rename -Switch param to -NewVersion (silently broke channel resolution)
A script parameter named exactly $Switch collides with PowerShell's `switch`
statement keyword - confirmed by bisection - and made every `$X = switch (...)
{...}` in the script silently evaluate to empty instead of erroring. This is
what produced the malformed "v0.4.1-.0" tag on the last release attempt
(Channel resolved to "" instead of "stable", Iteration to 0). Renaming the
parameter is the only fix; nothing else about the switch statement itself
was wrong.
2026-07-10 03:25:26 +03:00
ospab 10ec253fa0 chore: release v0.4.1-.0 on master 2026-07-10 03:17:41 +03:00
ospab cb59a5343f merge master: reconcile 3 commits pushed directly to master
master had UAC/SmartScreen fix, RTT/speed-display toggle, and a run-name CI
tweak that never made it back into alpha. Alpha already independently
contains equivalent (UAC fix is byte-identical) or superior (run-name
handles the newer alpha/beta/nightly channel scheme master's version
doesn't know about) versions of all three, so this merge is a pure
reconciliation - alpha's side wins on every conflicting hunk.
2026-07-10 03:14:59 +03:00
ospab b7bd8c20a5 docs: update CLI arguments to subcommands 2026-07-10 03:05:30 +03:00
ospab d725a4440b chore: gitignore netstack-smoltcp (vendored, already tracked separately) 2026-07-10 01:47:20 +03:00
ospab caab8698ba chore: remove dead ostp-license/frontend (143MB committed node_modules+dist)
No source ever existed in this tree for it - only a built dist/ and a
full node_modules/ dump (6200+ files), and nothing in the codebase
references "ostp-license" anywhere. Leftover from the old commercial-
license-gated era before the AGPLv3 switch; pure bloat since.

Also added **/node_modules/ to .gitignore - its absence is exactly how
this got committed in the first place.
2026-07-10 01:46:32 +03:00
ospab 3e9e8845f1 docs: remove nonexistent 'prober' entry from CLI reference
'ostp prober' was never a real subcommand - ostp-prober is a separate,
gitignored standalone tool, not part of the ostp binary's CLI surface
(no Prober variant in the Commands enum, no handler in main.rs). Also
fixed misaligned columns on the proxy-env/proxy-env-clear lines.
2026-07-10 01:44:01 +03:00
ospab e52087ee8e fix: restore LICENSE file to actual AGPL-3.0 text (was stuck on old BSL 1.1)
The repo switched to AGPLv3 back on 2026-06-18 (commit 9ce9e6d), and
Cargo.toml/README have said AGPL-3.0 ever since — but that license-change
commit was never carried forward into the 0.4.x rebuild branch, so the
actual LICENSE file silently reverted to the pre-rebuild BSL 1.1 text
(with a "converts to MIT in 2030" clause that hasn't applied for months).
Restored the real AGPLv3 text from 9ce9e6d.

Also added the missing `license` field to a few crate manifests that
didn't declare one (ostp-gui/src-tauri, ostp-jni, ostp-tun-helper), and
dropped the Tauri template placeholder authors/description.
2026-07-10 01:36:41 +03:00
ospab 2092f6c716 fix(flutter/android): surface getMetrics failures into the in-app log
Traced the whole traffic-counter pipeline (Dart -> MethodChannel ->
Kotlin -> JNI -> Bridge) end to end; it's architecturally identical to
the working desktop implementation, so no code-level bug was found.
Previously a getMetrics exception was only reported as a PlatformException
that Dart swallows with a bare debugPrint, invisible in the in-app log
viewer users actually have access to. Now it's also written to the
native log buffer via OstpClientSdk.addLog, so if the counter breaks
again the actual cause (exception vs. genuinely-zero atomics) shows up
in View Logs instead of requiring adb.
2026-07-10 01:23:28 +03:00
ospab 223f02287a feat(flutter): add optional live speed/RTT display, matching desktop GUI
Mirrors ostp-gui's "Show Speed" / "Show RTT" client settings toggles
(both default on): the home screen now shows live download/upload
throughput (computed from byte deltas between 1s polls, same as
desktop's poll()) as a subtitle under the existing cumulative
Download/Upload totals, and the RTT box is now hideable. Also fixed
"Test Ping" to actually query getMetrics instead of just faking a
500ms spinner with no real measurement.
2026-07-10 01:18:07 +03:00
ospab 1d1a1ea5af refactor: remove dead stealth_sni config field across the whole stack
stealth_sni was never actually consumed to construct any wire bytes —
verified dead in bridge.rs (only stored, never read). It implied
TLS/HTTP SNI mimicry that this project deliberately does not do
(zapret-like: packet-level DPI obfuscation only, no protocol
mimicry). Removed from the runtime schema (config.rs, bridge.rs),
both CLI/GUI local config shapes and their JSON templates, the
Flutter profile model/UI/share-link logic, and README feature docs.
migrate.rs now drops the field from legacy configs with a note
instead of carrying it forward.
2026-07-10 01:04:56 +03:00
ospab 1b3390a3cf fix(flutter): declutter profile editor, fix contrast bugs, unblock app list
- Profile edit dialog: moved junk packets + TCP fragmentation into their own
  modals (tap-to-configure), replacing 5 inline field rows with a compact
  2-button row. These are occasional/advanced settings, not something every
  profile edit needs to see up front.
- Profile card: subtitle repeated the server address verbatim whenever a
  profile had no custom name (name falls back to serverAddr) — showing
  "1.2.3.4:50000" as both title AND subtitle, with transport mode tacked on
  the end of the second copy. Now only shown once; added maxLines/ellipsis
  so long addresses truncate instead of wrapping awkwardly.
- Mobile: removed the "Bypass Processes" field entirely (editor UI, prefs
  key, config JSON). Android per-app selection (Configure Split Tunneling)
  is the real, correct control here — a process-name text field doesn't map
  to anything meaningful on Android the way it does on desktop.
- Share icon changed from a QR icon (redundant — the modal already shows a
  QR code) to the standard Material share glyph. Share modal title no
  longer interpolates the profile's name, which — same root cause as
  above — can silently BE the raw server address; title is now generic
  ("Share Profile") so a screenshot/recording can't leak host:port through it.
- Contrast: the monochrome theme's colorScheme.primary is pure white
  (0xFFFFFFFF); several buttons hardcoded white text/icons on top of it
  (Bypass/Proxy mode toggles, Copy Link), making them invisible when active.
  Added an _onColor() helper (luminance-based black/white pick) and applied
  it everywhere a button's foreground sits on a theme color.
- "Configure Split Tunneling" appeared to hang for 10-15s before doing
  anything: MainActivity.kt's getInstalledApps handler enumerated every
  installed package AND decoded+re-encoded each one's icon synchronously
  inside the MethodChannel callback, which runs on the main/UI thread by
  default — blocking it for the whole duration meant Flutter couldn't
  render ANY frame, not even the loading spinner, until it finished. Moved
  the work onto a background Thread; only the final result.success() hops
  back via runOnUiThread(). Navigation + spinner now show immediately.
2026-07-10 00:45:30 +03:00
ospab 7d9e5faeec fix(flutter): eagle watermark rendered as a flat gray square
assets/logo.png had NO real alpha transparency — both the background and
the eagle shape were fully opaque (A=255 everywhere), just baked in as
near-black (3,3,3) vs near-white (253,253,253) RGB. Applying `color:
Colors.white` to tint it painted the WHOLE bounding square white (alpha
being 255 across the entire image gives BlendMode nothing to mask against),
which at low Opacity looked like a flat gray square instead of a silhouette.

Converted the asset in place: since it was already grayscale (R=G=B), each
pixel's luminance became its new alpha channel, RGB set to pure white. The
background (near-black, low luminance) is now near-transparent; the eagle
(near-white, high luminance) is now near-opaque. This is the same effect the
desktop GUI gets for free from its logo.svg (a vector eagle path with no
background element at all — inherently transparent), just reproduced for a
raster asset without adding flutter_svg as a new dependency.

The `color: Colors.white` tint in both watermark call sites is now
redundant (the asset is already a pure-white silhouette) and removed.
2026-07-10 00:24:28 +03:00
ospab eda2a0eba7 fix(flutter): increase watermark opacity and apply to settings 2026-07-10 00:09:55 +03:00
ospab 22c2d5edd8 feat(flutter): update UI theme to monochrome with eagle watermark 2026-07-09 23:57:58 +03:00
ospab fa7ec2cd9a feat(flutter): update launcher icons to new eagle design with round support 2026-07-09 23:52:37 +03:00
ospab 794ea5251b refactor(ci): target_version + per-channel iteration in gha.ps1
Previous scheme conflated "which release is this" with "how many times has
it been rebuilt": every run bumped the patch version, so by the time a build
was ready to promote to master the version number had already crept forward
by however many alpha/beta iterations it took to get there.

Now a release cycle has one fixed target version (e.g. 0.4.1) that stays in
every manifest unchanged through all alpha/beta iterations; only a
per-channel counter increments, and that counter lives ONLY in the git tag,
never in Cargo.toml:

  v0.4.1-alpha.1 -> v0.4.1-alpha.2 -> ... -> v0.4.1-alpha.N
  v0.4.1-beta.1  -> v0.4.1-beta.2  -> ... -> v0.4.1-beta.N
  v0.4.1                                          <- master, iteration dropped

Deliberately "0.4.1-alpha.N" (dot AFTER the hyphen — a semver pre-release
identifier), not "0.4.1.N-alpha" (a 4th dot component before the hyphen):
the latter isn't valid semver and Cargo's version parser rejects it outright,
so it can never appear in Cargo.toml. That's also why the target version
itself never needs to change on a plain iteration — bumping every manifest +
refreshing both Cargo.locks is now skipped entirely unless -Switch actually
changes the target, making a routine alpha/beta push fast (just the state
file's counter + a tag).

Also fixes a real bug found while touching this: release.yml's push trigger
is tags-only ("v*") with no branch trigger, so the old `git push origin
$branch`-only path for alpha/pre-release never actually started a CI run —
only the master path (which already pushed a tag) worked. Every channel now
always pushes a real tag, which is what actually triggers the build.

release.yml's resolve-channel needed no changes: its tag-channel detection
already does substring matching (*-alpha*/*-beta*), so it classifies
"v0.4.1-alpha.37" correctly without modification.

-Prefix is gone — channel was always 1:1 with -Branch (alpha/pre-release/
master), so it was a redundant, independently-settable axis that could
silently drift from the branch (e.g. -Branch alpha -Prefix beta).
2026-07-09 23:42:27 +03:00
ospab c6d506e6c0 feat(flutter): profiles + per-profile junk/frag, drop WSS
Merges the best of both lineages instead of a blind revert to v0.3.21:
kept from v0.3.21: multi-profile management (add via QR scan/link/manual,
single-select active profile, auto-mode transport/MTU probing). Kept from
current: Share Config (QR generation), Check for Updates, curated stealth-SNI
domain list, and actually-rendered exclusions fields (v0.3.21 loaded/saved
them but never showed them in the UI — dead code).

New: junk packets (pc/ps min/max) and TCP fragmentation (chunk/sleep) are now
per-profile fields in the profile editor, mirroring the desktop GUI's profile
object shape 1:1 (ostp-gui/src/main.js) so behavior matches across platforms.

WSS is gone — removed from the model, the UI, and the config builder. The
core dropped TLS-mimicry transports entirely (see §A of the rebuild), so
there was nothing left for it to configure.

Config building now targets the flat single-server schema
(ostp_client::config::ClientConfig) built from ONE active profile, not the
old modular inbounds/outbounds/urltest-failover config — the core no longer
supports connecting to multiple servers at once, matching how the desktop
GUI already works (single activeId). Also dropped a dead nested "tun": {...}
object that neither version's JSON producer nor the real ClientConfig struct
ever actually used — serde silently ignored it.
2026-07-09 23:42:10 +03:00
ospab 61091b6d56 chore(release): 0.4.7 + fix resolve-channel prerelease labelling
resolve-channel treated EVERY v* tag as stable, so v0.4.6-beta got published
as a non-prerelease "Latest" release, sitting on top of the release line. Now
a pushed tag is used as-is and its suffix decides the channel: v*-alpha / v*-beta
are prereleases, only a bare vX.Y.Z is stable. (A tag is never recomputed from
Cargo.toml, so the release can't upload to a different tag than the one pushed.)

Version bumped 0.4.5 -> 0.4.7 (0.4.6 is already taken by the mislabelled beta).
This commit's tip is what gets tagged v0.4.7-beta to cut the beta build.
2026-07-09 14:58:40 +03:00
ospab aa1c4ccd52 chore: ignore AI agent instructions directory 2026-07-09 14:53:11 +03:00
ospab 5ab6833eab feat(core): time-rotating junk marker — kill the static per-user fingerprint
The junk marker was a per-key CONSTANT sent in plaintext at a fixed offset in
junk frames. Junk is meant to look like random noise (zapret-style), but a
constant prefix is a recognizable per-user structure: an on-path observer
watching one user sees the same 4 bytes on every junk packet, i.e. an OSTP
fingerprint. (The earlier fix only removed the GLOBAL constant.)

Now the marker rotates every 60s window: junk_marker = HKDF(key, ver, 0x04 ||
window). To an observer the prefix changes each window (no fixed signature),
and a captured marker is only valid for ~1 window — the "bit of protection"
against a leaked marker. Only a key holder can compute it, so an outsider still
can't forge a silently-dropped junk packet (and silent-drop is cheaper than
normal processing anyway, so junk spam was never a DoS lever to begin with).

- core: derive_junk_marker(key, window) + current_junk_window() (60s window),
  same version-gated HKDF scheme; junk_marker dropped from DerivedSecrets.
- client: stamps junk with the current window's marker.
- server: checks current AND previous window per key (absorbs ~1 window of
  clock skew) before falling through to unauthorized-probe handling.
- Not a wire break: only junk framing changes; real handshake/data untouched.
  During mixed rollout, unmatched junk merely logs as a probe (cosmetic).
2026-07-09 14:43:40 +03:00
ospab 5dc3a60017 refactor(logging): consolidate all logs into one ostp.log, Windows clears on start
Every process (CLI daemon, GUI, TUN helper) and every subsystem (tracing, the
core event logger, the helper IPC, panic hook) wrote its own file: ostp-cli.log
+ ostp-core.log + ostp-helper.log + ostp-crash.log — a pile per run. Now they
all funnel into a single ostp.log next to the exe.

- logging: LOG_FILE_NAME/log_file_path() as the one source of truth; init_tracing
  gains a `truncate` arg. Truncation is gated twice: Windows-only (cfg!(windows))
  AND daemon-only. One-shot commands (gk/check/init/-V/...) and the elevated TUN
  helper pass truncate=false so they can never wipe a running daemon's log;
  invocation_is_daemon() detects the daemon from argv. On Linux the server always
  appends (history kept, OS-rotated) as requested.
- runner/helper manual writers + panic hook now target log_file_path(), so their
  output lands in the same ostp.log instead of separate files.
2026-07-09 14:32:08 +03:00
ospab a33e5d3874 ci: fix invalid rust toolchain name 2026-07-09 14:30:23 +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
ospab e21acc2ee1 docs: update references from nightly to alpha 2026-07-09 02:49:33 +03:00
ospab 1568db3323 ci: rename nightly to alpha 2026-07-09 02:46:47 +03:00
ospab edb2d8e229 ci: fix powershell encoding error 2026-07-09 02:45:00 +03:00
ospab d609a3e883 ci: fix powershell parse error caused by utf-8 em-dash 2026-07-09 02:44:11 +03:00
ospab 43914055b3 ci: fix branch name in gha.ps1 2026-07-09 02:43:46 +03:00
ospab 3df5d5fccf ci: remove push branches to save GHA minutes, update script to beta 2026-07-09 02:43:07 +03:00
ospab 3d531ee0d9 fix(gui): fix light theme invisible text/borders 2026-07-09 02:39:19 +03:00
ospab 2819e2b3c2 feat(gui): add junk and tcp fragmentation global settings 2026-07-09 02:35:21 +03:00
ospab 244d3ad374 ci: update GHA run-name format for releases 2026-07-09 01:25:46 +03:00
ospab b89c6b0950 ci: update GHA run-name format for releases 2026-07-09 01:24:43 +03:00
ospab 992c212c76 feat(gui): make RTT and speed display optional 2026-07-08 23:59:35 +03:00
ospab 6361a87072 feat(gui): make RTT and speed display optional 2026-07-08 23:12:21 +03:00
ospab fbc37e9d39 fix(gui): fix UAC SmartScreen and scrolling UI layout 2026-07-08 22:50:21 +03:00
ospab db581ca391 fix(gui): fix UAC SmartScreen and scrolling UI layout 2026-07-08 22:45:03 +03:00
ospab a547ebff17 fix(ci): quote run-name — unquoted colon broke YAML parsing on every push
The run-name expression contains the GHA string literal 'Release build: {0}'
— an unquoted YAML plain scalar treats ": " as starting a nested mapping,
which invalidated the entire workflow file at parse time (before any job
runs). Every push since that line was introduced failed instantly with
"Invalid workflow file ... line 11", silently burning an Actions-minutes
run each time for nothing. Wrapping the whole expression in double quotes
fixes it — verified with `npx js-yaml` that the file now parses and the
run-name value round-trips intact.
2026-07-08 22:23:01 +03:00
ospab d065f6ceca chore: release 0.4.4-nightly on nightly 2026-07-08 19:13:55 +03:00
ospab d822f48891 refactor(config): one canonical config.json schema for client/server/relay
All three on-disk config.json shapes (client, server, relay) used to be
declared locally inside ostp/src/main.rs, invisible to any other consumer —
which is exactly how ostp_client::migrate ended up matching against loosely
typed serde_json::Value instead of a real schema, with no guarantee its
hand-built output actually matched what the CLI parser expected.

Moved every one of those definitions (AppMode, UnifiedConfig, ServerConfig,
RelayServerConfig, ClientFileConfig, TunConfig, ExcludeConfig, MuxConfig,
TransportConfigRaw, ApiConfig, FallbackCfg, ListenConfig, UserConfig) into
ostp_client::config — the same file that already held the runtime
ClientConfig/OstpConfig/etc. main.rs now imports them instead of
re-declaring them (`ClientFileConfig as ClientConfig` to avoid colliding
with the runtime ClientConfig, which stays separate on purpose: it's the
engine's internal shape — handshake/io timeouts and the like a user never
sets in config.json — built FROM one of these via the mapping in
run_client_directly, not the same thing).

ServerConfig.dns is now Option<serde_json::Value> rather than
Option<ostp_server::dns::DnsConfig> — ostp-client doesn't (and shouldn't)
depend on ostp-server just to name that type. main.rs, which already depends
on both crates, deserializes it right before handing it to run_server().

cmd_migrate now proves its output against this schema before ever writing
to disk (serde_json::from_value::<UnifiedConfig>(migrated)) — a migrator/
schema drift is now a hard error at migrate time, not a corrupted
config.json discovered later. Added a matching unit test
(every_migrated_output_matches_the_canonical_schema) that exercises this
same check on all three migration paths (modular, legacy-flat, server).
2026-07-08 18:58:34 +03:00
ospab 26665a826f feat(client): one authoritative config migrator, manual-only
- fix(cli): stop printing the startup banner ("ostp-cli vX.Y.Z | OS: ...")
  to stderr on every single command invocation. init_tracing() ran
  unconditionally before command dispatch, so `ostp -V`, `ostp gk`, etc. all
  showed it. It's still written to the log file (useful there), just no
  longer echoed via the stderr tracing layer for one-shot commands.

- feat(client): add ostp-client::migrate, the ONE place config migration
  runs. Previously there were three uncoordinated migration paths: a Python
  snippet embedded in scripts/install.sh (only touched server api.* fields,
  ran on every update), the old 0.3.x line's auto-migration on every hot
  reload (silent besides a log warning), and nothing at all for the current
  rebuild. Consolidated into one module covering every config shape that's
  actually existed:
    - v0.3.1-v0.3.21 modular (inbounds/outbounds/routing) -> current flat
      schema, including correctly resolving routing.default_outbound through
      a urltest/selector group to the real server, and reporting (not
      silently dropping) every additional server a multi-server config had.
    - pre-0.3.1 flat configs carrying now-dead fields (tun.wintun_path,
      tun.ipv4_address, transport.wss) -> dropped with an explicit reason,
      everything else passes through untouched.
    - server configs -> backfills api.* defaults and drops legacy api.token
      (ported straight from the install.sh Python, same behavior, correct
      place).
  6 unit tests cover all of the above against realistic fixtures. Wired up
  as `ostp migrate` (was missing from Commands entirely) — no other code
  path calls into this module, so a config's shape only ever changes when
  explicitly asked.

- feat(cli): `ostp import <url>` now asks the same TUN/mux/debug questions
  `ostp connect <url>` always did. Previously import just wrote flat
  defaults to disk with no way to turn any of that on short of hand-editing
  the resulting config.json afterward. Extracted the shared prompt into
  prompt_client_options() so both paths stay in sync.

- chore(install): remove the embedded Python config-migration snippet from
  install.sh; schema migration must never happen implicitly during an
  install/update. Points users at `ostp migrate` instead.
2026-07-08 18:45:04 +03:00
ospab 7b43e1dcf7 ci: prefix rolling-channel release tags with "v" for consistency
Stable releases were already "vX.Y.Z" (from the actual git tag pushed for
that channel), but nightly/beta tags were bare "X.Y.Z-nightly"/"X.Y.Z-beta"
— inconsistent with the v-prefixed convention used everywhere else. Every
channel's tag now starts with v: "vX.Y.Z-nightly", "vX.Y.Z-beta", "vX.Y.Z".
2026-07-08 18:20:45 +03:00
ospab b17e5499eb ci: fix run-name to say "channel" instead of a bare branch name
run-name showed "release version nightly" / "release version pre-release"
for every branch-push run — read exactly like the release TAG was bare
"nightly"/"pre-release" (the bug fixed earlier), when the actual GH Release
tag has been correctly versioned (e.g. "0.4.3-nightly") all along via
resolve-channel. run-name can't reference job outputs (it's evaluated before
any job runs), so it can't show the real computed tag directly — spell out
"channel" instead so the label can't be mistaken for the release tag again.
2026-07-08 18:18:11 +03:00
ospab ec947ec9d1 chore: release 0.4.3-nightly on nightly 2026-07-08 18:13:37 +03:00
ospab 0ec09d1311 ci: add scripts/gha.ps1 — versioned release cutter with channel memory
Replaces ad-hoc manual tag pushes (which is how the confusing v0.4.1-beta /
v0.4.2-beta / bare "nightly" / "pre-release" release mess happened) with one
script that always goes through the same path: bump every version manifest,
commit, and push in the way release.yml's resolve-channel job actually
expects (branch push for nightly/pre-release, a real "vX.Y.Z" tag for
master — never a hand-pushed "vX.Y.Z-beta"-style tag).

Remembers the last {version, branch, prefix} used in .release-state.json, so
a bare run repeats last time's channel with the patch version bumped, and
-Switch starts a new version line (e.g. 0.3.x -> 0.4.0) without disturbing
which channel is currently being released to.
2026-07-08 18:05:01 +03:00
ospab f81610f939 chore: bump version to 0.4.2 2026-07-08 17:28:14 +03:00
ospab 114011df5a docs: add commit conventions and branch strategy to CONTRIBUTING
- New "Commit Message Conventions" section (type(scope): summary + a body
  only when the why isn't obvious from the diff) — formalizes the style
  already used across this rebuild's history.
- New "Branch Strategy" section documenting the nightly -> pre-release ->
  master promotion model (pre-release/master are fast-forward-only,
  never committed to directly).
- Fixed PR/branch-creation instructions that still said "target master" /
  "branch from master" — contributor work targets nightly now.
- Clarified the ostp-control build step is optional for day-to-day
  core/client/server work (the server embeds a dummy dist/ otherwise).
2026-07-08 17:28:10 +03:00
ospab f96daaf57d feat(client): auto-reconnect on network change or any subsystem drop
run_client_core previously ran once: if the OSTP protocol connection, the
TUN device, or the local proxy listener ended for any reason (network
change stranding the socket/adapter on a dead interface, a transient
crash, a drop the inner Bridge-level "TunnelStopped" retry couldn't
recover from), the whole client returned/errored and just stayed down.

Wrapped the existing body (now run_client_once) in an outer supervising
loop: any non-shutdown-requested exit triggers a full clean restart —
fresh DNS resolution, fresh Bridge, fresh TUN/proxy — with backoff
(1/2/5/10/20/30s, resetting once a run has been stable for 60s). Only an
explicit shutdown request stops the loop. connection_state reports
"connecting" during the retry wait so the UI shows reconnecting, not
disconnected.
2026-07-08 17:28:05 +03:00
ospab 6929d42736 Polish docs/README to match v0.4.x: fix license mismatch, CLI, crypto docs
- README.md/README.ru.md: License section still said "Business Source
  License 1.1 ... converts to MIT in 2030" while the badge right above it,
  Cargo.toml, and LICENSE itself all say AGPL-3.0 — a direct contradiction.
  Now both say AGPL-3.0 and link to LICENSE.
- README.md/README.ru.md: CLI Reference / Quick Start described the old
  flag-based interface (--init, --check, --generate-key, --links, bare
  positional URL) that no longer exists after the subcommand refactor.
  Rewrote both to the current `ostp <command>` surface (run/connect/setup/
  init/check/gk/links/import/update/migrate/prober/proxy-env/uninstall),
  including gk's alias and update's --branch/--version. RU previously had
  no command reference at all; added one to match EN.
- docs/{en,ru}/obfuscation.md: removed the XTLS-Reality section (feature
  removed in §A) and replaced it with an accurate description of junk
  packets + TCP fragmentation, the actual current supplementary stealth
  mechanism, including the per-key junk marker (no global DPI signature).
  Also corrected the key-derivation and masking-algorithm descriptions,
  which described a much older scheme (SHA-256(access_key)[0..8] + static/
  nonce-based XOR) than what derive_all_secrets()/derive_payload_mask()
  actually implement now (HKDF with version-gated, domain-separated
  outputs; HMAC-SHA256 mask keyed on the packet's own ciphertext). The RU
  version was additionally rewritten out of an oddly formal "industrial
  telemetry" register into plain technical Russian.
2026-07-08 03:06:53 +03:00
ospab 5e0ff4a7ef Remove repo-root cruft: scratch files, dead migration script, stale wiki/config
- .ostp_public_ip: a server RUNTIME cache file (its own detected public IP),
  never meant to be tracked — got committed by accident from a dev run in
  the repo root. Removed and gitignored so it can't happen again.
- test.json (2 bytes), test_addr.rs (95 bytes): leftover scratch files with
  no references anywhere in code, CI, or docs.
- refactor.py: a one-off AST-surgery script hardcoding an absolute path to
  a specific dev machine (d:/ospab-projects/ostp/...); its job (splitting
  up bridge.rs::run()) is long done, and it isn't invoked by anything.
- server.json: duplicate/stale example config at repo root — the real
  canonical example already lives at docs/relay-config-example.json, and
  this one still had a "reality" section for the TLS-mimicry feature we
  removed in §A.
- ostp-wiki/: duplicate in-repo copy of wiki content. The old 0.3.x
  lineage already deleted this once ("remove useless ostp-wiki folder from
  root") before this rebuild branched off an earlier point that predates
  that cleanup — removing it here brings v0.4.x back in line with that
  decision.
2026-07-08 02:59:02 +03:00
ospab c330a0abe3 Fix UAC diagnostics parity, gk alias, flutter version, and versioned CI channels
- GUI launch_as_admin now matches the CLI's UAC diagnosis: detects
  ERROR_CANCELLED (1223, user declined the prompt) instead of silently
  treating it as success, and reports GetLastError()+exe path for any other
  ShellExecuteW failure, replacing the old single opaque "denied or missing"
  message that made GUI/TUI failures impossible to tell apart.
- generate-key subcommand renamed to `gk` (kept `generate-key` as an alias).
- Fixed a real short-flag collision: GenerateKey's --count used short='c',
  which collides with the global --config short (propagated into every
  subcommand); clap validates the whole command tree on first parse(), so
  this could break parsing for the entire CLI, not just generate-key/gk.
  --count is now short='n'.
- ostp-flutter/pubspec.yaml version was stuck at 0.2.97+12; bumped to 0.4.1+13.
- release.yml: added a resolve-channel job that computes one release tag per
  run instead of repeating the logic in five upload steps. Rolling channel
  pushes now carry the actual Cargo.toml version instead of a bare channel
  name: `{version}-nightly` for the nightly branch, `{version}-beta` for
  pre-release. workflow_dispatch gained a `channel` input restricted to
  nightly/beta only — a manual run can never accidentally publish a "stable"
  release; that still requires an explicit vX.Y.Z tag push.
2026-07-08 02:52:23 +03:00
187 changed files with 6989 additions and 6604 deletions

View File

@ -1,26 +1,107 @@
name: CI/CD
run-name: "CI/CD: release version ${{ github.ref_name }}"
# `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"/"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.
# NOTE: this value MUST be quoted. The GHA string literal below contains
# "Release build: {0}" - an unquoted YAML plain scalar treats ": " (colon
# then space) as starting a nested mapping, which is exactly what broke every
# single push since this line was introduced: GitHub rejected the whole
# workflow file at parse time (before any job runs), silently burning an
# Actions-minutes-billed run per push for nothing.
run-name: "${{ startsWith(github.ref, 'refs/tags/') && (contains(github.ref_name, 'beta') && format('CI/CD: beta version {0}', github.ref_name) || contains(github.ref_name, 'alpha') && format('CI/CD: alpha version {0}', github.ref_name) || format('CI/CD: release version {0}', github.ref_name)) || format('CI/CD: {0} channel build', github.ref_name) }}"
on:
push:
tags:
- "v*"
branches:
- nightly
- pre-release
workflow_dispatch:
inputs:
channel:
description: >-
Manually build+release just this rolling channel. Stable releases
are NEVER picked here on purpose - cut those only via a real
"vX.Y.Z" tag push, so a manual dispatch can't accidentally publish
a "stable" release.
type: choice
required: true
default: alpha
options:
- alpha
- beta
permissions:
contents: write
# ── Global defaults ─────────────────────────────────────────────────────────
# -- Global defaults ---------------------------------------------------------
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: short
jobs:
# Computes ONE channel + release tag for this whole run, so every build
# job (native matrix + all 3 GUI platforms + Android) uploads to the exact
# same release under the exact same tag, instead of repeating this logic
# (and risking it drifting out of sync) in five separate places.
#
# 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 `beta` -> "{version}-beta" (rolling, same tag every push)
# - workflow_dispatch -> forced by the `channel` input (alpha|beta only)
resolve-channel:
name: Resolve release channel
runs-on: ubuntu-latest
outputs:
channel: ${{ steps.resolve.outputs.channel }}
tag_name: ${{ steps.resolve.outputs.tag_name }}
prerelease: ${{ steps.resolve.outputs.prerelease }}
steps:
- uses: actions/checkout@v4
- name: Resolve channel, version, and release tag
id: resolve
shell: bash
run: |
set -euo pipefail
BASE_VERSION=$(grep -m1 '^version' Cargo.toml | sed -E 's/version *= *"([^"]+)"/\1/')
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
# A pushed tag is authoritative — use it AS-IS (never recompute it
# from Cargo.toml, or the release would upload to a different tag than
# the one that triggered this run). The channel, and thus prerelease,
# is decided by the tag's suffix: v0.4.7-beta / v0.4.7-alpha are
# prereleases; a bare vX.Y.Z is the only thing that becomes stable.
TAG="${{ github.ref_name }}"
case "$TAG" in
*-alpha*) CHANNEL="alpha" ;;
*-beta*) CHANNEL="beta" ;;
*) CHANNEL="stable" ;;
esac
else
# No tag (workflow_dispatch, or a legacy branch push): pick the
# 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 }}" = "beta" ]; then
CHANNEL="beta"
else
CHANNEL="alpha"
fi
TAG="v${BASE_VERSION}-${CHANNEL}"
fi
echo "Resolved channel=$CHANNEL tag=$TAG (base version $BASE_VERSION)"
echo "channel=$CHANNEL" >> "$GITHUB_OUTPUT"
echo "tag_name=$TAG" >> "$GITHUB_OUTPUT"
echo "prerelease=$([ "$CHANNEL" = "stable" ] && echo false || echo true)" >> "$GITHUB_OUTPUT"
check-and-test:
name: Check & Test
runs-on: ubuntu-latest
@ -58,13 +139,13 @@ jobs:
publish-release-matrix:
name: Release for ${{ matrix.target }}
needs: check-and-test
needs: [check-and-test, resolve-channel]
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
# ── Windows ──────────────────────────────────────────────────────
# -- Windows ------------------------------------------------------
- os: windows-latest
target: x86_64-pc-windows-msvc
artifact_name: ostp.exe
@ -83,7 +164,7 @@ jobs:
release_name: ostp-windows-arm64.zip
wintun_arch: arm64
# ── macOS ─────────────────────────────────────────────────────────
# -- macOS ---------------------------------------------------------
- os: macos-latest
target: x86_64-apple-darwin
artifact_name: ostp
@ -94,7 +175,7 @@ jobs:
artifact_name: ostp
release_name: ostp-darwin-arm64.tar.gz
# ── Linux native ──────────────────────────────────────────────────
# -- Linux native --------------------------------------------------
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
artifact_name: ostp
@ -106,7 +187,7 @@ jobs:
release_name: ostp-linux-386.tar.gz
use_cross: true
# ── Linux cross ───────────────────────────────────────────────────
# -- Linux cross ---------------------------------------------------
- os: ubuntu-latest
target: aarch64-unknown-linux-musl
artifact_name: ostp
@ -144,7 +225,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
# ── Frontend Build ─────────────────────────────────────────────────────
# -- Frontend Build -----------------------------------------------------
- name: Setup Node.js
uses: actions/setup-node@v4
with:
@ -157,18 +238,18 @@ jobs:
if [ -f package.json ]; then
npm install && npm run build
else
echo "ostp-control has no package.json using committed dist/"
echo "ostp-control has no package.json - using committed dist/"
[ -f dist/index.html ] || echo '<!doctype html><title>OSTP</title>' > dist/index.html
fi
# ── Rust toolchain ─────────────────────────────────────────────────────
# -- Rust toolchain -----------------------------------------------------
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ matrix.toolchain || 'stable' }}
targets: ${{ !matrix.use_cross && matrix.target || '' }}
# ── Cargo cache (shared per target) ───────────────────────────────────
# -- Cargo cache (shared per target) -----------------------------------
- name: Restore Cargo cache
uses: actions/cache@v4
with:
@ -181,18 +262,18 @@ jobs:
restore-keys: |
cargo-${{ matrix.target }}-
# ── MUSL tools for native Linux musl builds ────────────────────────────
# -- MUSL tools for native Linux musl builds ----------------------------
- name: Install musl-tools
if: ${{ matrix.os == 'ubuntu-latest' && !matrix.use_cross }}
run: sudo apt-get update && sudo apt-get install -y musl-tools
# ── Native build ───────────────────────────────────────────────────────
# -- Native build -------------------------------------------------------
- name: Build (native)
if: ${{ !matrix.use_cross }}
shell: bash
run: cargo build --release --target ${{ matrix.target }} --bin ostp
# ── Cross build ────────────────────────────────────────────────────────
# -- Cross build --------------------------------------------------------
- name: Restore cross binary cache
if: ${{ matrix.use_cross }}
id: cross-cache
@ -203,13 +284,21 @@ 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 }}
run: cross build --release --target ${{ matrix.target }} --bin ostp
# ── Driver dependencies ────────────────────────────────────────────────
# -- Driver dependencies ------------------------------------------------
- name: Download wintun (Windows)
if: ${{ matrix.os == 'windows-latest' }}
shell: pwsh
@ -221,7 +310,7 @@ jobs:
Get-ChildItem "$dir/wt_tmp" -Filter "wintun.dll" -Recurse | Where-Object { $_.FullName -match 'bin[\\/]${{ matrix.wintun_arch }}[\\/]' } | Copy-Item -Destination "$dir/"
Remove-Item "$dir/wt.zip","$dir/wt_tmp" -Recurse -Force
# ── Package ────────────────────────────────────────────────────────────
# -- Package ------------------------------------------------------------
- name: Package (Windows)
if: ${{ matrix.os == 'windows-latest' }}
shell: pwsh
@ -240,26 +329,23 @@ jobs:
FILES="${{ matrix.artifact_name }}"
tar -czf "${{ matrix.release_name }}" -C "$dir" $FILES
# ── Upload ─────────────────────────────────────────────────────────────
# -- Upload -------------------------------------------------------------
- name: Upload to GitHub Release
uses: softprops/action-gh-release@v2
with:
# Version tags (v0.4.1, v0.4.1-beta.N) use their own name as the
# release; branch pushes (nightly/pre-release) roll a release named
# after the branch itself — no name remapping needed since
# github.ref_name is already the tag OR the branch name as-is.
tag_name: ${{ github.ref_name }}
# Any branch push is a rolling prerelease; for real version tags,
# a hyphenated suffix (-beta.N) marks it prerelease, a bare
# semver tag (v0.4.1) is a stable release.
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') || contains(github.ref_name, '-') }}
# Computed once in resolve-channel so every platform/job in this run
# lands on the exact same tag: "{version}-alpha" / "{version}-beta"
# for rolling channel pushes, or the pushed "vX.Y.Z" tag as-is for a
# real stable release.
tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
files: ${{ matrix.release_name }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-windows-gui:
name: Build Windows GUI (Tauri) - ${{ matrix.arch }}
needs: check-and-test
needs: [check-and-test, resolve-channel]
runs-on: windows-latest
strategy:
matrix:
@ -292,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
@ -326,22 +420,19 @@ jobs:
- name: Upload to GitHub Release
uses: softprops/action-gh-release@v2
with:
# Version tags (v0.4.1, v0.4.1-beta.N) use their own name as the
# release; branch pushes (nightly/pre-release) roll a release named
# after the branch itself — no name remapping needed since
# github.ref_name is already the tag OR the branch name as-is.
tag_name: ${{ github.ref_name }}
# Any branch push is a rolling prerelease; for real version tags,
# a hyphenated suffix (-beta.N) marks it prerelease, a bare
# semver tag (v0.4.1) is a stable release.
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') || contains(github.ref_name, '-') }}
# Computed once in resolve-channel so every platform/job in this run
# lands on the exact same tag: "{version}-alpha" / "{version}-beta"
# for rolling channel pushes, or the pushed "vX.Y.Z" tag as-is for a
# real stable release.
tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
files: ostp-windows-gui-${{ matrix.arch }}.zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-linux-gui:
name: Build Linux GUI (Tauri) - ${{ matrix.arch }}
needs: check-and-test
needs: [check-and-test, resolve-channel]
runs-on: ubuntu-latest
strategy:
matrix:
@ -377,39 +468,46 @@ 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
run: |
npm install
# TUN mode shells out to this helper, elevated via pkexec. Only the
# Windows job used to build it, so the Linux package shipped without
# it and TUN could never start.
cargo build -p ostp-tun-helper --release --target ${{ matrix.target }} --manifest-path ../Cargo.toml
npx tauri build --no-bundle --target ${{ matrix.target }}
- name: Package Portable Tarball
run: |
set -euo pipefail
mkdir ostp-linux-gui-${{ matrix.arch }}
cp ostp-gui/src-tauri/target/${{ matrix.target }}/release/ostp-gui ostp-linux-gui-${{ matrix.arch }}/
# The GUI looks for the helper next to its own executable first.
cp target/${{ matrix.target }}/release/ostp-tun-helper ostp-linux-gui-${{ matrix.arch }}/
tar -czf ostp-linux-gui-${{ matrix.arch }}.tar.gz ostp-linux-gui-${{ matrix.arch }}
- name: Upload to GitHub Release
uses: softprops/action-gh-release@v2
with:
# Version tags (v0.4.1, v0.4.1-beta.N) use their own name as the
# release; branch pushes (nightly/pre-release) roll a release named
# after the branch itself — no name remapping needed since
# github.ref_name is already the tag OR the branch name as-is.
tag_name: ${{ github.ref_name }}
# Any branch push is a rolling prerelease; for real version tags,
# a hyphenated suffix (-beta.N) marks it prerelease, a bare
# semver tag (v0.4.1) is a stable release.
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') || contains(github.ref_name, '-') }}
# Computed once in resolve-channel so every platform/job in this run
# lands on the exact same tag: "{version}-alpha" / "{version}-beta"
# for rolling channel pushes, or the pushed "vX.Y.Z" tag as-is for a
# real stable release.
tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
files: ostp-linux-gui-${{ matrix.arch }}.tar.gz
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-macos-gui:
name: Build macOS GUI (Tauri) - ${{ matrix.arch }}
needs: check-and-test
needs: [check-and-test, resolve-channel]
runs-on: macos-latest
strategy:
matrix:
@ -442,7 +540,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
@ -459,22 +560,19 @@ jobs:
- name: Upload to GitHub Release
uses: softprops/action-gh-release@v2
with:
# Version tags (v0.4.1, v0.4.1-beta.N) use their own name as the
# release; branch pushes (nightly/pre-release) roll a release named
# after the branch itself — no name remapping needed since
# github.ref_name is already the tag OR the branch name as-is.
tag_name: ${{ github.ref_name }}
# Any branch push is a rolling prerelease; for real version tags,
# a hyphenated suffix (-beta.N) marks it prerelease, a bare
# semver tag (v0.4.1) is a stable release.
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') || contains(github.ref_name, '-') }}
# Computed once in resolve-channel so every platform/job in this run
# lands on the exact same tag: "{version}-alpha" / "{version}-beta"
# for rolling channel pushes, or the pushed "vX.Y.Z" tag as-is for a
# real stable release.
tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
files: ostp-macos-gui-${{ matrix.arch }}.tar.gz
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-android:
name: Build Android Client (Flutter) - ${{ matrix.arch }}
needs: check-and-test
needs: [check-and-test, resolve-channel]
runs-on: ubuntu-latest
strategy:
matrix:
@ -510,40 +608,117 @@ 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
with:
# Version tags (v0.4.1, v0.4.1-beta.N) use their own name as the
# release; branch pushes (nightly/pre-release) roll a release named
# after the branch itself — no name remapping needed since
# github.ref_name is already the tag OR the branch name as-is.
tag_name: ${{ github.ref_name }}
# Any branch push is a rolling prerelease; for real version tags,
# a hyphenated suffix (-beta.N) marks it prerelease, a bare
# semver tag (v0.4.1) is a stable release.
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') || contains(github.ref_name, '-') }}
# Computed once in resolve-channel so every platform/job in this run
# lands on the exact same tag: "{version}-alpha" / "{version}-beta"
# for rolling channel pushes, or the pushed "vX.Y.Z" tag as-is for a
# real stable release.
tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
files: ostp-flutter/ostp-android-${{ matrix.arch }}.apk
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

18
.gitignore vendored
View File

@ -5,6 +5,7 @@
**/*.rs.bk
.idea/
.vscode/
**/node_modules/
# Binaries & libraries
*.exe
@ -25,6 +26,17 @@ 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
# Logs
*.log
@ -34,8 +46,14 @@ turn-harvesting-idea.md
# Private tooling (closed-source)
ostp-prober/
ostp-lab/
ostp-brain/
# Management panel built assets (built separately; dummy dist created for rust-embed build)
ostp-control/
.agents/
netstack-smoltcp/
dnstt/
ostp-web/

View File

@ -1 +0,0 @@
127.0.0.1

6
.release-state.json Normal file
View File

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

View File

@ -10,10 +10,12 @@ By contributing to this project, you agree to abide by our code of conduct and l
1. [Development Setup](#development-setup)
2. [Project Structure](#project-structure)
3. [Development Workflow](#development-workflow)
4. [Coding Guidelines](#coding-guidelines)
5. [Submitting Pull Requests](#submitting-pull-requests)
6. [Security Vulnerabilities](#security-vulnerabilities)
3. [Branch Strategy](#branch-strategy)
4. [Development Workflow](#development-workflow)
5. [Commit Message Conventions](#commit-message-conventions)
6. [Coding Guidelines](#coding-guidelines)
7. [Submitting Pull Requests](#submitting-pull-requests)
8. [Security Vulnerabilities](#security-vulnerabilities)
---
@ -33,20 +35,19 @@ To build and test OSTP locally, you will need:
cd ostp
```
2. **Build the control panel frontend**:
```bash
cd ostp-control
npm install
npm run build
cd ..
```
3. **Build the entire Cargo workspace**:
2. **Build the entire Cargo workspace**:
```bash
cargo build
```
`ostp-control` (the web panel) is only needed if you're working on it
specifically - the server build embeds a dummy `dist/` via `rust-embed`
otherwise, so this step is not required for day-to-day core/client/server
work. If you *are* touching the panel:
```bash
cd ostp-control && npm install && npm run build && cd ..
```
4. **Run tests**:
3. **Run tests**:
```bash
cargo test --workspace
```
@ -66,11 +67,28 @@ The repository is organized as a Cargo workspace containing the following crates
---
## Branch Strategy
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. |
| `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. |
`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`.
---
## Development Workflow
1. **Check for existing issues** or open a new one to discuss proposed changes before starting work.
2. **Fork the repository** and create a new branch from `master`:
2. **Fork the repository** and create a new branch from `alpha`:
```bash
git checkout alpha
git checkout -b feat/your-feature-name
```
3. **Implement your changes**, ensuring you write appropriate unit or integration tests.
@ -89,6 +107,32 @@ The repository is organized as a Cargo workspace containing the following crates
---
## Commit Message Conventions
```
<type>(<scope>): <short, imperative summary>
<optional body - explain WHY, not what; the diff already shows what changed>
```
- **Type** - one of: `feat` (new capability), `fix` (bug fix), `docs`, `refactor` (no behavior change), `perf`, `test`, `chore` (deps/tooling/version bumps), `ci`, `security`.
- **Scope** (optional) - the crate or area touched: `client`, `server`, `core`, `gui`, `flutter`, `ci`, `docs`, etc. e.g. `fix(client): ...`.
- **Summary** - imperative mood ("add", not "added"/"adds"), no trailing period, ideally under ~70 characters.
- **Body** - only when the *why* isn't obvious from the diff: a prior bug this fixes, a constraint that shaped the approach, a tradeoff you made. Don't restate what the diff already shows. Wrap at ~72 columns.
```
fix(server): drop junk frames by per-key marker instead of a global one
A fixed 4-byte marker on every junk packet is itself a DPI signature any
observer can filter on across every OSTP deployment. Derive the marker
from the access key (HKDF, same scheme as obfuscation_key/psk) so it's
per-user and indistinguishable from the packet's own random payload.
```
Multiple unrelated changes belong in separate commits, not one bundled commit - it keeps `git bisect` and review useful. Squash-merge is fine for a PR with a few "fix typo" / "address review" commits, but don't squash logically distinct changes together.
---
## Coding Guidelines
* **Safety**: Avoid using `unsafe` blocks unless absolutely necessary for low-level system bindings (e.g., FFI configurations like `setsockopt`). When using `unsafe`, add safety doc comments explaining why it is safe.
@ -104,7 +148,7 @@ The repository is organized as a Cargo workspace containing the following crates
```bash
git push origin feat/your-feature-name
```
2. Open a Pull Request (PR) targeting the `master` branch.
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

@ -10,10 +10,12 @@
1. [Подготовка окружения](#подготовка-окружения)
2. [Структура проекта](#структура-проекта)
3. [Процесс разработки](#процесс-разработки)
4. [Правила оформления кода](#правила-оформления-кода)
5. [Создание Pull Request](#создание-pull-request)
6. [Уязвимости безопасности](#уязвимости-безопасности)
3. [Стратегия веток](#стратегия-веток)
4. [Процесс разработки](#процесс-разработки)
5. [Оформление коммитов](#оформление-коммитов)
6. [Правила оформления кода](#правила-оформления-кода)
7. [Создание Pull Request](#создание-pull-request)
8. [Уязвимости безопасности](#уязвимости-безопасности)
---
@ -33,20 +35,19 @@
cd ostp
```
2. **Соберите веб-интерфейс панели управления**:
```bash
cd ostp-control
npm install
npm run build
cd ..
```
3. **Соберите весь Cargo-workspace**:
2. **Соберите весь Cargo-workspace**:
```bash
cargo build
```
`ostp-control` (веб-панель) нужна только если вы работаете конкретно над
ней - в остальных случаях сервер собирается с пустым `dist/` через
`rust-embed`, и этот шаг не нужен для повседневной работы над
core/client/server. Если вы всё же трогаете панель:
```bash
cd ostp-control && npm install && npm run build && cd ..
```
4. **Запустите тесты**:
3. **Запустите тесты**:
```bash
cargo test --workspace
```
@ -66,11 +67,28 @@
---
## Стратегия веток
В репозитории три долгоживущие ветки, по возрастанию стабильности:
| Ветка | Роль |
|---|---|
| `alpha` | Активная разработка. Вся новая работа и фиксы попадают сюда первыми. |
| `beta` | Периодически перематывается вперёд (fast-forward) от `alpha`, когда та немного «отлежалась». Собирается в канал релиза `{версия}-beta`. |
| `master` | Перематывается вперёд от `beta`, когда та доказала стабильность. Настоящие тегированные релизы (`vX.Y.Z`) режутся отсюда. |
В `beta` и `master` **никогда** не коммитят напрямую - они только перематываются вперёд от ветки уровнем ниже. Это значит, что промоушен - всегда обычный `git merge` без единого конфликта по построению: не мержите/не ребейзьте свою фичу прямо в `beta` или `master`.
**PR от контрибьюторов нацелены на `alpha`**, не на `master`.
---
## Процесс разработки
1. **Проверьте существующие задачи** или откройте новую тему (Issue) для обсуждения предлагаемых изменений.
2. **Сделайте fork репозитория** и создайте новую ветку от `master`:
2. **Сделайте fork репозитория** и создайте новую ветку от `alpha`:
```bash
git checkout alpha
git checkout -b feat/имя-вашей-фичи
```
3. **Внесите необходимые изменения** и добавьте соответствующие модульные или интеграционные тесты.
@ -89,6 +107,33 @@
---
## Оформление коммитов
```
<тип>(<область>): <краткое описание в повелительном наклонении>
<опционально: тело - объясняет ПОЧЕМУ, а не что; диф и так показывает что изменилось>
```
- **Тип** - один из: `feat` (новая функциональность), `fix` (исправление бага), `docs`, `refactor` (без изменения поведения), `perf`, `test`, `chore` (зависимости/тулинг/версии), `ci`, `security`.
- **Область** (опционально) - крейт или часть проекта: `client`, `server`, `core`, `gui`, `flutter`, `ci`, `docs` и т.д., например `fix(client): ...`.
- **Краткое описание** - повелительное наклонение ("добавь", а не "добавил"/"добавляет"), без точки в конце, желательно до ~70 символов.
- **Тело** - только когда причина не очевидна из дифа: какой баг это чинит, какое ограничение определило подход, на какой trade-off вы пошли. Не пересказывайте то, что и так видно в дифе. Перенос строк на ~72 символах.
```
fix(server): отбрасывать junk-фреймы по маркеру для каждого ключа, а не глобальному
Фиксированный 4-байтовый маркер на каждом junk-пакете сам по себе - сигнатура
DPI, по которой можно фильтровать любого наблюдателя во всех деплойментах OSTP
сразу. Выводим маркер из access_key (HKDF, та же схема что у
obfuscation_key/psk), чтобы он был индивидуальным для ключа и неотличимым от
случайной полезной нагрузки пакета.
```
Несколько несвязанных изменений - это несколько отдельных коммитов, а не один сборный. Это сохраняет пользу от `git bisect` и код-ревью. Squash-merge подходит для PR с парой коммитов вроде "fix typo" / "address review", но не сквошьте вместе логически разные изменения.
---
## Правила оформления кода
* **Безопасность (Safety)**: Избегайте использования блоков `unsafe` везде, где это возможно. Допускается их использование только для низкоуровневых системных вызовов (например, FFI-настройки сокетов `setsockopt`). Любой блок `unsafe` должен сопровождаться комментарием `// SAFETY: ...`.
@ -104,7 +149,7 @@
```bash
git push origin feat/имя-вашей-фичи
```
2. Создайте Pull Request (PR) в ветку `master` основного репозитория.
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.4"
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.4"
dependencies = [
"anyhow",
"base64",
@ -1437,7 +1440,7 @@ dependencies = [
[[package]]
name = "ostp-core"
version = "0.4.1"
version = "0.4.4"
dependencies = [
"anyhow",
"bytes",
@ -1471,7 +1474,7 @@ dependencies = [
[[package]]
name = "ostp-server"
version = "0.4.1"
version = "0.4.4"
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.4"
dependencies = [
"anyhow",
"libc",
@ -1515,7 +1519,7 @@ dependencies = [
[[package]]
name = "ostp-tun-helper"
version = "0.4.1"
version = "0.4.4"
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.4"
[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" }

701
LICENSE
View File

@ -1,74 +1,661 @@
Business Source License 1.1
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Parameters
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Licensor: Ospab Foundation (represented by Syralev Georgiy)
Licensed Work: The Ospab Stealth Transport Protocol (OSTP) and all
associated workspace crates, utilities, and documents.
Additional Use Grant: The Licensor hereby grants you the right to copy,
modify, create derivative works, redistribute, and
make non-production and non-commercial use of the
Licensed Work. You are also permitted to use the
Licensed Work in production for personal, private
utility and non-profit organizations.
Change Date: May 14, 2030
Change License: MIT License (as defined below)
Preamble
-----------------------------------------------------------------------------------
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
Terms
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
1. The Licensor hereby grants you the right to copy, modify, create derivative works,
redistribute, and make use of the Licensed Work only as permitted by the
Additional Use Grant.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
2. The Licensor hereby grants you the right to copy, modify, create derivative works,
redistribute, and make use of the Licensed Work under the terms of the Change
License on and after the Change Date.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
3. To the extent that any term of this License (including the Additional Use Grant
and the Change License) is in conflict with the Terms of this License, these
Terms shall take precedence.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
4. Every copy of the Licensed Work and any derivative work must include this
License and all other copyright, trademark, and proprietary notices included
with the Licensed Work.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
5. Any use of the Licensed Work that is not permitted by this License is a breach
of this License and may terminate your rights under this License.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
6. DISCLAIMER OF WARRANTY. TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED
WORK IS PROVIDED ON AN "AS IS" BASIS. THE LICENSOR MAKES NO REPRESENTATIONS OR
WARRANTIES OF ANY KIND CONCERNING THE LICENSED WORK, EXPRESS OR IMPLIED, STATUTORY
OR OTHERWISE, INCLUDING, WITHOUT LIMITATION, WARRANTIES OF TITLE,
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR NONINFRINGEMENT.
The precise terms and conditions for copying, distribution and
modification follow.
7. LIMITATION OF LIABILITY. TO THE EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT
WILL THE LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY FOR ANY SPECIAL, INCIDENTAL,
CONSEQUENTIAL, PUNITIVE, OR EXEMPLARY DAMAGES ARISING OUT OF THIS LICENSE OR THE
USE OF THE LICENSED WORK, EVEN IF THE LICENSOR HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
TERMS AND CONDITIONS
-----------------------------------------------------------------------------------
0. Definitions.
Change License Text (MIT License)
"This License" refers to version 3 of the GNU Affero General Public License.
Copyright (c) 2026 Syralev Georgiy (Ospab Foundation)
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
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 Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
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.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

134
README.md
View File

@ -1,4 +1,4 @@
# OSTP Ospab Stealth Transport Protocol
# OSTP - Ospab Stealth Transport Protocol
[Русский язык](README.ru.md) · [Wiki](https://github.com/ospab/ostp/wiki) · [Contributing](CONTRIBUTING.md) · [Releases](https://github.com/ospab/ostp/releases)
@ -10,7 +10,7 @@
> A fast, custom encrypted transport protocol written in Rust.
**OSTP** (Ospab Stealth Transport Protocol) is a high-performance transport protocol. It implements a custom ARQ transport over UDP, as well as a UoT (UDP-over-TCP) mode. Every byte on the wire — including packet headers — is cryptographically indistinguishable from random noise, making it highly resistant to Deep Packet Inspection (DPI).
**OSTP** (Ospab Stealth Transport Protocol) is a high-performance transport protocol. It implements a custom ARQ transport over UDP, as well as a UoT (UDP-over-TCP) mode. Every byte on the wire - including packet headers - is cryptographically indistinguishable from random noise, making it highly resistant to Deep Packet Inspection (DPI).
---
@ -35,16 +35,16 @@ Download pre-built binaries for your platform from [GitHub Releases](https://git
| Feature | Description |
|---------|-------------|
| **Full Traffic Obfuscation** | Every packet — including headers — is indistinguishable from random noise. Session IDs and nonces are masked with per-packet HMAC-derived keys. |
| **Noise Protocol Handshake** | `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s` PSK-authenticated, forward-secret key exchange with no static identity exposure. |
| **Full Traffic Obfuscation** | Every packet - including headers - is indistinguishable from random noise. Session IDs and nonces are masked with per-packet HMAC-derived keys. |
| **Noise Protocol Handshake** | `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s` - PSK-authenticated, forward-secret key exchange with no static identity exposure. |
| **Reliable UDP (ARQ)** | Selective ACK/NACK with rate-limited retransmission, configurable reorder buffer, and exponential backoff. |
| **Multiplexed Streams** | Multiple logical TCP streams over a single encrypted UDP session with per-stream flow control. |
| **Seamless Roaming** | Clients can switch networks (WiFi ↔ LTE) without session interruption tracked by session-ID, not IP. |
| **Seamless Roaming** | Clients can switch networks (WiFi ↔ LTE) without session interruption - tracked by session-ID, not IP. |
| **Management API** | Built-in REST API for third-party panels (3x-ui, custom dashboards). Per-user stats, traffic limits, key CRUD. |
| **Fallback Server** | TCP fallback proxy to a web server makes OSTP indistinguishable from nginx during active probing. |
| **Fallback Server** | TCP fallback proxy to a web server - makes OSTP indistinguishable from nginx during active probing. |
| **Multi-Listener** | Bind to multiple addresses simultaneously (dual-stack IPv4/IPv6, multi-port). |
| **TUN Mode** | Full-system VPN via native `smoltcp` network stack without external dependencies. All traffic transparently routed through the tunnel. |
| **xHTTP Stealth (UoT)** | UDP-over-TCP tunnel that completely hides traffic. Since all data is fully encrypted and length-prefixed, it bypasses DPI filters that block unknown UDP traffic by riding over a plain TCP connection. |
| **UoT (UDP-over-TCP)** | Bare UDP-over-TCP tunnel, no protocol mimicry. Since all data is fully encrypted and length-prefixed, it bypasses DPI filters that block unknown UDP traffic by riding over a plain TCP connection. |
| **Mobile & Web Apps** | Beautiful cross-platform mobile client (Flutter) and a modern Web Control Panel (React/Vite) for effortless server and client management. |
| **TURN Relay** | RFC 5766 TURN support for environments where direct UDP is blocked. |
| **Hot-Reload** | Runtime config reload without restart (access keys, exclusions, mux settings). |
@ -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
subgraph OSTPCoreClient ["OSTP Core Protocol"]
B --> C{Protocol Machine}
C -->|Noise Handshake| D[ChaCha20Poly1305 AEAD]
D -->|Obfuscated UDP Payload| E((UDP Socket))
end
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 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]
end
G -->|Active Probing / Unauth| FB[TCP Fallback Proxy]
FB -->|Forward| NGINX[nginx / Caddy]
H -->|Stats & Traffic| API[Management API]
I -->|Outbound| WWW((Internet))
subgraph Internet["🌐 Hostile Network (DPI/Firewall)"]
Tunnel{"Fully Obfuscated\nEncrypted UDP\n(Looks like noise)"}:::network
end
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
Server -->|Decrypted Traffic| Relay
Server -->|Active Probe / Scanner| Fallback
Relay -->|Clear Traffic| Target
end
Client <==> Tunnel <==> Server
```
---
@ -95,15 +102,15 @@ graph TD
```bash
# On your VPS (server):
./ostp --init server
./ostp init server
# On your machine (client):
./ostp --init client
./ostp init client
```
### 2. Edit config
**Server** set your access keys:
**Server** - set your access keys:
```jsonc
{
"mode": "server",
@ -114,14 +121,14 @@ graph TD
}
```
**Client** point to your server:
**Client** - point to your server:
```jsonc
{
"mode": "client",
"server": "YOUR_SERVER_IP:50000",
"access_key": "YOUR_SECRET_KEY",
"socks5_bind": "127.0.0.1:1088",
"transport": { "mode": "udp", "stealth_sni": "vk.com" },
"transport": { "mode": "udp" },
"tun": { "enable": false, "dns": "1.1.1.1" }
}
```
@ -129,16 +136,16 @@ graph TD
### 3. Run
```bash
./ostp # Uses config.json in current directory
./ostp --config /path/to.json # Custom config path
./ostp --check # Validate config without running
./ostp --generate-key # Generate a new access key
./ostp --links # Print client share links
./ostp # Uses config.json in current directory
./ostp --config /path/to.json # Custom config path
./ostp check # Validate config without running
./ostp gk # Generate a new access key
./ostp links # Print client share links
```
### 4. Connect via share link (one-liner)
```bash
./ostp "ostp://ACCESS_KEY@server.com:50000?..."
./ostp connect "ostp://ACCESS_KEY@server.com:50000?..."
```
> [!WARNING]
@ -171,21 +178,33 @@ Full API reference: [Management API](https://github.com/ospab/ostp/wiki/Manageme
## CLI Reference
```
ostp [OPTIONS] [URL]
ostp [--config <PATH>] [COMMAND]
Options:
Commands:
run Run the daemon using the config file (default when no command is given)
connect <URL> Connect once using a share link: ostp://KEY@HOST:PORT
setup Interactive setup wizard
init <MODE> Generate a template config (server/client/relay)
check Validate the configuration file and exit
gk Generate a secure access key (alias: generate-key)
--format <FMT> Key format: hex, base64 (default: hex)
-n, --count <N> Number of keys to generate (default: 1)
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, 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
proxy-env-clear Print shell export commands to unset it
uninstall Stop the service and remove the binary and config
Global options:
--config <PATH> Config file path (default: config.json)
--init <MODE> Generate template config (server/client)
--check Validate configuration and exit
-g, --generate-key Generate a secure access key
-c, --count <N> Number of keys to generate (default: 1)
--format <FMT> Key format: hex, base64 (default: hex)
--links Print client share links from server config
Arguments:
[URL] Connect via share link: ostp://KEY@HOST:PORT
```
Every subcommand also accepts `-h`/`--help` for its own option list.
---
## Protocol Summary
@ -218,7 +237,7 @@ cargo test -p ostp-core -p ostp-server
## Documentation
- **[Wiki](https://github.com/ospab/ostp/wiki)** Full documentation
- **[Wiki](https://github.com/ospab/ostp/wiki)** - Full documentation
- [Installation](https://github.com/ospab/ostp/wiki/Installation)
- [Configuration Reference](https://github.com/ospab/ostp/wiki/Configuration)
- [Management API](https://github.com/ospab/ostp/wiki/Management-API)
@ -230,8 +249,7 @@ cargo test -p ostp-core -p ostp-server
## License
Business Source License 1.1. Free for personal and non-commercial use.
Converts to MIT License on May 14, 2030.
GNU Affero General Public License v3.0 (AGPL-3.0). See [LICENSE](LICENSE) for the full text.
---

View File

@ -1,4 +1,4 @@
# OSTP Ospab Stealth Transport Protocol
# OSTP - Ospab Stealth Transport Protocol
[English](README.md) · [Contributing](CONTRIBUTING.ru.md)
@ -10,7 +10,7 @@
> Быстрый кастомный зашифрованный транспортный протокол на Rust.
**OSTP** (Ospab Stealth Transport Protocol) кастомный транспортный протокол. Реализует собственный ARQ-транспорт поверх UDP, а также режим UoT (UDP-over-TCP). Каждый байт, включая заголовки пакетов, криптографически неотличим от случайного шума, что делает его устойчивым к системам глубокого анализа трафика (DPI).
**OSTP** (Ospab Stealth Transport Protocol) - кастомный транспортный протокол. Реализует собственный ARQ-транспорт поверх UDP, а также режим UoT (UDP-over-TCP). Каждый байт, включая заголовки пакетов, криптографически неотличим от случайного шума, что делает его устойчивым к системам глубокого анализа трафика (DPI).
---
@ -19,12 +19,12 @@
| Возможность | Описание |
|-------------|----------|
| **Обфускация трафика** | Каждый пакет, включая заголовки, неотличим от случайного шума. Session ID и nonce маскируются HMAC-ключами, уникальными для каждого пакета. |
| **Noise Protocol** | `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s` аутентификация через PSK, forward secrecy, без раскрытия идентичности. |
| **Noise Protocol** | `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s` - аутентификация через PSK, forward secrecy, без раскрытия идентичности. |
| **Reliable UDP (ARQ)** | Selective ACK/NACK с rate-limited ретрансмиссией, настраиваемым reorder-буфером и exponential backoff. Разработан для 10 Гбит/с. |
| **Мультиплексирование** | Несколько логических TCP-потоков поверх одной зашифрованной UDP-сессии с per-stream flow control. |
| **Бесшовный роуминг** | Клиент может менять сети (WiFi ↔ 4G) без разрыва сессии сервер отслеживает session-ID, а не IP-адрес. |
| **Бесшовный роуминг** | Клиент может менять сети (WiFi ↔ 4G) без разрыва сессии - сервер отслеживает session-ID, а не IP-адрес. |
| **TUN-режим** | Полносистемный VPN без внешних зависимостей (встроенный network stack на базе `smoltcp`). |
| **xHTTP Стелс (UoT)** | Туннель UDP-over-TCP, который полностью скрывает трафик. Поскольку все данные полностью зашифрованы и имеют префикс длины, он обходит DPI фильтры, блокирующие неизвестный UDP трафик, передавая всё по обычному TCP соединению. |
| **UoT (UDP-over-TCP)** | Голый туннель UDP-over-TCP, без имитации протоколов. Поскольку все данные полностью зашифрованы и имеют префикс длины, он обходит DPI фильтры, блокирующие неизвестный UDP трафик, передавая всё по обычному TCP соединению. |
| **Мобильные и Web приложения** | Красивый кроссплатформенный мобильный клиент (Flutter) и современная Web панель управления (React/Vite) для удобного администрирования. |
| **TURN Relay** | RFC 5766 TURN для окружений, где прямой UDP заблокирован. |
| **Hot-Reload** | Перезагрузка конфига в рантайме без перезапуска (ключи, исключения, mux, TURN). |
@ -35,33 +35,42 @@
## Архитектура
```mermaid
graph TD
subgraph Client ["Клиент"]
A[Браузер / Прил.] -->|SOCKS5 / HTTP| B(Bridge Multiplexer)
TUN[TUN Интерфейс] -->|IP Пакеты| B
subgraph OSTPCoreClient ["OSTP Core Протокол"]
B --> C{Protocol Machine}
C -->|Noise Handshake| D[ChaCha20Poly1305 AEAD]
D -->|Обфусцированный UDP| E((UDP Сокет))
end
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 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]
end
G -->|Active Probing / Unauth| FB[TCP Fallback Proxy]
FB -->|Перенаправление| NGINX[nginx / Caddy]
I -->|Outbound| WWW((Интернет))
subgraph Internet["🌐 Сеть с цензурой (DPI)"]
Tunnel{"Зашифрованный UDP\n(Выглядит как белый шум)"}:::network
end
subgraph Remote["🖥️ Удаленный сервер (VPS)"]
Server["OSTP Сервер\n(Аутентификация)"]:::ostpCore
Relay["Мультиплексор соединений"]:::ostpCore
Fallback["Фейковый сайт\n(Nginx/Caddy)"]:::fallback
Target["Свободный интернет\n(YouTube, Google и т.д.)"]:::external
Server -->|Расшифрованный трафик| Relay
Server -->|Сканеры цензоров| Fallback
Relay -->|Чистый трафик| Target
end
Client <==> Tunnel <==> Server
```
---
@ -84,8 +93,8 @@ irm https://raw.githubusercontent.com/ospab/ostp/master/scripts/install.ps1 | ie
Создать конфиг по умолчанию:
```bash
./ostp --init server # VPS
./ostp --init client # Локальная машина
./ostp init server # VPS
./ostp init client # Локальная машина
```
### Сервер (`config.json`)
@ -116,8 +125,7 @@ irm https://raw.githubusercontent.com/ospab/ostp/master/scripts/install.ps1 | ie
"debug": false,
// Настройки транспорта (udp или uot)
"transport": {
"mode": "udp",
"stealth_sni": "vk.com"
"mode": "udp"
},
// TUN-режим (полносистемный VPN)
"tun": {
@ -156,6 +164,36 @@ irm https://raw.githubusercontent.com/ospab/ostp/master/scripts/install.ps1 | ie
./ostp
```
### Справка по командам
```
ostp [--config <PATH>] [КОМАНДА]
Команды:
run Запустить демон по конфигу (по умолчанию, если команда не указана)
connect <URL> Подключиться по share-ссылке: ostp://KEY@HOST:PORT
setup Интерактивный мастер настройки
init <MODE> Сгенерировать шаблон конфига (server/client/relay)
check Проверить конфиг и выйти
gk Сгенерировать access-key (алиас: generate-key)
--format <FMT> Формат ключа: hex, base64 (по умолчанию hex)
-n, --count <N> Количество ключей (по умолчанию 1)
links Вывести client-share-ссылки из серверного конфига
import <URL> Импортировать share-ссылку в конфиг
update Обновить OSTP до актуального релиза
-b, --branch <NAME> Канал релиза: stable, beta, alpha (по умолчанию stable)
-v, --version <VER> Обновиться на точную версию вместо последней в канале
migrate Принудительно мигрировать конфиг к текущему формату
proxy-env Вывести shell-команды для локального SOCKS-прокси
proxy-env-clear Вывести shell-команды для их отмены
uninstall Остановить сервис и удалить бинарник с конфигом
Глобальные опции:
--config <PATH> Путь к конфигу (по умолчанию config.json)
```
У каждой подкоманды есть своя справка через `-h`/`--help`.
### TUN-режим (Windows)
Использует встроенный сетевой стек `smoltcp` и виртуальный адаптер `wintun` (необходима `wintun.dll`). Требует запуска с правами Администратора.
@ -204,5 +242,4 @@ cross build --release --target x86_64-unknown-linux-gnu
## Лицензия
Business Source License 1.1. Бесплатно для личного и некоммерческого использования.
Переходит в MIT License 14 мая 2030 года.
GNU Affero General Public License v3.0 (AGPL-3.0). Полный текст - в файле [LICENSE](LICENSE).

View File

@ -5,40 +5,38 @@ Traditional tunneling protocols (such as TLS, OpenVPN, and WireGuard) exhibit di
---
## Obfuscation Key Derivation
## Secret Derivation
To dynamically mask protocol data, an 8-byte obfuscation key is statically derived from the shared `access_key` configured on both the client and the server:
Every protocol secret — the obfuscation key, the Noise PSK, the handshake padding range, and the per-key junk marker (see below) — is derived from the shared `access_key` via a single HKDF-SHA256 pass, domain-separated by a trailing info byte per output:
$$\text{Key} = \text{SHA-256}(\text{access\_key})[0..8]$$
```
PRK = HKDF-Extract(salt = SHA-256(access_key)[0..16], IKM = access_key || PROTOCOL_VERSION)
obfuscation_key = HKDF-Expand(PRK, info = SHA-256(access_key)[16..] || 0x01, 8 bytes)
psk = HKDF-Expand(PRK, info = SHA-256(access_key)[16..] || 0x02, 32 bytes)
handshake_pad = HKDF-Expand(PRK, info = SHA-256(access_key)[16..] || 0x03, 2 bytes)
junk_marker = HKDF-Expand(PRK, info = SHA-256(access_key)[16..] || 0x04, 4 bytes)
```
This key is established pre-session and is never transmitted across the wire in any capacity.
The wire protocol version is mixed into the IKM, not sent as a plaintext byte: peers on a different protocol version derive an entirely different `obfuscation_key`, so they simply cannot deobfuscate each other's packets and are rejected as unauthorized — a hard version gate with no recognizable marker ever appearing on the wire. No secret is ever transmitted; both sides derive the same values independently from the shared access key.
---
## Dynamic In-Place Masking Algorithm
OSTP datagrams are processed "in-place" immediately prior to transmission and right after arrival. Two distinct mathematical modes are utilized based on the current handshake phase:
OSTP datagrams are masked "in-place" immediately prior to transmission and right after arrival. The mask itself is **derived from the packet's own ciphertext**, not from a fixed keystream or a counter, so it changes with every packet automatically:
```
mask = HMAC-SHA256(key = obfuscation_key, message = ciphertext[0..min(32, len)])
```
### 1. Handshake Phase Mode (`is_handshake = true`)
During connection initiation (Noise Handshake), the wire packet consists of a 4-byte `session_id` prefixed to the Noise payload. To mask the fixed session ID:
* **Masking**: The first 4 bytes are XORed with the first 4 bytes of the derived obfuscation key:
$$\text{raw}[i] = \text{raw}[i] \oplus \text{Key}[i \pmod 8], \quad i \in [0..3]$$
* **De-masking**: A repeated XOR with the identical key bytes recovers the original `session_id`.
The wire packet is `[4-byte session_id][2-byte noise_len][Noise payload]`. The mask is computed over the Noise payload (`raw[6..]`), and its first 6 bytes are XORed onto `session_id || noise_len`.
### 2. Data Transmission Mode (`is_handshake = false`)
Post-handshake, the wire layout contains:
`[4-byte session_id]` + `[8-byte nonce]` + `[AEAD Ciphertext]`
The wire packet is `[4-byte session_id][8-byte nonce][AEAD ciphertext]`. The mask is computed over the AEAD ciphertext, and its first 12 bytes are XORed onto `session_id || nonce`.
To completely randomize metadata, a two-tiered dynamic XOR masking process is applied:
1. **Nonce Masking**: The 8-byte `nonce` (sequence counter) is XORed with the full 8-byte static key:
$$\text{nonce\_bytes}[i] = \text{nonce\_bytes}[i] \oplus \text{Key}[i], \quad i \in [0..7]$$
2. **Session ID Masking**: The 4-byte `session_id` is masked using high dynamic entropy — the lower 32 bits of the **original (unmasked)** `nonce` value:
$$\text{session\_id\_bytes}[i] = \text{session\_id\_bytes}[i] \oplus \text{real\_nonce\_low32\_bytes}[i], \quad i \in [0..3]$$
#### Impact of the Scheme:
Because the `nonce` increments strictly with each outgoing datagram, the session ID's masking keystream continuously changes. This breaks all packet header correlations and eliminates repeating byte patterns, rendering statistical fingerprinting futile.
#### Impact of the Scheme
Because the mask is keyed on both the shared secret and the packet's own ciphertext, no two packets — even consecutive ones from the same session — share a keystream, without needing an explicit counter-based scheme. This breaks all packet header correlations and eliminates repeating byte patterns, rendering statistical fingerprinting futile.
---
@ -50,6 +48,13 @@ The `AdaptivePadder` calculates dynamic dummy byte quantities to append to the p
- **Dynamic Distributions**: The padding algorithms emulate length profiles commonly seen in whitelisted HTTPS or real-time video streams.
- **Encrypted Overheads**: The appended padding resides within the AEAD cipher scope. Consequently, passive observers cannot distinguish padding bytes from useful application payload, hiding the true message boundary lengths.
## XTLS-Reality Impersonation
---
OSTP provides a custom, dependency-free implementation of the XTLS-Reality protocol. It fully simulates a TLS 1.3 handshake (with realistic ClientHello profiles) to bypass advanced DPI filters. Post-handshake, it utilizes ChaCha20Poly1305 to seamlessly encrypt and tunnel the inner HTTP/WSS connections.
## Junk Packets & TCP Fragmentation
OSTP does not try to impersonate a known protocol (TLS, HTTP, or otherwise) — a fingerprint-matching filter can always be updated to catch an impersonation attempt. Instead it follows a **zapret-like** approach: no recognizable header at all, plus active manipulation of packet boundaries, so there is nothing distinctive to fingerprint in the first place.
- **Junk packets**: before the handshake, the client sends a configurable number (`junk_pc`) of random-size (`junk_ps`) filler datagrams. Each carries a 4-byte marker **derived from the access key** (the `junk_marker` above) rather than a fixed constant — a fixed marker would itself be a universal signature any observer could filter on across every OSTP deployment. The server derives the same per-key marker while trying candidate keys and drops matching junk silently, before it ever reaches the "unauthorized probe" logging path.
- **TCP fragmentation** (UoT/TCP transport only): the first packet (the handshake) is split into small chunks (`frag_chunk` bytes) with short delays (`frag_sleep` ms) between writes, so DPI that inspects only the first TCP segment never sees a complete handshake to fingerprint.
Both are configurable per-profile; neither is sent over plain UDP transport, where a standalone junk datagram would look exactly like a random one-off probe to the server.

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

@ -20,9 +20,15 @@
// Адрес следующего узла в цепочке UDP
"upstream_udp": "TARGET_SERVER_IP:50000",
// URL API конечного (целевого) сервера для синхронизации access_keys
// Должен быть доступен с этого relay-сервера (можно через SSH-туннель)
"upstream_api_url": "http://TARGET_SERVER_IP:9090",
// URL API конечного (целевого) сервера для синхронизации access_keys.
// Должен быть доступен с этого relay-сервера (можно через SSH-туннель).
//
// ВАЖНО: URL обязан включать секретный путь панели (api.webpath целевого
// сервера). Management API смонтирован ВНУТРИ этого пути именно он скрывает
// панель от сканеров, поэтому голый host:port попадает в несуществующий
// маршрут, и синхронизация падает с 404 ещё до проверки токена.
// Это тот же адрес, по которому вы открываете веб-панель.
"upstream_api_url": "http://TARGET_SERVER_IP:9090/TARGET_SERVER_WEBPATH",
// Bearer-токен для доступа к API целевого сервера
// Должен совпадать с api.token в конфиге target-сервера

View File

@ -1,55 +1,60 @@
# Маскирование энтропии сигналов OSTP
# Обфускация трафика OSTP
## Философия структуры канала
## Философия
Традиционные сетевые протоколы промышленного сбора данных могут обладать фиксированными заголовками, что при анализе статистического распределения байт ведет к предвзятости выборок и искажению телеметрического профиля. Задача механизмов энтропийного маскирования OSTP — достижение **равномерного вероятностного распределения значений байт**, начиная с самого первого пакета. Это делает сигналы шины данных абсолютно однородными и устойчивыми к корреляционному анализу и структурному мониторингу сетевых контроллеров.
Классические туннельные протоколы (TLS, OpenVPN, WireGuard) имеют узнаваемые сигнатуры в хэндшейке или статичные заголовки пакетов. Механизм обфускации OSTP спроектирован так, чтобы **начиная с первого байта** трафик был максимально похож на случайный шум — и для DPI-систем был неотличим от него.
---
## Производная сигнатурная матрица (Keystream Initialization Vector)
## Деривация секретов
Для стабилизации битового распределения используется 8-байтовый вектор, вычисляемый на базе глобального идентификатора регистрации узла (`access_key`):
Все секреты протокола — ключ обфускации, PSK Noise-хэндшейка, диапазон паддинга хэндшейка и маркер junk-пакетов (см. ниже) — выводятся из общего `access_key` одним проходом HKDF-SHA256, с разделением по доменам через последний байт `info`:
$$\text{Key} = \text{SHA-256}(\text{access\_key})[0..8]$$
```
PRK = HKDF-Extract(salt = SHA-256(access_key)[0..16], IKM = access_key || PROTOCOL_VERSION)
obfuscation_key = HKDF-Expand(PRK, info = SHA-256(access_key)[16..] || 0x01, 8 байт)
psk = HKDF-Expand(PRK, info = SHA-256(access_key)[16..] || 0x02, 32 байта)
handshake_pad = HKDF-Expand(PRK, info = SHA-256(access_key)[16..] || 0x03, 2 байта)
junk_marker = HKDF-Expand(PRK, info = SHA-256(access_key)[16..] || 0x04, 4 байта)
```
Данная последовательность фиксируется на передающем и принимающем узлах и не передается через внешние сетевые шлюзы.
Версия протокола подмешивается в IKM, а не передаётся открытым байтом на проводе: пиры с разной версией протокола выведут разный `obfuscation_key` и просто не смогут деобфусцировать пакеты друг друга — жёсткий version gate без единого узнаваемого маркера на проводе. Ни один секрет никогда не передаётся — обе стороны независимо выводят одинаковые значения из общего access_key.
---
## Алгоритм динамического маскирования пакетов (In-place Masking)
## Алгоритм динамического маскирования
Пакетные структуры OSTP проходят низкоуровневую предобработку непосредственно перед выдачей в канальный уровень (Layer 3) и при получении. В зависимости от фазы жизненного цикла сессии связи выделяют две модели:
Датаграммы OSTP маскируются "на месте" прямо перед отправкой и сразу после получения. Сама маска **выводится из шифротекста самого пакета**, а не из статичного потока ключа или счётчика — поэтому она меняется от пакета к пакету автоматически:
### 1. Этап начального согласования среды (`is_handshake = true`)
В период инициализации канала передачи пакет структурирован как 4-байтовое поле логического адреса порта `session_id` и криптографический блок согласования среды. Для подавления статических компонент ID порта применяется процедура обратимого битового сложения:
```
mask = HMAC-SHA256(key = obfuscation_key, message = ciphertext[0..min(32, len)])
```
* **Обработка**: Первые 4 байта вектора пакета проходят побитовую операцию XOR с первыми 4 байтами сигнатурной матрицы:
$$\text{raw}[i] = \text{raw}[i] \oplus \text{Key}[i \pmod 8], \quad i \in [0..3]$$
* **Восстановление**: Обратное наложение сигнатурной матрицы возвращает корректное значение логического идентификатора.
### 1. Фаза хэндшейка (`is_handshake = true`)
Пакет на проводе — `[4 байта session_id][2 байта noise_len][Noise-полезная нагрузка]`. Маска считается по Noise-полезной нагрузке (`raw[6..]`), и её первые 6 байт накладываются XOR'ом на `session_id || noise_len`.
### 2. Этап высокоскоростного переноса данных (`is_handshake = false`)
После перевода сессии в состояние активности кадр передачи принимает следующий вид:
`[4 байта session_id]` + `[8 байт nonce]` + `[Полезная нагрузка блока]`
### 2. Фаза передачи данных (`is_handshake = false`)
Пакет на проводе — `[4 байта session_id][8 байт nonce][AEAD-шифротекст]`. Маска считается по шифротексту, и её первые 12 байт накладываются XOR'ом на `session_id || nonce`.
Для максимизации дифференциальной энтропии применяется двухступенчатое динамическое взвешивание:
1. **Коррекция счетчика цикла (Nonce Correction)**: 8-байтовое значение инкрементного счетчика пакета подвергается побитовому сложению с вектором матрицы:
$$\text{nonce\_bytes}[i] = \text{nonce\_bytes}[i] \oplus \text{Key}[i], \quad i \in [0..7]$$
2. **Маскирование ID сессии**: 4-байтовое поле логического адреса маскируется с помощью переменной высокочастотной энтропии — младших 32 бит **исходного** показателя системного счетчика пакетов:
$$\text{session\_id\_bytes}[i] = \text{session\_id\_bytes}[i] \oplus \text{real\_nonce\_low32\_bytes}[i], \quad i \in [0..3]$$
#### Статистическая устойчивость:
Благодаря инкрементации счетчика на каждом цикле отправки, маскирующий поток (keystream) для поля `session_id` постоянно видоизменяется. Это полностью нивелирует фиксированные битовые паттерны во всем спектре UDP-датаграмм и исключает появление повторяющихся префиксов.
#### Эффект схемы
Поскольку маска зависит одновременно от общего секрета и от содержимого шифротекста конкретного пакета, никакие два пакета — даже два подряд идущих в одной сессии — не используют одинаковый ключевой поток, и для этого не нужна явная схема на основе счётчика. Это полностью убирает корреляции между заголовками пакетов и повторяющиеся байтовые паттерны, делая статистический фингерпринтинг бесполезным.
---
## Выравнивание блоков по границам регистров (Adaptive Alignment)
## Статистический паддинг
Дополнительно к маскировке заголовков, протокол OSTP исключает возможность анализа поведения системы на основе длин пакетов данных. Модуль адаптивного заполнения (`AdaptivePadder`) рассчитывает оптимальный размер буфера выравнивания (`padding`), интегрируемый в структуру пакета до момента активации шифрующего каскада:
Помимо маскирования заголовков, OSTP защищается от анализа длин пакетов (Traffic Length Analysis). `AdaptivePadder` вычисляет случайный размер мусорных байт, добавляемых к полезной нагрузке ещё до шифрования:
- **Стратегия заполнения буферов**: Механизм анализирует текущую длину выборки телеметрии и производит масштабирование до типичных кратных длин промышленных сетей передачи данных и буферов потоковых агрегаторов.
- **Изоляция выравнивания**: Данные заполнения помещаются внутрь защищенной области кадра. Внешние анализаторы топологии сети не способны определить внутренние границы между телеметрической нагрузкой и служебными полями выравнивания, видя только монолитный блок данных.
- **Динамическое распределение**: длины паддинга подобраны так, чтобы напоминать профили длин обычного HTTPS-трафика или видеопотоков.
- **Внутри шифротекста**: добавленный паддинг находится внутри области AEAD-шифрования — пассивный наблюдатель не может отличить паддинг от полезной нагрузки и не видит настоящую границу сообщения.
## XTLS-Reality (Имитация TLS 1.3)
---
OSTP предоставляет собственную реализацию протокола XTLS-Reality без сторонних зависимостей. Протокол полностью имитирует рукопожатие TLS 1.3 (с реалистичным профилем ClientHello) для обхода продвинутых DPI фильтров. После успешного рукопожатия применяется ChaCha20Poly1305 для бесшовного шифрования и туннелирования внутренних HTTP/WSS соединений.
## Junk-пакеты и TCP-фрагментация
OSTP не пытается притворяться известным протоколом (TLS, HTTP и т.п.) — фильтр по сигнатуре всегда можно обновить под конкретную имитацию. Вместо этого используется подход **в духе zapret**: никакого узнаваемого заголовка вообще, плюс активная манипуляция границами пакетов — фингерпринтить попросту нечего.
- **Junk-пакеты**: перед хэндшейком клиент отправляет настраиваемое количество (`junk_pc`) мусорных датаграмм случайного размера (`junk_ps`). Каждая несёт 4-байтовый маркер, **выведенный из access_key** (тот самый `junk_marker` выше), а не фиксированную константу — константный маркер сам по себе стал бы универсальной сигнатурой для любого наблюдателя сразу по всем серверам OSTP. Сервер, перебирая кандидатов-ключей, выводит тот же маркер и тихо отбрасывает junk, не доходя до логирования «unauthorized probe».
- **TCP-фрагментация** (только для транспорта UoT/TCP): первый пакет (хэндшейк) режется на мелкие куски (`frag_chunk` байт) с небольшими задержками (`frag_sleep` мс) между записями — DPI, анализирующий только первый TCP-сегмент, никогда не видит цельный хэндшейк для фингерпринтинга.
Обе фичи настраиваются per-профиль; ни одна не применяется поверх обычного UDP-транспорта, где отдельная junk-датаграмма выглядела бы для сервера точь-в-точь как случайный одиночный проб.

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).

BIN
icons/logo_new.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 769 KiB

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,25 @@ 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);
/// How long to keep retrying a resume-triggered reconnect before handing the
/// problem back to the ordinary stall path. That path is what releases the
/// system proxy, so this is really a bound on how long the machine may be left
/// with no working internet at all after waking.
const RESUME_RECONNECT_GIVE_UP: Duration = Duration::from_secs(45);
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 +59,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 {
@ -65,7 +128,6 @@ pub struct Bridge {
pub mux_sessions: usize,
pub transport_mode: String,
pub stealth_sni: String,
pub tcp_fragmentation: bool,
pub frag_chunk: usize,
pub frag_sleep: u64,
@ -81,6 +143,21 @@ pub struct Bridge {
last_rtt_ms: f64,
last_sample_at: Instant,
last_valid_recv: Instant,
/// Set when a suspend/resume is detected, cleared once a reconnect actually
/// succeeds. Waking is precisely when the network is least likely to be
/// ready — Wi-Fi has not reassociated yet — so a single attempt fired
/// milliseconds after resume usually fails, and a one-shot forced reconnect
/// then fell back to the ordinary 25s stall heuristic. That heuristic keys
/// off a monotonic clock which does not advance while the machine is
/// asleep, so it could take a further 25s of real uptime to fire, or not
/// fire at all. Retrying until success removes the dependency on either.
forced_reconnect_pending: bool,
last_forced_reconnect_try: Instant,
/// Wall-clock start of the current resume-reconnect campaign, used to bound
/// it. Wall clock rather than Instant because the monotonic clock does not
/// advance across suspend on Windows, so it cannot measure anything that
/// begins at wake.
forced_reconnect_started: Option<SystemTime>,
}
impl Bridge {
@ -102,7 +179,6 @@ impl Bridge {
mux_sessions: config.multiplex.sessions.max(1),
transport_mode: config.transport.mode.clone(),
stealth_sni: config.transport.stealth_sni.clone(),
tcp_fragmentation: config.transport.tcp_fragmentation,
frag_chunk: config.transport.frag_chunk,
frag_sleep: config.transport.frag_sleep,
@ -118,6 +194,9 @@ impl Bridge {
last_rtt_ms: 0.0,
last_sample_at: Instant::now(),
last_valid_recv: Instant::now(),
forced_reconnect_pending: false,
last_forced_reconnect_try: Instant::now(),
forced_reconnect_started: None,
})
}
@ -133,6 +212,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 {
@ -168,18 +262,89 @@ 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.forced_reconnect_pending = true;
self.forced_reconnect_started = Some(SystemTime::now());
self.last_forced_reconnect_try = Instant::now() - Duration::from_secs(60);
}
// Give up if resume reconnects keep failing. Retrying forever
// looks harmless but is not: the system proxy stays pointed at
// our local listener the whole time, so the machine has NO
// working internet — not merely no tunnel — while the UI sits
// on "connecting". Handing the retry to the ordinary keepalive
// path restores the proxy through its hard-timeout branch,
// which force=true deliberately skips.
//
// Measured on the wall clock: Instant does not advance across
// suspend on Windows (QPC stops), so a monotonic deadline can
// not bound anything that starts at wake.
if self.forced_reconnect_pending {
let pending_for = self
.forced_reconnect_started
.and_then(|t| t.elapsed().ok())
.unwrap_or_default();
if pending_for > RESUME_RECONNECT_GIVE_UP {
self.forced_reconnect_pending = false;
self.forced_reconnect_started = None;
let _ = tx.send(UiEvent::Log(format!(
"Reconnect after suspend failed for {}s — releasing the system \
proxy so normal traffic works; will keep retrying in the \
background",
pending_for.as_secs()
))).await;
// Make the ordinary stall path fire on the next
// keepalive tick: it is the one that tears the proxy
// back down (or, with kill switch on, deliberately
// keeps blocking).
self.last_valid_recv = Instant::now()
.checked_sub(Duration::from_secs(3600))
.unwrap_or_else(Instant::now);
}
}
// Keep retrying a resume-triggered reconnect until one lands.
// The first attempt fires within half a second of waking, when
// the NIC is typically still reassociating, so treating it as
// one-shot left the tunnel dead until some other timer noticed.
if self.running
&& self.forced_reconnect_pending
&& self.last_forced_reconnect_try.elapsed() >= Duration::from_secs(3)
{
self.last_forced_reconnect_try = Instant::now();
self.handle_keepalive(true, &mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx, &mut proxy_rx).await;
// handle_keepalive refreshes last_valid_recv only when a
// session was actually established, so this is a real
// success check rather than "we tried".
if self.last_valid_recv.elapsed() < Duration::from_secs(3) {
self.forced_reconnect_pending = false;
self.forced_reconnect_started = None;
let _ = tx.send(UiEvent::Log("Reconnected after suspend".into())).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() => {
@ -188,7 +353,20 @@ impl Bridge {
}
}
proxy_ev = proxy_rx.recv(), if self.running && sessions_opt.as_ref().map(|s| {
s.iter().any(|ses| ses.machine.in_flight_count() < ses.machine.cwnd_packets().clamp(16, 16384))
// Upper bound matches MAX_CWND_PACKETS in ostp-core's congestion
// controller. The old 16384 ceiling let ~20 MB sit in flight,
// which on a mobile uplink is minutes of buffered queue rather
// than throughput — the app kept handing over data long after
// the path had stopped draining it.
// Two independent gates. cwnd bounds how much may be in
// flight; pacing bounds how FAST it is released. Without the
// second, a full window goes out back-to-back and lands in
// the bottleneck's buffer as standing queue rather than
// throughput — the thing that produced multi-second RTT.
s.iter().any(|ses| {
ses.machine.in_flight_count() < ses.machine.cwnd_packets().clamp(16, 1024)
&& ses.machine.can_pace_packet()
})
}).unwrap_or(true) => {
self.handle_proxy_event(proxy_ev, &mut sessions_opt, &mut stream_map, &tx, &proxy_tx).await;
}
@ -211,8 +389,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];
@ -225,6 +403,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);
@ -297,6 +491,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>,
@ -333,35 +528,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;
}
@ -414,6 +583,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);
@ -430,31 +625,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;
}
@ -525,6 +697,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>,
@ -533,9 +706,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 {
@ -566,34 +742,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;
}
@ -869,7 +1020,21 @@ impl Bridge {
Ok(addrs) => addrs.collect(),
Err(e) => return Err(anyhow::anyhow!("failed to resolve server address {}: {}", self.server_addr, e)),
};
resolved_addrs.sort_by_key(|addr| if addr.is_ipv6() { 0 } else { 1 });
// IPv4 first. Addresses are tried strictly in order, each burning its
// full retry budget before the next is touched, so this ordering decides
// how long a bad family stalls the whole connect. Mobile carriers
// routinely hand out IPv6 with no working route and BLACKHOLE it rather
// than rejecting, so every IPv6 candidate costs the full timeout budget
// — with several AAAA records the working IPv4 address was not reached
// for tens of seconds. (The same ordering bug was already fixed on the
// server's outbound path and in the UoT connect.)
resolved_addrs.sort_by_key(|addr| if addr.is_ipv6() { 1 } else { 0 });
// NAT64 is a fallback for IPv6-only networks. Retrying it per failing
// address multiplied an already-long connect: each attempt re-runs a DNS
// lookup and another full round of handshake retries, for a path that
// either works for the whole network or for none of it.
let mut nat64_attempted = false;
let mut last_err = anyhow::anyhow!("no IP addresses resolved for {}", self.server_addr);
@ -882,7 +1047,8 @@ impl Bridge {
let socket = match self.try_connect_transport(target_ip, port).await {
Ok(sock) => sock,
Err(e) => {
if let std::net::IpAddr::V4(ipv4) = target_ip {
if let (std::net::IpAddr::V4(ipv4), false) = (target_ip, nat64_attempted) {
nat64_attempted = true;
tx.send(UiEvent::Log(format!("Direct IPv4 connection failed: {}. Trying NAT64 fallback...", e))).await.ok();
let nat64_ipv6 = synthesize_nat64(ipv4).await;
match self.try_connect_transport(std::net::IpAddr::V6(nat64_ipv6), port).await {
@ -963,7 +1129,8 @@ impl Bridge {
let (final_socket, size) = if success {
(socket, size)
} else {
if let std::net::IpAddr::V4(ipv4) = target_ip {
if let (std::net::IpAddr::V4(ipv4), false) = (target_ip, nat64_attempted) {
nat64_attempted = true;
tx.send(UiEvent::Log("Direct IPv4 handshake timed out. Trying NAT64 fallback...".to_string())).await.ok();
let nat64_ipv6 = synthesize_nat64(ipv4).await;
match self.try_connect_transport(std::net::IpAddr::V6(nat64_ipv6), port).await {
@ -1033,7 +1200,6 @@ impl Bridge {
self.mux_enabled = cfg.multiplex.enabled;
self.mux_sessions = cfg.multiplex.sessions.max(1);
self.transport_mode = cfg.transport.mode.clone();
self.stealth_sni = cfg.transport.stealth_sni.clone();
self.tcp_fragmentation = cfg.transport.tcp_fragmentation;
self.frag_chunk = cfg.transport.frag_chunk.max(1);
self.frag_sleep = cfg.transport.frag_sleep;
@ -1051,7 +1217,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();
@ -1060,9 +1246,14 @@ impl Bridge {
let frag_sleep = self.frag_sleep;
let [junk_pc_min, junk_pc_max] = self.junk_pc;
let [junk_ps_min, junk_ps_max] = self.junk_ps;
// Per-key junk marker (derived from the access key) — NOT a global
// constant, so junk frames carry no universal DPI signature.
let junk_marker = ostp_core::crypto::derive_all_secrets(&self.access_key).junk_marker;
// Time-rotating per-key junk marker — NOT a global constant and NOT
// even a static per-user value: it changes every window, so junk
// carries no fixed DPI signature on the wire. All frames in this
// burst are sent within milliseconds, so one window applies to all.
let junk_marker = ostp_core::crypto::derive_junk_marker(
&self.access_key,
ostp_core::crypto::current_junk_window(),
);
{
use tokio::io::AsyncWriteExt;
@ -1183,8 +1374,19 @@ fn next_profile(current: TrafficProfile) -> TrafficProfile {
}
async fn synthesize_nat64(ip: std::net::Ipv4Addr) -> std::net::Ipv6Addr {
// Well-known prefix (RFC 6052), used if discovery doesn't answer in time.
let mut prefix = [0x00, 0x64, 0xff, 0x9b, 0, 0, 0, 0, 0, 0, 0, 0];
if let Ok(addrs) = tokio::net::lookup_host("ipv4only.arpa:80").await {
// Bound the discovery lookup. This runs on exactly the networks that are
// already misbehaving, where the resolver can hang for tens of seconds
// before giving up — unbounded, it was a large part of why connecting over
// a broken mobile network took minutes. Falling back to the well-known
// prefix is strictly better than waiting.
let discovery = tokio::time::timeout(
Duration::from_secs(2),
tokio::net::lookup_host("ipv4only.arpa:80"),
)
.await;
if let Ok(Ok(addrs)) = discovery {
for addr in addrs {
if let std::net::SocketAddr::V6(v6) = addr {
let octets = v6.ip().octets();

View File

@ -70,15 +70,13 @@ pub struct LocalProxyConfig {
}
/// Transport layer configuration.
/// `mode` = "udp" (default) or "uot" (UDP over TCP с xHTTP-транспортом).
/// `mode` = "udp" (default) or "uot" (UDP over TCP, no protocol mimicry —
/// zapret-like: no recognizable header at all, not a fake TLS/HTTP shell).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransportConfig {
/// "udp" or "uot"
#[serde(default = "default_transport_mode")]
pub mode: String,
/// TLS SNI and HTTP Host for xHTTP routing
#[serde(default)]
pub stealth_sni: String,
/// Split the first UoT/TCP packet (handshake) into tiny TCP segments to
/// break DPI that inspects the first packet. UoT/TCP only; ignored for UDP.
pub tcp_fragmentation: bool,
@ -106,7 +104,6 @@ impl Default for TransportConfig {
fn default() -> Self {
Self {
mode: default_transport_mode(),
stealth_sni: String::new(),
tcp_fragmentation: false,
frag_chunk: default_frag_chunk(),
frag_sleep: default_frag_sleep(),
@ -192,7 +189,6 @@ struct RawUnifiedConfig {
#[derive(Debug, Deserialize)]
struct RawTransportSection {
mode: Option<String>,
stealth_sni: Option<String>,
tcp_fragmentation: Option<bool>,
frag_chunk: Option<usize>,
frag_sleep: Option<u64>,
@ -270,7 +266,6 @@ impl ClientConfig {
},
transport: TransportConfig {
mode: raw.transport.as_ref().and_then(|t| t.mode.clone()).unwrap_or_else(default_transport_mode),
stealth_sni: raw.transport.as_ref().and_then(|t| t.stealth_sni.clone()).unwrap_or_default(),
tcp_fragmentation: raw.transport.as_ref().and_then(|t| t.tcp_fragmentation).unwrap_or(false),
frag_chunk: raw.transport.as_ref().and_then(|t| t.frag_chunk).unwrap_or_else(default_frag_chunk),
frag_sleep: raw.transport.as_ref().and_then(|t| t.frag_sleep).unwrap_or_else(default_frag_sleep),
@ -293,3 +288,251 @@ impl ClientConfig {
})
}
}
// ═══════════════════════════════════════════════════════════════════════
// On-disk config.json shapes — client, server, and relay.
//
// This is the ONE place these are defined. They used to be declared locally
// inside ostp/src/main.rs (the CLI binary) with no other consumer able to
// see them, which is exactly how ostp-client::migrate ended up working
// against loosely-typed serde_json::Value instead of a real schema, and how
// the CLI, the migrator, and this crate's own hot-reload path could each
// silently drift out of sync with what a config.json actually looks like.
// main.rs now imports these instead of re-declaring them (see the `use
// ostp_client::config::{...}` at its top).
//
// These are DELIBERATELY separate from ClientConfig/OstpConfig/etc. above:
// this section is the friendly, minimal shape a user actually edits by
// hand; the types above are what the running engine needs internally
// (handshake/io timeouts, resolved addresses, ...) and are built FROM one
// of these via the mapping in ostp/src/main.rs::run_client_directly. Only
// `ClientConfig` collides by name with the runtime type above, so the
// on-disk one is `ClientFileConfig` — everything else keeps its natural name.
// ═══════════════════════════════════════════════════════════════════════
#[derive(Debug, Deserialize, Serialize)]
#[serde(tag = "mode", rename_all = "lowercase")]
pub enum AppMode {
Server(ServerConfig),
Client(ClientFileConfig),
Relay(RelayServerConfig),
}
#[derive(Debug, Deserialize, Serialize)]
pub struct UnifiedConfig {
#[serde(flatten)]
pub mode: AppMode,
pub log_level: Option<String>,
}
impl UnifiedConfig {
pub fn validate(&self) -> Result<()> {
match &self.mode {
AppMode::Server(cfg) => {
if cfg.access_keys.is_empty() {
anyhow::bail!("Server configuration must contain at least one access_key.");
}
if let Some(outbound) = &cfg.outbound {
if outbound.enabled {
let action = outbound.default_action.as_deref().unwrap_or("direct");
if action == "direct" && outbound.rules.is_empty() {
println!("\n[WARNING] Server outbound proxy is ENABLED, but default_action is 'direct' and there are no rules!");
println!(" This means ALL traffic will bypass the proxy and go out directly from the server IP.");
println!(" If you want all traffic to be proxied, change 'default_action' to 'proxy'.\n");
}
}
}
}
AppMode::Client(cfg) => {
if cfg.access_key.is_empty() {
anyhow::bail!("Client configuration must contain an access_key.");
}
}
AppMode::Relay(cfg) => {
if cfg.upstream_tcp.is_empty() {
anyhow::bail!("Relay configuration must specify upstream_tcp address.");
}
if cfg.upstream_api_url.is_empty() {
anyhow::bail!("Relay configuration must specify upstream_api_url.");
}
}
}
Ok(())
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(untagged)]
pub enum UserConfig {
Detailed {
access_key: String,
name: Option<String>,
limit_bytes: Option<u64>,
},
KeyOnly(String),
}
impl UserConfig {
pub fn key(&self) -> String {
match self {
UserConfig::KeyOnly(k) => k.clone(),
UserConfig::Detailed { access_key, .. } => access_key.clone(),
}
}
pub fn name(&self) -> Option<String> {
match self {
UserConfig::KeyOnly(_) => None,
UserConfig::Detailed { name, .. } => name.clone(),
}
}
pub fn limit(&self) -> Option<u64> {
match self {
UserConfig::KeyOnly(_) => None,
UserConfig::Detailed { limit_bytes, .. } => *limit_bytes,
}
}
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ServerConfig {
pub listen: ListenConfig,
pub access_keys: Vec<UserConfig>,
pub debug: Option<bool>,
pub outbound: Option<OutboundConfig>,
pub api: Option<ApiConfig>,
pub fallback: Option<FallbackCfg>,
pub transport: Option<TransportConfigRaw>,
// Left untyped: ostp-client does not (and should not) depend on
// ostp-server just to name its DnsConfig type. The CLI binary — which
// already depends on both crates — deserializes this into
// ostp_server::dns::DnsConfig right before handing it to run_server().
pub dns: Option<serde_json::Value>,
}
/// Relay-node config.json shape.
#[derive(Debug, Deserialize, Serialize)]
pub struct RelayServerConfig {
/// Listen address(es) (UDP + TCP UoT)
pub listen: ListenConfig,
/// Upstream address for TCP (UoT) traffic
pub upstream_tcp: String,
/// Upstream address for UDP traffic
pub upstream_udp: String,
// ── Deprecated ──────────────────────────────────────────────────────────
// The relay used to authenticate clients itself and pulled the access-key
// list from the target server's management API to do it. It no longer does:
// sessions are authenticated end-to-end by the target server, and a relay
// that re-checks credentials only adds a weaker second gate plus a copy of
// the key list on a machine that does not need one. These are kept solely
// so existing relay configs still parse; they are ignored.
#[serde(default)]
pub upstream_api_url: String,
#[serde(default)]
pub upstream_api_token: String,
#[serde(default)]
pub sync_interval_secs: u64,
pub debug: Option<bool>,
}
/// Supports both a single string "0.0.0.0:50000" and an array
/// ["0.0.0.0:50000", "[::]:50000"].
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(untagged)]
pub enum ListenConfig {
Single(String),
Multiple(Vec<String>),
}
impl ListenConfig {
pub fn addresses(&self) -> Vec<String> {
match self {
ListenConfig::Single(s) => vec![s.clone()],
ListenConfig::Multiple(v) => v.clone(),
}
}
pub fn primary(&self) -> String {
match self {
ListenConfig::Single(s) => s.clone(),
ListenConfig::Multiple(v) => v.first().cloned().unwrap_or_default(),
}
}
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ApiConfig {
pub enabled: Option<bool>,
pub bind: Option<String>,
pub token: Option<String>,
pub webpath: Option<String>,
pub username: Option<String>,
pub password_hash: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct FallbackCfg {
pub enabled: Option<bool>,
pub listen: Option<String>,
pub target: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ClientFileConfig {
pub server: String,
pub access_key: String,
pub mtu: Option<usize>,
pub socks5_bind: Option<String>,
pub tun: Option<TunConfig>,
pub debug: Option<bool>,
pub exclude: Option<ExcludeConfig>,
pub mux: Option<MuxConfig>,
pub transport: Option<TransportConfigRaw>,
pub gui: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct TransportConfigRaw {
pub mode: Option<String>,
pub tcp_fragmentation: Option<bool>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct TunConfig {
pub enable: bool,
pub wintun_path: Option<String>,
pub ipv4_address: Option<String>,
pub dns: Option<String>,
pub kill_switch: Option<bool>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct OutboundConfig {
pub enabled: bool,
pub protocol: String,
pub address: String,
pub port: u16,
#[serde(default)]
pub rules: Vec<OutboundRule>,
pub default_action: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct OutboundRule {
pub domain_suffix: Option<Vec<String>>,
pub ip_cidr: Option<Vec<String>>,
pub protocol: Option<String>,
pub action: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ExcludeConfig {
pub domains: Option<Vec<String>>,
pub ips: Option<Vec<String>>,
pub processes: Option<Vec<String>>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct MuxConfig {
pub enabled: Option<bool>,
pub sessions: Option<usize>,
}

View File

@ -1,6 +1,7 @@
pub mod app;
pub mod bridge;
pub mod config;
pub mod migrate;
pub mod signal;
pub mod sysproxy;
pub mod transport;

View File

@ -3,6 +3,53 @@ use std::io::Write;
use std::path::PathBuf;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
/// The single canonical log file for the whole core. Every process (CLI daemon,
/// GUI, TUN helper) and every subsystem (tracing, the core event logger, the
/// helper IPC, panics) writes here — no more per-binary / per-subsystem sprawl
/// (`ostp-cli.log` + `ostp-core.log` + `ostp-helper.log` + `ostp-crash.log`).
pub const LOG_FILE_NAME: &str = "ostp.log";
/// Absolute path to the shared log file, next to the running executable.
pub fn log_file_path() -> PathBuf {
std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.join(LOG_FILE_NAME)))
.unwrap_or_else(|| PathBuf::from(LOG_FILE_NAME))
}
/// True if this invocation is the long-running daemon (a client/server run),
/// as opposed to a one-shot subcommand (`gk`, `check`, `init`, `-V`, ...).
///
/// Used to gate log truncation: only the daemon clears the log at startup, so a
/// one-shot command run while a daemon is live can never wipe the daemon's log.
/// A daemon invocation is simply one that carries none of the one-shot tokens
/// (`ostp`, `ostp run`, `ostp connect <url>` → daemon; everything else → one-shot).
pub fn invocation_is_daemon<I: IntoIterator<Item = String>>(args: I) -> bool {
const ONE_SHOT: &[&str] = &[
"gk", "generate-key", "check", "init", "setup", "links", "import",
"update", "migrate", "prober", "proxy-env", "proxy-env-clear",
"uninstall", "-V", "--version", "-h", "--help", "help",
];
!args
.into_iter()
.skip(1) // program name
.any(|a| ONE_SHOT.contains(&a.as_str()))
}
/// Append a single timestamped line to the shared log file. Used by the manual
/// writers (core event logger, TUN helper IPC) so their output lands in the same
/// `ostp.log` as the tracing subscriber instead of a separate file.
pub fn append_line(msg: &str) {
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(log_file_path()) {
let _ = writeln!(
file,
"[{}] {}",
chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
msg
);
}
}
pub fn setup_panic_hook() {
std::panic::set_hook(Box::new(|info| {
let payload = info.payload();
@ -16,7 +63,7 @@ pub fn setup_panic_hook() {
let location = info.location().unwrap_or_else(|| std::panic::Location::caller());
let backtrace = std::backtrace::Backtrace::force_capture();
let crash_msg = format!(
"[{}] PANIC at {}:{}\nMessage: {}\nBacktrace:\n{:?}",
chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
@ -29,19 +76,16 @@ pub fn setup_panic_hook() {
eprintln!("{}", crash_msg);
tracing::error!("{}", crash_msg);
let path = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.join("ostp-crash.log")))
.unwrap_or_else(|| PathBuf::from("ostp-crash.log"));
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
// Crashes land in the same shared log file (append — a crash must never
// truncate, and the tracing worker may already be dead so we write direct).
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(log_file_path()) {
let _ = file.write_all(crash_msg.as_bytes());
let _ = file.write_all(b"\n===================================================\n");
}
}));
}
/// Initialises tracing and writes to `<app_name>.log` next to the executable.
/// Initialises tracing and writes to the shared `ostp.log` next to the executable.
///
/// The `level` parameter controls the minimum log level:
/// - `"error"` — only errors
@ -51,7 +95,17 @@ pub fn setup_panic_hook() {
/// - `"trace"` — all messages including very verbose internal state
///
/// The environment variable `RUST_LOG` overrides this value if set.
pub fn init_tracing(level: &str, app_name: &str, version: &str) -> Option<tracing_appender::non_blocking::WorkerGuard> {
///
/// `truncate`: clear the log at startup. Honoured **only on Windows** — Linux
/// servers keep their history (OS-rotated). Pass `true` only from the daemon's
/// own entrypoint; one-shot commands and child processes (the TUN helper) pass
/// `false` so they append instead of wiping a running daemon's log.
pub fn init_tracing(
level: &str,
app_name: &str,
version: &str,
truncate: bool,
) -> Option<tracing_appender::non_blocking::WorkerGuard> {
// RUST_LOG overrides the config-derived level
let env_filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| {
@ -66,14 +120,41 @@ pub fn init_tracing(level: &str, app_name: &str, version: &str) -> Option<tracin
}
});
let path = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.join(format!("{}.log", app_name))))
.unwrap_or_else(|| PathBuf::from(format!("{}.log", app_name)));
let path = log_file_path();
let mut open_opts = OpenOptions::new();
open_opts.create(true);
// Truncate-on-startup is Windows-only and daemon-only. Everywhere else append:
// Linux keeps server history, and one-shot commands / the TUN helper must not
// wipe a running daemon's log.
if truncate && cfg!(windows) {
open_opts.write(true).truncate(true);
} else {
open_opts.append(true);
}
if let Ok(mut file) = open_opts.open(&path) {
// Write the startup banner directly to the log file, bypassing the
// tracing subscriber entirely. Emitting it via tracing::info!() hits
// BOTH layers below (file AND stderr), so every one-shot CLI command
// (`ostp -V`, `ostp gk`, `ostp check`, ...) printed this banner to the
// terminal on every single invocation — pure noise for anything that
// isn't the long-running daemon. It's still genuinely useful for
// whoever's reading the log file later, so keep it there, just not on
// screen for commands that aren't the daemon.
let _ = writeln!(
file,
"{} v{} | OS: {} | Arch: {} | log_level: {} | log_file: {}",
app_name,
version,
std::env::consts::OS,
std::env::consts::ARCH,
level,
path.display(),
);
if let Ok(file) = OpenOptions::new().create(true).append(true).open(&path) {
let (file_writer, guard) = tracing_appender::non_blocking(file);
let fmt_layer = tracing_subscriber::fmt::layer()
.with_target(true)
.with_line_number(true)
@ -81,7 +162,7 @@ pub fn init_tracing(level: &str, app_name: &str, version: &str) -> Option<tracin
.with_thread_names(false)
.with_ansi(false)
.with_writer(file_writer);
let stderr_layer = tracing_subscriber::fmt::layer()
.with_target(true)
.with_writer(std::io::stderr);
@ -91,17 +172,7 @@ pub fn init_tracing(level: &str, app_name: &str, version: &str) -> Option<tracin
.with(fmt_layer)
.with(stderr_layer)
.try_init();
tracing::info!(
"{} v{} | OS: {} | Arch: {} | log_level: {} | log_file: {}",
app_name,
version,
std::env::consts::OS,
std::env::consts::ARCH,
level,
path.display(),
);
Some(guard)
} else {
// Fallback: stderr only

559
ostp-client/src/migrate.rs Normal file
View File

@ -0,0 +1,559 @@
//! The ONE authoritative place that upgrades an old `config.json` to the
//! current schema. Reachable only via the explicit `ostp migrate` command —
//! nothing else in this codebase silently rewrites a user's config on their
//! behalf (the old 0.3.x line used to auto-migrate on every load with just a
//! log warning; that's exactly the kind of "invisible until something looks
//! wrong" behavior this module replaces).
//!
//! Every field this module cannot map forward is reported explicitly in
//! `MigrationReport.notes`, never silently dropped without a trace.
use serde_json::{json, Value};
#[derive(Debug, Default)]
pub struct MigrationReport {
/// Whether anything was actually different from the current schema.
pub changed: bool,
/// Human-readable line per field added, converted, or dropped.
pub notes: Vec<String>,
}
impl MigrationReport {
fn note(&mut self, msg: impl Into<String>) {
self.changed = true;
self.notes.push(msg.into());
}
}
/// Which config this file is (mirrors `AppMode`'s `"mode"` tag). Old configs
/// from before that tag existed are sniffed structurally as a fallback.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigKind {
Client,
Server,
Relay,
}
pub fn detect_kind(json: &Value) -> Option<ConfigKind> {
match json.get("mode").and_then(|v| v.as_str()) {
Some("client") => return Some(ConfigKind::Client),
Some("server") => return Some(ConfigKind::Server),
Some("relay") => return Some(ConfigKind::Relay),
_ => {}
}
// No (or unrecognized) "mode" tag — this is an older config from before
// it was mandatory. Sniff by the fields that have been present on each
// shape since the earliest surviving config format.
if json.get("upstream_tcp").is_some() || json.get("upstream_api_url").is_some() {
Some(ConfigKind::Relay)
} else if json.get("access_keys").is_some() || json.get("listen").is_some() {
Some(ConfigKind::Server)
} else if json.get("access_key").is_some() || json.get("server").is_some() {
Some(ConfigKind::Client)
} else {
None
}
}
/// Migrates a client config of any known past shape to the current flat
/// schema. Returns the migrated JSON and a report of every change made.
///
/// Known input shapes, oldest first:
/// - **v0.3.1v0.3.21 "modular multi-server"**: `inbounds`/`outbounds` arrays
/// + `routing.rules`. Only the first `ostp`-type outbound is kept (this
/// line no longer supports multiple simultaneous servers); every other
/// `ostp` outbound is reported by tag+address so nothing vanishes
/// invisibly. `urltest`/`selector`/`direct`/`block` outbounds have no
/// equivalent and are dropped (reported).
/// - **pre-0.3.1 flat (up to v0.2.98)**: same field names as today
/// (`server`, `access_key`, `tun`, `exclude`, `mux`, `transport`, ...)
/// except `tun.wintun_path`/`tun.ipv4_address` (internal driver detail,
/// never user-meaningful data) and `transport.wss` (the WSS framing
/// feature removed entirely in the 0.4.0 rebuild) — both dropped with an
/// explicit note; everything else maps 1:1, nothing to convert.
/// - **configs carrying a leftover `transport.stealth_sni`**: dropped with a
/// note, same reasoning as `wss` — it never fed into anything on the wire
/// (no TLS/HTTP mimicry exists in this project), so there is no successor
/// field. Not tied to a specific version: it lingered in the schema well
/// past when the mimicry work it was meant for got removed.
/// - **current flat schema**: no-op, `changed = false`.
pub fn migrate_client_json(json: Value) -> (Value, MigrationReport) {
let mut report = MigrationReport::default();
let has_inbounds = json.get("inbounds").and_then(|v| v.as_array()).is_some();
let has_outbounds = json.get("outbounds").and_then(|v| v.as_array()).is_some();
if has_inbounds && has_outbounds {
return migrate_client_from_modular(json, report);
}
// Flat shape already (current or pre-0.3.1) — normalize obsolete fields
// in place rather than rebuilding the whole document from scratch, so
// any field this module doesn't know about yet still survives untouched.
let mut out = json;
if let Some(tun) = out.get_mut("tun").and_then(|t| t.as_object_mut()) {
for dead_field in ["wintun_path", "ipv4_address"] {
if tun.remove(dead_field).is_some() {
report.note(format!(
"Dropped tun.{dead_field} — internal driver detail from an older WinTun \
integration, not applicable to the current TUN implementation."
));
}
}
}
if let Some(transport) = out.get_mut("transport").and_then(|t| t.as_object_mut()) {
if transport.remove("wss").is_some() {
report.note(
"Dropped transport.wss — WSS framing was removed in the 0.4.0 rebuild \
(the project follows a zapret-like approach: no protocol mimicry, \
just packet-level obfuscation/manipulation, so there is no successor field)."
.to_string(),
);
}
if transport.remove("stealth_sni").is_some() {
report.note(
"Dropped transport.stealth_sni — never actually used to construct any wire \
bytes (no TLS/HTTP mimicry exists in this project same zapret-like \
reasoning as transport.wss), so it was unused config plumbing with no effect."
.to_string(),
);
}
}
(out, report)
}
fn migrate_client_from_modular(json: Value, mut report: MigrationReport) -> (Value, MigrationReport) {
report.changed = true; // the shape itself is being replaced regardless of field-level detail
let inbounds = json.get("inbounds").and_then(|v| v.as_array()).cloned().unwrap_or_default();
let outbounds = json.get("outbounds").and_then(|v| v.as_array()).cloned().unwrap_or_default();
let routing = json.get("routing").cloned().unwrap_or(json!({}));
let default_outbound = routing.get("default_outbound").and_then(|v| v.as_str()).map(String::from);
// ── Pick the primary "ostp" outbound ────────────────────────────────
// Prefer the one routing.default_outbound points at (directly, or via a
// urltest/selector group that references it); otherwise take the first
// ostp outbound in file order. Every other ostp outbound is reported by
// tag+address, not silently discarded.
let ostp_outbounds: Vec<&Value> = outbounds
.iter()
.filter(|o| o.get("type").and_then(|t| t.as_str()) == Some("ostp"))
.collect();
// default_outbound might name an ostp outbound directly, OR name a
// urltest/selector GROUP whose first member is the one to actually use —
// check both, since a plain `.or_else` here would never even attempt the
// group lookup while default_outbound is Some(_) (which it almost always
// is), silently falling through to "just take the first ostp outbound in
// file order" instead — exactly the kind of silent wrong answer this
// migrator exists to avoid.
let primary_tag: Option<String> = default_outbound.as_deref().and_then(|def_tag| {
if ostp_outbounds.iter().any(|o| o.get("tag").and_then(|t| t.as_str()) == Some(def_tag)) {
return Some(def_tag.to_string());
}
outbounds.iter().find_map(|o| {
let is_group = matches!(o.get("type").and_then(|t| t.as_str()), Some("urltest") | Some("selector"));
let tag_matches = o.get("tag").and_then(|t| t.as_str()) == Some(def_tag);
if is_group && tag_matches {
o.get("outbounds")
.and_then(|v| v.as_array())
.and_then(|arr| arr.first())
.and_then(|v| v.as_str())
.map(String::from)
} else {
None
}
})
});
let primary = primary_tag
.as_deref()
.and_then(|tag| ostp_outbounds.iter().find(|o| o.get("tag").and_then(|t| t.as_str()) == Some(tag)))
.copied()
.or_else(|| ostp_outbounds.first().copied());
let Some(primary) = primary else {
report.note(
"No 'ostp'-type outbound found in the old modular config — nothing to migrate \
the server connection from. Wrote a placeholder; you MUST fill in server/access_key \
by hand or re-import a share link."
.to_string(),
);
return (
json!({
"server": "127.0.0.1:50000",
"access_key": "",
}),
report,
);
};
for other in &ostp_outbounds {
if !std::ptr::eq(*other, primary) {
let tag = other.get("tag").and_then(|t| t.as_str()).unwrap_or("?");
let addr = other.get("server").and_then(|t| t.as_str()).unwrap_or("?");
let port = other.get("port").and_then(|t| t.as_u64()).unwrap_or(0);
report.note(format!(
"Dropped additional server '{tag}' ({addr}:{port}) — multi-server / urltest \
failover is no longer supported; only one server per config now. Kept the \
one from routing.default_outbound (or the first one if that wasn't set)."
));
}
}
let server = primary.get("server").and_then(|v| v.as_str()).unwrap_or("127.0.0.1").to_string();
let port = primary.get("port").and_then(|v| v.as_u64()).unwrap_or(50000);
let access_key = primary.get("access_key").and_then(|v| v.as_str()).unwrap_or("").to_string();
let transport_type = primary
.get("transport")
.and_then(|t| t.get("type").or_else(|| t.get("mode")))
.and_then(|v| v.as_str())
.unwrap_or("udp")
.to_string();
if let Some(sni) = primary.get("transport").and_then(|t| t.get("stealth_sni")).and_then(|v| v.as_str()) {
if !sni.is_empty() {
report.note(format!(
"Dropped transport.stealth_sni ({sni:?}) — never actually used to construct \
any wire bytes; unused config plumbing with no successor field."
));
}
}
let tcp_fragmentation = primary
.get("transport")
.and_then(|t| t.get("tcp_fragmentation"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
let mux_enabled = primary.get("multiplex").and_then(|m| m.get("enabled")).and_then(|v| v.as_bool()).unwrap_or(false);
let mux_sessions = primary.get("multiplex").and_then(|m| m.get("sessions")).and_then(|v| v.as_u64()).unwrap_or(1);
// ── TUN + local proxy inbounds ───────────────────────────────────────
let tun_inbound = inbounds.iter().find(|i| i.get("type").and_then(|t| t.as_str()) == Some("tun"));
let proxy_inbound = inbounds.iter().find(|i| i.get("type").and_then(|t| t.as_str()) == Some("local_proxy"));
let tun_enable = tun_inbound.is_some();
let mtu = tun_inbound.and_then(|t| t.get("mtu")).and_then(|v| v.as_u64());
let socks5_bind = proxy_inbound
.map(|p| {
let listen = p.get("listen").and_then(|v| v.as_str()).unwrap_or("127.0.0.1");
let port = p.get("port").and_then(|v| v.as_u64()).unwrap_or(1088);
format!("{listen}:{port}")
})
.unwrap_or_else(|| "127.0.0.1:1088".to_string());
// ── Exclusions from routing.rules → direct ──────────────────────────
let mut ex_domains: Vec<String> = Vec::new();
let mut ex_ips: Vec<String> = Vec::new();
let mut ex_processes: Vec<String> = Vec::new();
if let Some(rules) = routing.get("rules").and_then(|v| v.as_array()) {
for rule in rules {
if rule.get("outbound").and_then(|v| v.as_str()) != Some("direct") {
continue; // only "route to direct" rules were ever exclusions in the old format
}
if let Some(v) = rule.get("domain_suffix").and_then(|v| v.as_array()) {
ex_domains.extend(v.iter().filter_map(|s| s.as_str().map(String::from)));
}
if let Some(v) = rule.get("ip_cidr").and_then(|v| v.as_array()) {
ex_ips.extend(v.iter().filter_map(|s| s.as_str().map(String::from)));
}
if let Some(v) = rule.get("process_name").and_then(|v| v.as_array()) {
ex_processes.extend(v.iter().filter_map(|s| s.as_str().map(String::from)));
}
}
}
for other_rule_outbound in routing
.get("rules")
.and_then(|v| v.as_array())
.into_iter()
.flatten()
.filter_map(|r| r.get("outbound").and_then(|v| v.as_str()))
.filter(|o| *o != "direct")
{
report.note(format!(
"Dropped a routing rule targeting outbound '{other_rule_outbound}' — only \
\"route to direct\" rules map to today's exclusions; anything else \
(custom per-domain outbound selection) has no equivalent anymore."
));
}
let debug = json.get("log").and_then(|l| l.get("level")).and_then(|v| v.as_str()) == Some("debug");
let mut client = json!({
"server": server,
"port": port,
"access_key": access_key,
"socks5_bind": socks5_bind,
"debug": debug,
"tun": {
"enable": tun_enable,
"dns": null,
"kill_switch": false,
},
"exclude": {
"domains": ex_domains,
"ips": ex_ips,
"processes": ex_processes,
},
"mux": {
"enabled": mux_enabled,
"sessions": mux_sessions,
},
"transport": {
"mode": transport_type,
"tcp_fragmentation": tcp_fragmentation,
},
});
if let Some(mtu) = mtu {
client["mtu"] = json!(mtu);
}
if let Some(gui) = json.get("gui") {
client["gui"] = gui.clone();
}
(client, report)
}
/// Migrates a server config. The server shape has stayed structurally
/// identical since the earliest surviving version — this only backfills the
/// `api` section (added after some configs already existed) and drops the
/// legacy `api.token` field. Ported from the ad-hoc Python snippet that used
/// to live in `scripts/install.sh` and only ran at install/update time.
pub fn migrate_server_json(json: Value) -> (Value, MigrationReport) {
let mut report = MigrationReport::default();
let mut out = json;
let obj = match out.as_object_mut() {
Some(o) => o,
None => return (out, report),
};
let api = obj.entry("api").or_insert_with(|| json!({}));
if let Some(api_obj) = api.as_object_mut() {
let defaults: [(&str, Value); 5] = [
("enabled", json!(false)),
("bind", json!("0.0.0.0:9090")),
("webpath", json!("")),
("username", json!("")),
("password_hash", json!("")),
];
for (key, default) in defaults {
if !api_obj.contains_key(key) {
report.note(format!("Added api.{key} = {default} (missing default)"));
api_obj.insert(key.to_string(), default);
}
}
if api_obj.remove("token").is_some() {
report.note(
"Dropped legacy api.token — superseded by api.password_hash; \
set a new admin password with the management API or panel."
.to_string(),
);
}
}
(out, report)
}
#[cfg(test)]
mod tests {
use super::*;
/// A realistic v0.3.21-shaped modular config (TUN + local_proxy inbounds,
/// a single ostp outbound, exclusion rules, mux) — mirrors the actual
/// shape from that tag, field for field.
#[test]
fn modular_single_server_preserves_every_field() {
let old = json!({
"version": "0.3.21",
"log": { "level": "debug" },
"inbounds": [
{ "type": "tun", "tag": "tun-in", "auto_route": true, "mtu": 1350 },
{ "type": "local_proxy", "tag": "socks-in", "protocol": "socks", "listen": "127.0.0.1", "port": 1088 }
],
"outbounds": [
{
"type": "ostp", "tag": "proxy",
"server": "203.0.113.5", "port": 50000, "access_key": "sekrit123",
"transport": { "type": "uot", "stealth_sni": "vk.com", "tcp_fragmentation": true },
"multiplex": { "enabled": true, "sessions": 4 }
},
{ "type": "direct", "tag": "direct" },
{ "type": "block", "tag": "block" }
],
"routing": {
"rules": [
{ "domain_suffix": ["local.lan", "internal.corp"], "outbound": "direct" },
{ "ip_cidr": ["192.168.0.0/16"], "outbound": "direct" },
{ "process_name": ["steam.exe"], "outbound": "direct" }
],
"default_outbound": "proxy"
}
});
let (new, report) = migrate_client_json(old);
assert!(report.changed);
assert_eq!(new["server"], "203.0.113.5");
assert_eq!(new["port"], 50000);
assert_eq!(new["access_key"], "sekrit123");
assert_eq!(new["socks5_bind"], "127.0.0.1:1088");
assert_eq!(new["mtu"], 1350);
assert_eq!(new["debug"], true);
assert_eq!(new["tun"]["enable"], true);
assert_eq!(new["transport"]["mode"], "uot");
assert_eq!(new["transport"]["tcp_fragmentation"], true);
assert_eq!(new["mux"]["enabled"], true);
assert_eq!(new["mux"]["sessions"], 4);
assert_eq!(new["exclude"]["domains"], json!(["local.lan", "internal.corp"]));
assert_eq!(new["exclude"]["ips"], json!(["192.168.0.0/16"]));
assert_eq!(new["exclude"]["processes"], json!(["steam.exe"]));
// stealth_sni never fed into any wire bytes — dropped, not carried forward.
assert!(new["transport"].get("stealth_sni").is_none());
assert!(report.notes.iter().any(|n| n.contains("stealth_sni") && n.contains("vk.com")));
}
/// Old modular configs that had MULTIPLE ostp outbounds (multi-server) —
/// must keep the one routing.default_outbound points at and report every
/// other one by name/address rather than picking silently.
#[test]
fn modular_multi_server_keeps_default_and_reports_the_rest() {
let old = json!({
"inbounds": [],
"outbounds": [
{ "type": "ostp", "tag": "proxy-0", "server": "1.1.1.1", "port": 50000, "access_key": "k1" },
{ "type": "ostp", "tag": "proxy-1", "server": "2.2.2.2", "port": 50000, "access_key": "k2" },
{
"type": "urltest", "tag": "proxy",
"outbounds": ["proxy-1", "proxy-0"], "url": "http://cp.cloudflare.com"
}
],
"routing": { "rules": [], "default_outbound": "proxy" }
});
let (new, report) = migrate_client_json(old);
// urltest's first member (proxy-1 / 2.2.2.2) is the one actually picked.
assert_eq!(new["server"], "2.2.2.2");
assert_eq!(new["access_key"], "k2");
assert!(report.notes.iter().any(|n| n.contains("proxy-0") && n.contains("1.1.1.1")));
}
/// Pre-0.3.1 flat config carrying fields that no longer exist
/// (tun.wintun_path, tun.ipv4_address, transport.wss, transport.stealth_sni)
/// — those get dropped with a note; every field that's still meaningful
/// passes through untouched, byte for byte.
#[test]
fn flat_legacy_drops_only_dead_fields() {
let old = json!({
"server": "198.51.100.9:50000",
"access_key": "oldkey",
"mtu": 1200,
"socks5_bind": "127.0.0.1:1090",
"tun": {
"enable": true,
"wintun_path": "C:\\Program Files\\wintun\\wintun.dll",
"ipv4_address": "10.0.0.2",
"dns": "1.1.1.1",
"kill_switch": true
},
"exclude": { "domains": ["a.com"], "ips": null, "processes": null },
"mux": { "enabled": false, "sessions": 1 },
"transport": { "mode": "udp", "stealth_sni": "bing.com", "wss": true }
});
let (new, report) = migrate_client_json(old);
assert!(report.changed);
// Untouched fields survive exactly as they were.
assert_eq!(new["server"], "198.51.100.9:50000");
assert_eq!(new["access_key"], "oldkey");
assert_eq!(new["mtu"], 1200);
assert_eq!(new["tun"]["enable"], true);
assert_eq!(new["tun"]["dns"], "1.1.1.1");
assert_eq!(new["tun"]["kill_switch"], true);
assert_eq!(new["exclude"]["domains"], json!(["a.com"]));
// Dead fields are gone...
assert!(new["tun"].get("wintun_path").is_none());
assert!(new["tun"].get("ipv4_address").is_none());
assert!(new["transport"].get("wss").is_none());
assert!(new["transport"].get("stealth_sni").is_none());
// ...and their removal was reported, not silent.
assert!(report.notes.iter().any(|n| n.contains("wintun_path")));
assert!(report.notes.iter().any(|n| n.contains("ipv4_address")));
assert!(report.notes.iter().any(|n| n.contains("wss")));
assert!(report.notes.iter().any(|n| n.contains("stealth_sni")));
}
/// A config already in the current shape must be a true no-op: report
/// says nothing changed, and every field is untouched.
#[test]
fn current_flat_config_is_a_no_op() {
let current = json!({
"server": "example.com:50000",
"access_key": "k",
"tun": { "enable": false, "dns": null, "kill_switch": false },
"exclude": { "domains": [], "ips": [], "processes": [] },
"mux": { "enabled": false, "sessions": 1 },
"transport": { "mode": "udp", "tcp_fragmentation": false }
});
let (new, report) = migrate_client_json(current.clone());
assert!(!report.changed);
assert_eq!(new, current);
}
/// Every migrated output must actually deserialize into the ONE
/// canonical schema (`crate::config`) — this is the same check
/// `cmd_migrate` runs at runtime before ever touching a user's file,
/// exercised here directly so a schema/migrator drift fails a fast unit
/// test instead of surfacing as "your migrated config won't load".
#[test]
fn every_migrated_output_matches_the_canonical_schema() {
let modular = json!({
"inbounds": [{ "type": "tun", "tag": "tun-in", "mtu": 1350 }],
"outbounds": [
{ "type": "ostp", "tag": "proxy", "server": "1.2.3.4", "port": 50000, "access_key": "k" },
{ "type": "direct", "tag": "direct" }
],
"routing": { "rules": [], "default_outbound": "proxy" }
});
let (new, _) = migrate_client_json(modular);
serde_json::from_value::<crate::config::ClientFileConfig>(new)
.expect("modular->flat migration output must match ClientFileConfig");
let legacy_flat = json!({
"server": "1.2.3.4:50000",
"access_key": "k",
"tun": { "enable": true, "wintun_path": "x", "ipv4_address": "y" }
});
let (new, _) = migrate_client_json(legacy_flat);
serde_json::from_value::<crate::config::ClientFileConfig>(new)
.expect("legacy-flat migration output must match ClientFileConfig");
let server = json!({ "listen": "0.0.0.0:50000", "access_keys": ["k"] });
let (new, _) = migrate_server_json(server);
serde_json::from_value::<crate::config::ServerConfig>(new)
.expect("server migration output must match ServerConfig");
}
#[test]
fn server_config_backfills_api_defaults_and_drops_legacy_token() {
let old = json!({
"listen": "0.0.0.0:50000",
"access_keys": ["k1"],
"api": { "token": "old-plain-token" }
});
let (new, report) = migrate_server_json(old);
assert!(report.changed);
assert_eq!(new["api"]["enabled"], false);
assert_eq!(new["api"]["bind"], "0.0.0.0:9090");
assert!(new["api"].get("token").is_none());
assert!(report.notes.iter().any(|n| n.contains("api.token")));
}
#[test]
fn detect_kind_falls_back_to_structural_sniffing_without_mode_tag() {
assert_eq!(detect_kind(&json!({"access_key": "x", "server": "y"})), Some(ConfigKind::Client));
assert_eq!(detect_kind(&json!({"access_keys": ["x"], "listen": "y"})), Some(ConfigKind::Server));
assert_eq!(detect_kind(&json!({"upstream_tcp": "x", "upstream_api_url": "y"})), Some(ConfigKind::Relay));
assert_eq!(detect_kind(&json!({"mode": "client", "server": "x"})), Some(ConfigKind::Client));
}
}

View File

@ -10,10 +10,9 @@ use std::fs::OpenOptions;
use std::io::Write as _;
fn log_to_core_file(msg: &str) {
let path = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.join("ostp-core.log")))
.unwrap_or_else(|| std::path::PathBuf::from("ostp-core.log"));
// Writes into the single shared ostp.log (same file as the tracing appender),
// not a separate ostp-core.log — see logging::LOG_FILE_NAME.
let path = crate::logging::log_file_path();
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
let _ = writeln!(file, "[{}] {}", chrono::Local::now().format("%Y-%m-%d %H:%M:%S"), msg);
}
@ -183,7 +182,64 @@ pub async fn run_client(config: crate::config::ClientConfig) -> Result<()> {
run_client_core(config, metrics, shutdown_rx, None).await
}
/// Runs the client with auto-reconnect: any subsystem ending — a network
/// change stranding the TUN adapter/UDP socket on a dead interface, the OSTP
/// protocol connection dropping in a way the inner Bridge-level retry (see
/// `UiEvent::TunnelStopped` below) couldn't recover from, or a proxy/TUN task
/// crashing outright — triggers a full clean restart (fresh DNS resolution,
/// fresh Bridge, fresh TUN/proxy) with exponential backoff, instead of the
/// client just dying. Only an explicit shutdown request stops this loop.
pub async fn run_client_core(
config: crate::config::ClientConfig,
metrics: Arc<BridgeMetrics>,
mut shutdown_rx_ext: watch::Receiver<bool>,
config_rx: Option<watch::Receiver<crate::config::ClientConfig>>,
) -> Result<()> {
use portable_atomic::Ordering;
const BACKOFF_SCHEDULE_SECS: [u64; 6] = [1, 2, 5, 10, 20, 30];
// A run that stayed up at least this long counts as "was actually
// connected", so a later drop restarts the backoff from the top instead
// of inheriting a long delay from a previous flaky stretch.
const STABLE_UPTIME: std::time::Duration = std::time::Duration::from_secs(60);
let mut backoff_idx = 0usize;
loop {
if *shutdown_rx_ext.borrow() {
return Ok(());
}
let attempt_start = std::time::Instant::now();
let result = run_client_once(config.clone(), metrics.clone(), shutdown_rx_ext.clone(), config_rx.clone()).await;
if *shutdown_rx_ext.borrow() {
// Shutdown was requested during (or right after) this attempt — honor it, don't retry.
return result;
}
if let Err(ref e) = result {
tracing::warn!("client run ended unexpectedly, will auto-reconnect: {e}");
}
if attempt_start.elapsed() >= STABLE_UPTIME {
backoff_idx = 0;
}
let delay = BACKOFF_SCHEDULE_SECS[backoff_idx.min(BACKOFF_SCHEDULE_SECS.len() - 1)];
backoff_idx += 1;
// Reflect the retry wait as "connecting" rather than "disconnected".
metrics.connection_state.store(1, Ordering::Relaxed);
tokio::select! {
_ = tokio::time::sleep(std::time::Duration::from_secs(delay)) => {}
_ = shutdown_rx_ext.changed() => {
if *shutdown_rx_ext.borrow() {
return Ok(());
}
}
}
}
}
async fn run_client_once(
mut config: crate::config::ClientConfig,
metrics: Arc<BridgeMetrics>,
mut shutdown_rx_ext: watch::Receiver<bool>,

View File

@ -235,7 +235,7 @@ pub fn enable_system_proxy(proxy_addr: &str) {
println!("OSTP Local Proxy is running at socks5://{}", proxy_addr);
println!("Since you are in a headless/terminal environment, OSTP cannot automatically");
println!("configure your system proxy. To route traffic from this terminal, run:");
println!("\n eval $(ostp --proxy-env)\n");
println!("\n eval $(ostp proxy-env)\n");
println!("Or configure your application (e.g. curl -x socks5://{})", proxy_addr);
println!("===================================================================\n");
}

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 {
use futures::SinkExt;
let mut buf = [0u8; 65536];
loop {
match socket_rx.recv_from(&mut buf).await {
Ok((n, peer)) => {
let mut lock = tx_clone.lock().await;
let _ = lock.send((buf[..n].to_vec(), peer, client_src)).await;
// 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 {
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 = smoltcp_tx.lock().await;
let _ = lock.send((buf[..n].to_vec(), peer, client_src)).await;
}
Err(_) => break,
}
Err(_) => break,
}
}
});
while let Some((payload, dst)) = session_rx.recv().await {
socket.send_to(&payload, dst).await?;
}
Ok(())

View File

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

@ -8,4 +8,5 @@ pub use noise::{NoiseRole, NoiseSession};
pub use obfuscation::{
deobfuscate_header_inplace, deobfuscate_packet_inplace, obfuscate_packet_inplace,
derive_obfuscation_key, derive_psk, derive_all_secrets, DerivedSecrets,
derive_junk_marker, current_junk_window, JUNK_MARKER_WINDOW_SECS,
};

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
.write_message(payload, out)
.map_err(|_| ProtocolError::Crypto("noise-write".to_string())),
NoiseSession::Transport(_) => Err(ProtocolError::State("noise already in transport".to_string())),
}
self.handshake
.write_message(payload, out)
.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
.read_message(input, out)
.map_err(|e| ProtocolError::Crypto(format!("noise-read: {:?}", e))),
NoiseSession::Transport(_) => Err(ProtocolError::State("noise already in transport".to_string())),
}
self.handshake
.read_message(input, out)
.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,16 +54,16 @@ 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],
pub handshake_pad_min: usize,
pub handshake_pad_max: usize,
/// Per-key 4-byte prefix stamped on junk frames so the server can drop them
/// without a GLOBAL constant marker (which would be a universal DPI signature
/// for all OSTP users — exactly what the version gate avoids for the handshake).
pub junk_marker: [u8; 4],
}
// NOTE: the junk marker is NOT part of DerivedSecrets — it is time-rotating and
// derived separately per window via `derive_junk_marker` (see below), so it
// carries no static per-user signature.
/// OSTP wire protocol version. Mixed into key derivation (NOT sent on the
/// wire) so peers running incompatible versions derive entirely different
@ -75,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)
@ -129,25 +132,61 @@ pub(crate) fn derive_all_secrets_versioned(access_key: &[u8], version: u8) -> De
let pad_min = 16 + (pad_bytes[0] as usize % 64); // 16-79
let pad_max = pad_min + 48 + (pad_bytes[1] as usize % 128); // +48..+175
// Derive junk marker (4 bytes) — info = key_hash[16..] || 0x04.
// Per-key: to an outsider it is indistinguishable from the random junk
// payload, so there is no cross-user signature; the server, knowing the key,
// derives the same marker and drops the junk silently.
let mut junk_info = info_base.to_vec();
junk_info.push(0x04);
let junk_bytes = hkdf_expand(&prk, &junk_info, 4);
let mut junk_marker = [0u8; 4];
junk_marker.copy_from_slice(&junk_bytes);
DerivedSecrets {
obfuscation_key,
psk,
handshake_pad_min: pad_min,
handshake_pad_max: pad_max,
junk_marker,
}
}
/// Window length (seconds) for the rotating junk marker. The marker changes
/// every window, so junk carries no static per-user fingerprint on the wire;
/// the server checks the current and previous window to absorb clock skew.
pub const JUNK_MARKER_WINDOW_SECS: u64 = 60;
/// The current junk-marker time window (unix seconds / window length).
pub fn current_junk_window() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() / JUNK_MARKER_WINDOW_SECS)
.unwrap_or(0)
}
/// Derive the 4-byte junk marker for a given time `window`.
///
/// Uses the same version-gated HKDF scheme as [`derive_all_secrets`], with the
/// window folded into the `info` (label byte `0x04`). Folding in the window
/// makes the marker rotate: to an on-path observer the junk prefix changes every
/// window (no fixed signature), and a captured marker is only valid for ~1
/// window. Only a holder of the access key can compute it, so an outsider cannot
/// forge a silently-dropped junk packet.
pub fn derive_junk_marker(access_key: &[u8], window: u64) -> [u8; 4] {
derive_junk_marker_versioned(access_key, window, PROTOCOL_VERSION)
}
pub(crate) fn derive_junk_marker_versioned(access_key: &[u8], window: u64, version: u8) -> [u8; 4] {
use sha2::Digest;
let key_hash = sha2::Sha256::digest(access_key);
let salt = &key_hash[..16];
let info_base = &key_hash[16..];
let mut ikm = Vec::with_capacity(access_key.len() + 1);
ikm.extend_from_slice(access_key);
ikm.push(version);
let prk = hkdf_extract(salt, &ikm);
// info = key_hash[16..] || 0x04 || window(LE) — same label byte as before,
// now parameterised by the time window.
let mut info = info_base.to_vec();
info.push(0x04);
info.extend_from_slice(&window.to_le_bytes());
let bytes = hkdf_expand(&prk, &info, 4);
let mut marker = [0u8; 4];
marker.copy_from_slice(&bytes);
marker
}
// ── Legacy API (delegates to derive_all_secrets) ─────────────────────────────
pub fn derive_obfuscation_key(access_key: &[u8]) -> [u8; 8] {

View File

@ -191,4 +191,29 @@ mod tests {
assert_eq!(recovered_nonce, nonce);
assert_eq!(&packet[12..], &ciphertext);
}
/// The junk marker must: be stable within a window (client and server agree),
/// rotate across windows (no static on-wire fingerprint), and differ per key
/// (one user's marker never silently-drops on another user's flow).
#[test]
fn test_junk_marker_rotation() {
let key_a = b"access-key-alpha";
let key_b = b"access-key-bravo";
// Stable within a window.
assert_eq!(derive_junk_marker(key_a, 1000), derive_junk_marker(key_a, 1000));
// Rotates across adjacent windows.
assert_ne!(derive_junk_marker(key_a, 1000), derive_junk_marker(key_a, 1001));
assert_ne!(derive_junk_marker(key_a, 1000), derive_junk_marker(key_a, 999));
// Distinct per key within the same window.
assert_ne!(derive_junk_marker(key_a, 1000), derive_junk_marker(key_b, 1000));
// A different protocol version yields a different marker (version gate).
assert_ne!(
derive_junk_marker_versioned(key_a, 1000, PROTOCOL_VERSION),
derive_junk_marker_versioned(key_a, 1000, PROTOCOL_VERSION.wrapping_add(1)),
);
}
}

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,10 +1,14 @@
use bytes::Bytes;
use rand::Rng;
use sha2::{Digest, Sha256};
use thiserror::Error;
use std::collections::{BTreeMap, VecDeque};
use std::time::{Duration, Instant};
/// Upper bound on a single frame's retransmit timer, after exponential backoff
/// is applied to the adaptive RTO. Past this the session is dead from the
/// user's point of view, and waiting longer only delays recovery.
const MAX_EFFECTIVE_RTO: Duration = Duration::from_secs(8);
use crate::congestion::CongestionController;
use crate::crypto::{NoiseRole, NoiseSession, SessionCipher};
use crate::framing::{AdaptivePadder, FrameHeader, FrameKind, FramedPacket, PaddingStrategy};
@ -103,6 +107,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,10 +171,33 @@ 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
}
/// Whether the pacing bucket currently allows releasing another packet.
///
/// The congestion window bounds how much may be UNACKNOWLEDGED; it says
/// nothing about how fast that window is emptied onto the wire. Sending a
/// whole window back-to-back is what drives a deep buffer into standing
/// queue, so admission is gated on both.
pub fn can_pace_packet(&self) -> bool {
self.cc.can_pace_packet()
}
pub fn on_send(&mut self, bytes: u64) {
self.cc.on_send(bytes);
}
@ -237,7 +275,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,8 +303,7 @@ 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| {
ProtocolError::Crypto(format!("noise-read: {:?} (raw_len={}, noise_len={})", e, raw_vec.len(), noise_len))
@ -281,9 +320,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 +335,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 +500,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,18 +682,39 @@ 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;
}
// Exponential backoff, but bounded in absolute terms. base_rto is
// itself adaptive and can reach RTO_MAX (16s) on a congested path;
// multiplying that by the 64x backoff cap yields a frame that sits
// unretransmitted for ~17 MINUTES, long past the point where the
// session is simply dead to the user. Cap the product so backoff
// stays a backoff rather than an outage.
let backoff_factor = 1u64 << (frame.retries as u64).min(6);
let effective_rto = Duration::from_millis(base_rto_ms.saturating_mul(backoff_factor));
let effective_rto = Duration::from_millis(base_rto_ms.saturating_mul(backoff_factor))
.min(MAX_EFFECTIVE_RTO);
if now.duration_since(frame.last_sent) >= effective_rto {
// Only burn the retry counter and reset the RTO timer when the
// 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;
}
actions.push(ProtocolAction::SendDatagram(frame.bytes.clone()));
retransmit_budget -= 1;
}
}
@ -685,24 +843,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;
let rtt = now.duration_since(frame.last_sent);
if rtt < min_rtt {
min_rtt = rtt;
// 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);
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 +900,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 +1131,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.
signingConfig = signingConfigs.getByName("debug")
// Use the real upload key when one was supplied; otherwise fall back to
// the debug keystore so a plain local `flutter build apk --release`
// still works for development. Anything PUBLISHED must take the first
// branch — a debug-signed build cannot be updated over, and its key is
// machine-local, so it also can't be reproduced later.
if (hasReleaseSigning) {
signingConfig = signingConfigs.getByName("release")
} else {
logger.warn(
"OSTP: no release keystore configured (android/key.properties or " +
"OSTP_KEYSTORE_PATH) - falling back to the DEBUG keystore. This APK " +
"is for local use only: users cannot update over it, and the key is " +
"not reproducible on another machine."
)
signingConfig = signingConfigs.getByName("debug")
}
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}

View File

@ -92,28 +92,46 @@ class MainActivity : FlutterActivity() {
val metrics = net.ostp.client.OstpClientSdk.getMetrics()
result.success(metrics ?: "{}")
} catch (e: Throwable) {
// Surfaced into the in-app log viewer (not just logcat) so a
// broken traffic counter is diagnosable from a user's bug
// report without adb access.
android.util.Log.e("MainActivity", "getMetrics failed", e)
try {
net.ostp.client.OstpClientSdk.addLog("getMetrics failed: ${e.javaClass.simpleName}: ${e.message}")
} catch (_: Throwable) {}
result.error("ERROR", e.message, null)
}
}
"getInstalledApps" -> {
try {
val pm = packageManager
val apps = pm.getInstalledApplications(PackageManager.GET_META_DATA)
val list = apps.map { app ->
val isSystem = ((app.flags and ApplicationInfo.FLAG_SYSTEM) != 0) &&
(pm.getLaunchIntentForPackage(app.packageName) == null)
val iconBase64 = getAppIconBase64(pm, app)
mapOf(
"name" to pm.getApplicationLabel(app).toString(),
"package" to app.packageName,
"isSystem" to isSystem,
"icon" to (iconBase64 ?: "")
)
// MethodChannel handlers run on the main/UI thread by default.
// Enumerating every installed package AND decoding+re-encoding
// each one's icon to PNG/base64 is expensive (100+ apps is
// common) — done inline here it blocked the main thread for
// 10-15s, during which Flutter couldn't render ANY frame, not
// even the "loading" spinner, so the screen just appeared to
// hang before jumping straight to the fully-loaded list.
// Do the work on a background thread; only the final
// `result.success(...)` needs to hop back onto the UI thread.
val pm = packageManager
Thread {
try {
val apps = pm.getInstalledApplications(PackageManager.GET_META_DATA)
val list = apps.map { app ->
val isSystem = ((app.flags and ApplicationInfo.FLAG_SYSTEM) != 0) &&
(pm.getLaunchIntentForPackage(app.packageName) == null)
val iconBase64 = getAppIconBase64(pm, app)
mapOf(
"name" to pm.getApplicationLabel(app).toString(),
"package" to app.packageName,
"isSystem" to isSystem,
"icon" to (iconBase64 ?: "")
)
}
runOnUiThread { result.success(list) }
} catch (e: Exception) {
runOnUiThread { result.error("ERROR", e.message, null) }
}
result.success(list)
} catch (e: Exception) {
result.error("ERROR", e.message, null)
}
}.start()
}
else -> result.notImplemented()
}

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: 4.4 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 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: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 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: 4.7 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: 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: 10 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 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: 11 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 769 KiB

View File

@ -26,11 +26,11 @@ class OstpApp extends StatelessWidget {
debugShowCheckedModeBanner: false,
theme: ThemeData(
brightness: Brightness.dark,
scaffoldBackgroundColor: const Color(0xFF08080F),
scaffoldBackgroundColor: const Color(0xFF000000),
colorScheme: const ColorScheme.dark(
primary: Color(0xFF6C72FF),
secondary: Color(0xFF22D3A5),
surface: Color(0xFF151522),
primary: Color(0xFFFFFFFF),
secondary: Color(0xFFAAAAAA),
surface: Color(0xFF111111),
),
fontFamily: 'Inter',
useMaterial3: true,

View File

@ -0,0 +1,91 @@
import 'dart:convert';
/// A saved server profile. Field shape mirrors the desktop GUI's profile
/// object (ostp-gui/src/main.js) 1:1 server/key/transport/tcp_fragmentation/
/// frag_chunk/frag_sleep/junk_pc/junk_ps so behavior matches across
/// platforms. `wss` was dropped: the core no longer supports TLS-mimicry
/// transports (only plain UDP / UoT), so there is nothing left to carry it.
class OstpProfile {
String id;
String name;
String serverAddr;
String accessKey;
String transportMode; // 'udp' | 'uot'
bool active;
// Junk packets + TCP fragmentation per-profile, exactly like ostp-gui's
// profile editor. Defaults match ostp_client::config::TransportConfig's
// own defaults (frag_chunk=2, frag_sleep=2, junk_pc=[2,5], junk_ps=[100,1000]).
bool tcpFragmentation;
int fragChunk;
int fragSleep;
int junkPcMin;
int junkPcMax;
int junkPsMin;
int junkPsMax;
OstpProfile({
required this.id,
required this.name,
required this.serverAddr,
required this.accessKey,
this.transportMode = 'udp',
this.active = false,
this.tcpFragmentation = false,
this.fragChunk = 2,
this.fragSleep = 2,
this.junkPcMin = 2,
this.junkPcMax = 5,
this.junkPsMin = 100,
this.junkPsMax = 1000,
});
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'serverAddr': serverAddr,
'accessKey': accessKey,
'transportMode': transportMode,
'active': active,
'tcpFragmentation': tcpFragmentation,
'fragChunk': fragChunk,
'fragSleep': fragSleep,
'junkPcMin': junkPcMin,
'junkPcMax': junkPcMax,
'junkPsMin': junkPsMin,
'junkPsMax': junkPsMax,
};
}
factory OstpProfile.fromJson(Map<String, dynamic> json) {
return OstpProfile(
id: json['id'] as String? ?? '',
name: json['name'] as String? ?? 'Unnamed Profile',
serverAddr: json['serverAddr'] as String? ?? '',
accessKey: json['accessKey'] as String? ?? '',
transportMode: json['transportMode'] as String? ?? 'udp',
active: json['active'] as bool? ?? false,
tcpFragmentation: json['tcpFragmentation'] as bool? ?? false,
fragChunk: json['fragChunk'] as int? ?? 2,
fragSleep: json['fragSleep'] as int? ?? 2,
junkPcMin: json['junkPcMin'] as int? ?? 2,
junkPcMax: json['junkPcMax'] as int? ?? 5,
junkPsMin: json['junkPsMin'] as int? ?? 100,
junkPsMax: json['junkPsMax'] as int? ?? 1000,
);
}
}
List<OstpProfile> decodeProfiles(String? json) {
if (json == null || json.isEmpty) return [];
try {
final List<dynamic> decoded = jsonDecode(json);
return decoded.map((e) => OstpProfile.fromJson(e)).toList();
} catch (_) {
return [];
}
}
String encodeProfiles(List<OstpProfile> profiles) =>
jsonEncode(profiles.map((e) => e.toJson()).toList());

View File

@ -15,6 +15,13 @@ class AppRoutingScreen extends StatefulWidget {
State<AppRoutingScreen> createState() => _AppRoutingScreenState();
}
/// Picks readable black/white text for a given (opaque) background color.
/// The monochrome theme's `primary` is pure white — hardcoded white text on
/// top of it was invisible; this picks the contrasting color instead.
Color _onColor(Color bg) {
return ThemeData.estimateBrightnessForColor(bg) == Brightness.light ? Colors.black : Colors.white;
}
class _AppRoutingScreenState extends State<AppRoutingScreen> {
static const platform = MethodChannel('com.ospab.ostp/vpn');
@ -154,10 +161,13 @@ class _AppRoutingScreenState extends State<AppRoutingScreen> {
color: _routingMode == 'bypass' ? theme.colorScheme.primary : Colors.white.withOpacity(0.1),
),
),
child: const Center(
child: Center(
child: Text(
'Bypass Mode',
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white),
style: TextStyle(
fontWeight: FontWeight.bold,
color: _routingMode == 'bypass' ? _onColor(theme.colorScheme.primary) : Colors.white70,
),
),
),
),
@ -181,10 +191,13 @@ class _AppRoutingScreenState extends State<AppRoutingScreen> {
color: _routingMode == 'proxy' ? theme.colorScheme.secondary : Colors.white.withOpacity(0.1),
),
),
child: const Center(
child: Center(
child: Text(
'Proxy Mode',
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white),
style: TextStyle(
fontWeight: FontWeight.bold,
color: _routingMode == 'proxy' ? _onColor(theme.colorScheme.secondary) : Colors.white70,
),
),
),
),

View File

@ -1,16 +1,19 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import '../models/connection_state_enum.dart';
import '../models/ostp_profile.dart';
import 'settings_screen.dart';
import 'logs_screen.dart';
import 'app_routing_screen.dart';
import 'qr_scanner_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;
@ -22,23 +25,34 @@ class HomeScreen extends StatefulWidget {
class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
static const platform = MethodChannel('com.ospab.ostp/vpn');
ConnectionStateEnum _state = ConnectionStateEnum.disconnected;
Timer? _pollTimer;
Timer? _uptimeTimer;
int _uptimeSecs = 0;
String _serverAddr = '127.0.0.1:443';
String _accessKey = 'default_key';
// Single active profile the core only ever connects to one server at a
// time (no multi-server/urltest failover since the 0.4.x flat config),
// matching how the desktop GUI picks exactly one profile as `activeId`.
OstpProfile? _activeProfile;
String _download = '0 B';
String _upload = '0 B';
// Live throughput (bytes/sec, computed from deltas between polls) and RTT
// are optional, same as the desktop GUI's "Show Speed" / "Show RTT" toggles
// in client settings default on, persisted in prefs.
bool _showSpeed = true;
bool _showRtt = true;
String _downSpeed = '0 B/s';
String _upSpeed = '0 B/s';
int _prevBytesRecv = 0;
int _prevBytesSent = 0;
late AnimationController _pulseController;
late AnimationController _spinController;
bool _isCheckingPing = false;
String _pingText = 'Target Ping: -- ms';
String _pingText = '-- ms';
Color _pingColor = Colors.white54;
@override
@ -67,40 +81,46 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
debugPrint("Failed to check initial state: $e");
}
}
void _loadSettings() {
setState(() {
_serverAddr = widget.prefs.getString('server_addr') ?? '127.0.0.1:443';
_accessKey = widget.prefs.getString('access_key') ?? '';
final profiles = decodeProfiles(widget.prefs.getString('profiles_json'));
// Single-select: if more than one is somehow marked active (shouldn't
// happen the editor enforces exclusivity but don't crash on stale data).
final actives = profiles.where((p) => p.active).toList();
_activeProfile = actives.isNotEmpty ? actives.first : null;
_showSpeed = widget.prefs.getBool('show_speed') ?? true;
_showRtt = widget.prefs.getBool('show_rtt') ?? true;
});
_updateLatestConfigJson();
}
void _updateLatestConfigJson() {
/// Builds the exact JSON the native core (ostp-jni) deserializes as
/// `ostp_client::config::ClientConfig`. Field names/nesting must match that
/// struct precisely unknown keys are silently ignored by serde, so a typo
/// here doesn't fail loudly, it just quietly does nothing.
Map<String, dynamic> _buildConfigMap() {
final p = _activeProfile;
final exDomains = widget.prefs.getString('ex_domains') ?? '';
final exIps = widget.prefs.getString('ex_ips') ?? '';
final exProcesses = widget.prefs.getString('ex_processes') ?? '';
final debugMode = widget.prefs.getBool('debug_mode') ?? false;
final transportMode = widget.prefs.getString('transport_mode') ?? 'udp';
final stealthSni = widget.prefs.getString('stealth_sni') ?? 'vk.com';
final mtu = widget.prefs.getString('mtu') ?? '1140';
final muxEnabled = widget.prefs.getBool('mux_enabled') ?? false;
final muxSessions = widget.prefs.getString('mux_sessions') ?? '2';
final dnsServer = widget.prefs.getString('dns_server');
final effectiveDnsServer = (dnsServer == null || dnsServer.isEmpty) ? '1.1.1.1' : dnsServer;
final tunStack = 'ostp';
const tunStack = 'ostp';
final appRoutingMode = widget.prefs.getString('app_routing_mode') ?? 'bypass';
final appRoutingPackages = widget.prefs.getStringList('app_routing_packages') ?? [];
final localBind = widget.prefs.getString('local_bind') ?? '127.0.0.1:1088';
final configMap = {
return {
"mode": "client",
"debug": debugMode,
"ostp": {
"server_addr": _serverAddr,
"server_addr": p?.serverAddr ?? '',
"local_bind_addr": "0.0.0.0:0",
"access_key": _accessKey,
"access_key": p?.accessKey ?? '',
"handshake_timeout_ms": 10000,
"io_timeout_ms": 5000,
"mtu": int.tryParse(mtu) ?? 1140,
@ -109,34 +129,40 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
"bind_addr": localBind,
"connect_timeout_ms": 15000,
},
// Junk packets + TCP fragmentation are per-profile settings same
// shape as the desktop GUI's profile object — not global toggles.
"transport": {
"mode": transportMode,
"stealth_sni": stealthSni,
"mode": p?.transportMode ?? 'udp',
"tcp_fragmentation": p?.tcpFragmentation ?? false,
"frag_chunk": p?.fragChunk ?? 2,
"frag_sleep": p?.fragSleep ?? 2,
"junk_pc": [p?.junkPcMin ?? 2, p?.junkPcMax ?? 5],
"junk_ps": [p?.junkPsMin ?? 100, p?.junkPsMax ?? 1000],
},
"multiplex": {
"enabled": muxEnabled,
"sessions": int.tryParse(muxSessions) ?? 2,
},
"tun": {
"enable": true,
"stack": tunStack
},
"exclusions": {
"domains": exDomains.split('\n').where((s) => s.trim().isNotEmpty).toList(),
"ips": exIps.split('\n').where((s) => s.trim().isNotEmpty).toList(),
"processes": exProcesses.split('\n').where((s) => s.trim().isNotEmpty).toList(),
// No per-process exclusion field on mobile Android's per-app
// selection (app_rules below) is the equivalent, and correct, control.
"processes": const [],
},
"app_rules": {
"mode": appRoutingMode,
"packages": appRoutingPackages,
},
"dns_server": effectiveDnsServer,
"tun_stack": tunStack
"tun_stack": tunStack,
};
}
void _updateLatestConfigJson() {
final configMap = _buildConfigMap();
widget.prefs.setString('latest_config_json', jsonEncode(configMap));
platform.invokeMethod('saveConfig', {
"configJson": jsonEncode(configMap)
});
platform.invokeMethod('saveConfig', {"configJson": jsonEncode(configMap)});
}
@override
@ -150,87 +176,27 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
Future<void> _toggleConnection() async {
if (_state == ConnectionStateEnum.disconnected) {
if (_serverAddr.isEmpty || _accessKey.isEmpty) {
if (_activeProfile == null || _activeProfile!.serverAddr.isEmpty || _activeProfile!.accessKey.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please configure Server and Key in Settings')),
const SnackBar(content: Text('Please select or add a profile in Settings')),
);
return;
}
setState(() {
_state = ConnectionStateEnum.connecting;
});
_pulseController.repeat(reverse: true);
_spinController.repeat();
final dnsServer = widget.prefs.getString('dns_server');
final effectiveDnsServer = (dnsServer == null || dnsServer.isEmpty) ? '1.1.1.1' : dnsServer;
final exDomains = widget.prefs.getString('ex_domains') ?? '';
final exIps = widget.prefs.getString('ex_ips') ?? '';
final exProcesses = widget.prefs.getString('ex_processes') ?? '';
final debugMode = widget.prefs.getBool('debug_mode') ?? false;
final transportMode = widget.prefs.getString('transport_mode') ?? 'udp';
final stealthSni = widget.prefs.getString('stealth_sni') ?? 'vk.com';
final mtu = widget.prefs.getString('mtu') ?? '1140';
final muxEnabled = widget.prefs.getBool('mux_enabled') ?? false;
final muxSessions = widget.prefs.getString('mux_sessions') ?? '2';
final tunStack = 'ostp';
final appRoutingMode = widget.prefs.getString('app_routing_mode') ?? 'bypass';
final appRoutingPackages = widget.prefs.getStringList('app_routing_packages') ?? [];
final localBind = widget.prefs.getString('local_bind') ?? '127.0.0.1:1088';
final configMap = {
"mode": "client",
"debug": debugMode,
"ostp": {
"server_addr": _serverAddr,
"local_bind_addr": "0.0.0.0:0",
"access_key": _accessKey,
"handshake_timeout_ms": 10000,
"io_timeout_ms": 5000,
"mtu": int.tryParse(mtu) ?? 1140,
},
"local_proxy": {
"bind_addr": localBind,
"connect_timeout_ms": 15000,
},
"transport": {
"mode": transportMode,
"stealth_sni": stealthSni,
},
"multiplex": {
"enabled": muxEnabled,
"sessions": int.tryParse(muxSessions) ?? 2,
},
"tun": {
"enable": true,
"stack": tunStack
},
"exclusions": {
"domains": exDomains.split('\n').where((s) => s.trim().isNotEmpty).toList(),
"ips": exIps.split('\n').where((s) => s.trim().isNotEmpty).toList(),
"processes": exProcesses.split('\n').where((s) => s.trim().isNotEmpty).toList(),
},
"app_rules": {
"mode": appRoutingMode,
"packages": appRoutingPackages,
},
"dns_server": dnsServer,
"tun_stack": tunStack
};
widget.prefs.setString('latest_config_json', jsonEncode(configMap));
final configMap = _buildConfigMap();
final configStr = jsonEncode(configMap);
widget.prefs.setString('latest_config_json', configStr);
try {
await platform.invokeMethod('saveConfig', {
"configJson": jsonEncode(configMap)
});
await platform.invokeMethod('startTunnel', {
"configJson": jsonEncode(configMap)
});
await platform.invokeMethod('saveConfig', {"configJson": configStr});
await platform.invokeMethod('startTunnel', {"configJson": configStr});
bool started = false;
for (int i = 0; i < 10; i++) {
await Future.delayed(const Duration(milliseconds: 500));
@ -240,7 +206,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
break;
}
}
if (started) {
_setConnected();
} else {
@ -289,30 +255,34 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
}
}
/// Cycles transport mode x MTU to find a working combination against the
/// active profile's server. WSS/Reality are gone (the core dropped
/// TLS-mimicry transports entirely see §A), so this only has udp/uot x
/// MTU left to probe; junk/frag stay at whatever the active profile has set.
Future<void> _runAutoMode() async {
final mtus = [1500, 1350, 1280, 1140];
final modes = [
{'t': 'udp'},
{'t': 'uot'},
];
final modes = ['udp', 'uot'];
if (_serverAddr.isEmpty || _accessKey.isEmpty) {
final active = _activeProfile;
if (active == null || active.serverAddr.isEmpty || active.accessKey.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please configure Server and Key first')),
const SnackBar(content: Text('Please select a profile with a server and key first')),
);
return;
}
for (var mode in modes) {
for (var mtu in mtus) {
final originalMode = active.transportMode;
final originalMtu = widget.prefs.getString('mtu') ?? '1140';
for (final mode in modes) {
for (final mtu in mtus) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Testing: ${mode['t']} | MTU: $mtu'), duration: const Duration(seconds: 2)),
SnackBar(content: Text('Testing: $mode | MTU: $mtu'), duration: const Duration(seconds: 2)),
);
// Update prefs
await widget.prefs.setString('mtu', mtu.toString());
await widget.prefs.setString('transport_mode', mode['t'] as String);
active.transportMode = mode;
_updateLatestConfigJson();
setState(() {
@ -337,7 +307,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
if (started) {
_setConnected();
// Wait to see if connection is stable and ping is successful
await Future.delayed(const Duration(seconds: 3));
try {
final metricsJson = await platform.invokeMethod('getMetrics');
@ -345,30 +314,37 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
final Map<String, dynamic> parsed = jsonDecode(metricsJson);
final rttMs = parsed['rtt_ms'] as int? ?? 0;
if (rttMs > 0) {
// Working combo found persist it onto the profile.
_persistActiveProfile();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Success! Found working config: ${mode['t']} (MTU $mtu)')),
SnackBar(content: Text('Success! Found working config: $mode (MTU $mtu)')),
);
}
return; // Stop on first working config
return;
}
}
} catch (e) {
// Ignore metrics error
} catch (_) {
// Ignore metrics error, fall through to try next combo.
}
// Connection seems unstable or no ping, stop and try next
await platform.invokeMethod('stopTunnel');
_setDisconnected();
} else {
_setDisconnected();
}
} catch (e) {
} catch (_) {
_setDisconnected();
}
}
}
// No working combo found revert the active profile/mtu to what they
// were before probing so we don't leave it on a broken guess.
active.transportMode = originalMode;
await widget.prefs.setString('mtu', originalMtu);
_updateLatestConfigJson();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Auto search finished. No working config found.')),
@ -376,14 +352,25 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
}
}
void _persistActiveProfile() {
final active = _activeProfile;
if (active == null) return;
final profiles = decodeProfiles(widget.prefs.getString('profiles_json'));
final idx = profiles.indexWhere((p) => p.id == active.id);
if (idx >= 0) {
profiles[idx] = active;
widget.prefs.setString('profiles_json', encodeProfiles(profiles));
}
}
void _setConnected() {
if (!mounted) return;
setState(() {
_state = ConnectionStateEnum.connected;
});
_pulseController.stop();
_pulseController.value = 1.0;
_pulseController.value = 1.0;
_uptimeSecs = 0;
_uptimeTimer?.cancel();
_uptimeTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
@ -398,7 +385,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
if (!mounted) return;
try {
final isRunning = await platform.invokeMethod('isRunning');
if (isRunning == true && _state == ConnectionStateEnum.disconnected) {
_setConnected();
} else if (isRunning == false && _state == ConnectionStateEnum.connected) {
@ -413,7 +400,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
final bytesRecv = parsed['bytes_recv'] as int? ?? 0;
final connState = parsed['connection_state'] as int? ?? 2;
final rttMs = parsed['rtt_ms'] as int? ?? 0;
if (connState == 0) {
try {
await platform.invokeMethod('stopTunnel');
@ -428,13 +415,19 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
}
return;
}
if (mounted) {
setState(() {
_download = _formatBytes(bytesRecv);
_upload = _formatBytes(bytesSent);
if (rttMs > 0 && !_isCheckingPing) {
_pingText = 'Server Ping: $rttMs ms';
final dRecv = bytesRecv > _prevBytesRecv ? bytesRecv - _prevBytesRecv : 0;
final dSent = bytesSent > _prevBytesSent ? bytesSent - _prevBytesSent : 0;
_prevBytesRecv = bytesRecv;
_prevBytesSent = bytesSent;
_downSpeed = '${_formatBytes(dRecv)}/s';
_upSpeed = '${_formatBytes(dSent)}/s';
if (rttMs > 0) {
_pingText = '$rttMs ms';
if (rttMs < 100) {
_pingColor = const Color(0xFF22D3A5);
} else if (rttMs < 250) {
@ -460,33 +453,18 @@ 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;
});
await Future.delayed(const Duration(milliseconds: 500));
if (mounted) {
setState(() {
_isCheckingPing = false;
});
}
}
void _setDisconnected() {
if (!mounted) return;
setState(() {
_state = ConnectionStateEnum.disconnected;
_download = '0 B';
_upload = '0 B';
_pingText = 'Target Ping: -- ms';
_downSpeed = '0 B/s';
_upSpeed = '0 B/s';
_prevBytesRecv = 0;
_prevBytesSent = 0;
_pingText = '-- ms';
_pingColor = Colors.white54;
_isCheckingPing = false;
});
_pulseController.stop();
_pulseController.value = 0.0;
@ -506,39 +484,25 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
body: Stack(
children: [
Positioned(
top: -150, right: -100,
child: Container(
width: 400, height: 400,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: theme.colorScheme.primary.withOpacity(0.15),
),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 100, sigmaY: 100),
child: Container(),
Positioned.fill(
child: Opacity(
opacity: 0.1,
child: Center(
child: Image.asset(
'assets/logo.png',
width: MediaQuery.of(context).size.shortestSide * 0.6,
// No color tint needed the asset now carries real alpha
// (background pixels' luminance was baked into alpha, see
// git history), so it's already a pure white silhouette.
),
),
),
),
Positioned(
bottom: -100, left: -100,
child: Container(
width: 350, height: 350,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: theme.colorScheme.secondary.withOpacity(0.1),
),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 100, sigmaY: 100),
child: Container(),
),
),
),
SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
@ -577,13 +541,13 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
width: 12, height: 12,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: _state == ConnectionStateEnum.connected
? theme.colorScheme.secondary
color: _state == ConnectionStateEnum.connected
? kConnectedGreen
: theme.colorScheme.primary,
boxShadow: [
BoxShadow(
color: _state == ConnectionStateEnum.connected
? theme.colorScheme.secondary.withOpacity(0.5)
color: _state == ConnectionStateEnum.connected
? 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;
}
@ -677,7 +641,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
),
),
),
AnimatedBuilder(
animation: _pulseController,
builder: (context, child) {
@ -722,9 +686,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
],
),
),
const SizedBox(height: 40),
Text(
_state == ConnectionStateEnum.disconnected ? 'Disconnected' :
_state == ConnectionStateEnum.connecting ? 'Connecting...' : 'Connected',
@ -742,105 +706,64 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
color: Colors.white54,
),
),
const SizedBox(height: 30),
AnimatedOpacity(
opacity: _state == ConnectionStateEnum.connected ? 1.0 : 0.0,
duration: const Duration(milliseconds: 300),
child: Column(
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.08),
borderRadius: BorderRadius.circular(30),
border: Border.all(color: Colors.white.withOpacity(0.15)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.08),
borderRadius: BorderRadius.circular(30),
border: Border.all(color: Colors.white.withOpacity(0.15)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.dns_rounded, size: 18, color: Colors.white70),
const SizedBox(width: 10),
Text(
_serverAddr,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 15,
fontWeight: FontWeight.w600,
color: Colors.white70,
),
),
],
),
),
const SizedBox(height: 16),
Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.03),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white.withOpacity(0.06)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
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),
Text(
_pingText,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: _pingColor,
),
),
],
),
),
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)),
),
),
],
const Icon(Icons.dns_rounded, size: 18, color: Colors.white70),
const SizedBox(width: 10),
Text(
_activeProfile?.name ?? 'No profile selected',
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 15,
fontWeight: FontWeight.w600,
color: Colors.white70,
),
),
],
),
),
if (_showRtt)
AnimatedOpacity(
opacity: _state == ConnectionStateEnum.connected ? 1.0 : 0.0,
duration: const Duration(milliseconds: 300),
child: Padding(
padding: const EdgeInsets.only(top: 10),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.03),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white.withOpacity(0.06)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.speed_rounded, size: 13, color: _pingColor),
const SizedBox(width: 6),
Text(
_pingText,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: _pingColor,
),
),
],
),
),
),
)
],
);
@ -856,15 +779,15 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildMetricItem(Icons.arrow_downward_rounded, 'Download', _download, theme.colorScheme.secondary),
_buildMetricItem(Icons.arrow_downward_rounded, 'Download', _download, theme.colorScheme.secondary, _showSpeed ? _downSpeed : null),
Container(width: 1, height: 40, color: Colors.white.withOpacity(0.15)),
_buildMetricItem(Icons.arrow_upward_rounded, 'Upload', _upload, theme.colorScheme.primary),
_buildMetricItem(Icons.arrow_upward_rounded, 'Upload', _upload, theme.colorScheme.primary, _showSpeed ? _upSpeed : null),
],
),
);
}
Widget _buildMetricItem(IconData icon, String label, String value, Color color) {
Widget _buildMetricItem(IconData icon, String label, String value, Color color, [String? speed]) {
return Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
@ -903,6 +826,19 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
color: Colors.white,
),
),
if (speed != null) ...[
const SizedBox(height: 2),
Text(
speed,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 12,
fontWeight: FontWeight.w600,
color: color,
),
),
],
],
),
)
@ -911,4 +847,3 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
);
}
}

File diff suppressed because it is too large Load Diff

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.2.97+12
version: 0.4.4+31
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
@ -72,9 +72,8 @@ flutter:
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
assets:
- assets/logo.png
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images

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