diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55ace603..fef74826 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -172,6 +172,7 @@ jobs: .\deploy\Test-DeploymentTemplates.ps1 .\deploy\Test-ArtifactSecurity.ps1 .\deploy\Test-SetupAdapter.ps1 + .\deploy\Test-LocalServerHttp.ps1 .\deploy\Test-FailoverRecovery.ps1 .\deploy\Test-MachinePath.ps1 .\deploy\Test-SwitcherConfig.ps1 @@ -198,6 +199,7 @@ jobs: run: | .\deploy\Test-ArtifactSecurity.ps1 .\deploy\Test-SetupAdapter.ps1 + .\deploy\Test-LocalServerHttp.ps1 .\deploy\Test-FailoverRecovery.ps1 .\deploy\Test-MachinePath.ps1 .\deploy\Test-SwitcherConfig.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a760ccc..4bc325ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,22 @@ Planned version: **1.5.4**. - The MCP server rejects broken nodes and edges before it merges a workflow change (#726). - All MCP requests, including dependency queries, wait until the server connection is ready (#727). +- **Setup and uninstall** + - A server uninstall whose cleanup fails stops before the "Apps & features" entry is removed, + keeps the installation marker and the deploy scripts and exits with a non-zero code, so the + same uninstall can be run again. A failed service deletion stops it before any file is removed. + The desktop uninstaller stops the same way and keeps NodePilot installed (#714). + - A desktop update keeps a custom data path and custom service names (#709). + - When a desktop update fails after its services were removed, the rollback registers both + services again with their previous settings and folder permissions, so the old version starts + again (#710). + - Deleting the data on uninstall (`-PurgeData`, `/PURGEDATA=1`) also works on German Windows. If + ownership of an item cannot be taken, nothing under the data directory is deleted (#731). + - When a reinstall that changes the service account fails, the rollback gives the previous + account its access to the install directory back, so the restored service starts (#735). + - The gMSA repair command suggested by the setup check adds this host to the existing principals + instead of replacing them. The deploy README no longer suggests delegation on every target + (#713). - **Monitoring** - The observability summary queries the counter names the exporter actually publishes, so executions per minute, success rate and failures show values again. The Grafana CPU panels use @@ -121,6 +137,13 @@ Planned version: **1.5.4**. - The CLI, the MCP server and the Python admin scripts under `scripts/` no longer follow redirects with the login token, so the token is never sent to another host (#711). - Logging out with the CLI also ends the MCP server's session right away (#712). +- The server setup accepts only the configured certificate, by thumbprint, for its health probe and + the first login, does not follow redirects there and no longer turns off certificate validation + for the whole process. +- The desktop setup hands the first-login token to the account that started setup, also under + Remote Desktop or a switched user, and to the user signed in at the console when software + distribution runs setup as SYSTEM. If the starting account cannot be determined, provisioning stops + instead of handing the token to another profile (#732). - MCP alerting output shows webhook targets only as scheme, host and port, because the rest of the URL can contain tokens (#730). - GitHub tokens in step output are masked except for their prefix such as `ghp_`; before, the whole diff --git a/deploy/ArtifactSecurity.ps1 b/deploy/ArtifactSecurity.ps1 index 6ad35e5e..26433606 100644 --- a/deploy/ArtifactSecurity.ps1 +++ b/deploy/ArtifactSecurity.ps1 @@ -697,6 +697,35 @@ function Resolve-SidLabel { } } +function Get-NodePilotDirectoryAccessSnapshot { + <# + The DACL of an existing directory, including its protection flag, so a rollback can give a + previous service identity back exactly the access it had. $null when the directory is absent. + #> + param([Parameter(Mandatory)][string]$Path) + if (-not (Test-Path -LiteralPath $Path -PathType Container)) { return $null } + $access = (Get-Acl -LiteralPath $Path).GetSecurityDescriptorSddlForm( + [System.Security.AccessControl.AccessControlSections]::Access) + return [pscustomobject]@{ Path = $Path; Access = $access } +} + +function Restore-NodePilotDirectoryAccessSnapshot { + <# + Writes a DACL captured by Get-NodePilotDirectoryAccessSnapshot back and verifies it. Inheriting + children pick it up, so it runs before restored binaries are copied in and started. + #> + param([Parameter(Mandatory)][object]$Snapshot) + $acl = Get-Acl -LiteralPath $Snapshot.Path + $acl.SetSecurityDescriptorSddlForm($Snapshot.Access, + [System.Security.AccessControl.AccessControlSections]::Access) + Set-Acl -LiteralPath $Snapshot.Path -AclObject $acl + $restored = (Get-Acl -LiteralPath $Snapshot.Path).GetSecurityDescriptorSddlForm( + [System.Security.AccessControl.AccessControlSections]::Access) + if ($restored -ne $Snapshot.Access) { + throw "The previous access rules on '$($Snapshot.Path)' could not be restored." + } +} + function Assert-ServiceDirectoryAclUsable { <# Verify, repair once, verify again, then fail instead of handing the service a directory it diff --git a/deploy/Build-Artifact.ps1 b/deploy/Build-Artifact.ps1 index 866fd3a8..5c847978 100644 --- a/deploy/Build-Artifact.ps1 +++ b/deploy/Build-Artifact.ps1 @@ -611,6 +611,7 @@ $deployScriptFiles = @( 'Uninstall-NodePilot.ps1' # entry point 'ArtifactSecurity.ps1' # dot-sourced by install + update 'DeploymentProgress.ps1' + 'LocalServerHttp.ps1' 'Preflight.ps1' # dot-sourced by install 'ServiceControl.ps1' # dot-sourced by install + update 'MachinePath.ps1' # dot-sourced by install + update + uninstall diff --git a/deploy/Install-NodePilot.ps1 b/deploy/Install-NodePilot.ps1 index 7d7b6194..691d0449 100644 --- a/deploy/Install-NodePilot.ps1 +++ b/deploy/Install-NodePilot.ps1 @@ -220,6 +220,7 @@ if (-not (Test-Path -LiteralPath $ArtifactSecurityScript -PathType Leaf)) { } . $ArtifactSecurityScript . (Join-Path $PSScriptRoot 'DeploymentProgress.ps1') +. (Join-Path $PSScriptRoot 'LocalServerHttp.ps1') # Readiness checks live in their own file because the setup wizard runs the same set behind a # re-check button. Because of that shared use, nothing in Preflight.ps1 may mutate anything. @@ -250,6 +251,8 @@ $postgresRootCertificatePreviouslyExisted = $false $postgresRootCertificateTouched = $false $installMutationStarted = $false $dataAclApplied = $false +$previousInstallRootAccess = $null +$installRootAclApplied = $false try { if (-not (Test-Path -LiteralPath $ArtifactPath -PathType Leaf)) { throw "Artifact not found: $ArtifactPath" } $ArtifactPath = (Resolve-Path -LiteralPath $ArtifactPath).Path @@ -585,47 +588,19 @@ function Enable-SqlReadCommittedSnapshot { function Invoke-HealthProbe { param( [Parameter(Mandatory)][string]$Url, + [Parameter(Mandatory)][string]$CertificateThumbprint, [int]$TimeoutSeconds = 60 ) - # Trust any cert on localhost - the installer may run before a public hostname resolves, - # and the only caller is the installer itself. - if ($PSVersionTable.PSVersion.Major -ge 6) { - $deadline = (Get-Date).AddSeconds($TimeoutSeconds) - while ((Get-Date) -lt $deadline) { - try { - $r = Invoke-WebRequest -Uri $Url -UseBasicParsing -SkipCertificateCheck -TimeoutSec 5 - if ($r.StatusCode -eq 200) { return $true } - } catch { Start-Sleep -Seconds 2 } - } - return $false - } else { - if (-not ('TrustAllCerts' -as [type])) { - Add-Type @" -using System.Net; using System.Security.Cryptography.X509Certificates; -public class TrustAllCerts : ICertificatePolicy { - public bool CheckValidationResult(ServicePoint s, X509Certificate c, WebRequest r, int p) { return true; } -} -"@ - } - $previousCertificatePolicy = [System.Net.ServicePointManager]::CertificatePolicy - $previousSecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline) { try { - [System.Net.ServicePointManager]::CertificatePolicy = New-Object TrustAllCerts - [System.Net.ServicePointManager]::SecurityProtocol = 'Tls12, Tls13' - $deadline = (Get-Date).AddSeconds($TimeoutSeconds) - while ((Get-Date) -lt $deadline) { - try { - $r = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 5 - if ($r.StatusCode -eq 200) { return $true } - } catch { Start-Sleep -Seconds 2 } - } - return $false - } - finally { - [System.Net.ServicePointManager]::CertificatePolicy = $previousCertificatePolicy - [System.Net.ServicePointManager]::SecurityProtocol = $previousSecurityProtocol + $response = Invoke-NodePilotLocalHttps -Uri $Url -CertificateThumbprint $CertificateThumbprint -TimeoutSeconds 5 + if ($response.StatusCode -eq 200) { return $true } } + catch { } + Start-Sleep -Seconds 2 } + return $false } function Grant-LogOnAsServiceRight { @@ -1008,6 +983,10 @@ if (Test-Path -LiteralPath $previousSettingsPath -PathType Leaf) { $previousSettingsBytes = [IO.File]::ReadAllBytes($previousSettingsPath) } +# The install root grants the service identity execute access. A failed identity change must give +# the previous identity that access back before its restored binaries start. +$previousInstallRootAccess = Get-NodePilotDirectoryAccessSnapshot -Path $InstallPath + if ((Test-Path -LiteralPath $InstallPath -PathType Container) -and @(Get-ChildItem -LiteralPath $InstallPath -Force).Count -gt 0) { Write-Step 'Backing up the existing installation' @@ -1073,6 +1052,7 @@ New-Item -ItemType Directory -Path (Join-Path $DataPath 'logs') -Force | Out-Nul # The binaries are read-only for the service: it executes them, it never rewrites them. # Inheritance is dropped here too, so a permissive parent (a custom root on D:\ inherits # BUILTIN\Users:(M) from the volume) cannot reach the files the service starts from. +$installRootAclApplied = $true Set-DirectoryAclForService -Path $InstallPath -ServiceAccount $AclIdentity ` -ReadOnlyForService -SkipServiceRule:$isLocalSystem Set-DirectoryAclForService -Path $DataPath -ServiceAccount $AclIdentity -SkipServiceRule:$isLocalSystem @@ -1438,7 +1418,7 @@ try { # spamming "false-positive failed" reports for installs that ARE actually succeeding. $probeUrl = "https://localhost:$HttpsPort/healthz/ready" Write-Info " Probing $probeUrl (up to 180s)..." -if (-not (Invoke-HealthProbe -Url $probeUrl -TimeoutSeconds 180)) { +if (-not (Invoke-HealthProbe -Url $probeUrl -CertificateThumbprint $NormalizedThumbprint -TimeoutSeconds 180)) { Show-NodePilotServiceStartDiagnostics -DataPath $DataPath throw "Service did not report /healthz/ready within 180s. Check the NodePilot and Windows Application logs for the startup error." } @@ -1670,6 +1650,12 @@ catch { Remove-ExistingService -Name $ServiceName -InstallPath $InstallPath ` -FoundMessage "Removing the service '$ServiceName' that this run registered." } + # Before the binaries are copied back: restored files inherit this DACL, and the + # previous service identity has to be able to execute them. + if ($installRootAclApplied -and $previousInstallRootAccess -and + (Test-Path -LiteralPath $InstallPath -PathType Container)) { + Restore-NodePilotDirectoryAccessSnapshot -Snapshot $previousInstallRootAccess + } if ($installRollback) { # Refuses a backup whose DACL or files changed since this run made it, before the # install directory is wiped. diff --git a/deploy/Invoke-NodePilotSetup.ps1 b/deploy/Invoke-NodePilotSetup.ps1 index cc710d25..e302135d 100644 --- a/deploy/Invoke-NodePilotSetup.ps1 +++ b/deploy/Invoke-NodePilotSetup.ps1 @@ -88,6 +88,7 @@ if ([string]::IsNullOrWhiteSpace($PayloadRoot)) { . (Join-Path $scriptDirectory 'SetupContract.ps1') . (Join-Path $scriptDirectory 'DeploymentProgress.ps1') +. (Join-Path $scriptDirectory 'LocalServerHttp.ps1') . (Join-Path $scriptDirectory 'SetupProvisioning.ps1') $result = New-NodePilotResultBuffer @@ -135,6 +136,7 @@ function Invoke-NodePilotFirstLogin { Justification = 'Sent as a JSON body and written to the credential file; plaintext throughout by design.')] param( [Parameter(Mandatory)][int]$HttpsPort, + [Parameter(Mandatory)][string]$CertificateThumbprint, [Parameter(Mandatory)][string]$Username, [Parameter(Mandatory)][string]$Password, [Parameter(Mandatory)][string]$Token @@ -144,31 +146,11 @@ function Invoke-NodePilotFirstLogin { $body = @{ username = $Username; password = $Password } | ConvertTo-Json -Compress $headers = @{ 'X-Setup-Token' = $Token } - # The certificate on a loopback call names the public hostname, not localhost, so validation - # is relaxed for this request. The previous policy is restored in the finally block so nothing - # else in this process inherits it. - $previousPolicy = $null - $policyChanged = $false try { - if ($PSVersionTable.PSVersion.Major -lt 6) { - if (-not ('TrustAllCertsBootstrap' -as [type])) { - Add-Type @" -using System.Net; using System.Security.Cryptography.X509Certificates; -public class TrustAllCertsBootstrap : ICertificatePolicy { - public bool CheckValidationResult(ServicePoint s, X509Certificate c, WebRequest r, int p) { return true; } -} -"@ - } - $previousPolicy = [System.Net.ServicePointManager]::CertificatePolicy - [System.Net.ServicePointManager]::CertificatePolicy = New-Object TrustAllCertsBootstrap - [System.Net.ServicePointManager]::SecurityProtocol = 'Tls12, Tls13' - $policyChanged = $true - $response = Invoke-WebRequest -Uri $uri -Method POST -Body $body -Headers $headers ` - -ContentType 'application/json' -UseBasicParsing -TimeoutSec 30 - } - else { - $response = Invoke-WebRequest -Uri $uri -Method POST -Body $body -Headers $headers ` - -ContentType 'application/json' -UseBasicParsing -TimeoutSec 30 -SkipCertificateCheck + $response = Invoke-NodePilotLocalHttps -Uri $uri -CertificateThumbprint $CertificateThumbprint ` + -Method POST -Body $body -Headers $headers -TimeoutSeconds 30 + if ($response.StatusCode -lt 200 -or $response.StatusCode -ge 300) { + throw "Bootstrap login returned HTTP $($response.StatusCode); redirects are not accepted." } return [pscustomobject]@{ Status = 'Created'; Detail = "HTTP $($response.StatusCode)" } } @@ -186,9 +168,6 @@ public class TrustAllCertsBootstrap : ICertificatePolicy { } catch { } return [pscustomobject]@{ Status = 'Failed'; Detail = $detail } } - finally { - if ($policyChanged) { [System.Net.ServicePointManager]::CertificatePolicy = $previousPolicy } - } } function Get-NodePilotServiceCrashReason { @@ -783,6 +762,7 @@ function Invoke-SetupInstall { $bootstrapUser = [string]$answers['bootstrap.adminUsername'] $bootstrapPassword = New-NodePilotBootstrapPassword $outcome = Invoke-NodePilotFirstLogin -HttpsPort ([int]$answers['network.httpsPort']) ` + -CertificateThumbprint $splat['CertThumbprint'] ` -Username $bootstrapUser -Password $bootstrapPassword -Token $token Set-NodePilotResult -Buffer $result -Section 'bootstrap' -Name 'status' -Value $outcome.Status diff --git a/deploy/LocalServerHttp.ps1 b/deploy/LocalServerHttp.ps1 new file mode 100644 index 00000000..5633e88d --- /dev/null +++ b/deploy/LocalServerHttp.ps1 @@ -0,0 +1,73 @@ +#requires -Version 5.1 +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version 3.0 + +if (-not ('NodePilot.Deployment.LocalServerHttp' -as [type])) { + # The Framework-compatible HTTP API is obsolete on PowerShell 7, but its per-request + # certificate callback is supported on both runtimes. + Add-Type -IgnoreWarnings -TypeDefinition @' +using System; +using System.Net; +using System.Text.RegularExpressions; + +namespace NodePilot.Deployment +{ + public static class LocalServerHttp + { + public static HttpWebRequest CreateRequest(Uri uri, string certificateThumbprint, int timeoutMilliseconds) + { + if (!uri.IsAbsoluteUri || uri.Scheme != Uri.UriSchemeHttps || !uri.IsLoopback || uri.UserInfo.Length != 0) + throw new ArgumentException("Setup requests require a loopback HTTPS URL.", "uri"); + if (certificateThumbprint == null || !Regex.IsMatch(certificateThumbprint, "\\A[0-9a-fA-F]{40}\\z")) + throw new ArgumentException("The configured server certificate thumbprint is required.", "certificateThumbprint"); + if (timeoutMilliseconds <= 0) + throw new ArgumentOutOfRangeException("timeoutMilliseconds"); + + var request = (HttpWebRequest)WebRequest.Create(uri); + request.AllowAutoRedirect = false; + request.Proxy = null; + request.Timeout = timeoutMilliseconds; + request.ReadWriteTimeout = timeoutMilliseconds; + // Preflight validates the selected certificate. Loopback uses that exact identity, + // even when the certificate names the public hostname or is explicitly self-signed. + request.ServerCertificateValidationCallback = (sender, certificate, chain, errors) => + certificate != null && String.Equals(certificate.GetCertHashString(), certificateThumbprint, StringComparison.OrdinalIgnoreCase); + return request; + } + } +} +'@ +} + +function Invoke-NodePilotLocalHttps { + [CmdletBinding()] + param( + [Parameter(Mandatory)][uri]$Uri, + [Parameter(Mandatory)][string]$CertificateThumbprint, + [ValidateSet('GET', 'POST')][string]$Method = 'GET', + [string]$Body, + [hashtable]$Headers = @{}, + [ValidateRange(1, 300)][int]$TimeoutSeconds = 30 + ) + + $request = [NodePilot.Deployment.LocalServerHttp]::CreateRequest($Uri, $CertificateThumbprint, $TimeoutSeconds * 1000) + $request.Method = $Method + foreach ($key in $Headers.Keys) { $request.Headers.Add([string]$key, [string]$Headers[$key]) } + if ($Method -eq 'POST') { + $request.ContentType = 'application/json' + $bytes = [Text.Encoding]::UTF8.GetBytes($Body) + try { + $request.ContentLength = $bytes.Length + $stream = $request.GetRequestStream() + try { $stream.Write($bytes, 0, $bytes.Length) } + finally { $stream.Dispose() } + } + finally { [Array]::Clear($bytes, 0, $bytes.Length) } + } + $response = $request.GetResponse() + try { return [pscustomobject]@{ StatusCode = [int]$response.StatusCode } } + finally { $response.Dispose() } +} diff --git a/deploy/Preflight.ps1 b/deploy/Preflight.ps1 index d4c36f56..ef252329 100644 --- a/deploy/Preflight.ps1 +++ b/deploy/Preflight.ps1 @@ -1078,6 +1078,18 @@ function Get-NodePilotGmsaRetrievalState { catch { return & $unknown $_.Exception.Message } } +function Get-NodePilotGmsaRetrievalRemediation { + param([Parameter(Mandatory)][string]$Sam, [Parameter(Mandatory)][string]$ComputerName) + $accountLiteral = $Sam.Replace("'", "''") + $computerLiteral = $ComputerName.Replace("'", "''") + @( + ('$account = Get-ADServiceAccount -Identity ''{0}'' -Properties PrincipalsAllowedToRetrieveManagedPassword -ErrorAction Stop' -f $accountLiteral) + '$principals = @($account.PrincipalsAllowedToRetrieveManagedPassword | ForEach-Object { if ($_ -is [string]) { $_ } else { $_.DistinguishedName } })' + ('$principals += (Get-ADComputer -Identity ''{0}'' -ErrorAction Stop).DistinguishedName' -f $computerLiteral) + 'Set-ADServiceAccount -Identity $account -PrincipalsAllowedToRetrieveManagedPassword @($principals | Sort-Object -Unique) -ErrorAction Stop' + ) -join "`r`n" +} + function New-NodePilotGmsaRetrievalResult { <# The verdict for the LDAP answer, separated from the directory call so every branch is @@ -1110,8 +1122,7 @@ function New-NodePilotGmsaRetrievalResult { -RemediationHint ('Allow this host to retrieve the password, then RESTART it - a computer ' + "account's group membership only reaches the machine through a new " + 'Kerberos ticket. As Domain Admin:') ` - -Remediation ("Set-ADServiceAccount -Identity $Sam -PrincipalsAllowedToRetrieveManagedPassword " + - "(Get-ADComputer '$env:COMPUTERNAME')") ` + -Remediation (Get-NodePilotGmsaRetrievalRemediation -Sam $Sam -ComputerName $env:COMPUTERNAME) ` -AbortMessage "$env:COMPUTERNAME may not retrieve the managed password of '$Sam'." } New-NodePilotPreflightResult -Id 'gmsa' -Title $Title -Status 'Pass' ` diff --git a/deploy/README.md b/deploy/README.md index 678a60d0..a632a0af 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -257,24 +257,19 @@ The shipped default profile leaves all external providers disabled and sets loca `BreakGlassOnly`. SCIM tokens are rotated without interruption by keeping the old value briefly under `Authentication:Scim:PreviousBearerToken` and deleting it after the IdP switch. -### 5. Kerberos constrained delegation (for WinRM targets) +### 5. Authorize the WinRM execution identity -> This section concerns the **integrated (credential-less) WinRM path only**. If NodePilot uses credentials stored per machine, no delegation is needed — the service's identity is then irrelevant. Under **LocalSystem** the identity to delegate is the **computer account** of the NodePilot server (`Get-ADComputer `) instead of the gMSA — otherwise the procedure is identical. +The integrated (credential-less) WinRM path authenticates directly as the NodePilot +process identity: the gMSA, or the host computer account when running as LocalSystem. +Grant that identity permission to the target's WinRM endpoint and the resources used +by the workflow. For LocalSystem HA installations, authorize each NodePilot host. +This first hop needs no resource-based constrained delegation and does not impersonate +the browser user. Stored per-machine credentials use their own account permissions. -For the gMSA to reach target machines via implicit Kerberos, **resource-based constrained delegation** -has to be configured on every target server: - -```powershell -# On a DC: -$gmsa = Get-ADServiceAccount -Identity svc-nodepilot -foreach ($target in $targetHosts) { - $computer = Get-ADComputer -Identity $target - Set-ADComputer -Identity $target ` - -PrincipalsAllowedToDelegateToAccount ( - (Get-ADComputer $target).PrincipalsAllowedToDelegateToAccount + $gmsa - ) -} -``` +Delegated-user or second-hop access is a separate topology requiring an explicit design; +do not enable delegation on every target as part of this setup. Existing AD retrieval +or delegation grants must be preserved when an administrator changes them. The setup's +gMSA repair snippet reads existing password-retrieval principals before adding this host. WinRM endpoint on the targets (if not already active): @@ -562,8 +557,14 @@ in the lab: 31 seconds). If the service is deleted in that window, the process i longer reachable through the SCM — and the subsequent deletion pulls the DLLs out from under a running application. The script therefore waits up to `-ProcessExitTimeoutSeconds` (default 90) for processes from `InstallPath` and otherwise aborts **with the service still registered**, so that a supported way -to stop it remains. If files are left behind afterwards (virus scanner, search indexer), the script -still runs to completion, names the leftovers together with the holding process, and exits with code 1. +to stop it remains. A failed `sc.exe delete` stops it the same way, before any file is removed. If files +are left behind afterwards (virus scanner, search indexer), the script still runs to completion, names +the leftovers together with the holding process, and exits with code 1. + +**A failed uninstall can be repeated.** The installation marker and `\deploy\` (which holds +this script) are removed only after everything else is gone. Any non-zero exit makes the GUI and +`/SILENT` uninstaller abort before Inno removes a file or the "Apps & features" entry, and the +uninstaller itself exits non-zero; fix the reported cause and uninstall again. Two things are **deliberately** left in place, because both can be shared with something else on the host and blindly revoking them would break exactly that: the gMSA's "Log on as a service" right and the @@ -573,9 +574,11 @@ uninstall whether it is still there. **`-PurgeData` takes ownership first.** The installer writes `jwt-secret.key` and `admin-setup.token` owner-only to the *service account*; otherwise not even an administrator can delete them and the run -aborts halfway. The script therefore calls `takeown` and `icacls` with the well-known SID -`S-1-5-32-544` — **without** `(OI)(CI)`: those are container inheritance flags that are silently -discarded on a leaf file while `icacls` still reports success. +aborts halfway. The script therefore takes ownership item by item with `takeown /A` (the Administrators +group, without the localized confirmation answer `takeown /R /D` expects) and grants the well-known SID +`S-1-5-32-544` with `icacls` — **without** `(OI)(CI)`: those are container inheritance flags that are +silently discarded on a leaf file while `icacls` still reports success. If either tool fails for any +item, nothing under the data directory is deleted and the run exits with code 1. ## Backup & disaster recovery diff --git a/deploy/Test-DeploymentTemplates.ps1 b/deploy/Test-DeploymentTemplates.ps1 index 9cefb1fc..0d558fa6 100644 --- a/deploy/Test-DeploymentTemplates.ps1 +++ b/deploy/Test-DeploymentTemplates.ps1 @@ -1023,6 +1023,54 @@ Assert-TextMatches -Name 'a failed first install is narrated as an undo' ` # wording written for an upgrade. Assert-TextMatches -Name 'the rollback names the service it registered itself' ` -Text $rollbackBlock -Pattern "FoundMessage `"Removing the service '\`$ServiceName' that this run registered\.`"" +# The install root carries the execute grant for the service identity. A failed gMSA A-to-B +# reinstall restored A's binaries under B's DACL, so A could not start them. +Assert-TextMatches -Name 'the installer snapshots the install root DACL before changing it' ` + -Text $installerScript -Pattern '(?s)\$previousInstallRootAccess = Get-NodePilotDirectoryAccessSnapshot -Path \$InstallPath.*?\$installRootAclApplied = \$true\s*\r?\nSet-DirectoryAclForService -Path \$InstallPath' +Assert-TextMatches -Name 'the rollback restores install root access before the binaries and the service' ` + -Text $rollbackBlock -Pattern '(?s)Restore-NodePilotDirectoryAccessSnapshot -Snapshot \$previousInstallRootAccess.*?Restore-NodePilotBinaryBackup -Backup \$installRollback.*?Restore-ServiceRollbackSnapshot -Snapshot \$previousService' +& { + . $ArtifactSecurityPath + $root = Join-Path ([IO.Path]::GetTempPath()) ('nodepilot-installroot-' + [Guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $root | Out-Null + # LocalService and NetworkService stand in for gMSA A and gMSA B. + $identityA = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-19') + $identityB = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-20') + $me = [System.Security.Principal.WindowsIdentity]::GetCurrent().User + $setRootAcl = { + param($ServiceIdentity) + $acl = New-Object System.Security.AccessControl.DirectorySecurity + $acl.SetAccessRuleProtection($true, $false) + foreach ($grant in @(@($me, 'FullControl'), @($ServiceIdentity, 'ReadAndExecute'))) { + $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule( + $grant[0], $grant[1], 'ContainerInherit,ObjectInherit', 'None', 'Allow'))) + } + Set-Acl -LiteralPath $root -AclObject $acl + } + $grantsTo = { + param($Path, $Sid) + @((Get-Acl -LiteralPath $Path).Access | Where-Object { + $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]) -eq $Sid -and + ($_.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::ExecuteFile) + }).Count -gt 0 + } + try { + & $setRootAcl $identityA + $snapshot = Get-NodePilotDirectoryAccessSnapshot -Path $root + & $setRootAcl $identityB + Restore-NodePilotDirectoryAccessSnapshot -Snapshot $snapshot + [IO.File]::WriteAllText((Join-Path $root 'NodePilot.Api.exe'), 'restored binary') + $restored = Join-Path $root 'NodePilot.Api.exe' + if (-not (& $grantsTo $restored $identityA) -or (& $grantsTo $restored $identityB)) { + throw 'Deployment template check failed: a restored binary must be executable by the previous identity only.' + } + if ($null -ne (Get-NodePilotDirectoryAccessSnapshot -Path (Join-Path $root 'absent'))) { + throw 'Deployment template check failed: an absent install root has no access snapshot.' + } + } + finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + Write-Host ' OK a failed identity change gives the previous identity its install root access back' -ForegroundColor DarkGray +} Assert-TextMatches -Name 'the rollback hands the secrets back to the previous identity too' ` -Text $rollbackBlock -Pattern '(?s)Set-NodePilotServiceOwnedFileAcl[\s\S]{0,200}\$previousAclIdentity' # Guarded, so a failure before the ACL was ever touched does not rewrite a directory this run had @@ -1203,17 +1251,105 @@ if ($purgeBlockStart -lt 0) { } $purgeBlock = $uninstallScript.Substring($purgeBlockStart) Assert-TextMatches -Name 'purging takes ownership before deleting owner-only files' ` - -Text $purgeBlock -Pattern 'takeown\.exe' -# "BUILTIN\Administrators" does not resolve on a non-English Windows. + -Text $purgeBlock -Pattern '(?s)Grant-NodePilotPurgeAccess -Path \$DataPath.*?Remove-Item -LiteralPath \$DataPath' +$purgeAccessStart = $uninstallScript.IndexOf('function Grant-NodePilotPurgeAccess') +if ($purgeAccessStart -lt 0) { + throw 'Deployment template check failed: could not locate the purge ownership helper in the uninstaller.' +} +$purgeAccess = $uninstallScript.Substring($purgeAccessStart, + $uninstallScript.IndexOf("`nWrite-Step", $purgeAccessStart) - $purgeAccessStart) +# "BUILTIN\Administrators" does not resolve on a non-English Windows, and takeown /D expects a +# localized answer (Y on English, J on German Windows). /A names the group without either. +Assert-TextMatches -Name 'ownership goes to Administrators without a localized name' ` + -Text $purgeAccess -Pattern 'takeown\.exe /F \$ItemPath /A' +Assert-TextDoesNotMatch -Name 'takeown is never given a localized confirmation answer' ` + -Text $uninstallScript -Pattern 'takeown[^\r\n]*/D\b' Assert-TextMatches -Name 'the ownership grant uses the well-known SID, not a localised group name' ` - -Text $purgeBlock -Pattern 'S-1-5-32-544' + -Text $purgeAccess -Pattern 'S-1-5-32-544' Assert-TextDoesNotMatch -Name 'the ownership grant must not use a localised group name' ` - -Text $purgeBlock -Pattern 'BUILTIN\\Administrators' + -Text $purgeAccess -Pattern 'BUILTIN\\Administrators' # (OI) and (CI) are CONTAINER inheritance flags. Applied to a leaf file, icacls drops them, reports # "Successfully processed 1 files" and adds no ACE whatsoever - so the grant looks like it worked # and the file stays undeletable. Measured on the lab host against jwt-secret.key. Assert-TextDoesNotMatch -Name 'the grant must not carry container inheritance flags' ` - -Text $purgeBlock -Pattern 'icacls[^\r\n]*\(OI\)' + -Text $purgeAccess -Pattern 'icacls[^\r\n]*\(OI\)' + +# A failed native step leaves the data in place and fails the uninstall instead of deleting part +# of a tree it could not open. +Assert-TextMatches -Name 'ownership failures stop the purge before anything is deleted' ` + -Text $purgeBlock -Pattern '(?s)if \(\$ownershipFailures\.Count -gt 0\) \{.*?\$dataPathRemaining = \$true.*?\}\s*else \{\s*try \{\s*Remove-Item -LiteralPath \$DataPath' + +# The helper against a real tree with a folder this account cannot list until its "ownership" is +# taken, which is what an owner-only directory looks like to an administrator. +& { + $uninstallErrors = $null + $uninstallAst = [System.Management.Automation.Language.Parser]::ParseFile( + $UninstallScriptPath, [ref]$null, [ref]$uninstallErrors) + if ($uninstallErrors.Count) { throw 'Uninstaller contains PowerShell syntax errors.' } + $helper = $uninstallAst.Find({ param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq 'Grant-NodePilotPurgeAccess' + }, $false) + . ([scriptblock]::Create($helper.Extent.Text)) + $fixture = Join-Path ([IO.Path]::GetTempPath()) ('nodepilot-purge-' + [Guid]::NewGuid().ToString('N')) + $locked = Join-Path $fixture 'keys' + New-Item -ItemType Directory -Path (Join-Path $locked 'inner') -Force | Out-Null + foreach ($file in @('jwt-secret.key', 'keys\key-1.xml', 'keys\inner\key-2.xml')) { + [IO.File]::WriteAllText((Join-Path $fixture $file), 'fixture') + } + $me = [System.Security.Principal.WindowsIdentity]::GetCurrent().User + $deny = New-Object System.Security.AccessControl.FileSystemAccessRule($me, 'ListDirectory', 'Deny') + $lockedAcl = Get-Acl -LiteralPath $locked + $lockedAcl.AddAccessRule($deny) + Set-Acl -LiteralPath $locked -AclObject $lockedAcl + $calls = New-Object 'System.Collections.Generic.List[string]' + $unlock = { + param($ItemPath) + $calls.Add("own:$ItemPath") + if ($ItemPath -eq $locked) { + $acl = Get-Acl -LiteralPath $locked + [void]$acl.RemoveAccessRule($deny) + Set-Acl -LiteralPath $locked -AclObject $acl + } + 0 + } + try { + $failures = @(Grant-NodePilotPurgeAccess -Path $fixture -TakeOwnership $unlock ` + -GrantAdministrators { param($ItemPath) $calls.Add("grant:$ItemPath"); 0 }) + $expected = @($fixture, (Join-Path $fixture 'jwt-secret.key'), $locked, (Join-Path $locked 'inner'), + (Join-Path $locked 'key-1.xml'), (Join-Path $locked 'inner\key-2.xml')) + $missing = @($expected | Where-Object { + $calls.IndexOf("own:$_") -lt 0 -or $calls.IndexOf("grant:$_") -lt $calls.IndexOf("own:$_") }) + if ($failures.Count -ne 0 -or $missing.Count -ne 0) { + throw "Deployment template check failed: purge ownership must reach every item behind an unlistable folder (missing: $($missing -join ', '))." + } + + $calls.Clear() + $failures = @(Grant-NodePilotPurgeAccess -Path $fixture ` + -TakeOwnership { param($ItemPath) $calls.Add("own:$ItemPath"); if ($ItemPath -like '*jwt-secret.key') { 1 } else { 0 } } ` + -GrantAdministrators { param($ItemPath) $calls.Add("grant:$ItemPath"); 0 }) + if ($failures.Count -ne 1 -or $failures[0] -notmatch 'takeown exited with 1' -or + $calls.Contains("grant:$(Join-Path $fixture 'jwt-secret.key')")) { + throw 'Deployment template check failed: a failed takeown must be reported and must skip the grant.' + } + } + finally { + $acl = Get-Acl -LiteralPath $locked + [void]$acl.RemoveAccessRule($deny) + Set-Acl -LiteralPath $locked -AclObject $acl + Remove-Item -LiteralPath $fixture -Recurse -Force -ErrorAction SilentlyContinue + } + Write-Host ' OK purge ownership is taken item by item without a localized answer and reports native failures' -ForegroundColor DarkGray +} + +# A failed cleanup must stay repeatable: the marker and deploy\ are removed only after everything +# else is gone, and both setup uninstallers abort before Inno removes anything. +Assert-TextMatches -Name 'the uninstaller keeps the marker and its scripts while anything is left' ` + -Text $uninstallScript -Pattern '(?s)if \(\$installPathRemaining -or \$backupsRemaining -or \$dataPathRemaining\) \{.*?exit 1\s*\}.*?Remove-Item -LiteralPath \$recoveryHelperPath.*?Remove-Item -LiteralPath \$markerPath' +Assert-TextMatches -Name 'the uninstaller defers deploy\ while it empties the install directory' ` + -Text $uninstallScript -Pattern "\`$entry\.Name -eq \`$recoveryHelperName\) \{ continue \}" +Assert-TextMatches -Name 'a failed service deletion stops the uninstall before files are removed' ` + -Text $uninstallScript -Pattern '(?s)\$deleteExit -ne 0 -and \$deleteExit -ne 1072\) \{\s*throw' # --- server setup wizard contracts ------------------------------------------------------------- # The Pascal side has no unit-test story at all, so what CAN be pinned statically is pinned here @@ -1225,6 +1361,16 @@ Assert-TextDoesNotMatch -Name 'the server setup must not use a [Run] section' ` -Text $serverIss -Pattern '(?mi)^\s*\[Run\]' Assert-TextMatches -Name 'every Exec result is inspected' ` -Text $serverIss -Pattern '(?m)ResultCode\s*<>\s*0' +# Abort in usUninstall is the one place Inno stops an uninstall before it removes files and the +# Apps & Features entry, with a non-zero exit code for /SILENT callers. +$serverUninstallStep = [regex]::Match($serverIss, + '(?s)procedure CurUninstallStepChanged\(CurUninstallStep: TUninstallStep\);.*?\r?\nend;').Value +Assert-TextMatches -Name 'a failed server cleanup aborts the uninstall' ` + -Text $serverUninstallStep -Pattern '(?s)else if ResultCode <> 0 then\s*begin.*?Abort;\s*end;' +Assert-TextMatches -Name 'a server cleanup that cannot start aborts the uninstall' ` + -Text $serverUninstallStep -Pattern "(?s)if not Exec\('powershell\.exe', Arguments.*?then\s*begin.*?Abort;\s*end" +Assert-TextMatches -Name 'a missing server cleanup script aborts while the installation marker exists' ` + -Text $serverUninstallStep -Pattern "(?s)if not FileExists\(ScriptPath\) then\s*begin\s*//[^\r\n]*\r?\n\s*//[^\r\n]*\r?\n\s*if not RegKeyExists\(HKLM64, 'SOFTWARE\\NodePilot\\Server'\) then Exit;.*?Abort;" # Windows Server 2022 is build 20348. Copying the desktop installer's 22000 would make the SERVER # setup refuse to run on the only operating system it targets. @@ -2875,14 +3021,86 @@ Assert-TextMatches -Name 'the desktop shell launch is gated on provisioning succ # the token, and the only remaining copy was SYSTEM-owned and unreadable. Pin that the profile is # resolved rather than assumed - asserting merely that the function is *called* would not catch a # regression that passes it the wrong value, which is how the 1.2.8 PATH defect slipped through. -Assert-TextMatches -Name 'the handoff profile is resolved from the interactive user' ` - -Text $desktopProvision -Pattern '(?s)function Get-InteractiveUserProfile.*?Win32_ComputerSystem' -Assert-TextMatches -Name 'the handoff target comes from the resolved profile, not the current process' ` - -Text $desktopProvision -Pattern '(?s)\$handoffBase\s*=\s*\$interactive\.LocalAppData' +# The console user is no substitute either: under RDP or a switched user it is someone else. +# Setup identifies the account that started it and passes its SID. +Assert-TextMatches -Name 'the handoff target comes from the resolved profile' ` + -Text $desktopProvision -Pattern '(?s)\$handoffBase\s*=\s*\$handoffTarget\.LocalAppData' Assert-TextMatches -Name 'the handoff ACL is granted to the resolved user' ` - -Text $desktopProvision -Pattern '(?s)\$handoffOwnerSid\s*=\s*\$interactive\.Sid' + -Text $desktopProvision -Pattern '(?s)\$handoffOwnerSid\s*=\s*\$handoffTarget\.Sid' Assert-TextMatches -Name 'the handoff directory is built from the resolved base' ` -Text $desktopProvision -Pattern "\`$handoffDir = Join-Path \`$handoffBase 'NodePilot'" +Assert-TextDoesNotMatch -Name 'the handoff base never defaults to this process profile' ` + -Text $desktopProvision -Pattern '\$handoffBase\s*=\s*\$env:LOCALAPPDATA' +Assert-TextMatches -Name 'setup identifies the account that started it as that account' ` + -Text $desktopIss -Pattern '(?s)function OriginalUserSid\(\).*?ExecAsOriginalUser\(.*?WindowsIdentity\]::GetCurrent\(\)\.User\.Value' +Assert-TextMatches -Name 'setup passes that account to the provisioner' ` + -Text $desktopIss -Pattern "(?s)procedure ProvisionRuntime\(\).*?SetupUserSid := OriginalUserSid\(\);.*?-HandoffUserSid" +& { + $tokens = $null; $parseErrors = $null + $provisionAst = [Management.Automation.Language.Parser]::ParseInput($desktopProvision, [ref]$tokens, [ref]$parseErrors) + if ($parseErrors.Count) { throw 'Desktop provisioner did not parse.' } + $resolver = $provisionAst.Find({ param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Resolve-DesktopHandoffTarget' + }, $false) + if (-not $resolver) { throw 'Resolve-DesktopHandoffTarget was not found in the desktop provisioner.' } + . ([scriptblock]::Create($resolver.Extent.Text)) + $fixtureProfile = Join-Path ([IO.Path]::GetTempPath()) ('nodepilot-handoff-' + [Guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $fixtureProfile | Out-Null + $setupUserSid = 'S-1-5-21-1111111111-2222222222-3333333333-1001' + $consoleUserSid = 'S-1-5-21-1111111111-2222222222-3333333333-1003' + $consoleProfile = Join-Path $fixtureProfile 'console' + New-Item -ItemType Directory -Path $consoleProfile | Out-Null + function Get-ItemProperty { + param($Path, $Name, $ErrorAction) + if ($Path -like "*\ProfileList\$setupUserSid") { return [pscustomobject]@{ ProfileImagePath = $fixtureProfile } } + if ($Path -like "*\ProfileList\$consoleUserSid") { return [pscustomobject]@{ ProfileImagePath = $consoleProfile } } + throw "fixture: no profile at $Path" + } + # A console user that differs from the setup user, as under RDP or a switched user. + $script:consoleSid = $consoleUserSid + $script:consoleQueries = 0 + function Get-DesktopConsoleUserSid { $script:consoleQueries++; $script:consoleSid } + try { + $target = Resolve-DesktopHandoffTarget -HandoffUserSid $setupUserSid + if ($target.Sid.Value -ne $setupUserSid -or $target.LocalAppData -ne (Join-Path $fixtureProfile 'AppData\Local') -or + $script:consoleQueries -ne 0) { + throw 'Deployment template check failed: the handoff must go to the account that started setup.' + } + # Software distribution runs setup as SYSTEM, which never runs the shell. + $target = Resolve-DesktopHandoffTarget -HandoffUserSid 'S-1-5-18' + if ($target.Sid.Value -ne $consoleUserSid -or $target.LocalAppData -ne (Join-Path $consoleProfile 'AppData\Local')) { + throw 'Deployment template check failed: setup run as SYSTEM must hand over to the console user.' + } + $script:consoleSid = $null + $target = Resolve-DesktopHandoffTarget -HandoffUserSid 'S-1-5-18' 3>$null + if ($target.LocalAppData -ne $env:LOCALAPPDATA -or + $target.Sid.Value -ne ([System.Security.Principal.WindowsIdentity]::GetCurrent()).User.Value) { + throw 'Deployment template check failed: setup run as SYSTEM with nobody signed in must keep the handoff with that account.' + } + foreach ($case in @( + @{ Name = 'no setup account'; Sid = '' }, + @{ Name = 'an account without a profile'; Sid = 'S-1-5-21-1111111111-2222222222-3333333333-1002' }, + @{ Name = 'a value that is not a SID'; Sid = 'CONTOSO\someone' })) { + $refused = $false + try { [void](Resolve-DesktopHandoffTarget -HandoffUserSid $case.Sid) } catch { $refused = $true } + if (-not $refused) { + throw "Deployment template check failed: the handoff must fail explicitly for $($case.Name)." + } + } + } + finally { Remove-Item -LiteralPath $fixtureProfile -Recurse -Force -ErrorAction SilentlyContinue } + Write-Host ' OK the first-run handoff goes to the setup account, to the console user when setup ran as a service account, and fails explicitly when it is unknown' -ForegroundColor DarkGray +} + +# Same contract as the server uninstaller: a failed cleanup keeps the installation retryable. +$desktopUninstallStep = [regex]::Match($desktopIss, + '(?s)procedure CurUninstallStepChanged\(CurUninstallStep: TUninstallStep\);.*?\r?\nend;').Value +Assert-TextMatches -Name 'a failed desktop cleanup aborts the uninstall' ` + -Text $desktopUninstallStep -Pattern '(?s)else if ResultCode <> 0 then\s*begin.*?Abort;\s*end;' +Assert-TextMatches -Name 'a desktop cleanup that cannot start aborts the uninstall' ` + -Text $desktopUninstallStep -Pattern "(?s)if not Exec\('powershell\.exe', Params.*?then\s*begin.*?Abort;\s*end" +Assert-TextMatches -Name 'a missing desktop cleanup script aborts while a service is registered' ` + -Text $desktopUninstallStep -Pattern '(?s)if not FileExists\(ScriptPath\) then.*?RegKeyExists\(HKLM64, ''SYSTEM\\CurrentControlSet\\Services\\NodePilot''\).*?Abort;' # 2b. Install over an installation, uninstall, install again. Each defect below left a machine that # looked installed or uninstalled and was neither. @@ -2891,6 +3109,32 @@ $desktopUninstall = Get-Content -LiteralPath (Join-Path $desktopDirectory 'Unins $desktopPrepare = Get-Content -LiteralPath (Join-Path $desktopDirectory 'Prepare-DesktopSetup.ps1') -Raw $desktopRuntime = Get-Content -LiteralPath (Join-Path $desktopDirectory 'DesktopRuntime.ps1') -Raw $desktopUpdate = Get-Content -LiteralPath (Join-Path $desktopDirectory 'Update-Desktop.ps1') -Raw +& { + $tokens = $null; $parseErrors = $null + $ast = [Management.Automation.Language.Parser]::ParseInput($desktopUpdate, [ref]$tokens, [ref]$parseErrors) + if ($parseErrors.Count) { throw 'Desktop updater did not parse.' } + $command = $ast.Find({ param($node) + $node -is [Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -eq 'powershell.exe' -and $node.Extent.Text.Contains('Provision-LocalDb.ps1') + }, $true) + if (-not $command) { throw 'Desktop reprovisioning invocation missing.' } + function powershell.exe { + param([switch]$NoProfile, $ExecutionPolicy, $File, $InstallPath, $DataPath, $ApiServiceName, $DbServiceName, $HandoffUserSid) + [pscustomobject]@{ Install = $InstallPath; Data = $DataPath; Api = $ApiServiceName; Db = $DbServiceName; Sid = $HandoffUserSid } + } + $InstallPath = Join-Path $env:TEMP 'custom app' + $DataPath = Join-Path $env:TEMP 'custom data' + $ApiServiceName = 'CustomApi'; $DbServiceName = 'CustomDb' + $HandoffUserSid = 'S-1-5-21-1-2-3-1001' + $actual = & ([scriptblock]::Create($command.Extent.Text)) + if ($actual.Install -ne $InstallPath -or $actual.Data -ne $DataPath -or + $actual.Api -ne $ApiServiceName -or $actual.Db -ne $DbServiceName) { + throw 'Desktop reprovisioning lost custom paths or service identities.' + } + if ($actual.Sid -ne $HandoffUserSid) { + throw 'Desktop reprovisioning must name the account that receives the first-run handoff.' + } +} # A failed provisioning ended an unattended install with exit code 0, or - worse - left it waiting # forever on a message box that /SUPPRESSMSGBOXES does not suppress. diff --git a/deploy/Test-DesktopUpdate.ps1 b/deploy/Test-DesktopUpdate.ps1 index 8ec25311..c87d9e98 100644 --- a/deploy/Test-DesktopUpdate.ps1 +++ b/deploy/Test-DesktopUpdate.ps1 @@ -32,6 +32,14 @@ function Stop-Everything { if ($script:stopFails) { throw 'Runtime could not be stopped' } } function Start-Services { $script:events.Add('start-db'); $script:events.Add('start-api') } +function Restore-DesktopServiceSnapshots { + param([object[]]$Snapshots) + $script:events.Add('restore-services') + $script:restoredSnapshots = $Snapshots + if ($script:servicesRestoreFails) { throw 'Fixture service registration failure' } +} +$serviceSnapshots = @([pscustomobject]@{ Name = 'fake-db' }, [pscustomobject]@{ Name = 'fake-api' }) +$script:servicesRestoreFails = $false function Start-DatabaseForRestore { $script:events.Add('start-db') } function Get-PostgresConnection { [pscustomobject]@{ DbHost = 'fake'; Port = '1'; Username = 'fake'; Database = 'fake'; Password = 'fixture-only' } @@ -54,6 +62,7 @@ New-Item -ItemType Directory -Path $fixtureRoot | Out-Null $DesktopJson = Join-Path $fixtureRoot 'desktop.json' $previousDesktopJsonBytes = $null $previousDesktopJsonAcl = $null +$componentAcls = @{} $previousPassword = [Environment]::GetEnvironmentVariable('PGPASSWORD') try { foreach ($exitCode in @(0, 1)) { @@ -67,7 +76,11 @@ try { $restoreIndex = $script:events.IndexOf('restore') $apiIndex = $script:events.IndexOf('start-api') + $servicesIndex = $script:events.IndexOf('restore-services') Assert-True ($restoreIndex -ge 0) 'Rollback must attempt the database restore.' + Assert-True ($servicesIndex -ge 0 -and $servicesIndex -lt $restoreIndex -and + $servicesIndex -lt $script:events.IndexOf('start-db')) 'Rollback must restore both service registrations before the database restore starts PostgreSQL.' + Assert-True (($script:restoredSnapshots | ForEach-Object { $_.Name }) -join ',' -eq 'fake-db,fake-api') 'Rollback must restore the snapshots taken before the update.' if ($exitCode -eq 0) { Assert-True ($apiIndex -gt $restoreIndex) 'API must remain stopped until the database restore succeeds.' Assert-True ($script:lastError -eq 'Update rolled back to the previous version.') 'Successful rollback should be identified accurately.' @@ -83,6 +96,16 @@ try { Assert-True (-not $script:events.Contains('restore')) 'Failed shutdown must prevent database restore.' Assert-True (-not $script:events.Contains('start-api')) 'Failed shutdown must not restart the API.' + # A service that cannot be registered again stops the rollback before PostgreSQL is touched. + $script:events.Clear() + $script:stopFails = $false + $script:servicesRestoreFails = $true + $script:lastError = '' + & $rollback + $script:servicesRestoreFails = $false + Assert-True (-not $script:events.Contains('restore') -and -not $script:events.Contains('start-api')) 'A failed service restoration must prevent database restore and API start.' + Assert-True ($script:lastError -match 'rollback failed') 'A failed service restoration must be reported as failed rollback.' + # A changed free-port choice must roll back together with the old API configuration. $script:stopFails = $false $script:restoreExit = 0 @@ -104,8 +127,28 @@ try { $newAcl = Get-Acl -LiteralPath $DesktopJson $newAcl.SetAccessRuleProtection($false, $true) Set-Acl -LiteralPath $DesktopJson -AclObject $newAcl + # The install directory grants Users read; the rollback copy lost it to the data directory's ACL. + $usersSid = New-Object Security.Principal.SecurityIdentifier('S-1-5-32-545') + $installAcl = Get-Acl -LiteralPath $InstallPath + $installAcl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($usersSid, 'ReadAndExecute', + 'ContainerInherit,ObjectInherit', 'None', 'Allow'))) + Set-Acl -LiteralPath $InstallPath -AclObject $installAcl + $componentAcls = @{ app = (Get-Acl -LiteralPath $newApp) } + $savedAcl = New-Object Security.AccessControl.DirectorySecurity + $savedAcl.SetAccessRuleProtection($true, $false) + foreach ($sid in @([Security.Principal.WindowsIdentity]::GetCurrent().User, (New-Object Security.Principal.SecurityIdentifier('S-1-5-18')))) { + $savedAcl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($sid, 'FullControl', + 'ContainerInherit,ObjectInherit', 'None', 'Allow'))) + } + Set-Acl -LiteralPath $savedApp -AclObject $savedAcl $swappedComponents = @('app') & $rollback + foreach ($restoredPath in @($newApp, (Join-Path $newApp 'appsettings.Production.json'))) { + $usersRead = @((Get-Acl -LiteralPath $restoredPath).Access | Where-Object { + $_.IsInherited -and $_.IdentityReference.Translate([Security.Principal.SecurityIdentifier]) -eq $usersSid }) + Assert-True ($usersRead.Count -gt 0) "Rollback must give '$restoredPath' the install directory's inherited access back." + } + $componentAcls = @{} $restoredApp = Get-Content -LiteralPath (Join-Path $newApp 'appsettings.Production.json') -Raw | ConvertFrom-Json $restoredDesktop = Get-Content -LiteralPath $DesktopJson -Raw | ConvertFrom-Json Assert-True ($restoredDesktop.origin -eq "https://localhost:$($restoredApp.port)") 'Rollback must restore the desktop origin together with the old API port.' @@ -156,6 +199,98 @@ try { } Remove-Item -LiteralPath $resolvedFixture -Recurse -Force } + +# The service snapshot helpers against a fixture key under HKCU: what the provisioner deletes is +# registered again with the same values, key ACL first, and a missing service fails explicitly. +& { + # The readiness check above replaced Join-Path. + Remove-Item -LiteralPath Function:\Join-Path -ErrorAction SilentlyContinue + $runtimeAst = [System.Management.Automation.Language.Parser]::ParseFile( + (Join-Path $PSScriptRoot 'desktop\DesktopRuntime.ps1'), [ref]$null, [ref]$parseErrors) + if ($parseErrors.Count) { throw "DesktopRuntime.ps1 contains PowerShell syntax errors: $parseErrors" } + foreach ($name in @('Get-DesktopServiceSnapshot', 'Restore-DesktopServiceSnapshots')) { + $function = $runtimeAst.Find({ param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $name + }, $false) + if (-not $function) { throw "$name was not found in DesktopRuntime.ps1." } + . ([scriptblock]::Create($function.Extent.Text)) + } + $root = 'HKCU:\Software\NodePilotDesktopServiceFixture-' + [Guid]::NewGuid().ToString('N') + $log = New-Object 'System.Collections.Generic.List[string]' + function Remove-DesktopService { param($Name) $log.Add("remove:$Name"); Remove-Item -LiteralPath (Join-Path $root $Name) -Recurse -Force -ErrorAction SilentlyContinue } + function Test-DesktopServiceExists { param($Name) Test-Path -LiteralPath (Join-Path $root $Name) } + function Write-Host { param($Object) } + function Invoke-CimMethod { + param($ClassName, $MethodName, $Arguments) + $log.Add("create:$($Arguments.Name)") + $script:createArgs[$Arguments.Name] = $Arguments + if ($script:createFails) { return [pscustomobject]@{ ReturnValue = 21 } } + $key = New-Item -Path (Join-Path $root $Arguments.Name) -Force + $key.SetValue('ImagePath', $Arguments.PathName, [Microsoft.Win32.RegistryValueKind]::ExpandString) + return [pscustomobject]@{ ReturnValue = 0 } + } + $script:createArgs = @{} + $script:createFails = $false + try { + New-Item -Path $root -Force | Out-Null + $db = New-Item -Path (Join-Path $root 'fake-db') -Force + $db.SetValue('ImagePath', '"C:\Program Files\NodePilot\pgsql\bin\pg_ctl.exe" runservice -N "fake-db" -o "-p 47100"', [Microsoft.Win32.RegistryValueKind]::ExpandString) + $db.SetValue('DisplayName', 'NodePilot Database') + $db.SetValue('ObjectName', 'NT AUTHORITY\NetworkService') + $db.SetValue('Start', 2, [Microsoft.Win32.RegistryValueKind]::DWord) + $db.SetValue('ErrorControl', 1, [Microsoft.Win32.RegistryValueKind]::DWord) + $db.SetValue('FailureActions', [byte[]](0x80, 0x51, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0x14, 0, 0, 0, 1, 0, 0, 0, 0x88, 0x13, 0, 0), [Microsoft.Win32.RegistryValueKind]::Binary) + $api = New-Item -Path (Join-Path $root 'fake-api') -Force + $api.SetValue('ImagePath', '"C:\Program Files\NodePilot\app\NodePilot.Api.exe" --contentRoot "C:\Program Files\NodePilot\app"', [Microsoft.Win32.RegistryValueKind]::ExpandString) + $api.SetValue('DisplayName', 'NodePilot') + $api.SetValue('Description', 'NodePilot workflow orchestrator (desktop, loopback).') + $api.SetValue('ObjectName', 'LocalSystem') + $api.SetValue('Start', 2, [Microsoft.Win32.RegistryValueKind]::DWord) + $api.SetValue('ErrorControl', 1, [Microsoft.Win32.RegistryValueKind]::DWord) + $api.SetValue('DelayedAutostart', 1, [Microsoft.Win32.RegistryValueKind]::DWord) + $api.SetValue('DependOnService', [string[]]@('fake-db'), [Microsoft.Win32.RegistryValueKind]::MultiString) + $api.SetValue('Environment', [string[]]@('ASPNETCORE_ENVIRONMENT=Production', 'ConnectionStrings__Postgres=Host=127.0.0.1;Port=47100;Password=fixture-only'), [Microsoft.Win32.RegistryValueKind]::MultiString) + $apiAcl = Get-Acl -Path (Join-Path $root 'fake-api') + $apiAcl.SetAccessRuleProtection($true, $true) + Set-Acl -Path (Join-Path $root 'fake-api') -AclObject $apiAcl + $apiAccess = (Get-Acl -Path (Join-Path $root 'fake-api')).GetSecurityDescriptorSddlForm('Access') + + $snapshots = @( + Get-DesktopServiceSnapshot -Name 'fake-db' -ServicesRoot $root + Get-DesktopServiceSnapshot -Name 'fake-api' -ServicesRoot $root + ) + # What the provisioner does before it fails: both registrations are gone. + Remove-Item -LiteralPath (Join-Path $root 'fake-db'), (Join-Path $root 'fake-api') -Recurse -Force + $log.Clear() + Restore-DesktopServiceSnapshots -Snapshots $snapshots -ServicesRoot $root + Assert-True (($log -join ',') -eq 'remove:fake-api,remove:fake-db,create:fake-db,create:fake-api') 'Service restoration must remove dependents first and register the database before the API.' + Assert-True ($script:createArgs['fake-db'].StartName -eq 'NT AUTHORITY\NetworkService' -and $script:createArgs['fake-db'].StartPassword -eq '') 'The database service must be registered again as NetworkService.' + Assert-True ($script:createArgs['fake-api'].StartName -eq 'LocalSystem' -and -not $script:createArgs['fake-api'].ContainsKey('StartPassword')) 'The API service must be registered again as LocalSystem.' + Assert-True ($script:createArgs['fake-api'].PathName -eq $snapshots[1].ImagePath -and @($script:createArgs['fake-api'].ServiceDependencies) -join ',' -eq 'fake-db') 'The API service keeps its quoted binary path and its dependency.' + $restoredApi = Get-Item -LiteralPath (Join-Path $root 'fake-api') + Assert-True ((@($restoredApi.GetValue('Environment')) -join "`n") -ceq ($snapshots[1].Environment -join "`n")) 'The API connection string must be restored exactly.' + Assert-True ($restoredApi.GetValue('DelayedAutostart') -eq 1 -and $restoredApi.GetValue('Description') -eq $snapshots[1].Description) 'Delayed start and description must be restored.' + Assert-True ((Get-Acl -Path (Join-Path $root 'fake-api')).GetSecurityDescriptorSddlForm('Access') -eq $apiAccess) 'The service key ACL must be restored.' + $restoredFailure = [byte[]](Get-Item -LiteralPath (Join-Path $root 'fake-db')).GetValue('FailureActions') + Assert-True (($restoredFailure -join ',') -eq ($snapshots[0].FailureActions -join ',')) 'Failure actions must be restored byte for byte.' + + $script:createFails = $true + $failure = $null + try { Restore-DesktopServiceSnapshots -Snapshots $snapshots -ServicesRoot $root } catch { $failure = $_ } + $script:createFails = $false + Assert-True ($null -ne $failure -and $failure.Exception.Message -match 'could not be registered again') 'A failed registration must fail the restoration explicitly.' + + $missing = $null + try { [void](Get-DesktopServiceSnapshot -Name 'not-registered' -ServicesRoot $root) } catch { $missing = $_ } + Assert-True ($null -ne $missing -and $missing.Exception.Message -match 'not registered') 'An update must refuse to start without both service registrations.' + } + finally { + if ($root -like 'HKCU:\Software\NodePilotDesktopServiceFixture-*') { + Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + # The mocks above set the exit code of a native command; do not leak it to the caller. $global:LASTEXITCODE = 0 -Write-Host 'Desktop rollback checks passed (database restore, shutdown, desktop origin/ACL, readiness).' +Write-Host 'Desktop rollback checks passed (database restore, service registrations, shutdown, desktop origin/ACL, readiness).' diff --git a/deploy/Test-DesktopUpdatePayload.ps1 b/deploy/Test-DesktopUpdatePayload.ps1 index e4632a61..68e100d3 100644 --- a/deploy/Test-DesktopUpdatePayload.ps1 +++ b/deploy/Test-DesktopUpdatePayload.ps1 @@ -39,6 +39,7 @@ try { } . ([scriptblock]::Create($componentAssignment.Extent.Text)) $swappedComponents = New-Object 'System.Collections.Generic.List[string]' + $componentAcls = @{} . ([scriptblock]::Create(($validation.Extent.Text -join "`n"))) . ([scriptblock]::Create($swap.Extent.Text)) foreach ($name in @('app', 'desktop', 'pgsql', 'deploy', 'tools')) { diff --git a/deploy/Test-LocalServerHttp.ps1 b/deploy/Test-LocalServerHttp.ps1 new file mode 100644 index 00000000..22152845 --- /dev/null +++ b/deploy/Test-LocalServerHttp.ps1 @@ -0,0 +1,81 @@ +#requires -Version 5.1 +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version 3.0 +. (Join-Path $PSScriptRoot 'LocalServerHttp.ps1') + +$script:passed = 0 +function Assert-LocalHttps { + param([bool]$Condition, [string]$Message) + if (-not $Condition) { throw $Message } + $script:passed++ +} + +function Import-SetupFunction { + param([string]$FileName, [string]$Name) + $tokens = $null; $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot $FileName), [ref]$tokens, [ref]$errors) + if ($errors.Count) { throw "Could not parse $FileName" } + $function = $ast.Find({ param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $Name }, $true) + if (-not $function) { throw "Missing function $Name" } + return [scriptblock]::Create($function.Extent.Text) +} + +$rsa = [Security.Cryptography.RSA]::Create(2048) +$certificate = $null; $otherCertificate = $null +try { + $certificateRequest = [Security.Cryptography.X509Certificates.CertificateRequest]::new( + 'CN=setup.synthetic.invalid', $rsa, [Security.Cryptography.HashAlgorithmName]::SHA256, [Security.Cryptography.RSASignaturePadding]::Pkcs1) + $certificate = $certificateRequest.CreateSelfSigned([DateTimeOffset]::UtcNow.AddDays(-1), [DateTimeOffset]::UtcNow.AddDays(1)) + $otherCertificate = $certificateRequest.CreateSelfSigned([DateTimeOffset]::UtcNow.AddDays(-2), [DateTimeOffset]::UtcNow.AddDays(2)) + $policyProperty = [Net.ServicePointManager].GetProperty('CertificatePolicy') + $previousPolicy = if ($policyProperty) { $policyProperty.GetValue($null) } else { $null } + $previousCallback = [Net.ServicePointManager]::ServerCertificateValidationCallback + $request = [NodePilot.Deployment.LocalServerHttp]::CreateRequest([uri]'https://localhost:12345/healthz/ready', $certificate.Thumbprint, 1000) + $errors = [Net.Security.SslPolicyErrors]::RemoteCertificateNameMismatch -bor [Net.Security.SslPolicyErrors]::RemoteCertificateChainErrors + Assert-LocalHttps ($request.ServerCertificateValidationCallback.Invoke($request, $certificate, $null, $errors)) 'The selected self-signed public-host certificate must identify the loopback server.' + Assert-LocalHttps (-not $request.ServerCertificateValidationCallback.Invoke($request, $otherCertificate, $null, [Net.Security.SslPolicyErrors]::None)) 'A different certificate must fail even with a valid chain and hostname.' + Assert-LocalHttps (-not $request.ServerCertificateValidationCallback.Invoke($request, $null, $null, $errors)) 'A missing certificate must fail.' + Assert-LocalHttps (-not $request.AllowAutoRedirect) 'Bootstrap credentials must not follow redirects.' + Assert-LocalHttps ($null -eq $request.Proxy) 'Loopback setup must not use a configured proxy.' + if ($policyProperty) { + Assert-LocalHttps ([object]::ReferenceEquals($previousPolicy, $policyProperty.GetValue($null))) 'The global certificate policy changed.' + } + Assert-LocalHttps ([object]::ReferenceEquals($previousCallback, [Net.ServicePointManager]::ServerCertificateValidationCallback)) 'The global validation callback changed.' + foreach ($uri in @('http://localhost:12345/', 'https://server.synthetic.invalid/', 'https://user@localhost/')) { + $rejected = $false + try { [NodePilot.Deployment.LocalServerHttp]::CreateRequest([uri]$uri, $certificate.Thumbprint, 1000) | Out-Null } + catch { $rejected = $true } + Assert-LocalHttps $rejected "Unsafe setup URI accepted: $uri" + } + $rejected = $false + try { [NodePilot.Deployment.LocalServerHttp]::CreateRequest([uri]'https://localhost/', '', 1000) | Out-Null } + catch { $rejected = $true } + Assert-LocalHttps $rejected 'Missing certificate pin was accepted.' + + . (Import-SetupFunction -FileName 'Install-NodePilot.ps1' -Name 'Invoke-HealthProbe') + . (Import-SetupFunction -FileName 'Invoke-NodePilotSetup.ps1' -Name 'Invoke-NodePilotFirstLogin') + $script:requests = @() + $script:statusCode = 200 + function Invoke-NodePilotLocalHttps { + param($Uri, $CertificateThumbprint, $Method = 'GET', $Body, $Headers, $TimeoutSeconds) + $script:requests += [pscustomobject]@{Uri=$Uri; Pin=$CertificateThumbprint; Method=$Method; Body=$Body; Headers=$Headers} + return [pscustomobject]@{StatusCode=$script:statusCode} + } + Assert-LocalHttps (Invoke-HealthProbe -Url 'https://localhost:12345/healthz/ready' -CertificateThumbprint $certificate.Thumbprint) 'Pinned readiness response was rejected.' + $result = Invoke-NodePilotFirstLogin -HttpsPort 12345 -CertificateThumbprint $certificate.Thumbprint -Username 'synthetic-admin' -Password 'synthetic-password' -Token 'synthetic-token' + Assert-LocalHttps ($result.Status -eq 'Created') 'Pinned bootstrap response was rejected.' + Assert-LocalHttps (@($script:requests | Where-Object Pin -ne $certificate.Thumbprint).Count -eq 0) 'A caller lost the configured certificate pin.' + Assert-LocalHttps ($script:requests[1].Headers['X-Setup-Token'] -eq 'synthetic-token') 'Bootstrap token was not sent through the pinned transport.' + $script:statusCode = 302 + $result = Invoke-NodePilotFirstLogin -HttpsPort 12345 -CertificateThumbprint $certificate.Thumbprint -Username 'synthetic-admin' -Password 'synthetic-password' -Token 'synthetic-token' + Assert-LocalHttps ($result.Status -eq 'Failed') 'Redirect was treated as successful bootstrap.' +} +finally { + if ($certificate) { $certificate.Dispose() } + if ($otherCertificate) { $otherCertificate.Dispose() } + $rsa.Dispose() +} +Write-Host "Local server HTTPS checks passed: $script:passed" diff --git a/deploy/Test-SetupAdapter.ps1 b/deploy/Test-SetupAdapter.ps1 index d90f5386..44f18d66 100644 --- a/deploy/Test-SetupAdapter.ps1 +++ b/deploy/Test-SetupAdapter.ps1 @@ -1801,6 +1801,29 @@ try { -State ([pscustomobject]@{ Determined = $true; Allowed = $true; Detail = '' }) Assert-True -Name 'a host that may fetch the password passes' -Condition ($permitted.Status -eq 'Pass') + & { + # Execute the generated advice against in-memory AD boundaries only. + function Get-ADServiceAccount { + param($Identity, $Properties, $ErrorAction) + if ($Properties -ne 'PrincipalsAllowedToRetrieveManagedPassword') { throw 'Existing grants were not requested.' } + [pscustomobject]@{ PrincipalsAllowedToRetrieveManagedPassword = @( + [pscustomobject]@{ DistinguishedName = 'CN=ExistingGroup,DC=test' }, 'CN=ExistingHost,DC=test') } + } + function Get-ADComputer { + param($Identity, $ErrorAction) + [pscustomobject]@{ DistinguishedName = 'CN=NewHost,DC=test' } + } + function Set-ADServiceAccount { + param($Identity, $PrincipalsAllowedToRetrieveManagedPassword, $ErrorAction) + Assert-True -Name 'gMSA advice preserves existing grants while adding the host' -Condition ( + @($PrincipalsAllowedToRetrieveManagedPassword).Count -eq 3 -and + $PrincipalsAllowedToRetrieveManagedPassword -contains 'CN=ExistingGroup,DC=test' -and + $PrincipalsAllowedToRetrieveManagedPassword -contains 'CN=ExistingHost,DC=test' -and + $PrincipalsAllowedToRetrieveManagedPassword -contains 'CN=NewHost,DC=test') + } + & ([scriptblock]::Create($denied.Remediation)) + } + # Not knowing is not evidence: an unreachable directory must not refuse an install. $unknown = New-NodePilotGmsaRetrievalResult -Sam 'q-sdvorch2' -Title $gmsaTitle ` -State ([pscustomobject]@{ Determined = $false; Allowed = $false; Detail = 'no directory could be reached' }) diff --git a/deploy/Uninstall-NodePilot.ps1 b/deploy/Uninstall-NodePilot.ps1 index 87a7f4fe..76b6c0f4 100644 --- a/deploy/Uninstall-NodePilot.ps1 +++ b/deploy/Uninstall-NodePilot.ps1 @@ -147,6 +147,60 @@ function Remove-NodePilotBinaryBackups { return $failed } +function Grant-NodePilotPurgeAccess { + <# + Takes ownership of $Path and everything below it for Administrators (takeown /A), then grants + that group full control, item by item. takeown /R prompts with a localized answer on folders + it cannot list, so the recursion happens here: a folder unlocked in one pass exposes its + children to the next. A failed takeown skips the grant for that item. Returns the failures. + #> + param( + [Parameter(Mandatory)][string]$Path, + [scriptblock]$TakeOwnership = { + param($ItemPath) + & takeown.exe /F $ItemPath /A 2>&1 | Out-Null + $LASTEXITCODE + }, + # No (OI)(CI): those are container flags, and on a leaf file icacls drops them and adds no + # ACE at all while still reporting success. + [scriptblock]$GrantAdministrators = { + param($ItemPath) + & icacls.exe $ItemPath /grant '*S-1-5-32-544:(F)' /C /Q 2>&1 | Out-Null + $LASTEXITCODE + }, + [int]$MaxPasses = 16 + ) + # Native tools report on stderr; under 'Stop' Windows PowerShell turns that into an exception. + $ErrorActionPreference = 'Continue' + $done = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase) + $failures = @{} + for ($pass = 1; $pass -le $MaxPasses; $pass++) { + $items = @(Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue) + + @(Get-ChildItem -LiteralPath $Path -Recurse -Force -ErrorAction SilentlyContinue) + $pending = @($items | Where-Object { -not $done.Contains($_.FullName) }) + if ($pending.Count -eq 0) { break } + $progress = $false + # Parents first, so their children become listable for the next pass. + foreach ($item in @($pending | Sort-Object { $_.FullName.Length })) { + $ownerExit = & $TakeOwnership $item.FullName + if ($ownerExit -ne 0) { + $failures[$item.FullName] = "takeown exited with $ownerExit for $($item.FullName)" + continue + } + $grantExit = & $GrantAdministrators $item.FullName + if ($grantExit -ne 0) { + $failures[$item.FullName] = "icacls exited with $grantExit for $($item.FullName)" + continue + } + $failures.Remove($item.FullName) + [void]$done.Add($item.FullName) + $progress = $true + } + if (-not $progress) { break } + } + return @($failures.Values | Sort-Object) +} + Write-Step "Stopping and removing service '$ServiceName'" $svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue if ($svc) { @@ -188,7 +242,14 @@ if ($svc) { } & sc.exe delete $ServiceName | Out-Null - Write-Info " sc.exe delete returned exit $LASTEXITCODE" + $deleteExit = $LASTEXITCODE + Write-Info " sc.exe delete returned exit $deleteExit" + # 1072: already marked for deletion. Anything else leaves the service registered, so stop + # before removing the files it runs from. + if ($deleteExit -ne 0 -and $deleteExit -ne 1072) { + throw ("sc.exe delete '$ServiceName' failed with exit $deleteExit. Nothing else was removed; " + + 'fix the cause and run the uninstall again.') + } # sc.exe delete normally removes the whole service key, including the Environment MULTI_SZ # that holds ConnectionStrings__Postgres - the database password. While anything still holds @@ -204,24 +265,6 @@ if ($svc) { Write-Info " Service not present." } -Write-Step "Removing the installation marker" -# Without this, a fresh install after an uninstall is misdetected as an upgrade forever - the -# setup wizard reads this key to choose between the two. -$markerPath = 'HKLM:\SOFTWARE\NodePilot\Server' -if (Test-Path -LiteralPath $markerPath) { - Remove-Item -LiteralPath $markerPath -Recurse -Force -ErrorAction SilentlyContinue - Write-Info " Removed: $markerPath" -} else { - Write-Info " No installation marker present." -} -# Drop the parent only when this uninstall emptied it; something else may live under it. -$markerParent = 'HKLM:\SOFTWARE\NodePilot' -if ((Test-Path -LiteralPath $markerParent) -and - -not (Get-ChildItem -LiteralPath $markerParent -ErrorAction SilentlyContinue) -and - -not (Get-Item -LiteralPath $markerParent -ErrorAction SilentlyContinue).GetValueNames()) { - Remove-Item -LiteralPath $markerParent -Force -ErrorAction SilentlyContinue -} - Write-Step "Removing the CLI from the machine PATH" # Install-NodePilot.ps1 appends \tools\np so operators can just type `np`. Leaving it # behind would point PATH at a directory this uninstall is about to delete, and repeated @@ -260,10 +303,13 @@ if (Test-Path $InstallPath) { # arbitrary remainder behind. Skipping Inno's files lets it finish the job itself; run # standalone there are no such files and everything goes. $uninstallerPattern = 'unins*' + # deploy\ holds this script. It goes last, once everything else is gone, so a failed run can + # be repeated from the same place. + $recoveryHelperName = 'deploy' for ($attempt = 1; $attempt -le 3; $attempt++) { $failed = $false foreach ($entry in Get-ChildItem -LiteralPath $InstallPath -Force -ErrorAction SilentlyContinue) { - if ($entry.Name -like $uninstallerPattern) { continue } + if ($entry.Name -like $uninstallerPattern -or $entry.Name -eq $recoveryHelperName) { continue } try { Remove-Item -LiteralPath $entry.FullName -Recurse -Force -ErrorAction Stop } catch { $failed = $true @@ -277,23 +323,17 @@ if (Test-Path $InstallPath) { if ($attempt -lt 3) { Start-Sleep -Seconds 2 } } - # Only when nothing is left. Inno removes its own uninstaller and the empty directory after - # this script returns. - $remaining = @(Get-ChildItem -LiteralPath $InstallPath -Force -ErrorAction SilentlyContinue) - if ($remaining.Count -eq 0) { - Remove-Item -LiteralPath $InstallPath -Force -ErrorAction SilentlyContinue - } - elseif (@($remaining | Where-Object { $_.Name -notlike $uninstallerPattern }).Count -eq 0) { - Write-Info ' Leaving the setup uninstaller to remove itself and the directory.' - } - - # Anything still here that is not the setup's own uninstaller is a genuine leftover. + # Anything still here that is not the setup's own uninstaller or deploy\ is a genuine leftover. + $recoveryHelperPrefix = (Join-Path $InstallPath $recoveryHelperName).TrimEnd('\') + '\' $leftovers = @(Get-ChildItem -LiteralPath $InstallPath -Recurse -Force -File -ErrorAction SilentlyContinue | - Where-Object { $_.Name -notlike $uninstallerPattern }) + Where-Object { + $_.Name -notlike $uninstallerPattern -and + -not $_.FullName.StartsWith($recoveryHelperPrefix, [StringComparison]::OrdinalIgnoreCase) + }) if ($leftovers.Count -gt 0) { - # Do not abort here. The service and the marker are already gone; stopping now would skip - # the data notice and the residue report. Report the leftovers, carry on, and exit - # non-zero at the end. + # Do not abort here. The service is already gone; stopping now would skip the data notice + # and the residue report. Report the leftovers, carry on, and exit non-zero at the end, + # with the marker and deploy\ kept for the retry. $installPathRemaining = $true Write-Warn " $($leftovers.Count) file(s) could not be deleted under $InstallPath." $holders = @(Get-ProcessesUnderPath -Path $InstallPath) @@ -324,32 +364,30 @@ if ($PurgeData) { # account, so an administrator can neither read nor delete them and a plain Remove-Item # gets partway through the directory and then throws. # - # Ownership is taken first, using the well-known Administrators SID rather than the group - # name: "BUILTIN\Administrators" does not resolve on a non-English Windows, the same - # reason the ACL helpers in ArtifactSecurity.ps1 use SIDs. - # - # The grant carries no inheritance flags. (OI)(CI) are container flags; applied to a leaf - # file icacls drops them silently and adds no ACE at all. /T visits every item, so a flat - # grant is sufficient for a tree that is about to be deleted anyway. - $previousPreference = $ErrorActionPreference - $ErrorActionPreference = 'Continue' - try { - & takeown.exe /F $DataPath /R /D Y 2>&1 | Out-Null - & icacls.exe $DataPath /grant '*S-1-5-32-544:(F)' /T /C 2>&1 | Out-Null - } - finally { $ErrorActionPreference = $previousPreference } - - try { - Remove-Item -LiteralPath $DataPath -Recurse -Force -ErrorAction Stop - Write-Info " Deleted: $DataPath" - } - catch { - # Report and continue. Aborting here would skip the closing report, and the operator - # would be left with a half-purged directory and no idea which parts survived. + # Ownership is taken first, for the Administrators group via takeown /A and the + # well-known SID: neither the group name nor a takeown answer is the same on every + # Windows language. + $ownershipFailures = @(Grant-NodePilotPurgeAccess -Path $DataPath) + if ($ownershipFailures.Count -gt 0) { + # Nothing is deleted from a tree that is only partly accessible, so a retry starts from + # the same state. $dataPathRemaining = $true - Write-Warn " Could not fully delete $DataPath : $($_.Exception.Message)" - $survivors = @(Get-ChildItem -LiteralPath $DataPath -Recurse -Force -File -ErrorAction SilentlyContinue) - Write-Warn " $($survivors.Count) file(s) remain. Delete the folder by hand, or reboot and retry." + foreach ($failure in $ownershipFailures) { Write-Warn " $failure" } + Write-Warn " $DataPath was not deleted. Resolve the failures above and run the uninstall again." + } + else { + try { + Remove-Item -LiteralPath $DataPath -Recurse -Force -ErrorAction Stop + Write-Info " Deleted: $DataPath" + } + catch { + # Report and continue. Aborting here would skip the closing report, and the operator + # would be left with a half-purged directory and no idea which parts survived. + $dataPathRemaining = $true + Write-Warn " Could not fully delete $DataPath : $($_.Exception.Message)" + $survivors = @(Get-ChildItem -LiteralPath $DataPath -Recurse -Force -File -ErrorAction SilentlyContinue) + Write-Warn " $($survivors.Count) file(s) remain. Delete the folder by hand, or reboot and retry." + } } } else { Write-Info " Data path not present." @@ -401,7 +439,48 @@ if ($installedThumbprint -and $isManagedAccount) { Write-Host "" if ($installPathRemaining -or $backupsRemaining -or $dataPathRemaining) { - Write-Warn 'Uninstall finished with leftovers (see above).' + # The marker and deploy\ stay, so the setup uninstaller and this script can be run again. + Write-Warn 'Uninstall finished with leftovers (see above). The installation marker and the deploy scripts were kept; run the uninstall again once the cause is fixed.' exit 1 } + +Write-Step "Removing the deploy scripts and the installation marker" +$recoveryHelperPath = Join-Path $InstallPath 'deploy' +if (Test-Path -LiteralPath $recoveryHelperPath) { + try { Remove-Item -LiteralPath $recoveryHelperPath -Recurse -Force -ErrorAction Stop } + catch { + Write-Warn " Could not delete $recoveryHelperPath : $($_.Exception.Message)" + Write-Warn 'Uninstall finished with leftovers. The installation marker was kept; run the uninstall again.' + exit 1 + } + Write-Info " Deleted: $recoveryHelperPath" +} +# Only when nothing is left. Inno removes its own uninstaller and the empty directory after this +# script returns. +if (Test-Path -LiteralPath $InstallPath) { + $remaining = @(Get-ChildItem -LiteralPath $InstallPath -Force -ErrorAction SilentlyContinue) + if ($remaining.Count -eq 0) { + Remove-Item -LiteralPath $InstallPath -Force -ErrorAction SilentlyContinue + Write-Info " Deleted: $InstallPath" + } + else { + Write-Info ' Leaving the setup uninstaller to remove itself and the directory.' + } +} +# Without this, a fresh install after an uninstall is misdetected as an upgrade forever - the +# setup wizard reads this key to choose between the two. +$markerPath = 'HKLM:\SOFTWARE\NodePilot\Server' +if (Test-Path -LiteralPath $markerPath) { + Remove-Item -LiteralPath $markerPath -Recurse -Force -ErrorAction SilentlyContinue + Write-Info " Removed: $markerPath" +} else { + Write-Info " No installation marker present." +} +# Drop the parent only when this uninstall emptied it; something else may live under it. +$markerParent = 'HKLM:\SOFTWARE\NodePilot' +if ((Test-Path -LiteralPath $markerParent) -and + -not (Get-ChildItem -LiteralPath $markerParent -ErrorAction SilentlyContinue) -and + -not (Get-Item -LiteralPath $markerParent -ErrorAction SilentlyContinue).GetValueNames()) { + Remove-Item -LiteralPath $markerParent -Force -ErrorAction SilentlyContinue +} Write-Ok "Uninstall complete." diff --git a/deploy/desktop/DesktopRuntime.ps1 b/deploy/desktop/DesktopRuntime.ps1 index fca41186..546949ec 100644 --- a/deploy/desktop/DesktopRuntime.ps1 +++ b/deploy/desktop/DesktopRuntime.ps1 @@ -84,6 +84,102 @@ function Remove-DesktopService { } } +function Get-DesktopServiceSnapshot { + # Reads everything the provisioner registers for a desktop service from its service key, so a + # failed update can register it again unchanged. The environment holds the database password + # and stays in memory only. Throws when the service is not registered. + param( + [Parameter(Mandatory)] [string] $Name, + [string] $ServicesRoot = 'HKLM:\SYSTEM\CurrentControlSet\Services' + ) + $keyPath = Join-Path $ServicesRoot $Name + if (-not (Test-Path -LiteralPath $keyPath)) { + throw "Service '$Name' is not registered. Repair the installation with setup before updating." + } + $key = Get-Item -LiteralPath $keyPath + $read = { + param([string] $ValueName) + $key.GetValue($ValueName, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + } + $objectName = [string](& $read 'ObjectName') + if (@('LocalSystem', 'NT AUTHORITY\NetworkService') -notcontains $objectName) { + throw "Service '$Name' runs as '$objectName'. Only LocalSystem and NetworkService registrations can be restored." + } + $imagePath = [string](& $read 'ImagePath') + if ([string]::IsNullOrWhiteSpace($imagePath)) { throw "Service '$Name' has no ImagePath." } + return [pscustomobject]@{ + Name = $Name + ImagePath = $imagePath + DisplayName = [string](& $read 'DisplayName') + ObjectName = $objectName + Start = [int](& $read 'Start') + ErrorControl = [int](& $read 'ErrorControl') + DependOnService = @(& $read 'DependOnService' | Where-Object { $_ }) + Description = & $read 'Description' + DelayedAutostart = & $read 'DelayedAutostart' + FailureActions = & $read 'FailureActions' + Environment = & $read 'Environment' + # -Path: Windows PowerShell's Get-Acl -LiteralPath does not find registry keys. + KeyAccess = (Get-Acl -Path $keyPath).GetSecurityDescriptorSddlForm( + [System.Security.AccessControl.AccessControlSections]::Access) + } +} + +function Restore-DesktopServiceSnapshots { + # Registers the services from Get-DesktopServiceSnapshot again, replacing any registration of + # the same name. Snapshots are given in dependency order (database first). Throws unless every + # service is registered afterwards. + param( + [Parameter(Mandatory)] [object[]] $Snapshots, + [string] $ServicesRoot = 'HKLM:\SYSTEM\CurrentControlSet\Services' + ) + for ($i = $Snapshots.Count - 1; $i -ge 0; $i--) { Remove-DesktopService -Name $Snapshots[$i].Name } + foreach ($snapshot in $Snapshots) { + $startMode = switch ($snapshot.Start) { + 2 { 'Automatic' } 3 { 'Manual' } 4 { 'Disabled' } + default { throw "Service '$($snapshot.Name)' has unsupported start type $($snapshot.Start)." } + } + $createArgs = @{ + Name = $snapshot.Name + DisplayName = $snapshot.DisplayName + PathName = $snapshot.ImagePath + ServiceType = [byte]16 + ErrorControl = [byte]$snapshot.ErrorControl + StartMode = $startMode + DesktopInteract = $false + StartName = $snapshot.ObjectName + } + if ($snapshot.ObjectName -ne 'LocalSystem') { $createArgs['StartPassword'] = '' } + if ($snapshot.DependOnService.Count -gt 0) { $createArgs['ServiceDependencies'] = [string[]]$snapshot.DependOnService } + $created = Invoke-CimMethod -ClassName Win32_Service -MethodName Create -Arguments $createArgs + if ($created.ReturnValue -ne 0) { + throw "Service '$($snapshot.Name)' could not be registered again (Win32_Service.Create=$($created.ReturnValue))." + } + + $keyPath = Join-Path $ServicesRoot $snapshot.Name + # The key ACL comes back before the environment, which holds the database password. + $keyAcl = Get-Acl -Path $keyPath + $keyAcl.SetSecurityDescriptorSddlForm($snapshot.KeyAccess, + [System.Security.AccessControl.AccessControlSections]::Access) + Set-Acl -Path $keyPath -AclObject $keyAcl + # The SCM reads these values from the key itself. + $values = @( + @{ Name = 'Description'; Value = $snapshot.Description; Type = 'String' }, + @{ Name = 'DelayedAutostart'; Value = $snapshot.DelayedAutostart; Type = 'DWord' }, + @{ Name = 'FailureActions'; Value = $snapshot.FailureActions; Type = 'Binary' }, + @{ Name = 'Environment'; Value = $snapshot.Environment; Type = 'MultiString' }) + foreach ($value in $values) { + if ($null -eq $value.Value) { continue } + New-ItemProperty -Path $keyPath -Name $value.Name -PropertyType $value.Type ` + -Value $value.Value -Force | Out-Null + } + if (-not (Test-DesktopServiceExists -Name $snapshot.Name)) { + throw "Service '$($snapshot.Name)' is not registered after restoring it." + } + Write-Host " Restored service '$($snapshot.Name)'" + } +} + function Stop-DesktopRuntime { # Ends everything that holds a file under InstallPath or the database cluster: the Electron # shell and the CLI/MCP clients, both services, and any PostgreSQL process that outlived its diff --git a/deploy/desktop/NodePilot.iss b/deploy/desktop/NodePilot.iss index 3318dd1c..e4bc8a71 100644 --- a/deploy/desktop/NodePilot.iss +++ b/deploy/desktop/NodePilot.iss @@ -203,6 +203,39 @@ begin 'The full log is at:' + #13#10 + GetEnv('TEMP') + '\nodepilot-setup-prepare.log'; end; +// SID of the account that started setup before elevation - the account the shell is launched as +// (runasoriginaluser). Empty when it cannot be determined. A check runs as that account and +// reports the position of its own SID in the machine's profile list as its exit code; no file +// another user could write is involved. The offset keeps PowerShell's own error codes out of range. +function OriginalUserSid(): String; +var + Sids: TArrayOfString; + List: String; + I, ResultCode: Integer; +begin + Result := ''; + if not RegGetSubkeyNames(HKLM64, 'SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList', Sids) then + Exit; + List := ''; + for I := 0 to GetArrayLength(Sids) - 1 do + begin + if I > 0 then + List := List + ','; + List := List + '''' + Sids[I] + ''''; + end; + try + if not ExecAsOriginalUser('powershell.exe', + '-NoProfile -NonInteractive -Command "exit ([array]::IndexOf([string[]]@(' + List + + '), [Security.Principal.WindowsIdentity]::GetCurrent().User.Value) + 1000)"', + '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then + Exit; + except + Exit; + end; + if (ResultCode >= 1000) and (ResultCode < 1000 + GetArrayLength(Sids)) then + Result := Sids[ResultCode - 1000]; +end; + // Runs elevated provisioning and reports its exit code. Failure does not roll back files or a // database that the user may still need; the installer reports the log instead. procedure ProvisionRuntime(); @@ -210,6 +243,8 @@ var ResultCode: Integer; ProvisionScript: String; LogPath: String; + Params: String; + SetupUserSid: String; begin ProvisionScript := ExpandConstant('{app}\deploy\Provision-LocalDb.ps1'); LogPath := GetEnv('TEMP') + '\nodepilot-provision.log'; @@ -217,9 +252,14 @@ begin WizardForm.StatusLabel.Caption := 'Provisioning local database and services (this can take a minute)...'; WizardForm.Refresh(); - if not Exec('powershell.exe', - '-NoProfile -ExecutionPolicy Bypass -File "' + ProvisionScript + '" -InstallPath "' + ExpandConstant('{app}') + '"', - '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then + Params := '-NoProfile -ExecutionPolicy Bypass -File "' + ProvisionScript + '" -InstallPath "' + + ExpandConstant('{app}') + '"'; + // Without it the provisioner refuses to hand the first-run token to anyone. + SetupUserSid := OriginalUserSid(); + if SetupUserSid <> '' then + Params := Params + ' -HandoffUserSid "' + SetupUserSid + '"'; + + if not Exec('powershell.exe', Params, '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then begin ProvisionOk := False; SuppressibleMsgBox('NodePilot could not start Windows PowerShell to set up its database and services.'#13#10#13#10 @@ -300,10 +340,16 @@ begin ScriptPath := ExpandConstant('{app}\deploy\Uninstall-Desktop.ps1'); if not FileExists(ScriptPath) then begin + // Without the script the services can only be gone already; otherwise keep everything. + if not RegKeyExists(HKLM64, 'SYSTEM\CurrentControlSet\Services\NodePilot') and + not RegKeyExists(HKLM64, 'SYSTEM\CurrentControlSet\Services\NodePilotDb') then Exit; SuppressibleMsgBox('The NodePilot uninstall script is missing:' + #13#10 + ScriptPath + #13#10#13#10 + - 'The NodePilot services, the certificate and the data have NOT been removed.', + 'The NodePilot services, the certificate and the data have NOT been removed, and this ' + + 'uninstall was stopped. Install NodePilot again, then uninstall it.', mbCriticalError, MB_OK, IDOK); - Exit; + // Abort in usUninstall ends the uninstaller with a non-zero exit code before Inno removes + // any file or registry entry, so the installation stays retryable. + Abort; end; Params := '-NoProfile -ExecutionPolicy Bypass -File "' + ScriptPath + '" -InstallPath "' + @@ -312,10 +358,20 @@ begin Params := Params + ' -PurgeData'; if not Exec('powershell.exe', Params, '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then - SuppressibleMsgBox('Could not start Windows PowerShell to remove the NodePilot services.', - mbCriticalError, MB_OK, IDOK) + begin + SuppressibleMsgBox('Could not start Windows PowerShell to remove the NodePilot services. ' + + 'Nothing was removed; run the uninstall again.', + mbCriticalError, MB_OK, IDOK); + Abort; + end else if ResultCode <> 0 then + begin + // Uninstall-Desktop.ps1 is safe to repeat, and the abort keeps the program files, the + // scripts and the Apps & Features entry it needs for that. SuppressibleMsgBox('NodePilot could not be removed completely (exit code ' + IntToStr(ResultCode) + ').' + #13#10#13#10 + + 'The uninstall was stopped and NodePilot stays installed. Fix the problem, then uninstall again. ' + 'The full log is at:' + #13#10 + GetEnv('TEMP') + '\nodepilot-uninstall.log', mbError, MB_OK, IDOK); + Abort; + end; end; diff --git a/deploy/desktop/Provision-LocalDb.ps1 b/deploy/desktop/Provision-LocalDb.ps1 index b4fc9516..729a40c0 100644 --- a/deploy/desktop/Provision-LocalDb.ps1 +++ b/deploy/desktop/Provision-LocalDb.ps1 @@ -41,9 +41,10 @@ param( [int] $PgPortRangeEnd = 47149, [string] $DbName = 'nodepilot', [string] $DbRole = 'nodepilot', - # Where the first-run handoff is written. Normally resolved from the interactive (console) - # user, because Inno launches the Electron shell as that user ([Run] runasoriginaluser) and - # the shell reads their %LOCALAPPDATA%. Override only for a dev loop or a test. + # SID of the account that started setup before elevation. Inno launches the Electron shell as + # that account ([Run] runasoriginaluser), so the first-run handoff goes to its %LOCALAPPDATA%. + [string] $HandoffUserSid, + # Overrides the handoff directory for a dev loop or a test. [string] $HandoffUserProfile ) @@ -100,47 +101,75 @@ function New-RandomSecret([int] $bytes = 32) { return ([Convert]::ToBase64String($buf)) -replace '[+/=]', '' } -# The interactive (console) user, as SID + %LOCALAPPDATA%, or $null when there is none. -# -# Inno runs the installer elevated but launches the shell with `runasoriginaluser`, so the account -# running this script is not necessarily the account the shell runs as. The handoff must land in -# the console user's profile, otherwise the shell never finds it. Win32_ComputerSystem.UserName -# names that user; the profile path comes from the ProfileList registry rather than a string built -# from the user name, so redirected or renamed profiles still resolve. -function Get-InteractiveUserProfile { +# SID of the user signed in at the console, or $null when there is none. +function Get-DesktopConsoleUserSid { try { $userName = (Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop).UserName + if ([string]::IsNullOrWhiteSpace($userName)) { return $null } + return (New-Object System.Security.Principal.NTAccount($userName)).Translate( + [System.Security.Principal.SecurityIdentifier]).Value } catch { + Write-Warning "Could not resolve the console user: $($_.Exception.Message)" return $null } - if ([string]::IsNullOrWhiteSpace($userName)) { return $null } +} +# Where the first-run handoff goes and which SID may read it. +# +# Inno launches the shell with `runasoriginaluser`, as the account that started setup before UAC +# elevation. That account is identified by setup and passed as -HandoffUserSid; neither this +# process (possibly another administrator) nor the console user (wrong for RDP and switched +# users) is a substitute. The exception is setup run as SYSTEM or another service account by +# software distribution: no shell runs as that account, so the console user gets the handoff, +# or this process's profile when nobody is signed in. The profile comes from the ProfileList +# registry, so redirected or renamed profiles still resolve. -HandoffUserProfile is the dev-loop +# override. Throws when the account is unknown or has no profile. +function Resolve-DesktopHandoffTarget { + param([string] $HandoffUserProfile, [string] $HandoffUserSid) + if (-not [string]::IsNullOrWhiteSpace($HandoffUserProfile)) { + return [pscustomobject]@{ + LocalAppData = $HandoffUserProfile + Sid = ([System.Security.Principal.WindowsIdentity]::GetCurrent()).User + Source = '-HandoffUserProfile' + } + } + if ([string]::IsNullOrWhiteSpace($HandoffUserSid)) { + throw ('The account that started setup is unknown, so the first-run setup token was not handed ' + + 'over. Run setup again, or pass -HandoffUserSid. See docs/desktop-troubleshooting.md.') + } try { - $sid = (New-Object System.Security.Principal.NTAccount($userName)).Translate( - [System.Security.Principal.SecurityIdentifier]) + $sid = New-Object System.Security.Principal.SecurityIdentifier($HandoffUserSid) } catch { - Write-Warning "Could not resolve the SID of interactive user '$userName': $($_.Exception.Message)" - return $null + throw "-HandoffUserSid '$HandoffUserSid' is not a SID." + } + if (@('S-1-5-18', 'S-1-5-19', 'S-1-5-20') -contains $sid.Value) { + $consoleSid = Get-DesktopConsoleUserSid + if (-not $consoleSid) { + Write-Warning ('Setup ran as a service account and nobody is signed in, so the first-run setup ' + + 'token stays with this account. See docs/desktop-troubleshooting.md.') + return [pscustomobject]@{ + LocalAppData = $env:LOCALAPPDATA + Sid = ([System.Security.Principal.WindowsIdentity]::GetCurrent()).User + Source = 'this process (setup ran as a service account, nobody signed in)' + } + } + $sid = New-Object System.Security.Principal.SecurityIdentifier($consoleSid) } - $profileKey = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\$($sid.Value)" try { $profilePath = (Get-ItemProperty -Path $profileKey -Name 'ProfileImagePath' -ErrorAction Stop).ProfileImagePath } catch { - Write-Warning "No profile directory registered for interactive user '$userName' ($($sid.Value))." - return $null + throw "No profile directory is registered for $($sid.Value), the account that started setup." } # ProfileImagePath is commonly a REG_EXPAND_SZ holding %SystemDrive%\Users\. $profilePath = [Environment]::ExpandEnvironmentVariables($profilePath) if ([string]::IsNullOrWhiteSpace($profilePath) -or -not (Test-Path -LiteralPath $profilePath)) { - Write-Warning "Profile directory '$profilePath' for '$userName' does not exist." - return $null + throw "Profile directory '$profilePath' of $($sid.Value), the account that started setup, does not exist." } - return [pscustomobject]@{ - UserName = $userName - Sid = $sid LocalAppData = (Join-Path $profilePath 'AppData\Local') + Sid = $sid + Source = if ($sid.Value -eq $HandoffUserSid) { "setup user $($sid.Value)" } else { "console user $($sid.Value), setup ran as $HandoffUserSid" } } } @@ -661,29 +690,10 @@ if (Test-Path -LiteralPath $tokenPath) { & takeown.exe /f $tokenPath /a | Out-Null & icacls.exe $tokenPath /grant '*S-1-5-32-544:(R)' | Out-Null - # Resolve which profile the handoff belongs in and which SID may read it: the explicit - # parameter wins, then the interactive user, then this process - the last being correct only - # when the installing user elevated their own session. - $handoffOwnerSid = $null - if (-not [string]::IsNullOrWhiteSpace($HandoffUserProfile)) { - $handoffBase = $HandoffUserProfile - Write-Host " Handoff target from -HandoffUserProfile: $handoffBase" - } else { - $interactive = Get-InteractiveUserProfile - if ($interactive) { - $handoffBase = $interactive.LocalAppData - $handoffOwnerSid = $interactive.Sid - Write-Host " Handoff target: interactive user $($interactive.UserName)" - } else { - $handoffBase = $env:LOCALAPPDATA - Write-Warning ("No interactive user could be determined; falling back to this process's " + - "profile. If the installer was elevated with a different account, the " + - "first-run setup page will not appear - see docs/desktop-troubleshooting.md.") - } - } - if ($null -eq $handoffOwnerSid) { - $handoffOwnerSid = ([System.Security.Principal.WindowsIdentity]::GetCurrent()).User - } + $handoffTarget = Resolve-DesktopHandoffTarget -HandoffUserProfile $HandoffUserProfile -HandoffUserSid $HandoffUserSid + $handoffBase = $handoffTarget.LocalAppData + $handoffOwnerSid = $handoffTarget.Sid + Write-Host " Handoff target: $($handoffTarget.Source)" $handoffDir = Join-Path $handoffBase 'NodePilot' New-Item -ItemType Directory -Force -Path $handoffDir | Out-Null diff --git a/deploy/desktop/README.md b/deploy/desktop/README.md index ca802a6a..c0eee0d5 100644 --- a/deploy/desktop/README.md +++ b/deploy/desktop/README.md @@ -106,11 +106,17 @@ service-environment value. **Which user is "that user" is resolved explicitly, not assumed.** The installer runs elevated but launches the shell with `runasoriginaluser`, so the two are different principals whenever a standard user elevates with someone *else's* administrator credentials — the normal case on a - managed machine. `Get-InteractiveUserProfile` therefore resolves the console user via - `Win32_ComputerSystem.UserName` and reads their profile directory out of the `ProfileList` - registry, instead of using the elevated process's own `%LOCALAPPDATA%`. Inno's `{localappdata}` - would be no better: it expands in the elevated context too. `-HandoffUserProfile` overrides the - resolution for the dev loop and for tests. Getting this wrong strands the user on a login form + managed machine. The console user is no substitute either: under RDP or a switched user it is a + different account. Setup therefore runs a check *as the original user* (`ExecAsOriginalUser`), + which reports its own SID's position in the `ProfileList` registry through its exit code, and + passes that SID as `-HandoffUserSid`. `Resolve-DesktopHandoffTarget` reads the profile directory + for exactly that SID; Inno's `{localappdata}` would be no better, it expands in the elevated + context. When the account is unknown or has no profile, provisioning fails explicitly instead of + picking another profile. Setup run as SYSTEM or another service account by software distribution + is the exception: no shell runs as that account, so the user signed in at the console gets the + handoff, or the service account itself when nobody is signed in. `Update-Desktop.ps1` passes the account running the update unless + `-HandoffUserSid` names another; `-HandoffUserProfile` overrides the directory for the dev loop + and for tests. Getting this wrong strands the user on a login form for an account that does not exist yet, with the only remaining token copy SYSTEM-owned — recovery steps are in [`docs/desktop-troubleshooting.md`](../../docs/desktop-troubleshooting.md). 3. Electron shows a **local** setup page whose only bridge is `completeAdminSetup({username,password})`. @@ -233,6 +239,11 @@ Running the Electron shell straight from source (`npm start`, see below) starts recreates them, and reuses the cluster (`initdb` is skipped when `pgdata\PG_VERSION` exists), so data survives. `Update-Desktop.ps1` also implements a full staged update with binary + config + DB rollback for direct/advanced use. Postgres **major** upgrades are out of scope for v1. + Before anything changes, the staged update snapshots both service registrations (binary path, + account, start type, dependencies, failure actions, environment with the connection string, key + ACL); it refuses to start when either service is missing. The rollback registers both again from + that snapshot before the database restore, because re-provisioning may have deleted them, and a + service that cannot be registered or started fails the rollback explicitly. During staged rollback only PostgreSQL starts first; the API stays stopped until the snapshot restore succeeds. A failed restore stops recovery with an explicit error and the backup path for manual recovery. The offline `deploy/Test-DesktopUpdate.ps1` checks this ordering and the @@ -243,8 +254,10 @@ Running the Electron shell straight from source (`npm start`, see below) starts clients, deletes both services, removes the certificate **with its private key**, the setup handoffs and the rendered configuration; with `-PurgeData` also `ProgramData\NodePilot` (taking ownership of what the API and Postgres protected) and the `NodePilot` folders in every profile (`%APPDATA%`: shell - session + `np`/`nodepilot-mcp` config, `%LOCALAPPDATA%`: handoff). It exits 1 when anything is left, - and the uninstaller reports `%TEMP%\nodepilot-uninstall.log`. + session + `np`/`nodepilot-mcp` config, `%LOCALAPPDATA%`: handoff). It exits 1 when anything is left; + the uninstaller then reports `%TEMP%\nodepilot-uninstall.log` and aborts before Inno removes the + program files or the "Apps & features" entry, with a non-zero exit code, so the uninstall can be + run again once the cause is fixed. - **Antivirus:** the installer sets no AV exclusions. Electron's Chromium native DLLs, Postgres' WAL I/O and the generated `%TEMP%\nodepilot_*.ps1` scripts are the usual false-positive sources — a hand-off list with per-entry rationale and residual risk is in [`docs/av-exclusions.md`](../../docs/av-exclusions.md). diff --git a/deploy/desktop/Update-Desktop.ps1 b/deploy/desktop/Update-Desktop.ps1 index f712ec6a..b02ca310 100644 --- a/deploy/desktop/Update-Desktop.ps1 +++ b/deploy/desktop/Update-Desktop.ps1 @@ -22,6 +22,9 @@ param( # Directory containing the new app\ desktop\ pgsql\ deploy\ tools\ payload (full-update mode only). [string] $NewArtifactPath, [int] $KeepBackupCount = 3, + # Receives the first-run setup token when no administrator exists yet: the account running + # this update unless another SID is given. + [string] $HandoffUserSid = ([System.Security.Principal.WindowsIdentity]::GetCurrent()).User.Value, [switch] $BackupOnly ) @@ -120,9 +123,18 @@ foreach ($component in $components) { } $backupFile = New-DatabaseBackup +# The provisioner deletes and re-registers both services. Their old registrations, including the +# API's connection string, are needed to restore the database and to start the old binaries. +$serviceSnapshots = @( + Get-DesktopServiceSnapshot -Name $DbServiceName + Get-DesktopServiceSnapshot -Name $ApiServiceName +) $rollbackRoot = Join-Path $DataPath ("rollback\{0}" -f (Get-Date).ToString('yyyyMMdd-HHmmss')) New-Item -ItemType Directory -Force -Path $rollbackRoot | Out-Null $swappedComponents = New-Object 'System.Collections.Generic.List[string]' +# The data directory's ACL propagates into the rollback copies, so a restored component gets the +# access it had in the install directory back from here. +$componentAcls = @{} $previousDesktopJsonBytes = $null $previousDesktopJsonAcl = $null if (Test-Path -LiteralPath $DesktopJson) { @@ -138,7 +150,10 @@ function Stop-Everything { function Start-Services { foreach ($svc in @($DbServiceName, $ApiServiceName)) { - if (Get-Service -Name $svc -ErrorAction SilentlyContinue) { & sc.exe start $svc | Out-Null } + if (-not (Get-Service -Name $svc -ErrorAction SilentlyContinue)) { + throw "Service '$svc' is not registered and cannot be started." + } + & sc.exe start $svc | Out-Null Start-Sleep -Seconds 2 } } @@ -192,7 +207,10 @@ try { foreach ($c in $components) { $cur = Join-Path $InstallPath $c $new = Join-Path $NewArtifactPath $c - if (Test-Path -LiteralPath $cur) { Move-Item -LiteralPath $cur -Destination (Join-Path $rollbackRoot $c) -Force } + if (Test-Path -LiteralPath $cur) { + $componentAcls[$c] = Get-Acl -LiteralPath $cur + Move-Item -LiteralPath $cur -Destination (Join-Path $rollbackRoot $c) -Force + } # Record before copy so a partially copied new component is removed on failure, even # when the previous installation did not yet contain that component. $swappedComponents.Add($c) @@ -200,7 +218,9 @@ try { } Write-Step 'Re-provisioning' - & powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $InstallPath 'deploy\Provision-LocalDb.ps1') -InstallPath $InstallPath + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $InstallPath 'deploy\Provision-LocalDb.ps1') ` + -InstallPath $InstallPath -DataPath $DataPath -ApiServiceName $ApiServiceName -DbServiceName $DbServiceName ` + -HandoffUserSid $HandoffUserSid if ($LASTEXITCODE -ne 0) { throw "Provisioner exited with code $LASTEXITCODE." } if (-not (Test-Ready)) { throw 'Health check failed after update.' } @@ -218,6 +238,9 @@ catch { if (Test-Path -LiteralPath $cur) { Remove-Item -LiteralPath $cur -Recurse -Force } if (Test-Path -LiteralPath $saved) { Move-Item -LiteralPath $saved -Destination $cur -Force + # Re-applying the original ACL re-propagates inheritance from the install directory, + # so NetworkService can run PostgreSQL again. + if ($componentAcls.ContainsKey($c)) { Set-Acl -LiteralPath $cur -AclObject $componentAcls[$c] } } } # The shell handoff lives outside the swapped binaries. Restore its origin and ACL @@ -228,6 +251,9 @@ catch { } elseif (Test-Path -LiteralPath $DesktopJson) { Remove-Item -LiteralPath $DesktopJson -Force -ErrorAction Stop } + # The provisioner may have deleted both services or registered new ones. The restore + # below reads the API registration and starts the database service. + Restore-DesktopServiceSnapshots -Snapshots $serviceSnapshots # The API migrates and writes the database at startup. Keep it stopped until the # complete snapshot has been restored, and leave it stopped if restoration fails. Restore-DatabaseBackup diff --git a/deploy/server/Build-ServerInstaller.ps1 b/deploy/server/Build-ServerInstaller.ps1 index 4dfd3ac4..64cbae46 100644 --- a/deploy/server/Build-ServerInstaller.ps1 +++ b/deploy/server/Build-ServerInstaller.ps1 @@ -144,6 +144,7 @@ Write-Step 'Staging the deployment scripts' $deployScripts = @( 'ArtifactSecurity.ps1' 'DeploymentProgress.ps1' + 'LocalServerHttp.ps1' 'MachinePath.ps1' 'SwitcherConfig.ps1' 'Preflight.ps1' diff --git a/deploy/server/NodePilotServer.iss b/deploy/server/NodePilotServer.iss index 8d4845ec..ce01bd7a 100644 --- a/deploy/server/NodePilotServer.iss +++ b/deploy/server/NodePilotServer.iss @@ -2234,11 +2234,16 @@ begin ScriptPath := ExpandConstant('{app}\deploy\Uninstall-NodePilot.ps1'); if not FileExists(ScriptPath) then begin + // The script removes the marker only after a complete cleanup, so without the marker there + // is nothing left for it to do and Inno may finish. + if not RegKeyExists(HKLM64, 'SOFTWARE\NodePilot\Server') then Exit; SuppressibleMsgBox('The NodePilot uninstall script is missing:' + #13#10 + ScriptPath + #13#10#13#10 + - 'The Windows service and its firewall rules have NOT been removed. Run ' + - 'Uninstall-NodePilot.ps1 by hand, or reinstall and uninstall again.', + 'The Windows service and its firewall rules have NOT been removed, and this uninstall ' + + 'was stopped. Reinstall NodePilot Server, then uninstall again.', mbCriticalError, MB_OK, IDOK); - Exit; + // Abort in usUninstall ends the uninstaller with a non-zero exit code before Inno removes + // any file or registry entry, so the installation stays retryable. + Abort; end; // Built HERE, at uninstall time. This is the whole reason the [UninstallRun] section is gone. @@ -2273,15 +2278,24 @@ begin Arguments := Arguments + Switches; if not Exec('powershell.exe', Arguments, '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then - SuppressibleMsgBox('Could not start PowerShell to remove the NodePilot service.', - mbCriticalError, MB_OK, IDOK) + begin + SuppressibleMsgBox('Could not start PowerShell to remove the NodePilot service. ' + + 'Nothing was removed; run the uninstall again.', + mbCriticalError, MB_OK, IDOK); + Abort; + end else if ResultCode <> 0 then - // Reported, not swallowed. The script exits non-zero when it had to leave files behind, and - // an operator who is told that can act on it. - SuppressibleMsgBox('The NodePilot uninstall reported problems (exit code ' + + begin + // The script exits non-zero while something is left. It keeps the marker and deploy\ in + // that case, and the abort keeps the uninstaller and its Apps & Features entry, so the + // same uninstall can be repeated once the cause is fixed. + SuppressibleMsgBox('The NodePilot uninstall could not finish (exit code ' + IntToStr(ResultCode) + ').' + #13#10#13#10 + - 'Check %TEMP% for details; some files or the Windows service may still be present.', + 'The uninstall was stopped and NodePilot Server stays registered. Fix the problem ' + + 'reported by Uninstall-NodePilot.ps1, then uninstall again.', mbError, MB_OK, IDOK); + Abort; + end; end; if CurUninstallStep = usPostUninstall then diff --git a/docs/desktop-troubleshooting.md b/docs/desktop-troubleshooting.md index 3d681722..8f92e6de 100644 --- a/docs/desktop-troubleshooting.md +++ b/docs/desktop-troubleshooting.md @@ -38,13 +38,16 @@ It appears because the installer leaves a one-shot token at **Before 1.2.11 this could land in the wrong profile.** The installer runs elevated; if a standard user started it and entered a *different* administrator's credentials at the UAC prompt, the token went to the administrator's profile while the app ran as the standard user, which never found it. -The installer now resolves the interactive user explicitly, so a fresh install of 1.2.11 or later -does not hit this. +From 1.2.11 the installer resolved the interactive console user, which is still a different account +under RDP or a switched user. Setup now passes the SID of the account that started it to the +provisioner and fails explicitly when that account has no profile, instead of picking another one. +Setup run as SYSTEM by software distribution hands the token to the user signed in at the console; +with nobody signed in it stays with SYSTEM, and Option B below recovers it. If you are on an affected installation, recover it in one of two ways. **Option A — reinstall (simplest).** The setup token only exists while no user account exists, so -re-running the installer of 1.2.11 or later puts the handoff in the right place. Nothing is lost: +re-running a current installer puts the handoff in the right place. Nothing is lost: the database is preserved across a reinstall. **Option B — read the token by hand.** In an **elevated** PowerShell: diff --git a/src/nodepilot-docs-ui/content/de/deployment/desktop.md b/src/nodepilot-docs-ui/content/de/deployment/desktop.md index 7e96b149..f8ab16cc 100644 --- a/src/nodepilot-docs-ui/content/de/deployment/desktop.md +++ b/src/nodepilot-docs-ui/content/de/deployment/desktop.md @@ -163,7 +163,7 @@ Der Installer: - erstellt `desktop.json`, - übergibt den einmaligen Setup-Token geschützt an die Electron-Shell. -Der Setup-Token geht in das Profil des **interaktiven** Benutzers — also des Benutzers, unter dem die Shell anschließend läuft. Das ist nicht zwingend derjenige, der den Installer startet: Der Installer läuft eleviert, und gibt ein Standardbenutzer am UAC-Dialog die Anmeldedaten eines *anderen* Administratorkontos ein, sind das zwei verschiedene Profile. Der Installer ermittelt den interaktiven Benutzer deshalb ausdrücklich, statt vom eigenen Profil auszugehen. +Der Setup-Token geht in das Profil des Benutzers, der den Installer **gestartet** hat — also des Benutzers, unter dem die Shell anschließend läuft. Das ist nicht zwingend das Konto, unter dem der Installer läuft: Der Installer läuft eleviert, und gibt ein Standardbenutzer am UAC-Dialog die Anmeldedaten eines *anderen* Administratorkontos ein, sind das zwei verschiedene Profile. Auch der Konsolenbenutzer taugt nicht als Ersatz, denn bei Remotedesktop oder einem Benutzerwechsel ist das jemand anderes. Der Installer ermittelt deshalb das Konto, das ihn gestartet hat, und übergibt den Token genau diesem Konto. Lässt sich das Konto nicht ermitteln, bricht die Provisionierung mit einer Meldung ab, statt den Token einem anderen Profil zu geben. Startet eine Softwareverteilung den Installer als SYSTEM, läuft unter diesem Konto keine Shell; der Token geht dann an den an der Konsole angemeldeten Benutzer oder bleibt bei SYSTEM, wenn niemand angemeldet ist. Scheitert die Provisionierung, meldet der Installer das mit einem Verweis auf sein Log unter `%TEMP%\nodepilot-provision.log` und startet die Shell nicht — statt eine erfolgreiche Installation zu melden, die anschließend nicht startet. Ursachen und Abhilfen stehen im [Desktop-Troubleshooting](https://github.com/Sev7eNup/NodePilot/blob/main/docs/desktop-troubleshooting.md), eine Übersicht aller Logdateien unter [Logs & Diagnose](logs). diff --git a/src/nodepilot-docs-ui/content/de/deployment/production.md b/src/nodepilot-docs-ui/content/de/deployment/production.md index 4fef5126..c85adddf 100644 --- a/src/nodepilot-docs-ui/content/de/deployment/production.md +++ b/src/nodepilot-docs-ui/content/de/deployment/production.md @@ -678,6 +678,10 @@ Nach einer Installation über das GUI-Setup geht dasselbe über „Apps & Featur & 'C:\Program Files\NodePilot\unins000.exe' /VERYSILENT /SUPPRESSMSGBOXES /PURGEDATA=1 ``` +Lässt sich etwas nicht entfernen, bricht die Deinstallation mit einem Exit-Code ungleich 0 ab und +behält den Installations-Marker, die Deploy-Skripte und den Eintrag unter „Apps & Features". Die +gemeldete Ursache beheben und dieselbe Deinstallation erneut ausführen. + **Die Datenbank wird nie entfernt, und es gibt dafür keine Option.** NodePilot legt sie nicht an — sie wurde in Kapitel 2 separat bereitgestellt, hat oft ein eigenes Backup- und Replikationsregime, und in einem Active/Passive-Cluster teilen sich beide Knoten dieselbe. Aus diff --git a/src/nodepilot-docs-ui/content/en/deployment/desktop.md b/src/nodepilot-docs-ui/content/en/deployment/desktop.md index b3fe1111..831147a0 100644 --- a/src/nodepilot-docs-ui/content/en/deployment/desktop.md +++ b/src/nodepilot-docs-ui/content/en/deployment/desktop.md @@ -162,11 +162,15 @@ The installer: - creates `desktop.json`, - hands the one-time setup token to the Electron shell in a protected way. -The setup token goes to the profile of the **interactive** user — that is, the user the shell then -runs as. That is not necessarily the user who runs the installer: the installer runs elevated, and if -a standard user enters the credentials of a *different* administrator account at the UAC prompt, -those are two different profiles. The installer therefore resolves the interactive user explicitly -instead of assuming its own profile. +The setup token goes to the profile of the user who **started** the installer — the user the shell +then runs as. That is not necessarily the account the installer runs under: the installer runs +elevated, and if a standard user enters the credentials of a *different* administrator account at +the UAC prompt, those are two different profiles. The console user is no substitute either, since +under Remote Desktop or a switched user it is someone else. The installer therefore identifies the +account that started it and hands the token to exactly that account. If that account cannot be +determined, provisioning fails with a message instead of handing the token to another profile. +When software distribution runs the installer as SYSTEM, no shell runs as that account; the token +then goes to the user signed in at the console, or stays with SYSTEM when nobody is signed in. If the provisioning fails, the installer reports it with a pointer to its log at `%TEMP%\nodepilot-provision.log` and does not start the shell — rather than reporting a successful diff --git a/src/nodepilot-docs-ui/content/en/deployment/production.md b/src/nodepilot-docs-ui/content/en/deployment/production.md index 8f9d929c..c4e7ba45 100644 --- a/src/nodepilot-docs-ui/content/en/deployment/production.md +++ b/src/nodepilot-docs-ui/content/en/deployment/production.md @@ -676,6 +676,10 @@ After an installation through the GUI setup, the same is possible through "Apps & 'C:\Program Files\NodePilot\unins000.exe' /VERYSILENT /SUPPRESSMSGBOXES /PURGEDATA=1 ``` +If something cannot be removed, the uninstall stops with a non-zero exit code and keeps the +installation marker, the deploy scripts and the "Apps & features" entry. Fix the cause it reports +and run the same uninstall again. + **The database is never removed, and there is no option for it.** NodePilot does not create it — it was provisioned separately in chapter 2, often has its own backup and replication regime, and in an active/passive cluster both nodes share it. For the same reason, the gMSA's "Log on as a service"