# 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). 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 Pins = new HashSet(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