Files
shopdb-flask/plugins/geenforce/client/ShopdbEnforceClient.psm1
cproudlock 9d65ef103d geenforce: display-readiness batch (server hardening, PS client wiring, display scope)
Get GE-Enforce closer to running on credential-less Intune/Entra display PCs
that pull manifest + payloads over HTTPS instead of SMB.

Server (plugins/geenforce/api/routes.py):
- Rate-limit + 512MB served-size ceiling on GET /payload/<sha256> (reuses the
  login limiter's cache pattern, config-overridable via GEENFORCE_PAYLOAD_*).
- New tests: payload hardening, manifestblobs model-vs-migration parity, and a
  report-contract test locking the lowercase per-entry report keys.

PS client (plugins/geenforce/client/):
- Fix New-ShopdbReport per-entry key casing to lowercase (name/action/selfhealed/
  exitcode/message) to match what the server reads; the engine emits PascalCase.
- Enforce TLS 1.2 in the network functions.
- Fetch + merge the fleet-wide common scope alongside the pctype scope
  (pctype wins on conflict; -NoCommon opt-out).
- Normalize whatever the engine returns into a well-formed summary.
- Make the empty-cache fail-safe observable: event-log entry + report ping
  instead of a silent exit 0.

Manifest (plugins/geenforce/seed_display_scope.py + docs/GE-ENFORCE-DISPLAY.md):
- Seed a gea-shopfloor-display scope: 4 Edge kiosk drift-heal registry entries
  + 1 data-driven dispatcher (Dashboard/Lobby/3DPrintRoom via display-type.txt).
  Kiosk EXEs stay image-baked; the manifest heals policy/config drift only.
- Documents the common SMB-payload audit (entries needing http/inline before a
  share-less display can inherit common).

Migration registry (shopdb/plugins/alembic_template.py + test):
- Register the pre-existing manifestblobs and the new printersupplyalerts tables
  in PLUGIN_TABLE_OWNERS; update EXPECTED_HEAD_REVISION for geenforce (0002blobs),
  printers (0002supplyalerts), and printedparts (0004txnrev) which had drifted.
2026-07-23 08:16:38 -04:00

428 lines
19 KiB
PowerShell

<#
.SYNOPSIS
Client-side helpers for sourcing GE-Enforce manifests from shopdb and reporting
results back. Site-neutral reference kit - deploy alongside GE-Enforce; do NOT
hardcode any site here.
This module does NOT replace Install-FromManifest.ps1 (the engine). It only
changes where the manifest JSON comes from (shopdb HTTP instead of a share
file) and adds a result report. The engine, detection, self-heal, and SMB
payload resolution stay exactly as they are.
Resilience mirrors GE-Enforce: any failure is non-fatal (fail-safe). If shopdb
is unreachable the client enforces from the last-known-good cached manifest and
a PC is never left unmanaged because the web app is down.
Config (params override registry): HKLM:\SOFTWARE\GE\ShopDB
BaseUrl e.g. https://shopdb.example.geaerospace.net
ApiToken a geenforce.fetch (+ geenforce.report) managed service token,
provisioned the same way as SFLD creds (Azure DSC).
#>
Set-StrictMode -Version Latest
function Set-ShopdbTls {
<#
Force TLS 1.2 for the process-wide ServicePointManager. Windows PowerShell
5.1 (what runs as SYSTEM on the display image) does not always negotiate
TLS 1.2 by default, so every network helper calls this first. Mirrors the
pattern in docs/COLLECTOR-INTEGRATION.md. Best-effort: never throws.
#>
try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch {}
}
function Get-ShopdbProperty {
<#
Read a property/key from either a hashtable or a PSCustomObject, matching
any of the given names case-insensitively. Returns $null when absent
instead of throwing under Set-StrictMode. The engine's per-entry outcomes
and summary may arrive as either shape, so all normalization goes through
this.
#>
param($InputObject, [Parameter(Mandatory)][string[]]$Names)
if ($null -eq $InputObject) { return $null }
if ($InputObject -is [System.Collections.IDictionary]) {
foreach ($wanted in $Names) {
foreach ($key in @($InputObject.Keys)) {
if ($key -is [string] -and $key -ieq $wanted) { return $InputObject[$key] }
}
}
return $null
}
$properties = $InputObject.PSObject.Properties
foreach ($wanted in $Names) {
foreach ($property in $properties) {
if ($property.Name -ieq $wanted) { return $property.Value }
}
}
return $null
}
function Get-ShopdbConfig {
param([string]$BaseUrl, [string]$ApiToken)
$regPath = 'HKLM:\SOFTWARE\GE\ShopDB'
if ((-not $BaseUrl -or -not $ApiToken) -and (Test-Path $regPath)) {
$props = Get-ItemProperty -Path $regPath -ErrorAction SilentlyContinue
if (-not $BaseUrl -and $props.BaseUrl) { $BaseUrl = $props.BaseUrl }
if (-not $ApiToken -and $props.ApiToken) { $ApiToken = $props.ApiToken }
}
if (-not $BaseUrl -or -not $ApiToken) { return $null }
return @{ BaseUrl = $BaseUrl.TrimEnd('/'); ApiToken = $ApiToken }
}
function Sync-ShopdbManifest {
<#
Fetch the current published manifest for a scope into a local cache, using
an ETag so an unchanged manifest is a cheap 304. On any network error, fall
back to the last-known-good cached copy. Returns:
@{ Path; Version; Source } where Source is
'shopdb' | 'cache-304' | 'cache-lastgood' | $null (nothing available)
#>
param(
[Parameter(Mandatory)] [string]$Scope,
[Parameter(Mandatory)] [hashtable]$Config,
[string]$CacheDir = 'C:\ProgramData\ShopDB\geenforce'
)
Set-ShopdbTls
if (-not (Test-Path $CacheDir)) { New-Item -ItemType Directory -Path $CacheDir -Force | Out-Null }
$manifestPath = Join-Path $CacheDir "$Scope.json"
$etagPath = Join-Path $CacheDir "$Scope.etag"
$headers = @{ 'X-API-Key' = $Config.ApiToken }
if (Test-Path $etagPath) { $headers['If-None-Match'] = (Get-Content -LiteralPath $etagPath -Raw).Trim() }
$uri = "$($Config.BaseUrl)/api/geenforce/manifest?pctype=$([uri]::EscapeDataString($Scope))"
try {
$response = Invoke-WebRequest -Uri $uri -Headers $headers -UseBasicParsing `
-TimeoutSec 30 -ErrorAction Stop
if ($response.StatusCode -eq 200) {
[System.IO.File]::WriteAllText($manifestPath, $response.Content)
# PowerShell 7 returns header values as string arrays; 5.1 as scalars.
# @(...)[0] yields a clean scalar in both.
$etag = @($response.Headers['ETag'])[0]
if ($etag) {
Set-Content -LiteralPath $etagPath -Value $etag -NoNewline
}
$version = @($response.Headers['X-Manifest-Version'])[0]
if ($version) {
Set-Content -LiteralPath (Join-Path $CacheDir "$Scope.version") -Value $version -NoNewline
}
return @{ Path = $manifestPath; Version = $version; Source = 'shopdb' }
}
} catch {
$status = $null
if ($_.Exception.Response) { $status = [int]$_.Exception.Response.StatusCode }
if ($status -eq 304 -and (Test-Path $manifestPath)) {
return @{ Path = $manifestPath; Version = (Read-CachedVersion $CacheDir $Scope); Source = 'cache-304' }
}
# Network/other failure: fall back to last-known-good.
if (Test-Path $manifestPath) {
return @{ Path = $manifestPath; Version = (Read-CachedVersion $CacheDir $Scope); Source = 'cache-lastgood' }
}
# Nothing cached (e.g. a fresh display): report why so the failure is not
# silent. Include the HTTP status (401 auth, TLS-trust surfaces as a
# non-status transport error) and the exception message.
$errText = $_.Exception.Message
if ($status) { $errText = "HTTP $status - $errText" }
return @{ Path = $null; Version = $null; Source = $null; Error = $errText }
}
# 304 without exception (some PS versions) -> use cache.
if (Test-Path $manifestPath) {
return @{ Path = $manifestPath; Version = (Read-CachedVersion $CacheDir $Scope); Source = 'cache-304' }
}
return @{ Path = $null; Version = $null; Source = $null }
}
function Read-CachedVersion {
param([string]$CacheDir, [string]$Scope)
$verPath = Join-Path $CacheDir "$Scope.version"
if (Test-Path $verPath) { return (Get-Content -LiteralPath $verPath -Raw).Trim() }
return $null
}
function Compare-ShopdbShadow {
<#
Shadow-mode comparison: do the shopdb manifest and the on-share manifest
select the same ordered entry names? Returns @{ Same; ShopdbOnly; ShareOnly;
OrderDiff }. Behavioral, not byte, comparison.
#>
param([Parameter(Mandatory)][string]$ShopdbManifestPath,
[Parameter(Mandatory)][string]$ShareManifestPath)
$shopdb = (Get-Content -LiteralPath $ShopdbManifestPath -Raw | ConvertFrom-Json)
$share = (Get-Content -LiteralPath $ShareManifestPath -Raw | ConvertFrom-Json)
$shopdbNames = @($shopdb.Applications | ForEach-Object { $_.Name })
$shareNames = @($share.Applications | ForEach-Object { $_.Name })
return @{
Same = (($shopdbNames -join '|') -eq ($shareNames -join '|'))
ShopdbOnly = @($shopdbNames | Where-Object { $_ -notin $shareNames })
ShareOnly = @($shareNames | Where-Object { $_ -notin $shopdbNames })
OrderDiff = (($shopdbNames -join '|') -ne ($shareNames -join '|'))
}
}
function Send-ShopdbReport {
<#
POST an enforcement report to shopdb. Best-effort: never throws, returns
$true on success. Report is a hashtable matching POST /api/geenforce/report.
#>
param([Parameter(Mandatory)][hashtable]$Config,
[Parameter(Mandatory)][hashtable]$Report)
Set-ShopdbTls
try {
$body = ($Report | ConvertTo-Json -Depth 6)
Invoke-RestMethod -Uri "$($Config.BaseUrl)/api/geenforce/report" `
-Method Post -Headers @{ 'X-API-Key' = $Config.ApiToken } `
-ContentType 'application/json' -Body $body -TimeoutSec 30 -ErrorAction Stop | Out-Null
return $true
} catch {
return $false
}
}
function New-ShopdbReport {
<#
Build a report payload from an engine summary. `Summary` is expected to
carry Installed/Skipped/Failed/Filtered counts and a Results list whose
per-entry outcomes carry name/action and optionally selfhealed/exitcode/
message. The engine emits these in PascalCase (Name/Action/SelfHealed/
ExitCode/Message); the shopdb report contract is entirely lowercase, so
this function maps every per-entry key down to lowercase. Case is matched
case-insensitively, so a caller that already lowercased still works.
#>
param([string]$Hostname = $env:COMPUTERNAME,
[Parameter(Mandatory)][string]$Scope,
[int]$AppliedVersion,
[Parameter(Mandatory)][hashtable]$Summary)
$results = @(foreach ($entry in @($Summary.Results)) {
if ($null -eq $entry) { continue }
$mapped = @{
name = [string](Get-ShopdbProperty -InputObject $entry -Names 'name')
action = [string](Get-ShopdbProperty -InputObject $entry -Names 'action')
}
$selfHealed = Get-ShopdbProperty -InputObject $entry -Names 'selfhealed'
if ($null -ne $selfHealed) { $mapped['selfhealed'] = [bool]$selfHealed }
$exitCode = Get-ShopdbProperty -InputObject $entry -Names 'exitcode'
if ($null -ne $exitCode) { $mapped['exitcode'] = [int]$exitCode }
$message = Get-ShopdbProperty -InputObject $entry -Names 'message'
if ($message) { $mapped['message'] = [string]$message }
$mapped
})
return @{
hostname = $Hostname
scopename = $Scope
appliedversion = $AppliedVersion
enforcerversion = $Summary.EnforcerVersion
counts = @{
installed = [int]$Summary.Installed
skipped = [int]$Summary.Skipped
failed = [int]$Summary.Failed
filtered = [int]$Summary.Filtered
}
results = $results
}
}
function Get-ShopdbPayload {
<#
Fetch a payload blob by content hash over HTTPS, verify the sha256, and
cache it locally (content-addressed, last-known-good). This is how a
share-less PC pulls an installer the manifest references. Returns the local
path, or $null on failure / hash mismatch.
#>
param(
[Parameter(Mandatory)][string]$Sha256,
[Parameter(Mandatory)][hashtable]$Config,
[string]$Filename,
[string]$CacheDir = 'C:\ProgramData\ShopDB\geenforce'
)
Set-ShopdbTls
$sha = $Sha256.Trim().ToLower()
$payloadDir = Join-Path $CacheDir 'payloads'
if (-not (Test-Path $payloadDir)) { New-Item -ItemType Directory -Path $payloadDir -Force | Out-Null }
$ext = if ($Filename) { [System.IO.Path]::GetExtension($Filename) } else { '' }
$dest = Join-Path $payloadDir "$sha$ext"
# Cache hit only counts if the cached bytes still hash correctly.
if (Test-Path $dest) {
if ((Get-FileHash -LiteralPath $dest -Algorithm SHA256).Hash.ToLower() -eq $sha) { return $dest }
Remove-Item -LiteralPath $dest -Force -ErrorAction SilentlyContinue
}
$uri = "$($Config.BaseUrl)/api/geenforce/payload/$sha"
$tmp = "$dest.tmp"
try {
Invoke-WebRequest -Uri $uri -Headers @{ 'X-API-Key' = $Config.ApiToken } `
-UseBasicParsing -TimeoutSec 120 -OutFile $tmp -ErrorAction Stop
} catch {
if (Test-Path $tmp) { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue }
return $null
}
$got = (Get-FileHash -LiteralPath $tmp -Algorithm SHA256).Hash.ToLower()
if ($got -ne $sha) {
Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
return $null
}
Move-Item -LiteralPath $tmp -Destination $dest -Force
return $dest
}
function Resolve-ShopdbPayloads {
<#
Rewrite a manifest so http/inline payload entries install from a locally
fetched file instead of a share path - keeping the engine (and its SMB
handling) untouched. For each entry with PayloadSha256 (PayloadSource
http/inline), fetches + verifies the payload and points the entry's
installer path at the local copy (Installer for MSI/EXE/CMD/BAT/INF, Script
for PS1, Source for File). Returns a rewritten sibling manifest path, or the
original path when there is nothing to resolve. Throws if a referenced
payload cannot be fetched/verified (caller decides fail-safe behavior).
#>
param(
[Parameter(Mandatory)][string]$ManifestPath,
[Parameter(Mandatory)][hashtable]$Config,
[string]$CacheDir = 'C:\ProgramData\ShopDB\geenforce'
)
$json = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json
$pathField = @{ MSI='Installer'; EXE='Installer'; CMD='Installer'; BAT='Installer';
INF='Installer'; PS1='Script'; File='Source' }
$changed = $false
foreach ($entry in @($json.Applications)) {
$src = [string]$entry.PayloadSource
$sha = [string]$entry.PayloadSha256
if (-not $sha -or ($src -ne 'http' -and $src -ne 'inline')) { continue }
$field = $pathField[[string]$entry.Type]
if (-not $field) { continue }
$local = Get-ShopdbPayload -Sha256 $sha -Config $Config -Filename $entry.PayloadRef -CacheDir $CacheDir
if (-not $local) { throw "payload $sha for '$($entry.Name)' could not be fetched/verified" }
if ($entry.PSObject.Properties.Name -contains $field) { $entry.$field = $local }
else { $entry | Add-Member -NotePropertyName $field -NotePropertyValue $local }
$changed = $true
}
if (-not $changed) { return $ManifestPath }
$out = [System.IO.Path]::ChangeExtension($ManifestPath, '.resolved.json')
($json | ConvertTo-Json -Depth 20) | Set-Content -LiteralPath $out -Encoding UTF8
return $out
}
function Merge-ShopdbManifests {
<#
Merge the fleet-wide 'common' scope manifest into a pctype (display) scope
manifest and write the merged result to a sibling file. This mirrors how
the real GE-Enforce.ps1 applies scopes in order (common first, then the
pctype), except we produce a single merged manifest for the unchanged
engine to run once. Merge rules:
- Entries are keyed by Name (case-insensitive).
- common's unique entries come first, then all pctype entries, so
common's own apps enforce ahead of the pctype's, matching the real
script's common-then-pctype ordering.
- On a Name conflict the PCTYPE entry wins (the pctype override replaces
common's version, and keeps common's slot out of the list).
Returns the merged manifest path. If there is no common manifest, returns
the primary path unchanged. The merged manifest keeps the pctype
manifest's top-level Version (that is the version the display reports as
applied).
#>
param(
[Parameter(Mandatory)][string]$PrimaryManifestPath,
[string]$CommonManifestPath
)
if (-not $CommonManifestPath -or -not (Test-Path $CommonManifestPath)) { return $PrimaryManifestPath }
$primary = Get-Content -LiteralPath $PrimaryManifestPath -Raw | ConvertFrom-Json
$common = Get-Content -LiteralPath $CommonManifestPath -Raw | ConvertFrom-Json
$primaryApps = @($primary.Applications)
$commonApps = @($common.Applications)
$primaryNames = @{}
foreach ($app in $primaryApps) {
$name = [string]$app.Name
if ($name) { $primaryNames[$name.ToLower()] = $true }
}
# common entries the pctype does not override, then all pctype entries.
$merged = @()
foreach ($app in $commonApps) {
$name = [string]$app.Name
if ($name -and $primaryNames.ContainsKey($name.ToLower())) { continue }
$merged += $app
}
foreach ($app in $primaryApps) { $merged += $app }
$primary.Applications = $merged
$out = [System.IO.Path]::ChangeExtension($PrimaryManifestPath, '.merged.json')
($primary | ConvertTo-Json -Depth 20) | Set-Content -LiteralPath $out -Encoding UTF8
return $out
}
function ConvertTo-ShopdbSummary {
<#
Normalize whatever the engine returns into a well-formed summary hashtable
so New-ShopdbReport always has clean input, without assuming the engine was
fixed.
Expected engine contract (what a compliant engine returns):
@{ Installed=<int>; Skipped=<int>; Failed=<int>; Filtered=<int>;
EnforcerVersion=<string>;
Results=@( @{ Name; Action; SelfHealed; ExitCode; Message } ... ) }
This adapter tolerates any of:
- $null / empty -> a zeroed summary.
- a hashtable or PSCustomObject with those keys (any casing).
- an array / multiple emitted objects -> the last element that looks
like a summary (has any count or a Results list) is used.
- a bare return code (int) or unrecognized object -> a zeroed summary.
Counts are coerced to int; a missing EnforcerVersion is left for the caller
to default. Always returns a hashtable.
#>
param($EngineResult, [string]$DefaultEnforcerVersion = '2.6')
$zero = @{ Installed = 0; Skipped = 0; Failed = 0; Filtered = 0;
Results = @(); EnforcerVersion = $DefaultEnforcerVersion }
$candidate = $EngineResult
if ($candidate -is [System.Array]) {
$picked = $null
foreach ($item in $candidate) {
if ($null -eq $item) { continue }
$looksLikeSummary = $false
foreach ($key in @('Installed', 'Skipped', 'Failed', 'Filtered', 'Results')) {
if ($null -ne (Get-ShopdbProperty -InputObject $item -Names $key)) { $looksLikeSummary = $true; break }
}
if ($looksLikeSummary) { $picked = $item }
}
$candidate = $picked
}
if ($null -eq $candidate) { return $zero }
# A bare return code (int) or any object without the expected members reads
# as all-null through Get-ShopdbProperty below, which yields the zeroed
# summary - exactly the fail-open behavior we want for a non-compliant engine.
$results = Get-ShopdbProperty -InputObject $candidate -Names 'Results'
$enforcerVersion = [string](Get-ShopdbProperty -InputObject $candidate -Names 'EnforcerVersion')
if (-not $enforcerVersion) { $enforcerVersion = $DefaultEnforcerVersion }
$toInt = {
param($value)
$parsed = 0
if ($null -ne $value -and [int]::TryParse([string]$value, [ref]$parsed)) { return $parsed }
return 0
}
return @{
Installed = (& $toInt (Get-ShopdbProperty -InputObject $candidate -Names 'Installed'))
Skipped = (& $toInt (Get-ShopdbProperty -InputObject $candidate -Names 'Skipped'))
Failed = (& $toInt (Get-ShopdbProperty -InputObject $candidate -Names 'Failed'))
Filtered = (& $toInt (Get-ShopdbProperty -InputObject $candidate -Names 'Filtered', 'PCFiltered'))
Results = @($results)
EnforcerVersion = $enforcerVersion
}
}
Export-ModuleMember -Function Get-ShopdbConfig, Sync-ShopdbManifest, `
Compare-ShopdbShadow, Send-ShopdbReport, New-ShopdbReport, Read-CachedVersion, `
Get-ShopdbPayload, Resolve-ShopdbPayloads, Merge-ShopdbManifests, `
ConvertTo-ShopdbSummary