# Report-PrintersToShopDB.ps1 # # Reports the print queues this PC ACTUALLY has to ShopDB, so the register can # be compared against what the bay is SUPPOSED to have. ShopDB knows the # assignment (GET /api/printers/for-host/, applied by # Set-ShopdbPrinters.ps1); it has never known what is really installed. This is # that missing half. # # TARGET: the ADR-006 collector API. # POST /api/collector/printers # 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. # # READ ONLY. It calls nothing that creates, changes or removes a queue, a port # or a driver - only Get-*. Convergence is Set-ShopdbPrinters.ps1's job and # stays there; a reporter that also fixes things cannot be trusted to tell you # what was broken. # # OBSERVED IS NOT ASSIGNED. The server stores this in its own table and never # turns it into an assignment on its own. A drifted bay reporting its drift must # not be able to redefine what correct means. # # THE LATEST REPORT REPLACES THE PREVIOUS ONE for this hostname, which makes an # empty queues list a legitimate "this bay has no printers" and wipes the host's # observed rows. So a FAILED enumeration must send NOTHING rather than an empty # list - see the $enumerated flag below. Reporting nothing loses one cycle; # reporting [] after a WMI hiccup deletes real state and reads as a bay that # lost its printers. # # 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 (the same secret store Report-AssetToShopDB.ps1 uses; provisioned # at imaging), or overridden via the manifest entry's Args -ApiKey. Never bake # the key into the manifest JSON on the share. # # Runs every GE-Enforce cycle as a Type=PS1 / DetectionMethod=Always entry under # the SYSTEM task. Always exits 0 so a printer problem never fails an # enforcement run; failures are logged, never thrown. param( # Flask collector endpoint for the printers plugin. Empty resolves from # HKLM:\SOFTWARE\GE\ShopDB BaseUrl; override here if the path ever moves. [string]$ApiUrl = '', # 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 = '', # Identity field of the payload. Defaults to this machine's name, which is # what the assignment side (for-host) and the computers collector both key on. [string]$Hostname = $env:COMPUTERNAME, [int]$TimeoutSec = 30, # Enumerate and log the payload, post nothing. For proving what a bay would # report before a site is pointed at a live server. [switch]$WhatIfOnly ) $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-printers-{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 } $REGPATHS = @('HKLM:\SOFTWARE\GE\ShopDB', 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB') function Get-ShopdbRegValue([string]$name) { foreach ($path in $REGPATHS) { try { if (Test-Path $path) { $value = [string](Get-ItemProperty -Path $path -Name $name -ErrorAction Stop).$name if ($value -and $value.Trim()) { return $value.Trim() } } } catch {} } return '' } Log "=== Report printers to ShopDB (collector) : $Hostname ===" # 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) { $base = Get-ShopdbRegValue 'BaseUrl' if ($base) { $ApiUrl = $base.TrimEnd('/') + '/api/collector/printers' } } if (-not $ApiUrl -and -not $WhatIfOnly) { 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) { $ApiKey = Get-ShopdbRegValue 'CollectorKey' } if (-not $ApiKey -and -not $WhatIfOnly) { Log 'ERROR no collector key (HKLM:\SOFTWARE\GE\ShopDB CollectorKey or -ApiKey). Skipping.' exit 0 } # Queues that are not devices: the Windows-supplied virtual printers plus the # Office writers. They exist on every image, match nothing in the register, and # would each land as an UNKNOWN row on every bay in the fleet. # # Matched on the PORT, not the queue name, because the name is whatever a user # renamed it to while the port of a virtual device is fixed. Two of them are # matched on driver as well, since a redirected-port queue can share PORTPROMPT. $VIRTUALPORTS = @('PORTPROMPT:', 'SHRFAX:', 'XPSPort:', 'nul:', 'NUL:') $VIRTUALDRIVERS = @( 'Microsoft XPS Document Writer', 'Microsoft XPS Document Writer v4', 'Microsoft Print To PDF', 'Microsoft Shared Fax Driver', 'Send to Microsoft OneNote Driver', 'Microsoft Software Printer Driver' ) function Test-VirtualQueue([string]$portname, [string]$drivername) { foreach ($p in $VIRTUALPORTS) { if ($portname -and $portname.Trim().ToLower() -eq $p.ToLower()) { return $true } } # OneNote's port is a per-install GUID path, so it can only be caught here. if ($portname -and $portname -like 'Microsoft.Office.OneNote*') { return $true } foreach ($d in $VIRTUALDRIVERS) { if ($drivername -and $drivername.Trim().ToLower() -eq $d.ToLower()) { return $true } } return $false } # Port address is the primary match key server-side: an IP or FQDN is # unambiguous where a queue name is a local habit. Built once as a lookup so a # bay with 8 queues does not re-enumerate ports 8 times. # # A port with no host address (USB, WSD, a redirected port) reports a null # address and matches on name alone, which is correct: a locally attached # printer is still a real printer worth seeing. $portAddresses = @{} $portsRead = $false try { foreach ($port in (Get-PrinterPort -ErrorAction Stop)) { $address = '' if ($port.PSObject.Properties['PrinterHostAddress']) { $address = [string]$port.PrinterHostAddress } if ($port.Name) { $portAddresses[[string]$port.Name] = $address.Trim() } } $portsRead = $true } catch { Log "WARN Get-PrinterPort failed, falling back to WMI ports: $($_.Exception.Message)" } if (-not $portsRead) { # PS 5.1-era hosts without the PrintManagement module, and images where the # spooler cmdlets are broken but WMI still answers. try { foreach ($port in (Get-CimInstance -ClassName Win32_TCPIPPrinterPort -ErrorAction Stop)) { if ($port.Name) { $portAddresses[[string]$port.Name] = ([string]$port.HostAddress).Trim() } } $portsRead = $true } catch { # Not fatal: queues still report, just without an address to match on. Log "WARN could not read printer ports at all: $($_.Exception.Message)" } } # The queues themselves. $enumerated stays false unless a read actually # succeeded, because "no queues" and "could not look" are the same empty list # and the server treats them very differently (see the header). $queues = @() $enumerated = $false try { foreach ($printer in (Get-Printer -ErrorAction Stop)) { $queues += [pscustomobject]@{ queuename = [string]$printer.Name drivername = [string]$printer.DriverName portname = [string]$printer.PortName } } $enumerated = $true } catch { Log "WARN Get-Printer failed, falling back to WMI queues: $($_.Exception.Message)" } if (-not $enumerated) { try { foreach ($printer in (Get-CimInstance -ClassName Win32_Printer -ErrorAction Stop)) { $queues += [pscustomobject]@{ queuename = [string]$printer.Name drivername = [string]$printer.DriverName portname = [string]$printer.PortName } } $enumerated = $true } catch { Log "ERROR could not enumerate printers: $($_.Exception.Message)" } } if (-not $enumerated) { # Deliberately posts nothing. An empty report REPLACES this host's observed # rows, so a failed read must not be able to claim the bay has no printers. Log 'ERROR enumeration failed; posting NOTHING so the last good report stands.' exit 0 } # Which queue the interactive user actually prints to. This process is SYSTEM, # and the default printer is per user, so Win32_Printer.Default here describes # the SYSTEM session and is usually wrong. Read the console user's own value # first: HKU\\...\Windows Device holds ",winspool,". $defaultName = '' try { $consoleUser = [string](Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop).UserName if ($consoleUser) { $sid = (New-Object System.Security.Principal.NTAccount($consoleUser)).Translate( [System.Security.Principal.SecurityIdentifier]).Value $devicePath = "Registry::HKEY_USERS\$sid\Software\Microsoft\Windows NT\CurrentVersion\Windows" $device = [string](Get-ItemProperty -Path $devicePath -Name Device -ErrorAction Stop).Device if ($device) { $defaultName = ($device -split ',')[0].Trim() } if ($defaultName) { Log "default for $consoleUser : $defaultName" } } } catch { # Nobody logged on, a roaming hive not loaded, or a name that will not # translate. Not worth a warning every cycle on an unattended bay. } if (-not $defaultName) { # Falls back to whatever this session sees. Marked in the log because a # SYSTEM-session default is weak evidence and a reviewer should know which # one they are looking at before seeding an assignment from it. try { $sysDefault = Get-CimInstance -ClassName Win32_Printer -ErrorAction Stop | Where-Object { $_.Default } | Select-Object -First 1 if ($sysDefault) { $defaultName = [string]$sysDefault.Name Log "default from the SYSTEM session (no console user): $defaultName" } } catch {} } $reported = @() $skipped = 0 foreach ($queue in $queues) { if (-not $queue.queuename) { continue } if (Test-VirtualQueue $queue.portname $queue.drivername) { $skipped++; continue } $portAddress = '' if ($queue.portname -and $portAddresses.ContainsKey($queue.portname)) { $portAddress = [string]$portAddresses[$queue.portname] } $row = @{ queuename = $queue.queuename isdefault = ($defaultName -and $queue.queuename -eq $defaultName) } # Sent only when present: a null is "not known", and an empty string would # read as a driver or a port genuinely named nothing. if ($queue.drivername) { $row['drivername'] = $queue.drivername } if ($queue.portname) { $row['portname'] = $queue.portname } if ($portAddress) { $row['portaddress'] = $portAddress } $reported += $row Log ("queue: {0} | driver={1} | port={2} | address={3} | default={4}" -f ` $queue.queuename, $queue.drivername, $queue.portname, $portAddress, $row['isdefault']) } Log "reporting $($reported.Count) queue(s), $skipped virtual queue(s) skipped" if ($reported.Count -eq 0) { # Legitimate and meaningful: it clears this host's observed rows so the # comparison shows every assigned printer as missing, which is exactly what # a bay with no queues is. Log 'no real queues on this host; reporting an empty set (clears observed state)' } # Collector schema fields (lowercase concatenated). hostname is the identity # field. observedat is sent for the record and named to match the declared # collector schema; the server stamps its own and ignores this one. $body = @{ hostname = $Hostname queues = @($reported) observedat = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss') } # Depth 4 covers hostname -> queues -> row -> value; the default of 2 flattens # the rows to type names. $json = $body | ConvertTo-Json -Compress -Depth 4 if ($WhatIfOnly) { Log "WOULD POST $ApiUrl $json" exit 0 } Log ("POST {0} host={1} queues={2}" -f $ApiUrl, $Hostname, $reported.Count) try { $response = Invoke-RestMethod -Uri $ApiUrl -Method Post -Body $json ` -ContentType 'application/json' ` -Headers @{ 'X-API-Key' = $ApiKey } ` -TimeoutSec $TimeoutSec -ErrorAction Stop Log ("RESPONSE {0}" -f ($response | ConvertTo-Json -Compress -Depth 4)) } catch { Log "ERROR POST failed: $($_.Exception.Message)" } exit 0