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.
This commit is contained in:
@@ -24,6 +24,16 @@
|
||||
.PARAMETER ShadowMode
|
||||
Fetch + compare + report, but install from the share (no behavior change).
|
||||
|
||||
.PARAMETER CommonScope
|
||||
The fleet-wide scope every PC inherits (default 'common'). Its manifest is
|
||||
fetched in addition to -Scope and merged in, so a display enforces its own
|
||||
scope entries PLUS common's. On a Name conflict the -Scope (pctype) entry
|
||||
wins. Set -NoCommon to disable, or point at a different common scope name.
|
||||
|
||||
.PARAMETER NoCommon
|
||||
Do not fetch or merge the common scope; enforce -Scope alone (the original
|
||||
single-scope behavior).
|
||||
|
||||
.NOTES
|
||||
Fail-safe: any error exits 0 so a bad web app never blocks or breaks a PC.
|
||||
Config comes from HKLM:\SOFTWARE\GE\ShopDB (BaseUrl, ApiToken) - see the psm1.
|
||||
@@ -34,6 +44,8 @@ param(
|
||||
[Parameter(Mandatory)] [string]$EnginePath,
|
||||
[string]$ShareManifestPath,
|
||||
[switch]$ShadowMode,
|
||||
[string]$CommonScope = 'common',
|
||||
[switch]$NoCommon,
|
||||
[string]$BaseUrl,
|
||||
[string]$ApiToken,
|
||||
[string]$LogFile = "C:\Logs\Shopfloor\shopdb-enforce-$(Get-Date -Format yyyyMMdd).log"
|
||||
@@ -46,6 +58,27 @@ function Write-Log {
|
||||
Write-Host $line
|
||||
}
|
||||
|
||||
function Write-ShopdbEventLog {
|
||||
<#
|
||||
Write a Windows Application event-log entry under source 'ShopdbEnforce'.
|
||||
Used to make an otherwise silent fail-safe (no manifest and an empty cache)
|
||||
observable to whoever watches the display. Best-effort: registering the
|
||||
source needs admin, which the SYSTEM scheduled task has; any failure is
|
||||
swallowed so it can never break the fail-safe.
|
||||
#>
|
||||
param([string]$Message,
|
||||
[string]$EntryType = 'Warning',
|
||||
[int]$EventId = 1001)
|
||||
$source = 'ShopdbEnforce'
|
||||
try {
|
||||
if (-not [System.Diagnostics.EventLog]::SourceExists($source)) {
|
||||
New-EventLog -LogName Application -Source $source -ErrorAction Stop
|
||||
}
|
||||
Write-EventLog -LogName Application -Source $source -EntryType $EntryType `
|
||||
-EventId $EventId -Message $Message -ErrorAction Stop
|
||||
} catch {}
|
||||
}
|
||||
|
||||
try {
|
||||
Import-Module (Join-Path $PSScriptRoot 'ShopdbEnforceClient.psm1') -Force
|
||||
|
||||
@@ -57,7 +90,21 @@ try {
|
||||
|
||||
$sync = Sync-ShopdbManifest -Scope $Scope -Config $config
|
||||
if (-not $sync.Path) {
|
||||
Write-Log "No manifest available for $Scope (shopdb unreachable, no cache)." 'WARN'
|
||||
# Fail-safe stays (exit 0), but a fresh display with an empty cache would
|
||||
# otherwise enforce nothing SILENTLY. Surface it: a Windows event-log
|
||||
# entry plus a best-effort report ping so it shows in Enforcement Reports.
|
||||
$reason = if ($sync.Error) { $sync.Error } else { 'shopdb unreachable and no cached manifest' }
|
||||
Write-Log "No manifest available for $Scope ($reason)." 'WARN'
|
||||
Write-ShopdbEventLog -Message ("GE-Enforce could not fetch a manifest for scope '$Scope' and has no cached copy; nothing was enforced this cycle. Reason: $reason") -EntryType 'Error' -EventId 1001
|
||||
try {
|
||||
$failReport = New-ShopdbReport -Scope $Scope -AppliedVersion 0 -Summary @{
|
||||
Installed = 0; Skipped = 0; Failed = 1; Filtered = 0; EnforcerVersion = '2.6'
|
||||
Results = @(@{ Name = '(manifest-fetch)'; Action = 'failed'; Message = $reason })
|
||||
}
|
||||
if (Send-ShopdbReport -Config $config -Report $failReport) {
|
||||
Write-Log 'Reported empty-cache fetch failure to shopdb.'
|
||||
}
|
||||
} catch {}
|
||||
exit 0
|
||||
}
|
||||
Write-Log "Manifest for $Scope from $($sync.Source) (v$($sync.Version))."
|
||||
@@ -75,29 +122,49 @@ try {
|
||||
|
||||
# Which manifest the engine actually runs against.
|
||||
if ($ShadowMode -and $ShareManifestPath) {
|
||||
# Shadow: install from the share exactly as today (no payload resolve).
|
||||
# Shadow: install from the share exactly as today (no payload resolve,
|
||||
# no common merge - the share already carries its own common scope).
|
||||
$manifestToRun = $ShareManifestPath
|
||||
} else {
|
||||
# Common-scope inheritance: a display enforces its own scope PLUS the
|
||||
# fleet-wide common scope. Fetch common too (best-effort, same fail-safe
|
||||
# cache) and merge it in with the pctype winning on conflict. Skipped
|
||||
# when -NoCommon, or when this run IS the common scope.
|
||||
$manifestToMerge = $sync.Path
|
||||
if (-not $NoCommon -and $CommonScope -and ($CommonScope -ine $Scope)) {
|
||||
$commonSync = Sync-ShopdbManifest -Scope $CommonScope -Config $config
|
||||
if ($commonSync.Path) {
|
||||
$manifestToMerge = Merge-ShopdbManifests -PrimaryManifestPath $sync.Path -CommonManifestPath $commonSync.Path
|
||||
if ($manifestToMerge -ne $sync.Path) {
|
||||
Write-Log "Merged common scope '$CommonScope' (from $($commonSync.Source), v$($commonSync.Version)) into $Scope."
|
||||
}
|
||||
} else {
|
||||
Write-Log "Common scope '$CommonScope' unavailable (no fetch, no cache) - enforcing $Scope alone." 'WARN'
|
||||
}
|
||||
}
|
||||
|
||||
# Cutover: stage any http/inline payloads to local files and rewrite the
|
||||
# manifest to point at them, so the UNCHANGED engine installs from local
|
||||
# (no SMB needed for share-less PCs).
|
||||
$manifestToRun = Resolve-ShopdbPayloads -ManifestPath $sync.Path -Config $config
|
||||
if ($manifestToRun -ne $sync.Path) {
|
||||
$manifestToRun = Resolve-ShopdbPayloads -ManifestPath $manifestToMerge -Config $config
|
||||
if ($manifestToRun -ne $manifestToMerge) {
|
||||
Write-Log "Resolved http/inline payloads to local files: $manifestToRun"
|
||||
}
|
||||
}
|
||||
|
||||
# --- INTEGRATION POINT ---------------------------------------------------
|
||||
# Run the engine. Install-FromManifest.ps1 is expected to return (or you
|
||||
# adapt it to return) a summary carrying Installed/Skipped/Failed/Filtered
|
||||
# and a Results list of @{Name;Action;SelfHealed;ExitCode;Message}. Wire this
|
||||
# to your engine's actual return/parse; the shape below is the contract.
|
||||
# Run the engine. EXPECTED ENGINE CONTRACT: Install-FromManifest.ps1 returns
|
||||
# a summary object (hashtable or PSCustomObject) carrying integer counts
|
||||
# Installed / Skipped / Failed / Filtered
|
||||
# a string EnforcerVersion, and a Results list of per-entry outcomes
|
||||
# @{ Name; Action; SelfHealed; ExitCode; Message }.
|
||||
# The engine may not honor that yet: it might return $null, a bare return
|
||||
# code, or emit several objects. ConvertTo-ShopdbSummary adapts whatever it
|
||||
# returns into a well-formed summary hashtable so the report stage always
|
||||
# gets clean input (we do NOT assume the engine was fixed).
|
||||
Write-Log "Running engine against $manifestToRun"
|
||||
$summary = & $EnginePath -ManifestPath $manifestToRun -PCType $Scope
|
||||
if (-not $summary) {
|
||||
$summary = @{ Installed = 0; Skipped = 0; Failed = 0; Filtered = 0; Results = @() }
|
||||
}
|
||||
if (-not $summary.EnforcerVersion) { $summary.EnforcerVersion = '2.6' }
|
||||
$engineResult = & $EnginePath -ManifestPath $manifestToRun -PCType $Scope
|
||||
$summary = ConvertTo-ShopdbSummary -EngineResult $engineResult
|
||||
|
||||
# Report the result (best-effort).
|
||||
$appliedVersion = 0
|
||||
|
||||
@@ -21,6 +21,43 @@
|
||||
|
||||
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'
|
||||
@@ -46,6 +83,7 @@ function Sync-ShopdbManifest {
|
||||
[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"
|
||||
@@ -80,7 +118,12 @@ function Sync-ShopdbManifest {
|
||||
if (Test-Path $manifestPath) {
|
||||
return @{ Path = $manifestPath; Version = (Read-CachedVersion $CacheDir $Scope); Source = 'cache-lastgood' }
|
||||
}
|
||||
return @{ Path = $null; Version = $null; Source = $null }
|
||||
# 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) {
|
||||
@@ -123,6 +166,7 @@ function Send-ShopdbReport {
|
||||
#>
|
||||
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" `
|
||||
@@ -137,14 +181,31 @@ function Send-ShopdbReport {
|
||||
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 of
|
||||
@{ Name; Action; SelfHealed; ExitCode; Message }. Shape the engine's own
|
||||
per-entry outcomes into this at the call site.
|
||||
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
|
||||
@@ -156,7 +217,7 @@ function New-ShopdbReport {
|
||||
failed = [int]$Summary.Failed
|
||||
filtered = [int]$Summary.Filtered
|
||||
}
|
||||
results = @($Summary.Results)
|
||||
results = $results
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,6 +234,7 @@ function Get-ShopdbPayload {
|
||||
[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 }
|
||||
@@ -241,6 +303,125 @@ function Resolve-ShopdbPayloads {
|
||||
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
|
||||
Get-ShopdbPayload, Resolve-ShopdbPayloads, Merge-ShopdbManifests, `
|
||||
ConvertTo-ShopdbSummary
|
||||
|
||||
Reference in New Issue
Block a user