Files
shopdb-flask/plugins/geenforce/client/ShopdbEnforceClient.psm1
cproudlock 0bb906a37c notifications: let Recognition set start/end dates; geenforce B2 client payload fetch
Recognition edit hid the time fields (grouped with Recertification), so start/end
could not be adjusted even though the backend honors them. Show the time fields
for every type except Recertification (due-date driven); Recognition end still
auto-fills to the next 8 AM reset when blank.

Also GE-Enforce B2 client (HTTPS payload consume): ShopdbEnforceClient.psm1 gains
Get-ShopdbPayload (fetch by sha256, verify, cache) + Resolve-ShopdbPayloads
(rewrite http/inline entries to local staged files so the engine installs from
local, no SMB); Invoke-ShopdbEnforce resolves payloads before running the engine;
importer parses PayloadSource/PayloadSha256/PayloadRef. VM-verified: a SYSTEM
Windows client fetched a payload over HTTP by hash, hash matched.
2026-07-21 10:56:00 -04:00

247 lines
11 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)
# 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' }
}
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)
}
}
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'
)
$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
}
Export-ModuleMember -Function Get-ShopdbConfig, Sync-ShopdbManifest, `
Compare-ShopdbShadow, Send-ShopdbReport, New-ShopdbReport, Read-CachedVersion, `
Get-ShopdbPayload, Resolve-ShopdbPayloads