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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011boHjE3EeexwszdiKDZ7iX
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user