mirror of https://github.com/ospab/ostp.git
feat(gui): register the helper task from an installer, not from the app
Elevation belongs to install time. Registering a task that runs elevated is itself privileged, so an unprivileged GUI can only obtain one by raising the very prompt we are trying to remove. There was nowhere to put it: the Windows GUI ships as a portable zip built with --no-bundle, so the project had no installer at all. Adds an NSIS one, whose POSTINSTALL hook registers the task while already elevated. Connecting then prompts zero times. NSIS over WiX because installerHooks is an NSIS feature; the MSI equivalent needs a custom action, which is more bespoke machinery, not less. installMode is perMachine — the default, currentUser, does not run elevated, and the hook would fail exactly as the in-app attempt did. The task's principal is the SID S-1-5-32-545 (BUILTIN\Users) with InteractiveToken rather than the installing user, so a machine-wide install serves every account instead of only whoever ran the installer; the name is localized and would not resolve. %LOCALAPPDATA% in the arguments is left unexpanded for the same reason — Task Scheduler expands it per running user. Also fixes the in-app fallback, which the portable zip still needs and which had never once worked. It trusted the exit code of an elevated schtasks, but -Verb RunAs launches through ShellExecute and a non-elevated parent generally cannot read the child's exit code: $p.ExitCode yields $null, and `exit $null` leaves PowerShell reporting 0 (measured, not assumed). Failure was arriving disguised as success. -Wait does not reliably block either, so deleting the task XML afterwards raced schtasks reading it. It now waits for the task to actually appear before deleting anything, and treats the exit code as advisory except for 1223, a declined prompt, which is worth failing fast on. Corrects one comment that asserted the opposite of the truth: schtasks writes UTF-16 to a console but UTF-8 with no BOM into a redirected pipe, which is the case that matters here. Only the fallback made the path check work at all. wintun.dll rides along as a bundled resource and the hook copies it beside the executables, since the helper loads it with a plain LoadLibrary. The uninstall hook removes both it and the task, so no stale registration is left pointing at a deleted binary.
This commit is contained in:
parent
cf14a4243c
commit
8b5c0a3a8c
|
|
@ -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 }}
|
||||
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 '<?xml version="1.0" encoding="UTF-16"?>$\r$\n'
|
||||
FileWrite $R1 '<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">$\r$\n'
|
||||
FileWrite $R1 ' <RegistrationInfo>$\r$\n'
|
||||
FileWrite $R1 ' <Description>Starts the OSTP TUN helper elevated so connecting does not prompt for consent every time.</Description>$\r$\n'
|
||||
FileWrite $R1 ' </RegistrationInfo>$\r$\n'
|
||||
FileWrite $R1 ' <Principals>$\r$\n'
|
||||
FileWrite $R1 ' <Principal id="Author">$\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 ' <GroupId>S-1-5-32-545</GroupId>$\r$\n'
|
||||
FileWrite $R1 ' <LogonType>InteractiveToken</LogonType>$\r$\n'
|
||||
FileWrite $R1 ' <RunLevel>HighestAvailable</RunLevel>$\r$\n'
|
||||
FileWrite $R1 ' </Principal>$\r$\n'
|
||||
FileWrite $R1 ' </Principals>$\r$\n'
|
||||
FileWrite $R1 ' <Settings>$\r$\n'
|
||||
; Parallel: reconnecting before a previous helper has fully exited must not
|
||||
; be silently dropped as a duplicate instance.
|
||||
FileWrite $R1 ' <MultipleInstancesPolicy>Parallel</MultipleInstancesPolicy>$\r$\n'
|
||||
; A VPN is most needed on battery, and a tunnel must not be killed on unplug.
|
||||
FileWrite $R1 ' <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>$\r$\n'
|
||||
FileWrite $R1 ' <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>$\r$\n'
|
||||
FileWrite $R1 ' <StartWhenAvailable>false</StartWhenAvailable>$\r$\n'
|
||||
FileWrite $R1 ' <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>$\r$\n'
|
||||
; PT0S disables the execution time limit; the default would tear the tunnel
|
||||
; down after three days.
|
||||
FileWrite $R1 ' <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>$\r$\n'
|
||||
FileWrite $R1 ' <Enabled>true</Enabled>$\r$\n'
|
||||
FileWrite $R1 ' <Hidden>false</Hidden>$\r$\n'
|
||||
FileWrite $R1 ' <AllowHardTerminate>true</AllowHardTerminate>$\r$\n'
|
||||
FileWrite $R1 ' </Settings>$\r$\n'
|
||||
FileWrite $R1 ' <Actions Context="Author">$\r$\n'
|
||||
FileWrite $R1 ' <Exec>$\r$\n'
|
||||
FileWrite $R1 ' <Command>$INSTDIR\ostp-tun-helper.exe</Command>$\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 ' <Arguments>--args-file "%LOCALAPPDATA%\OSTP\helper-args.json"</Arguments>$\r$\n'
|
||||
FileWrite $R1 ' </Exec>$\r$\n'
|
||||
FileWrite $R1 ' </Actions>$\r$\n'
|
||||
FileWrite $R1 '</Task>$\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
|
||||
|
|
@ -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-<target-triple>.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/<triple>/, 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');
|
||||
}
|
||||
Loading…
Reference in New Issue