diff --git a/playbook/shopfloor-setup/Collect-ImagingDiagnostics.ps1 b/playbook/shopfloor-setup/Collect-ImagingDiagnostics.ps1 new file mode 100644 index 0000000..f95a55b --- /dev/null +++ b/playbook/shopfloor-setup/Collect-ImagingDiagnostics.ps1 @@ -0,0 +1,371 @@ +<# +.SYNOPSIS + Collect everything needed to diagnose an imaged bay, into one zip. + +.DESCRIPTION + Read-only. Changes nothing on the machine. + + Written because every imaging investigation so far has meant walking to a bay + and hand-copying files, and each time we discovered another thing we wished + we had grabbed at the same moment. This takes the lot in one pass. + + Run it on a freshly imaged bay BEFORE lockdown. + + This captures KNOWN-CURRENT, not known-good. A bay straight off the line has + applications on it that should not be there - preinstall.json entries without + a PCTypes filter install everywhere, so a Display bay picks up Adobe, + OpenText and Defect Tracker. The point of collecting is to have an exact + record of what imaging really produces, so the unnecessary items can be + identified and filtered out. + + Before lockdown specifically, because afterwards you cannot tell whether + something is absent because lockdown removed it or because imaging never + installed it. + +.PARAMETER Destination + Where to drop the zip. Defaults to C:\Logs. Point it at a share to collect + centrally, e.g. -Destination \\172.16.9.1\enrollment\imaging-logs + (only reachable while the bay is still on the imaging LAN). + +.PARAMETER Label + Optional tag folded into the filename, e.g. -Label pre-lockdown. + +.EXAMPLE + .\Collect-ImagingDiagnostics.ps1 -Label pre-lockdown + +.EXAMPLE + .\Collect-ImagingDiagnostics.ps1 -Destination \\172.16.9.1\enrollment\imaging-logs +#> + +[CmdletBinding()] +param( + [string]$Destination = 'C:\Logs', + [string]$Label = '' +) + +$ErrorActionPreference = 'Continue' +$ProgressPreference = 'SilentlyContinue' + +$serial = try { (Get-CimInstance Win32_BIOS).SerialNumber.Trim() } catch { 'unknown' } +$stamp = Get-Date -Format 'yyyyMMdd-HHmmss' +$name = if ($Label) { "$serial-$Label-$stamp" } else { "$serial-$stamp" } +$work = Join-Path $env:TEMP "imgdiag-$name" +New-Item -ItemType Directory -Path $work -Force | Out-Null + +function Section { param([string]$File, [scriptblock]$Body) + $p = Join-Path $work $File + try { & $Body 2>&1 | Out-File -FilePath $p -Encoding utf8 -Width 500 } + catch { "COLLECTION ERROR: $_" | Out-File -FilePath $p -Encoding utf8 } + Write-Host (" {0}" -f $File) +} + +function CopyTree { param([string]$Src, [string]$Dst) + if (Test-Path $Src) { + $d = Join-Path $work $Dst + New-Item -ItemType Directory -Path $d -Force | Out-Null + robocopy $Src $d /E /R:0 /W:0 /NFL /NDL /NJH /NJS | Out-Null + Write-Host (" {0}\ <- {1}" -f $Dst, $Src) + } +} + +Write-Host "" +Write-Host "Collecting imaging diagnostics for $serial ..." +Write-Host "" + +# --- 1. Identity --------------------------------------------------------- +# ActiveComputerName is the name in use; ComputerName is the PENDING one. They +# differ when a rename is queued for the next reboot - which is exactly how the +# H -> F transition works, so capturing both tells you whether +# the package's rename landed or is still waiting. +Section 'identity.txt' { + '== computer names ==' + 'Active (in use): ' + (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\ComputerName\ActiveComputerName' -EA SilentlyContinue).ComputerName + 'Pending (next boot): ' + (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\ComputerName\ComputerName' -EA SilentlyContinue).ComputerName + 'NV Hostname: ' + (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters' -EA SilentlyContinue).'NV Hostname' + '' + '== hardware ==' + Get-CimInstance Win32_ComputerSystem | Select-Object Manufacturer, Model, Domain, PartOfDomain, TotalPhysicalMemory | Format-List + Get-CimInstance Win32_BIOS | Select-Object SerialNumber, SMBIOSBIOSVersion, ReleaseDate | Format-List + Get-CimInstance Win32_OperatingSystem | Select-Object Caption, Version, BuildNumber, InstallDate, LastBootUpTime | Format-List + '' + '== uptime / install ==' + 'Windows installed: ' + (Get-CimInstance Win32_OperatingSystem).InstallDate +} + +# --- 2. Enrollment state ------------------------------------------------- +# dsregcmd is the authority on whether the Entra join actually happened. +# criticalChecks.json is the package's own self-assessment and the two can +# disagree - "Entra ID Joined: false" right after imaging is normal because the +# bay has not reached the production network yet. +Section 'enrollment.txt' { + '== dsregcmd /status ==' + & dsregcmd /status + '' + '== package self-checks ==' + foreach ($f in 'C:\Logs\BPRT\criticalChecks.json','C:\Logs\BPRT\packageInfo.json', + 'C:\Logs\BPRT\Orchestrator\TokenMatch.json') { + if (Test-Path $f) { "--- $f ---"; Get-Content $f -Raw } + } + '' + '== staged selections ==' + foreach ($f in 'C:\Enrollment\pc-type.txt','C:\Enrollment\display-type.txt', + 'C:\Enrollment\pc-config.txt','C:\Enrollment\machine-number.txt', + 'C:\Enrollment\fetch-source.txt') { + if (Test-Path $f) { "$f = " + ((Get-Content $f -First 1) -replace 'pxe$','') } + } +} + +# --- 3. Installed applications ------------------------------------------ +# Registry Uninstall keys, both views. Deliberately NOT Win32_Product: querying +# it triggers an MSI reconfigure of every installed product, which is slow and +# can actually change the machine. +Section 'installed-apps.txt' { + $paths = @( + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*', + 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*', + 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*' + ) + Get-ItemProperty $paths -EA SilentlyContinue | + Where-Object { $_.DisplayName } | + Sort-Object DisplayName | + Select-Object DisplayName, DisplayVersion, Publisher, InstallDate, InstallLocation | + Format-Table -AutoSize +} + +# --- 4. Everything that starts by itself -------------------------------- +# THE section for "why did this app launch". Installers plant their own startup +# entries, so an app being installed IS an app being started - there is no +# separate switch. Capturing all four surfaces shows which app planted what. +# Note both registry views: a 32-bit installer's Run key lands under +# Wow6432Node and 64-bit tooling never sees it. +Section 'autostart.txt' { + '== HKLM Run / RunOnce (native + WOW64) ==' + foreach ($k in 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run', + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce', + 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run', + 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\RunOnce') { + if (Test-Path $k) { "--- $k ---"; Get-ItemProperty $k | Format-List } + } + '' + '== HKCU Run / RunOnce (current user) ==' + foreach ($k in 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run', + 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce') { + if (Test-Path $k) { "--- $k ---"; Get-ItemProperty $k | Format-List } + } + '' + '== Startup folders ==' + foreach ($d in "$env:ProgramData\Microsoft\Windows\Start Menu\Programs\StartUp", + "$env:AppData\Microsoft\Windows\Start Menu\Programs\StartUp") { + if (Test-Path $d) { "--- $d ---"; Get-ChildItem $d | Select-Object Name, Length, LastWriteTime | Format-Table -AutoSize } + } + '' + '== per-user Startup folders (all profiles) ==' + Get-ChildItem 'C:\Users' -Directory -EA SilentlyContinue | ForEach-Object { + $d = Join-Path $_.FullName 'AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup' + if (Test-Path $d) { "--- $d ---"; Get-ChildItem $d | Select-Object Name | Format-Table -AutoSize } + } + '' + '== scheduled tasks (non-Microsoft) ==' + Get-ScheduledTask -EA SilentlyContinue | + Where-Object { $_.TaskPath -notlike '\Microsoft\*' } | + Select-Object TaskPath, TaskName, State, + @{n='Triggers';e={ ($_.Triggers | ForEach-Object { $_.CimClass.CimClassName }) -join ',' }}, + @{n='Action';e={ ($_.Actions | ForEach-Object { $_.Execute }) -join ',' }} | + Sort-Object TaskPath, TaskName | Format-Table -AutoSize -Wrap + '' + '== services set to auto-start (non-Microsoft paths) ==' + Get-CimInstance Win32_Service -EA SilentlyContinue | + Where-Object { $_.StartMode -eq 'Auto' -and $_.PathName -notmatch 'C:\\Windows\\' } | + Select-Object Name, DisplayName, State, StartMode, PathName | + Sort-Object Name | Format-Table -AutoSize -Wrap +} + +# --- 5. Kiosk / display specifics --------------------------------------- +Section 'display-kiosk.txt' { + '== kiosk shortcuts anywhere in Startup ==' + Get-ChildItem 'C:\ProgramData\Microsoft\Windows\Start Menu\Programs\StartUp' -EA SilentlyContinue | + ForEach-Object { + $sh = (New-Object -ComObject WScript.Shell).CreateShortcut($_.FullName) + "{0}`n target: {1}`n args: {2}" -f $_.Name, $sh.TargetPath, $sh.Arguments + } + '' + '== Edge policies ==' + foreach ($k in 'HKLM:\SOFTWARE\Policies\Microsoft\Edge', + 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\System') { + if (Test-Path $k) { "--- $k ---"; Get-ItemProperty $k | Format-List } + } +} + +# --- 6. GE-Enforce ------------------------------------------------------- +Section 'ge-enforce.txt' { + '== registry config ==' + foreach ($k in 'HKLM:\SOFTWARE\GE\SFLD\Credentials','HKLM:\SOFTWARE\GE\SFLD\DSC', + 'HKLM:\SOFTWARE\GE\ShopDB') { + # values only - do not dump anything that looks like a secret + if (Test-Path $k) { + "--- $k ---" + Get-Item $k | Select-Object -ExpandProperty Property | ForEach-Object { + $v = (Get-ItemProperty $k -Name $_).$_ + if ($_ -match '(?i)key|token|secret|password|sas') { "$_ = " } + else { "$_ = $v" } + } + } + } + '' + '== enforce client / DSC on disk ==' + foreach ($d in 'C:\ProgramData\SFLD','C:\Deploy\Applications\BPRT') { + if (Test-Path $d) { "--- $d ---"; Get-ChildItem $d -Recurse -Depth 1 | Select-Object FullName, Length, LastWriteTime | Format-Table -AutoSize } + } +} + +# --- 6b. IS IT ACTUALLY TALKING TO THE SHOPDB GE-ENFORCE API? ----------- +# Config on disk proves nothing - the client can be present, configured, and +# never once succeed. This answers the actual question: is there a client, is it +# scheduled, has it run, and can this bay reach the endpoint right now. +# +# A 401 from the manifest endpoint is a GOOD result here: it proves DNS, routing +# and TLS all work and the service answered. Only a timeout or DNS failure means +# genuinely unreachable. No token is sent, so nothing here can enrol or change +# anything. +Section 'geenforce-api.txt' { + '== is the client installed? ==' + $clientPaths = @( + 'C:\Program Files\ShopDB', 'C:\ProgramData\ShopDB', + 'C:\Deploy\Applications\BPRT\ShopdbEnforceClient.psm1', + 'C:\Enrollment\ShopdbEnforceClient.psm1' + ) + $found = $false + foreach ($p in $clientPaths) { + if (Test-Path $p) { $found = $true; "FOUND: $p" + Get-ChildItem $p -Recurse -EA SilentlyContinue | + Select-Object FullName, Length, LastWriteTime | Format-Table -AutoSize } + } + if (-not $found) { 'NOT FOUND - no ShopDB enforce client on this machine.' } + '' + '== scheduled tasks that would run it ==' + $tasks = Get-ScheduledTask -EA SilentlyContinue | + Where-Object { $_.TaskName -match '(?i)shopdb|ge-?enforce|asset report' } + if ($tasks) { + foreach ($t in $tasks) { + $i = $t | Get-ScheduledTaskInfo -EA SilentlyContinue + "{0}\{1} State={2} LastRun={3} LastResult={4} NextRun={5}" -f ` + $t.TaskPath, $t.TaskName, $t.State, $i.LastRunTime, $i.LastTaskResult, $i.NextRunTime + ($t.Actions | ForEach-Object { " action: $($_.Execute) $($_.Arguments)" }) + } + } else { 'NONE - nothing scheduled to call the API.' } + '' + '== configured base URL ==' + $base = $null + foreach ($k in 'HKLM:\SOFTWARE\GE\ShopDB','HKLM:\SOFTWARE\GE\SFLD\Credentials') { + if (Test-Path $k) { + $p = Get-ItemProperty $k + foreach ($n in $p.PSObject.Properties.Name) { + if ($n -match '(?i)url|base|endpoint') { "$k\$n = $($p.$n)"; if (-not $base) { $base = $p.$n } } + } + } + } + if (-not $base) { '(no BaseUrl configured - falling back to the known prod URL for the reachability test)' } + if (-not $base) { $base = 'https://tsgwp00525.wjs.geaerospace.net/shopdb' } + '' + "== can this bay reach it right now? (base: $base) ==" + $targets = @( + @{ Name = 'API docs'; Url = "$base/api/docs" }, + @{ Name = 'GE-Enforce manifest'; Url = "$base/api/geenforce/manifest?pctype=gea-shopfloor-display" } + ) + foreach ($t in $targets) { + try { + $sw = [Diagnostics.Stopwatch]::StartNew() + $r = Invoke-WebRequest -Uri $t.Url -UseBasicParsing -TimeoutSec 20 -EA Stop + $sw.Stop() + "{0,-22} HTTP {1} in {2} ms <- reachable" -f $t.Name, $r.StatusCode, $sw.ElapsedMilliseconds + } catch { + $code = try { $_.Exception.Response.StatusCode.value__ } catch { $null } + if ($code) { + "{0,-22} HTTP {1} <- REACHABLE (service answered; 401/403 just means no token was sent)" -f $t.Name, $code + } else { + "{0,-22} UNREACHABLE: {1}" -f $t.Name, $_.Exception.Message + } + } + } + '' + '== name resolution / route ==' + try { + $h = ([uri]$base).Host + "host: $h" + Resolve-DnsName $h -EA Stop | Select-Object Name, Type, IPAddress | Format-Table -AutoSize + Test-NetConnection -ComputerName $h -Port 443 -InformationLevel Detailed -WarningAction SilentlyContinue | + Select-Object ComputerName, RemoteAddress, TcpTestSucceeded, PingSucceeded | Format-List + } catch { "DNS/route check failed: $_" } + '' + '== client logs, if any ==' + foreach ($d in 'C:\Logs\ShopDB','C:\Logs\GE-Enforce','C:\ProgramData\ShopDB\Logs') { + if (Test-Path $d) { "--- $d ---"; Get-ChildItem $d -Recurse | Select-Object FullName, Length, LastWriteTime | Format-Table -AutoSize } + } +} + +# --- 7. Drivers, disk, network ------------------------------------------ +Section 'drivers-disk-network.txt' { + '== third-party drivers ==' + & pnputil /enum-drivers + '' + '== disk layout ==' + Get-Disk -EA SilentlyContinue | Format-Table -AutoSize + Get-Partition -EA SilentlyContinue | Format-Table -AutoSize + Get-Volume -EA SilentlyContinue | Format-Table -AutoSize + '' + '== network ==' + Get-NetAdapter -EA SilentlyContinue | Select-Object Name, InterfaceDescription, Status, LinkSpeed, MacAddress | Format-Table -AutoSize + Get-NetIPAddress -AddressFamily IPv4 -EA SilentlyContinue | Select-Object InterfaceAlias, IPAddress, PrefixOrigin | Format-Table -AutoSize + Get-DnsClientServerAddress -AddressFamily IPv4 -EA SilentlyContinue | Format-Table -AutoSize +} + +# --- 8. Provisioning session state -------------------------------------- +Section 'provisioning-sessions.txt' { + '== sessions ==' + $k = 'HKLM:\SOFTWARE\Microsoft\Provisioning\Sessions' + if (Test-Path $k) { + Get-ChildItem $k | ForEach-Object { + $p = Get-ItemProperty $_.PSPath + "{0} State={1} RebootCount={2}" -f $_.PSChildName, $p.State, $p.RebootCount + } + } else { '(no provisioning sessions key)' } +} + +# --- 9. Log trees -------------------------------------------------------- +CopyTree 'C:\Logs' 'Logs' +CopyTree 'C:\Windows\Panther' 'Panther' +CopyTree 'C:\ProgramData\Microsoft\Provisioning' 'ProvisioningData' +foreach ($f in 'C:\Enrollment\winpe-staging.log','C:\Enrollment\setupcomplete.log') { + if (Test-Path $f) { Copy-Item $f $work -Force -EA SilentlyContinue } +} + +# Enrollment dir listing only - it holds an 8 GB package we do not want. +Section 'enrollment-dir-listing.txt' { + if (Test-Path 'C:\Enrollment') { + Get-ChildItem 'C:\Enrollment' -Recurse -EA SilentlyContinue | + Select-Object FullName, Length, LastWriteTime | Format-Table -AutoSize + } +} + +# --- 10. Provisioning event log ----------------------------------------- +try { + $evtx = Join-Path $work 'Provisioning-Diagnostics-Admin.evtx' + & wevtutil epl 'Microsoft-Windows-Provisioning-Diagnostics-Provider/Admin' $evtx /ow:true 2>$null + if (Test-Path $evtx) { Write-Host ' Provisioning-Diagnostics-Admin.evtx' } +} catch { } + +# --- zip ----------------------------------------------------------------- +New-Item -ItemType Directory -Path $Destination -Force -EA SilentlyContinue | Out-Null +$zip = Join-Path $Destination "imgdiag-$name.zip" +try { + Compress-Archive -Path (Join-Path $work '*') -DestinationPath $zip -Force -EA Stop + Remove-Item $work -Recurse -Force -EA SilentlyContinue + Write-Host "" + Write-Host "Wrote $zip" + Write-Host ("Size: {0:N1} MB" -f ((Get-Item $zip).Length / 1MB)) +} catch { + Write-Host "" + Write-Host "Could not zip to $Destination : $_" + Write-Host "Raw collection left at: $work" +} +Write-Host ""