Found in the Windows VM check. - The agent upgrade script wrapped Get-ShopDbServerPins in @(), which turned several registry pins into one joined string. During a certificate rotation the upgrade download would have refused the rotated server. It also merged appsettings pins with the registry; it now follows the agent's rule: when the ServerPins value exists, even empty, the registry owns the list. The shared pin block in the upgrade script and both reporters gains Test-ShopDbServerPinsValue. - A key-less package installed over an enrolled PC printed both "existing enrolled credential found; kept as is" and "This PC will request enrollment". The enrollment notices now print only when no credential exists.
635 lines
28 KiB
PowerShell
635 lines
28 KiB
PowerShell
# 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 <shopdb>/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). Once the endpoint agent (1.20+)
|
|
# has enrolled this PC, its per-PC credential is read from the agent's
|
|
# SYSTEM-only credential.bin and sent as X-Endpoint-Credential; the server then
|
|
# refuses the shared key for this PC (401 SHARED_KEY_REFUSED). Before that, a
|
|
# collector.ingest key is sent as the X-API-Key header, 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.
|
|
#
|
|
# PINS: when HKLM:\SOFTWARE\GE\ShopDB ServerPins is set, the server's TLS key
|
|
# must match one of them or nothing is sent. See
|
|
# plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md.
|
|
#
|
|
# 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. This script reports ALL of them and lets the server decide
|
|
# which is the PC's address, from the collector_corporate_ranges setting.
|
|
# Choosing here was wrong: a bay cannot know a site's addressing, and a CMM
|
|
# whose 10Gbit controller link carries a default route reported the
|
|
# machine-side address as the PC's - with the corporate one never sent.
|
|
|
|
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
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Per-PC credential and server pin. Kept in step with the same block in
|
|
# Report-AssetToShopDB.ps1, Report-PrintersToShopDB.ps1 and
|
|
# upgrade-shopdbagent.ps1: each runs standalone, so the text is duplicated.
|
|
#
|
|
# Credential: the endpoint agent (1.20+) enrolls and stores its own credential
|
|
# in credential.bin, DPAPI LocalMachine, readable only by SYSTEM and
|
|
# Administrators. When present it is sent as X-Endpoint-Credential and the
|
|
# shared key is not sent. Without it the shared CollectorKey goes as X-API-Key.
|
|
#
|
|
# Pins: HKLM\SOFTWARE\GE\ShopDB ServerPins (REG_MULTI_SZ), lowercase hex
|
|
# SHA-256 of the server leaf certificate's SubjectPublicKeyInfo. When any pin
|
|
# is set the TLS handshake is accepted only if the leaf key matches one.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
$ShopDbCredentialFile = Join-Path $env:ProgramData 'ShopDB\EndpointAgent\credential\credential.bin'
|
|
|
|
$ShopDbPinSource = @'
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Net;
|
|
using System.Net.Security;
|
|
using System.Security.Cryptography;
|
|
using System.Security.Cryptography.X509Certificates;
|
|
|
|
public static class ShopDbServerPinValidator
|
|
{
|
|
private static readonly HashSet<string> Pins = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
public static string LastPresented;
|
|
|
|
public static void Install(string[] pins)
|
|
{
|
|
Pins.Clear();
|
|
foreach (string pin in pins)
|
|
{
|
|
if (!String.IsNullOrEmpty(pin)) { Pins.Add(pin); }
|
|
}
|
|
ServicePointManager.ServerCertificateValidationCallback = Validate;
|
|
}
|
|
|
|
public static bool Validate(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors errors)
|
|
{
|
|
if (Pins.Count == 0) { return errors == SslPolicyErrors.None; }
|
|
if (certificate == null) { return false; }
|
|
try
|
|
{
|
|
LastPresented = SpkiSha256Hex(certificate.GetRawCertData());
|
|
}
|
|
catch (Exception)
|
|
{
|
|
LastPresented = null;
|
|
return false;
|
|
}
|
|
return Pins.Contains(LastPresented);
|
|
}
|
|
|
|
public static bool IsPinned(string pin)
|
|
{
|
|
return !String.IsNullOrEmpty(pin) && Pins.Contains(pin);
|
|
}
|
|
|
|
// Walks the DER: Certificate > TBSCertificate > [0] version (optional),
|
|
// serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo.
|
|
public static string SpkiSha256Hex(byte[] der)
|
|
{
|
|
int length;
|
|
int position = EnterSequence(der, 0, out length);
|
|
position = EnterSequence(der, position, out length);
|
|
if (der[position] == 0xA0) { position = Skip(der, position); }
|
|
for (int field = 0; field < 5; field++) { position = Skip(der, position); }
|
|
if (der[position] != 0x30) { throw new FormatException("SubjectPublicKeyInfo not found"); }
|
|
int end = Skip(der, position);
|
|
using (SHA256 sha = SHA256.Create())
|
|
{
|
|
byte[] hash = sha.ComputeHash(der, position, end - position);
|
|
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
|
}
|
|
}
|
|
|
|
private static int ReadLength(byte[] der, int position, out int length)
|
|
{
|
|
int first = der[position];
|
|
if (first < 0x80) { length = first; return position + 1; }
|
|
int count = first & 0x7F;
|
|
if (count == 0 || count > 4) { throw new FormatException("Unsupported DER length"); }
|
|
length = 0;
|
|
for (int i = 1; i <= count; i++) { length = (length << 8) | der[position + i]; }
|
|
return position + 1 + count;
|
|
}
|
|
|
|
private static int EnterSequence(byte[] der, int position, out int length)
|
|
{
|
|
if (der[position] != 0x30) { throw new FormatException("Expected SEQUENCE"); }
|
|
return ReadLength(der, position + 1, out length);
|
|
}
|
|
|
|
private static int Skip(byte[] der, int position)
|
|
{
|
|
int length;
|
|
int content = ReadLength(der, position + 1, out length);
|
|
return content + length;
|
|
}
|
|
}
|
|
'@
|
|
|
|
function Get-ShopDbServerPins {
|
|
# Both registry views, like CollectorKey. Anything not 64 hex chars is dropped.
|
|
$pins = @()
|
|
foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64, [Microsoft.Win32.RegistryView]::Registry32)) {
|
|
try {
|
|
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $view)
|
|
try {
|
|
$key = $base.OpenSubKey('SOFTWARE\GE\ShopDB')
|
|
if ($key) {
|
|
try {
|
|
foreach ($value in @($key.GetValue('ServerPins'))) {
|
|
if ($null -eq $value) { continue }
|
|
$pin = ([string]$value -replace '[\s:]', '').ToLowerInvariant()
|
|
if ($pin -match '^[0-9a-f]{64}$' -and $pins -notcontains $pin) { $pins += $pin }
|
|
}
|
|
} finally { $key.Dispose() }
|
|
}
|
|
} finally { $base.Dispose() }
|
|
} catch {}
|
|
}
|
|
return ,$pins
|
|
}
|
|
|
|
function Test-ShopDbServerPinsValue {
|
|
# True when ServerPins exists in either registry view, even empty. The
|
|
# registry then owns the pin list and appsettings pins are ignored, the
|
|
# same rule the agent follows.
|
|
foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64, [Microsoft.Win32.RegistryView]::Registry32)) {
|
|
try {
|
|
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $view)
|
|
try {
|
|
$key = $base.OpenSubKey('SOFTWARE\GE\ShopDB')
|
|
if ($key) {
|
|
try {
|
|
if ($key.GetValueNames() -contains 'ServerPins') { return $true }
|
|
} finally { $key.Dispose() }
|
|
}
|
|
} finally { $base.Dispose() }
|
|
} catch {}
|
|
}
|
|
return $false
|
|
}
|
|
|
|
function Enable-ShopDbServerPins([string[]]$Pins) {
|
|
# True when pinning is active. False means pins are set but could not be
|
|
# enforced; the caller must not send anything then.
|
|
if ($PSVersionTable.PSEdition -eq 'Core') {
|
|
# PowerShell 7 web cmdlets ignore ServicePointManager, so the check
|
|
# would silently not happen.
|
|
Log 'ERROR ServerPins is set; pin checking needs Windows PowerShell 5.1 (powershell.exe), not pwsh'
|
|
return $false
|
|
}
|
|
try {
|
|
if (-not ('ShopDbServerPinValidator' -as [type])) {
|
|
Add-Type -TypeDefinition $ShopDbPinSource -ErrorAction Stop
|
|
}
|
|
[ShopDbServerPinValidator]::Install($Pins)
|
|
return $true
|
|
} catch {
|
|
Log "ERROR ServerPins is set but pin checking could not be enabled: $($_.Exception.Message)"
|
|
return $false
|
|
}
|
|
}
|
|
|
|
function Get-ShopDbEndpointCredential([string]$TargetUrl) {
|
|
# Empty when there is none or it cannot be used for this URL.
|
|
if (-not (Test-Path -LiteralPath $ShopDbCredentialFile)) { return '' }
|
|
try {
|
|
Add-Type -AssemblyName System.Security -ErrorAction Stop
|
|
$protected = [System.IO.File]::ReadAllBytes($ShopDbCredentialFile)
|
|
$plain = [System.Security.Cryptography.ProtectedData]::Unprotect(
|
|
$protected, $null, [System.Security.Cryptography.DataProtectionScope]::LocalMachine)
|
|
$record = [System.Text.Encoding]::UTF8.GetString($plain) | ConvertFrom-Json
|
|
} catch {
|
|
Log "WARN could not read the endpoint credential ($ShopDbCredentialFile): $($_.Exception.Message)"
|
|
return ''
|
|
}
|
|
$credential = [string]$record.credential
|
|
if (-not $credential.StartsWith('sdbe_')) {
|
|
Log 'WARN endpoint credential file has no usable credential; ignoring it'
|
|
return ''
|
|
}
|
|
if (-not $TargetUrl.StartsWith('https://', [System.StringComparison]::OrdinalIgnoreCase)) {
|
|
Log "WARN not sending the endpoint credential over a non-https URL ($TargetUrl)"
|
|
return ''
|
|
}
|
|
# Never hand a credential to a server other than the one that issued it.
|
|
$issuer = ([string]$record.baseurl).Trim().TrimEnd('/')
|
|
if ($issuer -and -not ($TargetUrl.TrimEnd('/') + '/').StartsWith($issuer + '/', [System.StringComparison]::OrdinalIgnoreCase)) {
|
|
Log "WARN endpoint credential was issued by $issuer, not the target $TargetUrl; not sending it"
|
|
return ''
|
|
}
|
|
return $credential
|
|
}
|
|
|
|
function Get-ShopDbHttpFailure($ErrorRecord) {
|
|
# Status and error envelope code (data.error.code) from a failed request.
|
|
$status = 0
|
|
$code = ''
|
|
$text = ''
|
|
try { $status = [int]$ErrorRecord.Exception.Response.StatusCode } catch {}
|
|
if ($ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) {
|
|
$text = $ErrorRecord.ErrorDetails.Message
|
|
} else {
|
|
try {
|
|
$reader = New-Object System.IO.StreamReader($ErrorRecord.Exception.Response.GetResponseStream())
|
|
$text = $reader.ReadToEnd()
|
|
$reader.Dispose()
|
|
} catch {}
|
|
}
|
|
if ($text) {
|
|
try { $code = [string](($text | ConvertFrom-Json).data.error.code) } catch {}
|
|
}
|
|
$presented = ''
|
|
$matched = $false
|
|
if ('ShopDbServerPinValidator' -as [type]) {
|
|
$presented = [string][ShopDbServerPinValidator]::LastPresented
|
|
$matched = [ShopDbServerPinValidator]::IsPinned($presented)
|
|
}
|
|
# Innermost exception text: the outer WebException hides the real cause
|
|
# (unexpected EOF, connection reset, trust failure).
|
|
$message = ''
|
|
$exception = $ErrorRecord.Exception
|
|
while ($exception) {
|
|
if ($exception.Message) { $message = $exception.Message }
|
|
$exception = $exception.InnerException
|
|
}
|
|
return @{ Status = $status; Code = $code; PresentedPin = $presented; PinMatched = $matched; Message = $message }
|
|
}
|
|
|
|
function Write-ShopDbAuthFailure($Failure) {
|
|
switch ($Failure.Code) {
|
|
'CREDENTIAL_INVALID' {
|
|
Log ('ERROR 401 CREDENTIAL_INVALID: the server does not accept this PC''s endpoint credential ' +
|
|
'(revoked or unknown). The endpoint agent deletes it and re-enrolls on its own.')
|
|
}
|
|
'SHARED_KEY_REFUSED' {
|
|
Log ('ERROR 401 SHARED_KEY_REFUSED: the server refuses the shared key for this PC, because ' +
|
|
'the PC is enrolled or the shared key is switched off. Reports need the endpoint agent ' +
|
|
"credential: check agent 1.20+ is installed, running and enrolled ($ShopDbCredentialFile).")
|
|
}
|
|
'ASSET_MISMATCH' {
|
|
Log ('ERROR 403 ASSET_MISMATCH: this PC''s credential belongs to a different asset than the ' +
|
|
'hostname in the report. Check the hostname and the enrollment in ShopDB.')
|
|
}
|
|
default {
|
|
if ($Failure.Status -eq 0 -and $Failure.PresentedPin) {
|
|
# A pin was seen, but only an absent pin is a pin failure. A
|
|
# matched pin means the handshake passed and something later
|
|
# failed (server dropped mid-transfer, timeout).
|
|
if ($Failure.PinMatched) {
|
|
Log "ERROR request failed after the server key pin matched ($($Failure.PresentedPin)): $($Failure.Message)"
|
|
} else {
|
|
Log "ERROR TLS refused: server key pin $($Failure.PresentedPin) is not in ServerPins"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
# 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
|
|
}
|
|
|
|
# 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 {}
|
|
}
|
|
}
|
|
$EndpointCredential = Get-ShopDbEndpointCredential $ApiUrl
|
|
if (-not $EndpointCredential -and -not $ApiKey) {
|
|
Log 'ERROR no endpoint credential and no collector key (HKLM:\SOFTWARE\GE\ShopDB CollectorKey or -ApiKey). Skipping.'
|
|
exit 0
|
|
}
|
|
|
|
$serverPins = Get-ShopDbServerPins
|
|
if ($serverPins.Count -gt 0) {
|
|
if (-not (Enable-ShopDbServerPins $serverPins)) {
|
|
Log 'ERROR refusing to report without the server pin check. Skipping.'
|
|
exit 0
|
|
}
|
|
Log "server pin check on ($($serverPins.Count) pin(s))"
|
|
}
|
|
|
|
# -AllowedRanges and the CollectorRanges registry value are ACCEPTED AND
|
|
# IGNORED. Deciding which NIC is the PC's address moved to the server, where
|
|
# the site's addressing actually lives: this script now reports every NIC and
|
|
# takes no view. The parameter stays so a scheduled task still passing it does
|
|
# not fail on an unknown argument.
|
|
if ($AllowedRanges) {
|
|
Log 'NOTE -AllowedRanges is ignored; set collector_corporate_ranges in ShopDB settings instead.'
|
|
}
|
|
|
|
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.
|
|
# Those bays name their DEVICE instead, in asset-id.txt below.
|
|
$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)"
|
|
}
|
|
|
|
# The DEVICE hanging off this PC - the ONE enrollment file for every bay that
|
|
# has no NTLARS/eDNC MachineNo registry to identify it. It names the asset and
|
|
# nothing else: it does not say what kind of device that is, because the pc-type
|
|
# already does, so the same file serves a part marker, a Keyence, a Genspect or
|
|
# anything added later without a new file or a client change.
|
|
#
|
|
# Deliberately NOT machine-number.txt. machinenumber answers "which bay is
|
|
# this" and is what GE-Enforce TargetMachineNumbers gates on, so naming a device
|
|
# there would silently stop every bay-gated manifest entry from matching.
|
|
#
|
|
# WHY A FILE AT ALL: every other identity the server can fall back to is derived
|
|
# from the PC - a prior link from this PC, or an asset number built from this
|
|
# hostname - so none of them survive a PC SWAP. The replacement box mints a
|
|
# SECOND record for the same physical device while the first keeps its history
|
|
# under a dead PC's name. This file is what survives.
|
|
#
|
|
# The server ADOPTS the named asset, refuses a value it cannot resolve, and
|
|
# refuses one that resolves to the wrong kind of thing - so a typo warns instead
|
|
# of inventing a phantom device.
|
|
$deviceId = ''
|
|
$idFile = 'C:\Enrollment\asset-id.txt'
|
|
if (Test-Path -LiteralPath $idFile) {
|
|
try {
|
|
$deviceId = ([string](Get-Content -LiteralPath $idFile -First 1 -ErrorAction Stop)).Trim()
|
|
if ($deviceId) { Log "deviceId from ${idFile}: $deviceId" }
|
|
} catch { Log "WARN could not read ${idFile}: $($_.Exception.Message)" }
|
|
}
|
|
# 0.12.0 shipped measuringtool-id.txt before the file was generalised. Read it
|
|
# as a fallback so a bay already staged with one keeps reporting.
|
|
if (-not $deviceId) {
|
|
$mtFile = 'C:\Enrollment\measuringtool-id.txt'
|
|
if (Test-Path -LiteralPath $mtFile) {
|
|
try {
|
|
$deviceId = ([string](Get-Content -LiteralPath $mtFile -First 1 -ErrorAction Stop)).Trim()
|
|
if ($deviceId) { Log "deviceId from ${mtFile} (pre-0.13 name): $deviceId" }
|
|
} catch { Log "WARN could not read ${mtFile}: $($_.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)"
|
|
}
|
|
|
|
# EVERY connected hardware NIC with a routable IPv4, and no opinion about
|
|
# which one matters. Choosing used to happen here and this was the wrong place
|
|
# for it: a bay cannot know a site's addressing. A CMM whose Marvell AQtion
|
|
# 10Gbit link to its controller sits on 100.0.0.1 reported THAT as the PC's
|
|
# address - the link carries a default route too - and the corporate address
|
|
# was never sent at all, so ShopDB could not even show it as a second row.
|
|
#
|
|
# The server picks the primary now, from the site's configured corporate
|
|
# ranges, and records the rest. Report what is here; let it decide.
|
|
$interfaces = @()
|
|
$defaultRouteIfIndexes = @()
|
|
try {
|
|
$defaultRouteIfIndexes = @(Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction Stop |
|
|
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) {
|
|
$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 }
|
|
$interfaces += @{
|
|
ipaddress = $ipo.IPAddress
|
|
macaddress = $adapter.MacAddress
|
|
adaptername = $adapter.InterfaceDescription
|
|
isdefaultroute = [bool]($defaultRouteIfIndexes -contains $ipo.InterfaceIndex)
|
|
}
|
|
}
|
|
} catch {
|
|
Log "WARN interface enumeration failed: $($_.Exception.Message)"
|
|
}
|
|
if ($interfaces.Count -eq 0) {
|
|
Log 'WARN no routable IPv4 NIC found; posting identity without any interface.'
|
|
} else {
|
|
Log ("Reporting {0} NIC(s): {1}" -f $interfaces.Count,
|
|
(($interfaces | ForEach-Object { $_.ipaddress }) -join ', '))
|
|
}
|
|
|
|
# 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 ($deviceId) { $body['deviceid'] = $deviceId }
|
|
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 ($interfaces.Count -gt 0) { $body['interfaces'] = $interfaces }
|
|
$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} deviceId={11}" -f `
|
|
$ApiUrl, $hostname, $serialNumber, $pcType, $manufacturer, $model, $osVersion, $lastBootTime, $machineNo, $loggedInUser, $corpIp, $deviceId)
|
|
|
|
# Credential first; the shared key only when there is no credential, or when
|
|
# the server has just refused the credential (revoked: the shared key is
|
|
# admitted again for this PC until the agent re-enrolls). Never both at once.
|
|
$attempts = @()
|
|
if ($EndpointCredential) { $attempts += ,@('credential', @{ 'X-Endpoint-Credential' = $EndpointCredential }) }
|
|
if ($ApiKey) { $attempts += ,@('shared key', @{ 'X-API-Key' = $ApiKey }) }
|
|
|
|
foreach ($attempt in $attempts) {
|
|
try {
|
|
$resp = Invoke-RestMethod -Uri $ApiUrl -Method Post -Body $json `
|
|
-ContentType 'application/json' `
|
|
-Headers $attempt[1] `
|
|
-TimeoutSec $TimeoutSec -ErrorAction Stop
|
|
Log ("RESPONSE ({0}) {1}" -f $attempt[0], ($resp | ConvertTo-Json -Compress -Depth 4))
|
|
break
|
|
} catch {
|
|
Log "ERROR POST with $($attempt[0]) failed: $($_.Exception.Message)"
|
|
$failure = Get-ShopDbHttpFailure $_
|
|
Write-ShopDbAuthFailure $failure
|
|
if ($failure.Code -ne 'CREDENTIAL_INVALID') { break }
|
|
}
|
|
}
|
|
|
|
exit 0
|