The installer's task is correct on the reporting machine — right path, right
principal, and running it by hand starts the helper with no prompt — yet the
app still fell back to an elevated launch on every connect. The exact-path
comparison is the only thing that can reject it, and it was never worth its
strictness: what the check exists to catch is a task left pointing at a binary
that is gone, since `schtasks /Run` reports success merely for accepting such a
request and the app would then wait on a helper that never starts. Testing that
the registered file exists and is the helper catches exactly that case, without
charging a prompt for any other difference.
The reason this took several rounds to narrow down is the real defect: every
failure on this path went to `eprintln!`, and the GUI is a windowed binary with
no console, so the one decision that determines whether the user gets a consent
prompt was completely unobservable on their machine. It now appends to
%LOCALAPPDATA%\OSTP\helper-launch.log — what was registered, whether it exists,
what schtasks /Run answered, and whether the fallback was taken.
Three defects the first installer build exposed.
Settings could not be read or saved, "os error 5". With no config beside the
executable — which is the case for every fresh install — get_config_path fell
back to a bare relative "config.json", resolved against the process working
directory. Launched from a Start Menu shortcut that is whatever Windows chose,
frequently C:\Windows\System32. On a writable working directory the silent
outcome would have been worse than the error: settings persisting somewhere
unrelated and appearing to vanish. The config now lives beside the executable
only where that directory actually accepts writes, and otherwise under the
user's own profile, carrying an existing read-only copy across once.
Writability is measured, not inferred from the path: an install onto a data
drive may well be writable where Program Files is not.
The installer could not register the task: "The task XML is malformed.
(1,2)::ERROR: incorrect document syntax". Writing it from NSIS emitted a UTF-16
byte-order mark ahead of content whose encoding depends on whether makensis was
built in Unicode mode. Replaced with the ScheduledTasks cmdlets, which take the
same settings as arguments — no file, so no encoding to get wrong. Verified the
invocation reaches Register-ScheduledTask and fails only on "Access is denied"
when unelevated, which is exactly what the elevated installer supplies.
That command is delimited with backticks, NSIS's third quote character. As a
single-quoted string it would have ended at PowerShell's first quote.
"Copy failed" on wintun.dll: CopyFiles takes a destination directory, and it
was given a file path. It is also guarded now, so a missing resource says so
instead of failing mutely.
Finally, per request, the app no longer registers the task itself — that is the
installer's job alone. Without a task it goes straight to the direct elevated
launch, which prompts per connect as it always did, rather than spending a
prompt on a registration attempt and then another on the launch.
Naming the file tauri.windows.conf.json made Tauri merge it into every Windows
build automatically, and externalBin is resolved by the build script — so a
bare `cargo check` in src-tauri started failing with "resource path
binaries\ostp-tun-helper-x86_64-pc-windows-msvc.exe doesn't exist" unless the
sidecar had been staged first. That broke the release script's own cargo check
and would have broken the portable zip build too.
Renamed to tauri.installer.conf.json, which Tauri does not pick up on its own,
and passed explicitly with --config from the one step that wants it. Plain
builds are back to exactly what they were; only the installer needs staging.
Elevation belongs to install time. Registering a task that runs elevated is
itself privileged, so an unprivileged GUI can only obtain one by raising the
very prompt we are trying to remove. There was nowhere to put it: the Windows
GUI ships as a portable zip built with --no-bundle, so the project had no
installer at all. Adds an NSIS one, whose POSTINSTALL hook registers the task
while already elevated. Connecting then prompts zero times.
NSIS over WiX because installerHooks is an NSIS feature; the MSI equivalent
needs a custom action, which is more bespoke machinery, not less. installMode
is perMachine — the default, currentUser, does not run elevated, and the hook
would fail exactly as the in-app attempt did.
The task's principal is the SID S-1-5-32-545 (BUILTIN\Users) with
InteractiveToken rather than the installing user, so a machine-wide install
serves every account instead of only whoever ran the installer; the name is
localized and would not resolve. %LOCALAPPDATA% in the arguments is left
unexpanded for the same reason — Task Scheduler expands it per running user.
Also fixes the in-app fallback, which the portable zip still needs and which
had never once worked. It trusted the exit code of an elevated schtasks, but
-Verb RunAs launches through ShellExecute and a non-elevated parent generally
cannot read the child's exit code: $p.ExitCode yields $null, and `exit $null`
leaves PowerShell reporting 0 (measured, not assumed). Failure was arriving
disguised as success. -Wait does not reliably block either, so deleting the
task XML afterwards raced schtasks reading it. It now waits for the task to
actually appear before deleting anything, and treats the exit code as advisory
except for 1223, a declined prompt, which is worth failing fast on.
Corrects one comment that asserted the opposite of the truth: schtasks writes
UTF-16 to a console but UTF-8 with no BOM into a redirected pipe, which is the
case that matters here. Only the fallback made the path check work at all.
wintun.dll rides along as a bundled resource and the hook copies it beside the
executables, since the helper loads it with a plain LoadLibrary. The uninstall
hook removes both it and the task, so no stale registration is left pointing at
a deleted binary.
A Scheduled Task stores an absolute path. Checking that a task named
"OSTP TUN Helper" exists said nothing about whether its <Command> still points
at the helper we are about to run, and the paths do drift: a dev build
registers target\debug\ostp-tun-helper.exe, an installer registers Program
Files, and moving or reinstalling the app leaves the old path behind.
That failed silently in the worst way. schtasks /Run reports success for
merely ACCEPTING the launch request — a task whose exe no longer exists fails
afterwards, out of band, with nothing returned to us. So launch_as_admin
returned Ok, and the caller then sat in its 60-second connect loop before
reporting "Timeout connecting to helper." On every connect, permanently, with
no way out except deleting the task by hand.
The check now reads the registered <Command> back and compares it to the exe,
re-registering through the existing /F overwrite when they differ: one consent
prompt, once, instead of a permanent silent breakage.
The path is read via /Query /XML rather than /FO LIST /V because the list
format's field labels are localized — "Task To Run" is "Задача для запуска" on
a Russian Windows — while XML tag names are not. schtasks emits UTF-16LE with
a BOM there, which is decoded explicitly, with UTF-8 tolerated as a fallback.
Both paths are canonicalized before comparison so casing, `..` and 8.3 short
names do not read as a mismatch; a path that cannot be canonicalized no longer
exists, which is itself grounds to re-register.
Reported from v0.4.3: a consent prompt for schtasks, then 10-20 console windows
opening and closing, then STILL a prompt for the helper. Two defects of mine,
both in the change that was supposed to remove the repeated prompt.
Registration never succeeded. ShellExecuteW returns as soon as the elevated
process is LAUNCHED, not when it finishes, so the generated XML was deleted
while schtasks was still starting — it then had nothing to read. The task was
never created, so the code fell through to the direct elevated launch and the
user paid for two prompts to get what one used to do. Registration now goes
through PowerShell's Start-Process -Verb RunAs -Wait -PassThru, which actually
waits, lets the XML be deleted safely afterwards, and surfaces the real exit
code instead of it being inferred by polling. Arguments are passed as an array,
so the task name and XML path never touch a command line; verified the
generated script parses with a path containing an apostrophe, an ampersand and
spaces at once.
The flashing was every schtasks/reg/tasklist invocation: the GUI is a
windowed-subsystem binary, so each console child pops a window, and the
registration polled up to twenty times in a row. All of them now go through a
wrapper that sets CREATE_NO_WINDOW. This also silences flashes that predate
this feature — `tasklist` runs whenever the exclusions screen opens, and `reg`
on autostart changes.
Polling is gone with it: the exit code is authoritative, and the task's
presence is confirmed once rather than up to twenty times.
Also drops shell_execute_elevated, which this change had left with no callers.
Enabling TUN raised a consent dialog every single time, because the GUI
elevated the helper with ShellExecuteW("runas") on each connect.
Registers a Scheduled Task with RunLevel=HighestAvailable the first time TUN
is used — that registration is the one prompt — and triggers the task on every
later connect. Running a task is not an elevation request, so Windows shows no
dialog for it. If the task is missing or cannot be triggered, the code falls
back to the original direct elevated launch, so this can only improve on the
old behaviour, never break it.
A task stores a FIXED command line, so the per-launch port and token cannot be
arguments. The helper gained --args-file and the GUI writes them to
%LOCALAPPDATA%\OSTP\helper-args.json immediately before triggering; the helper
deletes it after reading. That path keeps the token inside the trust boundary
it already had — the helper runs elevated but as the same user, and no other
user can read it, which a shared location would not guarantee.
Registered from an XML definition rather than /TR: the exe and args paths would
otherwise need quoting inside an already-quoted /TR value, escaped again
through ShellExecuteW, which breaks as soon as either contains a space — and
both do by default (Program Files, usernames with spaces). Validated that the
generated XML parses with a path containing both a space and an ampersand.
XML also lets DisallowStartIfOnBatteries=false and ExecutionTimeLimit=PT0S be
stated explicitly, without which a laptop would refuse to start the tunnel on
battery and Windows would kill it after three days.
Deliberately NOT a Windows service, contrary to what I suggested earlier. The
helper is one-shot by design: it force-exits after teardown because WinTun's
blocking receive otherwise keeps the adapter and its default route alive and
breaks the next connect. A persistent LocalSystem service would mean
restructuring that lifecycle for the same end-user result. The task also runs
with the user's own token rather than LocalSystem, which is less privilege for
the same outcome. A service is still the better answer if the tunnel should
come up before login — that is the one thing this cannot do.
Brings the Linux GUI up to parity with Windows. Four things blocked it, each
independently sufficient:
- build.rs keyed the Windows-manifest step off cfg(windows), which in a build
script describes the HOST. Cross-compiling the helper from Windows to Linux
therefore took that branch and died with "Can only compile resource file
when target_env is gnu or msvc". Now keyed off CARGO_CFG_TARGET_OS, with
the cfg(windows) gate kept as a second check because winres is a host-
resolved build-dependency and simply does not exist on a Linux host.
- launch_as_admin was bail!("Windows only.") outside Windows. Implemented for
Linux via pkexec, polkit's front-end, which raises a graphical auth prompt;
sudo is unusable from a GUI with no terminal. Missing pkexec now names the
package to install instead of failing opaquely.
- The release workflow only built ostp-tun-helper in the Windows job, so the
Linux package shipped without it. It is now built and placed next to the
GUI binary, where find_helper_exe looks first.
- set_autostart/get_autostart were no-ops off Windows. Implemented via XDG
autostart (~/.config/autostart/ostp.desktop, honouring XDG_CONFIG_HOME),
the direct equivalent of the HKCU Run key.
The helper's own code needed no changes — it already compiled for Linux once
the build script stopped rejecting it. list_running_processes already had a
Linux branch.
The token file is created 0600 on Linux: /tmp is world-readable there, unlike
the Windows temp dir, and that token authorises control of the privileged
helper.
Not verified on a live Linux desktop from here — the Tauri backend cannot be
compiled for Linux on a Windows host (GTK dev libraries), so the cfg(linux)
paths are reviewed rather than built. CI compiles them.
Reported from the Linux GUI: it asked for "helper.exe" on Linux, and the
window was tiny.
The helper name had ".exe" hardcoded in every lookup path, so on Linux the
search could only ever fail. Fixing the name alone would have been misleading
though, because TUN mode does not work on Linux for a deeper reason:
launch_as_admin is `bail!("Windows only.")` outside Windows, and the release
workflow only builds ostp-tun-helper in the Windows GUI job. So the feature is
Windows-only, and the message now says exactly that and points at proxy mode,
instead of surfacing as a missing file named after a Windows executable —
which reads like a packaging mistake rather than an unimplemented feature. The
name is still resolved per-platform for when Linux elevation does land.
The window was 360x680 and `resizable: false`. Windows scales that by DPI, but
WebKitGTK on a HiDPI Linux display renders it close to raw pixels, giving a
postage-stamp window the user then could not resize. It is now resizable with
a sensible minimum, and .app-root caps and centres the column so a wider
window keeps the intended narrow layout instead of stretching the controls.
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.
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.
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.
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.
- 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.
security / protocol:
- Derive a PER-KEY junk marker (obfuscation.rs, info byte 0x04) instead of the
global constant [0x88,0x1A,0x93,0x5D]. A fixed marker was a universal DPI
signature identifying ALL OSTP users at once — exactly what the HKDF version
gate avoids for the handshake. Server drops junk via a new DispatchOutcome::Junk
inside the existing key-trial loop (secrets already derived → zero extra cost);
client stamps its own key's marker.
- §E: configurable junk/fragmentation params (junk_pc / junk_ps / frag_chunk / frag_sleep).
GUI (desktop):
- Light theme + toggle, GUI version footer in Settings.
- Fix mouse-wheel scroll on Settings (flex child needed min-height: 0).
- Drop the false "process exclusions unsupported in TUN mode" warning — they DO
work (native_handler maps port->process via GetExtendedTcpTable).
release / infra:
- build.ps1: add -PreRelease (tag CURRENT version as v<ver>-beta.N, no bump, no
master commit); guard the panel build when ostp-control ships no source; bump
the real ostp-gui/package.json instead of the nonexistent ostp-control one.
- release.yml: mark hyphenated tags as GitHub pre-releases; don't hard-fail the
web-panel step when there is no source (use committed dist/).
- Versions aligned to 0.4.1; README license badge BSL 1.1 -> AGPL v3.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Previously start_tunnel returned early if a tunnel was already running, so
changing the server while connected silently kept the OLD connection. Per
the plan ("server change = full stop+start, not hot-reload"), tear down any
existing InProcess/Helper tunnel before starting a fresh one. For the
elevated helper, wait ~1.2s after sending stop so it releases the ostp_tun
adapter before a new helper recreates it (avoids name clashes). start_tunnel
is only invoked on an explicit connect, so restarting here is safe.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The plan's share feature for the (single-config) desktop GUI. The QR is
rendered locally so the access key never leaves the device.
- src-tauri: add `qrcode = "0.14"` (features=["svg"]) + `generate_qr`
command (string -> SVG), registered in the invoke handler. Ported from
the current ostp-gui. (cargo check on src-tauri passes.)
- Frontend: "Share" button next to Import builds `ostp://KEY@HOST?sni&type`
from the current config fields, calls generate_qr, and shows a modal with
the QR + a read-only link + Copy. Added i18n keys (en/ru) so the new
data-i18n labels resolve (missing keys would render as the raw key).
Note: Rust side verified via cargo check; the frontend is syntax-checked
(node --check) but not runtime-verified — needs a Tauri build to confirm
visually.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Anti-DPI obfuscation the project wants to keep, ported from 0.3.x with the
harmful UDP behaviour designed out from the start.
- Junk: before the handshake on a UoT/TCP connection, send 2-5 random
length-prefixed frames (100-1000 B). The server reads each as a frame,
fails to authenticate it, drops it and keeps reading (drop-and-continue),
so junk perturbs DPI flow analysis without breaking the connection. Junk
is NEVER sent over UDP — there each junk would be a lone datagram
indistinguishable from a port scan (probe-flood / wasted CPU / the very
"self-ban" risk the plan calls out). Verified the server has no
probe-based ban, and the unauthorized-probe log is already rate-limited
(§B), so junk-over-UoT produces one debug line, not a flood.
- TCP fragmentation: new `transport.tcp_fragmentation` flag (default off).
When set, the writer splits the first real frame (the handshake) — length
header byte-by-byte then payload in 2-byte chunks with short gaps — so DPI
can't classify the handshake from a single read.
- Ranges are hardcoded for now; §E fine-tuning (configurable Jc/Jmin/Jmax,
S1/S2, H1..H4) is deferred.
Verified by loopback E2E: a UoT client with tcp_fragmentation=true connects
(junk logged as one rate-limited probe, then real handshake accepted) and
curl via SOCKS5 tunnels HTTPS successfully.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Clean-rebuild on the stably-working v0.2.98 base. The project's stealth
path is zapret-like (packet obfuscation / junk / fragmentation), NOT
TLS-mimicry, so WSS and Reality are dropped entirely.
- Delete dead orphan files: ostp-client/src/transport/xhttp.rs and
ostp-core/src/crypto/reality.rs (never declared as modules → not even
compiled), plus ostp-core/src/framing/wss.rs.
- Scrub the `wss` transport field from client config/bridge, the unified
CLI (ostp/src/main.rs), the Tauri GUI backend, the GUI frontend
(index.html/main.js), and the Flutter UI; also drop the Reality
pbk/sid plumbing and XTLS auto-search modes from both frontends.
- Drop now-unused client deps (x25519-dalek, chacha20poly1305, hex).
- Bump workspace to version 0.4.0 and license AGPL-3.0; make ostp's
ostp-core dep path-only so the version bump resolves.
- gitignore ostp-control/ (panel assets built separately; a dummy dist
is created for the rust-embed build).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When using xhttp (UoT) mode on Android, the underlying TcpStream was
not protected with VpnService.protect(fd). This caused the TCP connection
to be routed back into the TUN interface, creating an infinite routing
loop and failing the connection immediately.
Added Android-specific socket protection to the TcpStream in connect_xhttp.
This fixes xhttp/UoT mode on mobile networks.
The core bug: server sent 5 TLS records in server_hello but client only
read the first one (ServerHello), then passed remaining bytes (CCS + fake
records) into RealityStream. RealityStream saw 0x14 (CCS) != 0x17 and
immediately returned an error, killing the connection.
Changes:
- reality.rs: append ChangeCipherSpec after ClientHello (RFC 8446 D.4)
export REALITY_SERVER_HANDSHAKE_RECORDS=5 constant
- xhttp.rs: drain all 5 server handshake records before creating RealityStream
- uot.rs: rebuild server_hello as proper 5-record TLS 1.3 flight:
ServerHello + CCS + fake EE (108B) + fake Cert (812B) + fake Fin (52B)
drain client CCS from raw stream before wrapping in RealityStream