# Set-ShopdbPrinters.ps1 # # Makes this PC's printers match what ShopDB says the bay should have. Asks # GET /api/printers/for-host/ and creates any queue that is missing. # # WHY THE ASSIGNMENT IS NOT ON THIS PC: it is on the MACHINE, and reaches # whichever PC controls it. A reimaged or swapped box inherits the bay's printers # with nothing saved off the old one - the asset register is the backup. # # CONVERGES, does not install. A queue that already exists is left alone, so this # is cheap to run every enforcement cycle and safe to run twice. # # NEVER REMOVES A QUEUE. If a printer disappears from the response - because the # API had a bad minute, or someone unassigned it - the bay keeps printing. Taking # printers away from a working bay because of a transient error is the one # failure this must not have. # # DRIVERS ARE NOT FETCHED HERE. Install-ShopdbPrinterDrivers.ps1 stages the site's # set in the common scope, once per bay. A queue is created against a driver that # is already present; if it is not, that is logged and the printer is skipped, # because downloading 48 MB while somebody waits to print is the wrong moment. # # THE DEFAULT PRINTER IS PER USER. This runs as SYSTEM and cannot set it for the # logged-on person, so it records the desired default in HKLM and leaves applying # it to a logon task. Without that, SYSTEM would set a default nobody sees. # # IT ALSO REGISTERS THAT LOGON TASK, and stages a LOCAL copy of # Apply-ShopdbDefaultPrinter.ps1 for it to run. Recording a default that nothing # ever applies was the gap: the queues appeared, the default never moved. The # local copy is not tidiness - the share this script runs from is mounted only # for the enforcement cycle, and the task fires at logon when it is gone. # # Exits 0 always: a printer problem must not fail an enforcement run. param( # ShopDB base URL. Empty resolves from HKLM:\SOFTWARE\GE\ShopDB BaseUrl, # written by Install-GEEnforce.ps1 and already present wherever this runs. [string]$BaseUrl = '', # Defaults to this machine's name, which is what the collector upserts by. [string]$Hostname = $env:COMPUTERNAME, [int]$TimeoutSec = 30, # Where the per-user logon script is staged. Anywhere is fine as long as it # is on this PC and every user can read it. [string]$LocalScriptDir = (Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) 'ShopDB'), [string]$LogonTaskName = 'ShopDB default printer', # The task runs as a GROUP, not a person: a shared bay has no one owner and # the default must be applied for whoever logs on. If this name does not # resolve - it is localised on non-English Windows - the well-known SID is # tried instead. [string]$UsersGroup = 'BUILTIN\Users', # 0 means at logon only. A shared bay where people pick their own default # can be pulled back on a repeat; a single-user PC should not be, so the # neutral default is the one that does not argue with the user. [int]$RepeatMinutes = 0, # For a site that deploys the logon task by GPO instead. [switch]$NoLogonTask, # Report what would change and touch nothing. [switch]$WhatIfOnly ) $ErrorActionPreference = 'Continue' [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 ('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 } # Resolved once, at script scope: $PSScriptRoot is empty when the file is piped # into powershell rather than run by path, and the logon script sits beside this # one. $SCRIPTDIR = $PSScriptRoot if (-not $SCRIPTDIR -and $MyInvocation.MyCommand.Path) { $SCRIPTDIR = Split-Path -Parent $MyInvocation.MyCommand.Path } function Ensure-LogonTask { # Half of this feature is per-user state that SYSTEM cannot write. All SYSTEM # can do is arrange for something to run AS the user later, which is this # task. Nothing else registered it, so the default was recorded every cycle # and applied never. if ($NoLogonTask) { Log 'logon task: skipped (-NoLogonTask)' return } $source = '' if ($SCRIPTDIR) { $source = Join-Path $SCRIPTDIR 'Apply-ShopdbDefaultPrinter.ps1' } if (-not $source -or -not (Test-Path $source)) { Log "SKIP logon task: Apply-ShopdbDefaultPrinter.ps1 is not beside this script" return } # THE LOCAL COPY IS LOAD-BEARING. This script runs from a share that is # mounted only for the enforcement cycle; the task fires at logon, when the # share is gone. A task pointing at the share never runs and says nothing. $localscript = Join-Path $LocalScriptDir 'Apply-ShopdbDefaultPrinter.ps1' $refreshed = $false try { if (-not (Test-Path $LocalScriptDir)) { # Inherited ACL is what is wanted here: every user can read it, only # admins can write it, so the task cannot be pointed somewhere else. New-Item -ItemType Directory -Path $LocalScriptDir -Force -ErrorAction Stop | Out-Null } $stale = $true if (Test-Path $localscript) { $stale = (Get-FileHash -Path $localscript -Algorithm SHA256).Hash -ne (Get-FileHash -Path $source -Algorithm SHA256).Hash } if ($stale) { if ($WhatIfOnly) { Log "WOULD stage the logon script at $localscript" } else { Copy-Item -Path $source -Destination $localscript -Force -ErrorAction Stop $refreshed = $true Log "staged the logon script at $localscript" } } } catch { # No local copy means no task worth registering - a task pointing at a # file that is not there is worse than no task, because it looks fine. Log "ERROR staging ${localscript}: $($_.Exception.Message)" return } $arguments = "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$localscript`"" $task = Get-ScheduledTask -TaskName $LogonTaskName -ErrorAction SilentlyContinue if ($task -and -not $refreshed) { # Re-registering every cycle throws away the task's run history, which # is the only evidence it ever fired. So it is replaced only when it # points somewhere other than the local copy, or has no group principal # - a task left behind running as one person applies one person's # default. Matched on the PATH rather than the whole argument string # because Task Scheduler is free to normalise quoting, and an exact # compare would churn over a difference that changes nothing. $registered = @($task.Actions)[0] $pointslocal = $registered -and $registered.Arguments -and $registered.Arguments.IndexOf($localscript, [StringComparison]::OrdinalIgnoreCase) -ge 0 if ($pointslocal -and $task.Principal.GroupId) { Log "logon task present: $LogonTaskName" return } } if ($WhatIfOnly) { Log "WOULD register the logon task: $LogonTaskName -> $localscript" return } try { $triggers = @(New-ScheduledTaskTrigger -AtLogOn) if ($RepeatMinutes -gt 0) { $triggers += New-ScheduledTaskTrigger -Once -At (Get-Date) ` -RepetitionInterval (New-TimeSpan -Minutes $RepeatMinutes) } $action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument $arguments $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries ` -DontStopIfGoingOnBatteries -StartWhenAvailable } catch { # An SKU without the ScheduledTasks module is the likely reason. Nothing # to register with, and still not a reason to fail the run. Log "ERROR building the logon task: $($_.Exception.Message)" return } # Limited, not Highest: setting your own default printer needs no elevation, # and a task the whole Users group can trigger should not have any. $candidates = @($UsersGroup) if ($UsersGroup -ne 'S-1-5-32-545') { $candidates += 'S-1-5-32-545' } $lasterror = 'no principal accepted' foreach ($groupid in $candidates) { try { $principal = New-ScheduledTaskPrincipal -GroupId $groupid -RunLevel Limited -ErrorAction Stop Register-ScheduledTask -TaskName $LogonTaskName -Action $action -Trigger $triggers ` -Principal $principal -Settings $settings -Force -ErrorAction Stop | Out-Null Log "registered the logon task: $LogonTaskName as $groupid" return } catch { $lasterror = $_.Exception.Message } } # A missing logon task means the default is not applied. It does not mean the # queues are wrong, so it is logged and the run carries on. Log "ERROR registering ${LogonTaskName}: $lasterror" } $REGPATH = 'HKLM:\SOFTWARE\GE\ShopDB' if (-not $BaseUrl) { foreach ($path in @($REGPATH, 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB')) { try { if (Test-Path $path) { $value = [string](Get-ItemProperty -Path $path -Name BaseUrl -ErrorAction Stop).BaseUrl if ($value -and $value.Trim()) { $BaseUrl = $value.Trim(); break } } } catch {} } } if (-not $BaseUrl) { Log 'ERROR no ShopDB URL (HKLM:\SOFTWARE\GE\ShopDB BaseUrl or -BaseUrl). Skipping.' exit 0 } Log "=== Set printers for $Hostname ===" # Before the API call on purpose: the task depends on files on this PC, not on # the server. A bad minute from the API must not leave a bay with no way to apply # the default it was already told about. Ensure-LogonTask $url = $BaseUrl.TrimEnd('/') + '/api/printers/for-host/' + [uri]::EscapeDataString($Hostname) try { $response = Invoke-RestMethod -Uri $url -Method Get -TimeoutSec $TimeoutSec } catch { # An unreachable server means "no information", not "no printers". Changing # nothing is the only safe response. Log "ERROR could not read $url : $($_.Exception.Message)" exit 0 } $payload = $response.data if ($null -eq $payload) { $payload = $response } $wanted = @($payload.printers) $defaultid = $payload.defaultprinterid if ($wanted.Count -eq 0) { Log 'nothing assigned to this host' exit 0 } Log "assigned: $($wanted.Count) printer(s)" function Ensure-Port([string]$address) { $portname = 'IP_' + $address if (-not (Get-PrinterPort -Name $portname -ErrorAction SilentlyContinue)) { Add-PrinterPort -Name $portname -PrinterHostAddress $address -ErrorAction Stop Log "port: $portname" } return $portname } function Repair-Queue($queue, [string]$address, [string]$drivername) { $name = $queue.Name # The address ShopDB holds is the truth about where the printer IS. A queue # left pointing at the old address prints into the void, and looks fine. if ($address) { $wantedport = 'IP_' + $address if ($queue.PortName -ne $wantedport) { if ($WhatIfOnly) { Log "WOULD repoint $name : $($queue.PortName) -> $wantedport" } else { try { $portname = Ensure-Port $address Set-Printer -Name $name -PortName $portname -ErrorAction Stop Log "repointed $name : $($queue.PortName) -> $portname" } catch { Log "ERROR repointing ${name}: $($_.Exception.Message)" } } } } # A queue built on a driver the site has moved off keeps using it forever. # Only corrected when the wanted driver is actually staged - swapping a queue # onto a driver that is not installed would break a working printer. if ($drivername -and $queue.DriverName -ne $drivername) { if (-not (Get-PrinterDriver -Name $drivername -ErrorAction SilentlyContinue)) { Log "SKIP driver fix for $name : '$drivername' is not staged" } elseif ($WhatIfOnly) { Log "WOULD re-driver $name : $($queue.DriverName) -> $drivername" } else { try { Set-Printer -Name $name -DriverName $drivername -ErrorAction Stop Log "re-drivered $name : $($queue.DriverName) -> $drivername" } catch { Log "ERROR re-drivering ${name}: $($_.Exception.Message)" } } } if ($queue.PortName -eq ('IP_' + $address) -and ($drivername -eq '' -or $queue.DriverName -eq $drivername)) { Log "present: $name" } } $existing = @{} foreach ($queue in (Get-Printer -ErrorAction SilentlyContinue)) { $existing[$queue.Name] = $queue } $defaultname = '' foreach ($printer in $wanted) { $name = $printer.queuename if (-not $name) { continue } if ($printer.printerid -eq $defaultid) { $defaultname = $name } $address = $printer.hostname if (-not $address) { $address = $printer.ipaddress } $drivername = $printer.drivername if ($existing.ContainsKey($name)) { # A queue with the right NAME can still be wrong: pointing at a printer # that has moved, or built on a driver that has since been replaced. # Absence used to be the only thing fixed, so a bay with a stale queue # looked converged and printed to the wrong device. # # Corrected IN PLACE with Set-Printer, never removed and recreated: the # queue keeps its name, its sharing, its permissions, and whoever has it # as their default keeps it. Repair-Queue $existing[$name] $address $drivername continue } if (-not $address) { Log "SKIP $name : no hostname or IP to point a port at" continue } if (-not $drivername) { Log "SKIP $name : ShopDB has no driver name for it" continue } if (-not (Get-PrinterDriver -Name $drivername -ErrorAction SilentlyContinue)) { # Deliberately not fetched here - see the header. Log "SKIP $name : driver '$drivername' is not staged on this PC" continue } if ($WhatIfOnly) { Log "WOULD create: $name -> $address ($drivername)" continue } try { $portname = Ensure-Port $address Add-Printer -Name $name -DriverName $drivername -PortName $portname -ErrorAction Stop Log "created: $name -> $address ($drivername)" } catch { Log "ERROR creating ${name}: $($_.Exception.Message)" } } # The default is recorded, not applied: this process is SYSTEM and the setting # is per user. The logon task registered above runs # Apply-ShopdbDefaultPrinter.ps1, which reads this value in the user's context. if ($defaultname) { if ($WhatIfOnly) { Log "WOULD record default: $defaultname" } else { try { if (-not (Test-Path $REGPATH)) { New-Item -Path $REGPATH -Force | Out-Null } Set-ItemProperty -Path $REGPATH -Name DefaultPrinter -Value $defaultname Log "default recorded for the logon task: $defaultname" } catch { Log "ERROR recording the default: $($_.Exception.Message)" } } } else { Log 'no default assigned' } exit 0