mirror of https://github.com/ospab/ostp.git
Compare commits
10 Commits
v0.4.3-bet
...
master
| Author | SHA1 | Date |
|---|---|---|
|
|
cf14a4243c | |
|
|
66368c9d0f | |
|
|
bc61b47817 | |
|
|
5a33ed69c4 | |
|
|
c5e703c144 | |
|
|
05f25155bd | |
|
|
e6e0a7b28c | |
|
|
8f0ffd08c0 | |
|
|
8a1426ecf5 | |
|
|
e483af541f |
|
|
@ -477,12 +477,19 @@ jobs:
|
||||||
working-directory: ostp-gui
|
working-directory: ostp-gui
|
||||||
run: |
|
run: |
|
||||||
npm install
|
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 }}
|
npx tauri build --no-bundle --target ${{ matrix.target }}
|
||||||
|
|
||||||
- name: Package Portable Tarball
|
- name: Package Portable Tarball
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
mkdir ostp-linux-gui-${{ matrix.arch }}
|
mkdir ostp-linux-gui-${{ matrix.arch }}
|
||||||
cp ostp-gui/src-tauri/target/${{ matrix.target }}/release/ostp-gui 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 }}
|
tar -czf ostp-linux-gui-${{ matrix.arch }}.tar.gz ostp-linux-gui-${{ matrix.arch }}
|
||||||
|
|
||||||
- name: Upload to GitHub Release
|
- name: Upload to GitHub Release
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"target_version": "0.4.3",
|
"target_version": "0.4.4",
|
||||||
"branch": "beta",
|
"branch": "master",
|
||||||
"alpha_iteration": 0,
|
"alpha_iteration": 0,
|
||||||
"beta_iteration": 2
|
"beta_iteration": 0
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1386,7 +1386,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp"
|
name = "ostp"
|
||||||
version = "0.4.3"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|
@ -1409,7 +1409,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-client"
|
name = "ostp-client"
|
||||||
version = "0.4.3"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|
@ -1440,7 +1440,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-core"
|
name = "ostp-core"
|
||||||
version = "0.4.3"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
|
@ -1474,7 +1474,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-server"
|
name = "ostp-server"
|
||||||
version = "0.4.3"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
|
|
@ -1507,7 +1507,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-tun"
|
name = "ostp-tun"
|
||||||
version = "0.4.3"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"libc",
|
"libc",
|
||||||
|
|
@ -1519,7 +1519,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-tun-helper"
|
name = "ostp-tun-helper"
|
||||||
version = "0.4.3"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|
|
||||||
|
|
@ -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.3"
|
version = "0.4.4"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,12 @@ use crate::tunnel::{ProxyEvent, ProxyToClientMsg};
|
||||||
/// candidate address is tried.
|
/// candidate address is tried.
|
||||||
const UOT_CONNECT_TIMEOUT: Duration = Duration::from_secs(4);
|
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();
|
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)
|
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.
|
/// fire at all. Retrying until success removes the dependency on either.
|
||||||
forced_reconnect_pending: bool,
|
forced_reconnect_pending: bool,
|
||||||
last_forced_reconnect_try: Instant,
|
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 {
|
impl Bridge {
|
||||||
|
|
@ -185,6 +196,7 @@ impl Bridge {
|
||||||
last_valid_recv: Instant::now(),
|
last_valid_recv: Instant::now(),
|
||||||
forced_reconnect_pending: false,
|
forced_reconnect_pending: false,
|
||||||
last_forced_reconnect_try: Instant::now(),
|
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()
|
"Resumed after ~{}s suspend — forcing clean reconnect", wall_gap.as_secs()
|
||||||
))).await;
|
))).await;
|
||||||
self.forced_reconnect_pending = true;
|
self.forced_reconnect_pending = true;
|
||||||
|
self.forced_reconnect_started = Some(SystemTime::now());
|
||||||
self.last_forced_reconnect_try = Instant::now() - Duration::from_secs(60);
|
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.
|
// Keep retrying a resume-triggered reconnect until one lands.
|
||||||
// The first attempt fires within half a second of waking, when
|
// The first attempt fires within half a second of waking, when
|
||||||
// the NIC is typically still reassociating, so treating it as
|
// the NIC is typically still reassociating, so treating it as
|
||||||
|
|
@ -286,6 +334,7 @@ impl Bridge {
|
||||||
// success check rather than "we tried".
|
// success check rather than "we tried".
|
||||||
if self.last_valid_recv.elapsed() < Duration::from_secs(3) {
|
if self.last_valid_recv.elapsed() < Duration::from_secs(3) {
|
||||||
self.forced_reconnect_pending = false;
|
self.forced_reconnect_pending = false;
|
||||||
|
self.forced_reconnect_started = None;
|
||||||
let _ = tx.send(UiEvent::Log("Reconnected after suspend".into())).await;
|
let _ = tx.send(UiEvent::Log("Reconnected after suspend".into())).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.3+27
|
version: 0.4.4+31
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.4
|
sdk: ^3.11.4
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "ostp-gui",
|
"name": "ostp-gui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.4.3",
|
"version": "0.4.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
|
|
|
||||||
|
|
@ -2665,7 +2665,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-client"
|
name = "ostp-client"
|
||||||
version = "0.4.3"
|
version = "0.4.4"
|
||||||
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.3"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
|
@ -2713,7 +2713,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-gui"
|
name = "ostp-gui"
|
||||||
version = "0.4.3"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"json_comments",
|
"json_comments",
|
||||||
|
|
@ -2733,7 +2733,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-tun"
|
name = "ostp-tun"
|
||||||
version = "0.4.3"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"libc",
|
"libc",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "ostp-gui"
|
name = "ostp-gui"
|
||||||
version = "0.4.3"
|
version = "0.4.4"
|
||||||
description = "OSTP desktop GUI"
|
description = "OSTP desktop GUI"
|
||||||
authors = ["ospab"]
|
authors = ["ospab"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
|
||||||
|
|
@ -204,19 +204,34 @@ fn get_wintun_install_path() -> String {
|
||||||
String::new()
|
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).
|
/// Sets or removes the app from Windows startup (HKCU\...\Run).
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn set_autostart(enable: bool) -> Result<(), String> {
|
fn set_autostart(enable: bool) -> Result<(), String> {
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
use std::process::Command;
|
|
||||||
let key = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run";
|
let key = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run";
|
||||||
let app_name = "OSTP";
|
let app_name = "OSTP";
|
||||||
if enable {
|
if enable {
|
||||||
let exe = std::env::current_exe()
|
let exe = std::env::current_exe()
|
||||||
.map_err(|e| format!("Cannot get exe path: {}", e))?;
|
.map_err(|e| format!("Cannot get exe path: {}", e))?;
|
||||||
let exe_str = format!("\"{}\"", exe.to_string_lossy());
|
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"])
|
.args(["add", key, "/v", app_name, "/t", "REG_SZ", "/d", &exe_str, "/f"])
|
||||||
.output()
|
.output()
|
||||||
.map_err(|e| format!("reg add failed: {}", e))?;
|
.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());
|
return Err(String::from_utf8_lossy(&out.stderr).to_string());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let _ = Command::new("reg")
|
let _ = quiet_command("reg")
|
||||||
.args(["delete", key, "/v", app_name, "/f"])
|
.args(["delete", key, "/v", app_name, "/f"])
|
||||||
.output();
|
.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(())
|
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.
|
/// Checks if the app is currently in Windows startup.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn get_autostart() -> bool {
|
fn get_autostart() -> bool {
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
use std::process::Command;
|
|
||||||
let key = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run";
|
let key = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run";
|
||||||
let out = Command::new("reg")
|
let out = quiet_command("reg")
|
||||||
.args(["query", key, "/v", "OSTP"])
|
.args(["query", key, "/v", "OSTP"])
|
||||||
.output();
|
.output();
|
||||||
if let Ok(o) = out {
|
if let Ok(o) = out {
|
||||||
return o.status.success();
|
return o.status.success();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
if let Some(path) = linux_autostart_path() {
|
||||||
|
return path.exists();
|
||||||
|
}
|
||||||
|
}
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -254,8 +312,7 @@ fn get_autostart() -> bool {
|
||||||
fn list_running_processes() -> Vec<String> {
|
fn list_running_processes() -> Vec<String> {
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
use std::process::Command;
|
if let Ok(out) = quiet_command("tasklist")
|
||||||
if let Ok(out) = Command::new("tasklist")
|
|
||||||
.args(["/FO", "CSV", "/NH"])
|
.args(["/FO", "CSV", "/NH"])
|
||||||
.output()
|
.output()
|
||||||
{
|
{
|
||||||
|
|
@ -625,13 +682,18 @@ async fn start_tun_via_helper(
|
||||||
raw: &ClientConfigRaw,
|
raw: &ClientConfigRaw,
|
||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
) -> Result<bool, String> {
|
) -> 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 port = {
|
||||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").map_err(|e| format!("Bind error: {}", e))?;
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").map_err(|e| format!("Bind error: {}", e))?;
|
||||||
listener.local_addr().unwrap().port()
|
listener.local_addr().unwrap().port()
|
||||||
};
|
};
|
||||||
|
|
||||||
let auth_token = rand::random::<u64>().to_string();
|
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))?;
|
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;
|
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
|
||||||
|
|
||||||
|
|
@ -705,21 +767,32 @@ struct HelperPipeState {
|
||||||
error_msg: Option<String>,
|
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> {
|
fn find_helper_exe() -> Option<PathBuf> {
|
||||||
if let Ok(exe) = std::env::current_exe() {
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
if let Some(dir) = exe.parent() {
|
if let Some(dir) = exe.parent() {
|
||||||
// 1. Release/Production adjacent
|
// 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); }
|
if candidate.exists() { return Some(candidate); }
|
||||||
|
|
||||||
// 2. Tauri target directory fallback
|
// 2. Tauri target directory fallback
|
||||||
// e.g. from ostp-gui/src-tauri/target/debug/deps/
|
// e.g. from ostp-gui/src-tauri/target/debug/deps/
|
||||||
let mut parent = dir;
|
let mut parent = dir;
|
||||||
while let Some(p) = parent.parent() {
|
while let Some(p) = parent.parent() {
|
||||||
if p.file_name().map(|n| n == "target").unwrap_or(false) {
|
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); }
|
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); }
|
if rel.exists() { return Some(rel); }
|
||||||
}
|
}
|
||||||
parent = p;
|
parent = p;
|
||||||
|
|
@ -729,13 +802,13 @@ fn find_helper_exe() -> Option<PathBuf> {
|
||||||
// 3. Current working directory target fallback
|
// 3. Current working directory target fallback
|
||||||
let cwd = std::env::current_dir().unwrap_or_default();
|
let cwd = std::env::current_dir().unwrap_or_default();
|
||||||
let candidates = [
|
let candidates = [
|
||||||
cwd.join("ostp-tun-helper.exe"),
|
cwd.join(HELPER_EXE_NAME),
|
||||||
cwd.join("target").join("debug").join("ostp-tun-helper.exe"),
|
cwd.join("target").join("debug").join(HELPER_EXE_NAME),
|
||||||
cwd.join("target").join("release").join("ostp-tun-helper.exe"),
|
cwd.join("target").join("release").join(HELPER_EXE_NAME),
|
||||||
cwd.join("..").join("target").join("debug").join("ostp-tun-helper.exe"),
|
cwd.join("..").join("target").join("debug").join(HELPER_EXE_NAME),
|
||||||
cwd.join("..").join("target").join("release").join("ostp-tun-helper.exe"),
|
cwd.join("..").join("target").join("release").join(HELPER_EXE_NAME),
|
||||||
cwd.join("..").join("..").join("target").join("debug").join("ostp-tun-helper.exe"),
|
cwd.join("..").join("..").join("target").join("debug").join(HELPER_EXE_NAME),
|
||||||
cwd.join("..").join("..").join("target").join("release").join("ostp-tun-helper.exe"),
|
cwd.join("..").join("..").join("target").join("release").join(HELPER_EXE_NAME),
|
||||||
];
|
];
|
||||||
for path in &candidates {
|
for path in &candidates {
|
||||||
if path.exists() { return Some(path.clone()); }
|
if path.exists() { return Some(path.clone()); }
|
||||||
|
|
@ -743,8 +816,272 @@ fn find_helper_exe() -> Option<PathBuf> {
|
||||||
None
|
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('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reverse of [`xml_escape`]. `&` must be undone last or `&lt;` would
|
||||||
|
/// come back as `<`.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn xml_unescape(s: &str) -> String {
|
||||||
|
s.replace(""", "\"")
|
||||||
|
.replace("'", "'")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("&", "&")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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")]
|
#[cfg(target_os = "windows")]
|
||||||
fn launch_as_admin(exe: &std::path::PathBuf, token: &str, port: u16) -> anyhow::Result<()> {
|
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::ffi::OsStr;
|
||||||
use std::os::windows::ffi::OsStrExt;
|
use std::os::windows::ffi::OsStrExt;
|
||||||
use std::ptr::null_mut;
|
use std::ptr::null_mut;
|
||||||
|
|
@ -797,8 +1134,50 @@ fn launch_as_admin(exe: &std::path::PathBuf, token: &str, port: u16) -> anyhow::
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(target_os = "linux")]
|
||||||
fn launch_as_admin(_exe: &PathBuf, _token: &str, _port: u16) -> Result<()> { anyhow::bail!("Windows only."); }
|
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")]
|
#[cfg(target_os = "windows")]
|
||||||
fn show_error_dialog(msg: &str) {
|
fn show_error_dialog(msg: &str) {
|
||||||
|
|
|
||||||
|
|
@ -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.3",
|
"version": "0.4.4",
|
||||||
"identifier": "com.ospab.ostp",
|
"identifier": "com.ospab.ostp",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../src"
|
"frontendDist": "../src"
|
||||||
|
|
@ -11,9 +11,11 @@
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"title": "OSTP",
|
"title": "OSTP",
|
||||||
"width": 360,
|
"width": 400,
|
||||||
"height": 680,
|
"height": 720,
|
||||||
"resizable": false
|
"minWidth": 360,
|
||||||
|
"minHeight": 560,
|
||||||
|
"resizable": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
|
|
|
||||||
|
|
@ -660,6 +660,13 @@ function loadSettingsIntoForm() {
|
||||||
updateClientVisibility();
|
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() {
|
function collectAndSaveSettings() {
|
||||||
const s = {
|
const s = {
|
||||||
tun: inTun.checked,
|
tun: inTun.checked,
|
||||||
|
|
@ -686,19 +693,41 @@ function collectAndSaveSettings() {
|
||||||
fragChunk: parseInt(inFragChunk.value) || 2,
|
fragChunk: parseInt(inFragChunk.value) || 2,
|
||||||
fragSleep: !isNaN(parseInt(inFragSleep.value)) ? parseInt(inFragSleep.value) : 2,
|
fragSleep: !isNaN(parseInt(inFragSleep.value)) ? parseInt(inFragSleep.value) : 2,
|
||||||
};
|
};
|
||||||
|
// Cheap and local: safe to run on every debounced keystroke.
|
||||||
saveClientSettings(s);
|
saveClientSettings(s);
|
||||||
updateClientVisibility();
|
updateClientVisibility();
|
||||||
|
|
||||||
// Set autostart
|
// Everything below talks to the OS or restarts the tunnel. Running it per
|
||||||
invoke('set_autostart', { enable: s.launchStartup }).catch(() => {});
|
// 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') {
|
if (appState === 'connected') {
|
||||||
const cfg = buildConfig();
|
const tunnelRelevant = JSON.stringify([
|
||||||
if (cfg) {
|
s.tun, s.killSwitch, s.mux, s.muxSessions, s.mtu, s.dns, s.socks,
|
||||||
invoke('save_config', { jsonContent: JSON.stringify(cfg, null, 2) })
|
s.exDomains, s.exIps, s.exProcs, s.junkEnabled, s.junkPcMin, s.junkPcMax,
|
||||||
.then(() => invoke('reload_tunnel'))
|
s.junkPsMin, s.junkPsMax, s.tcpFrag, s.fragChunk, s.fragSleep,
|
||||||
.catch(() => {});
|
]);
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,13 @@ a { text-decoration: none; }
|
||||||
.app-root {
|
.app-root {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
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%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,21 @@
|
||||||
// or launched via ShellExecuteW("runas").
|
// or launched via ShellExecuteW("runas").
|
||||||
|
|
||||||
fn main() {
|
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)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
let mut res = winres::WindowsResource::new();
|
let mut res = winres::WindowsResource::new();
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
#[derive(Deserialize)]
|
||||||
#[serde(tag = "cmd", rename_all = "lowercase")]
|
#[serde(tag = "cmd", rename_all = "lowercase")]
|
||||||
enum GuiCmd {
|
enum GuiCmd {
|
||||||
|
|
@ -76,6 +84,28 @@ async fn main() -> Result<()> {
|
||||||
let _ = std::fs::remove_file(path); // securely delete after reading
|
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)");
|
log_to_file("Helper started (TCP mode)");
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue