Files
shopdb-flask/plugins/geenforce/client/ShopdbEnforceClient.psm1
cproudlock d894f054ac
All checks were successful
CI / backend (push) Successful in 1m42s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
Add GE-Enforce P4 client kit: fetch + report + shadow mode (reference)
Client-side integration kit for sourcing manifests from shopdb and reporting
results back. Site-neutral reference a site adapts into its GE-Enforce.ps1; the
live dispatcher and engine are NOT touched (they are read-only reference under
projects/pxe). Only the manifest JSON source moves from a share file to shopdb,
plus a result report.

- plugins/geenforce/client/ShopdbEnforceClient.psm1: Sync-ShopdbManifest (GET
  with ETag -> local cache; falls back to last-known-good when shopdb is
  unreachable so a PC is never left unmanaged), Compare-ShopdbShadow (behavioral
  diff vs the on-share manifest), Send-ShopdbReport / New-ShopdbReport (best-
  effort POST /report), Get-ShopdbConfig (BaseUrl + token from
  HKLM:\SOFTWARE\GE\ShopDB).
- plugins/geenforce/client/Invoke-ShopdbEnforce.ps1: orchestrator. Fetches,
  optionally shadow-compares (installs from the share, only logs the diff), runs
  the unchanged engine, and reports. Fail-safe: any error exits 0.
- docs/GE-ENFORCE-CLIENT.md: the fetch + report contracts, config, cache/fail-
  safe behavior, the staged shadow -> read-cutover -> payload-migration runbook,
  and TLS/payload-integrity notes.

The report JSON shape matches the POST /api/geenforce/report contract already
covered by the reporting tests. Nothing here runs the live client; shadow mode
and cutover stay a site decision after Milestone 1 sign-off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 18:30:08 -04:00

163 lines
7.2 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 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'
)
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)
if ($response.Headers.ETag) {
Set-Content -LiteralPath $etagPath -Value $response.Headers.ETag -NoNewline
}
$version = $null
if ($response.Headers['X-Manifest-Version']) { $version = $response.Headers['X-Manifest-Version'] }
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' }
}
return @{ Path = $null; Version = $null; Source = $null }
}
# 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)
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 of
@{ Name; Action; SelfHealed; ExitCode; Message }. Shape the engine's own
per-entry outcomes into this at the call site.
#>
param([string]$Hostname = $env:COMPUTERNAME,
[Parameter(Mandatory)][string]$Scope,
[int]$AppliedVersion,
[Parameter(Mandatory)][hashtable]$Summary)
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 = @($Summary.Results)
}
}
Export-ModuleMember -Function Get-ShopdbConfig, Sync-ShopdbManifest, `
Compare-ShopdbShadow, Send-ShopdbReport, New-ShopdbReport, Read-CachedVersion