Found in an end-to-end test on a Windows 11 VM against a self-signed server.
- Rollback: a 1.20 agent re-enrolls within a second of an un-enroll, so an
older agent pushed afterwards arrived on an enrolled PC and was refused.
Revoke now takes {"blockreenroll": true, "reason": ...}; a blocked PC is
never issued a credential and keeps working on the shared key until
POST .../assets/<assetid>/allow-reenroll. The Enrollment page offers the
block when un-enrolling and shows blocked PCs with an allow button.
- First upgrade of old agents: agents before 1.20 check the server
certificate when downloading the upgrade, so a store wave failed on PCs that
do not trust a self-signed server while the Migration tab said ready. A
failed download now marks the PC blocked with "server certificate not
trusted by this PC". New Install-ShopDBServerCertificate.ps1 (GE-Enforce,
SYSTEM) adds a non-CA server certificate to the trusted roots after checking
its thumbprint; the server certificate page offers a .cer download and the
SHA-1 thumbprint.
- The generated installer writes ServerPins (with an optional next pin for a
planned rotation), can leave the shared key out so the PC waits for
approval, never touches an existing credential, and skips the trusted-root
import for a CA certificate.
- The agent re-reads server pins about every 60 seconds and recycles pooled
connections within 2 minutes, so a pin change needs no restart.
- An alert can only be acknowledged by a PC it targets.
- A wave no longer counts a PC as failed after a later wave upgraded it.
- The scripts report a pin mismatch only when the pin really is missing, and
otherwise log the real error.
- Runbook: trust step before the first wave, rollback order, pin re-read,
verifying the pin against the live URL; agent contract updated.
594 lines
25 KiB
PowerShell
594 lines
25 KiB
PowerShell
# 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/<hostname>, 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 <shopdb>/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). 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 (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.
|
|
#
|
|
# 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.
|
|
#
|
|
# 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
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 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"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$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' }
|
|
$EndpointCredential = ''
|
|
if (-not $WhatIfOnly) {
|
|
$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))"
|
|
}
|
|
}
|
|
|
|
# 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\<sid>\...\Windows Device holds "<queue>,winspool,<port>".
|
|
$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)
|
|
# 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 {
|
|
$response = 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], ($response | 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
|