diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0d9f6e1..537366b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -404,6 +404,7 @@ jobs: run: | npm install cargo build -p ostp-tun-helper --release --target ${{ matrix.target }} + node stage-sidecar.js --release --target ${{ matrix.target }} npx tauri build --no-bundle --target ${{ matrix.target }} - name: Package Portable ZIP @@ -414,9 +415,25 @@ jobs: Copy-Item "ostp-gui/src-tauri/target/${{ matrix.target }}/release/ostp-gui.exe" $dir Copy-Item "target/${{ matrix.target }}/release/ostp-tun-helper.exe" $dir Copy-Item "target/${{ matrix.target }}/release/wintun.dll" $dir - + Compress-Archive -Path "$dir/*" -DestinationPath "ostp-windows-gui-${{ matrix.arch }}.zip" -Force + # The installer is what removes the per-connect consent prompt: it runs + # elevated, so its hook can register the helper's Scheduled Task once. + # The portable zip above cannot, and falls back to asking on first connect. + - name: Build NSIS Installer + working-directory: ostp-gui + run: npx tauri build --bundles nsis --target ${{ matrix.target }} + + - name: Collect installer + shell: pwsh + run: | + $nsis = Get-ChildItem -Path "ostp-gui/src-tauri/target/${{ matrix.target }}/release/bundle/nsis" -Filter *-setup.exe -ErrorAction SilentlyContinue | + Select-Object -First 1 + if (-not $nsis) { Write-Error "NSIS installer was not produced"; exit 1 } + Copy-Item $nsis.FullName "ostp-windows-gui-${{ matrix.arch }}-setup.exe" + Write-Host "installer: $($nsis.Name) -> ostp-windows-gui-${{ matrix.arch }}-setup.exe" + - name: Upload to GitHub Release uses: softprops/action-gh-release@v2 with: @@ -426,7 +443,9 @@ jobs: # real stable release. tag_name: ${{ needs.resolve-channel.outputs.tag_name }} prerelease: ${{ needs.resolve-channel.outputs.prerelease }} - files: ostp-windows-gui-${{ matrix.arch }}.zip + files: | + ostp-windows-gui-${{ matrix.arch }}.zip + ostp-windows-gui-${{ matrix.arch }}-setup.exe env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 7bddc47..9cff444 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,6 @@ ostp-control/ netstack-smoltcp/ dnstt/ ostp-web/ + +# Tauri sidecar staging area (copied from target/ at build time) +ostp-gui/src-tauri/binaries/ diff --git a/ostp-gui/package.json b/ostp-gui/package.json index 2ac953c..e1b9a1e 100644 --- a/ostp-gui/package.json +++ b/ostp-gui/package.json @@ -5,10 +5,11 @@ "type": "module", "scripts": { "tauri": "tauri", - "dev": "cargo build -p ostp-tun-helper && npx tauri dev", - "build": "cargo build -p ostp-tun-helper --release && npx tauri build --no-bundle", - "build:installer": "cargo build -p ostp-tun-helper --release && npx tauri build", - "build:dist": "npm run build && node build_dist.js" + "dev": "cargo build -p ostp-tun-helper && node stage-sidecar.js && npx tauri dev", + "build": "cargo build -p ostp-tun-helper --release && node stage-sidecar.js --release && npx tauri build --no-bundle", + "build:installer": "cargo build -p ostp-tun-helper --release && node stage-sidecar.js --release && npx tauri build --bundles nsis", + "build:dist": "npm run build && node build_dist.js", + "sidecar": "node stage-sidecar.js" }, "devDependencies": { "@tauri-apps/cli": "^2" diff --git a/ostp-gui/src-tauri/src/lib.rs b/ostp-gui/src-tauri/src/lib.rs index 4f53689..bb6700a 100644 --- a/ostp-gui/src-tauri/src/lib.rs +++ b/ostp-gui/src-tauri/src/lib.rs @@ -864,8 +864,12 @@ fn xml_unescape(s: &str) -> String { /// /// 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. +/// whereas XML tag names are fixed. +/// +/// Encoding depends on where the output goes, which is measured rather than +/// assumed: to a console schtasks writes UTF-16LE with a BOM, but into a +/// redirected pipe — our case — it writes UTF-8 with no BOM. Both are handled, +/// keyed off the BOM, so this keeps working if that ever flips. #[cfg(target_os = "windows")] fn helper_task_command() -> Option { let out = quiet_command("schtasks") @@ -1013,24 +1017,49 @@ fn install_helper_task(exe: &std::path::Path) -> anyhow::Result<()> { .args(["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", &ps]) .status(); - // schtasks has exited by now, so this is safe. + // 1223 is ERROR_CANCELLED: the consent prompt was declined. Nothing was + // launched, so there is no point waiting for a task to appear. + if let Ok(s) = &status { + if s.code() == Some(1223) { + let _ = std::fs::remove_file(&xml_path); + anyhow::bail!("the consent prompt was declined"); + } + } + + // The exit code is advisory only, never proof of success. `-Verb RunAs` + // launches through ShellExecute, and a non-elevated parent frequently + // cannot read the elevated child's exit code — `$p.ExitCode` then yields + // $null, and `exit $null` leaves PowerShell reporting 0. A failed + // registration would sail straight through a `s.success()` check. + // + // Worse, -Wait does not reliably block until the elevated process exits. + // Deleting the XML right after the call raced schtasks reading it — the + // exact bug that made the previous attempt fail — so wait for the task + // itself to show up. These queries are windowless, so unlike the earlier + // polling loop they cost the user nothing to watch. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); + let mut registered = false; + while std::time::Instant::now() < deadline { + if helper_task_matches(exe) { + registered = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(250)); + } + + // Only now is deleting it safe. let _ = std::fs::remove_file(&xml_path); + if registered { + return Ok(()); + } match status { - Ok(s) if s.success() => {} Ok(s) => anyhow::bail!( - "registering the scheduled task failed (exit code {:?}). A declined consent prompt \ - reports 1223.", + "the scheduled task did not appear after registration (powershell exit {:?})", 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. diff --git a/ostp-gui/src-tauri/tauri.windows.conf.json b/ostp-gui/src-tauri/tauri.windows.conf.json new file mode 100644 index 0000000..19a2b29 --- /dev/null +++ b/ostp-gui/src-tauri/tauri.windows.conf.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "externalBin": ["binaries/ostp-tun-helper"], + "resources": { "binaries/wintun.dll": "wintun.dll" }, + "windows": { + "nsis": { + "installMode": "perMachine", + "installerHooks": "./windows/hooks.nsh" + } + } + } +} diff --git a/ostp-gui/src-tauri/windows/hooks.nsh b/ostp-gui/src-tauri/windows/hooks.nsh new file mode 100644 index 0000000..2609a15 --- /dev/null +++ b/ostp-gui/src-tauri/windows/hooks.nsh @@ -0,0 +1,103 @@ +; Registers the Scheduled Task that lets the GUI start the TUN helper elevated +; without a consent prompt. +; +; This belongs in the installer, not in the app. Registering a task that runs +; with elevated rights is itself a privileged operation, so an unprivileged GUI +; can only get one by raising a UAC prompt — which is the very thing we are +; trying to remove. The installer already runs elevated, so here it costs +; nothing: the user consents once, to the install, and never again per connect. +; +; The task carries no usable trigger (a one-shot dated in the past), because it +; exists solely to be started on demand by the app. + +!macro OSTP_WRITE_TASK_XML OUTFILE + ; NSIS is built in Unicode mode here, so FileWrite emits UTF-16LE — which is + ; what `schtasks /XML` requires. It still needs the byte-order mark itself. + FileOpen $R1 "${OUTFILE}" w + FileWriteWord $R1 65279 + FileWrite $R1 '$\r$\n' + FileWrite $R1 '$\r$\n' + FileWrite $R1 ' $\r$\n' + FileWrite $R1 ' Starts the OSTP TUN helper elevated so connecting does not prompt for consent every time.$\r$\n' + FileWrite $R1 ' $\r$\n' + FileWrite $R1 ' $\r$\n' + FileWrite $R1 ' $\r$\n' + ; S-1-5-32-545 is BUILTIN\Users by SID rather than by name: the name is + ; localized ("Пользователи" on a Russian Windows) and would not resolve. + ; Combined with InteractiveToken this makes the task run as whichever user + ; actually launches it, so a machine-wide install still works for every + ; account instead of only the one that happened to run the installer. + FileWrite $R1 ' S-1-5-32-545$\r$\n' + FileWrite $R1 ' InteractiveToken$\r$\n' + FileWrite $R1 ' HighestAvailable$\r$\n' + FileWrite $R1 ' $\r$\n' + FileWrite $R1 ' $\r$\n' + FileWrite $R1 ' $\r$\n' + ; Parallel: reconnecting before a previous helper has fully exited must not + ; be silently dropped as a duplicate instance. + FileWrite $R1 ' Parallel$\r$\n' + ; A VPN is most needed on battery, and a tunnel must not be killed on unplug. + FileWrite $R1 ' false$\r$\n' + FileWrite $R1 ' false$\r$\n' + FileWrite $R1 ' false$\r$\n' + FileWrite $R1 ' false$\r$\n' + ; PT0S disables the execution time limit; the default would tear the tunnel + ; down after three days. + FileWrite $R1 ' PT0S$\r$\n' + FileWrite $R1 ' true$\r$\n' + FileWrite $R1 ' false$\r$\n' + FileWrite $R1 ' true$\r$\n' + FileWrite $R1 ' $\r$\n' + FileWrite $R1 ' $\r$\n' + FileWrite $R1 ' $\r$\n' + FileWrite $R1 ' $INSTDIR\ostp-tun-helper.exe$\r$\n' + ; The port and auth token change per launch and a task stores a fixed command + ; line, so they travel in this file instead. %LOCALAPPDATA% is deliberately + ; left unexpanded: Task Scheduler expands it when the task runs, which lands + ; on the profile of whoever launched it rather than the installing user's. + FileWrite $R1 ' --args-file "%LOCALAPPDATA%\OSTP\helper-args.json"$\r$\n' + FileWrite $R1 ' $\r$\n' + FileWrite $R1 ' $\r$\n' + FileWrite $R1 '$\r$\n' + FileClose $R1 +!macroend + +!macro NSIS_HOOK_POSTINSTALL + ; Bundled resources land in $INSTDIR\resources, but the helper loads wintun + ; with a plain LoadLibrary, which searches its own directory — so put a copy + ; beside the executables. + DetailPrint "Placing wintun.dll next to the helper..." + CopyFiles /SILENT "$INSTDIR\resources\wintun.dll" "$INSTDIR\wintun.dll" + + DetailPrint "Registering the OSTP TUN helper task..." + !insertmacro OSTP_WRITE_TASK_XML "$PLUGINSDIR\ostp-helper-task.xml" + + ; /F overwrites an existing registration, so reinstalling or upgrading to a + ; different directory repoints the task instead of leaving a stale path — the + ; app verifies the registered path at runtime and would otherwise have to + ; re-register it with a prompt. + nsExec::ExecToLog 'schtasks.exe /Create /TN "OSTP TUN Helper" /XML "$PLUGINSDIR\ostp-helper-task.xml" /F' + Pop $R0 + Delete "$PLUGINSDIR\ostp-helper-task.xml" + + ${If} $R0 == 0 + DetailPrint "Helper task registered; connecting will not prompt for consent." + ${Else} + ; Not fatal. The app keeps a fallback that registers the task itself on + ; first connect, at the cost of the one prompt this was meant to avoid. + DetailPrint "Could not register the helper task (schtasks returned $R0)." + DetailPrint "OSTP will still work, but the first connect will ask for consent." + ${EndIf} +!macroend + +!macro NSIS_HOOK_PREUNINSTALL + ; Leaving the task behind would point at a deleted executable, and the app + ; treats a mismatched path as grounds to re-register. + DetailPrint "Removing the OSTP TUN helper task..." + nsExec::ExecToLog 'schtasks.exe /Delete /TN "OSTP TUN Helper" /F' + Pop $R0 + + ; This copy was made by the install hook, so the uninstaller does not know + ; about it and would otherwise leave it behind. + Delete "$INSTDIR\wintun.dll" +!macroend diff --git a/ostp-gui/stage-sidecar.js b/ostp-gui/stage-sidecar.js new file mode 100644 index 0000000..4b9959d --- /dev/null +++ b/ostp-gui/stage-sidecar.js @@ -0,0 +1,70 @@ +// Stages ostp-tun-helper where Tauri expects a sidecar. +// +// tauri.windows.conf.json declares `externalBin: ["binaries/ostp-tun-helper"]`, +// and Tauri resolves that to `binaries/ostp-tun-helper-.exe` at +// build time, failing the build outright when the file is absent. Cargo writes +// the plain name instead, so every Windows build — dev, portable zip and +// installer alike — has to copy it across first. +// +// A no-op off Windows: externalBin lives in the Windows-only config, so the +// Linux and macOS GUI builds neither need nor have a helper sidecar. + +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +if (process.platform !== 'win32') { + process.exit(0); +} + +// --target may be passed through; fall back to the host triple rustc reports. +const targetFlag = process.argv.indexOf('--target'); +const triple = + targetFlag !== -1 && process.argv[targetFlag + 1] + ? process.argv[targetFlag + 1] + : execFileSync('rustc', ['-vV'], { encoding: 'utf8' }) + .split('\n') + .find((l) => l.startsWith('host:')) + .slice('host:'.length) + .trim(); + +const profile = process.argv.includes('--release') ? 'release' : 'debug'; +const repoRoot = path.resolve(__dirname, '..'); + +// Cargo drops a --target build under target//, and a host build +// straight into target/. CI always passes --target; local builds usually do not. +const candidates = [ + path.join(repoRoot, 'target', triple, profile, 'ostp-tun-helper.exe'), + path.join(repoRoot, 'target', profile, 'ostp-tun-helper.exe'), +]; +const src = candidates.find((p) => fs.existsSync(p)); +if (!src) { + console.error( + 'stage-sidecar: ostp-tun-helper.exe not found. Looked in:\n ' + + candidates.join('\n ') + + `\nBuild it first: cargo build -p ostp-tun-helper${profile === 'release' ? ' --release' : ''}` + ); + process.exit(1); +} + +const destDir = path.join(__dirname, 'src-tauri', 'binaries'); +fs.mkdirSync(destDir, { recursive: true }); +const dest = path.join(destDir, `ostp-tun-helper-${triple}.exe`); +fs.copyFileSync(src, dest); +console.log(`stage-sidecar: ${path.relative(repoRoot, src)} -> ${path.relative(repoRoot, dest)}`); + +// wintun.dll rides along as a bundled resource. It is only fetched by the +// release workflow, so a local build without it should warn rather than fail — +// the installer just ends up unable to bring a tunnel up. +const dllSrc = [ + path.join(repoRoot, 'target', triple, profile, 'wintun.dll'), + path.join(repoRoot, 'target', profile, 'wintun.dll'), +].find((p) => fs.existsSync(p)); +if (dllSrc) { + fs.copyFileSync(dllSrc, path.join(destDir, 'wintun.dll')); + console.log(`stage-sidecar: ${path.relative(repoRoot, dllSrc)} -> binaries/wintun.dll`); +} else if (fs.existsSync(path.join(destDir, 'wintun.dll'))) { + console.log('stage-sidecar: reusing the previously staged binaries/wintun.dll'); +} else { + console.warn('stage-sidecar: WARNING wintun.dll not found; a bundle build will fail on the missing resource'); +}