Commit Graph

589 Commits

Author SHA1 Message Date
ospab 0bb7db4f01 chore: release v0.4.5-beta.9 on beta 2026-08-19 21:08:46 +03:00
ospab f59d70778a feat(flutter): TTL-desync toggle in the Android profile editor
Mirrors the desktop toggle: a per-profile ttlDesync flag on OstpProfile, a
SwitchListTile in the profile editor (shown for all transports, since it is the
UDP-path counterpart to the UoT-only junk/fragmentation block), and
transport.ttl_desync in the config handed to the engine, with ttl_desync_auto
true so the shared ostp-client measures the hop distance and aims the decoys
itself. The auto-calibration engine is shared, so Android already had the
capability — this just exposes the switch.
2026-08-19 21:08:24 +03:00
ospab bfd079ff48 feat(gui): TTL-desync toggle in client settings
Adds a TTL Desync toggle to the obfuscation section of the desktop client
settings, wired the same way as the Junk/TCP-fragmentation toggles: persisted in
client settings, applied into transport.ttl_desync in the built config, included
in the hot-reload fingerprint so flipping it while connected re-applies, and
labelled in both EN and RU. ttl_desync_auto is passed true, so the engine
measures the hop distance and aims the decoys itself — the user just flips it on.
2026-08-19 19:58:18 +03:00
ospab 1e9111ab9f feat(client): auto-calibrate TTL-desync by measuring hops to the server
Adds a tiny built-in probe (ttl_probe) that measures the hop distance to the
server, so the TTL-desync decoys are aimed automatically instead of by a
hand-guessed number — without pulling in the whole ostp-prober and without any
new server endpoint or exposed port.

How it works, and why it needs no prober-server: the OSTP server answers only a
valid handshake and silently drops everything else, so the client sends the real
handshake with a rising IP TTL and watches for the first TTL that draws a reply.
Datagrams whose TTL is too low die on a router before the server and create no
state there; the first responding TTL is the server distance. Decoys are then
stamped at hops-1, so they clear the DPI (which sits far closer than the server)
yet expire before the server. This is inherently key-gated — no key, no valid
handshake, no reply, so an unauthenticated caller learns nothing — and rides the
existing UDP port, which is exactly the "works for key holders, no prober-server,
no ports exposed to the internet" property that was asked for.

Config: transport.ttl_desync_auto (on by default). When on and desync is
enabled, the measured value overrides ttl_desync_ttl; measurement runs once and
is cached, cleared on a config change. On measurement failure it falls back to
the configured fixed TTL rather than skipping desync. The whole path is gated
behind ttl_desync (off by default), so a normal connection never runs it.

The probe logic is unit-tested (measures against a local responder; decoy-TTL
math). The real hop measurement and the desync effect both need a real network
to confirm and cannot be exercised here.
2026-08-19 19:53:14 +03:00
ospab d187609629 chore: release v0.4.5-beta.8 on beta 2026-08-19 19:24:17 +03:00
ospab d4d4600d87 feat(client): opt-in TTL-desync decoys on the UDP handshake
Adds a socket-level TTL desync: before the UDP handshake, the client fires a
few decoy datagrams with a lowered IP TTL, then restores the socket's TTL and
sends the real handshake. The decoys are meant to reach an on-path DPI box and
expire before the server, so the box classifies the flow on the decoys while
the server never sees them. Each decoy carries the key's junk marker, so any
that does reach the server is dropped there silently.

UDP only. On UoT the carrier is a single TCP stream, so a socket-level TTL
change would apply to the real traffic too; proper TCP desync needs injected
packets via a driver (WinDivert/NFQUEUE), which this deliberately does not
attempt — it stays a no-op there rather than pretending to work.

Off by default, and configurable under transport: ttl_desync (bool),
ttl_desync_ttl (u8, default 8), ttl_desync_count (u8, default 2). The right TTL
is the injector hop distance the prober's ttl_injector_probe reports, plus a
hop or two so decoys die just past the DPI; the wrong value is simply inert,
which is why this ships opt-in. Plumbed through the engine, the CLI, and the
GUI config mapping; new fields default so existing configs are unaffected.

Its actual DPI-evasion effect cannot be verified here — it needs a real
censored path — so this is the mechanism, to be tuned against the prober.
2026-08-19 19:23:55 +03:00
ospab 3d2b9236e1 fix(tun): reassemble IPv4 fragments so large UDP (games) is not dropped
The user's game (Roblox) disconnected on join while the menu was fine. The log
showed the cause directly, over and over:

  ERROR netstack_smoltcp::udp: invalid err: wire::Error,
        src_ip: 10.1.0.2, dst_ip: 13.249.8.109, payload: [~1400 bytes]

The userspace netstack parses each IP packet and runs UdpPacket::new_checked on
its payload. An IP *fragment* passes the IP check but fails the UDP one — the
UDP length field describes the whole datagram while the fragment holds only a
slice — so smoltcp drops it with wire::Error. Large UDP datagrams (a game
sending >MTU packets that the OS fragments on the way to the TUN) therefore
vanished entirely and the game timed out. smoltcp 0.2.2 does no reassembly.

Adds an IPv4 reassembler between the TUN read and the netstack: fragments are
buffered by (src, dst, id, proto) with a 3s timeout and a group cap, and only a
fully reassembled datagram — header fixed up (fragment fields cleared, total
length and checksum recomputed) — is handed on. Non-fragmented packets pass
through untouched. Wired into both the Windows and Linux tun→stack loops.

The reassembly logic is pure and unit-tested (in-order, out-of-order,
pass-through, incomplete-group, checksum). End-to-end behaviour still needs the
user's live TUN to confirm, since that cannot be exercised here.
2026-08-19 19:11:35 +03:00
ospab 7f9c1e719c chore: release v0.4.5-beta.7 on beta 2026-08-18 23:14:07 +03:00
ospab 321365efe3 fix(tun): TUN routing failed on every connect, freezing under load
A user's log showed the same two failures on all 9 connects, 198 route errors
total:
  Added 0 bypass routes via 192.168.88.1 (if_index=11)
  Could not find ostp_tun index in routing table after 15s — traffic will NOT be captured

Two independent bugs in the Windows route layer:

1. The TUN interface index was looked up by matching FriendlyName == "ostp_tun"
   through GetAdaptersAddresses. WinTun does not set the FriendlyName to the
   adapter name, so the match never succeeded — every connect burned the full
   15s window and gave up. The tun crate hands the real index back directly via
   AbstractDevice::tun_index() (WinTun's own adapter index), which is instant and
   correct; the name lookup remains only as a fallback.

2. Every route add — the server-IP bypass and the TUN default route alike — went
   through the legacy CreateIpForwardEntry, which failed with error 160
   (ERROR_BAD_ARGUMENTS) on this machine for all of them. With the server-IP
   bypass never installed, the server's own packets were routed INTO the tunnel:
   a loop that stalls the link for seconds under load (the reported "VPN drops
   ~8s into a game" — the tunnel never actually disconnected, it froze; the log
   showed gap recovery skipping up to 402 frames with no packet loss on the
   wire). add_ipv4_route now shells to route.exe, which resolves the interface
   and validates the gateway itself and is already what the teardown path uses;
   its command form was verified to be accepted (fails only on elevation, not
   syntax). CREATE_NO_WINDOW keeps it from flashing a console per route.

Cannot be verified without the user's elevated TUN environment; the next log
will read "Added N bypass routes" and "Default route via TUN ... added" instead
of the failures.
2026-08-18 23:13:51 +03:00
ospab 44677c68e4 chore: release v0.4.5-beta.6 on beta 2026-08-17 17:25:16 +03:00
ospab a03e2c9855 fix(relay): stop rejecting every generated relay config at load
The relay is a transparent pipe now — it authenticates nothing and forwards to a
fixed next hop. Both the setup wizard and the `init` template write a relay
config with only listen + upstream_tcp + upstream_udp, and the relay runtime
uses exactly those. But UnifiedConfig::validate still demanded a non-empty
upstream_api_url — a field left from the old design where the relay
authenticated clients itself and pulled the key list from the target's API.

So the tool generated a config it then refused to load: every relay came up with
"Relay configuration must specify upstream_api_url." That is why relay "was
never finished" — it could not start from any config the tool itself produced.

Validation now matches the transparent relay: require both upstream addresses
(the runtime needs both carriers), and do not require the dead api_url. Leftover
api_url in an old config is still tolerated, just ignored.

Adds regression tests that load a config exactly as the daemon does
(deserialize into the one canonical UnifiedConfig, then validate) for all three
modes. This is the drift-catcher: whenever the wizard/template and the validator
disagree on required fields again, a test fails instead of a user's node
refusing to start.
2026-08-17 16:04:38 +03:00
ospab f5a1c17679 chore: release v0.4.5-beta.5 on beta 2026-08-11 23:31:11 +03:00
ospab 732d0bf5ae fix(installer): grant users permission to start the helper task
The task was registered correctly and pointed at the right binary — the app's
own log confirmed the match — but starting it failed:

  run: schtasks /Run failed (Some(1)):  ERROR: Access is denied.
  falling back to a direct elevated launch — this is the consent prompt

Registering a task and being allowed to start one are separate things, and I
had conflated them. The principal (BUILTIN\Users by SID, HighestAvailable)
decides who the task runs AS. Who may START it comes from the task's security
descriptor, and a task created by an elevated installer defaults to granting
execution to Administrators only. So the unprivileged GUI was refused and fell
back to prompting on every connect, exactly as before the installer existed.

This also explains why manual testing said the opposite: running the task by
hand happened from an elevated console, where it works, which pointed suspicion
at the app for several rounds.

Register-ScheduledTask cannot set a descriptor, so the hook now follows the
registration with a SetSecurityDescriptor call through the Task Scheduler COM
object: GA for Administrators and SYSTEM, GR+GX for BUILTIN\Users. A failure
there is reported on its own rather than being folded into the success message,
since the task would otherwise look registered while remaining unusable.
2026-08-11 23:28:36 +03:00
ospab 2887f1af8d chore: release v0.4.5-beta.4 on beta 2026-08-11 21:33:14 +03:00
ospab b18852ac07 fix(gui): relax the helper-task check, and make its decision observable
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.
2026-08-11 21:33:05 +03:00
ospab 1ab55fcdd0 chore: release v0.4.5-beta.3 on beta 2026-08-11 17:43:24 +03:00
ospab 234497759b fix(gui): config went to the working directory; installer wrote unusable XML
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.
2026-08-11 17:43:04 +03:00
ospab a63c34669b chore: release v0.4.5-beta.2 on beta 2026-08-11 17:08:08 +03:00
ospab b18de0379c fix(ci): staging script was CommonJS in an ES-module package
ostp-gui/package.json sets "type": "module", so a .js file is loaded as an ES
module and `require` is not defined — the installer build died on the first
line of stage-sidecar.js. Renamed to .cjs, which opts that one file back into
CommonJS.

The failure was also reported in the wrong place. pwsh does not abort a run
block when a native command exits non-zero, so the build carried on past the
dead script and failed several steps later complaining about a sidecar that
nothing had staged. The two commands are chained now, so staging failures
surface as themselves.

Verified locally this time: the script resolves the host triple, copies the
helper to the name Tauri expects, and warns about a missing wintun.dll rather
than failing silently.
2026-08-11 17:07:55 +03:00
ospab 96d6bb61d2 chore: release v0.4.5-beta.1 on beta 2026-08-11 16:54:34 +03:00
ospab 8af4be9b0a fix(gui): confine the installer's sidecar to the installer build
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.
2026-08-11 16:54:11 +03:00
ospab 8b5c0a3a8c feat(gui): register the helper task from an installer, not from the app
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.
2026-08-11 16:43:49 +03:00
ospab cf14a4243c chore: release v0.4.4 on master 2026-08-08 21:37:55 +03:00
ospab 66368c9d0f fix(gui): helper task checked only its name, not the exe it points at
A Scheduled Task stores an absolute path. Checking that a task named
"OSTP TUN Helper" exists said nothing about whether its <Command> still points
at the helper we are about to run, and the paths do drift: a dev build
registers target\debug\ostp-tun-helper.exe, an installer registers Program
Files, and moving or reinstalling the app leaves the old path behind.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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