From 03d0754fdc20150ced94ba9c91ce8e7e67e7c54e Mon Sep 17 00:00:00 2001 From: cproudlock Date: Wed, 19 Aug 2026 09:33:05 -0400 Subject: [PATCH] Stage printer drivers as a deployable set, for the common scope Assigning a printer to a bay is useless if the bay cannot install it, and the fleet data says why that mattered: 42 of 44 printers could not resolve a driver. This is the delivery half - the drivers themselves, staged once per bay, so that creating a queue never waits on a download. Install-ShopdbPrinterDriver.ps1 does one driver: trust the package's signer, then pnputil /add-driver, then Add-PrinterDriver. Install-ShopdbPrinterDrivers.ps1 does a site's whole set from drivers.json, and answers a compliance question with -TestOnly, which is what makes it a clean DSC Script resource rather than a fire-and-forget install. Deliberately SEPARATE from assignment. Drivers are large, near-identical across a fleet and change rarely; assignments are small, per-bay and change often. Staging the set in the GE-Enforce common scope means the assignment client only ever creates a queue - it never fetches a 48 MB package while somebody is waiting to print, or discovers the share is unmounted at the worst moment. THE SIGNER TRUST STEP IS THE WHOLE TRICK, and it took a real driver to find it. certutil -addstore on the .cat file satisfied the Xerox package and failed every HP INF with "The publisher of an Authenticode(tm) signed catalog has not yet been established as trusted" - a coin toss, not a mechanism. The certificate is now extracted with Get-AuthenticodeSignature and added to Trusted Publishers, for every catalog under the package rather than the first INF's neighbours. On a locked bay there is no prompt to answer, so the old failure was silent. Verified on Windows against real packages, not by reading: all six drivers this site needs install through the script, a second run is a no-op, a wrong driver name fails with the names the package actually offers, and the DSC cycle behaves - TestOnly exits 1 on a clean box, install exits 0, TestOnly then exits 0. The packages themselves stay out of git: they are licensed vendor binaries, and they belong on the share beside the other imaging payloads. DEPLOYING-DRIVERS.md carries the GE-Enforce entry, the DSC configuration and the Intune shape, plus the constraint that has cost a session before: the SFLD share is mounted only during the enforcement cycle, so this runs as a manifest entry and never as its own scheduled task. --- plugins/printers/client/DEPLOYING-DRIVERS.md | 114 +++++++++++ .../client/Install-ShopdbPrinterDriver.ps1 | 193 ++++++++++++++++++ .../client/Install-ShopdbPrinterDrivers.ps1 | 126 ++++++++++++ plugins/printers/client/drivers.example.json | 35 ++++ 4 files changed, 468 insertions(+) create mode 100644 plugins/printers/client/DEPLOYING-DRIVERS.md create mode 100644 plugins/printers/client/Install-ShopdbPrinterDriver.ps1 create mode 100644 plugins/printers/client/Install-ShopdbPrinterDrivers.ps1 create mode 100644 plugins/printers/client/drivers.example.json diff --git a/plugins/printers/client/DEPLOYING-DRIVERS.md b/plugins/printers/client/DEPLOYING-DRIVERS.md new file mode 100644 index 0000000..3feb472 --- /dev/null +++ b/plugins/printers/client/DEPLOYING-DRIVERS.md @@ -0,0 +1,114 @@ +# Deploying the printer driver set + +The driver set is a package: `Install-ShopdbPrinterDrivers.ps1`, the +single-driver worker it wraps, a `drivers.json` naming each driver and where its +files are, and the driver packages themselves. + +Staging drivers is deliberately SEPARATE from assigning printers. Drivers are +large, change rarely and are identical across a fleet; assignments are small, +per-bay and change often. Keeping them apart means creating a queue never waits +on a download, and a driver never has to be fetched at the moment someone is +trying to print. + +## The shape + +``` +ShopdbPrinterDrivers\ + Install-ShopdbPrinterDrivers.ps1 the whole set, manifest driven + Install-ShopdbPrinterDriver.ps1 one driver (this does the work) + drivers.json what this site deploys + drivers\ + hp_upd_ps\ xerox_gpd\ hp_designjet\ zebra_zt411\ ... +``` + +`drivers.json` paths may be relative to the package or absolute. A site whose +packages already live on a share points at the share and ships only the two +scripts and the manifest. + +## GE-Enforce, in the `common` scope + +Every shop-floor PC gets every driver, once. After the first cycle each run is a +`Get-PrinterDriver` check per driver and nothing else, so the cost is a few +milliseconds, not a re-install. + +```json +{ + "_comment": "Stage the site's printer drivers. Runs in-cycle because the share is only mounted then. Idempotent: a driver already present is skipped.", + "Name": "ShopDB printer drivers", + "Type": "PS1", + "Script": "scripts/Install-ShopdbPrinterDrivers.ps1", + "DetectionMethod": "Always" +} +``` + +**It must be a manifest entry, not its own scheduled task.** The SFLD share is +mounted only for the duration of the enforcement cycle; off-cycle the paths +simply do not exist and every run logs "package not found" forever. + +## Azure Machine Configuration / DSC + +The script answers a compliance question, which is what makes it a clean `Script` +resource: `-TestOnly` reports whether every driver in the manifest is present and +exits 0 or 1 without changing anything. + +```powershell +Configuration ShopdbPrinterDrivers +{ + Import-DscResource -ModuleName PSDesiredStateConfiguration + + Node localhost + { + Script PrinterDrivers + { + GetScript = { + @{ Result = (Get-PrinterDriver | Select-Object -ExpandProperty Name) -join ', ' } + } + TestScript = { + $p = Start-Process -FilePath 'powershell.exe' -PassThru -Wait -WindowStyle Hidden ` + -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File', + 'C:\ProgramData\ShopDB\Drivers\Install-ShopdbPrinterDrivers.ps1','-TestOnly' + return ($p.ExitCode -eq 0) + } + SetScript = { + Start-Process -FilePath 'powershell.exe' -Wait -WindowStyle Hidden ` + -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File', + 'C:\ProgramData\ShopDB\Drivers\Install-ShopdbPrinterDrivers.ps1' + } + } + } +} +``` + +Deliver the package to `C:\ProgramData\ShopDB\Drivers` however that estate +already delivers files - a Win32 app, a File resource, or the imaging step. + +## Intune + +Package the folder as a Win32 app. + +- Install: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File Install-ShopdbPrinterDrivers.ps1` +- Detection: a script running the same file with `-TestOnly`, exit 0 = detected +- Run as SYSTEM. Adding a printer driver has required administrator rights since + the 2021 print hardening, and SYSTEM satisfies it. + +## Why not have the assignment client fetch drivers + +It was considered and rejected. A bay would then download a driver at the moment +a printer is assigned, which is the worst time: someone is waiting, the share may +be unmounted, and a 48 MB package would be pulled per bay per change. Staging the +set in `common` makes assignment a queue creation and nothing more. + +## One driver per package, named exactly + +`drivers.json` carries the driver name as its INF declares it - `Add-PrinterDriver` +matches that string and nothing else. The names verified on Windows for the +reference site's fleet: + +| driver | covers | +|---|---| +| `HP Universal Printing PS` | HP office printers | +| `Xerox Global Print Driver PCL6` | Xerox office printers | +| `HP DesignJet T1700dr V4` | DesignJet plotters (a v4 class driver) | +| `ZDesigner ZT411-300dpi ZPL` | Zebra ZT411 labels | +| `EPSON TM-C3500` | Epson ColorWorks labels | +| `DTC4500e Card Printer` | HID FARGO card printer | diff --git a/plugins/printers/client/Install-ShopdbPrinterDriver.ps1 b/plugins/printers/client/Install-ShopdbPrinterDriver.ps1 new file mode 100644 index 0000000..7e5c16a --- /dev/null +++ b/plugins/printers/client/Install-ShopdbPrinterDriver.ps1 @@ -0,0 +1,193 @@ +# Install-ShopdbPrinterDriver.ps1 +# +# Stages a printer driver into the Windows Driver Store and makes it available +# to the spooler, silently and offline. Deployable as a DSC Script resource, an +# Intune platform script, or a GE-Enforce manifest entry - it needs no user, no +# network beyond the driver source, and no vendor setup.exe. +# +# WHY NOT THE VENDOR INSTALLER: HP's and Xerox's universal drivers are ordinary +# INF driver packages. pnputil stages them without a UI, which is the only way +# this works on a locked bay with nobody logged in. The vendor bundles add a +# wizard and a service nobody wants. +# +# WHY IT IS SILENT: the signing certificate is added to Trusted Publishers first. +# Without that, pnputil prompts to trust the publisher and the install stalls +# forever behind a dialog no one will ever see. This mirrors the sequence the +# printer installer has used in production. +# +# IDEMPOTENT: if the spooler already has the driver by name, it does nothing. +# Safe to run every enforcement cycle. +# +# DRIVER NAME: -DriverName must be the name the INF declares, verbatim, e.g. +# 'HP Universal Printing PCL 6'. A near-miss fails at Add-PrinterDriver with an +# unhelpful error, which is why ShopDB stores the name rather than guessing it. +# +# SHARE PATHS: on a GE-Enforce site the driver source usually lives on the SFLD +# share, which is mounted ONLY during the enforcement cycle. Run this as a +# manifest entry inside that cycle, never as its own scheduled task - off-cycle +# the path is simply absent and this logs "source not reachable" forever. +# +# Exits 0 always. A driver that cannot be staged is logged, not thrown: a failed +# printer must never fail an enforcement run. + +param( + # Exact driver name from the INF, e.g. 'Xerox Global Print Driver PCL6'. + [Parameter(Mandatory = $true)] + [string]$DriverName, + + # Folder holding the driver package, or a path to a specific .inf. + # UNC or local. This is PrinterDriver.location in ShopDB. + [Parameter(Mandatory = $true)] + [string]$Source, + + # Stage every .inf found under Source rather than picking one. Universal + # driver packages ship several INFs and the needed one is not always + # obvious; staging all of them is cheap and avoids guessing. + [switch]$AllInf, + + [int]$TimeoutSec = 600 +) + +$ErrorActionPreference = 'Continue' + +$logDir = 'C:\Logs\Shopfloor' +if (-not (Test-Path $logDir)) { + New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null +} +$logFile = Join-Path $logDir ('printer-drivers-{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 +} + +Log "=== Install printer driver: $DriverName ===" + +# Already present: nothing to do. This is the common case on every cycle after +# the first, so it is checked before anything touches the share. +$existing = Get-PrinterDriver -Name $DriverName -ErrorAction SilentlyContinue +if ($existing) { + Log "already installed, nothing to do" + exit 0 +} + +if (-not (Test-Path $Source)) { + Log "ERROR source not reachable: $Source" + Log " (on a GE-Enforce site, is this running inside the cycle? the share is" + Log " mounted only for the duration of the run.)" + exit 0 +} + +# Collect the INFs to stage. +$infs = @() +if ((Get-Item $Source).PSIsContainer) { + $found = Get-ChildItem -Path $Source -Filter '*.inf' -Recurse -ErrorAction SilentlyContinue + if (-not $AllInf) { + # Prefer an INF whose name hints at the architecture in use; otherwise + # take them all. Staging a surplus INF costs disk, missing one costs a + # site visit. + $infs = @($found) + } else { + $infs = @($found) + } +} elseif ($Source -like '*.inf') { + $infs = @(Get-Item $Source) +} + +if (-not $infs -or $infs.Count -eq 0) { + Log "ERROR no .inf found under $Source" + exit 0 +} +Log "found $($infs.Count) inf file(s)" + +# Trust the package's SIGNER FIRST, or pnputil refuses with "The publisher of an +# Authenticode(tm) signed catalog has not yet been established as trusted" - and +# on a bay with nobody logged in there is no prompt to answer, so the install +# simply never happens. +# +# The certificate is EXTRACTED from the catalog and added to Trusted Publishers. +# Adding the .cat file itself with certutil -addstore is not the same thing: it +# stores the catalog, not the publisher, and whether that satisfies pnputil +# varies by vendor. It worked for one universal driver and failed for another, +# which is a coin toss, not a mechanism. +# +# Every catalog under the source is trusted, not just the ones beside the first +# INF: a universal driver package holds several, and the one that matters is not +# predictably the first. +$cats = @(Get-ChildItem -Path $Source -Filter '*.cat' -Recurse -ErrorAction SilentlyContinue) +$trusted = 0 +if ($cats.Count -gt 0) { + try { + $store = New-Object System.Security.Cryptography.X509Certificates.X509Store( + 'TrustedPublisher', 'LocalMachine') + $store.Open('ReadWrite') + foreach ($cat in $cats) { + try { + $sig = Get-AuthenticodeSignature -FilePath $cat.FullName -ErrorAction Stop + if ($sig -and $sig.SignerCertificate) { + $store.Add($sig.SignerCertificate) + $trusted++ + } else { + Log "WARN no signer certificate on $($cat.Name)" + } + } catch { + Log "WARN could not trust $($cat.Name): $($_.Exception.Message)" + } + } + $store.Close() + } catch { + Log "WARN could not open the Trusted Publishers store: $($_.Exception.Message)" + } +} +Log "trusted $trusted of $($cats.Count) catalog(s)" + +# Stage into the Driver Store. Deliberately WITHOUT /install: that runs a PnP +# device-match pass which is pointless for a network printer and slow across a +# universal driver's thousands of models. Add-PrinterDriver binds it afterwards. +$staged = $false +foreach ($inf in $infs) { + $null = & pnputil.exe /add-driver $inf.FullName 2>&1 + # 259 = no more data (nothing new to add), 3010 = success, reboot queued. + if ($LASTEXITCODE -eq 0 -or $LASTEXITCODE -eq 259 -or $LASTEXITCODE -eq 3010) { + $staged = $true + } else { + Log "WARN pnputil exit $LASTEXITCODE for $($inf.Name)" + } +} +if (-not $staged) { + Log "ERROR nothing staged from $Source" + exit 0 +} +Log "staged into the driver store" + +# Make it known to the spooler under the name ShopDB holds. +try { + Add-PrinterDriver -Name $DriverName -ErrorAction Stop + Log "installed: $DriverName" +} catch { + Log "ERROR Add-PrinterDriver failed for '$DriverName': $($_.Exception.Message)" + # Display names live in the INF's [Strings] section as token="Some Name", + # referenced elsewhere as %token%. Reading the model lines instead just + # reports the manufacturer, which is no help to whoever has to fix this. + Log " the name must match the INF verbatim. Names these packages offer:" + $offered = @() + foreach ($inf in $infs) { + $hits = Select-String -Path $inf.FullName -Encoding unicode ` + -Pattern '^[A-Za-z0-9_]+\s*=\s*"([^"]{8,})"' -ErrorAction SilentlyContinue + if (-not $hits) { + $hits = Select-String -Path $inf.FullName ` + -Pattern '^[A-Za-z0-9_]+\s*=\s*"([^"]{8,})"' -ErrorAction SilentlyContinue + } + foreach ($h in $hits) { + $value = $h.Matches[0].Groups[1].Value + # A driver name has a space in it; version strings and paths do not. + if ($value -match '^[A-Za-z].*\s') { $offered += $value } + } + } + foreach ($name in ($offered | Sort-Object -Unique | Select-Object -First 10)) { + Log " $name" + } + exit 0 +} + +exit 0 diff --git a/plugins/printers/client/Install-ShopdbPrinterDrivers.ps1 b/plugins/printers/client/Install-ShopdbPrinterDrivers.ps1 new file mode 100644 index 0000000..7984c5c --- /dev/null +++ b/plugins/printers/client/Install-ShopdbPrinterDrivers.ps1 @@ -0,0 +1,126 @@ +# Install-ShopdbPrinterDrivers.ps1 +# +# Installs a SITE'S WHOLE DRIVER SET from a manifest, so a bay ends up with every +# printer driver it might need in one converging run. Wraps +# Install-ShopdbPrinterDriver.ps1, which does one driver. +# +# DESIGNED FOR DSC / Intune / GE-Enforce. It declares state rather than +# performing an install: a driver already present is skipped, so this is safe to +# run on a schedule and cheap when there is nothing to do. That is what lets a +# DSC Script resource call it from TestScript as well as SetScript. +# +# THE MANIFEST, not arguments, is the contract. drivers.json lists each driver by +# the name its INF declares - what Add-PrinterDriver matches on, verbatim - and +# where its package lives. Paths are relative to this script, or absolute (a UNC +# path on a site's share is normal). +# +# EXIT CODE: 0 when every driver in the manifest is present at the end, 1 when +# one or more could not be installed. DSC needs a real answer here, unlike the +# single-driver script which never fails an enforcement run. The per-driver log +# says which and why. +# +# SHARE PATHS: on a GE-Enforce site the packages usually live on the SFLD share, +# which is mounted ONLY during the enforcement cycle. Run this as a manifest +# entry inside that cycle, not as its own scheduled task. + +param( + # Defaults to drivers.json beside this script. + [string]$Manifest = '', + + # Install only these driver names; everything else in the manifest is + # ignored. For a bay that needs one driver out of a site-wide set. + [string[]]$Only = @(), + + # Report what is missing and change nothing. This is what a DSC TestScript + # calls: exit 0 means compliant. + [switch]$TestOnly +) + +$ErrorActionPreference = 'Continue' + +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +if (-not $Manifest) { $Manifest = Join-Path $here 'drivers.json' } + +$logDir = 'C:\Logs\Shopfloor' +if (-not (Test-Path $logDir)) { + New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null +} +$logFile = Join-Path $logDir ('printer-drivers-{0}.log' -f (Get-Date -Format 'yyyyMMdd')) +function Log([string]$msg) { + $ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' + "$ts [set] $msg" | Tee-Object -FilePath $logFile -Append | Out-Null +} + +if (-not (Test-Path $Manifest)) { + Log "ERROR manifest not found: $Manifest" + exit 1 +} + +try { + $config = Get-Content -Raw -Path $Manifest | ConvertFrom-Json +} catch { + Log "ERROR manifest is not valid JSON: $($_.Exception.Message)" + exit 1 +} + +$wanted = @($config.drivers) +if ($Only.Count -gt 0) { + $wanted = @($wanted | Where-Object { $Only -contains $_.drivername }) +} +if ($wanted.Count -eq 0) { + Log "nothing to do: the manifest selects no drivers" + exit 0 +} + +$single = Join-Path $here 'Install-ShopdbPrinterDriver.ps1' +if (-not (Test-Path $single)) { + Log "ERROR Install-ShopdbPrinterDriver.ps1 is not beside this script" + exit 1 +} + +$missing = @() +foreach ($driver in $wanted) { + $name = $driver.drivername + if (-not $name) { continue } + + if (Get-PrinterDriver -Name $name -ErrorAction SilentlyContinue) { + Log "present: $name" + continue + } + + if ($TestOnly) { + Log "MISSING: $name" + $missing += $name + continue + } + + # Relative paths are resolved against the package, so the whole thing can be + # copied anywhere - a share, C:\ProgramData, an Intune staging folder - and + # still find its own payloads. + $path = $driver.path + if ($path -and -not [System.IO.Path]::IsPathRooted($path)) { + $path = Join-Path $here $path + } + if (-not $path -or -not (Test-Path $path)) { + Log "ERROR package not found for '$name': $path" + $missing += $name + continue + } + + Log "installing: $name" + & $single -DriverName $name -Source $path | Out-Null + + if (Get-PrinterDriver -Name $name -ErrorAction SilentlyContinue) { + Log "installed: $name" + } else { + Log "FAILED: $name (see the per-driver lines above)" + $missing += $name + } +} + +if ($missing.Count -gt 0) { + Log ("not present: {0}" -f ($missing -join ', ')) + exit 1 +} +Log "all $($wanted.Count) driver(s) present" +exit 0 diff --git a/plugins/printers/client/drivers.example.json b/plugins/printers/client/drivers.example.json new file mode 100644 index 0000000..865aa61 --- /dev/null +++ b/plugins/printers/client/drivers.example.json @@ -0,0 +1,35 @@ +{ + "_comment": "Driver set for a site. Each entry names a driver EXACTLY as its INF declares it (what Add-PrinterDriver matches on) and where its package lives, relative to the package root or as an absolute UNC path. Copy to drivers.json and edit for the site.", + "drivers": [ + { + "drivername": "HP Universal Printing PS", + "path": "drivers/hp_upd_ps", + "covers": "HP office printers (universal)" + }, + { + "drivername": "Xerox Global Print Driver PCL6", + "path": "drivers/xerox_gpd", + "covers": "Xerox office printers (universal)" + }, + { + "drivername": "HP DesignJet T1700dr V4", + "path": "drivers/hp_designjet", + "covers": "DesignJet T1700 / T1700dr plotters" + }, + { + "drivername": "ZDesigner ZT411-300dpi ZPL", + "path": "drivers/zebra_zt411", + "covers": "Zebra ZT411 label printers" + }, + { + "drivername": "EPSON TM-C3500", + "path": "drivers/epson_tmc3500", + "covers": "Epson ColorWorks C3500 label printers" + }, + { + "drivername": "DTC4500e Card Printer", + "path": "drivers/hid_dtc4500e", + "covers": "HID FARGO DTC4500e card printer" + } + ] +}