<# .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