From 8f0ffd08c0e053a5f680bab18ac3fa44a87a2d2e Mon Sep 17 00:00:00 2001 From: ospab Date: Tue, 4 Aug 2026 01:08:33 +0300 Subject: [PATCH] feat(gui): TUN mode and autostart on Linux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/release.yml | 7 +++ ostp-gui/src-tauri/src/lib.rs | 110 ++++++++++++++++++++++++++++------ ostp-tun-helper/build.rs | 15 +++++ 3 files changed, 114 insertions(+), 18 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 95f0d8d..0d9f6e1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/ostp-gui/src-tauri/src/lib.rs b/ostp-gui/src-tauri/src/lib.rs index 0143d63..2ac604e 100644 --- a/ostp-gui/src-tauri/src/lib.rs +++ b/ostp-gui/src-tauri/src/lib.rs @@ -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 { + 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,22 +669,10 @@ async fn start_tun_via_helper( raw: &ClientConfigRaw, app: tauri::AppHandle, ) -> Result { - // TUN mode goes through a privileged helper, and the only elevation path - // implemented is the Windows UAC one (see launch_as_admin). The helper is - // also not built for other platforms by the release workflow. Say that - // plainly and up front: previously this fell through to the helper lookup - // and surfaced as a missing-file error naming a Windows executable, which - // on Linux reads as a packaging mistake rather than an unimplemented - // feature. - if !cfg!(windows) { - return Err( - "TUN mode is currently Windows-only: it needs a privileged helper, and elevation \ - for it is only implemented on Windows. Use proxy mode (SOCKS5/HTTP) on this \ - platform." - .to_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() @@ -825,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::())); + 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) { diff --git a/ostp-tun-helper/build.rs b/ostp-tun-helper/build.rs index 2426eb1..ce5d223 100644 --- a/ostp-tun-helper/build.rs +++ b/ostp-tun-helper/build.rs @@ -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();