diff --git a/docs/ADOPTING-AT-ANOTHER-SITE.md b/docs/ADOPTING-AT-ANOTHER-SITE.md index 5951085..562d7ad 100644 --- a/docs/ADOPTING-AT-ANOTHER-SITE.md +++ b/docs/ADOPTING-AT-ANOTHER-SITE.md @@ -22,7 +22,7 @@ at all yet. | Artifact | Where it is today | |---|---| | GE-Enforce client (`Install-GEEnforce.ps1`, `Invoke-ShopdbEnforce.ps1`, `ShopdbEnforceClient.psm1`) | This repository, `plugins/geenforce/client/`. Present on any installed server under the install directory. | -| `Report-AssetToShopDB.ps1` | **Not in this repository.** It lives on the reference site's imaging share and is provided on request. It is planned to move to `plugins/computers/client/` so it versions with the collector contract it implements. | +| `Report-AssetToShopDB.ps1` | `plugins/computers/client/`, so it versions with the collector contract it implements. It names no site: the server comes from `HKLM:\SOFTWARE\GE\ShopDB` `BaseUrl` (which Install-GEEnforce.ps1 writes) or `-ApiUrl`, and the NIC it reports is the one carrying the default route unless the site names its ranges via `-AllowedRanges` or the `CollectorRanges` registry value. | | EventSaver (`EventSaver.scr`, `EventSaver.ini`, `EventSaver.cs`) | **Not in this repository.** Provided on request; the source is a single C# file that builds with the in-box .NET Framework compiler, so a site can rebuild it rather than trust a binary. See [EVENTSAVER.md](EVENTSAVER.md). | Ask the maintainers for the two that are not here. A site that would rather not diff --git a/plugins/computers/client/Report-AssetToShopDB.ps1 b/plugins/computers/client/Report-AssetToShopDB.ps1 new file mode 100644 index 0000000..d5f09d8 --- /dev/null +++ b/plugins/computers/client/Report-AssetToShopDB.ps1 @@ -0,0 +1,328 @@ +# Report-AssetToShopDB.ps1 +# +# Reports a PC's identity to ShopDB (Flask) so the computers/machines record +# stays current with whatever the PC actually is right now: hostname, BIOS +# serial, pc-type, logged-in user, DNC machine number (2001, 2002, ... when +# present) and its corp/AESFMA IPv4 address. +# +# TARGET: the ADR-006 collector API. +# POST /api/collector/computers +# The server is NOT baked in. It comes from HKLM:\SOFTWARE\GE\ShopDB BaseUrl, +# which Install-GEEnforce.ps1 provisions and the enforcement client already +# needs, or from -ApiUrl in the manifest entry's Args. ADR-015: a site name in +# product code is a defect, and this script ships to every site. +# The computers plugin's apply_collector_payload upserts idempotently by +# hostname (create if missing, patch-style update if present - only the fields +# posted here change, so it never clobbers model/VNC/WinRM). Machine number maps +# to Asset.assetnumber; the placeholder 9999 is skipped server-side. +# +# AUTH: the collector API does NOT honor the GE-Enforce IP allowlist (that only +# covers the geenforce fetch/report endpoints). It needs a collector.ingest key, +# sent as the X-API-Key header. The key is read from HKLM:\SOFTWARE\GE\ShopDB +# CollectorKey (same secret store the GE-Enforce client uses; provisioned at +# imaging), or overridden via the manifest entry's Args -ApiKey. Never bake the +# key into the manifest JSON on the share. +# +# Deployed in common\ (runs on EVERY shopfloor pc-type). Non-DNC bays report +# identity with no machineNo, so no PC-to-machine link is built - by design. +# Runs every GE-Enforce cycle as a Type=PS1 / DetectionMethod=Always entry under +# the SYSTEM task. Always exits 0 so "last run result" stays clean; failures are +# logged, never thrown. +# +# WHY ONE NIC ONLY: +# Some bays carry two NICs - a private controller NIC and the routable +# corporate NIC. Only the routable one belongs in ShopDB. A site may name its +# corporate ranges (-AllowedRanges, or the CollectorRanges registry value); with +# none configured the NIC carrying the DEFAULT ROUTE is used, which expresses +# the same intent without knowing any site's addressing. + +param( + # Flask collector endpoint for the computers plugin. Empty resolves from + # HKLM:\SOFTWARE\GE\ShopDB BaseUrl; override here if the path ever moves. + [string]$ApiUrl = '', + + # Comma-separated CIDRs naming this site's routable ranges, e.g. + # '10.20.0.0/23,10.21.4.0/26'. Empty uses the default-route NIC instead. + [string]$AllowedRanges = '', + + # collector.ingest key (X-API-Key). Default: read from the GE-Enforce secret + # store in the registry. Override with -ApiKey via Args for testing. + [string]$ApiKey = '', + + [int]$TimeoutSec = 30 +) + +$ErrorActionPreference = 'Continue' + +# Force TLS 1.2 - older images default to SystemDefault which may negotiate a +# protocol the site rejects; the collector POST is HTTPS. +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + +$logDir = 'C:\Logs\Shopfloor' +if (-not (Test-Path $logDir)) { + New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null +} +$logFile = Join-Path $logDir ('report-asset-{0}.log' -f (Get-Date -Format 'yyyyMMdd')) + +function Log([string]$msg) { + $ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' + "$ts $msg" | Tee-Object -FilePath $logFile -Append | Out-Null +} + +# Server from the GE-Enforce config hive when not passed via Args. Any site +# running this script is running the enforcement client, which cannot work +# without BaseUrl, so it is present wherever this is deployed. +if (-not $ApiUrl) { + foreach ($regPath in @('HKLM:\SOFTWARE\GE\ShopDB', 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB')) { + try { + if (Test-Path $regPath) { + $base = [string](Get-ItemProperty -Path $regPath -Name BaseUrl -ErrorAction Stop).BaseUrl + if ($base -and $base.Trim()) { + $ApiUrl = $base.Trim().TrimEnd('/') + '/api/collector/computers' + break + } + } + } catch {} + } +} +if (-not $ApiUrl) { + Log 'ERROR no ShopDB URL (HKLM:\SOFTWARE\GE\ShopDB BaseUrl or -ApiUrl). Skipping.' + exit 0 +} + +# A site may name its routable ranges rather than rely on the default route. +if (-not $AllowedRanges) { + foreach ($regPath in @('HKLM:\SOFTWARE\GE\ShopDB', 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB')) { + try { + if (Test-Path $regPath) { + $v = [string](Get-ItemProperty -Path $regPath -Name CollectorRanges -ErrorAction Stop).CollectorRanges + if ($v -and $v.Trim()) { $AllowedRanges = $v.Trim(); break } + } + } catch {} + } +} + +# collector key from the GE-Enforce secret store when not passed via Args. +if (-not $ApiKey) { + foreach ($regPath in @('HKLM:\SOFTWARE\GE\ShopDB', 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB')) { + try { + if (Test-Path $regPath) { + $v = [string](Get-ItemProperty -Path $regPath -Name CollectorKey -ErrorAction Stop).CollectorKey + if ($v -and $v.Trim()) { $ApiKey = $v.Trim(); break } + } + } catch {} + } +} +if (-not $ApiKey) { + Log 'ERROR no collector key (HKLM:\SOFTWARE\GE\ShopDB CollectorKey or -ApiKey). Skipping.' + exit 0 +} + +# Routable ranges, if this site named any. NO SITE ADDRESSING IS BAKED IN: an +# unconfigured site falls through to the default-route NIC below (ADR-015). +# +# NOT named $allowedRanges: PowerShell variable names are case-insensitive, so +# that collides with the [string] parameter above and the array is silently +# COERCED to a string. .Count on a string is 1, so the script then believes a +# range is configured and never falls back to the default route. +$rangeList = @() +foreach ($cidr in ($AllowedRanges -split ',')) { + $cidr = $cidr.Trim() + if (-not $cidr) { continue } + $parts = $cidr -split '/' + if ($parts.Count -ne 2) { Log "WARN ignoring malformed range '$cidr'"; continue } + $rangeList += @{ Network = $parts[0].Trim(); PrefixLen = [int]$parts[1] } +} + +function ConvertTo-Uint32([string]$ip) { + $bytes = ([System.Net.IPAddress]::Parse($ip)).GetAddressBytes() + [Array]::Reverse($bytes) + return [BitConverter]::ToUInt32($bytes, 0) +} + +function Test-InAllowedRange([string]$ip) { + try { + $ipInt = ConvertTo-Uint32 $ip + foreach ($r in $rangeList) { + $netInt = ConvertTo-Uint32 $r.Network + $mask = [uint32]([math]::Pow(2, 32) - [math]::Pow(2, 32 - $r.PrefixLen)) + if (($ipInt -band $mask) -eq ($netInt -band $mask)) { return $true } + } + } catch {} + return $false +} + +Log '=== Report asset to ShopDB (collector) ===' + +# hostname - the collector identity field. required. +$hostname = $env:COMPUTERNAME + +# BIOS serial - optional now (collector keys on hostname). Sent when present. +$serialNumber = '' +try { + $serialNumber = (Get-CimInstance -ClassName Win32_BIOS -ErrorAction Stop).SerialNumber + if ($serialNumber) { $serialNumber = $serialNumber.Trim() } +} catch { + Log "WARN could not read BIOS serial: $($_.Exception.Message)" +} + +# Machine identifier - optional, sent only if found. Maps to Asset.assetnumber +# server-side (9999 placeholder skipped there and here). +# 1. eDNC registry (WOW6432Node, then native) - DNC/collections bays (2001...). +# 2. C:\Enrollment\cmm\cmmid.txt - CMM bay id (e.g. CMM3). +# 3. C:\Enrollment\machine-number.txt - imaging value. +# keyence / genspect / part-marker have no per-bay id -> no machineNo sent. +$machineNo = '' +foreach ($regPath in @( + 'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General', + 'HKLM:\SOFTWARE\GE Aircraft Engines\DNC\General' +)) { + if ($machineNo) { break } + try { + if (Test-Path $regPath) { + $v = [string](Get-ItemProperty -Path $regPath -Name MachineNo -ErrorAction Stop).MachineNo + if ($v -and $v.Trim() -ne '9999') { $machineNo = $v.Trim() } + } + } catch { + Log "WARN could not read MachineNo from ${regPath}: $($_.Exception.Message)" + } +} +if (-not $machineNo) { + $cmmFile = 'C:\Enrollment\cmm\cmmid.txt' + if (Test-Path -LiteralPath $cmmFile) { + try { + $v = ([string](Get-Content -LiteralPath $cmmFile -First 1 -ErrorAction Stop)).Trim() + if ($v -and $v -ne '9999') { $machineNo = $v; Log "machineNo from $cmmFile (CMM bay id): $machineNo" } + } catch { Log "WARN could not read ${cmmFile}: $($_.Exception.Message)" } + } +} +if (-not $machineNo) { + $mnFile = 'C:\Enrollment\machine-number.txt' + if (Test-Path -LiteralPath $mnFile) { + try { + $v = ([string](Get-Content -LiteralPath $mnFile -First 1 -ErrorAction Stop)).Trim() + if ($v -and $v -ne '9999') { $machineNo = $v; Log "machineNo from $mnFile (imaging value): $machineNo" } + } catch { Log "WARN could not read ${mnFile}: $($_.Exception.Message)" } + } +} + +# OS name string (caption + feature-update + build), e.g. +# "Microsoft Windows 11 Enterprise 23H2 (build 22631)". Server upserts each +# distinct string into operatingsystems. +$osVersion = '' +$lastBootTime = '' +try { + $os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop + $osVersion = "$($os.Caption)".Trim() + $displayVersion = '' + try { + $displayVersion = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -Name DisplayVersion -ErrorAction Stop).DisplayVersion + } catch {} + if ($displayVersion) { $osVersion += " $displayVersion" } + if ($os.BuildNumber) { $osVersion += " (build $($os.BuildNumber))" } + $osVersion = $osVersion.Trim() + # ISO 8601 - collector parses via datetime.fromisoformat. + try { $lastBootTime = $os.LastBootUpTime.ToString('yyyy-MM-ddTHH:mm:ss') } catch {} +} catch {} + +# interactive console user. runs as SYSTEM, so use Win32_ComputerSystem.UserName +# (console session owner). empty when nobody logged on -> omitted so an +# unattended bay does not blank the last-known user. +$loggedInUser = '' +try { + $loggedInUser = (Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop).UserName + if ($loggedInUser) { $loggedInUser = ($loggedInUser -split '\\')[-1].Trim() } +} catch { + Log "WARN could not read logged-in user: $($_.Exception.Message)" +} + +# imaging pc-type (gea-shopfloor-*), read from the enrollment file. Sent only +# when present; absent -> server leaves existing pctype untouched (a bare report +# never re-types a PC). Unmapped values return a warning, not an error. +$pcType = '' +$ptFile = 'C:\Enrollment\pc-type.txt' +if (Test-Path -LiteralPath $ptFile) { + try { + $pcType = (Get-Content -LiteralPath $ptFile -First 1 -ErrorAction Stop).Trim() + } catch { Log "WARN could not read ${ptFile}: $($_.Exception.Message)" } +} + +# PC make/model from WMI. Server resolves/creates vendor + model. Sent only when +# present so a WMI read failure does not blank the model. +$manufacturer = '' +$model = '' +try { + $cs = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop + $manufacturer = "$($cs.Manufacturer)".Trim() + $model = "$($cs.Model)".Trim() +} catch { + Log "WARN could not read make/model: $($_.Exception.Message)" +} + +# The routable IPv4 - a physical, connected NIC. The collector schema takes a +# single ipaddress string, so report the corporate NIC and drop any +# controller/machine-LAN NIC. With ranges configured the IP must be in one; +# with none, the NIC carrying the default route is the routable one by +# definition, which needs no knowledge of a site's addressing. +$corpIp = '' +$defaultRouteIfIndexes = @() +if ($rangeList.Count -eq 0) { + try { + $defaultRouteIfIndexes = @(Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction Stop | + Sort-Object RouteMetric | + Select-Object -ExpandProperty InterfaceIndex -Unique) + } catch { + Log "WARN could not read the route table: $($_.Exception.Message)" + } +} +try { + $ipObjs = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop | + Where-Object { $_.IPAddress -notmatch '^169\.254' -and $_.IPAddress -ne '127.0.0.1' } + foreach ($ipo in $ipObjs) { + if ($corpIp) { break } + $adapter = $null + try { $adapter = Get-NetAdapter -InterfaceIndex $ipo.InterfaceIndex -ErrorAction Stop } catch {} + if (-not $adapter) { continue } + if (-not $adapter.HardwareInterface) { continue } + if ($adapter.Status -ne 'Up') { continue } + if ($rangeList.Count -gt 0) { + if (Test-InAllowedRange $ipo.IPAddress) { $corpIp = $ipo.IPAddress } + } elseif ($defaultRouteIfIndexes -contains $ipo.InterfaceIndex) { + $corpIp = $ipo.IPAddress + } + } +} catch { + Log "WARN interface enumeration failed: $($_.Exception.Message)" +} +if (-not $corpIp) { Log 'WARN no routable IPv4 NIC found; posting identity without ipaddress.' } + +# collector schema fields (lowercase concatenated). hostname is required; the +# rest are sent only when present so a partial read never blanks a good value. +$body = @{ hostname = $hostname } +if ($serialNumber) { $body['serialnumber'] = $serialNumber } +if ($machineNo) { $body['machinenumber'] = $machineNo } +if ($pcType) { $body['pctype'] = $pcType } +if ($manufacturer) { $body['vendorname'] = $manufacturer } +if ($model) { $body['modelnumber'] = $model } +if ($osVersion) { $body['osname'] = $osVersion } +if ($lastBootTime) { $body['lastboottime'] = $lastBootTime } +if ($loggedInUser) { $body['loggedinuser'] = $loggedInUser } +if ($corpIp) { $body['ipaddress'] = $corpIp } +$body['lastcheckin'] = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss') + +$json = $body | ConvertTo-Json -Compress -Depth 4 + +Log ("POST {0} host={1} serial={2} pcType={3} make={4} model={5} os={6} boot={7} machineNo={8} user={9} ip={10}" -f ` + $ApiUrl, $hostname, $serialNumber, $pcType, $manufacturer, $model, $osVersion, $lastBootTime, $machineNo, $loggedInUser, $corpIp) + +try { + $resp = Invoke-RestMethod -Uri $ApiUrl -Method Post -Body $json ` + -ContentType 'application/json' ` + -Headers @{ 'X-API-Key' = $ApiKey } ` + -TimeoutSec $TimeoutSec -ErrorAction Stop + Log ("RESPONSE {0}" -f ($resp | ConvertTo-Json -Compress -Depth 4)) +} catch { + Log "ERROR POST failed: $($_.Exception.Message)" +} + +exit 0