diff --git a/docs/GE-ENFORCE-CLIENT.md b/docs/GE-ENFORCE-CLIENT.md new file mode 100644 index 0000000..ada974c --- /dev/null +++ b/docs/GE-ENFORCE-CLIENT.md @@ -0,0 +1,126 @@ +# GE-Enforce client integration (shopdb manifest source + reporting) + +This is the client-side contract for the GE-Enforce manifest-store plugin: how a +PC sources its install manifest from shopdb instead of a share file, and how it +reports its enforcement result back. It pairs with the plugin proposal in +`docs/proposals/ge-enforce-plugin.md`. + +The reference kit lives in `plugins/geenforce/client/`: + +- `ShopdbEnforceClient.psm1` - fetch (with ETag + last-known-good cache), + shadow compare, and report helpers. +- `Invoke-ShopdbEnforce.ps1` - a reference orchestrator that fetches a manifest, + runs the UNCHANGED engine against it, and reports the result. + +These are site-neutral references, not the live dispatcher. A site adapts them +into its GE-Enforce.ps1 flow. The engine (`Install-FromManifest.ps1`), +detection, self-heal, and SMB payload resolution are untouched - only the source +of the manifest JSON moves, plus a result report. + +## What does NOT change + +- The engine and its four filters, all detection methods, self-heal, marker + files, and SMB payload staging. +- Payload transport for `smb` rows: the client still mounts the share and + resolves `apps/...` paths exactly as today. Only the manifest JSON source moves. +- The fail-safe posture: any error exits 0. A PC is never blocked or broken + because shopdb is unreachable. + +## Configuration + +Registry (provisioned by Azure DSC, same channel as the SFLD credentials): + +``` +HKLM:\SOFTWARE\GE\ShopDB + BaseUrl https://shopdb..geaerospace.net + ApiToken +``` + +Mint the token in shopdb: Settings > API Tokens, scopes `geenforce.fetch` and +`geenforce.report`. It is a service token (owner must hold those permissions). + +## Fetch contract + +``` +GET /api/geenforce/manifest?pctype=[&phase=runtime] + X-API-Key: + If-None-Match: (optional) +``` + +- `200` - body is the full published manifest JSON for the scope (fat client: + the engine filters locally, exactly as today). Response headers carry `ETag` + and `X-Manifest-Version`. Cache the body + ETag + version. +- `304` - your cached copy is current; use it. +- `404` - no such scope, or the scope has no published version yet. +- Network failure - enforce from the last-known-good cached manifest (the kit + does this automatically) and log a warning. + +The served manifest is always the current PUBLISHED snapshot, never a live draft +being edited in shopdb, so a half-finished edit can never reach a PC. + +## Report contract + +Each enforcement cycle, POST the result (best-effort; a failed report never +fails the cycle): + +``` +POST /api/geenforce/report + X-API-Key: + Content-Type: application/json +{ + "hostname": "WJCMM01", + "scopename": "gea-shopfloor-cmm", + "appliedversion": 3, // the published version you actually ran + "enforcerversion": "2.6", + "counts": { "installed": 1, "skipped": 3, "failed": 0, "filtered": 2 }, + "results": [ + { "name": "PC-DMIS 2019 R2", "action": "installed", "selfhealed": true }, + { "name": "Protect Viewer", "action": "skipped" }, + { "name": "eDNC", "action": "failed", "exitcode": 1603, + "message": "MSI 1603" } + ] +} +``` + +- `appliedversion` lets shopdb show which PCs received the latest manifest + (`receivedlatest` in the fleet view). +- `action` per entry: `installed` (fired - a self-heal when it should already be + present), `skipped` (detected present), `failed`, `filtered`. `selfhealed` + marks a drift correction. +- shopdb keeps the latest report per (hostname, scope, phase) plus history, and + surfaces it under Settings > Enforcement Reports. + +The engine already computes these counts (`installed/skipped/failed/pcFiltered` +at the end of its main loop) and knows each entry's action; shape them into the +`results` list at the call site (`New-ShopdbReport` in the kit takes a summary +with `Installed/Skipped/Failed/Filtered` + a `Results` list). + +## Cutover (safe, staged) + +1. **Configure** the registry values on a canary PC; mint the token. +2. **Shadow mode**: run `Invoke-ShopdbEnforce.ps1 -ShadowMode -ShareManifestPath + `. It installs from the SHARE (no behavior change), + fetches the shopdb manifest, logs any diff, and reports. Watch for zero diffs + across one PC of every pctype for ~20 cycles. +3. **Read cutover**: drop `-ShadowMode`. The engine now runs against the + shopdb-sourced manifest; payloads still come from the share. Rollback is a + one-line revert to the share-sourced call. Keep exporting manifests from + shopdb to the share (Settings > Imaging PC Types > Export to Share) so the + share stays a break-glass copy. +4. **Payload migration** (optional, later): move small scripts/configs to + `http`/`inline` payloads, verified by `payloadsha256`. Big MSIs stay on SMB. + +Do not cut a fleet over before the shadow diffs are clean. Preinstall +(`phase=preinstall`) stays share-sourced until its own cutover is planned - it +runs before enrollment provisions a token. + +## Security notes + +- The client runs as SYSTEM, so shopdb's TLS certificate must be in the machine + trust store (air-gapped/self-signed sites provision the CA via the same DSC + step as the token). +- `http`/`inline` payloads are verified against `payloadsha256` before running, + independent of how the entry detects install state. This is the real integrity + guarantee and holds even over plain HTTP inside a trusted segment. +- The token is a scoped service token: it can fetch manifests and report, and + nothing else. diff --git a/plugins/geenforce/client/Invoke-ShopdbEnforce.ps1 b/plugins/geenforce/client/Invoke-ShopdbEnforce.ps1 new file mode 100644 index 0000000..4128a82 --- /dev/null +++ b/plugins/geenforce/client/Invoke-ShopdbEnforce.ps1 @@ -0,0 +1,103 @@ +<# +.SYNOPSIS + Reference orchestrator: source a GE-Enforce manifest from shopdb, run the + UNCHANGED engine against it, and report the result back to shopdb. + + This is a thin wrapper around Install-FromManifest.ps1 (the engine). It is a + reference a site adapts into its GE-Enforce.ps1 flow; it is not the live + dispatcher. The engine, detection, self-heal, and SMB payload resolution are + untouched - only the source of the manifest JSON moves from a share file to + shopdb, plus a result report. + +.PARAMETER Scope + The imaging pc-type / scope name (e.g. gea-shopfloor-cmm), same value the + dispatcher already resolves from C:\Enrollment\pc-type.txt. + +.PARAMETER EnginePath + Path to Install-FromManifest.ps1 (the engine lib, >= 2.6). + +.PARAMETER ShareManifestPath + The current on-share manifest for this scope. In shadow mode the engine runs + against THIS (unchanged behavior) and shopdb is only compared + reported. Once + cut over, omit it and the engine runs against the shopdb-sourced manifest. + +.PARAMETER ShadowMode + Fetch + compare + report, but install from the share (no behavior change). + +.NOTES + Fail-safe: any error exits 0 so a bad web app never blocks or breaks a PC. + Config comes from HKLM:\SOFTWARE\GE\ShopDB (BaseUrl, ApiToken) - see the psm1. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string]$Scope, + [Parameter(Mandatory)] [string]$EnginePath, + [string]$ShareManifestPath, + [switch]$ShadowMode, + [string]$BaseUrl, + [string]$ApiToken, + [string]$LogFile = "C:\Logs\Shopfloor\shopdb-enforce-$(Get-Date -Format yyyyMMdd).log" +) + +function Write-Log { + param([string]$Message, [string]$Level = 'INFO') + $line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [$Level] $Message" + try { Add-Content -LiteralPath $LogFile -Value $line -ErrorAction SilentlyContinue } catch {} + Write-Host $line +} + +try { + Import-Module (Join-Path $PSScriptRoot 'ShopdbEnforceClient.psm1') -Force + + $config = Get-ShopdbConfig -BaseUrl $BaseUrl -ApiToken $ApiToken + if (-not $config) { + Write-Log 'No shopdb BaseUrl/ApiToken configured yet - retry next cycle.' 'WARN' + exit 0 + } + + $sync = Sync-ShopdbManifest -Scope $Scope -Config $config + if (-not $sync.Path) { + Write-Log "No manifest available for $Scope (shopdb unreachable, no cache)." 'WARN' + exit 0 + } + Write-Log "Manifest for $Scope from $($sync.Source) (v$($sync.Version))." + + # Shadow mode: compare shopdb vs the share, but install from the share. + if ($ShadowMode -and $ShareManifestPath -and (Test-Path $ShareManifestPath)) { + $diff = Compare-ShopdbShadow -ShopdbManifestPath $sync.Path -ShareManifestPath $ShareManifestPath + if ($diff.Same) { + Write-Log 'Shadow: shopdb manifest matches the share.' + } else { + Write-Log ("Shadow DIFF: shopdb-only=[{0}] share-only=[{1}] orderDiff={2}" -f ` + ($diff.ShopdbOnly -join ','), ($diff.ShareOnly -join ','), $diff.OrderDiff) 'WARN' + } + } + + # Which manifest the engine actually runs against. + $manifestToRun = if ($ShadowMode -and $ShareManifestPath) { $ShareManifestPath } else { $sync.Path } + + # --- INTEGRATION POINT --------------------------------------------------- + # Run the engine. Install-FromManifest.ps1 is expected to return (or you + # adapt it to return) a summary carrying Installed/Skipped/Failed/Filtered + # and a Results list of @{Name;Action;SelfHealed;ExitCode;Message}. Wire this + # to your engine's actual return/parse; the shape below is the contract. + Write-Log "Running engine against $manifestToRun" + $summary = & $EnginePath -ManifestPath $manifestToRun -PCType $Scope + if (-not $summary) { + $summary = @{ Installed = 0; Skipped = 0; Failed = 0; Filtered = 0; Results = @() } + } + if (-not $summary.EnforcerVersion) { $summary.EnforcerVersion = '2.6' } + + # Report the result (best-effort). + $appliedVersion = 0 + if ($sync.Version) { [int]::TryParse($sync.Version, [ref]$appliedVersion) | Out-Null } + $report = New-ShopdbReport -Scope $Scope -AppliedVersion $appliedVersion -Summary $summary + if (Send-ShopdbReport -Config $config -Report $report) { + Write-Log "Reported: installed=$($summary.Installed) skipped=$($summary.Skipped) failed=$($summary.Failed)." + } else { + Write-Log 'Report POST failed (non-fatal).' 'WARN' + } +} catch { + Write-Log "Unhandled error (non-fatal): $($_.Exception.Message)" 'ERROR' +} +exit 0 diff --git a/plugins/geenforce/client/ShopdbEnforceClient.psm1 b/plugins/geenforce/client/ShopdbEnforceClient.psm1 new file mode 100644 index 0000000..18492d6 --- /dev/null +++ b/plugins/geenforce/client/ShopdbEnforceClient.psm1 @@ -0,0 +1,162 @@ +<# +.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