From 01cf99da01f9dad069df6f62f1eb1559ffa57cfa Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 4 Sep 2026 12:18:33 -0400 Subject: [PATCH] fix(shopfloor): self-healing S: drive mapper to stop mid-shift drive loss S: was mapped once per logon (HKLM\Run -> Map-SfldShare.ps1) with no re-check, so any SMB session break the redirector could not silently re-authenticate left S: dead until the user logged out and back in. Replace the Run entry with a scheduled task (BUILTIN\Users, Limited) that fires at logon, every 3 min, on unlock/console-connect, and on network-connect. The mapper now probes S: with a real timed directory read (not net use output parsing, which reports a dead drive as healthy), only repairs after two consecutive failed probes with TCP 445 reachable, and never touches a working drive. Credentials go through New-SmbMapping / CredWrite instead of the net use command line, and a Domain Password credential is seeded so Windows' own reconnect and UNC access share one identity (kills the 1219 conflict class). Register-MapSfldShare stamps HKLM\SOFTWARE\GE\SfldShare\MapperHash with the staged script's SHA256 after success, so GE-Enforce can (re)register it fleet-wide via a common manifest entry and re-fire on version bumps. Verified in the win11 VM: healthy no-op with an open handle held across runs, repair of deleted / wrong-target / present-but-unreadable drives, unaided recovery after a NIC bounce and on the 3-min interval, and the full SYSTEM-from-share registrar path flipping detection to installed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011boHjE3EeexwszdiKDZ7iX --- .../shopfloor-setup/Run-ShopfloorSetup.ps1 | 12 +- .../Shopfloor/Register-MapSfldShare.ps1 | 194 +++++++------ .../Shopfloor/lib/Map-SfldShare.ps1 | 272 +++++++++++++++--- 3 files changed, 351 insertions(+), 127 deletions(-) diff --git a/playbook/shopfloor-setup/Run-ShopfloorSetup.ps1 b/playbook/shopfloor-setup/Run-ShopfloorSetup.ps1 index 880d135..bc75df4 100644 --- a/playbook/shopfloor-setup/Run-ShopfloorSetup.ps1 +++ b/playbook/shopfloor-setup/Run-ShopfloorSetup.ps1 @@ -419,10 +419,14 @@ if ($noEnforceTypes -contains $pcType) { Write-Warning "Register-GEEnforce.ps1 not found - no ongoing enforcement will run on this PC" } -# Map S: drive on user logon for every account in BUILTIN\Users. The -# vendor 'SFLD - Consume Credentials' task is principal-restricted and -# does not fire for the ShopFloor end-user, so this parallel task fills -# the gap. Cross-PC-type because every shopfloor account needs S:. +# Register the 'GE Map SFLD Share' task: keeps S: alive for every account +# in BUILTIN\Users (logon + every 3 min + unlock/console connect + network +# up), with a real read probe and a confirm-then-repair path. The vendor +# 'SFLD - Consume Credentials' task DOES fire for ShopFloor (principal is +# the Users group) but maps S: once per logon only; nothing re-maps it +# mid-shift, which is why S: stayed dead until re-logon. Our task is +# idempotent next to it (healthy S: is never touched). GE-Enforce also +# (re)registers it fleet-wide via the common manifest entry. # Display PCs skipped: kiosk user has no SFLD creds, S: map would fail # every logon. Self-contained Display has no share dependency. $registerMapShare = Join-Path $setupDir 'Shopfloor\Register-MapSfldShare.ps1' diff --git a/playbook/shopfloor-setup/Shopfloor/Register-MapSfldShare.ps1 b/playbook/shopfloor-setup/Shopfloor/Register-MapSfldShare.ps1 index 139fbb6..cf1f9ef 100644 --- a/playbook/shopfloor-setup/Shopfloor/Register-MapSfldShare.ps1 +++ b/playbook/shopfloor-setup/Shopfloor/Register-MapSfldShare.ps1 @@ -1,67 +1,30 @@ -# Register-MapSfldShare.ps1 - Stage Map-SfldShare.ps1 + register an -# HKLM\Run entry that maps S: for any interactive user (SupportUser, -# ShopFloor, any future end-user accounts). +# Register-MapSfldShare.ps1 - stage Map-SfldShare.ps1 and register the +# 'GE Map SFLD Share' scheduled task that keeps S: alive for every +# interactive BUILTIN\Users member (ShopFloor, SupportUser, ...). # -# Why HKLM\Run instead of a scheduled task: Run fires at Explorer -# startup in the logged-in user's interactive session with their full -# token + HKCU mounted. No principal/LogonType/group-SID plumbing, no -# "task fires in session 0 but drive not visible to Explorer" class of -# bugs. Works for every BUILTIN\Users member with no extra logic. -# -# Why not the vendor's ConsumeCredentials.ps1: it calls -# New-StoredCredential -Persist LocalMachine (needs admin) before net -# use. ShopFloor is non-admin, so the cred-store fails and net use has -# no auth. Our Map-SfldShare.ps1 reads HKLM creds directly and passes -# them inline to net use /user: -- no Credential Manager needed. +# Replaces the HKLM\Run entry of the same name. Run fired once per logon and +# had no way to repair S: mid-shift; a task can fire at logon, on an +# interval, on unlock / console connect and when the network comes up. +# Principal is the Users GROUP with RunLevel Limited, so it runs in each +# logged-on member's own session with a non-elevated token: the mapping +# lands where Explorer and the apps look for it. + +param( + [int]$IntervalMinutes = 3, + [string]$InstallRoot = 'C:\Program Files\GE\SfldShare' +) $ErrorActionPreference = 'Continue' -# PC types that are self-contained and must NOT map S:. A Display kiosk is -# Entra-joined with local accounts and no SFLD credentials, so the mapping can -# only ever fail - once per logon, forever. -# -# Run-ShopfloorSetup.ps1 already gates the CALL to this script on the same list, -# but on 579C144 (2026-08-06) the Run entry was registered on a Display anyway: -# -# [15:07:55] Set HKLM:\...\Run\GE Map SFLD Share = ...Map-SfldShare.ps1 -# -# with no "Skipping S: drive logon mapper" in the log, so something in the -# finalization phase reaches this script past the call-site gate. Gating here as -# well makes the outcome correct regardless of who invokes it. -$selfContainedTypes = @('Display', 'gea-shopfloor-display') -$pcTypeFile = 'C:\Enrollment\pc-type.txt' -$pcType = '' -if (Test-Path -LiteralPath $pcTypeFile) { - $pcType = (Get-Content -LiteralPath $pcTypeFile -First 1 -ErrorAction SilentlyContinue) - if ($pcType) { $pcType = $pcType.Trim() } -} -if ($pcType -and ($selfContainedTypes -contains $pcType)) { - $d = 'C:\Logs\SFLD' - if (-not (Test-Path $d)) { New-Item -ItemType Directory -Path $d -Force -EA SilentlyContinue | Out-Null } - Add-Content -Path (Join-Path $d 'register-mapshare.log') -EA SilentlyContinue ` - -Value ("[{0}] [INFO] PC type '{1}' is self-contained - not registering the S: mapper." -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $pcType) - Write-Host "PC type '$pcType' is self-contained - skipping S: drive mapper." - # Remove a stale entry from an earlier image or an earlier code path. - try { - $runKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run' - if ((Get-ItemProperty -Path $runKey -Name 'GE Map SFLD Share' -EA SilentlyContinue)) { - Remove-ItemProperty -Path $runKey -Name 'GE Map SFLD Share' -Force -EA Stop - Write-Host " removed stale 'GE Map SFLD Share' Run entry." - } - } catch { } - return -} - -$installRoot = 'C:\Program Files\GE\SfldShare' -$mapScript = Join-Path $installRoot 'Map-SfldShare.ps1' -$logDir = 'C:\Logs\SFLD' -$logFile = Join-Path $logDir 'register-mapshare.log' +$taskName = 'GE Map SFLD Share' $runKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run' $runValue = 'GE Map SFLD Share' $legacyTask = 'GE Shopfloor Map S: Drive' +$mapScript = Join-Path $InstallRoot 'Map-SfldShare.ps1' +$logDir = 'C:\Logs\SFLD' +$logFile = Join-Path $logDir 'register-mapshare.log' if (-not (Test-Path $logDir)) { New-Item -Path $logDir -ItemType Directory -Force | Out-Null } - function Write-RegLog { param([string]$Message) $line = '[{0}] [INFO] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message @@ -71,45 +34,108 @@ function Write-RegLog { Write-RegLog '=== Register-MapSfldShare start ===' -# Stage our Map-SfldShare.ps1 to a persistent location -if (-not (Test-Path $installRoot)) { - New-Item -Path $installRoot -ItemType Directory -Force | Out-Null +# 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. +$selfContainedTypes = @('Display', 'gea-shopfloor-display') +$pcType = '' +if (Test-Path -LiteralPath 'C:\Enrollment\pc-type.txt') { + $pcType = (Get-Content -LiteralPath 'C:\Enrollment\pc-type.txt' -First 1 -EA SilentlyContinue) + if ($pcType) { $pcType = $pcType.Trim() } } +if ($pcType -and ($selfContainedTypes -contains $pcType)) { + Write-RegLog "PC type '$pcType' is self-contained - not registering the S: mapper." + Remove-ItemProperty -Path $runKey -Name $runValue -Force -EA SilentlyContinue + if (Get-ScheduledTask -TaskName $taskName -EA SilentlyContinue) { Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -EA SilentlyContinue } + exit 0 +} + +# --- stage the script ---------------------------------------------------- $src = Join-Path $PSScriptRoot 'lib\Map-SfldShare.ps1' -if (Test-Path $src) { - Copy-Item -Path $src -Destination $mapScript -Force - Write-RegLog "Staged $src -> $mapScript" -} else { - Write-RegLog "Map-SfldShare.ps1 not found at $src - cannot register" - exit 1 +if (-not (Test-Path $src)) { Write-RegLog "Map-SfldShare.ps1 not found at $src - cannot register"; exit 1 } +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" + +# --- retire the older mechanisms ----------------------------------------- +if (Get-ItemProperty -Path $runKey -Name $runValue -EA SilentlyContinue) { + Remove-ItemProperty -Path $runKey -Name $runValue -Force -EA SilentlyContinue + Write-RegLog "Removed HKLM\Run '$runValue' (superseded by the scheduled task)" +} +if (Get-ScheduledTask -TaskName $legacyTask -EA SilentlyContinue) { + Unregister-ScheduledTask -TaskName $legacyTask -Confirm:$false -EA SilentlyContinue + Write-RegLog "Removed legacy task '$legacyTask'" } -# Remove the legacy scheduled task if it exists (left behind by older -# imaging runs that used the scheduled-task approach). -if (Get-ScheduledTask -TaskName $legacyTask -ErrorAction SilentlyContinue) { - try { - Unregister-ScheduledTask -TaskName $legacyTask -Confirm:$false -ErrorAction Stop - Write-RegLog "Removed legacy scheduled task '$legacyTask'" - } catch { - Write-RegLog "Failed to remove legacy task '$legacyTask': $_" - } -} - -# Register HKLM\Run entry. Runs at Explorer startup for every -# interactive user in that user's session. +# --- task ------------------------------------------------------------------ try { - $command = '"{0}" -NoProfile -ExecutionPolicy Bypass -File "{1}"' -f ` - "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe", $mapScript + $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) - if (-not (Test-Path $runKey)) { - New-Item -Path $runKey -Force | Out-Null + $ns = 'Root/Microsoft/Windows/TaskScheduler' + $triggers = New-Object System.Collections.ArrayList + + # 1. logon (any user) + [void]$triggers.Add((New-ScheduledTaskTrigger -AtLogOn)) + + # 2. interval, forever + $rep = New-ScheduledTaskTrigger -Once -At (Get-Date).Date -RepetitionInterval (New-TimeSpan -Minutes $IntervalMinutes) + [void]$triggers.Add($rep) + + # 3. console connect (1) + session unlock (8) + $ssCls = Get-CimClass -ClassName MSFT_TaskSessionStateChangeTrigger -Namespace $ns + foreach ($state in 1, 8) { + $t = New-CimInstance -CimClass $ssCls -ClientOnly + $t.StateChange = $state + $t.Delay = 'PT10S' + [void]$triggers.Add($t) } - New-ItemProperty -Path $runKey -Name $runValue -Value $command -PropertyType String -Force | Out-Null - Write-RegLog "Set $runKey\$runValue = $command" + + # 4. network profile connected (NetworkProfile/Operational 10000) - wifi + # roam / cable back / VPN up. Short delay so DNS is usable. + $evCls = Get-CimClass -ClassName MSFT_TaskEventTrigger -Namespace $ns + $ev = New-CimInstance -CimClass $evCls -ClientOnly + $ev.Subscription = '' + $ev.Delay = 'PT15S' + [void]$triggers.Add($ev) + + $principal = New-ScheduledTaskPrincipal -GroupId 'S-1-5-32-545' -RunLevel Limited + + $settings = New-ScheduledTaskSettingsSet ` + -MultipleInstances IgnoreNew ` + -ExecutionTimeLimit (New-TimeSpan -Minutes 5) ` + -StartWhenAvailable ` + -AllowStartIfOnBatteries ` + -DontStopIfGoingOnBatteries ` + -Hidden + + Register-ScheduledTask -TaskName $taskName ` + -Action $action -Trigger $triggers.ToArray() -Principal $principal -Settings $settings ` + -Force -ErrorAction Stop | Out-Null + + Write-RegLog "Registered task '$taskName' (BUILTIN\Users, Limited; logon + every $IntervalMinutes min + unlock/connect + network-up)" } catch { - Write-RegLog "FAILED to register Run key: $_" + Write-RegLog "FAILED to register task: $_" exit 1 } +# --- stamp ----------------------------------------------------------------- +# Written only after staging + registration both succeeded. The GE-Enforce +# manifest entry detects on MapperHash == SHA256 of Map-SfldShare.ps1, so a +# new script version (new hash) or a failed registration (no stamp) re-fires +# this registrar on the next cycle. +try { + $hash = (Get-FileHash -Path $mapScript -Algorithm SHA256).Hash + $stampKey = 'HKLM:\SOFTWARE\GE\SfldShare' + if (-not (Test-Path $stampKey)) { New-Item -Path $stampKey -Force | Out-Null } + Set-ItemProperty -Path $stampKey -Name MapperHash -Value $hash -Type String + Set-ItemProperty -Path $stampKey -Name RegisteredAt -Value (Get-Date -Format 's') -Type String + Set-ItemProperty -Path $stampKey -Name TaskName -Value $taskName -Type String + Write-RegLog "Stamped $stampKey MapperHash=$hash" +} catch { + Write-RegLog "Stamp write failed (registrar will re-run next cycle): $_" +} + Write-RegLog '=== Register-MapSfldShare end ===' exit 0 diff --git a/playbook/shopfloor-setup/Shopfloor/lib/Map-SfldShare.ps1 b/playbook/shopfloor-setup/Shopfloor/lib/Map-SfldShare.ps1 index 087d186..f5e0f16 100644 --- a/playbook/shopfloor-setup/Shopfloor/lib/Map-SfldShare.ps1 +++ b/playbook/shopfloor-setup/Shopfloor/lib/Map-SfldShare.ps1 @@ -1,68 +1,262 @@ -# Map-SfldShare.ps1 - Map S: drive on user logon using SFLD creds from HKLM. +# Map-SfldShare.ps1 - keep the SFLD share drive (S:) alive in the interactive +# user's session. Run by the 'GE Map SFLD Share' scheduled task as the +# logged-on user (BUILTIN\Users, Limited): at logon, every few minutes, on +# unlock / console connect, and when a network profile connects. # -# Runs as the interactive user (BUILTIN\Users, Limited) so the drive -# mapping lands in the logged-in user's session. Reads username/password -# directly from HKLM:\SOFTWARE\GE\SFLD\Credentials\* and passes them -# inline to net use -- no Windows Credential Manager involvement. +# Rules (the drive is never touched while it works): +# healthy -> exit, no changes. +# no live mapping -> map now (nothing to interrupt). +# mapped but unreadable -> repair only after $ConfirmFailures consecutive +# failed probes, and only if TCP 445 answers. +# mapped to wrong target -> same confirm-then-repair path. # -# Why not the vendor's ConsumeCredentials.ps1: it calls -# New-StoredCredential -Persist LocalMachine which requires admin. -# ShopFloor is a non-admin user, so the cred-store step fails silently -# and the subsequent net use (which relies on those stored creds) has -# no authentication. Direct net use /user: bypasses the issue entirely. +# Creds: HKLM:\SOFTWARE\GE\SFLD\Credentials\* entries that carry DriveLetter + +# ShareName (written by the vendor SetupCredentials package). The password is +# never put on a command line: New-SmbMapping runs in-process and the +# Credential Manager seed uses CredWrite via P/Invoke. The seed is what lets +# Windows' own logon-time restore and the redirector's transparent reconnect +# authenticate, so both stop producing red-X drives. + +param( + [int]$ConfirmFailures = 2, + [int]$ProbeTimeoutSec = 8, + [switch]$Now # repair on first failed probe (manual / logon use) +) $ErrorActionPreference = 'Continue' +# --- single instance per session --------------------------------------- +$mutex = New-Object System.Threading.Mutex($false, 'Local\GE-MapSfldShare') +if (-not $mutex.WaitOne(0)) { exit 0 } + +# --- logging ----------------------------------------------------------- $logDir = 'C:\Logs\SFLD' -if (-not (Test-Path $logDir)) { - try { New-Item -Path $logDir -ItemType Directory -Force | Out-Null } catch { $logDir = $env:TEMP } +try { + if (-not (Test-Path $logDir)) { New-Item -Path $logDir -ItemType Directory -Force -EA Stop | Out-Null } + $probeFile = Join-Path $logDir ('.w-' + [guid]::NewGuid().ToString('N')) + [IO.File]::WriteAllText($probeFile, 'x'); Remove-Item $probeFile -Force -EA SilentlyContinue +} catch { + $logDir = Join-Path $env:LOCALAPPDATA 'GE\SfldShare' + if (-not (Test-Path $logDir)) { New-Item -Path $logDir -ItemType Directory -Force -EA SilentlyContinue | Out-Null } } $logFile = Join-Path $logDir 'map-share.log' function Write-MapLog { - param([string]$Message) - $line = '[{0}] [{1}] {2}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $env:USERNAME, $Message + param([string]$Message, [string]$Level = 'INFO') + $line = '[{0}] [{1}] [{2}] {3}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $env:USERNAME, $Level, $Message Add-Content -Path $logFile -Value $line -ErrorAction SilentlyContinue + Write-Host $line } -Write-MapLog '=== Map-SfldShare start ===' +# --- per-user state (fail counter, last state) -------------------------- +$stateRoot = 'HKCU:\SOFTWARE\GE\SfldShare' +function Get-State { + param([string]$Letter) + $k = Join-Path $stateRoot $Letter + if (-not (Test-Path $k)) { New-Item -Path $k -Force | Out-Null } + $p = Get-ItemProperty -Path $k -EA SilentlyContinue + return @{ + Key = $k + FailCount = [int]($p.FailCount) + LastState = [string]$p.LastState + Seeded = [int]($p.CredSeeded) + } +} +function Set-State { + param([string]$Key, [int]$FailCount, [string]$LastState, [int]$Seeded = -1) + 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 } +} +# --- probes ------------------------------------------------------------- +function Test-Tcp445 { + param([string]$HostName, [int]$TimeoutMs = 3000) + try { + $c = New-Object System.Net.Sockets.TcpClient + $ar = $c.BeginConnect($HostName, 445, $null, $null) + $ok = $ar.AsyncWaitHandle.WaitOne($TimeoutMs) + if ($ok) { $c.EndConnect($ar) } + $c.Close() + return $ok + } catch { return $false } +} + +# Real read of the drive root in a runspace with a hard timeout. Test-Path on +# a dead redirector connection can block for 30 s or more. +function Test-DriveReadable { + param([string]$Drive, [int]$TimeoutSec) + $ps = [powershell]::Create() + $null = $ps.AddScript({ + param($d) + try { + $e = [System.IO.Directory]::EnumerateFileSystemEntries("$d\").GetEnumerator() + $null = $e.MoveNext() + 'OK' + } catch { 'ERR: ' + $_.Exception.GetType().Name + ': ' + $_.Exception.Message } + }).AddArgument($Drive) + $h = $ps.BeginInvoke() + if ($h.AsyncWaitHandle.WaitOne($TimeoutSec * 1000)) { + $r = $ps.EndInvoke($h); $ps.Dispose() + return [string]$r[0] + } + try { $ps.Stop() } catch {} + $ps.Dispose() + return 'TIMEOUT' +} + +# --- credential manager seed (CredWrite, no command line) --------------- +$script:credTypeLoaded = $false +function Set-StoredDomainCredential { + param([string]$Target, [string]$User, [string]$Password) + if (-not $script:credTypeLoaded) { + Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +public static class GeCredStore { + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct CREDENTIAL { + public uint Flags; public uint Type; public string TargetName; public string Comment; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten; + public uint CredentialBlobSize; public IntPtr CredentialBlob; public uint Persist; + public uint AttributeCount; public IntPtr Attributes; public string TargetAlias; public string UserName; + } + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + static extern bool CredWrite([In] ref CREDENTIAL cred, uint flags); + public static int Write(string target, string user, string password) { + byte[] blob = System.Text.Encoding.Unicode.GetBytes(password); + CREDENTIAL c = new CREDENTIAL(); + c.Type = 2; // CRED_TYPE_DOMAIN_PASSWORD + c.Persist = 2; // CRED_PERSIST_LOCAL_MACHINE + c.TargetName = target; c.UserName = user; c.Comment = "GE SFLD share"; + c.CredentialBlobSize = (uint)blob.Length; + c.CredentialBlob = Marshal.AllocHGlobal(blob.Length); + Marshal.Copy(blob, 0, c.CredentialBlob, blob.Length); + bool ok = CredWrite(ref c, 0); + int err = ok ? 0 : Marshal.GetLastWin32Error(); + Marshal.FreeHGlobal(c.CredentialBlob); + return err; + } +} +'@ -ErrorAction Stop + $script:credTypeLoaded = $true + } + return [GeCredStore]::Write($Target, $User, $Password) +} + +# --- mapping ------------------------------------------------------------ +function New-Mapping { + param([string]$Drive, [string]$Unc, [string]$User, [string]$Password, [string]$HostName) + $letter = $Drive.TrimEnd(':') + & net use $Drive /delete /y 2>&1 | Out-Null + Remove-Item -Path "HKCU:\Network\$letter" -Recurse -Force -EA SilentlyContinue + try { + New-SmbMapping -LocalPath $Drive -RemotePath $Unc -UserName $User -Password $Password -Persistent $true -ErrorAction Stop | Out-Null + return $null + } catch { + $msg = $_.Exception.Message + if ($msg -match '1219|more than one user name|Multiple connections') { + # stale session to the same host under another identity pins the + # credential; the hidden IPC$ connection is the pin. + Write-MapLog "1219 credential conflict on $HostName - dropping IPC`$ and retrying" 'WARN' + & net use "\\$HostName\IPC`$" /delete /y 2>&1 | Out-Null + try { + New-SmbMapping -LocalPath $Drive -RemotePath $Unc -UserName $User -Password $Password -Persistent $true -ErrorAction Stop | Out-Null + return $null + } catch { return $_.Exception.Message } + } + return $msg + } +} + +# --- main --------------------------------------------------------------- $credsBase = 'HKLM:\SOFTWARE\GE\SFLD\Credentials' -if (-not (Test-Path $credsBase)) { - Write-MapLog 'No HKLM SFLD credentials yet - exiting' - exit 0 -} +if (-not (Test-Path $credsBase)) { Write-MapLog 'No HKLM SFLD credentials yet - exiting'; exit 0 } +$entries = @() foreach ($entry in (Get-ChildItem -Path $credsBase -ErrorAction SilentlyContinue)) { $p = Get-ItemProperty -Path $entry.PSPath -ErrorAction SilentlyContinue if (-not $p -or -not $p.TargetHost -or -not $p.Username -or -not $p.Password) { continue } - - $drive = $null - $share = $null - try { $drive = $p.DriveLetter } catch {} - try { $share = $p.ShareName } catch {} - + $drive = [string]$p.DriveLetter; $share = [string]$p.ShareName if ([string]::IsNullOrWhiteSpace($drive) -or [string]::IsNullOrWhiteSpace($share)) { continue } + $entries += [pscustomobject]@{ + Drive = $drive.Trim().TrimEnd(':').ToUpper() + ':' + Unc = '\\{0}\{1}' -f $p.TargetHost, $share.TrimStart('\').TrimEnd('\') + Host = [string]$p.TargetHost + User = [string]$p.Username + Pass = [string]$p.Password + } +} +if ($entries.Count -eq 0) { exit 0 } - $drive = $drive.TrimEnd(':') + ':' - $share = $share.TrimStart('\') - $uncPath = "\\$($p.TargetHost)\$share" +$exit = 0 +foreach ($e in $entries) { + $letter = $e.Drive.TrimEnd(':') + $st = Get-State -Letter $letter - # Skip if already mapped to the right target - $existing = & net use $drive 2>&1 - if ($LASTEXITCODE -eq 0 -and ($existing -join "`n") -match [regex]::Escape($uncPath)) { - Write-MapLog "$drive already mapped to $uncPath - skipping" + $mapping = Get-SmbMapping -LocalPath $e.Drive -ErrorAction SilentlyContinue + $present = [bool]$mapping + $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) { + $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' } + } + + $probe = 'SKIPPED' + if ($present -and -not $wrong) { $probe = Test-DriveReadable -Drive $e.Drive -TimeoutSec $ProbeTimeoutSec } + + if ($present -and -not $wrong -and $probe -eq 'OK') { + if ($st.LastState -ne 'healthy') { Write-MapLog "$($e.Drive) healthy -> $($e.Unc)" } + Set-State -Key $st.Key -FailCount 0 -LastState 'healthy' continue } - & net use $drive /delete /y 2>$null | Out-Null - $out = & net use $drive $uncPath /user:$($p.Username) $($p.Password) /persistent:yes 2>&1 - if ($LASTEXITCODE -eq 0) { - Write-MapLog "Mapped $drive -> $uncPath" + # --- not healthy -------------------------------------------------- + if (-not $present) { + Write-MapLog "$($e.Drive) has no live mapping - mapping now" + } elseif ($wrong) { + $st.FailCount++ + Write-MapLog "$($e.Drive) points at '$target' not '$($e.Unc)' ($($st.FailCount)/$ConfirmFailures)" 'WARN' } else { - Write-MapLog "FAILED $drive -> $uncPath (exit $LASTEXITCODE): $out" + $st.FailCount++ + Write-MapLog "$($e.Drive) mapped (status $($mapping.Status)) but probe failed: $probe ($($st.FailCount)/$ConfirmFailures)" 'WARN' + } + + if ($present -and -not $Now -and $st.FailCount -lt $ConfirmFailures) { + Set-State -Key $st.Key -FailCount $st.FailCount -LastState 'suspect' + continue + } + + if (-not (Test-Tcp445 -HostName $e.Host)) { + Write-MapLog "$($e.Host):445 unreachable - not repairing $($e.Drive) this run" 'WARN' + Set-State -Key $st.Key -FailCount $st.FailCount -LastState 'unreachable' + $exit = 2 + 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 + if ($err) { + Write-MapLog "Repair FAILED $($e.Drive): $err" 'ERROR' + Set-State -Key $st.Key -FailCount $st.FailCount -LastState 'failed' + $exit = 1 + continue + } + $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' + } else { + Write-MapLog "Mapped $($e.Drive) but verify failed: $verify" 'ERROR' + Set-State -Key $st.Key -FailCount $st.FailCount -LastState 'failed' + $exit = 1 } } -Write-MapLog '=== Map-SfldShare end ===' -exit 0 +$mutex.ReleaseMutex() | Out-Null +exit $exit