mirror of https://github.com/ospab/ostp.git
Compare commits
4 Commits
df1a14d15c
...
e6e0a7b28c
| Author | SHA1 | Date |
|---|---|---|
|
|
e6e0a7b28c | |
|
|
8f0ffd08c0 | |
|
|
8a1426ecf5 | |
|
|
e483af541f |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@
|
|||
"target_version": "0.4.3",
|
||||
"branch": "beta",
|
||||
"alpha_iteration": 0,
|
||||
"beta_iteration": 2
|
||||
"beta_iteration": 3
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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+27
|
||||
version: 0.4.3+28
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.4
|
||||
|
|
|
|||
|
|
@ -229,9 +229,47 @@ fn set_autostart(enable: bool) -> Result<(), String> {
|
|||
.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 {
|
||||
|
|
@ -246,6 +284,12 @@ fn get_autostart() -> bool {
|
|||
return o.status.success();
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Some(path) = linux_autostart_path() {
|
||||
return path.exists();
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
|
|
@ -625,13 +669,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,21 +754,32 @@ 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
|
||||
// e.g. from ostp-gui/src-tauri/target/debug/deps/
|
||||
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 +789,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()); }
|
||||
|
|
@ -797,8 +857,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) {
|
||||
|
|
|
|||
|
|
@ -11,9 +11,11 @@
|
|||
"windows": [
|
||||
{
|
||||
"title": "OSTP",
|
||||
"width": 360,
|
||||
"height": 680,
|
||||
"resizable": false
|
||||
"width": 400,
|
||||
"height": 720,
|
||||
"minWidth": 360,
|
||||
"minHeight": 560,
|
||||
"resizable": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue