Found by running the kit under pwsh 7 against the live API: Invoke-WebRequest
returns header values as string ARRAYS in PS7 (scalars in Windows PowerShell
5.1), so X-Manifest-Version came back as @('1') and [int] on it threw - report
build failed. The target scheduled task runs 5.1 (works), but the kit must be
robust under PS7 too (target preinstalls PowerShell 7). Coerce ETag and
X-Manifest-Version with @(...)[0], a clean scalar in both.
Validated end to end on Linux pwsh 7.6.3 against the dev API: fetch (200) ->
cache-304 -> report sent -> landed received=true/status=ok. All 4 client scripts
parse clean; PSScriptAnalyzer shows only cosmetic warnings (Write-Host in a CLI,
intentional log-guard catch).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
165 lines
7.3 KiB
PowerShell
165 lines
7.3 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)
|
|
}
|
|
}
|
|
|
|
Export-ModuleMember -Function Get-ShopdbConfig, Sync-ShopdbManifest, `
|
|
Compare-ShopdbShadow, Send-ShopdbReport, New-ShopdbReport, Read-CachedVersion
|