Compare commits

...

48 Commits

Author SHA1 Message Date
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 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 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 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
69 changed files with 3947 additions and 2493 deletions

View File

@ -1,39 +1,107 @@
name: CI/CD name: CI/CD
# `run-name` is evaluated at workflow-start, BEFORE any job runs — it cannot
# see resolve-channel's computed tag_name (e.g. "0.4.3-nightly"), only the # `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 # `github.*` context. The old "release version ${{ github.ref_name }}" showed
# the bare branch name ("nightly"/"pre-release") for every run, which reads # the bare branch name ("alpha"/"pre-release") for every run, which reads
# exactly like a literal release tag and caused real confusion the actual # exactly like a literal release tag and caused real confusion - the actual
# release tag has been correct (versioned) all along; only this label lied # 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. # 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 # NOTE: this value MUST be quoted. The GHA string literal below contains
# "Release build: {0}" an unquoted YAML plain scalar treats ": " (colon # "Release build: {0}" - an unquoted YAML plain scalar treats ": " (colon
# then space) as starting a nested mapping, which is exactly what broke every # then space) as starting a nested mapping, which is exactly what broke every
# single push since this line was introduced: GitHub rejected the whole # single push since this line was introduced: GitHub rejected the whole
# workflow file at parse time (before any job runs), silently burning an # workflow file at parse time (before any job runs), silently burning an
# Actions-minutes-billed run per push for nothing. # 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, 'nightly') && format('CI/CD: nightly version {0}', github.ref_name) || format('CI/CD: release version {0}', github.ref_name)) || format('CI/CD: {0} channel build', github.ref_name) }}" 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: on:
push: push:
tags: tags:
- "v*" - "v*"
branches:
- nightly
- pre-release
workflow_dispatch: 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: permissions:
contents: write contents: write
# ── Global defaults ───────────────────────────────────────────────────────── # -- Global defaults ---------------------------------------------------------
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0 CARGO_INCREMENTAL: 0
RUST_BACKTRACE: short RUST_BACKTRACE: short
jobs: 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 `pre-release` -> "{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 }}" = "pre-release" ]; 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: check-and-test:
name: Check & Test name: Check & Test
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -71,13 +139,13 @@ jobs:
publish-release-matrix: publish-release-matrix:
name: Release for ${{ matrix.target }} name: Release for ${{ matrix.target }}
needs: check-and-test needs: [check-and-test, resolve-channel]
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
# ── Windows ────────────────────────────────────────────────────── # -- Windows ------------------------------------------------------
- os: windows-latest - os: windows-latest
target: x86_64-pc-windows-msvc target: x86_64-pc-windows-msvc
artifact_name: ostp.exe artifact_name: ostp.exe
@ -96,7 +164,7 @@ jobs:
release_name: ostp-windows-arm64.zip release_name: ostp-windows-arm64.zip
wintun_arch: arm64 wintun_arch: arm64
# ── macOS ───────────────────────────────────────────────────────── # -- macOS ---------------------------------------------------------
- os: macos-latest - os: macos-latest
target: x86_64-apple-darwin target: x86_64-apple-darwin
artifact_name: ostp artifact_name: ostp
@ -107,7 +175,7 @@ jobs:
artifact_name: ostp artifact_name: ostp
release_name: ostp-darwin-arm64.tar.gz release_name: ostp-darwin-arm64.tar.gz
# ── Linux native ────────────────────────────────────────────────── # -- Linux native --------------------------------------------------
- os: ubuntu-latest - os: ubuntu-latest
target: x86_64-unknown-linux-musl target: x86_64-unknown-linux-musl
artifact_name: ostp artifact_name: ostp
@ -119,7 +187,7 @@ jobs:
release_name: ostp-linux-386.tar.gz release_name: ostp-linux-386.tar.gz
use_cross: true use_cross: true
# ── Linux cross ─────────────────────────────────────────────────── # -- Linux cross ---------------------------------------------------
- os: ubuntu-latest - os: ubuntu-latest
target: aarch64-unknown-linux-musl target: aarch64-unknown-linux-musl
artifact_name: ostp artifact_name: ostp
@ -157,7 +225,7 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
# ── Frontend Build ───────────────────────────────────────────────────── # -- Frontend Build -----------------------------------------------------
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
@ -170,18 +238,18 @@ jobs:
if [ -f package.json ]; then if [ -f package.json ]; then
npm install && npm run build npm install && npm run build
else 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 [ -f dist/index.html ] || echo '<!doctype html><title>OSTP</title>' > dist/index.html
fi fi
# ── Rust toolchain ───────────────────────────────────────────────────── # -- Rust toolchain -----------------------------------------------------
- name: Setup Rust toolchain - name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
with: with:
toolchain: ${{ matrix.toolchain || 'stable' }} toolchain: ${{ matrix.toolchain || 'stable' }}
targets: ${{ !matrix.use_cross && matrix.target || '' }} targets: ${{ !matrix.use_cross && matrix.target || '' }}
# ── Cargo cache (shared per target) ─────────────────────────────────── # -- Cargo cache (shared per target) -----------------------------------
- name: Restore Cargo cache - name: Restore Cargo cache
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@ -194,18 +262,18 @@ jobs:
restore-keys: | restore-keys: |
cargo-${{ matrix.target }}- cargo-${{ matrix.target }}-
# ── MUSL tools for native Linux musl builds ──────────────────────────── # -- MUSL tools for native Linux musl builds ----------------------------
- name: Install musl-tools - name: Install musl-tools
if: ${{ matrix.os == 'ubuntu-latest' && !matrix.use_cross }} if: ${{ matrix.os == 'ubuntu-latest' && !matrix.use_cross }}
run: sudo apt-get update && sudo apt-get install -y musl-tools run: sudo apt-get update && sudo apt-get install -y musl-tools
# ── Native build ─────────────────────────────────────────────────────── # -- Native build -------------------------------------------------------
- name: Build (native) - name: Build (native)
if: ${{ !matrix.use_cross }} if: ${{ !matrix.use_cross }}
shell: bash shell: bash
run: cargo build --release --target ${{ matrix.target }} --bin ostp run: cargo build --release --target ${{ matrix.target }} --bin ostp
# ── Cross build ──────────────────────────────────────────────────────── # -- Cross build --------------------------------------------------------
- name: Restore cross binary cache - name: Restore cross binary cache
if: ${{ matrix.use_cross }} if: ${{ matrix.use_cross }}
id: cross-cache id: cross-cache
@ -222,7 +290,7 @@ jobs:
if: ${{ matrix.use_cross }} if: ${{ matrix.use_cross }}
run: cross build --release --target ${{ matrix.target }} --bin ostp run: cross build --release --target ${{ matrix.target }} --bin ostp
# ── Driver dependencies ──────────────────────────────────────────────── # -- Driver dependencies ------------------------------------------------
- name: Download wintun (Windows) - name: Download wintun (Windows)
if: ${{ matrix.os == 'windows-latest' }} if: ${{ matrix.os == 'windows-latest' }}
shell: pwsh shell: pwsh
@ -234,7 +302,7 @@ jobs:
Get-ChildItem "$dir/wt_tmp" -Filter "wintun.dll" -Recurse | Where-Object { $_.FullName -match 'bin[\\/]${{ matrix.wintun_arch }}[\\/]' } | Copy-Item -Destination "$dir/" 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 Remove-Item "$dir/wt.zip","$dir/wt_tmp" -Recurse -Force
# ── Package ──────────────────────────────────────────────────────────── # -- Package ------------------------------------------------------------
- name: Package (Windows) - name: Package (Windows)
if: ${{ matrix.os == 'windows-latest' }} if: ${{ matrix.os == 'windows-latest' }}
shell: pwsh shell: pwsh
@ -253,26 +321,23 @@ jobs:
FILES="${{ matrix.artifact_name }}" FILES="${{ matrix.artifact_name }}"
tar -czf "${{ matrix.release_name }}" -C "$dir" $FILES tar -czf "${{ matrix.release_name }}" -C "$dir" $FILES
# ── Upload ───────────────────────────────────────────────────────────── # -- Upload -------------------------------------------------------------
- name: Upload to GitHub Release - name: Upload to GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
# Version tags (v0.4.1, v0.4.1-beta.N) use their own name as the # Computed once in resolve-channel so every platform/job in this run
# release; branch pushes (nightly/pre-release) roll a release named # lands on the exact same tag: "{version}-alpha" / "{version}-beta"
# after the branch itself — no name remapping needed since # for rolling channel pushes, or the pushed "vX.Y.Z" tag as-is for a
# github.ref_name is already the tag OR the branch name as-is. # real stable release.
tag_name: ${{ github.ref_name }} tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
# Any branch push is a rolling prerelease; for real version tags, prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
# 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, '-') }}
files: ${{ matrix.release_name }} files: ${{ matrix.release_name }}
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-windows-gui: build-windows-gui:
name: Build Windows GUI (Tauri) - ${{ matrix.arch }} name: Build Windows GUI (Tauri) - ${{ matrix.arch }}
needs: check-and-test needs: [check-and-test, resolve-channel]
runs-on: windows-latest runs-on: windows-latest
strategy: strategy:
matrix: matrix:
@ -339,22 +404,19 @@ jobs:
- name: Upload to GitHub Release - name: Upload to GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
# Version tags (v0.4.1, v0.4.1-beta.N) use their own name as the # Computed once in resolve-channel so every platform/job in this run
# release; branch pushes (nightly/pre-release) roll a release named # lands on the exact same tag: "{version}-alpha" / "{version}-beta"
# after the branch itself — no name remapping needed since # for rolling channel pushes, or the pushed "vX.Y.Z" tag as-is for a
# github.ref_name is already the tag OR the branch name as-is. # real stable release.
tag_name: ${{ github.ref_name }} tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
# Any branch push is a rolling prerelease; for real version tags, prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
# 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, '-') }}
files: ostp-windows-gui-${{ matrix.arch }}.zip files: ostp-windows-gui-${{ matrix.arch }}.zip
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-linux-gui: build-linux-gui:
name: Build Linux GUI (Tauri) - ${{ matrix.arch }} name: Build Linux GUI (Tauri) - ${{ matrix.arch }}
needs: check-and-test needs: [check-and-test, resolve-channel]
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
@ -407,22 +469,19 @@ jobs:
- name: Upload to GitHub Release - name: Upload to GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
# Version tags (v0.4.1, v0.4.1-beta.N) use their own name as the # Computed once in resolve-channel so every platform/job in this run
# release; branch pushes (nightly/pre-release) roll a release named # lands on the exact same tag: "{version}-alpha" / "{version}-beta"
# after the branch itself — no name remapping needed since # for rolling channel pushes, or the pushed "vX.Y.Z" tag as-is for a
# github.ref_name is already the tag OR the branch name as-is. # real stable release.
tag_name: ${{ github.ref_name }} tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
# Any branch push is a rolling prerelease; for real version tags, prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
# 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, '-') }}
files: ostp-linux-gui-${{ matrix.arch }}.tar.gz files: ostp-linux-gui-${{ matrix.arch }}.tar.gz
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-macos-gui: build-macos-gui:
name: Build macOS GUI (Tauri) - ${{ matrix.arch }} name: Build macOS GUI (Tauri) - ${{ matrix.arch }}
needs: check-and-test needs: [check-and-test, resolve-channel]
runs-on: macos-latest runs-on: macos-latest
strategy: strategy:
matrix: matrix:
@ -472,22 +531,19 @@ jobs:
- name: Upload to GitHub Release - name: Upload to GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
# Version tags (v0.4.1, v0.4.1-beta.N) use their own name as the # Computed once in resolve-channel so every platform/job in this run
# release; branch pushes (nightly/pre-release) roll a release named # lands on the exact same tag: "{version}-alpha" / "{version}-beta"
# after the branch itself — no name remapping needed since # for rolling channel pushes, or the pushed "vX.Y.Z" tag as-is for a
# github.ref_name is already the tag OR the branch name as-is. # real stable release.
tag_name: ${{ github.ref_name }} tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
# Any branch push is a rolling prerelease; for real version tags, prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
# 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, '-') }}
files: ostp-macos-gui-${{ matrix.arch }}.tar.gz files: ostp-macos-gui-${{ matrix.arch }}.tar.gz
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-android: build-android:
name: Build Android Client (Flutter) - ${{ matrix.arch }} name: Build Android Client (Flutter) - ${{ matrix.arch }}
needs: check-and-test needs: [check-and-test, resolve-channel]
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
@ -548,15 +604,12 @@ jobs:
- name: Upload to GitHub Release - name: Upload to GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
# Version tags (v0.4.1, v0.4.1-beta.N) use their own name as the # Computed once in resolve-channel so every platform/job in this run
# release; branch pushes (nightly/pre-release) roll a release named # lands on the exact same tag: "{version}-alpha" / "{version}-beta"
# after the branch itself — no name remapping needed since # for rolling channel pushes, or the pushed "vX.Y.Z" tag as-is for a
# github.ref_name is already the tag OR the branch name as-is. # real stable release.
tag_name: ${{ github.ref_name }} tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
# Any branch push is a rolling prerelease; for real version tags, prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
# 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, '-') }}
files: ostp-flutter/ostp-android-${{ matrix.arch }}.apk files: ostp-flutter/ostp-android-${{ matrix.arch }}.apk
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

8
.gitignore vendored
View File

@ -5,6 +5,7 @@
**/*.rs.bk **/*.rs.bk
.idea/ .idea/
.vscode/ .vscode/
**/node_modules/
# Binaries & libraries # Binaries & libraries
*.exe *.exe
@ -25,6 +26,10 @@ test_route.ps1
config.json config.json
wintun.dll wintun.dll
# 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 # Logs
*.log *.log
@ -39,3 +44,6 @@ ostp-brain/
# Management panel built assets (built separately; dummy dist created for rust-embed build) # Management panel built assets (built separately; dummy dist created for rust-embed build)
ostp-control/ ostp-control/
.agents/
netstack-smoltcp/

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.1",
"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) 1. [Development Setup](#development-setup)
2. [Project Structure](#project-structure) 2. [Project Structure](#project-structure)
3. [Development Workflow](#development-workflow) 3. [Branch Strategy](#branch-strategy)
4. [Coding Guidelines](#coding-guidelines) 4. [Development Workflow](#development-workflow)
5. [Submitting Pull Requests](#submitting-pull-requests) 5. [Commit Message Conventions](#commit-message-conventions)
6. [Security Vulnerabilities](#security-vulnerabilities) 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 cd ostp
``` ```
2. **Build the control panel frontend**: 2. **Build the entire Cargo workspace**:
```bash
cd ostp-control
npm install
npm run build
cd ..
```
3. **Build the entire Cargo workspace**:
```bash ```bash
cargo build 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 ```bash
cargo test --workspace 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. |
| `pre-release` | Periodically fast-forwarded from `alpha` once it's had some soak time. Ships as the `{version}-beta` release channel. |
| `master` | Fast-forwarded from `pre-release` when it's proven stable. Real, tagged releases (`vX.Y.Z`) are cut from here. |
`pre-release` and `master` are **never** committed to directly - they only ever move forward by fast-forwarding from the branch below them. This means promotion is always a plain `git merge` with zero conflicts by construction: don't `git merge`/rebase feature work directly onto `pre-release` or `master`.
**Contributor PRs target `alpha`**, not `master`.
---
## Development Workflow ## Development Workflow
1. **Check for existing issues** or open a new one to discuss proposed changes before starting work. 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 ```bash
git checkout alpha
git checkout -b feat/your-feature-name git checkout -b feat/your-feature-name
``` ```
3. **Implement your changes**, ensuring you write appropriate unit or integration tests. 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 ## 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. * **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 ```bash
git push origin feat/your-feature-name 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 `pre-release`, never direct PRs).
3. In your PR description, explain the rationale behind your changes, what was fixed/added, and how it was tested. 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. 4. Verify that GitHub Actions CI runs successfully on your PR.

View File

@ -10,10 +10,12 @@
1. [Подготовка окружения](#подготовка-окружения) 1. [Подготовка окружения](#подготовка-окружения)
2. [Структура проекта](#структура-проекта) 2. [Структура проекта](#структура-проекта)
3. [Процесс разработки](#процесс-разработки) 3. [Стратегия веток](#стратегия-веток)
4. [Правила оформления кода](#правила-оформления-кода) 4. [Процесс разработки](#процесс-разработки)
5. [Создание Pull Request](#создание-pull-request) 5. [Оформление коммитов](#оформление-коммитов)
6. [Уязвимости безопасности](#уязвимости-безопасности) 6. [Правила оформления кода](#правила-оформления-кода)
7. [Создание Pull Request](#создание-pull-request)
8. [Уязвимости безопасности](#уязвимости-безопасности)
--- ---
@ -33,20 +35,19 @@
cd ostp cd ostp
``` ```
2. **Соберите веб-интерфейс панели управления**: 2. **Соберите весь Cargo-workspace**:
```bash
cd ostp-control
npm install
npm run build
cd ..
```
3. **Соберите весь Cargo-workspace**:
```bash ```bash
cargo build cargo build
``` ```
`ostp-control` (веб-панель) нужна только если вы работаете конкретно над
ней - в остальных случаях сервер собирается с пустым `dist/` через
`rust-embed`, и этот шаг не нужен для повседневной работы над
core/client/server. Если вы всё же трогаете панель:
```bash
cd ostp-control && npm install && npm run build && cd ..
```
4. **Запустите тесты**: 3. **Запустите тесты**:
```bash ```bash
cargo test --workspace cargo test --workspace
``` ```
@ -66,11 +67,28 @@
--- ---
## Стратегия веток
В репозитории три долгоживущие ветки, по возрастанию стабильности:
| Ветка | Роль |
|---|---|
| `alpha` | Активная разработка. Вся новая работа и фиксы попадают сюда первыми. |
| `pre-release` | Периодически перематывается вперёд (fast-forward) от `alpha`, когда та немного «отлежалась». Собирается в канал релиза `{версия}-beta`. |
| `master` | Перематывается вперёд от `pre-release`, когда та доказала стабильность. Настоящие тегированные релизы (`vX.Y.Z`) режутся отсюда. |
В `pre-release` и `master` **никогда** не коммитят напрямую - они только перематываются вперёд от ветки уровнем ниже. Это значит, что промоушен - всегда обычный `git merge` без единого конфликта по построению: не мержите/не ребейзьте свою фичу прямо в `pre-release` или `master`.
**PR от контрибьюторов нацелены на `alpha`**, не на `master`.
---
## Процесс разработки ## Процесс разработки
1. **Проверьте существующие задачи** или откройте новую тему (Issue) для обсуждения предлагаемых изменений. 1. **Проверьте существующие задачи** или откройте новую тему (Issue) для обсуждения предлагаемых изменений.
2. **Сделайте fork репозитория** и создайте новую ветку от `master`: 2. **Сделайте fork репозитория** и создайте новую ветку от `alpha`:
```bash ```bash
git checkout alpha
git checkout -b feat/имя-вашей-фичи git checkout -b feat/имя-вашей-фичи
``` ```
3. **Внесите необходимые изменения** и добавьте соответствующие модульные или интеграционные тесты. 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: ...`. * **Безопасность (Safety)**: Избегайте использования блоков `unsafe` везде, где это возможно. Допускается их использование только для низкоуровневых системных вызовов (например, FFI-настройки сокетов `setsockopt`). Любой блок `unsafe` должен сопровождаться комментарием `// SAFETY: ...`.
@ -104,7 +149,7 @@
```bash ```bash
git push origin feat/имя-вашей-фичи git push origin feat/имя-вашей-фичи
``` ```
2. Создайте Pull Request (PR) в ветку `master` основного репозитория. 2. Создайте Pull Request (PR) в ветку `alpha` основного репозитория (см. [Стратегия веток](#стратегия-веток) - `master` получает только fast-forward от `pre-release`, PR туда не принимаются напрямую).
3. Подробно опишите внесенные изменения: какая проблема решается, как проводилось тестирование и на каких платформах проверялась сборка. 3. Подробно опишите внесенные изменения: какая проблема решается, как проводилось тестирование и на каких платформах проверялась сборка.
4. Убедитесь, что автоматическое тестирование (GitHub Actions CI) завершилось успешно. 4. Убедитесь, что автоматическое тестирование (GitHub Actions CI) завершилось успешно.

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) Preamble
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)
----------------------------------------------------------------------------------- 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, When we speak of free software, we are referring to freedom, not
redistribute, and make use of the Licensed Work only as permitted by the price. Our General Public Licenses are designed to make sure that you
Additional Use Grant. 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, Developers that use our General Public Licenses protect your rights
redistribute, and make use of the Licensed Work under the terms of the Change with two steps: (1) assert copyright on the software, and (2) offer
License on and after the Change Date. 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 A secondary benefit of defending all users' freedom is that
and the Change License) is in conflict with the Terms of this License, these improvements made in alternate versions of the program, if they
Terms shall take precedence. 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 The GNU Affero General Public License is designed specifically to
License and all other copyright, trademark, and proprietary notices included ensure that, in such cases, the modified source code becomes available
with the Licensed Work. 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 An older license, called the Affero General Public License and
of this License and may terminate your rights under this License. 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 The precise terms and conditions for copying, distribution and
WORK IS PROVIDED ON AN "AS IS" BASIS. THE LICENSOR MAKES NO REPRESENTATIONS OR modification follow.
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.
7. LIMITATION OF LIABILITY. TO THE EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT TERMS AND CONDITIONS
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.
----------------------------------------------------------------------------------- 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 "The Program" refers to any copyrightable work licensed under this
of this software and associated documentation files (the "Software"), to deal License. Each licensee is addressed as "you". "Licensees" and
in the Software without restriction, including without limitation the rights "recipients" may be individuals or organizations.
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 To "modify" a work means to copy from or adapt all or part of the work
copies or substantial portions of the Software. 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 A "covered work" means either the unmodified Program or a work based
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, on the Program.
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER To "propagate" a work means to do anything with it that, without
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, permission, would make you directly or secondarily liable for
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE infringement under applicable copyright law, except executing it on a
SOFTWARE. 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/>.

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) [Русский язык](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. > 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 | | 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. | | **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. | | **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. | | **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. | | **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. | | **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). | | **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. | | **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. | | **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. | | **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). | | **Hot-Reload** | Runtime config reload without restart (access keys, exclusions, mux settings). |
@ -95,15 +95,15 @@ graph TD
```bash ```bash
# On your VPS (server): # On your VPS (server):
./ostp --init server ./ostp init server
# On your machine (client): # On your machine (client):
./ostp --init client ./ostp init client
``` ```
### 2. Edit config ### 2. Edit config
**Server** set your access keys: **Server** - set your access keys:
```jsonc ```jsonc
{ {
"mode": "server", "mode": "server",
@ -114,14 +114,14 @@ graph TD
} }
``` ```
**Client** point to your server: **Client** - point to your server:
```jsonc ```jsonc
{ {
"mode": "client", "mode": "client",
"server": "YOUR_SERVER_IP:50000", "server": "YOUR_SERVER_IP:50000",
"access_key": "YOUR_SECRET_KEY", "access_key": "YOUR_SECRET_KEY",
"socks5_bind": "127.0.0.1:1088", "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" } "tun": { "enable": false, "dns": "1.1.1.1" }
} }
``` ```
@ -131,14 +131,14 @@ graph TD
```bash ```bash
./ostp # Uses config.json in current directory ./ostp # Uses config.json in current directory
./ostp --config /path/to.json # Custom config path ./ostp --config /path/to.json # Custom config path
./ostp --check # Validate config without running ./ostp check # Validate config without running
./ostp --generate-key # Generate a new access key ./ostp gk # Generate a new access key
./ostp --links # Print client share links ./ostp links # Print client share links
``` ```
### 4. Connect via share link (one-liner) ### 4. Connect via share link (one-liner)
```bash ```bash
./ostp "ostp://ACCESS_KEY@server.com:50000?..." ./ostp connect "ostp://ACCESS_KEY@server.com:50000?..."
``` ```
> [!WARNING] > [!WARNING]
@ -171,21 +171,33 @@ Full API reference: [Management API](https://github.com/ospab/ostp/wiki/Manageme
## CLI Reference ## CLI Reference
``` ```
ostp [OPTIONS] [URL] ostp [--config <PATH>] [COMMAND]
Options: Commands:
--config <PATH> Config file path (default: config.json) run Run the daemon using the config file (default when no command is given)
--init <MODE> Generate template config (server/client) connect <URL> Connect once using a share link: ostp://KEY@HOST:PORT
--check Validate configuration and exit setup Interactive setup wizard
-g, --generate-key Generate a secure access key init <MODE> Generate a template config (server/client/relay)
-c, --count <N> Number of keys to generate (default: 1) check Validate the configuration file and exit
gk Generate a secure access key (alias: generate-key)
--format <FMT> Key format: hex, base64 (default: hex) --format <FMT> Key format: hex, base64 (default: hex)
--links Print client share links from server config -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, pre-release, 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
Arguments: Global options:
[URL] Connect via share link: ostp://KEY@HOST:PORT --config <PATH> Config file path (default: config.json)
``` ```
Every subcommand also accepts `-h`/`--help` for its own option list.
--- ---
## Protocol Summary ## Protocol Summary
@ -218,7 +230,7 @@ cargo test -p ostp-core -p ostp-server
## Documentation ## 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) - [Installation](https://github.com/ospab/ostp/wiki/Installation)
- [Configuration Reference](https://github.com/ospab/ostp/wiki/Configuration) - [Configuration Reference](https://github.com/ospab/ostp/wiki/Configuration)
- [Management API](https://github.com/ospab/ostp/wiki/Management-API) - [Management API](https://github.com/ospab/ostp/wiki/Management-API)
@ -230,8 +242,7 @@ cargo test -p ostp-core -p ostp-server
## License ## License
Business Source License 1.1. Free for personal and non-commercial use. GNU Affero General Public License v3.0 (AGPL-3.0). See [LICENSE](LICENSE) for the full text.
Converts to MIT License on May 14, 2030.
--- ---

View File

@ -1,4 +1,4 @@
# OSTP Ospab Stealth Transport Protocol # OSTP - Ospab Stealth Transport Protocol
[English](README.md) · [Contributing](CONTRIBUTING.ru.md) [English](README.md) · [Contributing](CONTRIBUTING.ru.md)
@ -10,7 +10,7 @@
> Быстрый кастомный зашифрованный транспортный протокол на Rust. > Быстрый кастомный зашифрованный транспортный протокол на 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-ключами, уникальными для каждого пакета. | | **Обфускация трафика** | Каждый пакет, включая заголовки, неотличим от случайного шума. 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 Гбит/с. | | **Reliable UDP (ARQ)** | Selective ACK/NACK с rate-limited ретрансмиссией, настраиваемым reorder-буфером и exponential backoff. Разработан для 10 Гбит/с. |
| **Мультиплексирование** | Несколько логических TCP-потоков поверх одной зашифрованной UDP-сессии с per-stream flow control. | | **Мультиплексирование** | Несколько логических TCP-потоков поверх одной зашифрованной UDP-сессии с per-stream flow control. |
| **Бесшовный роуминг** | Клиент может менять сети (WiFi ↔ 4G) без разрыва сессии сервер отслеживает session-ID, а не IP-адрес. | | **Бесшовный роуминг** | Клиент может менять сети (WiFi ↔ 4G) без разрыва сессии - сервер отслеживает session-ID, а не IP-адрес. |
| **TUN-режим** | Полносистемный VPN без внешних зависимостей (встроенный network stack на базе `smoltcp`). | | **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) для удобного администрирования. | | **Мобильные и Web приложения** | Красивый кроссплатформенный мобильный клиент (Flutter) и современная Web панель управления (React/Vite) для удобного администрирования. |
| **TURN Relay** | RFC 5766 TURN для окружений, где прямой UDP заблокирован. | | **TURN Relay** | RFC 5766 TURN для окружений, где прямой UDP заблокирован. |
| **Hot-Reload** | Перезагрузка конфига в рантайме без перезапуска (ключи, исключения, mux, TURN). | | **Hot-Reload** | Перезагрузка конфига в рантайме без перезапуска (ключи, исключения, mux, TURN). |
@ -84,8 +84,8 @@ irm https://raw.githubusercontent.com/ospab/ostp/master/scripts/install.ps1 | ie
Создать конфиг по умолчанию: Создать конфиг по умолчанию:
```bash ```bash
./ostp --init server # VPS ./ostp init server # VPS
./ostp --init client # Локальная машина ./ostp init client # Локальная машина
``` ```
### Сервер (`config.json`) ### Сервер (`config.json`)
@ -116,8 +116,7 @@ irm https://raw.githubusercontent.com/ospab/ostp/master/scripts/install.ps1 | ie
"debug": false, "debug": false,
// Настройки транспорта (udp или uot) // Настройки транспорта (udp или uot)
"transport": { "transport": {
"mode": "udp", "mode": "udp"
"stealth_sni": "vk.com"
}, },
// TUN-режим (полносистемный VPN) // TUN-режим (полносистемный VPN)
"tun": { "tun": {
@ -156,6 +155,36 @@ irm https://raw.githubusercontent.com/ospab/ostp/master/scripts/install.ps1 | ie
./ostp ./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, pre-release, alpha (по умолчанию stable)
-v, --version <VER> Обновиться на точную версию вместо последней в канале
migrate Принудительно мигрировать конфиг к текущему формату
proxy-env Вывести shell-команды для локального SOCKS-прокси
proxy-env-clear Вывести shell-команды для их отмены
uninstall Остановить сервис и удалить бинарник с конфигом
Глобальные опции:
--config <PATH> Путь к конфигу (по умолчанию config.json)
```
У каждой подкоманды есть своя справка через `-h`/`--help`.
### TUN-режим (Windows) ### TUN-режим (Windows)
Использует встроенный сетевой стек `smoltcp` и виртуальный адаптер `wintun` (необходима `wintun.dll`). Требует запуска с правами Администратора. Использует встроенный сетевой стек `smoltcp` и виртуальный адаптер `wintun` (необходима `wintun.dll`). Требует запуска с правами Администратора.
@ -204,5 +233,4 @@ cross build --release --target x86_64-unknown-linux-gnu
## Лицензия ## Лицензия
Business Source License 1.1. Бесплатно для личного и некоммерческого использования. GNU Affero General Public License v3.0 (AGPL-3.0). Полный текст - в файле [LICENSE](LICENSE).
Переходит в MIT License 14 мая 2030 года.

1
dnstt Submodule

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

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 ## 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`) ### 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: 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`.
* **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`.
### 2. Data Transmission Mode (`is_handshake = false`) ### 2. Data Transmission Mode (`is_handshake = false`)
Post-handshake, the wire layout contains: 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`.
`[4-byte session_id]` + `[8-byte nonce]` + `[AEAD Ciphertext]`
To completely randomize metadata, a two-tiered dynamic XOR masking process is applied: #### 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.
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.
--- ---
@ -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. - **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. - **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

@ -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 байтами сигнатурной матрицы: ### 1. Фаза хэндшейка (`is_handshake = true`)
$$\text{raw}[i] = \text{raw}[i] \oplus \text{Key}[i \pmod 8], \quad i \in [0..3]$$ Пакет на проводе — `[4 байта session_id][2 байта noise_len][Noise-полезная нагрузка]`. Маска считается по Noise-полезной нагрузке (`raw[6..]`), и её первые 6 байт накладываются XOR'ом на `session_id || noise_len`.
* **Восстановление**: Обратное наложение сигнатурной матрицы возвращает корректное значение логического идентификатора.
### 2. Этап высокоскоростного переноса данных (`is_handshake = false`) ### 2. Фаза передачи данных (`is_handshake = false`)
После перевода сессии в состояние активности кадр передачи принимает следующий вид: Пакет на проводе — `[4 байта session_id][8 байт nonce][AEAD-шифротекст]`. Маска считается по шифротексту, и её первые 12 байт накладываются XOR'ом на `session_id || nonce`.
`[4 байта session_id]` + `[8 байт 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-датаграмма выглядела бы для сервера точь-в-точь как случайный одиночный проб.

BIN
icons/logo_new.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 769 KiB

View File

@ -65,7 +65,6 @@ pub struct Bridge {
pub mux_sessions: usize, pub mux_sessions: usize,
pub transport_mode: String, pub transport_mode: String,
pub stealth_sni: String,
pub tcp_fragmentation: bool, pub tcp_fragmentation: bool,
pub frag_chunk: usize, pub frag_chunk: usize,
pub frag_sleep: u64, pub frag_sleep: u64,
@ -102,7 +101,6 @@ impl Bridge {
mux_sessions: config.multiplex.sessions.max(1), mux_sessions: config.multiplex.sessions.max(1),
transport_mode: config.transport.mode.clone(), transport_mode: config.transport.mode.clone(),
stealth_sni: config.transport.stealth_sni.clone(),
tcp_fragmentation: config.transport.tcp_fragmentation, tcp_fragmentation: config.transport.tcp_fragmentation,
frag_chunk: config.transport.frag_chunk, frag_chunk: config.transport.frag_chunk,
frag_sleep: config.transport.frag_sleep, frag_sleep: config.transport.frag_sleep,
@ -1033,7 +1031,6 @@ impl Bridge {
self.mux_enabled = cfg.multiplex.enabled; self.mux_enabled = cfg.multiplex.enabled;
self.mux_sessions = cfg.multiplex.sessions.max(1); self.mux_sessions = cfg.multiplex.sessions.max(1);
self.transport_mode = cfg.transport.mode.clone(); self.transport_mode = cfg.transport.mode.clone();
self.stealth_sni = cfg.transport.stealth_sni.clone();
self.tcp_fragmentation = cfg.transport.tcp_fragmentation; self.tcp_fragmentation = cfg.transport.tcp_fragmentation;
self.frag_chunk = cfg.transport.frag_chunk.max(1); self.frag_chunk = cfg.transport.frag_chunk.max(1);
self.frag_sleep = cfg.transport.frag_sleep; self.frag_sleep = cfg.transport.frag_sleep;
@ -1060,9 +1057,14 @@ impl Bridge {
let frag_sleep = self.frag_sleep; let frag_sleep = self.frag_sleep;
let [junk_pc_min, junk_pc_max] = self.junk_pc; let [junk_pc_min, junk_pc_max] = self.junk_pc;
let [junk_ps_min, junk_ps_max] = self.junk_ps; let [junk_ps_min, junk_ps_max] = self.junk_ps;
// Per-key junk marker (derived from the access key) — NOT a global // Time-rotating per-key junk marker — NOT a global constant and NOT
// constant, so junk frames carry no universal DPI signature. // even a static per-user value: it changes every window, so junk
let junk_marker = ostp_core::crypto::derive_all_secrets(&self.access_key).junk_marker; // 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; use tokio::io::AsyncWriteExt;

View File

@ -70,15 +70,13 @@ pub struct LocalProxyConfig {
} }
/// Transport layer configuration. /// 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransportConfig { pub struct TransportConfig {
/// "udp" or "uot" /// "udp" or "uot"
#[serde(default = "default_transport_mode")] #[serde(default = "default_transport_mode")]
pub mode: String, 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 /// 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. /// break DPI that inspects the first packet. UoT/TCP only; ignored for UDP.
pub tcp_fragmentation: bool, pub tcp_fragmentation: bool,
@ -106,7 +104,6 @@ impl Default for TransportConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
mode: default_transport_mode(), mode: default_transport_mode(),
stealth_sni: String::new(),
tcp_fragmentation: false, tcp_fragmentation: false,
frag_chunk: default_frag_chunk(), frag_chunk: default_frag_chunk(),
frag_sleep: default_frag_sleep(), frag_sleep: default_frag_sleep(),
@ -192,7 +189,6 @@ struct RawUnifiedConfig {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct RawTransportSection { struct RawTransportSection {
mode: Option<String>, mode: Option<String>,
stealth_sni: Option<String>,
tcp_fragmentation: Option<bool>, tcp_fragmentation: Option<bool>,
frag_chunk: Option<usize>, frag_chunk: Option<usize>,
frag_sleep: Option<u64>, frag_sleep: Option<u64>,
@ -270,7 +266,6 @@ impl ClientConfig {
}, },
transport: TransportConfig { transport: TransportConfig {
mode: raw.transport.as_ref().and_then(|t| t.mode.clone()).unwrap_or_else(default_transport_mode), 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), 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_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), frag_sleep: raw.transport.as_ref().and_then(|t| t.frag_sleep).unwrap_or_else(default_frag_sleep),
@ -293,3 +288,248 @@ 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,
/// Target server's API URL, for key sync
pub upstream_api_url: String,
/// Bearer token for the target server's API
#[serde(default)]
pub upstream_api_token: String,
/// Key sync interval in seconds (default 30)
#[serde(default = "default_sync_interval")]
pub sync_interval_secs: u64,
pub debug: Option<bool>,
}
fn default_sync_interval() -> u64 { 30 }
/// 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 app;
pub mod bridge; pub mod bridge;
pub mod config; pub mod config;
pub mod migrate;
pub mod signal; pub mod signal;
pub mod sysproxy; pub mod sysproxy;
pub mod transport; pub mod transport;

View File

@ -3,6 +3,53 @@ use std::io::Write;
use std::path::PathBuf; use std::path::PathBuf;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; 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() { pub fn setup_panic_hook() {
std::panic::set_hook(Box::new(|info| { std::panic::set_hook(Box::new(|info| {
let payload = info.payload(); let payload = info.payload();
@ -29,19 +76,16 @@ pub fn setup_panic_hook() {
eprintln!("{}", crash_msg); eprintln!("{}", crash_msg);
tracing::error!("{}", crash_msg); tracing::error!("{}", crash_msg);
let path = std::env::current_exe() // Crashes land in the same shared log file (append — a crash must never
.ok() // truncate, and the tracing worker may already be dead so we write direct).
.and_then(|p| p.parent().map(|d| d.join("ostp-crash.log"))) if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(log_file_path()) {
.unwrap_or_else(|| PathBuf::from("ostp-crash.log"));
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
let _ = file.write_all(crash_msg.as_bytes()); let _ = file.write_all(crash_msg.as_bytes());
let _ = file.write_all(b"\n===================================================\n"); 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: /// The `level` parameter controls the minimum log level:
/// - `"error"` — only errors /// - `"error"` — only errors
@ -51,7 +95,17 @@ pub fn setup_panic_hook() {
/// - `"trace"` — all messages including very verbose internal state /// - `"trace"` — all messages including very verbose internal state
/// ///
/// The environment variable `RUST_LOG` overrides this value if set. /// 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 // RUST_LOG overrides the config-derived level
let env_filter = EnvFilter::try_from_default_env() let env_filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| { .unwrap_or_else(|_| {
@ -66,12 +120,39 @@ pub fn init_tracing(level: &str, app_name: &str, version: &str) -> Option<tracin
} }
}); });
let path = std::env::current_exe() let path = log_file_path();
.ok()
.and_then(|p| p.parent().map(|d| d.join(format!("{}.log", app_name)))) let mut open_opts = OpenOptions::new();
.unwrap_or_else(|| PathBuf::from(format!("{}.log", app_name))); 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 (file_writer, guard) = tracing_appender::non_blocking(file);
let fmt_layer = tracing_subscriber::fmt::layer() let fmt_layer = tracing_subscriber::fmt::layer()
@ -92,16 +173,6 @@ pub fn init_tracing(level: &str, app_name: &str, version: &str) -> Option<tracin
.with(stderr_layer) .with(stderr_layer)
.try_init(); .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) Some(guard)
} else { } else {
// Fallback: stderr only // 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 _; use std::io::Write as _;
fn log_to_core_file(msg: &str) { fn log_to_core_file(msg: &str) {
let path = std::env::current_exe() // Writes into the single shared ostp.log (same file as the tracing appender),
.ok() // not a separate ostp-core.log — see logging::LOG_FILE_NAME.
.and_then(|p| p.parent().map(|d| d.join("ostp-core.log"))) let path = crate::logging::log_file_path();
.unwrap_or_else(|| std::path::PathBuf::from("ostp-core.log"));
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(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); 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 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( 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, mut config: crate::config::ClientConfig,
metrics: Arc<BridgeMetrics>, metrics: Arc<BridgeMetrics>,
mut shutdown_rx_ext: watch::Receiver<bool>, 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!("OSTP Local Proxy is running at socks5://{}", proxy_addr);
println!("Since you are in a headless/terminal environment, OSTP cannot automatically"); 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!("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!("Or configure your application (e.g. curl -x socks5://{})", proxy_addr);
println!("===================================================================\n"); println!("===================================================================\n");
} }

View File

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

View File

@ -59,11 +59,10 @@ pub struct DerivedSecrets {
pub psk: [u8; 32], pub psk: [u8; 32],
pub handshake_pad_min: usize, pub handshake_pad_min: usize,
pub handshake_pad_max: 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 /// OSTP wire protocol version. Mixed into key derivation (NOT sent on the
/// wire) so peers running incompatible versions derive entirely different /// wire) so peers running incompatible versions derive entirely different
@ -129,25 +128,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_min = 16 + (pad_bytes[0] as usize % 64); // 16-79
let pad_max = pad_min + 48 + (pad_bytes[1] as usize % 128); // +48..+175 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 { DerivedSecrets {
obfuscation_key, obfuscation_key,
psk, psk,
handshake_pad_min: pad_min, handshake_pad_min: pad_min,
handshake_pad_max: pad_max, 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) ───────────────────────────── // ── Legacy API (delegates to derive_all_secrets) ─────────────────────────────
pub fn derive_obfuscation_key(access_key: &[u8]) -> [u8; 8] { 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!(recovered_nonce, nonce);
assert_eq!(&packet[12..], &ciphertext); 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

@ -92,12 +92,29 @@ class MainActivity : FlutterActivity() {
val metrics = net.ostp.client.OstpClientSdk.getMetrics() val metrics = net.ostp.client.OstpClientSdk.getMetrics()
result.success(metrics ?: "{}") result.success(metrics ?: "{}")
} catch (e: Throwable) { } 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) result.error("ERROR", e.message, null)
} }
} }
"getInstalledApps" -> { "getInstalledApps" -> {
try { // 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 val pm = packageManager
Thread {
try {
val apps = pm.getInstalledApplications(PackageManager.GET_META_DATA) val apps = pm.getInstalledApplications(PackageManager.GET_META_DATA)
val list = apps.map { app -> val list = apps.map { app ->
val isSystem = ((app.flags and ApplicationInfo.FLAG_SYSTEM) != 0) && val isSystem = ((app.flags and ApplicationInfo.FLAG_SYSTEM) != 0) &&
@ -110,10 +127,11 @@ class MainActivity : FlutterActivity() {
"icon" to (iconBase64 ?: "") "icon" to (iconBase64 ?: "")
) )
} }
result.success(list) runOnUiThread { result.success(list) }
} catch (e: Exception) { } catch (e: Exception) {
result.error("ERROR", e.message, null) runOnUiThread { result.error("ERROR", e.message, null) }
} }
}.start()
} }
else -> result.notImplemented() else -> result.notImplemented()
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -26,11 +26,11 @@ class OstpApp extends StatelessWidget {
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: ThemeData( theme: ThemeData(
brightness: Brightness.dark, brightness: Brightness.dark,
scaffoldBackgroundColor: const Color(0xFF08080F), scaffoldBackgroundColor: const Color(0xFF000000),
colorScheme: const ColorScheme.dark( colorScheme: const ColorScheme.dark(
primary: Color(0xFF6C72FF), primary: Color(0xFFFFFFFF),
secondary: Color(0xFF22D3A5), secondary: Color(0xFFAAAAAA),
surface: Color(0xFF151522), surface: Color(0xFF111111),
), ),
fontFamily: 'Inter', fontFamily: 'Inter',
useMaterial3: true, 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(); 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> { class _AppRoutingScreenState extends State<AppRoutingScreen> {
static const platform = MethodChannel('com.ospab.ostp/vpn'); 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), color: _routingMode == 'bypass' ? theme.colorScheme.primary : Colors.white.withOpacity(0.1),
), ),
), ),
child: const Center( child: Center(
child: Text( child: Text(
'Bypass Mode', '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), color: _routingMode == 'proxy' ? theme.colorScheme.secondary : Colors.white.withOpacity(0.1),
), ),
), ),
child: const Center( child: Center(
child: Text( child: Text(
'Proxy Mode', '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,12 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import '../models/connection_state_enum.dart'; import '../models/connection_state_enum.dart';
import '../models/ostp_profile.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
import 'logs_screen.dart';
import 'app_routing_screen.dart';
import 'qr_scanner_screen.dart';
class HomeScreen extends StatefulWidget { class HomeScreen extends StatefulWidget {
final SharedPreferences prefs; final SharedPreferences prefs;
@ -28,12 +24,24 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
Timer? _uptimeTimer; Timer? _uptimeTimer;
int _uptimeSecs = 0; int _uptimeSecs = 0;
String _serverAddr = '127.0.0.1:443'; // Single active profile the core only ever connects to one server at a
String _accessKey = 'default_key'; // 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 _download = '0 B';
String _upload = '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 _pulseController;
late AnimationController _spinController; late AnimationController _spinController;
@ -70,37 +78,43 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
void _loadSettings() { void _loadSettings() {
setState(() { setState(() {
_serverAddr = widget.prefs.getString('server_addr') ?? '127.0.0.1:443'; final profiles = decodeProfiles(widget.prefs.getString('profiles_json'));
_accessKey = widget.prefs.getString('access_key') ?? ''; // 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(); _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 exDomains = widget.prefs.getString('ex_domains') ?? '';
final exIps = widget.prefs.getString('ex_ips') ?? ''; final exIps = widget.prefs.getString('ex_ips') ?? '';
final exProcesses = widget.prefs.getString('ex_processes') ?? '';
final debugMode = widget.prefs.getBool('debug_mode') ?? false; 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 mtu = widget.prefs.getString('mtu') ?? '1140';
final muxEnabled = widget.prefs.getBool('mux_enabled') ?? false; final muxEnabled = widget.prefs.getBool('mux_enabled') ?? false;
final muxSessions = widget.prefs.getString('mux_sessions') ?? '2'; final muxSessions = widget.prefs.getString('mux_sessions') ?? '2';
final dnsServer = widget.prefs.getString('dns_server'); final dnsServer = widget.prefs.getString('dns_server');
final effectiveDnsServer = (dnsServer == null || dnsServer.isEmpty) ? '1.1.1.1' : dnsServer; 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 appRoutingMode = widget.prefs.getString('app_routing_mode') ?? 'bypass';
final appRoutingPackages = widget.prefs.getStringList('app_routing_packages') ?? []; final appRoutingPackages = widget.prefs.getStringList('app_routing_packages') ?? [];
final localBind = widget.prefs.getString('local_bind') ?? '127.0.0.1:1088'; final localBind = widget.prefs.getString('local_bind') ?? '127.0.0.1:1088';
final configMap = {
return {
"mode": "client", "mode": "client",
"debug": debugMode, "debug": debugMode,
"ostp": { "ostp": {
"server_addr": _serverAddr, "server_addr": p?.serverAddr ?? '',
"local_bind_addr": "0.0.0.0:0", "local_bind_addr": "0.0.0.0:0",
"access_key": _accessKey, "access_key": p?.accessKey ?? '',
"handshake_timeout_ms": 10000, "handshake_timeout_ms": 10000,
"io_timeout_ms": 5000, "io_timeout_ms": 5000,
"mtu": int.tryParse(mtu) ?? 1140, "mtu": int.tryParse(mtu) ?? 1140,
@ -109,34 +123,40 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
"bind_addr": localBind, "bind_addr": localBind,
"connect_timeout_ms": 15000, "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": { "transport": {
"mode": transportMode, "mode": p?.transportMode ?? 'udp',
"stealth_sni": stealthSni, "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": { "multiplex": {
"enabled": muxEnabled, "enabled": muxEnabled,
"sessions": int.tryParse(muxSessions) ?? 2, "sessions": int.tryParse(muxSessions) ?? 2,
}, },
"tun": {
"enable": true,
"stack": tunStack
},
"exclusions": { "exclusions": {
"domains": exDomains.split('\n').where((s) => s.trim().isNotEmpty).toList(), "domains": exDomains.split('\n').where((s) => s.trim().isNotEmpty).toList(),
"ips": exIps.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": { "app_rules": {
"mode": appRoutingMode, "mode": appRoutingMode,
"packages": appRoutingPackages, "packages": appRoutingPackages,
}, },
"dns_server": effectiveDnsServer, "dns_server": effectiveDnsServer,
"tun_stack": tunStack "tun_stack": tunStack,
}; };
}
void _updateLatestConfigJson() {
final configMap = _buildConfigMap();
widget.prefs.setString('latest_config_json', jsonEncode(configMap)); widget.prefs.setString('latest_config_json', jsonEncode(configMap));
platform.invokeMethod('saveConfig', { platform.invokeMethod('saveConfig', {"configJson": jsonEncode(configMap)});
"configJson": jsonEncode(configMap)
});
} }
@override @override
@ -150,9 +170,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
Future<void> _toggleConnection() async { Future<void> _toggleConnection() async {
if (_state == ConnectionStateEnum.disconnected) { if (_state == ConnectionStateEnum.disconnected) {
if (_serverAddr.isEmpty || _accessKey.isEmpty) { if (_activeProfile == null || _activeProfile!.serverAddr.isEmpty || _activeProfile!.accessKey.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( 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; return;
} }
@ -163,73 +183,13 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
_pulseController.repeat(reverse: true); _pulseController.repeat(reverse: true);
_spinController.repeat(); _spinController.repeat();
final dnsServer = widget.prefs.getString('dns_server'); final configMap = _buildConfigMap();
final effectiveDnsServer = (dnsServer == null || dnsServer.isEmpty) ? '1.1.1.1' : dnsServer; final configStr = jsonEncode(configMap);
final exDomains = widget.prefs.getString('ex_domains') ?? ''; widget.prefs.setString('latest_config_json', configStr);
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));
try { try {
await platform.invokeMethod('saveConfig', { await platform.invokeMethod('saveConfig', {"configJson": configStr});
"configJson": jsonEncode(configMap) await platform.invokeMethod('startTunnel', {"configJson": configStr});
});
await platform.invokeMethod('startTunnel', {
"configJson": jsonEncode(configMap)
});
bool started = false; bool started = false;
for (int i = 0; i < 10; i++) { for (int i = 0; i < 10; i++) {
@ -289,30 +249,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 { Future<void> _runAutoMode() async {
final mtus = [1500, 1350, 1280, 1140]; final mtus = [1500, 1350, 1280, 1140];
final modes = [ final modes = ['udp', 'uot'];
{'t': 'udp'},
{'t': 'uot'},
];
if (_serverAddr.isEmpty || _accessKey.isEmpty) { final active = _activeProfile;
if (active == null || active.serverAddr.isEmpty || active.accessKey.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( 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; return;
} }
for (var mode in modes) { final originalMode = active.transportMode;
for (var mtu in mtus) { final originalMtu = widget.prefs.getString('mtu') ?? '1140';
for (final mode in modes) {
for (final mtu in mtus) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( 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('mtu', mtu.toString());
await widget.prefs.setString('transport_mode', mode['t'] as String); active.transportMode = mode;
_updateLatestConfigJson(); _updateLatestConfigJson();
setState(() { setState(() {
@ -337,7 +301,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
if (started) { if (started) {
_setConnected(); _setConnected();
// Wait to see if connection is stable and ping is successful
await Future.delayed(const Duration(seconds: 3)); await Future.delayed(const Duration(seconds: 3));
try { try {
final metricsJson = await platform.invokeMethod('getMetrics'); final metricsJson = await platform.invokeMethod('getMetrics');
@ -345,30 +308,37 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
final Map<String, dynamic> parsed = jsonDecode(metricsJson); final Map<String, dynamic> parsed = jsonDecode(metricsJson);
final rttMs = parsed['rtt_ms'] as int? ?? 0; final rttMs = parsed['rtt_ms'] as int? ?? 0;
if (rttMs > 0) { if (rttMs > 0) {
// Working combo found persist it onto the profile.
_persistActiveProfile();
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( 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) { } catch (_) {
// Ignore metrics error // Ignore metrics error, fall through to try next combo.
} }
// Connection seems unstable or no ping, stop and try next
await platform.invokeMethod('stopTunnel'); await platform.invokeMethod('stopTunnel');
_setDisconnected(); _setDisconnected();
} else { } else {
_setDisconnected(); _setDisconnected();
} }
} catch (e) { } catch (_) {
_setDisconnected(); _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) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Auto search finished. No working config found.')), const SnackBar(content: Text('Auto search finished. No working config found.')),
@ -376,6 +346,17 @@ 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() { void _setConnected() {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
@ -433,6 +414,12 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
setState(() { setState(() {
_download = _formatBytes(bytesRecv); _download = _formatBytes(bytesRecv);
_upload = _formatBytes(bytesSent); _upload = _formatBytes(bytesSent);
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 && !_isCheckingPing) { if (rttMs > 0 && !_isCheckingPing) {
_pingText = 'Server Ping: $rttMs ms'; _pingText = 'Server Ping: $rttMs ms';
if (rttMs < 100) { if (rttMs < 100) {
@ -469,7 +456,30 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
_pingColor = Colors.white70; _pingColor = Colors.white70;
}); });
await Future.delayed(const Duration(milliseconds: 500)); try {
final metricsJson = await platform.invokeMethod('getMetrics');
if (metricsJson != null && metricsJson.isNotEmpty) {
final Map<String, dynamic> parsed = jsonDecode(metricsJson);
final rttMs = parsed['rtt_ms'] as int? ?? 0;
if (mounted) {
setState(() {
if (rttMs > 0) {
_pingText = 'Server Ping: $rttMs ms';
_pingColor = rttMs < 100
? const Color(0xFF22D3A5)
: rttMs < 250
? Colors.amberAccent
: Colors.redAccent;
} else {
_pingText = 'Server Ping: -- ms';
_pingColor = Colors.white54;
}
});
}
}
} catch (e) {
debugPrint("Failed to check latency: $e");
}
if (mounted) { if (mounted) {
setState(() { setState(() {
@ -484,6 +494,10 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
_state = ConnectionStateEnum.disconnected; _state = ConnectionStateEnum.disconnected;
_download = '0 B'; _download = '0 B';
_upload = '0 B'; _upload = '0 B';
_downSpeed = '0 B/s';
_upSpeed = '0 B/s';
_prevBytesRecv = 0;
_prevBytesSent = 0;
_pingText = 'Target Ping: -- ms'; _pingText = 'Target Ping: -- ms';
_pingColor = Colors.white54; _pingColor = Colors.white54;
_isCheckingPing = false; _isCheckingPing = false;
@ -510,31 +524,17 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
return Scaffold( return Scaffold(
body: Stack( body: Stack(
children: [ children: [
Positioned( Positioned.fill(
top: -150, right: -100, child: Opacity(
child: Container( opacity: 0.1,
width: 400, height: 400, child: Center(
decoration: BoxDecoration( child: Image.asset(
shape: BoxShape.circle, 'assets/logo.png',
color: theme.colorScheme.primary.withOpacity(0.15), 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.
), ),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 100, sigmaY: 100),
child: Container(),
),
),
),
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(),
), ),
), ),
), ),
@ -745,12 +745,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
const SizedBox(height: 30), const SizedBox(height: 30),
AnimatedOpacity(
opacity: _state == ConnectionStateEnum.connected ? 1.0 : 0.0,
duration: const Duration(milliseconds: 300),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
@ -764,7 +758,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
const Icon(Icons.dns_rounded, size: 18, color: Colors.white70), const Icon(Icons.dns_rounded, size: 18, color: Colors.white70),
const SizedBox(width: 10), const SizedBox(width: 10),
Text( Text(
_serverAddr, _activeProfile?.name ?? 'No profile selected',
style: const TextStyle( style: const TextStyle(
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: 15, fontSize: 15,
@ -775,8 +769,14 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
], ],
), ),
), ),
const SizedBox(height: 16),
Container( if (_showRtt)
AnimatedOpacity(
opacity: _state == ConnectionStateEnum.connected ? 1.0 : 0.0,
duration: const Duration(milliseconds: 300),
child: Padding(
padding: const EdgeInsets.only(top: 16),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16), margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
@ -839,7 +839,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
], ],
), ),
), ),
],
), ),
) )
], ],
@ -856,15 +855,15 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [ 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)), 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( return Expanded(
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@ -903,6 +902,19 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
color: Colors.white, 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 +923,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 # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 0.2.97+12 version: 0.4.1+19
environment: environment:
sdk: ^3.11.4 sdk: ^3.11.4
@ -72,9 +72,8 @@ flutter:
uses-material-design: true uses-material-design: true
# To add assets to your application, add an assets section, like this: # To add assets to your application, add an assets section, like this:
# assets: assets:
# - images/a_dot_burr.jpeg - assets/logo.png
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see # An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images # https://flutter.dev/to/resolution-aware-images

View File

@ -1,9 +1,10 @@
[package] [package]
name = "ostp-gui" name = "ostp-gui"
version = "0.4.1" version = "0.4.1"
description = "A Tauri App" description = "OSTP desktop GUI"
authors = ["you"] authors = ["ospab"]
edition = "2021" edition = "2021"
license = "AGPL-3.0"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

View File

@ -56,7 +56,6 @@ struct TunConfig {
#[derive(Debug, Deserialize, Serialize, Clone)] #[derive(Debug, Deserialize, Serialize, Clone)]
struct TransportConfigRaw { struct TransportConfigRaw {
mode: Option<String>, mode: Option<String>,
stealth_sni: Option<String>,
tcp_fragmentation: Option<bool>, tcp_fragmentation: Option<bool>,
frag_chunk: Option<usize>, frag_chunk: Option<usize>,
frag_sleep: Option<u64>, frag_sleep: Option<u64>,
@ -167,7 +166,6 @@ fn map_to_client_config(raw: &ClientConfigRaw, mode: &str) -> ostp_client::confi
transport: ostp_client::config::TransportConfig { transport: ostp_client::config::TransportConfig {
mode: raw.transport.as_ref().and_then(|t| t.mode.clone()).unwrap_or_else(|| "udp".to_string()), mode: raw.transport.as_ref().and_then(|t| t.mode.clone()).unwrap_or_else(|| "udp".to_string()),
stealth_sni: raw.transport.as_ref().and_then(|t| t.stealth_sni.clone()).unwrap_or_else(|| "microsoft.com".to_string()),
tcp_fragmentation: raw.transport.as_ref().and_then(|t| t.tcp_fragmentation).unwrap_or(false), 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(2), frag_chunk: raw.transport.as_ref().and_then(|t| t.frag_chunk).unwrap_or(2),
frag_sleep: raw.transport.as_ref().and_then(|t| t.frag_sleep).unwrap_or(2), frag_sleep: raw.transport.as_ref().and_then(|t| t.frag_sleep).unwrap_or(2),
@ -767,6 +765,7 @@ fn launch_as_admin(exe: &std::path::PathBuf, token: &str, port: u16) -> anyhow::
// Use the GUI executable's directory as the working directory so dependencies are found // Use the GUI executable's directory as the working directory so dependencies are found
let cwd_path = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from(".")); let cwd_path = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("."));
let dir_wstr: Vec<u16> = cwd_path.parent().unwrap_or(std::path::Path::new(".")).as_os_str().encode_wide().chain(Some(0)).collect(); let dir_wstr: Vec<u16> = cwd_path.parent().unwrap_or(std::path::Path::new(".")).as_os_str().encode_wide().chain(Some(0)).collect();
// Remove Mark of the Web (Zone.Identifier) so SmartScreen doesn't block UAC // Remove Mark of the Web (Zone.Identifier) so SmartScreen doesn't block UAC
let zone_id = format!("{}:Zone.Identifier", exe.display()); let zone_id = format!("{}:Zone.Identifier", exe.display());
let _ = std::fs::remove_file(zone_id); let _ = std::fs::remove_file(zone_id);

View File

@ -8,7 +8,10 @@ fn main() {
// Read config BEFORE init_tracing so we can use the correct log level from config. // Read config BEFORE init_tracing so we can use the correct log level from config.
// If config is missing or debug=false we default to "info". // If config is missing or debug=false we default to "info".
let log_level = detect_log_level_from_config(); let log_level = detect_log_level_from_config();
let _log_guard = ostp_client::logging::init_tracing(&log_level, "ostp-gui", env!("CARGO_PKG_VERSION")); // The GUI launch IS the daemon's startup, so clear the shared log here
// (Windows-only inside init_tracing). The elevated TUN helper spawned later
// passes truncate=false so it appends instead of wiping this session's log.
let _log_guard = ostp_client::logging::init_tracing(&log_level, "ostp-gui", env!("CARGO_PKG_VERSION"), true);
tracing::info!("ostp-gui starting (log_level={})", log_level); tracing::info!("ostp-gui starting (log_level={})", log_level);
@ -28,7 +31,7 @@ fn main() {
{ {
use std::ffi::OsStr; use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt; use std::os::windows::ffi::OsStrExt;
let msg_w: Vec<u16> = OsStr::new(&format!("OSTP GUI crashed:\n\n{}\n\nSee ostp-gui.log for details.", msg)) let msg_w: Vec<u16> = OsStr::new(&format!("OSTP GUI crashed:\n\n{}\n\nSee ostp.log for details.", msg))
.encode_wide().chain(Some(0)).collect(); .encode_wide().chain(Some(0)).collect();
let title_w: Vec<u16> = OsStr::new("OSTP GUI — Fatal Error").encode_wide().chain(Some(0)).collect(); let title_w: Vec<u16> = OsStr::new("OSTP GUI — Fatal Error").encode_wide().chain(Some(0)).collect();
#[link(name = "user32")] extern "system" { #[link(name = "user32")] extern "system" {

View File

@ -302,6 +302,40 @@
</label> </label>
</div> </div>
<div class="section-divider-mini"><span>Obfuscation</span></div>
<div class="toggle-row" style="border-top:none;">
<div class="toggle-text">
<span class="toggle-name">Junk Packets</span>
<span class="toggle-hint">Random noise before connection (UoT only)</span>
</div>
<div class="toggle-with-gear">
<label class="toggle">
<input type="checkbox" id="cs-junk-enabled" />
<span class="toggle-track"><span class="toggle-thumb"></span></span>
</label>
<button id="btn-junk-settings" class="gear-btn" title="Configure junk packets">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</button>
</div>
</div>
<div class="toggle-row" style="border-top:none;">
<div class="toggle-text">
<span class="toggle-name">TCP Fragmentation</span>
<span class="toggle-hint">Split handshake into segments (UoT only)</span>
</div>
<div class="toggle-with-gear">
<label class="toggle">
<input type="checkbox" id="cs-tcp-frag" />
<span class="toggle-track"><span class="toggle-thumb"></span></span>
</label>
<button id="btn-frag-settings" class="gear-btn" title="Configure fragmentation">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</button>
</div>
</div>
</div> <!-- client-settings-card --> </div> <!-- client-settings-card -->
<div class="app-version" id="app-version">OSTP GUI</div> <div class="app-version" id="app-version">OSTP GUI</div>
@ -457,6 +491,58 @@
</div> </div>
</div> </div>
<!-- ── JUNK PACKETS MODAL ──────────────────────────────────── -->
<div id="junk-modal" class="modal-overlay hidden">
<div class="modal-content compact">
<h3 class="modal-title">Junk Packets</h3>
<p class="modal-text">Sends random garbage frames before the Noise handshake to confuse DPI pattern matching. Active in UoT/TCP mode only.</p>
<div class="modal-row-2">
<div class="field-group">
<label class="field-label" for="cs-junk-pc-min">Count min</label>
<input id="cs-junk-pc-min" class="field-input" type="number" min="0" max="100" placeholder="2" />
</div>
<div class="field-group">
<label class="field-label" for="cs-junk-pc-max">Count max</label>
<input id="cs-junk-pc-max" class="field-input" type="number" min="0" max="100" placeholder="5" />
</div>
</div>
<div class="modal-row-2">
<div class="field-group">
<label class="field-label" for="cs-junk-ps-min">Size min (bytes)</label>
<input id="cs-junk-ps-min" class="field-input" type="number" min="1" placeholder="100" />
</div>
<div class="field-group">
<label class="field-label" for="cs-junk-ps-max">Size max (bytes)</label>
<input id="cs-junk-ps-max" class="field-input" type="number" min="1" placeholder="1000" />
</div>
</div>
<div class="modal-actions">
<button id="btn-junk-done" class="btn primary">Done</button>
</div>
</div>
</div>
<!-- ── TCP FRAGMENTATION MODAL ──────────────────────────────── -->
<div id="frag-modal" class="modal-overlay hidden">
<div class="modal-content compact">
<h3 class="modal-title">TCP Fragmentation</h3>
<p class="modal-text">Splits the initial handshake into tiny TCP segments with delays between them. Prevents DPI from reading the full handshake signature. Active in UoT/TCP mode only.</p>
<div class="modal-row-2">
<div class="field-group">
<label class="field-label" for="cs-frag-chunk">Chunk size (bytes)</label>
<input id="cs-frag-chunk" class="field-input" type="number" min="1" placeholder="2" />
</div>
<div class="field-group">
<label class="field-label" for="cs-frag-sleep">Delay (ms)</label>
<input id="cs-frag-sleep" class="field-input" type="number" min="0" placeholder="2" />
</div>
</div>
<div class="modal-actions">
<button id="btn-frag-done" class="btn primary">Done</button>
</div>
</div>
</div>
<!-- Toast notification --> <!-- Toast notification -->
<div id="toast" class="toast" role="status" aria-live="polite"></div> <div id="toast" class="toast" role="status" aria-live="polite"></div>

View File

@ -159,6 +159,22 @@ const inShowSpeed = $('in-show-speed');
const groupKillSwitch = $('group-kill-switch'); const groupKillSwitch = $('group-kill-switch');
const groupMuxSessions = $('group-mux-sessions'); const groupMuxSessions = $('group-mux-sessions');
const inJunkEnabled = $('cs-junk-enabled');
const btnJunkSettings = $('btn-junk-settings');
const junkModal = $('junk-modal');
const inJunkPcMin = $('cs-junk-pc-min');
const inJunkPcMax = $('cs-junk-pc-max');
const inJunkPsMin = $('cs-junk-ps-min');
const inJunkPsMax = $('cs-junk-ps-max');
const btnJunkDone = $('btn-junk-done');
const inTcpFrag = $('cs-tcp-frag');
const btnFragSettings = $('btn-frag-settings');
const fragModal = $('frag-modal');
const inFragChunk = $('cs-frag-chunk');
const inFragSleep = $('cs-frag-sleep');
const btnFragDone = $('btn-frag-done');
// ── UTILITIES ───────────────────────────────────────────────────────── // ── UTILITIES ─────────────────────────────────────────────────────────
function fmtBytes(b) { function fmtBytes(b) {
if (!b || b === 0) return '0 B'; if (!b || b === 0) return '0 B';
@ -307,11 +323,11 @@ function buildConfig() {
debug: !!s.debug, debug: !!s.debug,
transport: { transport: {
mode: active.transport || 'udp', mode: active.transport || 'udp',
tcp_fragmentation: !!active.tcp_fragmentation, tcp_fragmentation: s.tcpFrag || !!active.tcp_fragmentation,
frag_chunk: active.frag_chunk || 2, frag_chunk: s.tcpFrag ? (s.fragChunk || 2) : (active.frag_chunk || 2),
frag_sleep: active.frag_sleep || 2, frag_sleep: s.tcpFrag ? (!isNaN(parseInt(s.fragSleep)) ? s.fragSleep : 2) : (active.frag_sleep !== undefined ? active.frag_sleep : 2),
junk_pc: active.junk_pc || [2, 5], junk_pc: s.junkEnabled ? [s.junkPcMin || 2, s.junkPcMax || 5] : (active.junk_pc || [2, 5]),
junk_ps: active.junk_ps || [100, 1000] junk_ps: s.junkEnabled ? [s.junkPsMin || 100, s.junkPsMax || 1000] : (active.junk_ps || [100, 1000])
}, },
tun: { tun: {
enable: !!s.tun, enable: !!s.tun,
@ -668,6 +684,14 @@ function loadSettingsIntoForm() {
inDebug.checked = !!s.debug; inDebug.checked = !!s.debug;
inShowRtt.checked = s.showRtt !== false; inShowRtt.checked = s.showRtt !== false;
inShowSpeed.checked = s.showSpeed !== false; inShowSpeed.checked = s.showSpeed !== false;
inJunkEnabled.checked = !!s.junkEnabled;
inJunkPcMin.value = s.junkPcMin || 2;
inJunkPcMax.value = s.junkPcMax || 5;
inJunkPsMin.value = s.junkPsMin || 100;
inJunkPsMax.value = s.junkPsMax || 1000;
inTcpFrag.checked = !!s.tcpFrag;
inFragChunk.value = s.fragChunk || 2;
inFragSleep.value = !isNaN(parseInt(s.fragSleep)) ? s.fragSleep : 2;
updateClientVisibility(); updateClientVisibility();
} }
@ -688,6 +712,14 @@ function collectAndSaveSettings() {
debug: inDebug.checked, debug: inDebug.checked,
showRtt: inShowRtt.checked, showRtt: inShowRtt.checked,
showSpeed: inShowSpeed.checked, showSpeed: inShowSpeed.checked,
junkEnabled: inJunkEnabled.checked,
junkPcMin: parseInt(inJunkPcMin.value) || 2,
junkPcMax: parseInt(inJunkPcMax.value) || 5,
junkPsMin: parseInt(inJunkPsMin.value) || 100,
junkPsMax: parseInt(inJunkPsMax.value) || 1000,
tcpFrag: inTcpFrag.checked,
fragChunk: parseInt(inFragChunk.value) || 2,
fragSleep: !isNaN(parseInt(inFragSleep.value)) ? parseInt(inFragSleep.value) : 2,
}; };
saveClientSettings(s); saveClientSettings(s);
updateClientVisibility(); updateClientVisibility();
@ -881,9 +913,9 @@ window.addEventListener('DOMContentLoaded', async () => {
wintunModal.addEventListener('click', e => { if (e.target === wintunModal) wintunModal.classList.add('hidden'); }); wintunModal.addEventListener('click', e => { if (e.target === wintunModal) wintunModal.classList.add('hidden'); });
// Client settings — wire all inputs // Client settings — wire all inputs
[inTun, inKillSwitch, inMux, inAutoconnect, inLaunchStartup, inDebug, inShowRtt, inShowSpeed] [inTun, inKillSwitch, inMux, inAutoconnect, inLaunchStartup, inDebug, inShowRtt, inShowSpeed, inJunkEnabled, inTcpFrag]
.forEach(el => el.addEventListener('change', collectAndSaveSettings)); .forEach(el => el.addEventListener('change', collectAndSaveSettings));
[inMuxSessions, inMtu, inDns, inSocks, inExDomains, inExIps, inExProcs] [inMuxSessions, inMtu, inDns, inSocks, inExDomains, inExIps, inExProcs, inJunkPcMin, inJunkPcMax, inJunkPsMin, inJunkPsMax, inFragChunk, inFragSleep]
.forEach(el => { .forEach(el => {
el.addEventListener('input', () => { el.addEventListener('input', () => {
clearTimeout(el._saveTimer); clearTimeout(el._saveTimer);
@ -891,6 +923,21 @@ window.addEventListener('DOMContentLoaded', async () => {
}); });
}); });
// Junk and Frag modals
btnJunkSettings.addEventListener('click', () => junkModal.classList.remove('hidden'));
btnJunkDone.addEventListener('click', () => {
collectAndSaveSettings();
junkModal.classList.add('hidden');
});
junkModal.addEventListener('click', e => { if (e.target === junkModal) junkModal.classList.add('hidden'); });
btnFragSettings.addEventListener('click', () => fragModal.classList.remove('hidden'));
btnFragDone.addEventListener('click', () => {
collectAndSaveSettings();
fragModal.classList.add('hidden');
});
fragModal.addEventListener('click', e => { if (e.target === fragModal) fragModal.classList.add('hidden'); });
// ── Global Keyboard Shortcuts (TUI emulation) ───────────────────── // ── Global Keyboard Shortcuts (TUI emulation) ─────────────────────
window.addEventListener('keydown', async e => { window.addEventListener('keydown', async e => {
// Ignore if typing in an input or textarea // Ignore if typing in an input or textarea
@ -922,6 +969,8 @@ window.addEventListener('DOMContentLoaded', async () => {
!profileModal.classList.contains('hidden') || !profileModal.classList.contains('hidden') ||
!shareModal.classList.contains('hidden') || !shareModal.classList.contains('hidden') ||
!wintunModal.classList.contains('hidden') || !wintunModal.classList.contains('hidden') ||
!junkModal.classList.contains('hidden') ||
!fragModal.classList.contains('hidden') ||
!addMenu.classList.contains('hidden')) { !addMenu.classList.contains('hidden')) {
return; return;
} }

View File

@ -7,14 +7,15 @@
:root { :root {
--c-bg: #080808; --c-bg: #080808;
--c-surface: #111111; --c-surface: #111111;
--c-card: rgba(255,255,255,0.04); --c-card: rgba(var(--c-fg-rgb), 0.04);
--c-card-border: rgba(255,255,255,0.09); --c-card-border: rgba(var(--c-fg-rgb), 0.09);
--c-card-hover: rgba(255,255,255,0.07); --c-card-hover: rgba(var(--c-fg-rgb), 0.07);
--c-fg-rgb: 255, 255, 255;
/* Accent = white */ /* Accent = white */
--c-accent: #ffffff; --c-accent: #ffffff;
--c-accent-dim: rgba(255,255,255,0.08); --c-accent-dim: rgba(var(--c-fg-rgb),0.08);
--c-accent-glow: rgba(255,255,255,0.18); --c-accent-glow: rgba(var(--c-fg-rgb),0.18);
/* Green only for "connected" state */ /* Green only for "connected" state */
--c-green: #e8e8e8; --c-green: #e8e8e8;
@ -50,6 +51,7 @@
--c-card: rgba(0,0,0,0.03); --c-card: rgba(0,0,0,0.03);
--c-card-border: rgba(0,0,0,0.10); --c-card-border: rgba(0,0,0,0.10);
--c-card-hover: rgba(0,0,0,0.05); --c-card-hover: rgba(0,0,0,0.05);
--c-fg-rgb: 0, 0, 0;
--c-accent: #18181b; --c-accent: #18181b;
--c-accent-dim: rgba(0,0,0,0.08); --c-accent-dim: rgba(0,0,0,0.08);
--c-accent-glow: rgba(0,0,0,0.14); --c-accent-glow: rgba(0,0,0,0.14);
@ -187,7 +189,7 @@ a { text-decoration: none; }
transition: all var(--t-fast); transition: all var(--t-fast);
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
} }
.icon-btn:hover { border-color: rgba(255,255,255,0.18); color: var(--c-txt-1); background: rgba(255,255,255,0.07); } .icon-btn:hover { border-color: rgba(var(--c-fg-rgb),0.18); color: var(--c-txt-1); background: rgba(var(--c-fg-rgb),0.07); }
.icon-btn:active { transform: scale(0.91); } .icon-btn:active { transform: scale(0.91); }
.add-btn:hover { border-color: var(--c-accent); color: var(--c-accent); } .add-btn:hover { border-color: var(--c-accent); color: var(--c-accent); }
@ -223,19 +225,19 @@ a { text-decoration: none; }
.orbit-wrap.connecting .orbit { .orbit-wrap.connecting .orbit {
animation: orbit-spin 2.5s linear infinite; animation: orbit-spin 2.5s linear infinite;
border-color: rgba(255,255,255,0.12); border-color: rgba(var(--c-fg-rgb),0.12);
opacity: 1; opacity: 1;
} }
.orbit-wrap.connecting .orbit-2 { animation-duration: 3.8s; animation-direction: reverse; border-color: rgba(255,255,255,0.07); } .orbit-wrap.connecting .orbit-2 { animation-duration: 3.8s; animation-direction: reverse; border-color: rgba(var(--c-fg-rgb),0.07); }
.orbit-wrap.connecting .orbit-3 { animation-duration: 5.5s; border-color: rgba(255,255,255,0.04); } .orbit-wrap.connecting .orbit-3 { animation-duration: 5.5s; border-color: rgba(var(--c-fg-rgb),0.04); }
.orbit-wrap.connected .orbit { .orbit-wrap.connected .orbit {
animation: orbit-spin 4s linear infinite; animation: orbit-spin 4s linear infinite;
border-color: rgba(255,255,255,0.14); border-color: rgba(var(--c-fg-rgb),0.14);
opacity: 1; opacity: 1;
} }
.orbit-wrap.connected .orbit-2 { animation-duration: 6s; animation-direction: reverse; border-color: rgba(255,255,255,0.08); } .orbit-wrap.connected .orbit-2 { animation-duration: 6s; animation-direction: reverse; border-color: rgba(var(--c-fg-rgb),0.08); }
.orbit-wrap.connected .orbit-3 { animation-duration: 9s; border-color: rgba(255,255,255,0.04); } .orbit-wrap.connected .orbit-3 { animation-duration: 9s; border-color: rgba(var(--c-fg-rgb),0.04); }
@keyframes orbit-spin { @keyframes orbit-spin {
from { transform: rotate(0deg); } from { transform: rotate(0deg); }
@ -253,24 +255,24 @@ a { text-decoration: none; }
color: var(--c-txt-3); color: var(--c-txt-3);
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
transition: all var(--t-slow); transition: all var(--t-slow);
box-shadow: 0 0 0 8px rgba(255,255,255,0.015), 0 8px 36px rgba(0,0,0,0.7); box-shadow: 0 0 0 8px rgba(var(--c-fg-rgb),0.015), 0 8px 36px rgba(0,0,0,0.7);
} }
.power-btn:hover { .power-btn:hover {
border-color: rgba(255,255,255,0.4); border-color: rgba(var(--c-fg-rgb),0.4);
color: var(--c-txt-1); color: var(--c-txt-1);
box-shadow: 0 0 0 10px rgba(255,255,255,0.04), 0 0 40px rgba(255,255,255,0.08), 0 8px 36px rgba(0,0,0,0.6); box-shadow: 0 0 0 10px rgba(var(--c-fg-rgb),0.04), 0 0 40px rgba(var(--c-fg-rgb),0.08), 0 8px 36px rgba(0,0,0,0.6);
transform: scale(1.04); transform: scale(1.04);
} }
.power-btn:active { transform: scale(0.96); } .power-btn:active { transform: scale(0.96); }
.power-btn.connecting { .power-btn.connecting {
border-color: rgba(255,255,255,0.5); border-color: rgba(var(--c-fg-rgb),0.5);
color: var(--c-txt-1); color: var(--c-txt-1);
animation: btn-breathe 2s infinite ease-in-out; animation: btn-breathe 2s infinite ease-in-out;
} }
.power-btn.connected { .power-btn.connected {
border-color: rgba(255,255,255,0.8); border-color: rgba(var(--c-fg-rgb),0.8);
color: var(--c-txt-1); color: var(--c-txt-1);
box-shadow: 0 0 0 8px rgba(255,255,255,0.04), 0 0 50px rgba(255,255,255,0.12), 0 8px 32px rgba(0,0,0,0.5); box-shadow: 0 0 0 8px rgba(var(--c-fg-rgb),0.04), 0 0 50px rgba(var(--c-fg-rgb),0.12), 0 8px 32px rgba(0,0,0,0.5);
} }
.power-btn.error { .power-btn.error {
border-color: var(--c-red); border-color: var(--c-red);
@ -278,8 +280,8 @@ a { text-decoration: none; }
} }
@keyframes btn-breathe { @keyframes btn-breathe {
0%,100% { box-shadow: 0 0 0 8px rgba(255,255,255,0.02), 0 0 20px rgba(255,255,255,0.05), 0 8px 32px rgba(0,0,0,0.6); } 0%,100% { box-shadow: 0 0 0 8px rgba(var(--c-fg-rgb),0.02), 0 0 20px rgba(var(--c-fg-rgb),0.05), 0 8px 32px rgba(0,0,0,0.6); }
50% { box-shadow: 0 0 0 12px rgba(255,255,255,0.06), 0 0 50px rgba(255,255,255,0.12), 0 8px 32px rgba(0,0,0,0.5); } 50% { box-shadow: 0 0 0 12px rgba(var(--c-fg-rgb),0.06), 0 0 50px rgba(var(--c-fg-rgb),0.12), 0 8px 32px rgba(0,0,0,0.5); }
} }
.power-icon { display: flex; align-items: center; justify-content: center; transition: transform var(--t-med); } .power-icon { display: flex; align-items: center; justify-content: center; transition: transform var(--t-med); }
@ -294,7 +296,7 @@ a { text-decoration: none; }
color: var(--c-txt-2); color: var(--c-txt-2);
transition: color var(--t-med); transition: color var(--t-med);
} }
.status-label.is-connecting { color: rgba(255,255,255,0.7); } .status-label.is-connecting { color: rgba(var(--c-fg-rgb),0.7); }
.status-label.is-connected { color: var(--c-txt-1); } .status-label.is-connected { color: var(--c-txt-1); }
.status-label.is-error { color: var(--c-red); } .status-label.is-error { color: var(--c-red); }
.status-sub { font-size: 0.72rem; color: var(--c-txt-2); opacity: 0.7; letter-spacing: 0.1px; } .status-sub { font-size: 0.72rem; color: var(--c-txt-2); opacity: 0.7; letter-spacing: 0.1px; }
@ -334,10 +336,10 @@ a { text-decoration: none; }
gap: 8px; gap: 8px;
padding: 8px 16px; padding: 8px 16px;
border-radius: var(--r-full); border-radius: var(--r-full);
background: rgba(255,255,255,0.05); background: rgba(var(--c-fg-rgb),0.05);
border: 1px solid rgba(255,255,255,0.10); border: 1px solid rgba(var(--c-fg-rgb),0.10);
font-size: 0.82rem; font-size: 0.82rem;
color: rgba(255,255,255,0.6); color: rgba(var(--c-fg-rgb),0.6);
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
font-weight: 500; font-weight: 500;
} }
@ -347,8 +349,8 @@ a { text-decoration: none; }
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0; gap: 0;
background: rgba(255,255,255,0.03); background: rgba(var(--c-fg-rgb),0.03);
border: 1px solid rgba(255,255,255,0.07); border: 1px solid rgba(var(--c-fg-rgb),0.07);
border-radius: var(--r-lg); border-radius: var(--r-lg);
overflow: hidden; overflow: hidden;
} }
@ -381,7 +383,7 @@ a { text-decoration: none; }
.live-stat-value.rtt-warn { color: var(--c-amber); } .live-stat-value.rtt-warn { color: var(--c-amber); }
.live-stat-value.rtt-bad { color: var(--c-red); } .live-stat-value.rtt-bad { color: var(--c-red); }
.live-stat-sep { width: 1px; height: 36px; background: rgba(255,255,255,0.07); flex-shrink: 0; } .live-stat-sep { width: 1px; height: 36px; background: rgba(var(--c-fg-rgb),0.07); flex-shrink: 0; }
/* ── Metrics bar ─────────────────────────────────────────────────────── */ /* ── Metrics bar ─────────────────────────────────────────────────────── */
.metrics-bar { .metrics-bar {
@ -389,21 +391,21 @@ a { text-decoration: none; }
align-items: center; align-items: center;
padding: 20px; padding: 20px;
flex-shrink: 0; flex-shrink: 0;
background: rgba(255,255,255,0.025); background: rgba(var(--c-fg-rgb),0.025);
border-top: 1px solid rgba(255,255,255,0.06); border-top: 1px solid rgba(var(--c-fg-rgb),0.06);
margin: 0 -20px; margin: 0 -20px;
padding-inline: 20px; padding-inline: 20px;
} }
.metric { flex: 1; display: flex; align-items: center; justify-content: center; gap: 11px; } .metric { flex: 1; display: flex; align-items: center; justify-content: center; gap: 11px; }
.metric-sep { width: 1px; height: 36px; background: rgba(255,255,255,0.08); flex-shrink: 0; } .metric-sep { width: 1px; height: 36px; background: rgba(var(--c-fg-rgb),0.08); flex-shrink: 0; }
.metric-icon { .metric-icon {
width: 34px; height: 34px; width: 34px; height: 34px;
border-radius: 9px; border-radius: 9px;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
} }
.down-icon { background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.5); } .down-icon { background: rgba(var(--c-fg-rgb),0.06); color: rgba(var(--c-fg-rgb),0.5); }
.up-icon { background: rgba(255,255,255,0.04); color: rgba(255,255,255,0.35); } .up-icon { background: rgba(var(--c-fg-rgb),0.04); color: rgba(var(--c-fg-rgb),0.35); }
.metric-body { display: flex; flex-direction: column; gap: 1px; } .metric-body { display: flex; flex-direction: column; gap: 1px; }
.metric-label { font-size: 0.62rem; color: var(--c-txt-2); text-transform: uppercase; letter-spacing: 0.8px; font-weight: 700; } .metric-label { font-size: 0.62rem; color: var(--c-txt-2); text-transform: uppercase; letter-spacing: 0.8px; font-weight: 700; }
.metric-value { font-size: 0.95rem; font-weight: 700; font-family: 'JetBrains Mono', monospace; color: var(--c-txt-1); font-variant-numeric: tabular-nums; } .metric-value { font-size: 0.95rem; font-weight: 700; font-family: 'JetBrains Mono', monospace; color: var(--c-txt-1); font-variant-numeric: tabular-nums; }
@ -442,7 +444,7 @@ a { text-decoration: none; }
line-height: 1.6; line-height: 1.6;
} }
.profile-empty svg { flex-shrink: 0; } .profile-empty svg { flex-shrink: 0; }
.profile-empty strong { color: rgba(255,255,255,0.5); } .profile-empty strong { color: rgba(var(--c-fg-rgb),0.5); }
/* ── Profile card ────────────────────────────────────────────────────── */ /* ── Profile card ────────────────────────────────────────────────────── */
.profile-card { .profile-card {
@ -457,10 +459,10 @@ a { text-decoration: none; }
transition: all var(--t-fast); transition: all var(--t-fast);
cursor: pointer; cursor: pointer;
} }
.profile-card:hover { border-color: rgba(255,255,255,0.14); background: var(--c-card-hover); } .profile-card:hover { border-color: rgba(var(--c-fg-rgb),0.14); background: var(--c-card-hover); }
.profile-card.active { .profile-card.active {
border-color: rgba(255,255,255,0.4); border-color: rgba(var(--c-fg-rgb),0.4);
background: rgba(255,255,255,0.06); background: rgba(var(--c-fg-rgb),0.06);
} }
.profile-radio { .profile-radio {
@ -507,7 +509,7 @@ a { text-decoration: none; }
letter-spacing: 0.5px; letter-spacing: 0.5px;
padding: 2px 5px; padding: 2px 5px;
border-radius: 4px; border-radius: 4px;
background: rgba(255,255,255,0.06); background: rgba(var(--c-fg-rgb),0.06);
color: var(--c-txt-2); color: var(--c-txt-2);
text-transform: uppercase; text-transform: uppercase;
flex-shrink: 0; flex-shrink: 0;
@ -521,7 +523,7 @@ a { text-decoration: none; }
color: var(--c-txt-2); color: var(--c-txt-2);
transition: all var(--t-fast); transition: all var(--t-fast);
} }
.profile-action-btn:hover { color: var(--c-txt-1); background: rgba(255,255,255,0.07); } .profile-action-btn:hover { color: var(--c-txt-1); background: rgba(var(--c-fg-rgb),0.07); }
/* ── Section dividers ────────────────────────────────────────────────── */ /* ── Section dividers ────────────────────────────────────────────────── */
.section-divider { .section-divider {
@ -535,7 +537,7 @@ a { text-decoration: none; }
letter-spacing: 1px; letter-spacing: 1px;
text-transform: uppercase; text-transform: uppercase;
} }
.section-divider::after { content: ''; flex: 1; height: 1px; background: rgba(255,255,255,0.06); } .section-divider::after { content: ''; flex: 1; height: 1px; background: rgba(var(--c-fg-rgb),0.06); }
.section-divider-mini { .section-divider-mini {
display: flex; display: flex;
@ -548,7 +550,7 @@ a { text-decoration: none; }
letter-spacing: 1px; letter-spacing: 1px;
text-transform: uppercase; text-transform: uppercase;
} }
.section-divider-mini::after { content: ''; flex: 1; height: 1px; background: rgba(255,255,255,0.04); } .section-divider-mini::after { content: ''; flex: 1; height: 1px; background: rgba(var(--c-fg-rgb),0.04); }
/* ── Client settings card ────────────────────────────────────────────── */ /* ── Client settings card ────────────────────────────────────────────── */
.client-settings-card { .client-settings-card {
@ -564,11 +566,11 @@ a { text-decoration: none; }
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 13px 14px; padding: 13px 14px;
border-top: 1px solid rgba(255,255,255,0.04); border-top: 1px solid rgba(var(--c-fg-rgb),0.04);
transition: background var(--t-fast); transition: background var(--t-fast);
} }
.toggle-row:first-child { border-top: none; } .toggle-row:first-child { border-top: none; }
.toggle-row:hover { background: rgba(255,255,255,0.025); } .toggle-row:hover { background: rgba(var(--c-fg-rgb),0.025); }
.toggle-row.sub-row { padding-left: 30px; } .toggle-row.sub-row { padding-left: 30px; }
.toggle-text { display: flex; flex-direction: column; gap: 2px; } .toggle-text { display: flex; flex-direction: column; gap: 2px; }
@ -582,14 +584,14 @@ a { text-decoration: none; }
display: block; display: block;
width: 36px; height: 20px; width: 36px; height: 20px;
border-radius: var(--r-full); border-radius: var(--r-full);
background: rgba(255,255,255,0.08); background: rgba(var(--c-fg-rgb),0.08);
border: 1px solid rgba(255,255,255,0.1); border: 1px solid rgba(var(--c-fg-rgb),0.1);
transition: all var(--t-med); transition: all var(--t-med);
position: relative; position: relative;
} }
.toggle input:checked ~ .toggle-track { .toggle input:checked ~ .toggle-track {
background: rgba(255,255,255,0.15); background: rgba(var(--c-fg-rgb),0.15);
border-color: rgba(255,255,255,0.35); border-color: rgba(var(--c-fg-rgb),0.35);
} }
.toggle-thumb { .toggle-thumb {
position: absolute; position: absolute;
@ -604,13 +606,29 @@ a { text-decoration: none; }
background: var(--c-accent); background: var(--c-accent);
} }
.toggle-with-gear {
display: flex;
align-items: center;
gap: 10px;
}
.gear-btn {
width: 26px; height: 26px;
display: flex; align-items: center; justify-content: center;
border-radius: var(--r-xs);
color: var(--c-txt-2);
transition: all var(--t-fast);
background: transparent;
}
.gear-btn:hover { color: var(--c-txt-1); background: rgba(var(--c-fg-rgb),0.07); }
:root.light .gear-btn:hover { background: rgba(0,0,0,0.05); }
/* ── Inline field (label + input on one line) ────────────────────────── */ /* ── Inline field (label + input on one line) ────────────────────────── */
.inline-field { .inline-field {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 10px 14px; padding: 10px 14px;
border-top: 1px solid rgba(255,255,255,0.04); border-top: 1px solid rgba(var(--c-fg-rgb),0.04);
gap: 12px; gap: 12px;
} }
.inline-field.sub-row { padding-left: 30px; } .inline-field.sub-row { padding-left: 30px; }
@ -643,8 +661,8 @@ a { text-decoration: none; }
.field-input { .field-input {
width: 100%; width: 100%;
background: rgba(255,255,255,0.05); background: rgba(var(--c-fg-rgb),0.05);
border: 1px solid rgba(255,255,255,0.08); border: 1px solid rgba(var(--c-fg-rgb),0.08);
border-radius: var(--r-sm); border-radius: var(--r-sm);
padding: 9px 11px; padding: 9px 11px;
color: var(--c-txt-1); color: var(--c-txt-1);
@ -653,8 +671,8 @@ a { text-decoration: none; }
transition: border-color var(--t-fast), box-shadow var(--t-fast); transition: border-color var(--t-fast), box-shadow var(--t-fast);
} }
.field-input:focus { .field-input:focus {
border-color: rgba(255,255,255,0.3); border-color: rgba(var(--c-fg-rgb),0.3);
box-shadow: 0 0 0 3px rgba(255,255,255,0.04); box-shadow: 0 0 0 3px rgba(var(--c-fg-rgb),0.04);
} }
.field-input::placeholder { color: var(--c-txt-3); } .field-input::placeholder { color: var(--c-txt-3); }
.field-input.mono { font-family: 'JetBrains Mono', monospace; font-size: 0.8rem; } .field-input.mono { font-family: 'JetBrains Mono', monospace; font-size: 0.8rem; }
@ -703,7 +721,7 @@ textarea.field-input {
color: var(--c-txt-2); color: var(--c-txt-2);
border: 1px solid var(--c-card-border); border: 1px solid var(--c-card-border);
} }
.btn.secondary:hover { border-color: rgba(255,255,255,0.2); color: var(--c-txt-1); } .btn.secondary:hover { border-color: rgba(var(--c-fg-rgb),0.2); color: var(--c-txt-1); }
.btn.danger { .btn.danger {
background: rgba(255,95,95,0.12); background: rgba(255,95,95,0.12);
color: var(--c-red); color: var(--c-red);
@ -718,7 +736,7 @@ textarea.field-input {
top: 56px; right: 20px; top: 56px; right: 20px;
z-index: 100; z-index: 100;
background: #1a1a1a; background: #1a1a1a;
border: 1px solid rgba(255,255,255,0.12); border: 1px solid rgba(var(--c-fg-rgb),0.12);
border-radius: var(--r-md); border-radius: var(--r-md);
overflow: hidden; overflow: hidden;
box-shadow: 0 16px 48px rgba(0,0,0,0.8); box-shadow: 0 16px 48px rgba(0,0,0,0.8);
@ -742,13 +760,13 @@ textarea.field-input {
color: var(--c-txt-1); color: var(--c-txt-1);
background: transparent; background: transparent;
border: none; border: none;
border-bottom: 1px solid rgba(255,255,255,0.05); border-bottom: 1px solid rgba(var(--c-fg-rgb),0.05);
transition: background var(--t-fast); transition: background var(--t-fast);
min-width: 180px; min-width: 180px;
text-align: left; text-align: left;
} }
.add-menu-item:last-child { border-bottom: none; } .add-menu-item:last-child { border-bottom: none; }
.add-menu-item:hover { background: rgba(255,255,255,0.06); } .add-menu-item:hover { background: rgba(var(--c-fg-rgb),0.06); }
.add-menu-item svg { color: var(--c-txt-2); flex-shrink: 0; } .add-menu-item svg { color: var(--c-txt-2); flex-shrink: 0; }
/* ── Modals ──────────────────────────────────────────────────────────── */ /* ── Modals ──────────────────────────────────────────────────────────── */
@ -772,7 +790,7 @@ textarea.field-input {
.modal-content { .modal-content {
background: #111111; background: #111111;
border: 1px solid rgba(255,255,255,0.1); border: 1px solid rgba(var(--c-fg-rgb),0.1);
border-radius: var(--r-lg); border-radius: var(--r-lg);
padding: 20px; padding: 20px;
width: calc(100% - 40px); width: calc(100% - 40px);
@ -810,7 +828,7 @@ textarea.field-input {
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem; font-size: 0.75rem;
color: var(--c-txt-1); color: var(--c-txt-1);
background: rgba(255,255,255,0.06); background: rgba(var(--c-fg-rgb),0.06);
padding: 1px 5px; padding: 1px 5px;
border-radius: 3px; border-radius: 3px;
} }
@ -842,7 +860,7 @@ textarea.field-input {
left: 50%; transform: translateX(-50%); left: 50%; transform: translateX(-50%);
z-index: 500; z-index: 500;
background: rgba(24,24,24,0.96); background: rgba(24,24,24,0.96);
border: 1px solid rgba(255,255,255,0.1); border: 1px solid rgba(var(--c-fg-rgb),0.1);
color: var(--c-txt-1); color: var(--c-txt-1);
font-size: 0.78rem; font-size: 0.78rem;
font-weight: 500; font-weight: 500;

View File

@ -2,6 +2,7 @@
name = "ostp-jni" name = "ostp-jni"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
license = "AGPL-3.0"
[lib] [lib]
name = "ostp_jni" name = "ostp_jni"

View File

@ -305,15 +305,23 @@ impl Dispatcher {
// Not an existing session — try each registered access key's derived obfuscation key // Not an existing session — try each registered access key's derived obfuscation key
let keys_snapshot: Vec<String> = self.access_keys.read().unwrap_or_else(|e| e.into_inner()).keys().cloned().collect(); let keys_snapshot: Vec<String> = self.access_keys.read().unwrap_or_else(|e| e.into_inner()).keys().cloned().collect();
// Junk marker rotates per time window; check the current and previous
// window so a client whose clock is up to ~1 window behind/ahead is still
// recognised. Computed once per datagram, not per candidate key.
let junk_window = ostp_core::crypto::current_junk_window();
for candidate_key in keys_snapshot { for candidate_key in keys_snapshot {
let secrets = ostp_core::crypto::derive_all_secrets(candidate_key.as_bytes()); let secrets = ostp_core::crypto::derive_all_secrets(candidate_key.as_bytes());
// Junk frames carry this key's per-key derived marker (no global // Junk frames carry this key's time-rotating marker (no global
// constant → no universal DPI signature). Drop silently — the secrets // constant, no static per-user signature). Drop silently.
// for this key are already derived here, so the check is free. if packet.len() >= 4 {
if packet.len() >= 4 && packet[0..4] == secrets.junk_marker { let m_now = ostp_core::crypto::derive_junk_marker(candidate_key.as_bytes(), junk_window);
let m_prev = ostp_core::crypto::derive_junk_marker(candidate_key.as_bytes(), junk_window.wrapping_sub(1));
if packet[0..4] == m_now || packet[0..4] == m_prev {
return Ok(DispatchOutcome::Junk); return Ok(DispatchOutcome::Junk);
} }
}
// Decode the session_id using this key's obfuscation // Decode the session_id using this key's obfuscation
// The handshake mask is derived from the Noise payload at bytes [6..], // The handshake mask is derived from the Noise payload at bytes [6..],

View File

@ -2,6 +2,7 @@
name = "ostp-tun-helper" name = "ostp-tun-helper"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true
[[bin]] [[bin]]
name = "ostp-tun-helper" name = "ostp-tun-helper"

View File

@ -14,10 +14,8 @@ use portable_atomic::Ordering;
fn log_to_file(msg: &str) { fn log_to_file(msg: &str) {
let msg = msg.to_string(); let msg = msg.to_string();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let path = std::env::current_exe() // Same shared ostp.log as everything else — not a separate ostp-helper.log.
.ok() let path = ostp_client::logging::log_file_path();
.and_then(|p| p.parent().map(|d| d.join("ostp-helper.log")))
.unwrap_or_else(|| std::path::PathBuf::from("ostp-helper.log"));
if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(path) { if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(path) {
let _ = writeln!(file, "[{}] {}", chrono::Local::now().format("%Y-%m-%d %H:%M:%S"), msg); let _ = writeln!(file, "[{}] {}", chrono::Local::now().format("%Y-%m-%d %H:%M:%S"), msg);
} }
@ -53,7 +51,10 @@ struct TunnelState {
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
ostp_client::logging::setup_panic_hook(); ostp_client::logging::setup_panic_hook();
let _log_guard = ostp_client::logging::init_tracing("info", "ostp-helper", env!("CARGO_PKG_VERSION")); // The helper is a child of the GUI, which already truncated the shared log at
// its own startup — pass false so the helper APPENDS instead of wiping the
// GUI's session log.
let _log_guard = ostp_client::logging::init_tracing("info", "ostp-helper", env!("CARGO_PKG_VERSION"), false);
if let Ok(exe) = std::env::current_exe() { if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() { if let Some(dir) = exe.parent() {

File diff suppressed because one or more lines are too long

19
ostp-web/dist/assets/index-DnZZkzK0.js vendored Normal file

File diff suppressed because one or more lines are too long

BIN
ostp-web/dist/favicon.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

12
ostp-web/dist/favicon.svg vendored Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 22 KiB

24
ostp-web/dist/icons.svg vendored Normal file
View File

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

19
ostp-web/dist/index.html vendored Normal file
View File

@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OSTP — Stealth Transport Protocol</title>
<meta name="description" content="Ospab Stealth Transport Protocol. An asynchronous networking framework designed for secure, resilient, and unidentifiable data transmission." />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&family=Outfit:wght@300;500;700&family=Playfair+Display:ital,wght@0,400;0,700;1,400&family=Syncopate:wght@400;700&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-DnZZkzK0.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CBAXD1xO.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

12
ostp-web/dist/logo.svg vendored Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -1,3 +0,0 @@
# OSTP Wiki
This repository contains the documentation and wiki pages for the Ospab Stealth Transport Protocol (OSTP).

View File

@ -1,149 +0,0 @@
# Справочник API управления OSTP
Сервер OSTP предоставляет REST API для управления пользователями, просмотра статистики трафика и интерактивного редактирования конфигурации.
По умолчанию API слушает на порту `9090` (хост настраивается в файле конфигурации).
---
## Авторизация
Все запросы к API (за исключением подписок) должны содержать заголовок `Authorization` с API-токеном (если токен включен в конфигурационном файле):
```http
Authorization: Bearer <ваш_api_токен>
```
Или в упрощенном виде:
```http
Authorization: <ваш_api_токен>
```
---
## Формат ответов
Все ответы API возвращаются в формате JSON следующей структуры:
```json
{
"ok": true,
"data": ...,
"error": null
}
```
В случае ошибки:
```json
{
"ok": false,
"data": null,
"error": "Описание ошибки"
}
```
---
## Список эндпоинтов
### 1. Статус сервера
Возвращает текущую версию, аптайм и количество пользователей.
* **URL**: `/api/server/status`
* **Метод**: `GET`
* **Формат `data`**:
```json
{
"version": "0.2.30",
"uptime_seconds": 12053,
"active_users": 2,
"total_users": 5
}
```
### 2. Получение текущего конфига
Запрашивает полное содержимое файла `config.json` с удалением комментариев для прямой модификации.
* **URL**: `/api/server/config`
* **Метод**: `GET`
* **Формат `data`**: Полный JSON-конфиг сервера.
### 3. Обновление конфига
Записывает новый JSON конфигурации сервера в файл `config.json` на диске. Это автоматически вызывает **hot-reload** ядра (применение ключей доступа и лимитов).
* **URL**: `/api/server/config`
* **Метод**: `PUT`
* **Тело запроса**: JSON нового конфигурационного файла.
* **Формат `data`**: `true` в случае успешного сохранения.
### 4. Список клиентов и их статистики
Возвращает список всех зарегистрированных ключей доступа с их текущей загрузкой, скачиванием, активными сессиями и статусом подключения.
* **URL**: `/api/users`
* **Метод**: `GET`
* **Формат `data`**:
```json
[
{
"access_key": "ostp_key_sample1",
"bytes_up": 2405020,
"bytes_down": 491029402,
"connections": 2,
"limit_bytes": 10737418240,
"online": true,
"name": "Ноутбук"
}
]
```
### 5. Создание клиента
Генерирует новый ключ доступа (или регистрирует пользовательский).
* **URL**: `/api/users`
* **Метод**: `POST`
* **Тело запроса**:
```json
{
"access_key": "my_custom_key_optional",
"name": "Имя клиента",
"limit_bytes": 50000000000
}
```
* **Формат `data`**: Строка созданного ключа доступа.
### 6. Удаление клиента
Отзывает ключ доступа и сбрасывает все связанные активные сессии.
* **URL**: `/api/users/:key`
* **Метод**: `DELETE`
* **Формат `data`**: `"User removed"`
### 7. Обновление клиента
Редактирует имя или лимит трафика для клиента.
* **URL**: `/api/users/:key`
* **Метод**: `PUT`
* **Тело запроса**:
```json
{
"name": "Новое имя",
"limit_bytes": 100000000000
}
```
* **Формат `data`**: `"User updated"`
### 8. Сброс счетчиков трафика
Обнуляет показания загрузки и скачивания для определенного пользователя.
* **URL**: `/api/users/{key}/reset`
* **Метод**: `POST`
* **Формат `data`**: `true`
### 9. Ссылка подписки клиента
Возвращает ссылку подписки или конфигурационный файл для клиента. Авторизация по Bearer-токену **не требуется** (ключ авторизуется сам через URL).
* **URL**: `/api/subscribe/:key`
* **Метод**: `GET`
* **Заголовки**:
- `Accept: text/plain` -> Возвращает текстовую ссылку `ostp://<key>@<host>:<port>?...`
- `Accept: application/json` -> Возвращает полный клиентский JSON-конфиг.

View File

@ -1,125 +0,0 @@
# Руководство по конфигурации OSTP (`config.json`)
Файл `config.json` является основным конфигурационным файлом для сервера, клиента и реле.
Ниже приведено подробное описание структуры для режима работы **Server**.
---
## Полный пример конфигурации
```json
{
"mode": "server",
"log_level": "info",
"listen": "0.0.0.0:50000",
"access_keys": [
"some_simple_key",
{
"access_key": "detailed_key_with_limit",
"name": "Рабочий Ноутбук",
"limit_bytes": 107374182400
}
],
"api": {
"enabled": true,
"bind": "127.0.0.1:9090",
"token": "7a3f8b2c4d9e0f1a2b3c4d5e6f7a8b9c"
},
"fallback": {
"enabled": false,
"listen": "0.0.0.0:443",
"target": "127.0.0.1:8080"
},
"reality": {
"enabled": false,
"dest": "www.microsoft.com:443",
"private_key": "...",
"pbk": "...",
"sid": "...",
"sni_list": ["www.microsoft.com"]
},
"outbound": {
"enabled": false,
"protocol": "socks5",
"address": "127.0.0.1",
"port": 9050,
"default_action": "proxy",
"rules": [
{
"domain_suffix": [".onion"],
"action": "proxy"
}
]
},
"debug": false
}
```
---
## Описание разделов конфигурации
### 1. Основные параметры
- **`mode`** (строка): Режим работы. Возможные варианты: `"server"`, `"client"`, `"relay"`.
- **`log_level`** (строка): Уровень логирования. Варианты: `"debug"`, `"info"`, `"warn"`, `"error"`.
- **`listen`** (строка или массив строк): Порт и интерфейсы, на которых сервер слушает входящие UDP (и опционально TCP/UoT) соединения. Примеры:
- `"0.0.0.0:50000"` (все IPv4 интерфейсы)
- `["0.0.0.0:50000", "[::]:50000"]` (поддержка IPv4 и IPv6 одновременно)
- **`debug`** (логический): Включает подробное отладочное логирование протокола.
---
### 2. Ключи доступа (`access_keys`)
Раздел содержит массив ключей доступа. Поддерживается два формата записи (для обратной совместимости):
1. **Простая строка**: Текст ключа доступа. Лимит трафика отсутствует.
```json
"my_secure_key"
```
2. **Объект с метаданными**:
- `access_key` (строка, обязательно): Текст ключа для подключения.
- `name` (строка, опционально): Человекочитаемое описание клиента.
- `limit_bytes` (число, опционально): Лимит трафика в байтах (загрузка + скачивание).
При достижении `limit_bytes` сессия клиента немедленно сбрасывается и подключение блокируется до обнуления счетчика или расширения лимита.
---
### 3. REST API Управления (`api`)
Используется для интеграции с панелью управления `ostp-control`.
- **`enabled`** (логический): Включение встроенного веб-сервера API.
- **`bind`** (строка): Интерфейс и порт для прослушивания (например, `"127.0.0.1:9090"`).
- **`token`** (строка): Bearer-токен для авторизации администратора. Автоматически генерируется сервером при команде `ostp --init server`.
---
### 4. Встроенный TCP Fallback прокси (`fallback`)
Позволяет маскировать порт под веб-сервер при сканировании активными DPI-зондами.
- **`enabled`** (логический): Включить проксирование TCP.
- **`listen`** (строка): Порт прослушивания TCP/TLS (например, `"0.0.0.0:443"`).
- **`target`** (строка): Локальный веб-сервер (например, `"127.0.0.1:8080"` на nginx/caddy), куда будут пересылаться все обычные запросы (не-OSTP трафик).
---
### 5. Reality Маскировка (`reality`)
Реализует спецификацию XTLS-Reality для бесшовной маскировки трафика под легитимный TLS-сервер.
- **`enabled`** (логический): Включение маскировки.
- **`dest`** (строка): Целевой домен маскировки (например, `"www.microsoft.com:443"`).
- **`private_key`** (строка): Приватный ключ Reality сервера (X25519).
- **`pbk`** (строка): Публичный ключ Reality сервера.
- **`sid`** (строка, 8 байт hex): Идентификатор сессии.
- **`sni_list`** (массив строк): Разрешенные SNI заголовки от клиентов.
---
### 6. Правила маршрутизации (`outbound`)
Позволяет пересылать часть исходящего трафика клиентов через прокси-сервер (например, SOCKS5/TOR).
- **`enabled`** (логический): Включить исходящую маршрутизацию.
- **`protocol`** (строка): Протокол прокси. На данный момент поддерживается `"socks5"`.
- **`address`** (строка): Хост прокси-сервера.
- **`port`** (число): Порт прокси-сервера.
- **`default_action`** (строка): Действие для трафика, не попавшего под правила. Варианты: `"direct"` (напрямую с сервера) или `"proxy"` (через прокси).
- **`rules`** (массив объектов): Список правил перенаправления:
- `domain_suffix` (массив строк): Фильтрация по суффиксу домена.
- `ip_cidr` (массив строк): Фильтрация по IP подсетям.
- `action` (строка): Действие при совпадении (`"direct"` или `"proxy"`).

1
ostp.wiki Submodule

@ -0,0 +1 @@
Subproject commit 90810f25f7af9e0a57bacce3d74ca3e46a6433e4

View File

@ -1,6 +1,5 @@
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use clap::Parser; use clap::Parser;
use serde::{Deserialize, Serialize};
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use colored::Colorize; use colored::Colorize;
@ -30,12 +29,13 @@ enum Commands {
mode: String, mode: String,
}, },
/// Generate a new secure access key /// Generate a new secure access key
#[command(name = "gk", alias = "generate-key")]
GenerateKey { GenerateKey {
/// Format for generated key (hex, base64) /// Format for generated key (hex, base64)
#[arg(long, default_value = "hex")] #[arg(long, default_value = "hex")]
format: String, format: String,
/// Number of keys to generate /// Number of keys to generate
// NOT short='c' `--config` is a global arg (propagated into every // NOT short='c' - `--config` is a global arg (propagated into every
// subcommand's scope), so a local '-c' here would collide with it. // subcommand's scope), so a local '-c' here would collide with it.
// Clap validates the whole command tree on the first parse() and // Clap validates the whole command tree on the first parse() and
// panics on a duplicate short flag, breaking the ENTIRE CLI. // panics on a duplicate short flag, breaking the ENTIRE CLI.
@ -54,7 +54,7 @@ enum Commands {
Uninstall, Uninstall,
/// Update OSTP: re-run the install script to fetch and install the latest version /// Update OSTP: re-run the install script to fetch and install the latest version
Update { Update {
/// Release branch to update from (stable, pre-release, nightly) /// Release branch to update from (stable, pre-release, alpha)
#[arg(short = 'b', long, default_value = "stable")] #[arg(short = 'b', long, default_value = "stable")]
branch: String, branch: String,
/// Exact release version to update to (e.g. 0.4.1 or 0.4.1-beta.3), /// Exact release version to update to (e.g. 0.4.1 or 0.4.1-beta.3),
@ -70,10 +70,15 @@ enum Commands {
ProxyEnv, ProxyEnv,
/// Output shell export commands to clear proxy (eval $(ostp proxy-env-clear)) /// Output shell export commands to clear proxy (eval $(ostp proxy-env-clear))
ProxyEnvClear, ProxyEnvClear,
/// Upgrade the configuration file to the current schema. This is the
/// ONLY place config migration ever runs - never automatically at
/// startup or during install/update, so a config never changes shape
/// without you asking it to.
Migrate,
} }
/// Bridges the new subcommand-based CLI onto the original flat-flag dispatch /// Bridges the new subcommand-based CLI onto the original flat-flag dispatch
/// below, so the ~500 lines of existing command logic don't need to change — /// below, so the ~500 lines of existing command logic don't need to change -
/// only how they get populated does. /// only how they get populated does.
struct LegacyArgs { struct LegacyArgs {
config: PathBuf, config: PathBuf,
@ -92,6 +97,60 @@ struct LegacyArgs {
import: Option<String>, import: Option<String>,
proxy_env: bool, proxy_env: bool,
proxy_env_clear: bool, proxy_env_clear: bool,
migrate: bool,
}
/// Asks the same TUN/mux/debug questions regardless of how a share link
/// reached this config - connecting directly (`ostp connect <url>`) or
/// importing it to disk (`ostp import <url>`). Previously only the connect
/// path asked; `import` just wrote flat defaults with no way to turn any of
/// this on short of hand-editing the resulting config.json.
fn prompt_client_options(client_cfg: &mut ClientConfig) {
use std::io::Write;
let mut input = String::new();
print!("{} Enable TUN (VPN) mode? [y/N]: ", "?".blue().bold());
std::io::stdout().flush().unwrap();
std::io::stdin().read_line(&mut input).unwrap();
if input.trim().eq_ignore_ascii_case("y") {
if let Some(tun) = &mut client_cfg.tun {
tun.enable = true;
}
}
print!("{} Enable connection multiplexing (mux)? [y/N]: ", "?".blue().bold());
std::io::stdout().flush().unwrap();
input.clear();
std::io::stdin().read_line(&mut input).unwrap();
if input.trim().eq_ignore_ascii_case("y") {
print!("How many sessions? [5]: ");
std::io::stdout().flush().unwrap();
input.clear();
std::io::stdin().read_line(&mut input).unwrap();
let mut sessions = 5;
if !input.trim().is_empty() {
if let Ok(s) = input.trim().parse() {
sessions = s;
}
}
if client_cfg.mux.is_none() {
client_cfg.mux = Some(MuxConfig {
enabled: Some(true),
sessions: Some(sessions),
});
} else if let Some(mux) = &mut client_cfg.mux {
mux.enabled = Some(true);
mux.sessions = Some(sessions);
}
}
print!("Enable debug mode? [y/N]: ");
std::io::stdout().flush().unwrap();
input.clear();
std::io::stdin().read_line(&mut input).unwrap();
if input.trim().eq_ignore_ascii_case("y") {
client_cfg.debug = Some(true);
}
} }
fn parse_ostp_link(link: &str) -> Result<ClientConfig> { fn parse_ostp_link(link: &str) -> Result<ClientConfig> {
@ -110,14 +169,12 @@ fn parse_ostp_link(link: &str) -> Result<ClientConfig> {
let host = parsed.host_str().ok_or_else(|| anyhow!("Missing host in share link"))?; let host = parsed.host_str().ok_or_else(|| anyhow!("Missing host in share link"))?;
let port = parsed.port().ok_or_else(|| anyhow!("Missing port in share link"))?; let port = parsed.port().ok_or_else(|| anyhow!("Missing port in share link"))?;
let server = format!("{host}:{port}"); let server = format!("{host}:{port}");
let mut sni = String::new();
let mut transport_mode = String::from("udp"); let mut transport_mode = String::from("udp");
let mut tun_enabled = false; let mut tun_enabled = false;
let mut tun_dns = None; let mut tun_dns = None;
for (k, v) in parsed.query_pairs() { for (k, v) in parsed.query_pairs() {
match &*k { match &*k {
"sni" => sni = v.into_owned(),
"type" => transport_mode = v.into_owned(), "type" => transport_mode = v.into_owned(),
"tun" => tun_enabled = v == "true", "tun" => tun_enabled = v == "true",
"dns" => tun_dns = Some(v.into_owned()), "dns" => tun_dns = Some(v.into_owned()),
@ -131,7 +188,6 @@ fn parse_ostp_link(link: &str) -> Result<ClientConfig> {
mtu: None, mtu: None,
transport: Some(TransportConfigRaw { transport: Some(TransportConfigRaw {
mode: Some(transport_mode), mode: Some(transport_mode),
stealth_sni: Some(sni.clone()),
tcp_fragmentation: None, tcp_fragmentation: None,
}), }),
socks5_bind: Some("127.0.0.1:1088".to_string()), socks5_bind: Some("127.0.0.1:1088".to_string()),
@ -171,226 +227,19 @@ fn parse_outbound_action(value: Option<String>) -> ostp_server::OutboundAction {
} }
} }
#[derive(Debug, Deserialize, Serialize)] // The on-disk config.json shapes (client/server/relay + all nested types)
#[serde(tag = "mode", rename_all = "lowercase")] // live in ostp_client::config now - this used to be ~220 lines of struct
enum AppMode { // definitions duplicated here with no other consumer able to see them,
Server(ServerConfig), // which is exactly why ostp_client::migrate had to work against loosely
Client(ClientConfig), // typed JSON instead of a real schema. `ClientFileConfig` is aliased back to
Relay(RelayServerConfig), // the bare `ClientConfig` name used throughout the rest of this file, so it
} // doesn't collide with `ostp_client::config::ClientConfig` (the RUNTIME
// shape the engine actually uses - a different thing on purpose; see the
#[derive(Debug, Deserialize, Serialize)] // doc comment on that struct).
struct UnifiedConfig { use ostp_client::config::{
#[serde(flatten)] AppMode, ClientFileConfig as ClientConfig, MuxConfig, TransportConfigRaw, TunConfig,
mode: AppMode, UnifiedConfig,
log_level: Option<String>, };
}
impl UnifiedConfig {
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.clone(),
}
}
}
#[derive(Debug, Deserialize, Serialize)]
struct ServerConfig {
listen: ListenConfig,
access_keys: Vec<UserConfig>,
debug: Option<bool>,
outbound: Option<OutboundConfig>,
api: Option<ApiConfig>,
fallback: Option<FallbackCfg>,
transport: Option<TransportConfigRaw>,
dns: Option<ostp_server::dns::DnsConfig>,
}
/// Конфигурация Relay-узла в config.json
#[derive(Debug, Deserialize, Serialize)]
struct RelayServerConfig {
/// Адрес(а) прослушивания (UDP + TCP UoT)
listen: ListenConfig,
/// Адрес upstream для TCP (UoT) трафика
upstream_tcp: String,
/// Адрес upstream для UDP трафика
upstream_udp: String,
/// URL API целевого сервера для синхронизации ключей
upstream_api_url: String,
/// Bearer-токен для API целевого сервера
#[serde(default)]
upstream_api_token: String,
/// Интервал синхронизации ключей в секундах (по умолчанию 30)
#[serde(default = "default_sync_interval")]
sync_interval_secs: u64,
debug: Option<bool>,
}
fn default_sync_interval() -> u64 { 30 }
/// Supports both single string "0.0.0.0:50000" and array ["0.0.0.0:50000", "[::]:50000"]
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(untagged)]
enum ListenConfig {
Single(String),
Multiple(Vec<String>),
}
impl ListenConfig {
fn addresses(&self) -> Vec<String> {
match self {
ListenConfig::Single(s) => vec![s.clone()],
ListenConfig::Multiple(v) => v.clone(),
}
}
fn primary(&self) -> String {
match self {
ListenConfig::Single(s) => s.clone(),
ListenConfig::Multiple(v) => v.first().cloned().unwrap_or_default(),
}
}
}
#[derive(Debug, Deserialize, Serialize)]
struct ApiConfig {
enabled: Option<bool>,
bind: Option<String>,
token: Option<String>,
webpath: Option<String>,
username: Option<String>,
password_hash: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
struct FallbackCfg {
enabled: Option<bool>,
listen: Option<String>,
target: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
struct ClientConfig {
server: String,
access_key: String,
mtu: Option<usize>,
socks5_bind: Option<String>,
tun: Option<TunConfig>,
debug: Option<bool>,
exclude: Option<ExcludeConfig>,
mux: Option<MuxConfig>,
transport: Option<TransportConfigRaw>,
gui: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
struct TransportConfigRaw {
mode: Option<String>,
stealth_sni: Option<String>,
tcp_fragmentation: Option<bool>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
struct TunConfig {
enable: bool,
wintun_path: Option<String>,
ipv4_address: Option<String>,
dns: Option<String>,
kill_switch: Option<bool>,
}
#[derive(Debug, Deserialize, Serialize)]
struct OutboundConfig {
enabled: bool,
protocol: String,
address: String,
port: u16,
#[serde(default)]
rules: Vec<OutboundRule>,
default_action: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
struct OutboundRule {
domain_suffix: Option<Vec<String>>,
ip_cidr: Option<Vec<String>>,
protocol: Option<String>,
action: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
struct ExcludeConfig {
domains: Option<Vec<String>>,
ips: Option<Vec<String>>,
processes: Option<Vec<String>>,
}
#[derive(Debug, Deserialize, Serialize)]
struct MuxConfig {
enabled: Option<bool>,
sessions: Option<usize>,
}
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
@ -399,7 +248,11 @@ async fn main() -> Result<()> {
// where it does not apply. // where it does not apply.
let _ = rlimit::increase_nofile_limit(1048576); let _ = rlimit::increase_nofile_limit(1048576);
ostp_client::logging::setup_panic_hook(); ostp_client::logging::setup_panic_hook();
let _log_guard = ostp_client::logging::init_tracing("info", "ostp-cli", env!("CARGO_PKG_VERSION")); // Clear the shared log at startup only when THIS invocation is the daemon —
// a one-shot command (`ostp gk`, `ostp check`, ...) must not wipe a running
// daemon's log. (Truncation itself is additionally Windows-only.)
let is_daemon = ostp_client::logging::invocation_is_daemon(std::env::args());
let _log_guard = ostp_client::logging::init_tracing("info", "ostp-cli", env!("CARGO_PKG_VERSION"), is_daemon);
let res = run_app().await; let res = run_app().await;
if let Err(e) = res { if let Err(e) = res {
@ -532,7 +385,7 @@ fn wizard_step(n: usize, total: usize, title: &str) {
println!(" {} {}", println!(" {} {}",
format!("[{}/{}]", n, total).bold().yellow(), format!("[{}/{}]", n, total).bold().yellow(),
title.bold()); title.bold());
println!(" {}", "".repeat(50).dimmed()); println!(" {}", "-".repeat(50).dimmed());
} }
fn wizard_box(lines: &[&str]) { fn wizard_box(lines: &[&str]) {
@ -604,10 +457,10 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
"Press Enter to accept the value shown in [brackets].", "Press Enter to accept the value shown in [brackets].",
]); ]);
// ── Mode selection ──────────────────────────────────────────────── // -- Mode selection ------------------------------------------------
println!(); println!();
println!(" {}", "Select operating mode:".bold()); println!(" {}", "Select operating mode:".bold());
println!(" {}", "".repeat(50).dimmed()); println!(" {}", "-".repeat(50).dimmed());
#[cfg(unix)] #[cfg(unix)]
{ {
@ -638,7 +491,7 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
} }
match mode_choice { match mode_choice {
// ── CLIENT ──────────────────────────────────────────────────── // -- CLIENT ----------------------------------------------------
"1" => { "1" => {
#[cfg(unix)] const TOTAL: usize = 5; #[cfg(unix)] const TOTAL: usize = 5;
#[cfg(windows)] const TOTAL: usize = 4; #[cfg(windows)] const TOTAL: usize = 4;
@ -647,15 +500,14 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
// Try import from link first // Try import from link first
let use_link = wizard_yn("Do you have a share link (ostp://...)?", false); let use_link = wizard_yn("Do you have a share link (ostp://...)?", false);
let (server, access_key, sni, transport_mode) = if use_link { let (server, access_key, transport_mode) = if use_link {
let link_str = wizard_prompt("Paste link", ""); let link_str = wizard_prompt("Paste link", "");
let parsed = url::Url::parse(&link_str).unwrap(); let parsed = url::Url::parse(&link_str).unwrap();
let mut p = parsed.query_pairs(); let mut p = parsed.query_pairs();
let sni = p.find(|(k, _)| k == "sni").map(|(_, v)| v.to_string()).unwrap_or_default();
let tm = p.find(|(k, _)| k == "type").map(|(_, v)| v.to_string()).unwrap_or("udp".to_string()); let tm = p.find(|(k, _)| k == "type").map(|(_, v)| v.to_string()).unwrap_or("udp".to_string());
(parsed.host_str().unwrap().to_string() + ":" + &parsed.port().unwrap_or(50000).to_string(), parsed.username().to_string(), sni, tm) (parsed.host_str().unwrap().to_string() + ":" + &parsed.port().unwrap_or(50000).to_string(), parsed.username().to_string(), tm)
} else { } else {
("127.0.0.1:50000".to_string(), "".to_string(), "".to_string(), "udp".to_string()) ("127.0.0.1:50000".to_string(), "".to_string(), "udp".to_string())
}; };
wizard_step(2, TOTAL, "Local proxy"); wizard_step(2, TOTAL, "Local proxy");
@ -663,11 +515,11 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
wizard_step(3, TOTAL, "VPN (TUN) mode"); wizard_step(3, TOTAL, "VPN (TUN) mode");
// SSH warning on Linux always // SSH warning on Linux - always
#[cfg(unix)] #[cfg(unix)]
{ {
println!(); println!();
println!("{}", "".repeat(60)); println!("{}", "-".repeat(60));
println!("{} {}", println!("{} {}",
"WARNING:".red().bold(), "WARNING:".red().bold(),
"TUN mode captures ALL network traffic.".yellow()); "TUN mode captures ALL network traffic.".yellow());
@ -679,7 +531,7 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
println!(""); println!("");
println!(" │ Make sure the VPN server is reachable before"); println!(" │ Make sure the VPN server is reachable before");
println!(" │ enabling TUN, or your SSH session may be lost!"); println!(" │ enabling TUN, or your SSH session may be lost!");
println!("{}", "".repeat(60)); println!("{}", "-".repeat(60));
} }
let tun_enable = wizard_yn("Enable TUN (full VPN) mode?", false); let tun_enable = wizard_yn("Enable TUN (full VPN) mode?", false);
@ -699,7 +551,7 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
s.parse::<usize>().unwrap_or(5) s.parse::<usize>().unwrap_or(5)
} else { 1 }; } else { 1 };
// Daemon step Linux only // Daemon step - Linux only
#[cfg(unix)] #[cfg(unix)]
{ {
wizard_step(5, TOTAL, "Auto-start (systemd)"); wizard_step(5, TOTAL, "Auto-start (systemd)");
@ -708,7 +560,6 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
// Build and save config // Build and save config
let key_for_gen = generate_secure_key("hex"); let key_for_gen = generate_secure_key("hex");
let _ = key_for_gen; let _ = key_for_gen;
let _ = &sni;
let client_json = serde_json::json!({ let client_json = serde_json::json!({
"mode": "client", "mode": "client",
@ -729,8 +580,7 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
"processes": [] "processes": []
}, },
"transport": { "transport": {
"mode": transport_mode, "mode": transport_mode
"stealth_sni": "www.microsoft.com"
}, },
"mux": { "mux": {
"enabled": mux_enable, "enabled": mux_enable,
@ -759,12 +609,12 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
&format!("TUN mode: {}", if tun_enable { "enabled" } else { "disabled" }), &format!("TUN mode: {}", if tun_enable { "enabled" } else { "disabled" }),
"", "",
"To start: ostp", "To start: ostp",
"To check: ostp --check", "To check: ostp check",
"Proxy env: eval $(ostp --proxy-env)", "Proxy env: eval $(ostp proxy-env)",
]); ]);
} }
// ── SERVER ──────────────────────────────────────────────────── // -- SERVER ----------------------------------------------------
"2" => { "2" => {
#[cfg(unix)] const TOTAL: usize = 4; #[cfg(unix)] const TOTAL: usize = 4;
#[cfg(windows)] const TOTAL: usize = 3; #[cfg(windows)] const TOTAL: usize = 3;
@ -832,12 +682,12 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
&format!("Keys: {}", key_count), &format!("Keys: {}", key_count),
"", "",
"To start: ostp", "To start: ostp",
"To check: ostp --check", "To check: ostp check",
"Share links: ostp --links", "Share links: ostp links",
]); ]);
} }
// ── SERVER + PANEL (Linux only) ─────────────────────────────── // -- SERVER + PANEL (Linux only) -------------------------------
#[cfg(unix)] #[cfg(unix)]
"3" => { "3" => {
const TOTAL: usize = 5; const TOTAL: usize = 5;
@ -940,7 +790,7 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
]); ]);
} }
// ── RELAY (Linux only) ──────────────────────────────────────── // -- RELAY (Linux only) ----------------------------------------
#[cfg(unix)] #[cfg(unix)]
"4" => { "4" => {
const TOTAL: usize = 3; const TOTAL: usize = 3;
@ -1069,6 +919,7 @@ async fn run_app() -> Result<()> {
import: None, import: None,
proxy_env: false, proxy_env: false,
proxy_env_clear: false, proxy_env_clear: false,
migrate: false,
}; };
if let Some(cmd) = raw_args.command { if let Some(cmd) = raw_args.command {
@ -1084,6 +935,7 @@ async fn run_app() -> Result<()> {
Commands::Import { url } => { args.import = Some(url); } Commands::Import { url } => { args.import = Some(url); }
Commands::ProxyEnv => { args.proxy_env = true; } Commands::ProxyEnv => { args.proxy_env = true; }
Commands::ProxyEnvClear => { args.proxy_env_clear = true; } Commands::ProxyEnvClear => { args.proxy_env_clear = true; }
Commands::Migrate => { args.migrate = true; }
} }
} }
@ -1095,7 +947,11 @@ async fn run_app() -> Result<()> {
return cmd_update(args.update_branch, args.target_version); return cmd_update(args.update_branch, args.target_version);
} }
// ── Setup wizard: explicit flag or first-time (no config) ──────── if args.migrate {
return cmd_migrate(&args.config);
}
// -- Setup wizard: explicit flag or first-time (no config) --------
if args.setup { if args.setup {
return run_setup_wizard(&args.config); return run_setup_wizard(&args.config);
} }
@ -1180,8 +1036,9 @@ async fn run_app() -> Result<()> {
if let Some(import_url) = args.import { if let Some(import_url) = args.import {
println!("{} Importing configuration from share link...", "[ostp]".cyan().bold()); println!("{} Importing configuration from share link...", "[ostp]".cyan().bold());
let client_cfg = parse_ostp_link(&import_url) let mut client_cfg = parse_ostp_link(&import_url)
.map_err(|e| anyhow!("Share Link Error: {e}"))?; .map_err(|e| anyhow!("Share Link Error: {e}"))?;
prompt_client_options(&mut client_cfg);
let unified = UnifiedConfig { let unified = UnifiedConfig {
mode: AppMode::Client(client_cfg), mode: AppMode::Client(client_cfg),
log_level: Some("info".to_string()), log_level: Some("info".to_string()),
@ -1201,53 +1058,7 @@ async fn run_app() -> Result<()> {
println!("{} Connecting via share link...", "[ostp]".cyan().bold()); println!("{} Connecting via share link...", "[ostp]".cyan().bold());
let mut client_cfg = parse_ostp_link(&url) let mut client_cfg = parse_ostp_link(&url)
.map_err(|e| anyhow!("Share Link Error: {e}"))?; .map_err(|e| anyhow!("Share Link Error: {e}"))?;
prompt_client_options(&mut client_cfg);
// Interactive prompt for URL launch
use std::io::Write;
print!("{} Enable TUN (VPN) mode? [y/N]: ", "?".blue().bold());
std::io::stdout().flush().unwrap();
let mut input = String::new();
std::io::stdin().read_line(&mut input).unwrap();
if input.trim().eq_ignore_ascii_case("y") {
if let Some(tun) = &mut client_cfg.tun {
tun.enable = true;
}
}
print!("{} Enable connection multiplexing (mux)? [y/N]: ", "?".blue().bold());
std::io::stdout().flush().unwrap();
input.clear();
std::io::stdin().read_line(&mut input).unwrap();
if input.trim().eq_ignore_ascii_case("y") {
print!("How many sessions? [5]: ");
std::io::stdout().flush().unwrap();
input.clear();
std::io::stdin().read_line(&mut input).unwrap();
let mut sessions = 5;
if !input.trim().is_empty() {
if let Ok(s) = input.trim().parse() {
sessions = s;
}
}
if client_cfg.mux.is_none() {
client_cfg.mux = Some(MuxConfig {
enabled: Some(true),
sessions: Some(sessions),
});
} else if let Some(mux) = &mut client_cfg.mux {
mux.enabled = Some(true);
mux.sessions = Some(sessions);
}
}
print!("Enable debug mode? [y/N]: ");
std::io::stdout().flush().unwrap();
input.clear();
std::io::stdin().read_line(&mut input).unwrap();
if input.trim().eq_ignore_ascii_case("y") {
client_cfg.debug = Some(true);
}
return run_client_directly(client_cfg).await; return run_client_directly(client_cfg).await;
} }
@ -1405,10 +1216,9 @@ async fn run_app() -> Result<()> {
"processes": [] "processes": []
}}, }},
// Transport Mode: "udp" (default WebRTC masquerade) or "uot" (TCP UoT) // Transport Mode: "udp" (default) or "uot" (UDP over TCP, no mimicry)
"transport": {{ "transport": {{
"mode": "udp", "mode": "udp"
"stealth_sni": "www.microsoft.com"
}}, }},
"mux": {{ "mux": {{
@ -1453,9 +1263,9 @@ async fn run_app() -> Result<()> {
anyhow::bail!( anyhow::bail!(
"Configuration file {:?} not found.\n\n\ "Configuration file {:?} not found.\n\n\
To generate a default configuration template, run:\n\ To generate a default configuration template, run:\n\
\t./ostp --init server\n\ \t./ostp init server\n\
\tor\n\ \tor\n\
\t./ostp --init client\n\n\ \t./ostp init client\n\n\
Or specify a custom configuration file path using:\n\ Or specify a custom configuration file path using:\n\
\t./ostp --config /path/to/your_config.json", \t./ostp --config /path/to/your_config.json",
args.config args.config
@ -1549,8 +1359,16 @@ async fn run_app() -> Result<()> {
}) })
}).collect::<Vec<_>>(); }).collect::<Vec<_>>();
let host = get_or_ask_public_ip(&args.config); let host = get_or_ask_public_ip(&args.config);
// Build DNS config and set owndns flag in subscribe links if DNS enabled // Build DNS config and set owndns flag in subscribe links if DNS enabled.
let dns_cfg = server_cfg.dns; // Kept untyped (serde_json::Value) in the shared ServerConfig so
// ostp-client doesn't need a dependency on ostp-server just to
// name this type - deserialize it here instead, where both
// crates are already in scope.
let dns_cfg: Option<ostp_server::dns::DnsConfig> = server_cfg
.dns
.map(serde_json::from_value)
.transpose()
.map_err(|e| anyhow!("Invalid 'dns' section in server config: {e}"))?;
// Pass all listen addresses for multi-listener support // Pass all listen addresses for multi-listener support
ostp_server::run_server(listen_addrs, Some(host), access_keys_meta, outbound, api_config, fallback_config, debug, dns_cfg, Some(args.config)).await?; ostp_server::run_server(listen_addrs, Some(host), access_keys_meta, outbound, api_config, fallback_config, debug, dns_cfg, Some(args.config)).await?;
} }
@ -1674,6 +1492,78 @@ fn cmd_update(_branch: String, _version: Option<String>) -> Result<()> {
anyhow::bail!("The 'update' command is only supported on Linux/Unix systems."); anyhow::bail!("The 'update' command is only supported on Linux/Unix systems.");
} }
/// The ONLY place config migration ever runs - see ostp_client::migrate for
/// why (and for the actual field-by-field mapping). Never called
/// automatically; only this explicit command touches an existing config's
/// shape.
fn cmd_migrate(config_path: &std::path::Path) -> Result<()> {
if !config_path.exists() {
anyhow::bail!("Configuration file not found at {:?}", config_path);
}
let raw_content = fs::read_to_string(config_path)?;
let mut stripped = json_comments::StripComments::new(raw_content.as_bytes());
let mut content_str = String::new();
{
use std::io::Read;
stripped.read_to_string(&mut content_str)?;
}
let parsed: serde_json::Value = serde_json::from_str(&content_str)
.map_err(|e| anyhow!("Failed to parse {:?} as JSON: {}", config_path, e))?;
let kind = ostp_client::migrate::detect_kind(&parsed)
.ok_or_else(|| anyhow!("Could not determine whether {:?} is a client, server, or relay config.", config_path))?;
let (migrated, report) = match kind {
ostp_client::migrate::ConfigKind::Client => {
let (mut v, r) = ostp_client::migrate::migrate_client_json(parsed);
if v.get("mode").is_none() { v["mode"] = serde_json::json!("client"); }
(v, r)
}
ostp_client::migrate::ConfigKind::Server => {
let (mut v, r) = ostp_client::migrate::migrate_server_json(parsed);
if v.get("mode").is_none() { v["mode"] = serde_json::json!("server"); }
(v, r)
}
ostp_client::migrate::ConfigKind::Relay => {
// The relay shape hasn't changed since it was introduced - nothing to migrate yet.
(parsed, ostp_client::migrate::MigrationReport::default())
}
};
if !report.changed {
println!("{} Config is already up to date, nothing to migrate.", "[ostp]".green().bold());
return Ok(());
}
// Prove the migrator's output actually matches the ONE canonical schema
// (ostp_client::config) before ever touching the user's file - this is
// what makes "single source of truth" a guarantee instead of just an
// intention: if migrate.rs's hand-built JSON ever drifts from what
// UnifiedConfig actually expects, this catches it here, not as a
// corrupted config.json on someone's server.
serde_json::from_value::<ostp_client::config::UnifiedConfig>(migrated.clone())
.map_err(|e| anyhow!(
"Internal error: the migrated config does not match the current schema ({e}). \
Nothing was written - this is a bug in the migrator, please report it."
))?;
let backup_path = config_path.with_extension("json.bak");
fs::copy(config_path, &backup_path)?;
println!("{} Original config backed up to {:?}", "[ostp]".cyan().bold(), backup_path);
let new_content = serde_json::to_string_pretty(&migrated)?;
fs::write(config_path, new_content)?;
println!("{} Migrated {:?} - changes made:", "[ostp]".green().bold(), config_path);
for note in &report.notes {
println!(" - {note}");
}
println!("\n{} Run 'ostp check' to validate the migrated config.", "[ostp]".cyan().bold());
Ok(())
}
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
fn ensure_elevated_for_tun() -> Result<()> { fn ensure_elevated_for_tun() -> Result<()> {
#[link(name = "shell32")] #[link(name = "shell32")]
@ -1720,7 +1610,7 @@ fn ensure_elevated_for_tun() -> Result<()> {
}; };
// ShellExecuteW's return is a pseudo-HINSTANCE: > 32 means the call itself // ShellExecuteW's return is a pseudo-HINSTANCE: > 32 means the call itself
// "succeeded" but that range INCLUDES ERROR_CANCELLED (1223), which is // "succeeded" - but that range INCLUDES ERROR_CANCELLED (1223), which is
// exactly what Windows returns when the user clicks "No" on the UAC // exactly what Windows returns when the user clicks "No" on the UAC
// prompt. The old check (`ret <= 32` only) treated a user-denied prompt // prompt. The old check (`ret <= 32` only) treated a user-denied prompt
// as success and silently exited without ever starting the tunnel. // as success and silently exited without ever starting the tunnel.
@ -1732,7 +1622,7 @@ fn ensure_elevated_for_tun() -> Result<()> {
anyhow::bail!( anyhow::bail!(
"Failed to request UAC elevation (ShellExecuteW ret={}, GetLastError={}). \ "Failed to request UAC elevation (ShellExecuteW ret={}, GetLastError={}). \
If this keeps happening, an unsigned binary can be silently blocked by \ If this keeps happening, an unsigned binary can be silently blocked by \
SmartScreen/antivirus during elevation try running this as Administrator manually.", SmartScreen/antivirus during elevation - try running this as Administrator manually.",
ret, win_err ret, win_err
); );
} }
@ -1745,7 +1635,7 @@ async fn run_client_directly(client_cfg: ClientConfig) -> Result<()> {
println!("{} Starting client (mode={}, server={})", "[ostp]".cyan().bold(), mode_str.yellow(), client_cfg.server.cyan()); println!("{} Starting client (mode={}, server={})", "[ostp]".cyan().bold(), mode_str.yellow(), client_cfg.server.cyan());
// TUN mode needs admin rights to create the WinTun adapter. This was // TUN mode needs admin rights to create the WinTun adapter. This was
// missing entirely before the CLI would just try to create the // missing entirely before - the CLI would just try to create the
// adapter unelevated and fail at the driver level with no UAC prompt // adapter unelevated and fail at the driver level with no UAC prompt
// ever shown, which is what "UAC denied regardless of GUI or TUI" // ever shown, which is what "UAC denied regardless of GUI or TUI"
// actually was for this code path: TUI never asked for elevation at all. // actually was for this code path: TUI never asked for elevation at all.
@ -1782,7 +1672,6 @@ async fn run_client_directly(client_cfg: ClientConfig) -> Result<()> {
}, },
transport: ostp_client::config::TransportConfig { transport: ostp_client::config::TransportConfig {
mode: client_cfg.transport.as_ref().and_then(|t| t.mode.clone()).unwrap_or_else(|| "udp".to_string()), mode: client_cfg.transport.as_ref().and_then(|t| t.mode.clone()).unwrap_or_else(|| "udp".to_string()),
stealth_sni: client_cfg.transport.as_ref().and_then(|t| t.stealth_sni.clone()).unwrap_or_else(|| "microsoft.com".to_string()),
tcp_fragmentation: client_cfg.transport.as_ref().and_then(|t| t.tcp_fragmentation).unwrap_or(false), tcp_fragmentation: client_cfg.transport.as_ref().and_then(|t| t.tcp_fragmentation).unwrap_or(false),
frag_chunk: 2, frag_chunk: 2,
frag_sleep: 2, frag_sleep: 2,

View File

@ -1,658 +0,0 @@
import sys
import re
with open("d:/ospab-projects/ostp/ostp-client/src/bridge.rs", "r", encoding="utf-8") as f:
code = f.read()
start_idx = code.find(" pub async fn run(")
end_idx = -1
brace_count = 0
in_run = False
for i in range(start_idx, len(code)):
if code[i] == '{':
in_run = True
brace_count += 1
elif code[i] == '}':
if in_run:
brace_count -= 1
if brace_count == 0:
end_idx = i + 1
break
prefix = code[:start_idx]
suffix = code[end_idx:]
# Define the new run function and helpers
new_run_and_helpers = """
pub async fn run(
mut self,
tx: mpsc::Sender<UiEvent>,
mut bridge_rx: mpsc::Receiver<BridgeCommand>,
mut shutdown: watch::Receiver<bool>,
mut proxy_rx: mpsc::Receiver<ProxyEvent>,
proxy_tx: mpsc::UnboundedSender<(u16, ProxyToClientMsg)>,
) -> Result<()> {
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));
let init_msg = if self.mode == "tun" {
"Bridge initialized (TUN mode)".to_string()
} else {
"Bridge initialized (proxy mode)".to_string()
};
tx.send(UiEvent::Log(init_msg)).await.ok();
let mut sessions_opt: Option<Vec<SessionState>> = None;
let mut udp_rx_opt: Option<mpsc::Receiver<(usize, Bytes)>> = None;
let mut proxy_guard: Option<crate::sysproxy::SystemProxyGuard> = None;
let mut stream_map: std::collections::HashMap<u16, usize> = std::collections::HashMap::new();
loop {
tokio::select! {
biased;
_ = shutdown.changed() => {
if *shutdown.borrow() {
self.running = false;
self.metrics.connection_state.store(0, Ordering::Relaxed);
proxy_guard = None;
sessions_opt = None;
udp_rx_opt = None;
stream_map.clear();
self.reset_proxy_streams(&tx, &proxy_tx, "manual stop");
break;
}
}
udp_msg = async {
match udp_rx_opt.as_mut() {
Some(rx) => rx.recv().await,
None => std::future::pending().await,
}
}, if self.running => {
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 {
break;
}
}
_ = metrics_tick.tick() => {
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;
}
}
_ = retransmit_tick.tick() => {
if self.running {
self.handle_retransmit(&mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx).await;
}
}
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))
}).unwrap_or(true) => {
self.handle_proxy_event(proxy_ev, &mut sessions_opt, &mut stream_map, &tx, &proxy_tx).await;
}
}
}
tx.send(UiEvent::Log("Bridge stopped".to_string())).await.ok();
Ok(())
}
async fn handle_inbound_udp(
&mut self,
udp_msg: Option<(usize, Bytes)>,
sessions_opt: &mut Option<Vec<SessionState>>,
udp_rx_opt: &mut Option<mpsc::Receiver<(usize, Bytes)>>,
proxy_guard: &mut Option<crate::sysproxy::SystemProxyGuard>,
stream_map: &mut std::collections::HashMap<u16, usize>,
tx: &mpsc::Sender<UiEvent>,
proxy_tx: &mpsc::UnboundedSender<(u16, ProxyToClientMsg)>,
) {
match udp_msg {
Some((session_index, inbound)) => {
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];
let initial_action = match session.machine.on_event(OstpEvent::Inbound(inbound)) {
Ok(a) => a,
Err(e) => {
let _ = tx.send(UiEvent::Log(format!("Protocol decrypt error: {e}"))).await;
tracing::warn!("Inbound protocol error (session {}): {}", session_index, e);
return;
}
};
let mut actions_queue = std::collections::VecDeque::new();
actions_queue.push_back(initial_action);
while let Some(current_action) = actions_queue.pop_front() {
match current_action {
ProtocolAction::Multiple(nested) => {
for a in nested {
actions_queue.push_back(a);
}
}
ProtocolAction::DeliverApp(stream_id, dec_payload) => {
match RelayMessage::decode(&dec_payload) {
Ok(relay_msg) => {
match relay_msg {
RelayMessage::ConnectOk => {
let _ = tx.send(UiEvent::Log(format!("Relay CONNECT OK stream_id={stream_id}"))).await;
let _ = proxy_tx.send((stream_id, ProxyToClientMsg::ConnectOk));
}
RelayMessage::Data(data) => {
let _ = proxy_tx.send((stream_id, ProxyToClientMsg::Data(Bytes::from(data))));
}
RelayMessage::Close => {
let _ = proxy_tx.send((stream_id, ProxyToClientMsg::Close));
}
RelayMessage::Error(msg) => {
let _ = tx.send(UiEvent::Log(format!("Relay error for stream {stream_id}: {msg}"))).await;
let _ = proxy_tx.send((stream_id, ProxyToClientMsg::Error(msg)));
}
RelayMessage::Pong(ts) => {
let now = SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64;
self.last_rtt_ms = now.saturating_sub(ts) as f64;
self.metrics.rtt_ms.store(self.last_rtt_ms as u32, Ordering::Relaxed);
}
RelayMessage::UdpAssociate => {}
RelayMessage::UdpData(target, data) => {
let _ = proxy_tx.send((stream_id, ProxyToClientMsg::UdpData(target, Bytes::from(data))));
}
RelayMessage::KeepAlive | RelayMessage::Ping(_) | RelayMessage::Connect(_) => {}
}
}
Err(err) => {
let _ = tx.send(UiEvent::Log(format!("Relay decode error for stream {stream_id}: {err}"))).await;
let _ = proxy_tx.send((stream_id, ProxyToClientMsg::Error("relay decode failed".to_string())));
}
}
}
ProtocolAction::SendDatagram(frame) => {
let _ = send_datagram(&session.socket, &frame, self.transport_mode == "udp" ).await;
self.metrics.bytes_sent.fetch_add(frame.len() as u64, Ordering::Relaxed);
}
_ => {}
}
}
}
}
}
None => {
let _ = tx.send(UiEvent::Log("UDP channel closed, resetting connection".to_string())).await;
self.running = false;
crate::sysproxy::disable_system_proxy();
*sessions_opt = None;
*udp_rx_opt = None;
stream_map.clear();
self.reset_proxy_streams(&tx, &proxy_tx, "udp reader closed");
let _ = tx.send(UiEvent::TunnelStopped).await;
}
}
}
async fn handle_bridge_cmd(
&mut self,
cmd: Option<BridgeCommand>,
sessions_opt: &mut Option<Vec<SessionState>>,
udp_rx_opt: &mut Option<mpsc::Receiver<(usize, Bytes)>>,
proxy_guard: &mut Option<crate::sysproxy::SystemProxyGuard>,
stream_map: &mut std::collections::HashMap<u16, usize>,
tx: &mpsc::Sender<UiEvent>,
proxy_tx: &mpsc::UnboundedSender<(u16, ProxyToClientMsg)>,
) -> bool {
match cmd {
Some(BridgeCommand::ToggleTunnel) => {
if self.running {
self.running = false;
self.metrics.connection_state.store(0, Ordering::Relaxed);
*proxy_guard = None;
*sessions_opt = None;
*udp_rx_opt = None;
stream_map.clear();
self.reset_proxy_streams(&tx, &proxy_tx, "manual stop");
tx.send(UiEvent::TunnelStopped).await.ok();
let stop_msg = if self.mode == "tun" { "TUN tunnel stopped" } else { "Bridge stopped" };
tx.send(UiEvent::Log(stop_msg.to_string())).await.ok();
} else {
tx.send(UiEvent::Log("Connecting to remote server...".to_string())).await.ok();
tx.send(UiEvent::Metrics { status: ConnectionStatus::Handshaking, rtt_ms: 0.0, throughput_bps: 0 }).await.ok();
self.metrics.connection_state.store(1, Ordering::Relaxed);
let session_count = if self.mux_enabled { self.mux_sessions.max(1) } else { 1 };
let (udp_tx, udp_rx) = mpsc::channel(100000);
let mut sessions = Vec::with_capacity(session_count);
let mut rtt_sum = 0.0;
let mut successful_sessions = 0;
for idx in 0..session_count {
let session_id: u32 = rand::thread_rng().gen();
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();
tokio::spawn(async move {
let mut buf = vec![0_u8; 65535];
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) => {
tracing::warn!("UDP socket recv error (session {}): {}", session_index, e);
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
}
}
});
sessions.push(SessionState { socket: sock, machine: mach });
rtt_sum += rtt;
successful_sessions += 1;
}
Err(err) => {
tx.send(UiEvent::Log(format!("Multiplex session {}/{} handshake failed: {}. Continuing with remaining sessions...", idx + 1, session_count, err))).await.ok();
}
}
}
if sessions.is_empty() {
*proxy_guard = None;
tx.send(UiEvent::Log("All multiplexed handshake attempts failed. Connection aborted.".to_string())).await.ok();
tx.send(UiEvent::TunnelStopped).await.ok();
self.metrics.connection_state.store(0, Ordering::Relaxed);
return True;
}
*udp_rx_opt = Some(udp_rx);
*sessions_opt = Some(sessions);
self.last_rtt_ms = rtt_sum / successful_sessions as f64;
self.running = true;
self.last_sample_at = Instant::now();
self.last_valid_recv = Instant::now();
let sys_proxy_addr = self.proxy_addr.replace("0.0.0.0:", "127.0.0.1:");
*proxy_guard = Some(crate::sysproxy::SystemProxyGuard::enable(&sys_proxy_addr));
tx.send(UiEvent::Metrics {
status: ConnectionStatus::Established,
rtt_ms: self.last_rtt_ms,
throughput_bps: 0,
}).await.ok();
self.metrics.connection_state.store(2, Ordering::Relaxed);
let start_msg = if self.mode == "tun" { "TUN tunnel established" } else { "Connection established" };
tx.send(UiEvent::Log(start_msg.to_string())).await.ok();
for session in sessions_opt.as_mut().unwrap().iter_mut() {
let ts = SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64;
let ping_payload = Bytes::from(RelayMessage::Ping(ts).encode());
if let Ok(ProtocolAction::SendDatagram(frame)) = session.machine.on_event(OstpEvent::Outbound(0, ping_payload)) {
let _ = send_datagram(&session.socket, &frame, self.transport_mode == "udp").await;
self.metrics.bytes_sent.fetch_add(frame.len() as u64, Ordering::Relaxed);
}
}
}
}
Some(BridgeCommand::NextProfile) => {
self.profile = next_profile(self.profile);
tx.send(UiEvent::ProfileChanged(self.profile)).await.ok();
tx.send(UiEvent::Log(format!("Obfuscation profile switched to {:?}", self.profile))).await.ok();
}
Some(BridgeCommand::NetworkChanged) => {
if self.running {
let _ = tx.send(UiEvent::Log("Network changed — starting immediate reconnect".to_string())).await;
self.metrics.connection_state.store(1, Ordering::Relaxed);
self.last_valid_recv = Instant::now() - Duration::from_secs(100);
let session_count = if self.mux_enabled { self.mux_sessions.max(1) } else { 1 };
let (udp_tx, udp_rx) = mpsc::channel(100000);
let mut new_sessions = Vec::with_capacity(session_count);
let mut successful_sessions = 0;
let mut rtt_sum = 0.0;
for idx in 0..session_count {
let session_id: u32 = rand::thread_rng().gen();
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];
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) => {
tracing::warn!("UDP recv error (network-change session {}): {}", session_index, e);
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
}
}
});
new_sessions.push(SessionState { socket: sock, machine: mach });
rtt_sum += rtt;
successful_sessions += 1;
}
Err(err) => {
let _ = tx.send(UiEvent::Log(format!("NetworkChanged reconnect session {}/{} failed: {}", idx + 1, session_count, err))).await;
}
}
}
if !new_sessions.is_empty() {
*sessions_opt = Some(new_sessions);
*udp_rx_opt = Some(udp_rx);
self.last_rtt_ms = rtt_sum / successful_sessions as f64;
self.last_valid_recv = Instant::now();
stream_map.clear();
self.reset_proxy_streams(&tx, &proxy_tx, "network changed");
self.metrics.connection_state.store(2, Ordering::Relaxed);
let _ = tx.send(UiEvent::Log("NetworkChanged reconnect successful!".to_string())).await;
} else {
let _ = tx.send(UiEvent::Log("NetworkChanged reconnect failed — will retry on keepalive tick".to_string())).await;
}
}
}
Some(BridgeCommand::ReloadConfig) => {
match ClientConfig::reload_from_json_near_binary() {
Ok(cfg) => {
self.apply_runtime_config(&cfg);
tx.send(UiEvent::Log("Runtime config reloaded".to_string())).await.ok();
if self.running {
self.running = false;
self.metrics.connection_state.store(0, Ordering::Relaxed);
*proxy_guard = None;
*sessions_opt = None;
stream_map.clear();
self.reset_proxy_streams(&tx, &proxy_tx, "config reload");
let _ = tx.send(UiEvent::TunnelStopped).await;
}
}
Err(err) => {
let _ = tx.send(UiEvent::Log(format!("Config reload failed: {err}"))).await;
}
}
}
Some(BridgeCommand::Shutdown) | None => {
self.running = false;
*proxy_guard = None;
return False;
}
}
True
}
async fn handle_keepalive(
&mut self,
sessions_opt: &mut Option<Vec<SessionState>>,
udp_rx_opt: &mut Option<mpsc::Receiver<(usize, Bytes)>>,
proxy_guard: &mut Option<crate::sysproxy::SystemProxyGuard>,
stream_map: &mut std::collections::HashMap<u16, usize>,
tx: &mpsc::Sender<UiEvent>,
proxy_tx: &mpsc::UnboundedSender<(u16, ProxyToClientMsg)>,
proxy_rx: &mut mpsc::Receiver<ProxyEvent>,
) {
if self.last_valid_recv.elapsed().as_secs() > 25 {
let elapsed = self.last_valid_recv.elapsed().as_secs();
if elapsed > 180 {
let _ = tx.send(UiEvent::Log("Connection permanently lost (3-minute hard timeout). Stopping tunnel.".into())).await;
self.running = false;
*proxy_guard = None;
*sessions_opt = None;
stream_map.clear();
self.reset_proxy_streams(&tx, &proxy_tx, "keepalive hard timeout");
let _ = tx.send(UiEvent::TunnelStopped).await;
self.metrics.connection_state.store(0, Ordering::Relaxed);
return;
}
let _ = tx.send(UiEvent::Log(format!("Connection stall detected ({}s silence). Attempting background reconnect...", elapsed))).await;
self.metrics.connection_state.store(1, Ordering::Relaxed);
let session_count = if self.mux_enabled { self.mux_sessions.max(1) } else { 1 };
let (udp_tx, udp_rx) = mpsc::channel(100000);
let mut new_sessions = Vec::with_capacity(session_count);
let mut successful_sessions = 0;
let mut rtt_sum = 0.0;
for idx in 0..session_count {
let session_id: u32 = rand::thread_rng().gen();
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];
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) => {
tracing::warn!("UDP socket recv error (reconnect session {}): {}", session_index, e);
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
}
}
});
new_sessions.push(SessionState { socket: sock, machine: mach });
rtt_sum += rtt;
successful_sessions += 1;
}
Err(err) => {
let _ = tx.send(UiEvent::Log(format!("Background reconnect session {}/{} failed: {}", idx + 1, session_count, err))).await;
}
}
}
if !new_sessions.is_empty() {
*sessions_opt = Some(new_sessions);
*udp_rx_opt = Some(udp_rx);
self.last_rtt_ms = rtt_sum / successful_sessions as f64;
self.last_valid_recv = Instant::now();
self.metrics.connection_state.store(2, Ordering::Relaxed);
let _ = tx.send(UiEvent::Log("Background reconnect successful! Connection restored.".into())).await;
for session in sessions_opt.as_mut().unwrap().iter_mut() {
let ts = SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64;
let ping_payload = Bytes::from(RelayMessage::Ping(ts).encode());
if let Ok(ProtocolAction::SendDatagram(frame)) = session.machine.on_event(OstpEvent::Outbound(0, ping_payload)) {
let _ = send_datagram(&session.socket, &frame, self.transport_mode == "udp").await;
self.metrics.bytes_sent.fetch_add(frame.len() as u64, Ordering::Relaxed);
}
}
stream_map.clear();
self.reset_proxy_streams(&tx, &proxy_tx, "background reconnect");
let mut flushed = 0;
while let Ok(stale) = proxy_rx.try_recv() {
if let ProxyEvent::NewStream { stream_id, .. } = stale {
let _ = proxy_tx.send((stream_id, ProxyToClientMsg::Error("connection reset".into())));
}
flushed += 1;
}
if flushed > 0 {
let _ = tx.send(UiEvent::Log(format!("Flushed {} stale proxy messages to prevent UDP burst", flushed))).await;
}
} else {
let _ = tx.send(UiEvent::Log("Background reconnect failed. Will retry on next tick...".into())).await;
}
}
if let Some(sessions) = sessions_opt.as_mut() {
for session in sessions.iter_mut() {
let ts = SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64;
let ping_payload = Bytes::from(RelayMessage::Ping(ts).encode());
if let Ok(ProtocolAction::SendDatagram(frame)) = session.machine.on_event(OstpEvent::Outbound(0, ping_payload)) {
let _ = send_datagram(&session.socket, &frame, self.transport_mode == "udp" ).await;
self.metrics.bytes_sent.fetch_add(frame.len() as u64, Ordering::Relaxed);
}
let ka_payload = Bytes::from(RelayMessage::KeepAlive.encode());
if let Ok(ProtocolAction::SendDatagram(frame)) = session.machine.on_event(OstpEvent::Outbound(0, ka_payload)) {
let _ = send_datagram(&session.socket, &frame, self.transport_mode == "udp" ).await;
self.metrics.bytes_sent.fetch_add(frame.len() as u64, Ordering::Relaxed);
}
}
}
}
async fn handle_retransmit(
&mut self,
sessions_opt: &mut Option<Vec<SessionState>>,
udp_rx_opt: &mut Option<mpsc::Receiver<(usize, Bytes)>>,
proxy_guard: &mut Option<crate::sysproxy::SystemProxyGuard>,
stream_map: &mut std::collections::HashMap<u16, usize>,
tx: &mpsc::Sender<UiEvent>,
proxy_tx: &mpsc::UnboundedSender<(u16, ProxyToClientMsg)>,
) {
let mut fatal_err = None;
if let Some(sessions) = sessions_opt.as_mut() {
for session in sessions.iter_mut() {
match session.machine.on_event(OstpEvent::Tick) {
Ok(action) => {
let mut queue = vec![action];
while let Some(current_action) = queue.pop() {
match current_action {
ProtocolAction::Multiple(nested) => {
for a in nested {
queue.push(a);
}
}
ProtocolAction::SendDatagram(frame) => {
let _ = send_datagram(&session.socket, &frame, self.transport_mode == "udp" ).await;
self.metrics.bytes_sent.fetch_add(frame.len() as u64, Ordering::Relaxed);
}
_ => {}
}
}
}
Err(e) => {
fatal_err = Some(e);
break;
}
}
}
}
if let Some(e) = fatal_err {
let _ = tx.send(UiEvent::Log(format!("Protocol tick fatal error: {e}"))).await;
self.running = false;
*proxy_guard = None;
*sessions_opt = None;
*udp_rx_opt = None;
stream_map.clear();
self.reset_proxy_streams(&tx, &proxy_tx, "protocol fatal error");
let _ = tx.send(UiEvent::TunnelStopped).await;
self.metrics.connection_state.store(0, Ordering::Relaxed);
}
}
async fn handle_proxy_event(
&mut self,
proxy_ev: Option<ProxyEvent>,
sessions_opt: &mut Option<Vec<SessionState>>,
stream_map: &mut std::collections::HashMap<u16, usize>,
tx: &mpsc::Sender<UiEvent>,
proxy_tx: &mpsc::UnboundedSender<(u16, ProxyToClientMsg)>,
) {
if let Some(ev) = proxy_ev {
if let Some(sessions) = sessions_opt.as_mut() {
if sessions.is_empty() {
if let ProxyEvent::NewStream { stream_id, .. } = ev {
let _ = proxy_tx.send((stream_id, ProxyToClientMsg::Error("tunnel stopped".into())));
}
return;
}
let (stream_id, relay_msg, is_close) = match ev {
ProxyEvent::NewStream { stream_id, target } => {
let _ = tx.send(UiEvent::Log(format!("Proxy CONNECT stream_id={stream_id} target={target}"))).await;
(stream_id, RelayMessage::Connect(target), false)
}
ProxyEvent::UdpAssociate { stream_id } => {
let _ = tx.send(UiEvent::Log(format!("Proxy UDP ASSOCIATE stream_id={stream_id}"))).await;
(stream_id, RelayMessage::UdpAssociate, false)
}
ProxyEvent::UdpData { stream_id, target, payload } => {
(stream_id, RelayMessage::UdpData(target, payload.to_vec()), false)
}
ProxyEvent::Data { stream_id, payload } => (stream_id, RelayMessage::Data(payload.to_vec()), false),
ProxyEvent::Close { stream_id } => {
let _ = tx.send(UiEvent::Log(format!("Proxy CLOSE stream_id={stream_id}"))).await;
(stream_id, RelayMessage::Close, true)
}
};
let len = sessions.len();
let session_index = *stream_map.entry(stream_id).or_insert_with(|| {
rand::thread_rng().gen_range(0..len)
});
if is_close {
stream_map.remove(&stream_id);
}
let session = &mut sessions[session_index];
let out_payload = Bytes::from(relay_msg.encode());
match session.machine.on_event(OstpEvent::Outbound(stream_id, out_payload)) {
Ok(ProtocolAction::SendDatagram(frame)) => {
if send_datagram(&session.socket, &frame, self.transport_mode == "udp" ).await.is_ok() {
self.metrics.bytes_sent.fetch_add(frame.len() as u64, Ordering::Relaxed);
tracing::trace!("Outbound datagram sent stream_id={stream_id} bytes={}", frame.len());
}
}
Ok(ProtocolAction::Multiple(list)) => {
let mut sent = 0usize;
for item in list {
if let ProtocolAction::SendDatagram(frame) = item {
if send_datagram(&session.socket, &frame, self.transport_mode == "udp" ).await.is_ok() {
self.metrics.bytes_sent.fetch_add(frame.len() as u64, Ordering::Relaxed);
sent += 1;
}
}
}
tracing::trace!("Outbound datagram batch stream_id={stream_id} sent={sent}");
}
Ok(ProtocolAction::Noop) => {
tracing::trace!("Outbound datagram noop stream_id={stream_id}");
}
Ok(_) => {
tracing::trace!("Outbound datagram unexpected action stream_id={stream_id}");
}
Err(e) => {
tracing::warn!("Protocol error packing outbound stream_id={}: {}", stream_id, e);
let _ = tx.send(UiEvent::Log(format!("Protocol error packing TCP: {e}"))).await;
}
}
} else {
if let ProxyEvent::NewStream { stream_id, .. } = ev {
let _ = proxy_tx.send((stream_id, ProxyToClientMsg::Error("tunnel stopped".into())));
}
}
}
}
"""
with open("d:/ospab-projects/ostp/ostp-client/src/bridge.rs", "w", encoding="utf-8") as f:
f.write(prefix + new_run_and_helpers + suffix)
print("Done")

241
scripts/gha.ps1 Normal file
View File

@ -0,0 +1,241 @@
<#
.SYNOPSIS
Cuts a new OSTP release and pushes the tag that triggers the matching
GitHub Actions build (see .github/workflows/release.yml, which only
triggers on "v*" tag pushes + workflow_dispatch a bare branch push
does NOT start a build).
.DESCRIPTION
A release cycle has ONE fixed target version (e.g. "0.4.1") that stays in
Cargo.toml/tauri.conf.json/package.json unchanged through every alpha and
beta build only a per-channel ITERATION counter increments, and that
counter lives ONLY in the git tag, never in the manifests:
v0.4.1-alpha.1 -> v0.4.1-alpha.2 -> ... -> v0.4.1-alpha.100
v0.4.1-beta.1 -> v0.4.1-beta.2 -> ... -> v0.4.1-beta.100
v0.4.1 <- master: iteration dropped
This is deliberately NOT "0.4.1.5-alpha" (a 4th dot-separated component
before the hyphen) that is not valid semver, and Cargo's version parser
rejects it outright. "0.4.1-alpha.5" (dot AFTER the hyphen, a semver
pre-release identifier) is the only form that keeps Cargo.toml itself
parseable, so that's the only place the iteration number is allowed to
live: the git tag.
Promoting to beta/master first fast-forwards that branch to `alpha`
(--ff-only this always succeeds cleanly as long as nobody ever commits
directly to pre-release/master, per CONTRIBUTING.md's branch strategy), so
a release always ships alpha's latest, not a stale branch. Switching to a
channel for the first time in a cycle resets THAT channel's iteration
counter to 1 (a fresh promotion starts its own count; it doesn't inherit
wherever alpha's counter happened to be).
Remembers {target_version, branch, alpha_iteration, beta_iteration} in
.release-state.json at the repo root. Running with no arguments repeats
last time's branch, bumping that channel's iteration by one manifests
are NOT touched (nothing to bump: the target version hasn't changed).
-NewVersion starts a new target version line and resets both iteration
counters to 0 THIS is the one case that bumps every manifest.
.PARAMETER NewVersion
Set a new target version (e.g. "0.4.2") instead of continuing the current
one. Resets both alpha_iteration and beta_iteration to 0. Defaults the
channel back to alpha unless -Branch is also given this run.
NOTE: this parameter is deliberately NOT named "Switch" PowerShell's
`switch` statement keyword is matched case-insensitively against variable
names in scope, and a script parameter named exactly $Switch silently
breaks every `switch (...) { ... }` expression later in the same script
(it evaluates to nothing, no error). Confirmed by bisection: renaming the
parameter is the only thing that fixes it. Do not rename this back.
.PARAMETER Branch
Which branch/channel to release from: master, pre-release (beta), or alpha.
Defaults to whatever was used last time (see .release-state.json).
.EXAMPLE
.\scripts\gha.ps1
Bumps the current channel's iteration by one and pushes v{target}-{channel}.{N}.
.EXAMPLE
.\scripts\gha.ps1 -NewVersion 0.4.2
Starts a fresh 0.4.2 cycle: manifests -> 0.4.2, alpha iteration resets to 1,
ships v0.4.2-alpha.1.
.EXAMPLE
.\scripts\gha.ps1 -Branch pre-release
Promotes alpha -> pre-release (beta channel), resets beta_iteration to 1
(or bumps it if already mid-beta), ships v{target}-beta.{N}.
.EXAMPLE
.\scripts\gha.ps1 -Branch master
Promotes to master and ships the bare v{target} stable tag no iteration.
#>
[CmdletBinding()]
param(
[string]$NewVersion,
[ValidateSet('master', 'pre-release', 'alpha')]
[string]$Branch
)
$ErrorActionPreference = "Stop"
function Write-Step($msg) { Write-Host "==> $msg" -ForegroundColor Cyan }
function Fail($msg) { Write-Host "ERROR: $msg" -ForegroundColor Red; exit 1 }
# -- Locate repo root, regardless of where this script was invoked from ------
$RepoRoot = (git rev-parse --show-toplevel 2>$null)
if (-not $RepoRoot) { Fail "Not inside a git repository." }
Set-Location $RepoRoot
$StateFile = Join-Path $RepoRoot ".release-state.json"
# -- Refuse to run on a dirty tree: this script commits, and an autocommit --
# -- silently sweeping up unrelated WIP changes would be a nasty surprise. --
$dirty = git status --porcelain
if ($dirty) {
Write-Host $dirty
Fail "Working tree has uncommitted changes. Commit or stash them first."
}
# -- Load remembered state ----------------------------------------------------
$State = $null
if (Test-Path $StateFile) {
$State = Get-Content $StateFile -Raw | ConvertFrom-Json
}
$PrevBranch = if ($State) { $State.branch } else { $null }
$IsNewTarget = [bool]$NewVersion
$ResolvedBranch = if ($Branch) { $Branch } elseif ($PrevBranch) { $PrevBranch } else { "alpha" }
# -- Resolve the target version + per-channel iteration counters ------------
if ($IsNewTarget) {
if ($NewVersion -notmatch '^[0-9]+\.[0-9]+\.[0-9]+$') { Fail "-NewVersion must be a bare X.Y.Z version, got '$NewVersion'." }
$TargetVersion = $NewVersion
$AlphaIter = 0
$BetaIter = 0
# A fresh target version starts a fresh cycle at the bottom of the chain,
# unless the caller explicitly asked for a different branch this run.
if (-not $Branch) { $ResolvedBranch = "alpha" }
} else {
# NOTE: a pre-migration state file (old schema: {version, branch, prefix})
# has no target_version property at all — PowerShell silently returns
# $null for a missing property on a PSCustomObject rather than erroring,
# so this must check for it explicitly or $TargetVersion would end up
# $null and corrupt every manifest below.
$TargetVersion = if ($State -and $State.target_version) { $State.target_version } else {
(Select-String -Path (Join-Path $RepoRoot "Cargo.toml") -Pattern '^version = "([0-9]+\.[0-9]+\.[0-9]+)"').Matches[0].Groups[1].Value
}
$AlphaIter = if ($State -and $State.alpha_iteration) { [int]$State.alpha_iteration } else { 0 }
$BetaIter = if ($State -and $State.beta_iteration) { [int]$State.beta_iteration } else { 0 }
# Entering a channel that wasn't active last run (a promotion) starts
# THAT channel's count fresh — it doesn't inherit alpha's iteration number.
$BranchChanged = ($ResolvedBranch -ne $PrevBranch)
if ($BranchChanged -and $ResolvedBranch -eq "pre-release") { $BetaIter = 0 }
if ($BranchChanged -and $ResolvedBranch -eq "alpha") { $AlphaIter = 0 }
}
$Channel = switch ($ResolvedBranch) { "alpha" { "alpha" }; "pre-release" { "beta" }; "master" { "stable" } }
if ($Channel -eq "alpha") { $AlphaIter++ }
elseif ($Channel -eq "beta") { $BetaIter++ }
$Iteration = if ($Channel -eq "alpha") { $AlphaIter } else { $BetaIter }
$Tag = if ($Channel -eq "stable") { "v$TargetVersion" } else { "v$TargetVersion-$Channel.$Iteration" }
Write-Step "Releasing $Tag on '$ResolvedBranch'"
# -- Checkout the target branch, promoting it from alpha first --------------
$CurrentBranch = git rev-parse --abbrev-ref HEAD
if ($CurrentBranch -ne $ResolvedBranch) {
Write-Step "Checking out $ResolvedBranch"
git checkout $ResolvedBranch 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) { Fail "Could not check out branch '$ResolvedBranch'." }
}
if ($ResolvedBranch -ne "alpha") {
Write-Step "Fast-forwarding $ResolvedBranch to alpha (promotion)"
git merge alpha --ff-only 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) {
$msg = "'$ResolvedBranch' has diverged from alpha and can't fast-forward. " +
"Per CONTRIBUTING.md, nothing should ever be committed directly to " +
"$ResolvedBranch - check what's there before forcing anything."
Fail $msg
}
}
# -- Bump manifests ONLY when the target version itself changes. A plain -----
# -- alpha/beta iteration touches nothing but the state file's counter. -----
if ($IsNewTarget -or -not $State) {
Write-Step "Bumping target version -> $TargetVersion"
function Set-VersionLine($Path, $Pattern, $Replacement) {
$full = Join-Path $RepoRoot $Path
$text = Get-Content $full -Raw
$updated = $text -replace $Pattern, $Replacement
if ($updated -eq $text) { Fail "Version pattern not found in $Path - refusing to proceed with a stale file." }
[System.IO.File]::WriteAllText($full, $updated)
}
Set-VersionLine "Cargo.toml" '(?m)^version = "[0-9]+\.[0-9]+\.[0-9]+"' "version = `"$TargetVersion`""
Set-VersionLine "ostp-gui/src-tauri/Cargo.toml" '(?m)^version = "[0-9]+\.[0-9]+\.[0-9]+"' "version = `"$TargetVersion`""
Set-VersionLine "ostp-gui/src-tauri/tauri.conf.json" '"version": "[0-9]+\.[0-9]+\.[0-9]+"' "`"version`": `"$TargetVersion`""
Set-VersionLine "ostp-gui/package.json" '"version": "[0-9]+\.[0-9]+\.[0-9]+"' "`"version`": `"$TargetVersion`""
# Refresh Cargo.lock's per-package version entries. ostp-gui/src-tauri is
# excluded from the main workspace (its own Tauri build graph), so it has
# its own separate Cargo.lock that the main `cargo check` never touches.
Write-Step "Running cargo check to refresh Cargo.lock (main workspace)"
cargo check --workspace --exclude ostp-jni --quiet
if ($LASTEXITCODE -ne 0) { Fail "cargo check failed after the version bump - not committing a broken build." }
Write-Step "Running cargo check to refresh Cargo.lock (ostp-gui/src-tauri)"
Push-Location (Join-Path $RepoRoot "ostp-gui/src-tauri")
cargo check --quiet
$tauriCheckExit = $LASTEXITCODE
Pop-Location
if ($tauriCheckExit -ne 0) { Fail "cargo check failed in ostp-gui/src-tauri after the version bump." }
}
# Flutter's build number (Android versionCode) must strictly increase on
# every single build ever shipped — unlike the semantic version, it does NOT
# stay fixed across alpha/beta iterations, so this runs every time, not just
# on a target-version switch.
$pubspecPath = Join-Path $RepoRoot "ostp-flutter/pubspec.yaml"
$pubspecText = Get-Content $pubspecPath -Raw
if ($pubspecText -match 'version: [0-9]+\.[0-9]+\.[0-9]+\+([0-9]+)') {
$nextBuild = [int]$Matches[1] + 1
$pubspecText = $pubspecText -replace 'version: [0-9]+\.[0-9]+\.[0-9]+\+[0-9]+', "version: $TargetVersion+$nextBuild"
[System.IO.File]::WriteAllText($pubspecPath, $pubspecText)
} else {
Fail "Version pattern not found in ostp-flutter/pubspec.yaml."
}
# -- Persist the new state ---------------------------------------------------
[PSCustomObject]@{
target_version = $TargetVersion
branch = $ResolvedBranch
alpha_iteration = $AlphaIter
beta_iteration = $BetaIter
} | ConvertTo-Json | Set-Content $StateFile
# -- Commit -------------------------------------------------------------------
$commitMsg = "chore: release $Tag on $ResolvedBranch"
Write-Step "Committing: $commitMsg"
git add Cargo.toml Cargo.lock ostp-gui/src-tauri/Cargo.toml ostp-gui/src-tauri/Cargo.lock `
ostp-gui/src-tauri/tauri.conf.json ostp-gui/package.json ostp-flutter/pubspec.yaml `
.release-state.json
git commit -m $commitMsg | Out-Null
# -- Push. release.yml triggers ONLY on "v*" tag pushes (no branch trigger), -
# -- so the tag push is what actually starts the build; the branch push is -
# -- just so the promotion chain (alpha -> pre-release -> master) itself -
# -- keeps moving forward for the next --ff-only. -
Write-Step "Tagging $Tag and pushing $ResolvedBranch + tag"
git tag $Tag
git push origin $ResolvedBranch
git push origin $Tag
Write-Host ""
Write-Host "Done. Watch the build: https://github.com/ospab/ostp/actions" -ForegroundColor Green

View File

@ -28,7 +28,7 @@ fi
mkdir -p "$INSTALL_DIR" mkdir -p "$INSTALL_DIR"
mkdir -p "$CONFIG_DIR" mkdir -p "$CONFIG_DIR"
# ── Migration from legacy installations ────────────────────────────── # -- Migration from legacy installations ------------------------------
migrate_legacy() { migrate_legacy() {
local old_dir="$1" local old_dir="$1"
@ -68,7 +68,7 @@ if [ -L "$BIN_LINK" ] && [ ! -e "$BIN_LINK" ]; then
rm -f "$BIN_LINK" rm -f "$BIN_LINK"
fi fi
# ── Architecture detection ─────────────────────────────────────────── # -- Architecture detection -------------------------------------------
ARCH=$(uname -m) ARCH=$(uname -m)
case "$ARCH" in case "$ARCH" in
@ -85,7 +85,7 @@ esac
echo "Platform: linux/$ARCH" echo "Platform: linux/$ARCH"
# ── Parse arguments ──────────────────────────────────────────────────── # -- Parse arguments ----------------------------------------------------
TARGET_VERSION="" TARGET_VERSION=""
TARGET_BRANCH="stable" TARGET_BRANCH="stable"
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
@ -104,20 +104,20 @@ while [[ $# -gt 0 ]]; do
esac esac
done done
# ── Download binary ────────────────────────────────────────────────── # -- Download binary --------------------------------------------------
if [ -n "$TARGET_VERSION" ]; then if [ -n "$TARGET_VERSION" ]; then
LATEST_RELEASE="$TARGET_VERSION" LATEST_RELEASE="$TARGET_VERSION"
# Ensure it starts with 'v' if it's supposed to (only for real stable # Ensure it starts with 'v' if it's supposed to (only for real stable
# semver tags — the nightly/pre-release channels use bare tag names). # semver tags - the alpha/pre-release channels use bare tag names).
if [[ ! "$LATEST_RELEASE" =~ ^v ]] && [ "$TARGET_BRANCH" == "stable" ]; then if [[ ! "$LATEST_RELEASE" =~ ^v ]] && [ "$TARGET_BRANCH" == "stable" ]; then
LATEST_RELEASE="v$LATEST_RELEASE" LATEST_RELEASE="v$LATEST_RELEASE"
fi fi
echo "Fetching requested release $LATEST_RELEASE..." echo "Fetching requested release $LATEST_RELEASE..."
else else
if [ "$TARGET_BRANCH" == "nightly" ]; then if [ "$TARGET_BRANCH" == "alpha" ]; then
echo "Fetching nightly release..." echo "Fetching alpha release..."
LATEST_RELEASE="nightly" LATEST_RELEASE="alpha"
elif [ "$TARGET_BRANCH" == "pre-release" ]; then elif [ "$TARGET_BRANCH" == "pre-release" ]; then
echo "Fetching pre-release..." echo "Fetching pre-release..."
LATEST_RELEASE="pre-release" LATEST_RELEASE="pre-release"
@ -166,64 +166,27 @@ else
exit 1 exit 1
fi fi
# ── Create global symlink ──────────────────────────────────────────── # -- Create global symlink --------------------------------------------
ln -sf "$INSTALL_DIR/ostp" "$BIN_LINK" ln -sf "$INSTALL_DIR/ostp" "$BIN_LINK"
echo "Symlink created: $BIN_LINK -> $INSTALL_DIR/ostp" echo "Symlink created: $BIN_LINK -> $INSTALL_DIR/ostp"
# ── Update detection ───────────────────────────────────────────────── # -- Update detection -------------------------------------------------
if [ -f "$CONFIG_FILE" ]; then if [ -f "$CONFIG_FILE" ]; then
echo "--------------------------------------------------------" echo "--------------------------------------------------------"
echo "Existing configuration found at $CONFIG_FILE." echo "Existing configuration found at $CONFIG_FILE."
echo "Binary updated to ${LATEST_RELEASE:-latest}." echo "Binary updated to ${LATEST_RELEASE:-latest}."
# ── Config migration: add new fields, preserve existing values ── # Config SCHEMA migration does NOT happen here (or anywhere automatic) -
echo "Checking for new config fields..." # it used to be an ad-hoc Python snippet embedded right in this script,
python3 << 'PYEOF' # silently rewriting config.json on every update. That's exactly the kind
import json, sys # of surprise this project no longer does: the ONE place a config's shape
# is ever changed is the explicit `ostp migrate` command (see
CONFIG = '/etc/ostp/config.json' # ostp-client::migrate), which backs up the original file first and
# prints exactly what it changed. If your config predates this install,
with open(CONFIG) as f: # run it yourself:
raw = f.read() echo "If this config is from an older OSTP version, run 'ostp migrate' to upgrade it."
lines = [l for l in raw.split('\n') if not l.strip().startswith('//')]
cfg = json.loads('\n'.join(lines))
changed = False
# Ensure api section has all modern fields
if cfg.get('mode') == 'server':
if 'api' not in cfg:
cfg['api'] = {}
changed = True
api_defaults = {
'enabled': False,
'bind': '0.0.0.0:9090',
'webpath': '',
'username': '',
'password_hash': '',
}
for k, v in api_defaults.items():
if k not in cfg['api']:
cfg['api'][k] = v
changed = True
print(f'[migration] Added api.{k} = {json.dumps(v)}')
# Remove legacy "token" field if present
if 'token' in cfg['api']:
del cfg['api']['token']
changed = True
print('[migration] Removed legacy api.token field')
if changed:
with open(CONFIG, 'w') as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
print('[ok] Config migrated: new fields added, existing data preserved.')
else:
print('[ok] Config is up to date, no migration needed.')
PYEOF
# Update systemd service to use new paths # Update systemd service to use new paths
if [ -f "/etc/systemd/system/ostp.service" ]; then if [ -f "/etc/systemd/system/ostp.service" ]; then
@ -268,7 +231,7 @@ EOF
exit 0 exit 0
fi fi
# ── First install: delegate to the built-in setup wizard ───────────── # -- First install: delegate to the built-in setup wizard -------------
echo "" echo ""
echo "No configuration found. Launching setup wizard..." echo "No configuration found. Launching setup wizard..."

View File

@ -1,62 +0,0 @@
{
// OSTP Server Configuration
"mode": "server",
"log_level": "info",
// The address and port the server listens on for incoming OSTP connections.
"listen": "0.0.0.0:50000",
// List of valid keys. Clients must use one of these to connect.
"access_keys": [
"a1d8795a93553c08b4e89b017a16ca52"
],
// Optional proxy for outbound traffic.
"outbound": {
"enabled": false,
"protocol": "socks5",
"address": "127.0.0.1",
"port": 9050,
// default_action: 'proxy' (all through proxy) or 'direct' (bypass proxy by default).
"default_action": "proxy",
"rules": [
{
"domain_suffix": [".onion"],
"action": "proxy"
}
]
},
// Web control panel & Management API
"api": {
"enabled": false,
"bind": "0.0.0.0:9090",
// Static API token for Relay servers (optional)
"token": "",
// Secret URL path to hide panel from scanners (e.g. "mySecret123")
"webpath": "",
// Login credentials for web panel (password stored as SHA256 hash)
"username": "",
"password_hash": ""
},
// Fallback TCP proxy: unrecognized connections are proxied to a web server (anti-DPI).
"fallback": {
"enabled": false,
"listen": "0.0.0.0:443",
// Target web server (e.g., local nginx or caddy)
"target": "127.0.0.1:8080"
},
// Reality (XTLS) / UoT Masquerade parameters
"reality": {
"enabled": false,
"dest": "www.microsoft.com:443",
"private_key": "6FVg53jUBTt-dJ52F1Zu1RBCcW1gr9K84WdynBb7i80",
"pbk": "c9QjERoaqFGoKBd-9ZpNzj51E8B93fcnEQT_cohEk2E",
"sid": "960223edfa174fc5",
"sni_list": ["www.microsoft.com"]
},
"debug": false,
}

View File

@ -1 +0,0 @@


View File

@ -1,3 +0,0 @@
use std::net::SocketAddr; fn main() { println!(\
:?
\, \[::1]:80\.parse::<SocketAddr>()); }