From bd131f6da936510050dd2ff3094a03c4126ba1cb Mon Sep 17 00:00:00 2001 From: cproudlock Date: Tue, 25 Aug 2026 13:44:05 -0400 Subject: [PATCH] Diagnose a share that prompts, when the same UNC path works Shopfloor PCs intermittently lose S: and ask for credentials while navigating the identical UNC path in Explorer works fine. Four different causes produce that same appearance - a persistent reconnect that raced the network, a stored credential whose target string does not cover the name form in use, two identities contending for one server, or a password that rotated - and each needs a different fix. From the desk they are indistinguishable. Test-ShareAccess.ps1 collects what separates them: reachability of both name forms, the mappings and the account each remembers, the exact credential target strings, Kerberos tickets, live SMB sessions and the identities holding them, and the SMBClient/Connectivity log. Then it names the causes the evidence supports rather than leaving a wall of output to interpret. Read-only by construction: it maps nothing, deletes nothing, stores no credential, and prints usernames and a password-present flag but never a password, because its log lands where techs read it. Safe on a bay while somebody is working. It reports which identity it is running as, because mappings and SMB sessions are per logon session: run as the user to explain S:, as SYSTEM to explain W: and the backup scripts. A drive missing in one is genuinely absent in the other, and mistaking that for a fault is easy. Also derives the marker backup's share root from the script's own location rather than a hardcoded UNC. GE-Enforce mounts the share as W: with SFLD credentials, and reaching the same server by a different name than the mount used costs a second authentication - or fails outright with 1219 - for reasons the error text never mentions. The script's own path is the one handle guaranteed to name the connection that already exists. --- .../common/scripts/Backup-PartMarker.ps1 | 46 +++- .../common/scripts/Test-ShareAccess.ps1 | 249 ++++++++++++++++++ 2 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 playbook/shopfloor-setup/common/scripts/Test-ShareAccess.ps1 diff --git a/playbook/shopfloor-setup/common/scripts/Backup-PartMarker.ps1 b/playbook/shopfloor-setup/common/scripts/Backup-PartMarker.ps1 index 957f3cd..824343f 100644 --- a/playbook/shopfloor-setup/common/scripts/Backup-PartMarker.ps1 +++ b/playbook/shopfloor-setup/common/scripts/Backup-PartMarker.ps1 @@ -50,7 +50,21 @@ param( [string]$BaseUrl = '', # Root of the opaque-backup tree. The snapshot lands in # \partmarker\\\. - [string]$ShareRoot = '\\tsgwp00525.wjs.geaerospace.net\shared\dt\shopfloor\backup', + # + # EMPTY BY DEFAULT, and derived at runtime from where this script is running + # (see below). Passing a value overrides that, which is what a bench test + # wants. + # + # WHY NOT HARDCODE THE UNC: GE-Enforce mounts the share as W: using SFLD + # credentials from HKLM\SOFTWARE\GE\SFLD\Credentials, runs manifest + # entries from W:\\, and deletes the mapping at the end of the + # cycle. Windows keys SMB sessions by the SERVER NAME STRING, so reaching + # the same server by a different name than the one net use mapped can force + # a second authentication - and where the credentials differ it fails + # outright with error 1219, "multiple connections to a server by the same + # user are not allowed". A path that is obviously the same share fails on + # the name form alone, and the error is nothing like the cause. + [string]$ShareRoot = '', [int]$TimeoutSec = 30, # Hour of the day (LOCAL, 24h) the daily capture starts. 14:00 for two # reasons, and the second one is the important one: @@ -357,6 +371,36 @@ Write-ShopdbBackupLog -Kind $KIND -Message ( $(if ($manifest.Systems) { $manifest.Systems -join ', ' } else { 'unknown' })) # --- Write the snapshot to the share ---------------------------------------- +# Derive the share root from THIS SCRIPT'S OWN LOCATION unless told otherwise. +# Running as a manifest entry, $PSScriptRoot is on the share GE-Enforce just +# authenticated (W:\\scripts), so its qualifier is a handle to that +# exact connection - same server name, same credentials, already established. +# Nothing else is guaranteed to be. +if (-not $ShareRoot) { + $qualifier = Split-Path -Qualifier $PSScriptRoot -ErrorAction SilentlyContinue + if ($qualifier) { + $ShareRoot = Join-Path $qualifier 'backup' + Write-ShopdbBackupLog -Kind $KIND -Message ( + 'Share root derived from this script location: {0}' -f $ShareRoot) + } else { + # Running from a UNC path directly (no drive letter), which happens if + # the entry is invoked outside the usual mapping. Use the script's own + # server and share rather than a hardcoded hostname, for the same + # session-reuse reason. + $parts = $PSScriptRoot -split '\\' | Where-Object { $_ } + if ($parts.Count -ge 2) { + $ShareRoot = '\\{0}\{1}\backup' -f $parts[0], $parts[1] + Write-ShopdbBackupLog -Kind $KIND -Message ( + 'Share root derived from this script UNC: {0}' -f $ShareRoot) + } else { + Write-ShopdbBackupLog -Kind $KIND -Message ( + 'Cannot derive a share root from {0} and none was passed - skipping.' -f $PSScriptRoot) + Add-ShopdbBackupSkip -Kind $KIND -Reason 'no share root' + exit 0 + } + } +} + $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' $assetdir = Join-Path (Join-Path $ShareRoot 'partmarker') $ctx.MachineNumber $snapshotdir = Join-Path $assetdir $stamp diff --git a/playbook/shopfloor-setup/common/scripts/Test-ShareAccess.ps1 b/playbook/shopfloor-setup/common/scripts/Test-ShareAccess.ps1 new file mode 100644 index 0000000..c4b7902 --- /dev/null +++ b/playbook/shopfloor-setup/common/scripts/Test-ShareAccess.ps1 @@ -0,0 +1,249 @@ +# Test-ShareAccess.ps1 - why can this PC not reach the share? +# +# READ-ONLY. It maps nothing, deletes nothing, stores no credential and prints +# no password. Safe to run on a production bay while somebody is working. +# +# THE PROBLEM IT EXISTS FOR +# +# Shopfloor PCs intermittently lose S: - it prompts for credentials - while +# navigating the identical UNC path in Explorer works fine. That combination has +# several possible causes which look identical from the desk, and the fix +# differs for each: +# +# 1. Persistent reconnect raced the network. The mapping is restored early in +# logon, before wifi/802.1X is up, so it comes back dead. A manual UNC +# visit minutes later works because by then the server is reachable. +# 2. The stored credential does not cover the name form in use. Credential +# Manager matches by STRING: an entry for "server" does not serve +# "server.domain.com". One path finds the account, the other does not. +# 3. Two identities for one server. Windows allows ONE SMB session per server +# per logon session, so if the drive wants a shared account and something +# already opened that server as the logged-on user (or by a second name +# form), the loser gets a credential prompt rather than a clear error. +# 4. Genuinely stale password, after a rotation. +# +# This collects the evidence that separates them, and says which it looks like. +# +# CONTEXT MATTERS: drive mappings and SMB sessions are PER LOGON SESSION. Run it +# as the logged-on user to diagnose S:. Run it as SYSTEM (psexec -s, or a +# GE-Enforce manifest entry) to diagnose W: and the backup scripts. The two see +# different worlds, and a mapping visible in one is genuinely absent in the +# other - that is not a bug. +# +# Log: C:\Logs\Shopfloor\share-access-.log + +param( + # Server to test. Both the short and fully-qualified forms are exercised, + # because a mismatch between them is one of the causes. + [string]$Server = 'tsgwp00525.wjs.geaerospace.net', + # Share to probe on that server. + [string]$Share = 'shared', + # Drive letters to report on. S: is the user's shopfloor mapping, W: is the + # one GE-Enforce makes as SYSTEM for the duration of its cycle. + [string[]]$Drives = @('S:', 'W:') +) + +$ErrorActionPreference = 'Continue' + +$logDir = 'C:\Logs\Shopfloor' +if (-not (Test-Path $logDir)) { + try { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } catch { $logDir = $env:TEMP } +} +$logFile = Join-Path $logDir ('share-access-{0}.log' -f (Get-Date -Format 'yyyyMMdd-HHmmss')) + +function Say { + param([string]$Text = '') + Write-Host $Text + Add-Content -Path $logFile -Value $Text -ErrorAction SilentlyContinue +} + +function Section { + param([string]$Title) + Say '' + Say ('=== {0} ' -f $Title).PadRight(70, '=') +} + +$short = ($Server -split '\.')[0] +$forms = @($Server, $short) | Select-Object -Unique +$findings = New-Object System.Collections.Generic.List[string] + +Say ('Test-ShareAccess {0}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss')) +Say ('Log: {0}' -f $logFile) + +# --------------------------------------------------------------------------- +Section 'Who is running this' +# Everything below is scoped to this identity and this logon session. A drive +# absent here may be perfectly healthy in another session. +$me = [Security.Principal.WindowsIdentity]::GetCurrent() +$isSystem = $me.IsSystem +Say ('User : {0}{1}' -f $me.Name, $(if ($isSystem) { ' (SYSTEM - sees W:, NOT the user''s S:)' } else { '' })) +Say ('Computer : {0}' -f $env:COMPUTERNAME) +if (-not $isSystem) { + Say 'Note : run this again as SYSTEM (psexec -s) to see what GE-Enforce sees.' +} + +# --------------------------------------------------------------------------- +Section 'Name resolution and reachability' +# A dead mapping with a healthy UNC path is often just timing: if the server is +# unreachable at logon the persistent reconnect fails, and by the time anyone +# looks the network is fine. +foreach ($form in $forms) { + $ip = '' + try { + $ip = ([Net.Dns]::GetHostAddresses($form) | + Where-Object { $_.AddressFamily -eq 'InterNetwork' } | + Select-Object -First 1).IPAddressToString + } catch { $ip = 'DNS FAILED' } + + $port = 'no' + try { + $client = New-Object Net.Sockets.TcpClient + $async = $client.BeginConnect($form, 445, $null, $null) + if ($async.AsyncWaitHandle.WaitOne(3000) -and $client.Connected) { $port = 'yes' } + $client.Close() + } catch { } + Say ('{0,-45} -> {1,-16} SMB/445 reachable: {2}' -f $form, $ip, $port) + if ($port -ne 'yes') { + $findings.Add("Cannot reach $form on 445 right now. If the mapping failed at logon for the same reason, that is cause 1 - a reconnect that raced the network.") + } +} + +# --------------------------------------------------------------------------- +Section 'Drive mappings in THIS session' +# The 'user' column is the point: a drive remembering an account other than the +# one running this is what makes a credential prompt possible at all. +$netuse = & net use 2>&1 | Out-String +Say $netuse.TrimEnd() +foreach ($drive in $Drives) { + $line = ($netuse -split "`r?`n" | Where-Object { $_ -match [regex]::Escape($drive) } | Select-Object -First 1) + if (-not $line) { + Say ('{0} not present in this session.' -f $drive) + } elseif ($line -match 'Unavailable|Disconnected') { + Say ('{0} present but NOT connected.' -f $drive) + $findings.Add("$drive exists but is not connected - the classic dead persistent reconnect (cause 1).") + } +} + +# --------------------------------------------------------------------------- +Section 'Stored credentials' +# cmdkey matches by STRING, so the exact Target text decides whether a mapping +# finds its account. This prints targets and usernames only - never a password, +# and cmdkey cannot reveal one anyway. +$cmdkey = & cmdkey /list 2>&1 | Out-String +$targets = [regex]::Matches($cmdkey, 'Target:\s*(\S+)') | ForEach-Object { $_.Groups[1].Value } +if ($targets) { + Say 'Targets stored for this user:' + foreach ($t in $targets) { Say (' {0}' -f $t) } +} else { + Say 'No stored credentials for this user.' +} + +foreach ($form in $forms) { + $covered = @($targets | Where-Object { + $_ -match [regex]::Escape($form) -or + ($_ -match '^\*' -and $form -like ($_ -replace '^Domain:target=', '')) + }) + if ($covered.Count -eq 0) { + Say ('NO stored credential matches "{0}"' -f $form) + $findings.Add("No credential entry matches '$form'. If the mapping uses that form, it has no account to reconnect with (cause 2). Add an entry for every name form in use, or a wildcard.") + } else { + Say ('Credential covers "{0}": {1}' -f $form, ($covered -join ', ')) + } +} + +# --------------------------------------------------------------------------- +Section 'Kerberos tickets' +# A cifs/ ticket for the server means this identity can reach it on its own. +# On a shopfloor bay the logon usually CANNOT, which is why a stored account is +# mandatory - and that is what makes two-identities-one-server possible. +$klist = & klist 2>&1 | Out-String +$cifs = [regex]::Matches($klist, 'cifs/[^\s@]+') | ForEach-Object { $_.Value } | Select-Object -Unique +if ($cifs) { Say ('cifs tickets: {0}' -f ($cifs -join ', ')) } else { Say 'No cifs tickets for this identity.' } + +# --------------------------------------------------------------------------- +Section 'Live SMB sessions to this server' +# ONE session per server per logon session. Two name forms count as two servers +# - which is how a single share ends up wanting two identities at once. +$connections = @() +try { + $connections = @(Get-SmbConnection -ErrorAction Stop | + Where-Object { $_.ServerName -match [regex]::Escape($short) }) +} catch { } +if ($connections.Count) { + foreach ($c in $connections) { + Say ('{0,-45} share {1,-14} as {2}' -f $c.ServerName, $c.ShareName, $c.UserName) + } + $identities = @($connections | Select-Object -ExpandProperty UserName -Unique) + $servers = @($connections | Select-Object -ExpandProperty ServerName -Unique) + if ($identities.Count -gt 1) { + $findings.Add("More than one identity is connected to this server in this session ($($identities -join ', ')). That is cause 3, and whichever connected first wins - the other gets a credential prompt.") + } + if ($servers.Count -gt 1) { + $findings.Add("This server is connected under more than one NAME ($($servers -join ', ')). Windows treats those as different servers, so they get separate sessions and separate credential lookups.") + } +} else { + Say 'No live SMB session to this server in this logon session.' +} + +# --------------------------------------------------------------------------- +Section 'Can this identity read the share right now' +# Test-Path is deliberately not trusted on its own: a SYSTEM process denied +# access to a UNC path gets a "not found", so a permissions problem reads as a +# missing directory. +foreach ($form in $forms) { + $path = '\\{0}\{1}' -f $form, $Share + $ok = $false + try { $ok = Test-Path -LiteralPath $path -ErrorAction Stop } catch { } + Say ('{0,-55} readable: {1}' -f $path, $ok) + if (-not $ok -and $isSystem) { + Say ' (as SYSTEM a denial reports as "not found" - do not read this as a missing path)' + } +} + +# --------------------------------------------------------------------------- +Section 'SFLD credential provisioning (GE-Enforce)' +# Username and presence only. The password is never printed - it is a fleet +# credential and this log is written to a directory techs read. +$sfldBase = 'HKLM:\SOFTWARE\GE\SFLD\Credentials' +if (Test-Path $sfldBase) { + foreach ($key in Get-ChildItem $sfldBase -ErrorAction SilentlyContinue) { + $props = Get-ItemProperty -Path $key.PSPath -ErrorAction SilentlyContinue + $names = @($props.PSObject.Properties.Name) + $user = if ($names -contains 'Username') { $props.Username } else { '(none)' } + $hasPassword = ($names -contains 'Password') -and $props.Password + Say ('{0,-30} user {1,-28} password present: {2}' -f $key.PSChildName, $user, [bool]$hasPassword) + } +} else { + Say 'No SFLD credentials provisioned (Azure DSC has not run, or this is not a fleet PC).' + if ($isSystem) { + $findings.Add('No SFLD credential in the registry, so GE-Enforce cannot mount the share at all this cycle.') + } +} + +# --------------------------------------------------------------------------- +Section 'Recent SMB client errors' +try { + $events = Get-WinEvent -FilterHashtable @{ + LogName = 'Microsoft-Windows-SMBClient/Connectivity'; StartTime = (Get-Date).AddDays(-3) + } -MaxEvents 8 -ErrorAction Stop + foreach ($e in $events) { + Say ('{0} id {1} {2}' -f $e.TimeCreated.ToString('MM-dd HH:mm'), $e.Id, + (($e.Message -split "`r?`n")[0])) + } +} catch { + Say 'No SMBClient/Connectivity events in the last 3 days (or the log is not enabled).' +} + +# --------------------------------------------------------------------------- +Section 'What this looks like' +if ($findings.Count -eq 0) { + Say 'Nothing conclusive. If the drive is failing only at logon, run this again' + Say 'IMMEDIATELY after a failure - several of these signals are transient and' + Say 'a working session masks the state that caused the prompt.' +} else { + $n = 0 + foreach ($f in $findings) { $n++; Say ('{0}. {1}' -f $n, $f) } +} +Say '' +Say ('Full log: {0}' -f $logFile) +exit 0