diff --git a/easytier-gui/src-tauri/tauri.windows.conf.json b/easytier-gui/src-tauri/tauri.windows.conf.json index b71ceaa6..56c2cb38 100644 --- a/easytier-gui/src-tauri/tauri.windows.conf.json +++ b/easytier-gui/src-tauri/tauri.windows.conf.json @@ -7,9 +7,12 @@ "./*.sys" ], "windows": { + "nsis": { + "installerHooks": "./windows/installer-hooks.nsh" + }, "webviewInstallMode": { "type": "embedBootstrapper" } } } -} \ No newline at end of file +} diff --git a/easytier-gui/src-tauri/windows/README.md b/easytier-gui/src-tauri/windows/README.md new file mode 100644 index 00000000..1f500eca --- /dev/null +++ b/easytier-gui/src-tauri/windows/README.md @@ -0,0 +1,28 @@ +# Windows installer process handling + +NSIS remains a current-user installer. Its helper requests elevation only +when a running service or GUI requires it, and restores service startup and +running state after installation. During an update it temporarily disables +service startup because released EasyTier versions can trigger SCM recovery +even after an explicit stop. + +Run `test-installer-processes.ps1` with Windows PowerShell as administrator +on a test machine without an existing `easytier-gui` service. The test uses +isolated executables and a service that reproduces the old exit behavior +with failure recovery enabled. Run it with `-GuiOnly` from an unelevated +shell to check the path that needs no elevation. + +## Known limitations + +If preparation on the welcome page has already started a helper, it is +reused at installation time. Changing to another directory containing a +running GUI, or relaunching the GUI between those pages, may still cause +Tauri's default process check to fail. Rechecking those processes is a +separate follow-up; it was recorded as a minor finding in the final review. + +Executable matching does not expand Windows 8.3 short-path aliases. An +installation directory expressed using a short alias may not match the +process image's full path. This was reproduced with LocalService's short +temporary-directory path; the unprivileged GUI test passes when its +temporary directory uses the full path. This is recorded as a minor +follow-up. Use a full temporary-directory path when running the fixture. diff --git a/easytier-gui/src-tauri/windows/installer-hooks.nsh b/easytier-gui/src-tauri/windows/installer-hooks.nsh new file mode 100644 index 00000000..ca403dab --- /dev/null +++ b/easytier-gui/src-tauri/windows/installer-hooks.nsh @@ -0,0 +1,95 @@ +!define EASYTIER_INSTALLER_SCRIPT "${__FILEDIR__}\installer-processes.ps1" + +; Tauri uninstalls the old version on its reinstall page, before PREINSTALL. +; MUI consumes this callback on the first page (Welcome). Opening the installer +; alone does not stop anything; the user must first leave the welcome page. +!define MUI_PAGE_CUSTOMFUNCTION_LEAVE EasyTierPrepareInstall +!define MUI_CUSTOMFUNCTION_GUIINIT EasyTierInstallerGuiInit + +Var EasyTierHelper +Var EasyTierInstallerPid +Var EasyTierDone +Var EasyTierFailed + +!macro EasyTierPrepare RESTORE + ${If} $EasyTierHelper != "" + Return + ${EndIf} + InitPluginsDir + File "/oname=$PLUGINSDIR\installer-processes.ps1" "${EASYTIER_INSTALLER_SCRIPT}" + System::Call 'kernel32::GetCurrentProcessId() i .r0' + StrCpy $EasyTierInstallerPid $0 + nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\installer-processes.ps1" -InstallDir "$INSTDIR" -InstallerPid $EasyTierInstallerPid -RestoreService ${RESTORE}' + Pop $0 + Pop $1 + ${If} $0 != 0 + MessageBox MB_OK|MB_ICONSTOP "Could not close EasyTier:$\r$\n$1" /SD IDOK + Abort + ${EndIf} + IntOp $1 $1 + 0 + ${If} $1 != 0 + System::Call 'kernel32::OpenEventW(i 0x100000, i 0, w "Local\EasyTierInstaller-$EasyTierInstallerPid-done") p .r0' + StrCpy $EasyTierDone $0 + System::Call 'kernel32::OpenEventW(i 0x100000, i 0, w "Local\EasyTierInstaller-$EasyTierInstallerPid-failed") p .r0' + StrCpy $EasyTierFailed $0 + ${If} $EasyTierDone == 0 + ${OrIf} $EasyTierFailed == 0 + MessageBox MB_OK|MB_ICONSTOP "Could not wait for EasyTier to finish updating." /SD IDOK + Abort + ${EndIf} + StrCpy $EasyTierHelper 1 + ${EndIf} +!macroend + +!macro EasyTierFinish + ${If} $EasyTierHelper != "" + System::Call 'kernel32::OpenEventW(i 2, i 0, w "Local\EasyTierInstaller-$EasyTierInstallerPid-finish") p .r0' + ${If} $0 != 0 + System::Call 'kernel32::SetEvent(p r0)' + System::Call 'kernel32::CloseHandle(p r0)' + ${EndIf} + System::Call 'kernel32::WaitForSingleObject(p $EasyTierDone, i 65000) i .r0' + System::Call 'kernel32::WaitForSingleObject(p $EasyTierFailed, i 0) i .r1' + System::Call 'kernel32::CloseHandle(p $EasyTierDone)' + System::Call 'kernel32::CloseHandle(p $EasyTierFailed)' + StrCpy $EasyTierHelper "" + ${If} $0 != 0 + ${OrIf} $1 != 258 + MessageBox MB_OK|MB_ICONSTOP "Could not restore the EasyTier service. Please check its status in Windows Services." /SD IDOK + Abort + ${EndIf} + ${EndIf} +!macroend + +Function EasyTierPrepareInstall + !insertmacro EasyTierPrepare 1 +FunctionEnd + +Function EasyTierInstallerGuiInit + ; Passive installs skip Welcome, but still run the old uninstaller page. + ${GetOptions} $CMDLINE "/P" $0 + ${IfNot} ${Errors} + Call EasyTierPrepareInstall + ${EndIf} +FunctionEnd + +Function un.EasyTierPrepareUninstall + !insertmacro EasyTierPrepare 0 +FunctionEnd + +!macro NSIS_HOOK_PREINSTALL + ; Silent installs skip all pages. Also handle a fresh/custom install path. + Call EasyTierPrepareInstall +!macroend + +!macro NSIS_HOOK_POSTINSTALL + !insertmacro EasyTierFinish +!macroend + +!macro NSIS_HOOK_PREUNINSTALL + Call un.EasyTierPrepareUninstall +!macroend + +!macro NSIS_HOOK_POSTUNINSTALL + !insertmacro EasyTierFinish +!macroend diff --git a/easytier-gui/src-tauri/windows/installer-processes.ps1 b/easytier-gui/src-tauri/windows/installer-processes.ps1 new file mode 100644 index 00000000..641eface --- /dev/null +++ b/easytier-gui/src-tauri/windows/installer-processes.ps1 @@ -0,0 +1,264 @@ +param( + [ValidateSet('Prepare', 'Run')] + [string]$Action = 'Prepare', + [Parameter(Mandatory = $true)] + [string]$InstallDir, + [Parameter(Mandatory = $true)] + [int]$InstallerPid, + [int]$RestoreService = 1 +) + +$ErrorActionPreference = 'Stop' +$eventName = "Local\EasyTierInstaller-$InstallerPid" +$exePath = [IO.Path]::GetFullPath((Join-Path $InstallDir 'easytier-gui.exe')).Replace('\\?\', '') +Add-Type -AssemblyName System.ServiceProcess + +# Process.MainModule and WMI cannot reliably read an elevated GUI's path from +# the unelevated installer. Limited query access works across that boundary. +Add-Type @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Text; + +public static class EasyTierInstallerProcess +{ + [DllImport("kernel32.dll", SetLastError = true)] + public static extern IntPtr OpenProcess(uint access, bool inherit, int pid); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + static extern bool QueryFullProcessImageName(IntPtr process, uint flags, + StringBuilder path, ref int size); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool TerminateProcess(IntPtr process, uint exitCode); + [DllImport("kernel32.dll")] + static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds); + [DllImport("kernel32.dll")] + public static extern bool CloseHandle(IntPtr handle); + + public static string Path(IntPtr process) + { + var path = new StringBuilder(32768); + int size = path.Capacity; + if (!QueryFullProcessImageName(process, 0, path, ref size)) + { + int error = Marshal.GetLastWin32Error(); + if (WaitForSingleObject(process, 0) == 0) return null; + throw new Win32Exception(error); + } + return path.ToString().Replace(@"\\?\", ""); + } + + public static bool Matches(int pid, string path, bool terminate) + { + // Keep the same handle for path verification, termination and waiting, + // so a recycled PID cannot cause us to terminate a different process. + IntPtr process = OpenProcess(terminate ? 0x101001u : 0x101000u, false, pid); + if (process == IntPtr.Zero) + { + int error = Marshal.GetLastWin32Error(); + if (error == 87) return false; // The process has already exited. + throw new Win32Exception(error); + } + try + { + if (!String.Equals(Path(process), path, StringComparison.OrdinalIgnoreCase)) + return false; + if (terminate) + { + if (!TerminateProcess(process, 0) && WaitForSingleObject(process, 0) != 0) + throw new Win32Exception(); + if (WaitForSingleObject(process, 30000) != 0) + throw new TimeoutException("EasyTier did not exit within 30 seconds."); + } + return true; + } + finally { CloseHandle(process); } + } +} +'@ + +function Get-AppProcesses { + foreach ($process in @(Get-Process -Name easytier-gui -ErrorAction SilentlyContinue)) { + if ([EasyTierInstallerProcess]::Matches($process.Id, $exePath, $false)) { + $process + } + } +} + +function Get-AppService { + $service = Get-WmiObject -Class Win32_Service -Filter "Name='easytier-gui'" + if ($service) { + $path = [regex]::Match($service.PathName, '^(?:"([^"]+)"|(\S+))') + $binary = ($path.Groups[1].Value + $path.Groups[2].Value).Replace('\\?\', '') + if ($binary -eq $exePath) { $service } + } +} + +function New-InstallerEvent([string]$name) { + $security = New-Object Security.AccessControl.EventWaitHandleSecurity + # RunAs may use another administrator account. The installer keeps its + # original identity; only these completion signals cross the boundary. + foreach ($sid in @( + [Security.Principal.WindowsIdentity]::GetCurrent().User, + (New-Object Security.Principal.SecurityIdentifier 'S-1-5-32-544') + )) { + $rule = New-Object Security.AccessControl.EventWaitHandleAccessRule( + $sid, 'FullControl', 'Allow') + $security.AddAccessRule($rule) + } + $created = $false + New-Object Threading.EventWaitHandle( + $false, [Threading.EventResetMode]::ManualReset, $name, [ref]$created, $security) +} + +try { + if ($Action -eq 'Prepare') { + $service = Get-AppService + $runningService = $service -and $service.State -ne 'Stopped' + $needsElevation = $runningService + $processes = @() + try { + $processes = @(Get-AppProcesses) + foreach ($process in $processes) { + $handle = [EasyTierInstallerProcess]::OpenProcess(0x101001, $false, $process.Id) + if ($handle -eq [IntPtr]::Zero) { $needsElevation = $true } + else { [void][EasyTierInstallerProcess]::CloseHandle($handle) } + } + } + catch { + if ($_.Exception.GetBaseException().NativeErrorCode -ne 5) { throw } + $needsElevation = $true + } + if (!$runningService -and !$processes.Count -and !$needsElevation) { + Write-Output 0 + exit 0 + } + + $ready = New-InstallerEvent "$eventName-ready" + $finish = New-InstallerEvent "$eventName-finish" + $done = New-InstallerEvent "$eventName-done" + $failed = New-InstallerEvent "$eventName-failed" + try { + $arguments = '-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass ' + + "-File `"$PSCommandPath`" -Action Run -InstallDir `"$InstallDir`" " + + "-InstallerPid $InstallerPid -RestoreService $RestoreService" + $start = @{ + FilePath = "$PSHOME\powershell.exe" + ArgumentList = $arguments + WindowStyle = 'Hidden' + PassThru = $true + } + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($identity) + if ($needsElevation -and !$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + $start.Verb = 'RunAs' + } + $helper = Start-Process @start + while (!$ready.WaitOne(100)) { + if ($helper.HasExited) { + throw 'Could not stop EasyTier.' + } + } + if ($failed.WaitOne(0)) { throw 'Could not stop EasyTier.' } + Write-Output $helper.Id + } + finally { + $ready.Dispose() + $finish.Dispose() + $done.Dispose() + $failed.Dispose() + } + } + else { + $ready = [Threading.EventWaitHandle]::OpenExisting("$eventName-ready") + $finish = [Threading.EventWaitHandle]::OpenExisting("$eventName-finish") + $done = [Threading.EventWaitHandle]::OpenExisting("$eventName-done") + $failed = [Threading.EventWaitHandle]::OpenExisting("$eventName-failed") + $installer = [Diagnostics.Process]::GetProcessById($InstallerPid) + $installerExit = New-Object Threading.ManualResetEvent($false) + $installerExit.SafeWaitHandle = New-Object Microsoft.Win32.SafeHandles.SafeWaitHandle( + $installer.Handle, $false) + $restart = $false + $controller = $null + $serviceProcess = $null + $startMode = $null + try { + $service = Get-AppService + if ($service -and $service.State -ne 'Stopped') { + $controller = New-Object ServiceProcess.ServiceController('easytier-gui') + $restart = $RestoreService -ne 0 -and $service.State -ne 'Stop Pending' -and + $service.StartMode -ne 'Disabled' + if ($service.StartMode -ne 'Disabled') { + # Released versions can exit without reporting Stopped, + # which queues SCM recovery even after an explicit stop. + # Keep recovery from starting the old binary during setup. + $originalStartMode = $service.StartMode + $result = $service.ChangeStartMode('Disabled').ReturnValue + if ($result -ne 0) { throw "Could not disable service startup: $result" } + $startMode = if ($originalStartMode -eq 'Auto') { 'Automatic' } else { 'Manual' } + } + if ($service.ProcessId) { + $serviceProcess = [Diagnostics.Process]::GetProcessById($service.ProcessId) + # Acquire the handle before stopping, while the PID still exists. + $null = $serviceProcess.Handle + } + if ($controller.Status -eq 'StartPending') { + $controller.WaitForStatus('Running', [TimeSpan]::FromSeconds(30)) + } + if ($controller.Status -ne 'StopPending') { $controller.Stop() } + $controller.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(30)) + if ($serviceProcess -and !$serviceProcess.WaitForExit(30000)) { + throw 'The EasyTier service process did not exit within 30 seconds.' + } + } + foreach ($process in @(Get-AppProcesses)) { + [void][EasyTierInstallerProcess]::Matches($process.Id, $exePath, $true) + } + [void]$ready.Set() + [void][Threading.WaitHandle]::WaitAny(@($finish, $installerExit)) + } + finally { + # Also restore after cancellation/failure, provided the executable + # still exists. A completed uninstall must not restart the service. + try { + if ($startMode) { + $result = $service.ChangeStartMode($startMode).ReturnValue + if ($result -ne 0) { throw "Could not restore service startup: $result" } + } + if ($restart -and (Test-Path -LiteralPath $exePath)) { + $controller.Refresh() + if ($controller.Status -eq 'StopPending') { + $controller.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(30)) + } + if ($controller.Status -eq 'Stopped') { + $controller.Start() + $controller.WaitForStatus('Running', [TimeSpan]::FromSeconds(30)) + } + } + } + finally { + if ($controller) { $controller.Dispose() } + if ($serviceProcess) { $serviceProcess.Dispose() } + $installerExit.Dispose() + $installer.Dispose() + } + } + } +} +catch { + if ($Action -eq 'Run' -and $failed) { [void]$failed.Set() } + Write-Error $_ -ErrorAction Continue + exit 1 +} +finally { + if ($Action -eq 'Run' -and $done) { + # Completion events remain accessible to the original user even when + # UAC ran the helper under a different administrator account. + [void]$done.Set() + [void]$ready.Set() + $ready.Dispose() + $finish.Dispose() + $done.Dispose() + $failed.Dispose() + } +} diff --git a/easytier-gui/src-tauri/windows/test-installer-processes.ps1 b/easytier-gui/src-tauri/windows/test-installer-processes.ps1 new file mode 100644 index 00000000..f86efdc6 --- /dev/null +++ b/easytier-gui/src-tauri/windows/test-installer-processes.ps1 @@ -0,0 +1,169 @@ +# Run with Windows PowerShell as administrator on a machine without an +# installed easytier-gui service. All processes and files are test fixtures. +# Use -GuiOnly from an unelevated shell to test the path that needs no UAC. +param([switch]$GuiOnly) + +$ErrorActionPreference = 'Stop' +$helperScript = Join-Path $PSScriptRoot 'installer-processes.ps1' +$testDir = Join-Path $env:TEMP ("EasyTier installer test " + [guid]::NewGuid()) +$appDir = Join-Path $testDir 'app' +$otherDir = Join-Path $testDir 'other' +$exe = Join-Path $appDir 'easytier-gui.exe' +$helper = $null +$gui = $null +$other = $null +$parent = $null +$createdService = $false + +function Assert($condition, [string]$message) { + if (!$condition) { throw $message } +} + +function Prepare([int]$parentPid = $PID, [int]$restore = 1) { + $output = & "$PSHOME\powershell.exe" -NoProfile -ExecutionPolicy Bypass ` + -File $helperScript -InstallDir $appDir -InstallerPid $parentPid -RestoreService $restore + Assert ($LASTEXITCODE -eq 0) "Prepare failed: $output" + $helperPid = [int]($output | Select-Object -Last 1) + if ($helperPid) { + $process = [Diagnostics.Process]::GetProcessById($helperPid) + $null = $process.Handle + return $process + } +} + +function Finish($process) { + if (!$process) { return } + $finish = [Threading.EventWaitHandle]::OpenExisting("Local\EasyTierInstaller-$PID-finish") + $done = [Threading.EventWaitHandle]::OpenExisting("Local\EasyTierInstaller-$PID-done") + $failed = [Threading.EventWaitHandle]::OpenExisting("Local\EasyTierInstaller-$PID-failed") + try { + [void]$finish.Set() + Assert ($done.WaitOne(65000)) 'The helper did not signal completion.' + Assert (!$failed.WaitOne(0)) 'The helper reported a failure.' + } + finally { + $finish.Dispose() + $done.Dispose() + $failed.Dispose() + } + Assert ($process.WaitForExit(5000)) 'The helper did not finish.' + Assert ($process.ExitCode -eq 0) "The helper failed: $($process.ExitCode)" + $process.Dispose() +} + +function Start-TestService { + Start-Service easytier-gui + (Get-Service easytier-gui).WaitForStatus('Running', [TimeSpan]::FromSeconds(10)) +} + +if (Get-Service easytier-gui -ErrorAction SilentlyContinue) { + throw 'An easytier-gui service already exists; refusing to touch it.' +} +try { + [void](New-Item -ItemType Directory -Path $appDir, $otherDir) + Add-Type -ReferencedAssemblies System.ServiceProcess -OutputAssembly $exe -OutputType ConsoleApplication @' +using System; +using System.ServiceProcess; +using System.Threading; + +class FixtureService : ServiceBase +{ + static bool legacy; + public FixtureService() { ServiceName = "easytier-gui"; } + protected override void OnStop() + { + // Model released EasyTier versions that exit without reporting Stopped. + if (legacy) Environment.Exit(0); + } + static void Main(string[] args) + { + if (args.Length > 0) + { + legacy = args[0] == "--legacy"; + ServiceBase.Run(new FixtureService()); + } + else Thread.Sleep(Timeout.Infinite); + } +} +'@ + Copy-Item $exe (Join-Path $otherDir 'easytier-gui.exe') + + $helper = Prepare + Assert (!$helper) 'An idle installation must not start a helper.' + Write-Host 'PASS: no running process or service' + + $gui = Start-Process $exe -PassThru + $other = Start-Process (Join-Path $otherDir 'easytier-gui.exe') -PassThru + $helper = Prepare + Assert $gui.HasExited 'The GUI was not stopped.' + Assert (!$other.HasExited) 'A GUI from a different installation was stopped.' + Finish $helper + $helper = $null + Write-Host 'PASS: close GUI, preserve a different installation' + if ($GuiOnly) { return } + + New-Service -Name easytier-gui -BinaryPathName "`"$exe`" --legacy" -StartupType Automatic | Out-Null + $createdService = $true + & "$env:SystemRoot\System32\sc.exe" failure easytier-gui reset= 0 actions= restart/1000 | Out-Null + Assert ($LASTEXITCODE -eq 0) 'Could not configure service recovery.' + Start-TestService + $gui = Start-Process $exe -PassThru + $helper = Prepare + Start-Sleep -Milliseconds 1500 + Assert ((Get-Service easytier-gui).Status -eq 'Stopped') 'The service was not stopped.' + Assert ((Get-WmiObject Win32_Service -Filter "Name='easytier-gui'").StartMode -eq 'Disabled') 'Service recovery was not inhibited.' + Assert $gui.HasExited 'The GUI alongside the service was not stopped.' + Move-Item $exe "$exe.old" + Copy-Item "$exe.old" $exe + Finish $helper + $helper = $null + Assert ((Get-Service easytier-gui).Status -eq 'Running') 'The running service was not restored.' + Assert ((Get-WmiObject Win32_Service -Filter "Name='easytier-gui'").StartMode -eq 'Auto') 'Automatic startup was not restored.' + Write-Host 'PASS: stop legacy service despite failure recovery, replace executable, restore service' + + Set-Service easytier-gui -StartupType Disabled + Stop-Service easytier-gui + (Get-Service easytier-gui).WaitForStatus('Stopped', [TimeSpan]::FromSeconds(10)) + Start-Sleep -Milliseconds 1500 + Set-Service easytier-gui -StartupType Manual + $helper = Prepare + Assert (!$helper) 'A stopped service must not start a helper.' + $gui = Start-Process $exe -PassThru + $helper = Prepare + Finish $helper + $helper = $null + Assert ((Get-Service easytier-gui).Status -eq 'Stopped') 'A previously stopped service was started.' + Write-Host 'PASS: preserve stopped service state' + + Start-TestService + $parent = Start-Process "$PSHOME\powershell.exe" -ArgumentList '-NoProfile -Command Start-Sleep 300' -WindowStyle Hidden -PassThru + $helper = Prepare $parent.Id + Assert ((Get-Service easytier-gui).Status -eq 'Stopped') 'The service was not stopped before cancellation.' + $parent.Kill() + Assert ($helper.WaitForExit(35000)) 'The helper did not handle installer exit.' + Assert ($helper.ExitCode -eq 0) 'The helper failed while handling installer exit.' + $helper.Dispose() + $helper = $null + Assert ((Get-Service easytier-gui).Status -eq 'Running') 'Cancellation did not restore the service.' + Assert ((Get-WmiObject Win32_Service -Filter "Name='easytier-gui'").StartMode -eq 'Manual') 'Manual startup was not restored.' + Write-Host 'PASS: restore service when the installer exits' + + $helper = Prepare $PID 0 + Finish $helper + $helper = $null + Assert ((Get-Service easytier-gui).Status -eq 'Stopped') 'Uninstallation restarted the service.' + Write-Host 'PASS: do not restart the service after uninstall' +} +finally { + if ($helper -and !$helper.HasExited) { Finish $helper } + if ($createdService) { + Set-Service easytier-gui -StartupType Disabled + Stop-Service easytier-gui -ErrorAction SilentlyContinue + & "$env:SystemRoot\System32\sc.exe" delete easytier-gui | Out-Null + } + foreach ($process in @($gui, $other, $parent)) { + if ($process -and !$process.HasExited) { $process.Kill(); $process.WaitForExit() } + if ($process) { $process.Dispose() } + } + Remove-Item -LiteralPath $testDir -Recurse -Force +} diff --git a/easytier/src/core.rs b/easytier/src/core.rs index 6b33a31c..f58c96e6 100644 --- a/easytier/src/core.rs +++ b/easytier/src/core.rs @@ -1018,8 +1018,8 @@ fn win_service_event_loop( let normal_status = ServiceStatus { service_type: ServiceType::OWN_PROCESS, - current_state: ServiceState::Running, - controls_accepted: ServiceControlAccept::STOP, + current_state: ServiceState::Stopped, + controls_accepted: ServiceControlAccept::empty(), exit_code: ServiceExitCode::Win32(0), checkpoint: 0, wait_hint: Duration::default(),