Compare commits

...

14 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Docs under docs/ and the wiki are being rewritten concurrently and are left
alone here.
2026-07-31 20:00:29 +03:00
21 changed files with 1080 additions and 431 deletions

View File

@ -477,12 +477,19 @@ jobs:
working-directory: ostp-gui
run: |
npm install
# TUN mode shells out to this helper, elevated via pkexec. Only the
# Windows job used to build it, so the Linux package shipped without
# it and TUN could never start.
cargo build -p ostp-tun-helper --release --target ${{ matrix.target }} --manifest-path ../Cargo.toml
npx tauri build --no-bundle --target ${{ matrix.target }}
- name: Package Portable Tarball
run: |
set -euo pipefail
mkdir ostp-linux-gui-${{ matrix.arch }}
cp ostp-gui/src-tauri/target/${{ matrix.target }}/release/ostp-gui ostp-linux-gui-${{ matrix.arch }}/
# The GUI looks for the helper next to its own executable first.
cp target/${{ matrix.target }}/release/ostp-tun-helper ostp-linux-gui-${{ matrix.arch }}/
tar -czf ostp-linux-gui-${{ matrix.arch }}.tar.gz ostp-linux-gui-${{ matrix.arch }}
- name: Upload to GitHub Release

View File

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

12
Cargo.lock generated
View File

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

View File

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

View File

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

View File

@ -23,6 +23,12 @@ use crate::tunnel::{ProxyEvent, ProxyToClientMsg};
/// candidate address is tried.
const UOT_CONNECT_TIMEOUT: Duration = Duration::from_secs(4);
/// How long to keep retrying a resume-triggered reconnect before handing the
/// problem back to the ordinary stall path. That path is what releases the
/// system proxy, so this is really a bound on how long the machine may be left
/// with no working internet at all after waking.
const RESUME_RECONNECT_GIVE_UP: Duration = Duration::from_secs(45);
static SOCKET_PROTECTOR: std::sync::OnceLock<Box<dyn Fn(i32) -> bool + Send + Sync>> = std::sync::OnceLock::new();
pub fn set_socket_protector<F>(f: F)
@ -147,6 +153,11 @@ pub struct Bridge {
/// fire at all. Retrying until success removes the dependency on either.
forced_reconnect_pending: bool,
last_forced_reconnect_try: Instant,
/// Wall-clock start of the current resume-reconnect campaign, used to bound
/// it. Wall clock rather than Instant because the monotonic clock does not
/// advance across suspend on Windows, so it cannot measure anything that
/// begins at wake.
forced_reconnect_started: Option<SystemTime>,
}
impl Bridge {
@ -185,6 +196,7 @@ impl Bridge {
last_valid_recv: Instant::now(),
forced_reconnect_pending: false,
last_forced_reconnect_try: Instant::now(),
forced_reconnect_started: None,
})
}
@ -268,9 +280,45 @@ impl Bridge {
"Resumed after ~{}s suspend — forcing clean reconnect", wall_gap.as_secs()
))).await;
self.forced_reconnect_pending = true;
self.forced_reconnect_started = Some(SystemTime::now());
self.last_forced_reconnect_try = Instant::now() - Duration::from_secs(60);
}
// Give up if resume reconnects keep failing. Retrying forever
// looks harmless but is not: the system proxy stays pointed at
// our local listener the whole time, so the machine has NO
// working internet — not merely no tunnel — while the UI sits
// on "connecting". Handing the retry to the ordinary keepalive
// path restores the proxy through its hard-timeout branch,
// which force=true deliberately skips.
//
// Measured on the wall clock: Instant does not advance across
// suspend on Windows (QPC stops), so a monotonic deadline can
// not bound anything that starts at wake.
if self.forced_reconnect_pending {
let pending_for = self
.forced_reconnect_started
.and_then(|t| t.elapsed().ok())
.unwrap_or_default();
if pending_for > RESUME_RECONNECT_GIVE_UP {
self.forced_reconnect_pending = false;
self.forced_reconnect_started = None;
let _ = tx.send(UiEvent::Log(format!(
"Reconnect after suspend failed for {}s — releasing the system \
proxy so normal traffic works; will keep retrying in the \
background",
pending_for.as_secs()
))).await;
// Make the ordinary stall path fire on the next
// keepalive tick: it is the one that tears the proxy
// back down (or, with kill switch on, deliberately
// keeps blocking).
self.last_valid_recv = Instant::now()
.checked_sub(Duration::from_secs(3600))
.unwrap_or_else(Instant::now);
}
}
// Keep retrying a resume-triggered reconnect until one lands.
// The first attempt fires within half a second of waking, when
// the NIC is typically still reassociating, so treating it as
@ -286,6 +334,7 @@ impl Bridge {
// success check rather than "we tried".
if self.last_valid_recv.elapsed() < Duration::from_secs(3) {
self.forced_reconnect_pending = false;
self.forced_reconnect_started = None;
let _ = tx.send(UiEvent::Log("Reconnected after suspend".into())).await;
}
}

View File

@ -418,19 +418,22 @@ pub struct RelayServerConfig {
pub upstream_tcp: String,
/// Upstream address for UDP traffic
pub upstream_udp: String,
/// Target server's API URL, for key sync
// ── Deprecated ──────────────────────────────────────────────────────────
// The relay used to authenticate clients itself and pulled the access-key
// list from the target server's management API to do it. It no longer does:
// sessions are authenticated end-to-end by the target server, and a relay
// that re-checks credentials only adds a weaker second gate plus a copy of
// the key list on a machine that does not need one. These are kept solely
// so existing relay configs still parse; they are ignored.
#[serde(default)]
pub upstream_api_url: String,
/// Bearer token for the target server's API
#[serde(default)]
pub upstream_api_token: String,
/// Key sync interval in seconds (default 30)
#[serde(default = "default_sync_interval")]
#[serde(default)]
pub sync_interval_secs: u64,
pub debug: Option<bool>,
}
fn default_sync_interval() -> u64 { 30 }
/// Supports both a single string "0.0.0.0:50000" and an array
/// ["0.0.0.0:50000", "[::]:50000"].
#[derive(Debug, Deserialize, Serialize, Clone)]

View File

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 0.4.3+26
version: 0.4.4+31
environment:
sdk: ^3.11.4

View File

@ -1,7 +1,7 @@
{
"name": "ostp-gui",
"private": true,
"version": "0.4.3",
"version": "0.4.4",
"type": "module",
"scripts": {
"tauri": "tauri",

View File

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

View File

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

View File

@ -204,19 +204,34 @@ fn get_wintun_install_path() -> String {
String::new()
}
/// A `Command` for a console program, with the console window suppressed.
///
/// The GUI is a windowed-subsystem binary, so every console child it spawns
/// pops up a console window for as long as that child runs. With `reg`,
/// `tasklist` and `schtasks` all being invoked from here, that surfaced as
/// windows flashing on screen — worst while polling for the scheduled task,
/// which could spawn twenty of them in a row.
#[cfg(target_os = "windows")]
fn quiet_command(program: &str) -> std::process::Command {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
let mut cmd = std::process::Command::new(program);
cmd.creation_flags(CREATE_NO_WINDOW);
cmd
}
/// Sets or removes the app from Windows startup (HKCU\...\Run).
#[tauri::command]
fn set_autostart(enable: bool) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
use std::process::Command;
let key = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run";
let app_name = "OSTP";
if enable {
let exe = std::env::current_exe()
.map_err(|e| format!("Cannot get exe path: {}", e))?;
let exe_str = format!("\"{}\"", exe.to_string_lossy());
let out = Command::new("reg")
let out = quiet_command("reg")
.args(["add", key, "/v", app_name, "/t", "REG_SZ", "/d", &exe_str, "/f"])
.output()
.map_err(|e| format!("reg add failed: {}", e))?;
@ -224,28 +239,71 @@ fn set_autostart(enable: bool) -> Result<(), String> {
return Err(String::from_utf8_lossy(&out.stderr).to_string());
}
} else {
let _ = Command::new("reg")
let _ = quiet_command("reg")
.args(["delete", key, "/v", app_name, "/f"])
.output();
}
}
#[cfg(target_os = "linux")]
{
// XDG autostart: desktop environments launch every .desktop file in
// ~/.config/autostart on login. This is the portable equivalent of the
// HKCU Run key above and needs no elevation.
let path = linux_autostart_path().ok_or("Cannot determine the autostart directory")?;
if enable {
let exe = std::env::current_exe().map_err(|e| format!("Cannot get exe path: {}", e))?;
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)
.map_err(|e| format!("Cannot create {}: {}", dir.display(), e))?;
}
let entry = format!(
"[Desktop Entry]\n\
Type=Application\n\
Name=OSTP\n\
Exec=\"{}\"\n\
Terminal=false\n\
X-GNOME-Autostart-enabled=true\n",
exe.display()
);
std::fs::write(&path, entry)
.map_err(|e| format!("Cannot write {}: {}", path.display(), e))?;
} else if path.exists() {
std::fs::remove_file(&path)
.map_err(|e| format!("Cannot remove {}: {}", path.display(), e))?;
}
}
Ok(())
}
/// Path of the XDG autostart entry, honouring XDG_CONFIG_HOME.
#[cfg(target_os = "linux")]
fn linux_autostart_path() -> Option<PathBuf> {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.filter(|p| !p.as_os_str().is_empty())
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?;
Some(base.join("autostart").join("ostp.desktop"))
}
/// Checks if the app is currently in Windows startup.
#[tauri::command]
fn get_autostart() -> bool {
#[cfg(target_os = "windows")]
{
use std::process::Command;
let key = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run";
let out = Command::new("reg")
let out = quiet_command("reg")
.args(["query", key, "/v", "OSTP"])
.output();
if let Ok(o) = out {
return o.status.success();
}
}
#[cfg(target_os = "linux")]
{
if let Some(path) = linux_autostart_path() {
return path.exists();
}
}
false
}
@ -254,8 +312,7 @@ fn get_autostart() -> bool {
fn list_running_processes() -> Vec<String> {
#[cfg(target_os = "windows")]
{
use std::process::Command;
if let Ok(out) = Command::new("tasklist")
if let Ok(out) = quiet_command("tasklist")
.args(["/FO", "CSV", "/NH"])
.output()
{
@ -625,13 +682,18 @@ async fn start_tun_via_helper(
raw: &ClientConfigRaw,
app: tauri::AppHandle,
) -> Result<bool, String> {
// TUN goes through a privileged helper. Elevation is implemented for
// Windows (UAC) and Linux (polkit/pkexec); anywhere else launch_as_admin
// reports that plainly rather than letting this fail later as a confusing
// missing-file error.
let port = {
let listener = std::net::TcpListener::bind("127.0.0.1:0").map_err(|e| format!("Bind error: {}", e))?;
listener.local_addr().unwrap().port()
};
let auth_token = rand::random::<u64>().to_string();
let helper_exe = find_helper_exe().ok_or_else(|| "ostp-tun-helper.exe not found.".to_string())?;
let helper_exe = find_helper_exe()
.ok_or_else(|| format!("{HELPER_EXE_NAME} not found next to the app or in target/."))?;
launch_as_admin(&helper_exe, &auth_token, port).map_err(|e| format!("Failed to launch helper: {}", e))?;
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
@ -705,11 +767,22 @@ struct HelperPipeState {
error_msg: Option<String>,
}
/// Executable name of the TUN helper for the current platform.
///
/// The ".exe" suffix was hardcoded, so on Linux every lookup below searched for
/// a file that cannot exist and the GUI reported the helper as missing on a
/// platform where it ships without an extension.
const HELPER_EXE_NAME: &str = if cfg!(windows) {
"ostp-tun-helper.exe"
} else {
"ostp-tun-helper"
};
fn find_helper_exe() -> Option<PathBuf> {
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
// 1. Release/Production adjacent
let candidate = dir.join("ostp-tun-helper.exe");
let candidate = dir.join(HELPER_EXE_NAME);
if candidate.exists() { return Some(candidate); }
// 2. Tauri target directory fallback
@ -717,9 +790,9 @@ fn find_helper_exe() -> Option<PathBuf> {
let mut parent = dir;
while let Some(p) = parent.parent() {
if p.file_name().map(|n| n == "target").unwrap_or(false) {
let deb = p.join("debug").join("ostp-tun-helper.exe");
let deb = p.join("debug").join(HELPER_EXE_NAME);
if deb.exists() { return Some(deb); }
let rel = p.join("release").join("ostp-tun-helper.exe");
let rel = p.join("release").join(HELPER_EXE_NAME);
if rel.exists() { return Some(rel); }
}
parent = p;
@ -729,13 +802,13 @@ fn find_helper_exe() -> Option<PathBuf> {
// 3. Current working directory target fallback
let cwd = std::env::current_dir().unwrap_or_default();
let candidates = [
cwd.join("ostp-tun-helper.exe"),
cwd.join("target").join("debug").join("ostp-tun-helper.exe"),
cwd.join("target").join("release").join("ostp-tun-helper.exe"),
cwd.join("..").join("target").join("debug").join("ostp-tun-helper.exe"),
cwd.join("..").join("target").join("release").join("ostp-tun-helper.exe"),
cwd.join("..").join("..").join("target").join("debug").join("ostp-tun-helper.exe"),
cwd.join("..").join("..").join("target").join("release").join("ostp-tun-helper.exe"),
cwd.join(HELPER_EXE_NAME),
cwd.join("target").join("debug").join(HELPER_EXE_NAME),
cwd.join("target").join("release").join(HELPER_EXE_NAME),
cwd.join("..").join("target").join("debug").join(HELPER_EXE_NAME),
cwd.join("..").join("target").join("release").join(HELPER_EXE_NAME),
cwd.join("..").join("..").join("target").join("debug").join(HELPER_EXE_NAME),
cwd.join("..").join("..").join("target").join("release").join(HELPER_EXE_NAME),
];
for path in &candidates {
if path.exists() { return Some(path.clone()); }
@ -743,8 +816,272 @@ fn find_helper_exe() -> Option<PathBuf> {
None
}
/// Name of the Scheduled Task that runs the helper elevated without a prompt.
#[cfg(target_os = "windows")]
const HELPER_TASK_NAME: &str = "OSTP TUN Helper";
/// Fixed path the GUI writes launch parameters to, and the task's command line
/// reads them from.
///
/// A Scheduled Task stores a FIXED command line, so the per-launch port and
/// token cannot travel as arguments. The file lives under the user's own
/// LOCALAPPDATA: the helper runs elevated but as the SAME user, so this keeps
/// the token inside the trust boundary it already had — no other user can read
/// it, which would not be true of a shared location.
#[cfg(target_os = "windows")]
fn helper_args_file() -> PathBuf {
let base = std::env::var_os("LOCALAPPDATA")
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir);
base.join("OSTP").join("helper-args.json")
}
/// Minimal XML text escaping for the values interpolated into the task
/// definition. Paths and usernames are attacker-irrelevant here but can easily
/// 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")]
fn xml_unescape(s: &str) -> String {
s.replace("&quot;", "\"")
.replace("&apos;", "'")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&amp;", "&")
}
/// The exe path currently baked into the registered task, if any.
///
/// Queried as XML rather than `/FO LIST /V`: the list format's field labels are
/// localized (on a Russian Windows "Task To Run" is "Задача для запуска"),
/// whereas XML tag names are fixed. schtasks writes UTF-16LE with a BOM here,
/// but tolerate UTF-8 in case that ever changes.
#[cfg(target_os = "windows")]
fn helper_task_command() -> Option<String> {
let out = quiet_command("schtasks")
.args(["/Query", "/TN", HELPER_TASK_NAME, "/XML"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let text = if out.stdout.starts_with(&[0xFF, 0xFE]) {
let units: Vec<u16> = out.stdout[2..]
.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.collect();
String::from_utf16_lossy(&units)
} else {
String::from_utf8_lossy(&out.stdout).into_owned()
};
let start = text.find("<Command>")? + "<Command>".len();
let end = text[start..].find("</Command>")? + start;
Some(xml_unescape(text[start..end].trim()))
}
/// Whether a task is registered AND still points at the exe we are about to run.
///
/// The path matters as much as the name. A task registered by a dev build (or
/// by an install that has since moved) keeps its original `<Command>`, and
/// `schtasks /Run` reports success merely for *accepting* the request — a task
/// whose exe no longer exists fails asynchronously and silently. Trusting the
/// name alone therefore bought a 60-second "Timeout connecting to helper" on
/// every single connect, permanently, until the task was deleted by hand.
/// Re-registering costs one consent prompt and fixes it for good.
#[cfg(target_os = "windows")]
fn helper_task_matches(exe: &std::path::Path) -> bool {
let Some(registered) = helper_task_command() else {
return false;
};
let registered = registered.trim().trim_matches('"');
// Canonicalize both sides when possible so `..`, short 8.3 names and
// casing differences do not read as a mismatch. A missing file cannot be
// canonicalized — which is itself a mismatch worth re-registering over.
match (
std::fs::canonicalize(registered),
std::fs::canonicalize(exe),
) {
(Ok(a), Ok(b)) => a == b,
_ => registered.eq_ignore_ascii_case(&exe.display().to_string()),
}
}
/// Register the Scheduled Task. This is the ONLY step that needs elevation, and
/// 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
/// not an elevation request, Windows shows no consent dialog for it.
#[cfg(target_os = "windows")]
fn install_helper_task(exe: &std::path::Path) -> anyhow::Result<()> {
let args_file = helper_args_file();
if let Some(dir) = args_file.parent() {
std::fs::create_dir_all(dir)?;
}
// Register from an XML definition rather than /TR. The command line would
// otherwise need the exe path and the args path quoted INSIDE an already
// 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")]
fn launch_as_admin(exe: &std::path::PathBuf, token: &str, port: u16) -> anyhow::Result<()> {
// Preferred path: hand the parameters over in a file and trigger the
// pre-registered task, which runs elevated with no prompt. Falls back to a
// direct elevated launch when the task is absent (first ever run, or the
// user removed it) — and that first run is also where the task gets created,
// so the prompt appears once rather than on every connect.
let args_file = helper_args_file();
if let Some(dir) = args_file.parent() {
let _ = std::fs::create_dir_all(dir);
}
let payload = serde_json::json!({ "port": port, "token": token });
let wrote_args = std::fs::write(&args_file, payload.to_string()).is_ok();
if wrote_args {
if !helper_task_matches(exe) {
if let Err(e) = install_helper_task(exe) {
eprintln!("[OSTP] could not register the helper task ({e}); falling back to a direct elevated launch");
}
}
if helper_task_matches(exe) {
let run = quiet_command("schtasks")
.args(["/Run", "/TN", HELPER_TASK_NAME])
.output();
match run {
Ok(o) if o.status.success() => return Ok(()),
Ok(o) => eprintln!(
"[OSTP] schtasks /Run failed: {}",
String::from_utf8_lossy(&o.stderr).trim()
),
Err(e) => eprintln!("[OSTP] schtasks /Run could not start: {e}"),
}
}
// Falling through: remove the file so a stale token is not left behind.
let _ = std::fs::remove_file(&args_file);
}
launch_as_admin_direct(exe, token, port)
}
/// The original one-prompt-per-launch path, kept as the fallback.
#[cfg(target_os = "windows")]
fn launch_as_admin_direct(exe: &std::path::PathBuf, token: &str, port: u16) -> anyhow::Result<()> {
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
use std::ptr::null_mut;
@ -797,8 +1134,50 @@ fn launch_as_admin(exe: &std::path::PathBuf, token: &str, port: u16) -> anyhow::
Ok(())
}
#[cfg(not(target_os = "windows"))]
fn launch_as_admin(_exe: &PathBuf, _token: &str, _port: u16) -> Result<()> { anyhow::bail!("Windows only."); }
#[cfg(target_os = "linux")]
fn launch_as_admin(exe: &PathBuf, token: &str, port: u16) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
use std::process::Command;
// Same shape as the Windows path: the token goes through a file rather than
// argv, so it never shows up in the process list.
let token_file = std::env::temp_dir().join(format!("ostp_auth_{}.tmp", rand::random::<u32>()));
std::fs::write(&token_file, token)?;
// Unlike Windows, /tmp is world-readable here, and this token authenticates
// control of the privileged tunnel helper — restrict it to the owner.
let _ = std::fs::set_permissions(&token_file, std::fs::Permissions::from_mode(0o600));
// pkexec is polkit's front-end: in a desktop session it raises a graphical
// authentication dialog. sudo is not an option from a GUI process, which has
// no terminal to prompt on.
match Command::new("pkexec")
.arg(exe)
.arg("--port")
.arg(port.to_string())
.arg("--token-file")
.arg(&token_file)
.spawn()
{
Ok(_) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let _ = std::fs::remove_file(&token_file);
anyhow::bail!(
"pkexec was not found, so the TUN helper cannot be granted the privileges it \
needs. Install polkit (package \"policykit-1\" on Debian/Ubuntu, \"polkit\" on \
Fedora/Arch), or use proxy mode, which needs no elevation."
)
}
Err(e) => {
let _ = std::fs::remove_file(&token_file);
Err(e.into())
}
}
}
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
fn launch_as_admin(_exe: &PathBuf, _token: &str, _port: u16) -> Result<()> {
anyhow::bail!("TUN mode needs a privileged helper, which is implemented on Windows and Linux only. Use proxy mode on this platform.");
}
#[cfg(target_os = "windows")]
fn show_error_dialog(msg: &str) {

View File

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ostp-gui",
"version": "0.4.3",
"version": "0.4.4",
"identifier": "com.ospab.ostp",
"build": {
"frontendDist": "../src"
@ -11,9 +11,11 @@
"windows": [
{
"title": "OSTP",
"width": 360,
"height": 680,
"resizable": false
"width": 400,
"height": 720,
"minWidth": 360,
"minHeight": 560,
"resizable": true
}
],
"security": {

View File

@ -660,6 +660,13 @@ function loadSettingsIntoForm() {
updateClientVisibility();
}
// Last values actually pushed to the OS / backend, so repeated saves that did
// not change them stay free. Undefined until the first save, which is correct:
// the first one should apply.
let lastAppliedAutostart;
let lastAppliedTunnelConfig;
let hotReloadTimer;
function collectAndSaveSettings() {
const s = {
tun: inTun.checked,
@ -686,19 +693,41 @@ function collectAndSaveSettings() {
fragChunk: parseInt(inFragChunk.value) || 2,
fragSleep: !isNaN(parseInt(inFragSleep.value)) ? parseInt(inFragSleep.value) : 2,
};
// Cheap and local: safe to run on every debounced keystroke.
saveClientSettings(s);
updateClientVisibility();
// Set autostart
invoke('set_autostart', { enable: s.launchStartup }).catch(() => {});
// Everything below talks to the OS or restarts the tunnel. Running it per
// keystroke is what made typing in the exclusion fields lag by seconds: the
// 400ms debounce fires during natural pauses in typing, and each firing hit
// the Windows registry and then tore down and rebuilt the tunnel.
// Hot-reload exclusions if connected
// Only touch autostart when it actually changed — this is a registry write.
if (s.launchStartup !== lastAppliedAutostart) {
lastAppliedAutostart = s.launchStartup;
invoke('set_autostart', { enable: s.launchStartup }).catch(() => {});
}
// Hot-reload the tunnel only when something it actually reads has changed,
// and on a much longer debounce: a reload is disruptive, so it should land
// once the user has stopped editing rather than between keystrokes.
if (appState === 'connected') {
const cfg = buildConfig();
if (cfg) {
invoke('save_config', { jsonContent: JSON.stringify(cfg, null, 2) })
.then(() => invoke('reload_tunnel'))
.catch(() => {});
const tunnelRelevant = JSON.stringify([
s.tun, s.killSwitch, s.mux, s.muxSessions, s.mtu, s.dns, s.socks,
s.exDomains, s.exIps, s.exProcs, s.junkEnabled, s.junkPcMin, s.junkPcMax,
s.junkPsMin, s.junkPsMax, s.tcpFrag, s.fragChunk, s.fragSleep,
]);
if (tunnelRelevant !== lastAppliedTunnelConfig) {
clearTimeout(hotReloadTimer);
hotReloadTimer = setTimeout(() => {
lastAppliedTunnelConfig = tunnelRelevant;
const cfg = buildConfig();
if (cfg) {
invoke('save_config', { jsonContent: JSON.stringify(cfg, null, 2) })
.then(() => invoke('reload_tunnel'))
.catch(() => {});
}
}, 1500);
}
}
}

View File

@ -99,6 +99,13 @@ a { text-decoration: none; }
.app-root {
position: relative;
width: 100%;
/* The window is resizable so users on desktops where the toolkit does not
apply our DPI scaling (WebKitGTK on HiDPI Linux renders the configured
size as raw pixels, giving a postage-stamp window) can size it themselves.
Capping and centring the column keeps the intended narrow layout instead of
stretching controls across a wide window. */
max-width: 460px;
margin: 0 auto;
height: 100%;
display: flex;
flex-direction: column;

View File

@ -48,10 +48,23 @@ pub async fn connect_target(
}
if action == OutboundAction::Proxy {
let proxy_addr = format!("{}:{}", outbound.address, outbound.port);
return match outbound.protocol.as_str() {
// Case-insensitive: a config saying "SOCKS5" means the same thing
// as "socks5", and silently treating it as unknown is a trap.
return match outbound.protocol.to_ascii_lowercase().as_str() {
"socks5" => connect_via_socks5(&proxy_addr, target).await,
"http" => connect_via_http(&proxy_addr, target).await,
_ => connect_direct(target, connect_timeout).await,
// FAIL CLOSED. This used to fall through to a direct
// connection, so any unrecognised protocol string — a typo,
// a case difference, an empty value — silently sent ALL TCP
// straight out of the server while the operator believed it
// was proxied. Combined with the same bug on the UDP path,
// that is how one session ends up presenting two different
// exit addresses to the remote site.
other => Err(anyhow::anyhow!(
"outbound.protocol is \"{other}\", which is not a supported proxy type \
(expected \"socks5\" or \"http\"); refusing to connect to {target} \
directly, because the rules asked for the proxy"
)),
};
}
}
@ -370,10 +383,22 @@ pub async fn connect_udp_target(
}
if action == OutboundAction::Proxy {
let proxy_addr = format!("{}:{}", outbound.address, outbound.port);
if outbound.protocol == "socks5" {
if outbound.protocol.eq_ignore_ascii_case("socks5") {
return connect_udp_via_socks5(&proxy_addr, server_udp).await;
}
// HTTP CONNECT does not support UDP. Fallback to direct.
// FAIL CLOSED. HTTP CONNECT genuinely cannot carry UDP — but the
// answer to that is not to send the datagrams in the clear. The
// previous "fallback to direct" honoured a Proxy rule by
// egressing from the server's own address, so with an HTTP
// upstream every UDP flow (QUIC, DNS) leaked while TCP stayed
// proxied, presenting two exit IPs to the same remote site.
return Err(anyhow::anyhow!(
"outbound rules route UDP to {target} through the proxy, but the upstream \
protocol is \"{}\", which cannot carry UDP. Refusing to send directly. \
Use a socks5 upstream, or add an explicit udp rule with action \"direct\" \
or \"block\" so the intent is recorded in the config.",
outbound.protocol
));
}
}
}

View File

@ -1,403 +1,460 @@
//! Authenticated Relay Node
//! Transparent relay node.
//!
//! Принимает входящие UDP/TCP (UoT) соединения от клиентов,
//! валидирует HMAC-подпись клиента, используя ключи синхронизированные с upstream-сервера,
//! и слепо пробрасывает авторизованный трафик к целевому upstream-серверу.
//! Forwards traffic to a fixed upstream OSTP server:
//!
//! Архитектура цепочек:
//! Клиент -> [Relay 1] -> [Relay 2] -> ... -> [Target Server]
//! Каждый Relay скачивает access_keys напрямую с Target Server API.
//! Client -> [Relay] -> [Target server]
//!
//! ## Why this performs no authentication of its own
//!
//! The previous design had the relay authenticate clients itself, with an
//! HMAC handshake and a background job that pulled the access-key list from the
//! target server's management API. That was wrong on two counts.
//!
//! It did not work: no OSTP client has ever produced those credentials. The TCP
//! path expected an HTTP request (`GET /stream` with an `Authorization: Bearer`
//! header) and the UDP path expected a `timestamp || HMAC` preamble, while the
//! client sends junk frames followed by length-prefixed OSTP frames, and an
//! obfuscated Noise handshake, respectively. Every connection was rejected.
//!
//! It was also weak where it did apply: the HMAC covered only an 8-byte
//! timestamp, so a captured signature was a bearer token that anyone could
//! replay from any address for the length of the clock-skew window. And the
//! HTTP handshake was a plaintext `GET /stream` on the wire, a greppable
//! signature in a protocol whose entire premise is that no byte is
//! recognisable.
//!
//! Authentication belongs 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 anything that fails. A relay
//! that re-checks credentials adds a second, weaker gate and a copy of the key
//! list on a machine that has no need for it. So this relay makes no security
//! decisions at all — it is a pipe, and says so.
//!
//! What it does need is protection against being used as a resource sink, which
//! is what the session cap and admission rate limit below are for. It forwards
//! only to one fixed upstream and returns replies only to the sender, so it is
//! not a reflector: the amplification factor is one.
use anyhow::Result;
use bytes::Bytes;
use hmac::{Hmac, Mac};
use sha2::Sha256;
use anyhow::{Context, Result};
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::net::{TcpListener, TcpStream, UdpSocket};
use tokio::sync::Mutex;
/// Конфигурация Relay-узла.
/// Configuration for a relay node.
#[derive(Debug, Clone)]
pub struct RelayConfig {
/// Адрес(а) для прослушивания входящих соединений (UDP + TCP).
/// Address(es) to accept client traffic on (UDP and TCP both bind here).
pub listen_addrs: Vec<String>,
/// Адрес upstream TCP для пересылки (обычно тот же порт, что и у target-сервера).
/// Upstream target for TCP (UoT) traffic.
pub upstream_tcp: String,
/// Адрес upstream UDP.
/// Upstream target for UDP traffic.
pub upstream_udp: String,
/// URL API target-сервера для получения access_keys.
/// Пример: "http://127.0.0.1:9090"
pub upstream_api_url: String,
/// Bearer-токен для аутентификации на API target-сервера.
pub upstream_api_token: String,
/// Интервал синхронизации ключей (секунды).
pub sync_interval_secs: u64,
}
type SharedKeys = Arc<RwLock<Vec<String>>>;
/// Maximum concurrent UDP client sessions. Each holds one upstream socket and
/// one reader task, so this bounds both file descriptors and tasks.
const MAX_UDP_SESSIONS: usize = 4096;
/// A UDP session with no traffic for this long is reclaimed. Mobile NAT
/// bindings are typically shorter-lived than this, so it is generous enough not
/// to break roaming clients.
const UDP_SESSION_IDLE: Duration = Duration::from_secs(120);
/// Maximum concurrent relayed TCP connections.
const MAX_TCP_CONNECTIONS: usize = 4096;
/// Sustained rate (and burst ceiling) for admitting NEW sessions, per second.
/// Established sessions are never rate limited; this only bounds how fast an
/// unknown source can cause state to be allocated.
const NEW_SESSION_RATE: f64 = 200.0;
/// How long to wait for the upstream TCP connection before giving up.
const UPSTREAM_CONNECT_TIMEOUT: Duration = Duration::from_secs(8);
/// Точка входа Relay-узла.
pub async fn run_relay_node(cfg: RelayConfig) -> Result<()> {
let shared_keys: SharedKeys = Arc::new(RwLock::new(Vec::new()));
/// Token bucket bounding how fast new sessions may be created.
struct AdmissionLimiter {
tokens: f64,
last_refill: Instant,
}
// Первоначальная синхронизация ключей
if let Err(e) = sync_keys(&cfg, &shared_keys).await {
tracing::warn!("Relay: initial key sync failed: {}. Will retry.", e);
} else {
let count = shared_keys.read().unwrap_or_else(|e| e.into_inner()).len();
tracing::info!("Relay: synced {} access key(s) from upstream API", count);
impl AdmissionLimiter {
fn new() -> Self {
Self { tokens: NEW_SESSION_RATE, last_refill: Instant::now() }
}
// Фоновый синхронизатор ключей
let cfg_clone = cfg.clone();
let keys_clone = shared_keys.clone();
/// Consume one admission slot, or report that the caller should drop.
fn try_admit(&mut self) -> bool {
let now = Instant::now();
let elapsed = now.duration_since(self.last_refill).as_secs_f64();
self.last_refill = now;
self.tokens = (self.tokens + elapsed * NEW_SESSION_RATE).min(NEW_SESSION_RATE);
if self.tokens >= 1.0 {
self.tokens -= 1.0;
true
} else {
false
}
}
}
/// Entry point.
pub async fn run_relay_node(cfg: RelayConfig) -> Result<()> {
let udp_cfg = cfg.clone();
tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_secs(cfg_clone.sync_interval_secs)).await;
match sync_keys(&cfg_clone, &keys_clone).await {
Ok(count) => tracing::debug!("Relay: refreshed {} access key(s)", count),
Err(e) => tracing::warn!("Relay: key sync error: {}", e),
}
if let Err(e) = run_udp_relay(udp_cfg).await {
tracing::error!("Relay UDP loop error: {e}");
}
});
// Запуск UDP relay
{
let cfg_udp = cfg.clone();
let keys_udp = shared_keys.clone();
tokio::spawn(async move {
if let Err(e) = run_udp_relay(cfg_udp, keys_udp).await {
tracing::error!("Relay UDP loop error: {}", e);
}
});
}
// Запуск TCP (UoT) relay
run_tcp_relay(cfg, shared_keys).await
run_tcp_relay(cfg).await
}
/// Синхронизация access_keys с upstream API.
async fn sync_keys(cfg: &RelayConfig, shared_keys: &SharedKeys) -> Result<usize> {
let url = format!("{}/api/users", cfg.upstream_api_url.trim_end_matches('/'));
// ── UDP ──────────────────────────────────────────────────────────────────────
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()?;
let mut req = client.get(&url);
if !cfg.upstream_api_token.is_empty() {
req = req.header("Authorization", format!("Bearer {}", cfg.upstream_api_token));
}
let resp = req.send().await?;
if !resp.status().is_success() {
anyhow::bail!("API returned HTTP {}", resp.status());
}
#[derive(serde::Deserialize)]
struct UserStatsSnapshot {
access_key: String,
}
#[derive(serde::Deserialize)]
struct ApiResponse {
ok: bool,
data: Option<Vec<UserStatsSnapshot>>,
}
let body: ApiResponse = resp.json().await?;
if !body.ok {
anyhow::bail!("API returned error ok=false");
}
let keys: Vec<String> = body.data.unwrap_or_default().into_iter().map(|u| u.access_key).collect();
let count = keys.len();
{
let mut lock = shared_keys.write().unwrap();
*lock = keys;
}
Ok(count)
struct UdpSession {
upstream: Arc<UdpSocket>,
last_seen: Instant,
}
/// Проверяет HMAC-подпись клиента по набору ключей.
/// Возвращает true если хотя бы один ключ подходит.
fn verify_hmac(ts_bytes: &[u8; 8], provided_mac: &[u8], keys: &[String]) -> bool {
let client_ts = u64::from_be_bytes(*ts_bytes);
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
// Защита от replay: ±60 секунд
if client_ts > now + 30 || client_ts < now.saturating_sub(60) {
return false;
}
for key in keys {
if let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(key.as_bytes()) {
mac.update(ts_bytes);
if mac.verify_slice(provided_mac).is_ok() {
return true;
}
}
}
false
}
// ── UDP Relay ────────────────────────────────────────────────────────────────
async fn run_udp_relay(cfg: RelayConfig, shared_keys: SharedKeys) -> Result<()> {
// NAT-таблица: client_addr -> (upstream_socket, last_seen)
let nat_table: Arc<Mutex<HashMap<SocketAddr, (Arc<UdpSocket>, Instant)>>> =
async fn run_udp_relay(cfg: RelayConfig) -> Result<()> {
// client address -> the upstream socket carrying that client's flow
let sessions: Arc<Mutex<HashMap<SocketAddr, UdpSession>>> =
Arc::new(Mutex::new(HashMap::new()));
let limiter = Arc::new(Mutex::new(AdmissionLimiter::new()));
for bind_addr in &cfg.listen_addrs {
let sock = UdpSocket::bind(bind_addr).await?;
tracing::info!("Relay UDP listening on {}", bind_addr);
let sock = Arc::new(sock);
let upstream_udp = cfg.upstream_udp.clone();
let keys = shared_keys.clone();
let nat = nat_table.clone();
let sock = Arc::new(
UdpSocket::bind(bind_addr)
.await
.with_context(|| format!("relay: failed to bind UDP on {bind_addr}"))?,
);
tracing::info!("Relay UDP listening on {bind_addr} -> {}", cfg.upstream_udp);
let upstream_addr = cfg.upstream_udp.clone();
let sessions = sessions.clone();
let limiter = limiter.clone();
tokio::spawn(async move {
let mut buf = vec![0u8; 65535];
loop {
let (n, peer) = match sock.recv_from(&mut buf).await {
Ok(v) => v,
Err(_) => continue,
};
let packet = Bytes::copy_from_slice(&buf[..n]);
// Быстрая проверка: первый UDP-пакет от нового клиента содержит Noise handshake.
// Мы берём из него первые 8 байт как timestamp + 32 байта MAC.
// Если пакет достаточно длинный, проверяем подпись.
// Для уже авторизованных клиентов (есть в NAT) — пропускаем проверку.
{
let nat_lock = nat.lock().await;
if !nat_lock.contains_key(&peer) {
drop(nat_lock);
// Пакет должен быть >= 40 байт (8 ts + 32 hmac) для первичной проверки
if packet.len() < 40 {
tracing::debug!("Relay UDP: dropping short packet from {}", peer);
continue;
}
let ts_bytes: [u8; 8] = packet[0..8].try_into().unwrap();
let provided_mac = &packet[8..40];
let keys_guard = keys.read().unwrap_or_else(|e| e.into_inner());
if !verify_hmac(&ts_bytes, provided_mac, &keys_guard) {
tracing::debug!("Relay UDP: unauthorized probe from {}, dropped", peer);
continue;
}
tracing::debug!("Relay UDP: authorized new client {}", peer);
}
}
// Находим или создаём upstream socket для этого клиента
let upstream_sock = {
let mut nat_lock = nat.lock().await;
if let Some(entry) = nat_lock.get_mut(&peer) {
entry.1 = Instant::now();
entry.0.clone()
} else {
// Новый upstream socket для этого клиента
let usock = match UdpSocket::bind("0.0.0.0:0").await {
Ok(s) => Arc::new(s),
Err(e) => {
tracing::warn!("Relay UDP: failed to bind upstream socket: {}", e);
continue;
}
};
if usock.connect(&upstream_udp).await.is_err() {
tracing::warn!("Relay UDP: failed to connect to upstream {}", upstream_udp);
continue;
}
nat_lock.insert(peer, (usock.clone(), Instant::now()));
// Задача: читаем ответы от upstream и отправляем клиенту
let usock_rx = usock.clone();
let client_sock = sock.clone();
let peer_addr = peer;
tokio::spawn(async move {
let mut rbuf = vec![0u8; 65535];
loop {
match usock_rx.recv(&mut rbuf).await {
Ok(n) => {
let _ = client_sock.send_to(&rbuf[..n], peer_addr).await;
}
Err(_) => break,
}
}
});
usock
}
};
// Пересылаем пакет в upstream
let _ = upstream_sock.send(&packet).await;
}
});
}
// Периодически чистим устаревшие NAT записи (timeout 120 сек)
loop {
tokio::time::sleep(Duration::from_secs(30)).await;
let mut nat_lock = nat_table.lock().await;
let now = Instant::now();
nat_lock.retain(|_, (_, last)| now.duration_since(*last) < Duration::from_secs(120));
}
}
// ── TCP (UoT) Relay ──────────────────────────────────────────────────────────
async fn run_tcp_relay(cfg: RelayConfig, shared_keys: SharedKeys) -> Result<()> {
for bind_addr in &cfg.listen_addrs {
let listener = TcpListener::bind(bind_addr).await?;
tracing::info!("Relay TCP (UoT) listening on {}", bind_addr);
let upstream_tcp = cfg.upstream_tcp.clone();
let keys = shared_keys.clone();
tokio::spawn(async move {
loop {
let (stream, peer_addr) = match listener.accept().await {
let (len, peer) = match sock.recv_from(&mut buf).await {
Ok(v) => v,
Err(e) => {
tracing::warn!("Relay TCP accept error: {}", e);
tracing::warn!("Relay UDP recv error: {e}");
continue;
}
};
let upstream = upstream_tcp.clone();
let keys_clone = keys.clone();
tokio::spawn(async move {
if let Err(e) = handle_tcp_client(stream, peer_addr, upstream, keys_clone).await {
tracing::debug!("Relay TCP client {} closed: {}", peer_addr, e);
// Fast path: an established session just forwards.
{
let mut map = sessions.lock().await;
if let Some(session) = map.get_mut(&peer) {
session.last_seen = Instant::now();
let upstream = session.upstream.clone();
drop(map);
let _ = upstream.send(&buf[..len]).await;
continue;
}
}
// New client: bounded by both a hard cap and an admission rate,
// so a flood of spoofed sources cannot exhaust sockets or tasks.
{
let map = sessions.lock().await;
if map.len() >= MAX_UDP_SESSIONS {
continue;
}
}
if !limiter.lock().await.try_admit() {
continue;
}
let upstream = match new_upstream_socket(&upstream_addr).await {
Ok(s) => s,
Err(e) => {
tracing::warn!("Relay UDP: cannot reach upstream {upstream_addr}: {e}");
continue;
}
};
sessions.lock().await.insert(
peer,
UdpSession { upstream: upstream.clone(), last_seen: Instant::now() },
);
// Reverse direction for this client.
let back_sock = sock.clone();
let sessions_rx = sessions.clone();
tokio::spawn(async move {
let mut rbuf = vec![0u8; 65535];
loop {
match upstream.recv(&mut rbuf).await {
Ok(n) => {
if back_sock.send_to(&rbuf[..n], peer).await.is_err() {
break;
}
if let Some(s) = sessions_rx.lock().await.get_mut(&peer) {
s.last_seen = Instant::now();
}
}
Err(_) => break,
}
}
sessions_rx.lock().await.remove(&peer);
});
let _ = sessions
.lock()
.await
.get(&peer)
.map(|s| s.upstream.clone())
.unwrap()
.send(&buf[..len])
.await;
}
});
}
// Reclaim idle sessions. Dropping the entry closes the upstream socket,
// which ends that session's reader task.
loop {
tokio::time::sleep(Duration::from_secs(30)).await;
let now = Instant::now();
let mut map = sessions.lock().await;
let before = map.len();
map.retain(|_, s| now.duration_since(s.last_seen) < UDP_SESSION_IDLE);
let reclaimed = before - map.len();
if reclaimed > 0 {
tracing::debug!("Relay UDP: reclaimed {reclaimed} idle session(s), {} active", map.len());
}
}
}
/// One upstream socket per client, `connect`ed so replies can be read with
/// `recv` and cannot come from anywhere else.
async fn new_upstream_socket(upstream: &str) -> Result<Arc<UdpSocket>> {
// Resolve first, then bind the SAME address family. Binding "[::]:0" and
// connecting to an IPv4 upstream fails anywhere IPV6_V6ONLY defaults on
// (Windows, and many Linux configurations) — which is every deployment with
// an IPv4 target server, i.e. the common case.
let addr: SocketAddr = tokio::net::lookup_host(upstream)
.await
.with_context(|| format!("resolve upstream {upstream}"))?
.next()
.ok_or_else(|| anyhow::anyhow!("upstream {upstream} resolved to no addresses"))?;
let bind: SocketAddr = if addr.is_ipv6() {
"[::]:0".parse().expect("valid literal")
} else {
"0.0.0.0:0".parse().expect("valid literal")
};
let sock = UdpSocket::bind(bind).await?;
sock.connect(addr)
.await
.with_context(|| format!("connect to upstream {addr}"))?;
Ok(Arc::new(sock))
}
// ── TCP (UoT) ────────────────────────────────────────────────────────────────
async fn run_tcp_relay(cfg: RelayConfig) -> Result<()> {
let live = Arc::new(std::sync::atomic::AtomicUsize::new(0));
for bind_addr in &cfg.listen_addrs {
let listener = TcpListener::bind(bind_addr)
.await
.with_context(|| format!("relay: failed to bind TCP on {bind_addr}"))?;
tracing::info!("Relay TCP (UoT) listening on {bind_addr} -> {}", cfg.upstream_tcp);
let upstream = cfg.upstream_tcp.clone();
let live = live.clone();
tokio::spawn(async move {
loop {
let (client, peer) = match listener.accept().await {
Ok(v) => v,
Err(e) => {
tracing::warn!("Relay TCP accept error: {e}");
continue;
}
};
use std::sync::atomic::Ordering;
if live.load(Ordering::Relaxed) >= MAX_TCP_CONNECTIONS {
// Close immediately rather than queueing unbounded work.
drop(client);
continue;
}
live.fetch_add(1, Ordering::Relaxed);
let upstream = upstream.clone();
let live = live.clone();
tokio::spawn(async move {
if let Err(e) = splice_tcp(client, &upstream).await {
tracing::debug!("Relay TCP {peer} closed: {e}");
}
live.fetch_sub(1, Ordering::Relaxed);
});
}
});
}
// Держим поток живым
futures_util::future::pending::<()>().await;
Ok(())
}
/// Обработка одного TCP (UoT) соединения.
/// Splice a client connection to the upstream, byte for byte.
///
/// Алгоритм:
/// 1. Читаем HTTP-заголовки (фейковый WebSocket upgrade).
/// 2. Извлекаем HMAC-подпись из Authorization: Bearer.
/// 3. Проверяем подпись по синхронизированным ключам.
/// 4. Если авторизован — открываем соединение к upstream и пайпим потоки.
async fn handle_tcp_client(
mut client: TcpStream,
peer_addr: SocketAddr,
upstream_addr: String,
shared_keys: SharedKeys,
) -> Result<()> {
// Читаем HTTP-заголовки (до \r\n\r\n)
let mut header_buf = vec![0u8; 4096];
let mut header_len = 0usize;
loop {
let n = client.read(&mut header_buf[header_len..]).await?;
if n == 0 {
anyhow::bail!("connection closed before handshake");
}
header_len += n;
if header_buf[..header_len].windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
if header_len >= header_buf.len() {
anyhow::bail!("headers too large");
}
}
let headers_str = String::from_utf8_lossy(&header_buf[..header_len]);
// Быстрая проверка: должен быть GET /stream
if !headers_str.starts_with("GET /stream HTTP/1.1\r\n") {
// Возвращаем 404 как обычный сервер (anti-scan)
let _ = client.write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 9\r\nConnection: close\r\n\r\nNot Found").await;
anyhow::bail!("invalid request from {}", peer_addr);
}
// Извлекаем HMAC-подпись
let mut sig_b64 = None;
for line in headers_str.lines() {
let lower = line.to_ascii_lowercase();
if lower.starts_with("authorization: bearer ") {
sig_b64 = Some(line[22..].trim().to_string());
} else if lower.starts_with("cookie: ostp_token=") {
sig_b64 = Some(line[19..].trim().to_string());
}
}
let sig_b64 = match sig_b64 {
Some(s) => s,
None => {
let _ = client.write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 9\r\nConnection: close\r\n\r\nNot Found").await;
anyhow::bail!("missing authorization from {}", peer_addr);
}
};
let sig_bytes = base64::Engine::decode(
&base64::engine::general_purpose::STANDARD_NO_PAD,
&sig_b64,
/// Nothing is parsed or rewritten: the relay must stay agnostic to the payload,
/// both because the payload is an opaque encrypted stream and because any
/// parsing would be a place for the relay to disagree with the endpoints.
async fn splice_tcp(mut client: TcpStream, upstream_addr: &str) -> Result<()> {
let mut upstream = tokio::time::timeout(
UPSTREAM_CONNECT_TIMEOUT,
TcpStream::connect(upstream_addr),
)
.map_err(|_| anyhow::anyhow!("invalid base64 from {}", peer_addr))?;
.await
.map_err(|_| anyhow::anyhow!("upstream {upstream_addr} connect timed out"))?
.with_context(|| format!("connect to upstream {upstream_addr}"))?;
if sig_bytes.len() < 40 {
let _ = client.write_all(b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 12\r\nConnection: close\r\n\r\nUnauthorized").await;
anyhow::bail!("signature too short from {}", peer_addr);
}
// Both sides carry latency-sensitive framed traffic; Nagle would add delay
// for no benefit on an already-batched stream.
let _ = client.set_nodelay(true);
let _ = upstream.set_nodelay(true);
let ts_bytes: [u8; 8] = sig_bytes[0..8].try_into().unwrap();
let provided_mac = &sig_bytes[8..];
// Проверяем по синхронизированным ключам
let authorized = {
let keys = shared_keys.read().unwrap_or_else(|e| e.into_inner());
verify_hmac(&ts_bytes, provided_mac, &keys)
};
if !authorized {
let _ = client.write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 9\r\nConnection: close\r\n\r\nNot Found").await;
anyhow::bail!("unauthorized client {}", peer_addr);
}
tracing::info!("Relay TCP: authorized client {}, forwarding to {}", peer_addr, upstream_addr);
// Подключаемся к upstream
let mut upstream = TcpStream::connect(&upstream_addr).await
.map_err(|e| anyhow::anyhow!("failed to connect to upstream {}: {}", upstream_addr, e))?;
// Пересылаем upstream заголовки AS-IS (он сам проверит подпись)
upstream.write_all(&header_buf[..header_len]).await?;
// Пайпим оба потока: client <-> upstream
let (mut cr, mut cw) = client.into_split();
let (mut ur, mut uw) = upstream.into_split();
let c2u = tokio::spawn(async move {
let _ = tokio::io::copy(&mut cr, &mut uw).await;
});
let u2c = tokio::spawn(async move {
let _ = tokio::io::copy(&mut ur, &mut cw).await;
});
let _ = tokio::join!(c2u, u2c);
tokio::io::copy_bidirectional(&mut client, &mut upstream).await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// The admission limiter is what replaced per-client authentication as the
/// defence against resource abuse, so it has to actually stop admitting.
#[test]
fn admission_limiter_stops_at_the_burst_ceiling() {
let mut limiter = AdmissionLimiter::new();
let mut admitted = 0usize;
// Ask for far more than one burst without letting time pass.
for _ in 0..(NEW_SESSION_RATE as usize * 3) {
if limiter.try_admit() {
admitted += 1;
}
}
assert!(
admitted <= NEW_SESSION_RATE as usize + 1,
"admitted {admitted} sessions in one instant, ceiling is {NEW_SESSION_RATE}"
);
assert!(admitted > 0, "limiter admitted nothing at all");
}
/// It must also refill, or the relay would accept a burst once and then
/// refuse every client forever.
#[test]
fn admission_limiter_refills_over_time() {
let mut limiter = AdmissionLimiter::new();
while limiter.try_admit() {}
assert!(!limiter.try_admit(), "bucket should be empty");
std::thread::sleep(Duration::from_millis(50));
assert!(
limiter.try_admit(),
"limiter never refilled; the relay would stop accepting new clients"
);
}
/// End-to-end through the real UDP path: a client datagram reaches the
/// upstream and the reply comes back to that same client. This is the whole
/// job of the relay, and it is what the previous implementation could not do
/// with a real client, because it demanded credentials no client sends.
#[tokio::test]
async fn udp_relay_forwards_both_directions() {
// Stand-in upstream that echoes with a marker.
let upstream = UdpSocket::bind("127.0.0.1:0").await.unwrap();
let upstream_addr = upstream.local_addr().unwrap();
tokio::spawn(async move {
let mut buf = [0u8; 1500];
while let Ok((n, from)) = upstream.recv_from(&mut buf).await {
let mut reply = b"echo:".to_vec();
reply.extend_from_slice(&buf[..n]);
let _ = upstream.send_to(&reply, from).await;
}
});
let relay_listen = {
let probe = UdpSocket::bind("127.0.0.1:0").await.unwrap();
let a = probe.local_addr().unwrap();
drop(probe);
a
};
tokio::spawn(run_udp_relay(RelayConfig {
listen_addrs: vec![relay_listen.to_string()],
upstream_tcp: upstream_addr.to_string(),
upstream_udp: upstream_addr.to_string(),
}));
tokio::time::sleep(Duration::from_millis(150)).await;
// A plain OSTP-looking datagram: no credentials, no preamble.
let client = UdpSocket::bind("127.0.0.1:0").await.unwrap();
client.send_to(b"opaque-payload", relay_listen).await.unwrap();
let mut buf = [0u8; 1500];
let (n, _) = tokio::time::timeout(Duration::from_secs(3), client.recv_from(&mut buf))
.await
.expect("relay did not deliver a reply within 3s")
.unwrap();
assert_eq!(
&buf[..n],
b"echo:opaque-payload",
"relay did not forward the payload verbatim in both directions"
);
}
/// Same for TCP: bytes must cross unmodified in both directions, with no
/// handshake demanded of the client.
#[tokio::test]
async fn tcp_relay_splices_both_directions() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let upstream = TcpListener::bind("127.0.0.1:0").await.unwrap();
let upstream_addr = upstream.local_addr().unwrap();
tokio::spawn(async move {
if let Ok((mut sock, _)) = upstream.accept().await {
let mut buf = [0u8; 128];
if let Ok(n) = sock.read(&mut buf).await {
let mut reply = b"echo:".to_vec();
reply.extend_from_slice(&buf[..n]);
let _ = sock.write_all(&reply).await;
}
}
});
let relay_listen = {
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
let a = probe.local_addr().unwrap();
drop(probe);
a
};
tokio::spawn(run_tcp_relay(RelayConfig {
listen_addrs: vec![relay_listen.to_string()],
upstream_tcp: upstream_addr.to_string(),
upstream_udp: upstream_addr.to_string(),
}));
tokio::time::sleep(Duration::from_millis(150)).await;
let mut client = TcpStream::connect(relay_listen).await.unwrap();
client.write_all(b"opaque-stream").await.unwrap();
let mut buf = [0u8; 128];
let n = tokio::time::timeout(Duration::from_secs(3), client.read(&mut buf))
.await
.expect("relay did not deliver a reply within 3s")
.unwrap();
assert_eq!(&buf[..n], b"echo:opaque-stream");
}
}

View File

@ -47,12 +47,29 @@ impl Router {
let mut proxy = None;
if let Some(ref c) = cfg {
if c.enabled && c.protocol == "socks5" {
let proxy_addr = format!("{}:{}", c.address, c.port);
if let Ok(p) = crate::outbound::connect_udp_via_socks5(&proxy_addr, server_udp.clone()).await {
proxy = Some(Arc::new(p));
} else if self.debug {
tracing::warn!("Failed to establish SOCKS5 UDP Associate");
if c.enabled {
if c.protocol == "socks5" {
let proxy_addr = format!("{}:{}", c.address, c.port);
match crate::outbound::connect_udp_via_socks5(&proxy_addr, server_udp.clone()).await {
Ok(p) => proxy = Some(Arc::new(p)),
// Warn unconditionally, not only under `debug`. Every UDP
// flow the rules want proxied is now dropped instead of
// sent, so an operator who cannot see this has a session
// where TCP works and UDP silently does not.
Err(e) => tracing::warn!(
"SOCKS5 UDP ASSOCIATE to {proxy_addr} failed: {e}. UDP that the \
outbound rules route through the proxy will be DROPPED (it is not \
sent directly, which would expose this server's address)."
),
}
} else {
tracing::warn!(
"Upstream proxy protocol is '{}', which cannot carry UDP. UDP matching \
a Proxy rule will be DROPPED. Use a socks5 upstream for UDP, or add an \
explicit udp rule with action \"direct\" or \"block\" to make the \
intent explicit.",
c.protocol
);
}
}
}
@ -87,9 +104,28 @@ impl UdpSessionRouter {
return Err(anyhow::anyhow!("blocked by outbound udp rule: {}", target));
}
if action == crate::outbound::OutboundAction::Proxy {
if let Some(p) = &self.proxy {
return p.send_to(data, target).await;
}
return match &self.proxy {
Some(p) => p.send_to(data, target).await,
// FAIL CLOSED. This used to fall through to the direct
// socket, so whenever the UDP proxy was unavailable —
// the SOCKS5 UDP ASSOCIATE failed, or the upstream is an
// HTTP proxy, which cannot carry UDP at all — every UDP
// datagram silently egressed from the server's own
// address while TCP still went through the proxy. The
// session then had two different exit IPs, which is what
// Google flags and why YouTube (QUIC, i.e. UDP/443)
// geolocated to the server instead of the proxy exit.
//
// A rule that says "proxy" must never be satisfied by
// sending in the clear: a dropped datagram is visible and
// debuggable, a deanonymising leak is neither.
None => Err(anyhow::anyhow!(
"outbound rule requires the proxy for UDP to {target}, but no UDP \
proxy is available (SOCKS5 UDP ASSOCIATE failed, or the upstream \
is an HTTP proxy, which cannot carry UDP) - dropping rather than \
leaking the server's own address"
)),
};
}
}
}

View File

@ -4,6 +4,21 @@
// or launched via ShellExecuteW("runas").
fn main() {
// Key off the TARGET, not the host. In a build script `cfg(windows)`
// describes the machine doing the building, so cross-compiling the helper
// from Windows to Linux took this branch and failed with "Can only compile
// resource file when target_env is gnu or msvc". CARGO_CFG_TARGET_OS is the
// target being built for, which is what actually decides whether a Windows
// manifest belongs in the binary.
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
if target_os != "windows" {
return;
}
// Second gate, on the HOST: winres is declared under
// [target.'cfg(windows)'.build-dependencies], and build-dependencies are
// resolved against the host triple, so the crate simply does not exist when
// building on Linux. Referencing it unconditionally would fail to compile
// there even though the target check above already passed.
#[cfg(windows)]
{
let mut res = winres::WindowsResource::new();

View File

@ -24,6 +24,14 @@ fn log_to_file(msg: &str) {
/// Launch parameters handed over in a file rather than on the command line.
/// See the `--args-file` handling in `main` for why.
#[derive(Deserialize)]
struct HelperArgs {
port: u16,
token: String,
}
#[derive(Deserialize)]
#[serde(tag = "cmd", rename_all = "lowercase")]
enum GuiCmd {
@ -76,6 +84,28 @@ async fn main() -> Result<()> {
let _ = std::fs::remove_file(path); // securely delete after reading
}
}
// Both port and token from one file. A Scheduled Task stores a FIXED
// command line, so anything that varies per launch cannot be passed as
// an argument — the GUI writes this file immediately before triggering
// the task instead. That indirection is what lets the task be created
// once (a single UAC prompt) and reused for every later connect without
// prompting again.
if args[i] == "--args-file" && i + 1 < args.len() {
let path = &args[i + 1];
match std::fs::read_to_string(path) {
Ok(content) => {
let _ = std::fs::remove_file(path); // single use
match serde_json::from_str::<HelperArgs>(&content) {
Ok(parsed) => {
port = parsed.port;
expected_token = parsed.token;
}
Err(e) => log_to_file(&format!("Failed to parse --args-file: {e}")),
}
}
Err(e) => log_to_file(&format!("Failed to read --args-file {path}: {e}")),
}
}
}
log_to_file("Helper started (TCP mode)");

View File

@ -799,18 +799,16 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
let listen = wizard_prompt("Listen address (host:port)", "0.0.0.0:50000");
let upstream = wizard_prompt("Upstream server address (host:port)", "");
if upstream.is_empty() { anyhow::bail!("Upstream address cannot be empty."); }
let api_url = wizard_prompt("Upstream server API URL (e.g. http://1.2.3.4:9090)", "");
let api_token = wizard_prompt("Upstream API token (leave blank if none)", "");
wizard_step(2, TOTAL, "Saving configuration");
// No credentials are collected: the relay forwards transparently and
// authenticates nothing, so it needs neither the target's API nor a
// copy of the access keys.
let relay_json = serde_json::json!({
"mode": "relay",
"listen": listen,
"upstream_tcp": upstream,
"upstream_udp": upstream,
"upstream_api_url": api_url,
"upstream_api_token": api_token,
"sync_interval_secs": 30,
"debug": false
});
@ -1137,7 +1135,9 @@ async fn run_app() -> Result<()> {
println!(" Listen: {:?}", r.listen.primary().cyan());
println!(" Upstream TCP: {}", r.upstream_tcp.cyan());
println!(" Upstream UDP: {}", r.upstream_udp.cyan());
println!(" API sync: {}", r.upstream_api_url.yellow());
if !r.upstream_api_url.is_empty() {
println!(" {}", "upstream_api_url is set but no longer used - safe to remove".yellow());
}
}
}
}
@ -1213,9 +1213,9 @@ async fn run_app() -> Result<()> {
"listen": "0.0.0.0:50000",
"upstream_tcp": "TARGET_SERVER_IP:50000",
"upstream_udp": "TARGET_SERVER_IP:50000",
"upstream_api_url": "http://TARGET_SERVER_IP:9090",
"upstream_api_token": "YOUR_API_TOKEN_HERE",
"sync_interval_secs": 30,
// The relay forwards transparently and holds no keys: sessions are
// authenticated end-to-end by the target server, which drops anything that
// fails. Nothing else needs configuring here.
"debug": false
}"#.to_string()
} else {
@ -1414,14 +1414,18 @@ async fn run_app() -> Result<()> {
println!("{} Starting relay node on {:?}", "[ostp]".cyan().bold(), listen_addrs);
println!("{} Upstream TCP: {}", "[ostp]".cyan().bold(), relay_cfg.upstream_tcp);
println!("{} Upstream UDP: {}", "[ostp]".cyan().bold(), relay_cfg.upstream_udp);
println!("{} Key sync API: {}", "[ostp]".cyan().bold(), relay_cfg.upstream_api_url);
if !relay_cfg.upstream_api_url.is_empty() {
println!(
"{} Note: upstream_api_url is no longer used and can be removed. The relay \
forwards transparently; sessions are authenticated end-to-end by the target \
server.",
"[ostp]".yellow().bold()
);
}
let relay_config = ostp_server::RelayConfig {
listen_addrs,
upstream_tcp: relay_cfg.upstream_tcp,
upstream_udp: relay_cfg.upstream_udp,
upstream_api_url: relay_cfg.upstream_api_url,
upstream_api_token: relay_cfg.upstream_api_token,
sync_interval_secs: relay_cfg.sync_interval_secs,
};
ostp_server::relay_node::run_relay_node(relay_config).await?;
}