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:
@@ -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