Compare commits

..

18 Commits

Author SHA1 Message Date
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
23 changed files with 924 additions and 205 deletions

View File

@ -417,6 +417,29 @@ jobs:
Compress-Archive -Path "$dir/*" -DestinationPath "ostp-windows-gui-${{ matrix.arch }}.zip" -Force Compress-Archive -Path "$dir/*" -DestinationPath "ostp-windows-gui-${{ matrix.arch }}.zip" -Force
# The installer is what removes the per-connect consent prompt: it runs
# elevated, so its hook can register the helper's Scheduled Task once.
# The portable zip above cannot, and falls back to asking on first connect.
# The sidecar and its config are confined to this step: declaring
# externalBin in an auto-merged tauri.windows.conf.json would force every
# Windows build, down to a bare `cargo check`, to have the helper staged
# first, and fail the build script when it is not.
- name: Build NSIS Installer
working-directory: ostp-gui
# Chained, not two lines: pwsh does not abort a run block when a native
# command fails, so a staging failure would otherwise be reported far
# downstream as a missing sidecar rather than as itself.
run: node stage-sidecar.cjs --release --target ${{ matrix.target }} && npx tauri build --bundles nsis --target ${{ matrix.target }} --config src-tauri/tauri.installer.conf.json
- name: Collect installer
shell: pwsh
run: |
$nsis = Get-ChildItem -Path "ostp-gui/src-tauri/target/${{ matrix.target }}/release/bundle/nsis" -Filter *-setup.exe -ErrorAction SilentlyContinue |
Select-Object -First 1
if (-not $nsis) { Write-Error "NSIS installer was not produced"; exit 1 }
Copy-Item $nsis.FullName "ostp-windows-gui-${{ matrix.arch }}-setup.exe"
Write-Host "installer: $($nsis.Name) -> ostp-windows-gui-${{ matrix.arch }}-setup.exe"
- name: Upload to GitHub Release - name: Upload to GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
@ -426,7 +449,9 @@ jobs:
# real stable release. # real stable release.
tag_name: ${{ needs.resolve-channel.outputs.tag_name }} tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
prerelease: ${{ needs.resolve-channel.outputs.prerelease }} prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
files: ostp-windows-gui-${{ matrix.arch }}.zip files: |
ostp-windows-gui-${{ matrix.arch }}.zip
ostp-windows-gui-${{ matrix.arch }}-setup.exe
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

3
.gitignore vendored
View File

@ -57,3 +57,6 @@ ostp-control/
netstack-smoltcp/ netstack-smoltcp/
dnstt/ dnstt/
ostp-web/ ostp-web/
# Tauri sidecar staging area (copied from target/ at build time)
ostp-gui/src-tauri/binaries/

View File

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

12
Cargo.lock generated
View File

@ -1386,7 +1386,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]] [[package]]
name = "ostp" name = "ostp"
version = "0.4.4" version = "0.4.5"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@ -1409,7 +1409,7 @@ dependencies = [
[[package]] [[package]]
name = "ostp-client" name = "ostp-client"
version = "0.4.4" version = "0.4.5"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@ -1440,7 +1440,7 @@ dependencies = [
[[package]] [[package]]
name = "ostp-core" name = "ostp-core"
version = "0.4.4" version = "0.4.5"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@ -1474,7 +1474,7 @@ dependencies = [
[[package]] [[package]]
name = "ostp-server" name = "ostp-server"
version = "0.4.4" version = "0.4.5"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@ -1507,7 +1507,7 @@ dependencies = [
[[package]] [[package]]
name = "ostp-tun" name = "ostp-tun"
version = "0.4.4" version = "0.4.5"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"libc", "libc",
@ -1519,7 +1519,7 @@ dependencies = [
[[package]] [[package]]
name = "ostp-tun-helper" name = "ostp-tun-helper"
version = "0.4.4" version = "0.4.5"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",

View File

@ -12,7 +12,7 @@ resolver = "2"
[workspace.package] [workspace.package]
edition = "2021" edition = "2021"
license = "AGPL-3.0" license = "AGPL-3.0"
version = "0.4.4" version = "0.4.5"
[workspace.dependencies] [workspace.dependencies]
anyhow = "1.0" anyhow = "1.0"

View File

@ -133,6 +133,9 @@ pub struct Bridge {
pub frag_sleep: u64, pub frag_sleep: u64,
pub junk_pc: [usize; 2], pub junk_pc: [usize; 2],
pub junk_ps: [usize; 2], pub junk_ps: [usize; 2],
pub ttl_desync: bool,
pub ttl_desync_ttl: u8,
pub ttl_desync_count: u8,
pub mtu: usize, pub mtu: usize,
pub kill_switch: bool, pub kill_switch: bool,
pub reload_tx: Option<watch::Sender<crate::config::ExclusionConfig>>, pub reload_tx: Option<watch::Sender<crate::config::ExclusionConfig>>,
@ -184,6 +187,9 @@ impl Bridge {
frag_sleep: config.transport.frag_sleep, frag_sleep: config.transport.frag_sleep,
junk_pc: config.transport.junk_pc, junk_pc: config.transport.junk_pc,
junk_ps: config.transport.junk_ps, junk_ps: config.transport.junk_ps,
ttl_desync: config.transport.ttl_desync,
ttl_desync_ttl: config.transport.ttl_desync_ttl,
ttl_desync_count: config.transport.ttl_desync_count,
mtu: config.ostp.mtu, mtu: config.ostp.mtu,
kill_switch: config.kill_switch, kill_switch: config.kill_switch,
reload_tx: None, reload_tx: None,
@ -1108,6 +1114,34 @@ impl Bridge {
let is_uot = matches!(socket, crate::transport::Transport::Uot { .. }); let is_uot = matches!(socket, crate::transport::Transport::Uot { .. });
let (attempt_limit, attempt_timeout_ms) = if is_uot { (1, 8000) } else { (4, 1200) }; let (attempt_limit, attempt_timeout_ms) = if is_uot { (1, 8000) } else { (4, 1200) };
// TTL-desync (UDP only, opt-in): fire decoy datagrams that reach an
// on-path DPI box but expire before the server, so the box classifies
// the flow on the decoys rather than the real handshake that follows.
// Each carries the key's junk marker, so any decoy that does reach
// the server is dropped there silently.
if self.ttl_desync && !is_uot && self.ttl_desync_count > 0 {
let marker = ostp_core::crypto::derive_junk_marker(
&self.access_key,
ostp_core::crypto::current_junk_window(),
);
let decoys: Vec<bytes::Bytes> = {
let mut rng = rand::thread_rng();
let [min_s, max_s] = self.junk_ps;
let min_s = min_s.max(4);
let max_s = max_s.max(min_s);
(0..self.ttl_desync_count)
.map(|_| {
let len = rng.gen_range(min_s..=max_s);
let mut b = vec![0u8; len];
rng.fill(&mut b[..]);
b[..4].copy_from_slice(&marker);
bytes::Bytes::from(b)
})
.collect()
};
socket.send_ttl_decoys(&decoys, self.ttl_desync_ttl).await;
}
for attempt in 0..attempt_limit { for attempt in 0..attempt_limit {
if attempt > 0 { if attempt > 0 {
tx.send(UiEvent::Log(format!("Handshake attempt {} lost. Retransmitting...", attempt))).await.ok(); tx.send(UiEvent::Log(format!("Handshake attempt {} lost. Retransmitting...", attempt))).await.ok();
@ -1205,6 +1239,9 @@ impl Bridge {
self.frag_sleep = cfg.transport.frag_sleep; self.frag_sleep = cfg.transport.frag_sleep;
self.junk_pc = cfg.transport.junk_pc; self.junk_pc = cfg.transport.junk_pc;
self.junk_ps = cfg.transport.junk_ps; self.junk_ps = cfg.transport.junk_ps;
self.ttl_desync = cfg.transport.ttl_desync;
self.ttl_desync_ttl = cfg.transport.ttl_desync_ttl;
self.ttl_desync_count = cfg.transport.ttl_desync_count;
self.mtu = cfg.ostp.mtu; self.mtu = cfg.ostp.mtu;
self.keepalive_interval_sec = cfg.ostp.keepalive_interval_sec; self.keepalive_interval_sec = cfg.ostp.keepalive_interval_sec;
self.kill_switch = cfg.kill_switch; self.kill_switch = cfg.kill_switch;

View File

@ -92,6 +92,19 @@ pub struct TransportConfig {
/// [min, max] junk packet size in bytes /// [min, max] junk packet size in bytes
#[serde(default = "default_junk_size")] #[serde(default = "default_junk_size")]
pub junk_ps: [usize; 2], pub junk_ps: [usize; 2],
/// TTL-desync (UDP only): before the handshake, send decoy datagrams with a
/// lowered IP TTL so they reach an on-path DPI box but expire before the
/// server, poisoning the box's classification of the flow. Off by default —
/// it needs the TTL calibrated to the network, and the wrong value is inert.
#[serde(default)]
pub ttl_desync: bool,
/// TTL the decoy datagrams are sent with. Set it to one or two hops past the
/// injector distance the prober reports, so decoys die just beyond the DPI.
#[serde(default = "default_ttl_desync_ttl")]
pub ttl_desync_ttl: u8,
/// How many decoy datagrams to send per handshake.
#[serde(default = "default_ttl_desync_count")]
pub ttl_desync_count: u8,
} }
fn default_transport_mode() -> String { "udp".to_string() } fn default_transport_mode() -> String { "udp".to_string() }
@ -99,6 +112,8 @@ fn default_frag_chunk() -> usize { 2 }
fn default_frag_sleep() -> u64 { 2 } fn default_frag_sleep() -> u64 { 2 }
fn default_junk_count() -> [usize; 2] { [2, 5] } fn default_junk_count() -> [usize; 2] { [2, 5] }
fn default_junk_size() -> [usize; 2] { [100, 1000] } fn default_junk_size() -> [usize; 2] { [100, 1000] }
fn default_ttl_desync_ttl() -> u8 { 8 }
fn default_ttl_desync_count() -> u8 { 2 }
impl Default for TransportConfig { impl Default for TransportConfig {
fn default() -> Self { fn default() -> Self {
@ -109,6 +124,9 @@ impl Default for TransportConfig {
frag_sleep: default_frag_sleep(), frag_sleep: default_frag_sleep(),
junk_pc: default_junk_count(), junk_pc: default_junk_count(),
junk_ps: default_junk_size(), junk_ps: default_junk_size(),
ttl_desync: false,
ttl_desync_ttl: default_ttl_desync_ttl(),
ttl_desync_count: default_ttl_desync_count(),
} }
} }
} }
@ -194,6 +212,9 @@ struct RawTransportSection {
frag_sleep: Option<u64>, frag_sleep: Option<u64>,
junk_pc: Option<[usize; 2]>, junk_pc: Option<[usize; 2]>,
junk_ps: Option<[usize; 2]>, junk_ps: Option<[usize; 2]>,
ttl_desync: Option<bool>,
ttl_desync_ttl: Option<u8>,
ttl_desync_count: Option<u8>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -271,6 +292,9 @@ impl ClientConfig {
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),
junk_pc: raw.transport.as_ref().and_then(|t| t.junk_pc).unwrap_or_else(default_junk_count), junk_pc: raw.transport.as_ref().and_then(|t| t.junk_pc).unwrap_or_else(default_junk_count),
junk_ps: raw.transport.as_ref().and_then(|t| t.junk_ps).unwrap_or_else(default_junk_size), junk_ps: raw.transport.as_ref().and_then(|t| t.junk_ps).unwrap_or_else(default_junk_size),
ttl_desync: raw.transport.as_ref().and_then(|t| t.ttl_desync).unwrap_or(false),
ttl_desync_ttl: raw.transport.as_ref().and_then(|t| t.ttl_desync_ttl).unwrap_or_else(default_ttl_desync_ttl),
ttl_desync_count: raw.transport.as_ref().and_then(|t| t.ttl_desync_count).unwrap_or_else(default_ttl_desync_count),
}, },
exclusions: ExclusionConfig { exclusions: ExclusionConfig {
domains: exclusions.domains.unwrap_or_default(), domains: exclusions.domains.unwrap_or_default(),
@ -349,11 +373,18 @@ impl UnifiedConfig {
} }
} }
AppMode::Relay(cfg) => { AppMode::Relay(cfg) => {
// The relay forwards to a fixed next hop on both carriers, so it
// needs both upstream addresses. It does NOT need upstream_api_url:
// that field belonged to the old design where the relay
// authenticated clients itself, which it no longer does. Requiring
// it here was the bug that made every generated relay config
// (wizard and template alike write no api_url) fail to load with
// "must specify upstream_api_url" — a relay that could never start.
if cfg.upstream_tcp.is_empty() { if cfg.upstream_tcp.is_empty() {
anyhow::bail!("Relay configuration must specify upstream_tcp address."); anyhow::bail!("Relay configuration must specify upstream_tcp (the next hop's TCP/UoT address).");
} }
if cfg.upstream_api_url.is_empty() { if cfg.upstream_udp.is_empty() {
anyhow::bail!("Relay configuration must specify upstream_api_url."); anyhow::bail!("Relay configuration must specify upstream_udp (the next hop's UDP address).");
} }
} }
} }
@ -536,3 +567,76 @@ pub struct MuxConfig {
pub enabled: Option<bool>, pub enabled: Option<bool>,
pub sessions: Option<usize>, pub sessions: Option<usize>,
} }
#[cfg(test)]
mod tests {
use super::*;
/// Loads a config.json exactly as the daemon does: parse the JSON into the
/// canonical `UnifiedConfig`, then validate. This is the real drift-catcher —
/// if the wizard/template and the validator ever disagree on required fields,
/// this fails instead of a user's relay refusing to start.
fn load(json: &str) -> Result<UnifiedConfig> {
let cfg: UnifiedConfig = serde_json::from_str(json)?;
cfg.validate()?;
Ok(cfg)
}
/// Regression: the relay used to authenticate clients and so its config
/// carried `upstream_api_url`. The relay is a transparent pipe now and both
/// the wizard and the `init` template write NO api_url — yet validation kept
/// demanding it, so every generated relay config failed to load with
/// "must specify upstream_api_url". A relay that could never start.
#[test]
fn relay_config_without_api_url_loads() {
// Byte-for-byte the shape the wizard (main.rs) emits.
let json = r#"{
"mode": "relay",
"listen": "0.0.0.0:50000",
"upstream_tcp": "203.0.113.10:50000",
"upstream_udp": "203.0.113.10:50000",
"debug": false
}"#;
load(json).expect("a transparent-relay config must load without upstream_api_url");
}
/// A relay still needs somewhere to forward to on both carriers, so an
/// incomplete relay config must fail loudly at load, not connect-to-empty
/// per session at runtime.
#[test]
fn relay_config_missing_upstream_udp_is_rejected() {
let json = r#"{
"mode": "relay",
"listen": "0.0.0.0:50000",
"upstream_tcp": "203.0.113.10:50000",
"upstream_udp": "",
"debug": false
}"#;
assert!(load(json).is_err(), "a relay with no UDP upstream must be rejected");
}
/// A deprecated api_url left in an OLD config must not break loading — it is
/// ignored, not required and not forbidden.
#[test]
fn relay_config_with_leftover_api_url_still_loads() {
let json = r#"{
"mode": "relay",
"listen": "0.0.0.0:50000",
"upstream_tcp": "203.0.113.10:50000",
"upstream_udp": "203.0.113.10:50000",
"upstream_api_url": "http://old.example:8080",
"debug": false
}"#;
load(json).expect("a stale api_url must be tolerated, not rejected");
}
/// The minimal client and server shapes the template emits must also load,
/// so this test guards all three modes against generator/validator drift.
#[test]
fn minimal_client_and_server_configs_load() {
load(r#"{"mode":"client","server":"127.0.0.1:50000","access_key":"k"}"#)
.expect("minimal client config must load");
load(r#"{"mode":"server","listen":"0.0.0.0:50000","access_keys":["k"]}"#)
.expect("minimal server config must load");
}
}

View File

@ -53,4 +53,29 @@ impl Transport {
Self::Uot { .. } => Ok("0.0.0.0:0".parse().unwrap()), Self::Uot { .. } => Ok("0.0.0.0:0".parse().unwrap()),
} }
} }
/// TTL-desync: send `decoys` as datagrams with the IP TTL lowered to `ttl`,
/// then restore the socket's original TTL. The decoys are meant to reach an
/// on-path DPI box and expire before the server — poisoning the box's view
/// of the flow (it classifies on the decoy) while the server never sees
/// them. Calibrate `ttl` to the injector hop distance the prober reports.
///
/// UDP only: this manipulates individual datagrams' TTL. On UoT the carrier
/// is one TCP stream, so a socket-level TTL change would apply to the real
/// traffic too — proper TCP desync needs injected packets (a driver), which
/// this deliberately does not attempt. No-op there.
pub async fn send_ttl_decoys(&self, decoys: &[Bytes], ttl: u8) {
let Self::Udp(sock) = self else { return };
if decoys.is_empty() {
return;
}
let restore = sock.ttl().unwrap_or(128);
if sock.set_ttl(ttl as u32).is_err() {
return;
}
for d in decoys {
let _ = sock.send(d).await;
}
let _ = sock.set_ttl(restore);
}
} }

View File

@ -0,0 +1,307 @@
//! IPv4 fragment reassembly for the TUN → netstack path.
//!
//! Why this exists: the userspace netstack (netstack-smoltcp) parses each IP
//! packet it receives and, for UDP, runs `UdpPacket::new_checked` on the IP
//! payload. An IP *fragment* passes the IP-level check but fails the UDP one —
//! the UDP length field describes the whole datagram while the fragment carries
//! only a slice — so the netstack drops it with `wire::Error` and the datagram
//! never reaches the tunnel. Large UDP datagrams (game traffic, e.g. Roblox
//! sending >MTU packets that the OS fragments on the way to the TUN) therefore
//! vanish entirely, and the app times out.
//!
//! smoltcp 0.2.2 does no reassembly of its own, so we do it here, between the
//! TUN read and the netstack: fragments are buffered by (src, dst, id, proto),
//! and only a fully reassembled datagram is handed on. Non-fragmented packets
//! pass straight through untouched.
use std::collections::{BTreeMap, HashMap};
use std::time::{Duration, Instant};
/// A fragment group is discarded if not completed within this window, matching
/// the usual IP reassembly timeout. Prevents a lost tail fragment from pinning
/// memory forever.
const REASM_TIMEOUT: Duration = Duration::from_secs(3);
/// Cap on concurrently tracked fragment groups, so a flood of first-fragments
/// with no tail cannot grow memory without bound.
const MAX_GROUPS: usize = 4096;
/// A reassembled IPv4 datagram cannot exceed this (total-length is 16-bit).
const MAX_DATAGRAM: usize = 65_535;
type Key = (u32, u32, u16, u8); // src, dst, identification, protocol
struct Group {
/// fragment_offset (bytes) → that fragment's IP payload.
parts: BTreeMap<usize, Vec<u8>>,
/// IP header of the offset-0 fragment, reused for the reassembled packet.
header: Option<Vec<u8>>,
/// Total payload length, known once the last fragment (MF=0) is seen.
total_len: Option<usize>,
first_seen: Instant,
}
pub struct Reassembler {
groups: HashMap<Key, Group>,
last_sweep: Instant,
}
impl Reassembler {
pub fn new() -> Self {
Self { groups: HashMap::new(), last_sweep: Instant::now() }
}
/// Feed one frame read from the TUN. Returns the packet(s) to forward to the
/// netstack: the frame itself when it is not a fragment, a single fully
/// reassembled datagram when this frame completes one, or nothing when the
/// frame was buffered as an incomplete fragment.
pub fn process(&mut self, frame: &[u8]) -> Option<Vec<u8>> {
self.maybe_sweep();
let Some(v4) = Ipv4View::parse(frame) else {
// Not a parseable IPv4 packet (e.g. IPv6) — pass through unchanged;
// reassembly is not our job for it.
return Some(frame.to_vec());
};
// A packet is fragmented iff MF is set or it carries a non-zero offset.
if !v4.more_fragments && v4.frag_offset == 0 {
return Some(frame.to_vec());
}
let key = (v4.src, v4.dst, v4.id, v4.protocol);
let now = Instant::now();
if self.groups.len() >= MAX_GROUPS && !self.groups.contains_key(&key) {
// Under pressure, drop the oldest incomplete group to make room
// rather than refusing the new one outright.
if let Some(oldest) = self
.groups
.iter()
.min_by_key(|(_, g)| g.first_seen)
.map(|(k, _)| *k)
{
self.groups.remove(&oldest);
}
}
let group = self.groups.entry(key).or_insert_with(|| Group {
parts: BTreeMap::new(),
header: None,
total_len: None,
first_seen: now,
});
// Ignore a payload that would push the datagram past the legal maximum.
if v4.frag_offset + v4.payload.len() > MAX_DATAGRAM {
self.groups.remove(&key);
return None;
}
group.parts.insert(v4.frag_offset, v4.payload.to_vec());
if v4.frag_offset == 0 {
group.header = Some(v4.header.to_vec());
}
if !v4.more_fragments {
// The last fragment fixes the total length.
group.total_len = Some(v4.frag_offset + v4.payload.len());
}
// Complete? Walk fragments from offset 0 and require they tile the whole
// datagram with no hole. Overlaps are tolerated as long as coverage is
// contiguous (BTreeMap keeps them offset-ordered).
let (Some(total), Some(header)) = (group.total_len, group.header.clone()) else {
return None;
};
let mut expected = 0usize;
for (&off, part) in &group.parts {
if off > expected {
return None; // hole before this fragment
}
let end = off + part.len();
if end > expected {
expected = end;
}
}
if expected < total {
return None; // not fully covered yet
}
// Reassemble: header + payload bytes [0, total), then fix the header so
// it describes a single unfragmented datagram.
let mut payload = vec![0u8; total];
for (&off, part) in &group.parts {
let end = (off + part.len()).min(total);
if off < total {
payload[off..end].copy_from_slice(&part[..end - off]);
}
}
self.groups.remove(&key);
Some(build_reassembled(&header, &payload))
}
fn maybe_sweep(&mut self) {
let now = Instant::now();
if now.duration_since(self.last_sweep) < Duration::from_secs(1) {
return;
}
self.last_sweep = now;
self.groups.retain(|_, g| now.duration_since(g.first_seen) < REASM_TIMEOUT);
}
}
/// A read-only view over an IPv4 header and its payload.
struct Ipv4View<'a> {
header: &'a [u8],
payload: &'a [u8],
src: u32,
dst: u32,
id: u16,
protocol: u8,
more_fragments: bool,
frag_offset: usize,
}
impl<'a> Ipv4View<'a> {
fn parse(frame: &'a [u8]) -> Option<Self> {
if frame.len() < 20 {
return None;
}
if frame[0] >> 4 != 4 {
return None; // not IPv4
}
let ihl = ((frame[0] & 0x0f) as usize) * 4;
if ihl < 20 || frame.len() < ihl {
return None;
}
let total_len = u16::from_be_bytes([frame[2], frame[3]]) as usize;
// Trust the smaller of declared length and what we actually read.
let total_len = total_len.min(frame.len()).max(ihl);
let id = u16::from_be_bytes([frame[4], frame[5]]);
let flags_frag = u16::from_be_bytes([frame[6], frame[7]]);
let more_fragments = flags_frag & 0x2000 != 0;
let frag_offset = ((flags_frag & 0x1fff) as usize) * 8;
let protocol = frame[9];
let src = u32::from_be_bytes([frame[12], frame[13], frame[14], frame[15]]);
let dst = u32::from_be_bytes([frame[16], frame[17], frame[18], frame[19]]);
Some(Ipv4View {
header: &frame[..ihl],
payload: &frame[ihl..total_len],
src,
dst,
id,
protocol,
more_fragments,
frag_offset,
})
}
}
/// Stitch the offset-0 header onto a full payload, clearing the fragment fields
/// and fixing total-length and header checksum so the netstack sees one clean
/// datagram.
fn build_reassembled(header0: &[u8], payload: &[u8]) -> Vec<u8> {
let ihl = header0.len();
let mut out = Vec::with_capacity(ihl + payload.len());
out.extend_from_slice(header0);
out.extend_from_slice(payload);
let total = (ihl + payload.len()) as u16;
out[2..4].copy_from_slice(&total.to_be_bytes());
// Clear flags (except keep DF? no — a reassembled datagram is not a
// fragment and DF is irrelevant here) and the fragment offset.
out[6] = 0;
out[7] = 0;
// Recompute the IPv4 header checksum over the (possibly options-bearing)
// header only.
out[10] = 0;
out[11] = 0;
let cksum = ipv4_checksum(&out[..ihl]);
out[10..12].copy_from_slice(&cksum.to_be_bytes());
out
}
fn ipv4_checksum(header: &[u8]) -> u16 {
let mut sum: u32 = 0;
let mut i = 0;
while i + 1 < header.len() {
sum += u16::from_be_bytes([header[i], header[i + 1]]) as u32;
i += 2;
}
if i < header.len() {
sum += (header[i] as u32) << 8;
}
while sum >> 16 != 0 {
sum = (sum & 0xffff) + (sum >> 16);
}
!(sum as u16)
}
#[cfg(test)]
mod tests {
use super::*;
// Build a minimal IPv4 header for tests. `mf` = more-fragments, `offset`
// in bytes (must be /8), `payload_len` fills total_length.
fn ipv4(id: u16, mf: bool, offset: usize, payload: &[u8]) -> Vec<u8> {
let total = 20 + payload.len();
let mut h = vec![0u8; 20];
h[0] = 0x45; // v4, ihl 5
h[2..4].copy_from_slice(&(total as u16).to_be_bytes());
h[4..6].copy_from_slice(&id.to_be_bytes());
let flags_frag = (if mf { 0x2000u16 } else { 0 }) | ((offset / 8) as u16 & 0x1fff);
h[6..8].copy_from_slice(&flags_frag.to_be_bytes());
h[9] = 17; // UDP
h[12..16].copy_from_slice(&[10, 1, 0, 2]);
h[16..20].copy_from_slice(&[13, 249, 8, 109]);
h.extend_from_slice(payload);
h
}
#[test]
fn passes_non_fragmented_through() {
let mut r = Reassembler::new();
let pkt = ipv4(1, false, 0, &[1, 2, 3, 4]);
assert_eq!(r.process(&pkt), Some(pkt));
}
#[test]
fn reassembles_two_fragments() {
let mut r = Reassembler::new();
// 16 bytes of "UDP" payload split as 8 + 8.
let first = ipv4(42, true, 0, &[0, 1, 2, 3, 4, 5, 6, 7]);
let second = ipv4(42, false, 8, &[8, 9, 10, 11, 12, 13, 14, 15]);
assert_eq!(r.process(&first), None, "first fragment must be buffered");
let whole = r.process(&second).expect("second fragment completes it");
// Header says unfragmented, total length 36, payload is the full 16.
assert_eq!(whole[0] >> 4, 4);
assert_eq!(u16::from_be_bytes([whole[2], whole[3]]), 36);
assert_eq!(whole[6] & 0x20, 0, "MF must be cleared");
assert_eq!(u16::from_be_bytes([whole[6], whole[7]]) & 0x1fff, 0, "offset cleared");
assert_eq!(&whole[20..], &(0u8..16).collect::<Vec<_>>()[..]);
// A correctly checksummed header sums to zero when the check field is
// included in the computation.
assert_eq!(ipv4_checksum(&whole[..20]), 0, "header checksum must verify");
}
#[test]
fn out_of_order_fragments_reassemble() {
let mut r = Reassembler::new();
let first = ipv4(7, true, 0, &[0, 1, 2, 3, 4, 5, 6, 7]);
let last = ipv4(7, false, 16, &[16, 17, 18, 19]);
let mid = ipv4(7, true, 8, &[8, 9, 10, 11, 12, 13, 14, 15]);
assert_eq!(r.process(&last), None);
assert_eq!(r.process(&first), None);
let whole = r.process(&mid).expect("last piece completes it");
assert_eq!(&whole[20..], &(0u8..20).collect::<Vec<_>>()[..]);
}
#[test]
fn incomplete_group_yields_nothing() {
let mut r = Reassembler::new();
let first = ipv4(9, true, 0, &[0; 8]);
// Tail never arrives.
assert_eq!(r.process(&first), None);
}
}

View File

@ -1,4 +1,5 @@
mod proxy; mod proxy;
mod ip_reasm;
pub mod native_handler; pub mod native_handler;
mod udp_nat; mod udp_nat;

View File

@ -123,12 +123,18 @@ pub async fn run_native_tunnel(
let (mut tun_read, mut tun_write) = tokio::io::split(dev); let (mut tun_read, mut tun_write) = tokio::io::split(dev);
let mut tun_to_stack = tokio::spawn(async move { let mut tun_to_stack = tokio::spawn(async move {
// Reassemble IPv4 fragments before the netstack sees them: smoltcp drops
// UDP fragments with a wire::Error, which silently kills any >MTU UDP
// datagram (game traffic in particular). See ip_reasm for the details.
let mut reasm = super::ip_reasm::Reassembler::new();
let mut buf = vec![0u8; 65536]; let mut buf = vec![0u8; 65536];
loop { loop {
match tun_read.read(&mut buf).await { match tun_read.read(&mut buf).await {
Ok(0) => break, Ok(0) => break,
Ok(n) => { Ok(n) => {
let frame = buf[..n].to_vec(); let Some(frame) = reasm.process(&buf[..n]) else {
continue; // fragment buffered; nothing to forward yet
};
if let Err(e) = stack_sink.send(frame).await { if let Err(e) = stack_sink.send(frame).await {
if e.kind() == std::io::ErrorKind::BrokenPipe { if e.kind() == std::io::ErrorKind::BrokenPipe {
break; break;
@ -471,6 +477,9 @@ pub async fn run_native_tunnel_from_fd(
let (mut stack_sink, mut stack_stream) = stack.split(); let (mut stack_sink, mut stack_stream) = stack.split();
let _tun_to_stack = tokio::spawn(async move { let _tun_to_stack = tokio::spawn(async move {
// See the Windows path above: reassemble IPv4 fragments so smoltcp does
// not drop >MTU UDP datagrams.
let mut reasm = super::ip_reasm::Reassembler::new();
let mut buf = vec![0u8; 65536]; let mut buf = vec![0u8; 65536];
loop { loop {
let mut guard = match tun_stream.readable().await { let mut guard = match tun_stream.readable().await {
@ -502,7 +511,9 @@ pub async fn run_native_tunnel_from_fd(
Err(_) => continue, Err(_) => continue,
}; };
let frame = buf[..n].to_vec(); let Some(frame) = reasm.process(&buf[..n]) else {
continue; // fragment buffered; nothing to forward yet
};
if let Err(e) = stack_sink.send(frame).await { if let Err(e) = stack_sink.send(frame).await {
if e.kind() == std::io::ErrorKind::BrokenPipe { if e.kind() == std::io::ErrorKind::BrokenPipe {
break; break;

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.4.4+31 version: 0.4.5+39
environment: environment:
sdk: ^3.11.4 sdk: ^3.11.4

View File

@ -1,14 +1,15 @@
{ {
"name": "ostp-gui", "name": "ostp-gui",
"private": true, "private": true,
"version": "0.4.4", "version": "0.4.5",
"type": "module", "type": "module",
"scripts": { "scripts": {
"tauri": "tauri", "tauri": "tauri",
"dev": "cargo build -p ostp-tun-helper && npx tauri dev", "dev": "cargo build -p ostp-tun-helper && npx tauri dev",
"build": "cargo build -p ostp-tun-helper --release && npx tauri build --no-bundle", "build": "cargo build -p ostp-tun-helper --release && npx tauri build --no-bundle",
"build:installer": "cargo build -p ostp-tun-helper --release && npx tauri build", "build:installer": "cargo build -p ostp-tun-helper --release && node stage-sidecar.cjs --release && npx tauri build --bundles nsis --config src-tauri/tauri.installer.conf.json",
"build:dist": "npm run build && node build_dist.js" "build:dist": "npm run build && node build_dist.js",
"sidecar": "node stage-sidecar.cjs"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2" "@tauri-apps/cli": "^2"

View File

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

View File

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

View File

@ -61,6 +61,9 @@ struct TransportConfigRaw {
frag_sleep: Option<u64>, frag_sleep: Option<u64>,
junk_pc: Option<[usize; 2]>, junk_pc: Option<[usize; 2]>,
junk_ps: Option<[usize; 2]>, junk_ps: Option<[usize; 2]>,
ttl_desync: Option<bool>,
ttl_desync_ttl: Option<u8>,
ttl_desync_count: Option<u8>,
} }
#[derive(Debug, Deserialize, Serialize, Clone)] #[derive(Debug, Deserialize, Serialize, Clone)]
@ -134,16 +137,84 @@ struct AppState(Mutex<AppStateInner>);
// ── Config helpers ──────────────────────────────────────────────────────────── // ── Config helpers ────────────────────────────────────────────────────────────
/// Per-user config location, used whenever the config cannot live next to the
/// executable.
fn user_config_path() -> PathBuf {
let base = std::env::var_os(if cfg!(windows) { "APPDATA" } else { "HOME" })
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir);
let dir = if cfg!(windows) { base.join("OSTP") } else { base.join(".config").join("ostp") };
dir.join("config.json")
}
/// Where the GUI reads and writes its configuration.
///
/// Portable installs keep the config beside the executable, which is what the
/// zip has always done, and that is preserved wherever the directory is
/// actually writable.
///
/// What it must never do again is fall back to a bare relative `config.json`.
/// That resolves against the process working directory, which for a Start Menu
/// shortcut is whatever Windows chose — often `C:\Windows\System32`. Reading
/// and saving settings then failed with "Access is denied" (os error 5), and on
/// a writable working directory it would have been worse still: settings would
/// silently persist somewhere unrelated and appear to vanish.
///
/// Writability is measured rather than inferred from the install location. An
/// installer can put the app anywhere — a per-machine install onto a data drive
/// may well be writable, while Program Files is not — so the location alone
/// says nothing.
fn get_config_path() -> PathBuf { fn get_config_path() -> PathBuf {
if let Ok(exe_path) = std::env::current_exe() { if let Ok(exe_path) = std::env::current_exe() {
if let Some(parent) = exe_path.parent() { if let Some(parent) = exe_path.parent() {
let path = parent.join("config.json"); let portable = parent.join("config.json");
if path.exists() { if portable.exists() {
return path; if is_file_writable(&portable) {
return portable;
}
// Read-only beside the exe: unusable as the live file, but its
// contents are still worth carrying over once.
let user = user_config_path();
if !user.exists() {
if let Some(dir) = user.parent() {
let _ = std::fs::create_dir_all(dir);
}
let _ = std::fs::copy(&portable, &user);
}
} else if is_dir_writable(parent) {
// No config yet and the directory takes writes: a portable
// unzip, so keep the config travelling with the folder.
return portable;
} }
} }
} }
PathBuf::from("config.json")
let path = user_config_path();
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
path
}
/// Whether an existing file can actually be written to.
///
/// Answered by opening it, not by reading permission bits: on Windows the
/// effective answer depends on the ACL and on virtualization, and `readonly()`
/// reflects neither.
fn is_file_writable(path: &std::path::Path) -> bool {
std::fs::OpenOptions::new().append(true).open(path).is_ok()
}
/// Whether new files can be created in a directory, tested by doing it.
fn is_dir_writable(dir: &std::path::Path) -> bool {
let probe = dir.join(format!(".ostp-write-test-{}", std::process::id()));
match std::fs::File::create(&probe) {
Ok(_) => {
let _ = std::fs::remove_file(&probe);
true
}
Err(_) => false,
}
} }
fn map_to_client_config(raw: &ClientConfigRaw, mode: &str) -> ostp_client::config::ClientConfig { fn map_to_client_config(raw: &ClientConfigRaw, mode: &str) -> ostp_client::config::ClientConfig {
@ -171,6 +242,9 @@ fn map_to_client_config(raw: &ClientConfigRaw, mode: &str) -> ostp_client::confi
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),
junk_pc: raw.transport.as_ref().and_then(|t| t.junk_pc).unwrap_or([2, 5]), junk_pc: raw.transport.as_ref().and_then(|t| t.junk_pc).unwrap_or([2, 5]),
junk_ps: raw.transport.as_ref().and_then(|t| t.junk_ps).unwrap_or([100, 1000]), junk_ps: raw.transport.as_ref().and_then(|t| t.junk_ps).unwrap_or([100, 1000]),
ttl_desync: raw.transport.as_ref().and_then(|t| t.ttl_desync).unwrap_or(false),
ttl_desync_ttl: raw.transport.as_ref().and_then(|t| t.ttl_desync_ttl).unwrap_or(8),
ttl_desync_count: raw.transport.as_ref().and_then(|t| t.ttl_desync_count).unwrap_or(2),
}, },
exclusions: ostp_client::config::ExclusionConfig { exclusions: ostp_client::config::ExclusionConfig {
domains: raw.exclude.as_ref().and_then(|e| e.domains.clone()).unwrap_or_default(), domains: raw.exclude.as_ref().and_then(|e| e.domains.clone()).unwrap_or_default(),
@ -836,21 +910,8 @@ fn helper_args_file() -> PathBuf {
base.join("OSTP").join("helper-args.json") base.join("OSTP").join("helper-args.json")
} }
/// Minimal XML text escaping for the values interpolated into the task /// Undoes XML entity escaping. `&amp;` must be handled last, or `&amp;lt;`
/// definition. Paths and usernames are attacker-irrelevant here but can easily /// would come back as `<`.
/// contain `&`, which would otherwise produce invalid XML and a confusing
/// schtasks parse failure.
#[cfg(target_os = "windows")]
fn xml_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
/// Reverse of [`xml_escape`]. `&amp;` must be undone last or `&amp;lt;` would
/// come back as `<`.
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
fn xml_unescape(s: &str) -> String { fn xml_unescape(s: &str) -> String {
s.replace("&quot;", "\"") s.replace("&quot;", "\"")
@ -864,8 +925,12 @@ fn xml_unescape(s: &str) -> String {
/// ///
/// Queried as XML rather than `/FO LIST /V`: the list format's field labels are /// Queried as XML rather than `/FO LIST /V`: the list format's field labels are
/// localized (on a Russian Windows "Task To Run" is "Задача для запуска"), /// localized (on a Russian Windows "Task To Run" is "Задача для запуска"),
/// whereas XML tag names are fixed. schtasks writes UTF-16LE with a BOM here, /// whereas XML tag names are fixed.
/// but tolerate UTF-8 in case that ever changes. ///
/// Encoding depends on where the output goes, which is measured rather than
/// assumed: to a console schtasks writes UTF-16LE with a BOM, but into a
/// redirected pipe — our case — it writes UTF-8 with no BOM. Both are handled,
/// keyed off the BOM, so this keeps working if that ever flips.
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
fn helper_task_command() -> Option<String> { fn helper_task_command() -> Option<String> {
let out = quiet_command("schtasks") let out = quiet_command("schtasks")
@ -903,140 +968,48 @@ fn helper_task_command() -> Option<String> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
fn helper_task_matches(exe: &std::path::Path) -> bool { fn helper_task_matches(exe: &std::path::Path) -> bool {
let Some(registered) = helper_task_command() else { let Some(registered) = helper_task_command() else {
diag_log("task: schtasks /Query returned nothing usable — no task, or its XML had no <Command>");
return false; return false;
}; };
let registered = registered.trim().trim_matches('"'); let registered = registered.trim().trim_matches('"');
let path = std::path::Path::new(registered);
// Canonicalize both sides when possible so `..`, short 8.3 names and // Requiring the registered path to equal the helper we would have launched
// casing differences do not read as a mismatch. A missing file cannot be // was too strict, and bought nothing. What the check exists to catch is a
// canonicalized — which is itself a mismatch worth re-registering over. // task left pointing at a binary that is gone — `schtasks /Run` reports
match ( // success merely for accepting such a request, so the app would then wait
std::fs::canonicalize(registered), // on a helper that never starts. Testing that the file exists catches
std::fs::canonicalize(exe), // exactly that, while a task registered by the installer against an
) { // equivalent copy of the helper no longer costs the user a prompt.
(Ok(a), Ok(b)) => a == b, let same_program = path
_ => registered.eq_ignore_ascii_case(&exe.display().to_string()), .file_name()
} .map(|n| n.eq_ignore_ascii_case(HELPER_EXE_NAME))
.unwrap_or(false);
let exists = path.is_file();
diag_log(&format!(
"task: registered={registered:?} exists={exists} same_program={same_program} wanted={:?}",
exe.display().to_string()
));
exists && same_program
} }
/// Register the Scheduled Task. This is the ONLY step that needs elevation, and /// Appends a line to a small log beside the helper's argument file.
/// it happens once per machine; every later tunnel start reuses the task.
/// ///
/// RunLevel=HIGHEST makes the task run elevated, and because a task launch is /// The GUI is a windowed binary with no console, so every `eprintln!` on this
/// not an elevation request, Windows shows no consent dialog for it. /// path went nowhere — which left the one decision that matters, whether the
/// scheduled task gets used or the user gets a consent prompt, completely
/// unobservable from a user's machine.
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
fn install_helper_task(exe: &std::path::Path) -> anyhow::Result<()> { fn diag_log(msg: &str) {
let args_file = helper_args_file(); let path = helper_args_file().with_file_name("helper-launch.log");
if let Some(dir) = args_file.parent() { if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?; let _ = std::fs::create_dir_all(dir);
} }
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&path) {
// Register from an XML definition rather than /TR. The command line would use std::io::Write;
// otherwise need the exe path and the args path quoted INSIDE an already let _ = writeln!(f, "{msg}");
// quoted /TR value, escaped again through ShellExecuteW — a notoriously
// brittle chain when either path contains a space, which both of these do
// by default (Program Files, and usernames with spaces). XML also lets the
// battery and time-limit settings below be stated explicitly.
let user = format!(
"{}\\{}",
std::env::var("USERDOMAIN").unwrap_or_else(|_| "%COMPUTERNAME%".into()),
std::env::var("USERNAME").unwrap_or_default()
);
let xml = format!(
r#"<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<RegistrationInfo>
<Description>Runs the OSTP TUN helper elevated so enabling the tunnel does not prompt for consent every time.</Description>
</RegistrationInfo>
<Principals>
<Principal id="Author">
<UserId>{user}</UserId>
<LogonType>InteractiveToken</LogonType>
<RunLevel>HighestAvailable</RunLevel>
</Principal>
</Principals>
<Settings>
<MultipleInstancesPolicy>Parallel</MultipleInstancesPolicy>
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
<StartWhenAvailable>false</StartWhenAvailable>
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
<Enabled>true</Enabled>
<Hidden>false</Hidden>
<AllowHardTerminate>true</AllowHardTerminate>
</Settings>
<Actions Context="Author">
<Exec>
<Command>{exe}</Command>
<Arguments>--args-file "{args}"</Arguments>
</Exec>
</Actions>
</Task>
"#,
user = xml_escape(&user),
exe = xml_escape(&exe.display().to_string()),
args = xml_escape(&args_file.display().to_string()),
);
// schtasks /Create /XML expects UTF-16LE with a BOM.
let xml_path = std::env::temp_dir().join(format!("ostp_task_{}.xml", rand::random::<u32>()));
let mut utf16: Vec<u8> = vec![0xFF, 0xFE];
for unit in xml.encode_utf16() {
utf16.extend_from_slice(&unit.to_le_bytes());
} }
std::fs::write(&xml_path, &utf16)?;
// Registering a HighestAvailable task is itself privileged: this is the one
// prompt, and it happens once per machine.
//
// Elevate through PowerShell's Start-Process -Wait rather than
// ShellExecuteW. ShellExecuteW returns as soon as the elevated process is
// LAUNCHED, so the XML below was being deleted while schtasks was still
// starting up — registration then failed, leaving the user with a consent
// prompt that accomplished nothing, followed by a second prompt from the
// fallback path. -Wait makes the deletion safe and lets the exit code be
// checked instead of guessed at by polling.
//
// ArgumentList takes an array, so the task name and XML path never need
// quoting or escaping through a command line, only PowerShell's own
// single-quote doubling.
let ps = format!(
"$p = Start-Process -FilePath 'schtasks.exe' -Verb RunAs -Wait -PassThru \
-WindowStyle Hidden -ArgumentList @('/Create','/TN','{}','/XML','{}','/F'); \
exit $p.ExitCode",
ps_quote(HELPER_TASK_NAME),
ps_quote(&xml_path.display().to_string()),
);
let status = quiet_command("powershell")
.args(["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", &ps])
.status();
// schtasks has exited by now, so this is safe.
let _ = std::fs::remove_file(&xml_path);
match status {
Ok(s) if s.success() => {}
Ok(s) => anyhow::bail!(
"registering the scheduled task failed (exit code {:?}). A declined consent prompt \
reports 1223.",
s.code()
),
Err(e) => anyhow::bail!("could not run powershell to register the task: {e}"),
}
if helper_task_matches(exe) {
Ok(())
} else {
anyhow::bail!("schtasks reported success but the task does not point at {}", exe.display())
}
}
/// Escape a value for embedding in a PowerShell single-quoted string.
#[cfg(target_os = "windows")]
fn ps_quote(s: &str) -> String {
s.replace('\'', "''")
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
@ -1054,28 +1027,35 @@ fn launch_as_admin(exe: &std::path::PathBuf, token: &str, port: u16) -> anyhow::
let wrote_args = std::fs::write(&args_file, payload.to_string()).is_ok(); let wrote_args = std::fs::write(&args_file, payload.to_string()).is_ok();
if wrote_args { if wrote_args {
if !helper_task_matches(exe) { // Deliberately does NOT create the task when it is missing. Registering
if let Err(e) = install_helper_task(exe) { // one is privileged, so the app could only do it by raising the very
eprintln!("[OSTP] could not register the helper task ({e}); falling back to a direct elevated launch"); // prompt this exists to avoid — and it would then charge the user two
} // prompts for the privilege. Creating it belongs to the installer,
} // which is already elevated. Without it we simply fall through to the
// direct elevated launch, which prompts once per connect as before.
if helper_task_matches(exe) { if helper_task_matches(exe) {
let run = quiet_command("schtasks") let run = quiet_command("schtasks")
.args(["/Run", "/TN", HELPER_TASK_NAME]) .args(["/Run", "/TN", HELPER_TASK_NAME])
.output(); .output();
match run { match run {
Ok(o) if o.status.success() => return Ok(()), Ok(o) if o.status.success() => {
Ok(o) => eprintln!( diag_log("run: schtasks /Run accepted — no consent prompt");
"[OSTP] schtasks /Run failed: {}", return Ok(());
}
Ok(o) => diag_log(&format!(
"run: schtasks /Run failed ({:?}): {} {}",
o.status.code(),
String::from_utf8_lossy(&o.stdout).trim(),
String::from_utf8_lossy(&o.stderr).trim() String::from_utf8_lossy(&o.stderr).trim()
), )),
Err(e) => eprintln!("[OSTP] schtasks /Run could not start: {e}"), Err(e) => diag_log(&format!("run: schtasks /Run could not start: {e}")),
} }
} }
// Falling through: remove the file so a stale token is not left behind. // Falling through: remove the file so a stale token is not left behind.
let _ = std::fs::remove_file(&args_file); let _ = std::fs::remove_file(&args_file);
} }
diag_log("falling back to a direct elevated launch — this is the consent prompt");
launch_as_admin_direct(exe, token, port) launch_as_admin_direct(exe, token, port)
} }

View File

@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "ostp-gui", "productName": "ostp-gui",
"version": "0.4.4", "version": "0.4.5",
"identifier": "com.ospab.ostp", "identifier": "com.ospab.ostp",
"build": { "build": {
"frontendDist": "../src" "frontendDist": "../src"

View File

@ -0,0 +1,13 @@
{
"$schema": "https://schema.tauri.app/config/2",
"bundle": {
"externalBin": ["binaries/ostp-tun-helper"],
"resources": { "binaries/wintun.dll": "wintun.dll" },
"windows": {
"nsis": {
"installMode": "perMachine",
"installerHooks": "./windows/hooks.nsh"
}
}
}
}

View File

@ -0,0 +1,93 @@
; Registers the Scheduled Task that lets the GUI start the TUN helper elevated
; without a consent prompt.
;
; This belongs in the installer, not in the app. Registering a task that runs
; elevated is itself a privileged operation, so an unprivileged GUI could only
; obtain one by raising the very prompt we are trying to remove. The installer
; already runs elevated (installMode is perMachine), so here it costs nothing:
; the user consents once, to the install, and never again per connect.
;
; The task carries no trigger at all — it exists solely to be started on demand.
!macro NSIS_HOOK_POSTINSTALL
; Bundled resources land in $INSTDIR\resources, but the helper loads wintun
; with a plain LoadLibrary, which searches its own directory — so put a copy
; beside the executables. The destination is the directory, not a file path:
; CopyFiles takes a target directory, and naming the file made it fail.
${If} ${FileExists} "$INSTDIR\resources\wintun.dll"
DetailPrint "Placing wintun.dll next to the helper..."
CopyFiles /SILENT "$INSTDIR\resources\wintun.dll" "$INSTDIR"
${Else}
DetailPrint "WARNING: resources\wintun.dll is missing; TUN mode will not start."
${EndIf}
; Registered through PowerShell's ScheduledTasks module rather than
; `schtasks /XML`. Generating the XML from NSIS wrote a UTF-16 byte-order mark
; ahead of content whose encoding depended on whether makensis was built in
; Unicode mode, and schtasks rejected the result outright:
; "The task XML is malformed. (1,2)::ERROR: incorrect document syntax"
; The cmdlets take the same settings as arguments, so no file is written and
; there is no encoding to get wrong.
;
; The command is delimited with backticks, NSIS's third quote character, so
; that PowerShell's own single quotes and the shell's double quotes can both
; appear literally — inside a single-quoted NSIS string the first PowerShell
; quote would have terminated the argument early.
;
; $$ is an escaped literal dollar for PowerShell's variables; a bare $ would
; be read by NSIS as one of its own. The helper argument is assembled with
; [char]34 instead of nested quotes so that a username containing a space
; still yields a correctly quoted path, without three levels of escaping.
;
; The principal is the SID S-1-5-32-545 (BUILTIN\Users) rather than the
; installing user, so a per-machine install serves every account instead of
; only whoever ran the installer. The SID is used because the name is
; localized and would not resolve. %LOCALAPPDATA% is likewise left unexpanded
; for Task Scheduler to resolve per running user.
DetailPrint "Registering the OSTP TUN helper task..."
nsExec::ExecToLog `powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "$$act = New-ScheduledTaskAction -Execute '$INSTDIR\ostp-tun-helper.exe' -Argument ('--args-file ' + [char]34 + '%LOCALAPPDATA%\OSTP\helper-args.json' + [char]34); $$prn = New-ScheduledTaskPrincipal -GroupId 'S-1-5-32-545' -RunLevel Highest; $$set = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances Parallel; Register-ScheduledTask -TaskName 'OSTP TUN Helper' -Action $$act -Principal $$prn -Settings $$set -Force | Out-Null"`
Pop $R0
${If} $R0 == 0
; Registering the task is not enough to make it usable. The principal above
; decides WHO THE TASK RUNS AS; the task's security descriptor decides who
; is allowed to START it, and they are not the same thing. A task created by
; an elevated installer defaults to a DACL granting execution to
; Administrators only, so the unprivileged GUI got
; schtasks /Run -> ERROR: Access is denied
; and fell back to prompting on every single connect. Running it by hand
; from an elevated console worked, which is what made this look for a while
; like the app was at fault.
;
; Register-ScheduledTask cannot set a descriptor, so this goes through the
; Task Scheduler COM object. GA for Administrators and SYSTEM, GR+GX —
; read and execute — for BUILTIN\Users (BU), which is what lets a normal
; user start it without being elevated.
DetailPrint "Granting users permission to start the task..."
nsExec::ExecToLog `powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "$$svc = New-Object -ComObject Schedule.Service; $$svc.Connect(); $$t = $$svc.GetFolder('\').GetTask('OSTP TUN Helper'); $$t.SetSecurityDescriptor('D:(A;;GA;;;BA)(A;;GA;;;SY)(A;;GRGX;;;BU)', 0)"`
Pop $R1
${If} $R1 == 0
DetailPrint "Helper task registered; connecting will not ask for consent."
${Else}
DetailPrint "Task registered but its permissions could not be set (exit $R1)."
DetailPrint "Every connect will ask for consent."
${EndIf}
${Else}
; Not fatal: the app still works, it just falls back to an elevated launch
; that asks for consent on each connect.
DetailPrint "Could not register the helper task (exit $R0)."
DetailPrint "OSTP will still work, but every connect will ask for consent."
${EndIf}
!macroend
!macro NSIS_HOOK_PREUNINSTALL
; Leaving the task behind would point it at a deleted executable, and
; `schtasks /Run` reports success for merely accepting such a request — the
; app would wait on a helper that never starts.
DetailPrint "Removing the OSTP TUN helper task..."
nsExec::ExecToLog 'schtasks.exe /Delete /TN "OSTP TUN Helper" /F'
Pop $R0
; Copied by the install hook, so the uninstaller has no record of it.
Delete "$INSTDIR\wintun.dll"
!macroend

View File

@ -0,0 +1,73 @@
// Stages ostp-tun-helper where Tauri expects a sidecar.
//
// tauri.installer.conf.json declares `externalBin: ["binaries/ostp-tun-helper"]`,
// and Tauri resolves that to `binaries/ostp-tun-helper-<target-triple>.exe` at
// build time, failing the build outright when the file is absent. Cargo writes
// the plain name instead, so it has to be copied across first.
//
// Only the installer build needs this. That config is passed explicitly with
// --config rather than being named tauri.windows.conf.json, which Tauri would
// merge into every Windows build automatically — and then even a bare
// `cargo check` would fail on the missing sidecar.
//
// A no-op off Windows: the Linux and macOS GUI builds have no helper sidecar.
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
if (process.platform !== 'win32') {
process.exit(0);
}
// --target may be passed through; fall back to the host triple rustc reports.
const targetFlag = process.argv.indexOf('--target');
const triple =
targetFlag !== -1 && process.argv[targetFlag + 1]
? process.argv[targetFlag + 1]
: execFileSync('rustc', ['-vV'], { encoding: 'utf8' })
.split('\n')
.find((l) => l.startsWith('host:'))
.slice('host:'.length)
.trim();
const profile = process.argv.includes('--release') ? 'release' : 'debug';
const repoRoot = path.resolve(__dirname, '..');
// Cargo drops a --target build under target/<triple>/, and a host build
// straight into target/. CI always passes --target; local builds usually do not.
const candidates = [
path.join(repoRoot, 'target', triple, profile, 'ostp-tun-helper.exe'),
path.join(repoRoot, 'target', profile, 'ostp-tun-helper.exe'),
];
const src = candidates.find((p) => fs.existsSync(p));
if (!src) {
console.error(
'stage-sidecar: ostp-tun-helper.exe not found. Looked in:\n ' +
candidates.join('\n ') +
`\nBuild it first: cargo build -p ostp-tun-helper${profile === 'release' ? ' --release' : ''}`
);
process.exit(1);
}
const destDir = path.join(__dirname, 'src-tauri', 'binaries');
fs.mkdirSync(destDir, { recursive: true });
const dest = path.join(destDir, `ostp-tun-helper-${triple}.exe`);
fs.copyFileSync(src, dest);
console.log(`stage-sidecar: ${path.relative(repoRoot, src)} -> ${path.relative(repoRoot, dest)}`);
// wintun.dll rides along as a bundled resource. It is only fetched by the
// release workflow, so a local build without it should warn rather than fail —
// the installer just ends up unable to bring a tunnel up.
const dllSrc = [
path.join(repoRoot, 'target', triple, profile, 'wintun.dll'),
path.join(repoRoot, 'target', profile, 'wintun.dll'),
].find((p) => fs.existsSync(p));
if (dllSrc) {
fs.copyFileSync(dllSrc, path.join(destDir, 'wintun.dll'));
console.log(`stage-sidecar: ${path.relative(repoRoot, dllSrc)} -> binaries/wintun.dll`);
} else if (fs.existsSync(path.join(destDir, 'wintun.dll'))) {
console.log('stage-sidecar: reusing the previously staged binaries/wintun.dll');
} else {
console.warn('stage-sidecar: WARNING wintun.dll not found; a bundle build will fail on the missing resource');
}

View File

@ -99,18 +99,34 @@ pub async fn create(opts: OstpTunOptions) -> Result<OstpTunInterface> {
let current_exe = std::env::current_exe()?.to_string_lossy().into_owned(); let current_exe = std::env::current_exe()?.to_string_lossy().into_owned();
// A freshly created WinTun adapter can take several seconds to appear in // Take the interface index straight from the adapter WinTun just created,
// GetAdaptersAddresses (it only shows up once it has an operational IPv4 // via the tun crate. The old code looked it up by FriendlyName == "ostp_tun"
// binding). The default route via the TUN is what actually captures // through GetAdaptersAddresses — but WinTun does NOT set the FriendlyName to
// traffic, so this lookup is critical — give it a generous window (~15s). // the adapter name, so that match never succeeded: on every single connect
let mut tun_index = None; // it spun the full 15s and then gave up with "traffic will NOT be captured",
// leaving the default route (and, above, the server-IP bypass) uninstalled.
// get_adapter_index() is instant and correct.
use tun::AbstractDevice;
let tun_index = match dev.tun_index() {
Ok(idx) if idx > 0 => Some(idx as u32),
Ok(idx) => {
tracing::error!("WinTun reported a non-positive interface index ({idx})");
None
}
Err(e) => {
// Fall back to the old name lookup rather than fail outright.
tracing::warn!("Could not read TUN index from the adapter ({e}); falling back to name lookup");
let mut idx = None;
for _ in 0..75 { for _ in 0..75 {
if let Some(idx) = windows_route::sys::get_interface_index("ostp_tun") { if let Some(i) = windows_route::sys::get_interface_index("ostp_tun") {
tun_index = Some(idx); idx = Some(i);
break; break;
} }
tokio::time::sleep(std::time::Duration::from_millis(200)).await; tokio::time::sleep(std::time::Duration::from_millis(200)).await;
} }
idx
}
};
if let Some(idx) = tun_index { if let Some(idx) = tun_index {
match windows_route::sys::add_ipv4_route( match windows_route::sys::add_ipv4_route(

View File

@ -17,7 +17,7 @@ pub mod sys {
use winapi::shared::minwindef::{DWORD, ULONG}; use winapi::shared::minwindef::{DWORD, ULONG};
use winapi::shared::winerror::{ERROR_INSUFFICIENT_BUFFER, NO_ERROR}; use winapi::shared::winerror::{ERROR_INSUFFICIENT_BUFFER, NO_ERROR};
use winapi::um::iphlpapi::{ use winapi::um::iphlpapi::{
CreateIpForwardEntry, DeleteIpForwardEntry, GetAdaptersAddresses, GetIpForwardTable, DeleteIpForwardEntry, GetAdaptersAddresses, GetIpForwardTable,
}; };
use winapi::um::iptypes::{ use winapi::um::iptypes::{
GAA_FLAG_SKIP_ANYCAST, GAA_FLAG_SKIP_DNS_SERVER, GAA_FLAG_SKIP_MULTICAST, IP_ADAPTER_ADDRESSES, GAA_FLAG_SKIP_ANYCAST, GAA_FLAG_SKIP_DNS_SERVER, GAA_FLAG_SKIP_MULTICAST, IP_ADAPTER_ADDRESSES,
@ -88,20 +88,47 @@ pub mod sys {
if_index: u32, if_index: u32,
metric: u32, metric: u32,
) -> Result<(), String> { ) -> Result<(), String> {
let mut row: MIB_IPFORWARDROW = unsafe { mem::zeroed() }; // Installed through route.exe rather than CreateIpForwardEntry.
row.dwForwardDest = ipv4_to_dword(dest); //
row.dwForwardMask = ipv4_to_dword(mask); // The legacy CreateIpForwardEntry API was failing here with error 160
row.dwForwardNextHop = ipv4_to_dword(nexthop); // (ERROR_BAD_ARGUMENTS) on every single route — server-IP bypass and TUN
row.dwForwardIfIndex = if_index; // default route alike — which left the server IP routed INTO the tunnel
row.ForwardType = if nexthop == Ipv4Addr::UNSPECIFIED || dest == nexthop { 3 } else { 4 }; // (a loop that froze the link for seconds under load) and the default
row.ForwardProto = 3; // MIB_IPPROTO_NETMGMT // route uninstalled. route.exe resolves the interface and validates the
row.dwForwardMetric1 = metric; // gateway itself, and is what the teardown path already uses, so it
// succeeds where the hand-built MIB_IPFORWARDROW did not.
use std::os::windows::process::CommandExt;
use std::process::Command;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
let ret = unsafe { CreateIpForwardEntry(&mut row) }; // route add <dest> mask <mask> <gateway> metric <m> if <ifindex>
if ret == NO_ERROR { let out = Command::new("route")
.args([
"add",
&dest.to_string(),
"mask",
&mask.to_string(),
&nexthop.to_string(),
"metric",
&metric.to_string(),
"if",
&if_index.to_string(),
])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map_err(|e| format!("could not run route.exe: {e}"))?;
if out.status.success() {
Ok(()) Ok(())
} else { } else {
Err(format!("CreateIpForwardEntry failed: {}", ret)) // route.exe prints its diagnostics to stdout, not stderr.
let msg = String::from_utf8_lossy(&out.stdout);
let msg = msg.trim();
Err(format!(
"route add failed (exit {:?}): {}",
out.status.code(),
if msg.is_empty() { "no output" } else { msg }
))
} }
} }

View File

@ -1713,6 +1713,9 @@ async fn run_client_directly(client_cfg: ClientConfig) -> Result<()> {
frag_sleep: 2, frag_sleep: 2,
junk_pc: [2, 5], junk_pc: [2, 5],
junk_ps: [100, 1000], junk_ps: [100, 1000],
ttl_desync: false,
ttl_desync_ttl: 8,
ttl_desync_count: 2,
}, },
dns_server: client_cfg.tun.as_ref().and_then(|t| t.dns.clone()), dns_server: client_cfg.tun.as_ref().and_then(|t| t.dns.clone()),
kill_switch: client_cfg.tun.as_ref().and_then(|t| t.kill_switch).unwrap_or(false), kill_switch: client_cfg.tun.as_ref().and_then(|t| t.kill_switch).unwrap_or(false),