diff --git a/playbook/shopfloor-setup/Shopfloor/Register-MapSfldShare.ps1 b/playbook/shopfloor-setup/Shopfloor/Register-MapSfldShare.ps1 index cf1f9ef..58ff058 100644 --- a/playbook/shopfloor-setup/Shopfloor/Register-MapSfldShare.ps1 +++ b/playbook/shopfloor-setup/Shopfloor/Register-MapSfldShare.ps1 @@ -11,7 +11,10 @@ param( [int]$IntervalMinutes = 3, - [string]$InstallRoot = 'C:\Program Files\GE\SfldShare' + [string]$InstallRoot = 'C:\Program Files\GE\SfldShare', + # SHA256 the manifest entry detects on. When it differs from the share + # file the manifest was not bumped with the script; warn loudly once. + [string]$ExpectedHash = '' ) $ErrorActionPreference = 'Continue' @@ -32,8 +35,6 @@ function Write-RegLog { Write-Host $line } -Write-RegLog '=== Register-MapSfldShare start ===' - # Self-contained PC types never get S: (Display kiosk: local accounts, no # SFLD creds, mapping could only fail). Gate here as well as at the call # site - see the 579C144 note in the previous version. @@ -53,6 +54,36 @@ if ($pcType -and ($selfContainedTypes -contains $pcType)) { # --- stage the script ---------------------------------------------------- $src = Join-Path $PSScriptRoot 'lib\Map-SfldShare.ps1' if (-not (Test-Path $src)) { Write-RegLog "Map-SfldShare.ps1 not found at $src - cannot register"; exit 1 } +$srcHash = (Get-FileHash -Path $src -Algorithm SHA256).Hash +$stampKey = 'HKLM:\SOFTWARE\GE\SfldShare' + +# Manifest / share drift guard. The GE-Enforce entry detects on +# MapperHash == DetectionValue, so a share edit without a DetectionValue bump +# makes the engine call this every cycle. Say so once per hash, and take the +# fast path below so that loop costs nothing and never churns the task. +if ($ExpectedHash -and ($ExpectedHash -ne $srcHash)) { + $warned = (Get-ItemProperty -Path $stampKey -Name DriftWarnedHash -EA SilentlyContinue).DriftWarnedHash + if ($warned -ne $srcHash) { + Write-RegLog "WARN: share Map-SfldShare.ps1 is $srcHash but the manifest DetectionValue is $ExpectedHash - bump DetectionValue in common/manifest.json or the engine re-runs this every cycle" + if (-not (Test-Path $stampKey)) { New-Item -Path $stampKey -Force | Out-Null } + Set-ItemProperty -Path $stampKey -Name DriftWarnedHash -Value $srcHash -Type String -EA SilentlyContinue + } +} + +# Fast path: staged copy already matches the share and the task exists with +# our action -> nothing to (re)do. Re-stamp and leave (no Register -Force, +# so a live task is never torn down for no reason). +$taskNow = Get-ScheduledTask -TaskName $taskName -EA SilentlyContinue +if ($taskNow -and (Test-Path $mapScript) -and + ((Get-FileHash -Path $mapScript -Algorithm SHA256).Hash -eq $srcHash) -and + ($taskNow.Actions[0].Execute -like '*conhost.exe') -and + ($taskNow.Actions[0].Arguments -like "*$mapScript*")) { + if (-not (Test-Path $stampKey)) { New-Item -Path $stampKey -Force | Out-Null } + Set-ItemProperty -Path $stampKey -Name MapperHash -Value $srcHash -Type String -EA SilentlyContinue + exit 0 +} + +Write-RegLog '=== Register-MapSfldShare start ===' if (-not (Test-Path $InstallRoot)) { New-Item -Path $InstallRoot -ItemType Directory -Force | Out-Null } Copy-Item -Path $src -Destination $mapScript -Force Write-RegLog "Staged $src -> $mapScript" @@ -69,9 +100,14 @@ if (Get-ScheduledTask -TaskName $legacyTask -EA SilentlyContinue) { # --- task ------------------------------------------------------------------ try { + # conhost --headless, not powershell -WindowStyle Hidden: on Win11 the + # default console host is Windows Terminal, which ignores the hosted + # app's Hidden style and flashes a window + steals foreground for + # ~0.5 s on every fire (measured in the VM). Headless conhost shows + # nothing and moves focus nowhere. $ps = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" - $action = New-ScheduledTaskAction -Execute $ps ` - -Argument ('-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "{0}"' -f $mapScript) + $action = New-ScheduledTaskAction -Execute "$env:SystemRoot\System32\conhost.exe" ` + -Argument ('--headless "{0}" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "{1}"' -f $ps, $mapScript) $ns = 'Root/Microsoft/Windows/TaskScheduler' $triggers = New-Object System.Collections.ArrayList diff --git a/playbook/shopfloor-setup/Shopfloor/lib/Map-SfldShare.ps1 b/playbook/shopfloor-setup/Shopfloor/lib/Map-SfldShare.ps1 index f5e0f16..1e81068 100644 --- a/playbook/shopfloor-setup/Shopfloor/lib/Map-SfldShare.ps1 +++ b/playbook/shopfloor-setup/Shopfloor/lib/Map-SfldShare.ps1 @@ -59,15 +59,23 @@ function Get-State { Key = $k FailCount = [int]($p.FailCount) LastState = [string]$p.LastState - Seeded = [int]($p.CredSeeded) + CredHash = [string]$p.CredHash # SHA256(user + password) last seeded } } function Set-State { - param([string]$Key, [int]$FailCount, [string]$LastState, [int]$Seeded = -1) + param([string]$Key, [int]$FailCount, [string]$LastState, [string]$CredHash = '') Set-ItemProperty -Path $Key -Name FailCount -Value $FailCount -Type DWord -EA SilentlyContinue Set-ItemProperty -Path $Key -Name LastState -Value $LastState -Type String -EA SilentlyContinue Set-ItemProperty -Path $Key -Name LastRun -Value (Get-Date -Format 's') -Type String -EA SilentlyContinue - if ($Seeded -ge 0) { Set-ItemProperty -Path $Key -Name CredSeeded -Value $Seeded -Type DWord -EA SilentlyContinue } + # only overwrite the seeded-credential hash when the caller supplies one + if ($CredHash) { Set-ItemProperty -Path $Key -Name CredHash -Value $CredHash -Type String -EA SilentlyContinue } +} +function Get-CredHash { + param([string]$User, [string]$Password) + $sha = [System.Security.Cryptography.SHA256]::Create() + $b = $sha.ComputeHash([Text.Encoding]::UTF8.GetBytes("$User`n$Password")) + $sha.Dispose() + return ([BitConverter]::ToString($b) -replace '-', '') } # --- probes ------------------------------------------------------------- @@ -147,8 +155,34 @@ public static class GeCredStore { # --- mapping ------------------------------------------------------------ function New-Mapping { - param([string]$Drive, [string]$Unc, [string]$User, [string]$Password, [string]$HostName) + param([string]$Drive, [string]$Unc, [string]$User, [string]$Password, [string]$HostName, [bool]$PreFlight = $false) $letter = $Drive.TrimEnd(':') + + # When a mapping (wrong or unreadable) is being replaced, prove the new + # one can be made BEFORE deleting the old: map the target to a spare + # letter, then drop it. A failed pre-flight leaves the old mapping in + # place. 1219 is the exception - it means the old mapping's own session + # is what blocks the new identity, so deleting it is the only way. + if ($PreFlight) { + $used = @((Get-PSDrive -PSProvider FileSystem -EA SilentlyContinue).Name) + @((Get-SmbMapping -EA SilentlyContinue).LocalPath | ForEach-Object { $_.TrimEnd(':') }) + $spare = $null + foreach ($c in [char[]]'ZYXVUTQPONMLKJIHG') { if ($used -notcontains [string]$c) { $spare = "${c}:"; break } } + if ($spare) { + try { + New-SmbMapping -LocalPath $spare -RemotePath $Unc -UserName $User -Password $Password -Persistent $false -ErrorAction Stop | Out-Null + $ok = Test-DriveReadable -Drive $spare -TimeoutSec 8 + & net use $spare /delete /y 2>&1 | Out-Null + if ($ok -ne 'OK') { return "pre-flight via $spare mapped but unreadable ($ok) - old $Drive left in place" } + } catch { + $m = $_.Exception.Message + if ($m -notmatch '1219|more than one user name|Multiple connections') { + return "pre-flight via $spare failed: $m - old $Drive left in place" + } + Write-MapLog "pre-flight hit 1219 - the existing $Drive session blocks the new identity; replacing it" 'WARN' + } + } + } + & net use $Drive /delete /y 2>&1 | Out-Null Remove-Item -Path "HKCU:\Network\$letter" -Recurse -Force -EA SilentlyContinue try { @@ -200,11 +234,16 @@ foreach ($e in $entries) { $target = if ($mapping) { [string]$mapping.RemotePath } else { '' } $wrong = $present -and ($target.TrimEnd('\') -ne $e.Unc) - # Seed Credential Manager once per profile (idempotent, cheap). - if ($st.Seeded -ne 1) { + # Seed Credential Manager whenever the HKLM credential differs from what + # this profile last seeded (first run, or a password rotation), and again + # after every repair. Keeps Windows' own logon-time restore authenticating. + $credHash = Get-CredHash -User $e.User -Password $e.Pass + if ($st.CredHash -ne $credHash) { $rc = Set-StoredDomainCredential -Target $e.Host -User $e.User -Password $e.Pass - if ($rc -eq 0) { Write-MapLog "Seeded Credential Manager for $($e.Host) as $($e.User)"; Set-State -Key $st.Key -FailCount $st.FailCount -LastState $st.LastState -Seeded 1 } - else { Write-MapLog "CredWrite for $($e.Host) failed (win32 $rc)" 'WARN' } + if ($rc -eq 0) { + Write-MapLog "Seeded Credential Manager for $($e.Host) as $($e.User)$(if ($st.CredHash) { ' (credential changed)' })" + Set-State -Key $st.Key -FailCount $st.FailCount -LastState $st.LastState -CredHash $credHash + } else { Write-MapLog "CredWrite for $($e.Host) failed (win32 $rc)" 'WARN' } } $probe = 'SKIPPED' @@ -239,8 +278,8 @@ foreach ($e in $entries) { continue } - Write-MapLog "Repairing $($e.Drive) -> $($e.Unc)" - $err = New-Mapping -Drive $e.Drive -Unc $e.Unc -User $e.User -Password $e.Pass -HostName $e.Host + Write-MapLog "Repairing $($e.Drive) -> $($e.Unc)$(if ($present) { ' (pre-flight first, existing mapping kept on failure)' })" + $err = New-Mapping -Drive $e.Drive -Unc $e.Unc -User $e.User -Password $e.Pass -HostName $e.Host -PreFlight $present if ($err) { Write-MapLog "Repair FAILED $($e.Drive): $err" 'ERROR' Set-State -Key $st.Key -FailCount $st.FailCount -LastState 'failed' @@ -250,7 +289,11 @@ foreach ($e in $entries) { $verify = Test-DriveReadable -Drive $e.Drive -TimeoutSec $ProbeTimeoutSec if ($verify -eq 'OK') { Write-MapLog "Repaired $($e.Drive) -> $($e.Unc) (verified readable)" - Set-State -Key $st.Key -FailCount 0 -LastState 'healthy' + # re-seed after every repair so the stored credential matches what + # just proved to work + $rc = Set-StoredDomainCredential -Target $e.Host -User $e.User -Password $e.Pass + if ($rc -ne 0) { Write-MapLog "CredWrite after repair failed (win32 $rc)" 'WARN' } + Set-State -Key $st.Key -FailCount 0 -LastState 'healthy' -CredHash $credHash } else { Write-MapLog "Mapped $($e.Drive) but verify failed: $verify" 'ERROR' Set-State -Key $st.Key -FailCount $st.FailCount -LastState 'failed'