Execute WIKI-UPDATE-PLAN.md (14 items): - NEW docs/PRINTER-INSTALLER.md: install-list / pc-default / install-batch contract + public installer map page. - NEW-shape docs/API-REFERENCE.md: index + pointer to the live generated docs (/api/docs Redoc, openapi.json, llms.txt, MCP), replacing a stale full dump. - geenforce cutover + GE-ENFORCE-DISPLAY/CLIENT/DEPLOY: server-first display dispatcher (display-role by FQDN, display-type.txt fallback), dashboarddefaults FQDN keying, legacy kiosk autostart self-heal (Wow6432Node), per-PC-type cutover status. - PLUGINS: printers/slides rows + plugin-permissions note (slides.manage). - IMPORT-API: dashboarddefaults FQDN-first keying. - CONFIG: word-wise search, site_timezone setting. - PILOT-DEPLOY: servers-to-network reclassify step. IMPORT-ADOPTION: fixup note. - CLAUDE.md: test count 1077->1159, HTTPS-cutover state. CHANGELOG: timezone + kiosk-autostart fixes, site_timezone setting.
38 KiB
GE-Enforce over HTTPS API: cutover reference
This is the operator/developer reference for moving a shopfloor PC type off the
SMB-share GE-Enforce delivery and onto the shopdb HTTPS API. The first cohort
cut over was the displays/kiosks (gea-shopfloor-display): share-less,
Intune/Entra-joined PCs with no SFLD share credentials. This doc captures
everything learned doing that, so extending the cutover to the other pc-types
(gea-shopfloor-cmm, -collections, -keyence, -genspect, -heattreat,
-partmarker, -nocollections, common) does not require re-learning it.
It pairs with the existing docs (which describe the pieces; this one describes the CUTOVER):
docs/GE-ENFORCE.md- concepts and the plugindocs/GE-ENFORCE-CLIENT.md- client fetch/report contractdocs/GE-ENFORCE-DEPLOY.md- what must land on a PCdocs/GE-ENFORCE-DISPLAY.md- the display scope specifics/home/camp/projects/pxe/docs/ge-enforce-v2-architecture.md- the SMB world being cut away from
Contents:
- Overview and why
- Server architecture
- Delivery models: smb vs http/inline payloads
- Authoring a scope
- The on-PC client and engine
- Bootstrap for share-less PCs
- Asset reporting via the collector
- HARD-WON GOTCHAS
- How this was verified
- Deploy
- PLAYBOOK: extending to a new pc-type
- Open items / TODO
1. Overview and why
GE-Enforce v2 delivers desired-state manifests and installer payloads from the
SFLD SMB share (\\tsgwp00525.wjs.geaerospace.net\shared\dt\shopfloor\).
Every PC mounts the share with Azure-DSC-provisioned SFLD credentials, reads
<scope>\manifest.json, and runs the engine
(Install-FromManifest.ps1). That works for the domain fleet but is a hard
dead end for share-less PCs.
The HTTPS API path replaces the share as the transport, keeping the engine and its detection/self-heal behavior untouched:
| Concern | SMB share | HTTPS API |
|---|---|---|
| Manifest source | <scope>\manifest.json on the share |
GET /api/geenforce/manifest?pctype=<scope> (published snapshot) |
| Payload source | share paths (apps\...) |
GET /api/geenforce/payload/<sha256> (content-addressed) |
| Auth | SFLD share credential (DSC) | geenforce.fetch service token OR source-IP allowlist |
| Result visibility | log files on the PC | POST /api/geenforce/report -> Enforcement Reports UI |
| Versioning | file overwrite + _meta/history |
immutable published versions, rollback, ETag |
Which PCs MUST use the API: the share-less ones. Displays/kiosks are Intune/Entra-joined with no SFLD credentials and no domain trust, so SMB is not an option at all. The rest of the fleet CAN stay on the share (and currently does); for them the API is an opt-in migration, not a forced one.
West Jefferson facts used throughout this doc:
| Fact | Value |
|---|---|
| Prod host | tsgwp00525.wjs.geaerospace.net |
| App mount | /shopdb (IIS, app dir C:\inetpub\wwwroot\shopdb, pool shopdbflask-prod) |
| BaseUrl clients use | https://tsgwp00525.wjs.geaerospace.net/shopdb |
| Prod DB | shopdb_flask (MySQL) |
| Client allowlist CIDRs | 10.134.48.0/23,10.48.249.0/26 (the WJ corp/AESFMA shopfloor subnets) |
| Dev/staging instance | /ops mount, DB shopdb_flask_dev, pool shopdbflask |
2. Server architecture
All server code is in plugins/geenforce/ (routes: plugins/geenforce/api/routes.py).
Client-facing endpoints (three)
| Endpoint | Method | Auth | What it does |
|---|---|---|---|
/api/geenforce/manifest?pctype=<scope>&phase=runtime |
GET | geenforce.fetch token OR IP allowlist |
Serves the CURRENT PUBLISHED manifest snapshot for a scope (never the draft). ETag: "<scopeid>-v<version>", X-Manifest-Version header, 304 on If-None-Match. |
/api/geenforce/payload/<sha256> |
GET | geenforce.fetch token OR IP allowlist |
Streams a payload blob by content hash: blob store first (service.blob_path), then inline ManifestPayload. ETag = the hash. Per-IP rate limited (120/min default) and size-capped (512 MB default, 413 above). |
/api/geenforce/report |
POST | geenforce.report token OR IP allowlist |
Records one enforcement cycle via service.record_enforcement_report: hostname, scopename, appliedversion, enforcerversion, counts, per-entry results. Upserts the current report per (hostname, scopename, phase); older reports kept as history. |
Plus the collector for asset reporting (section 7): POST /api/collector/computers in shopdb/core/api/collector.py - a DIFFERENT auth
domain (collector.ingest), NOT covered by the geenforce allowlist.
Auth model
_require_service_token(scope) in routes.py is the decorator factory. Two
paths, fail-closed (neither -> 401):
- A managed service token with the scope (
geenforce.fetchfor manifest/payload,geenforce.reportfor report), sent asX-API-Keyor a Bearer PAT (authorized_service_token). A token may carryresourcescopelistbindings: a bound token can only fetch its own scope's manifest (403 otherwise) and only blobs those scopes' published manifests reference (service.blob_referenced_by_scopes, 404 so hashes cannot be probed). Displays get a token bound togea-shopfloor-display(see GE-ENFORCE-DISPLAY.md). - The IP allowlist:
_ip_allowlisted()checks the caller against the settinggeenforce_allowed_cidrs(comma-separated CIDRs/IPs, empty = disabled). Network trust replaces the shared secret for a vaulted fleet. The allowlisted path has no resource-scope binding (unrestricted).
The token paths use RBAC permissions the plugin registers in
GeEnforcePlugin.get_permissions(): geenforce.manage (edit),
geenforce.publish (ship), geenforce.fetch and geenforce.report
(client service tokens). Admin CRUD/publish/simulate/compliance routes are JWT
plus geenforce.manage/geenforce.publish.
Why the allowlist uses remote_addr (the IIS XFF dependency)
_trusted_client_ip() returns request.remote_addr, NOT the raw
X-Forwarded-For header. Proxies APPEND to X-Forwarded-For, so its first hop
is attacker-controlled: parsing it (as _client_ip() does, acceptably, for
rate limiting only) would let any caller send X-Forwarded-For: <allowlisted-ip> and bypass the token entirely.
remote_addr is trustworthy only because of a two-piece chain that MUST stay
in place:
- The IIS URL-Rewrite rule in the
/shopdbweb.config OVERWRITES (not appends) the inboundX-Forwarded-ForwithREMOTE_ADDR, the real TCP peer. - waitress runs with
--trusted-proxy=127.0.0.1 --trusted-proxy-headers=x-forwarded-for, so it derivesremote_addrfrom that overwritten header only when the request comes from IIS on localhost. A client that somehow hits waitress directly is not a trusted proxy, so itsremote_addris its own real peer address. Either way, real client IP.
If the IIS rule is ever removed, the allowlist stops matching (fails closed to 401 for token-less clients) - it does NOT become spoofable, but the fleet falls back to cached manifests. Keep the rule on.
3. Delivery models: smb vs http/inline payloads
Every ManifestEntry carries a payloadsource (plugins/geenforce/serializer.py
and importer.py):
| PayloadSource | Meaning | Manifest emission |
|---|---|---|
smb (default) |
Entry installs from the share exactly as v2 does; the entry's Installer/Script/Source is a share-relative path. |
Nothing emitted - share manifests round-trip byte-identical, parity preserved. |
http |
Payload lives in the server's content-addressed blob store (instance/geenforce/payloads/<sha256>, registry row ManifestBlob). For big files (MSIs, EXEs). Upload via flask geenforce add-payload <file> or service.store_blob. |
PayloadSource, PayloadSha256, PayloadRef keys on the entry. |
inline |
Payload bytes live IN the DB (ManifestPayload, <= 1 MB) - small scripts and configs. Attach via service.store_inline_payload(entry, filename, contenttype, rawbytes) or POST /api/geenforce/entries/<id>/payload. |
Same three keys. |
Both http and inline are served from the same client URL:
GET /api/geenforce/payload/<sha256> (blob store checked first, then inline).
The sha256 IS the integrity contract - the client re-hashes after download.
How the client stages payloads (Resolve-ShopdbPayloads)
Resolve-ShopdbPayloads in plugins/geenforce/client/ShopdbEnforceClient.psm1
is the bridge that lets the UNCHANGED engine install share-less:
- For each entry with
PayloadSha256andPayloadSourcehttp/inline, callGet-ShopdbPayload: download toC:\ProgramData\ShopDB\geenforce\payloads\<sha><ext>(ext fromPayloadRef), verify the sha256, keep it as a content-addressed last-known-good cache (a cache hit only counts if the bytes still hash right). - Rewrite the entry's path field to the LEAF filename of the staged file
(
Split-Path -Leaf) - NOT the absolute path. Field by Type:Installerfor MSI/EXE/CMD/BAT/INF,Scriptfor PS1,Sourcefor File. - Write a sibling
<scope>.resolved.jsonmanifest and return its path (or the original path if nothing needed resolving). A payload that cannot be fetched/verified THROWS - the runner's fail-safe catch decides what happens.
The runner (Invoke-ShopdbEnforce.ps1) then sets the engine's
-InstallerRoot to that same payloads directory, so the engine's
Join-Path $InstallerRoot <leaf> resolves to the staged file. smb entries in
a mixed manifest are left untouched and still resolve against the share (a PC
that has it).
4. Authoring a scope
Two authoring paths, both ending in service.replace_scope_draft(scopename, phase, manifest_dict) (idempotent draft rebuild - published versions are never
touched by a re-import):
A. import-share: adopt an existing SMB manifest
flask geenforce import-share --shareroot <path> [--scope <name>] [--preinstall <path>]
flask geenforce publish <scopename> [--phase runtime] [--notes "..."]
importer.discover_share walks the share root and ingests
common/manifest.json, display/manifest.json, and every
gea-shopfloor-*/manifest.json (skipping .bak variants). Entries come in as
smb payloads. Run flask geenforce parity --shareroot <path> first (Gate A):
proves import+re-export is behaviorally lossless before anything ships.
B. authoring in code: seed_display_scope as the template
plugins/geenforce/seed_display_scope.py is the reference for a scope that
never existed on the share. Pattern:
- Build the manifest dict in Python (
build_display_manifest()): fourType=Registrydrift-heal entries re-asserting the Edge kiosk relaunch policies from imaging (09-Setup-Display.ps1), one inline PS1 dispatcher, one inline PS1 always-on script. Registry heals useDetectionMethod=ValueMatchesagainst the same path/name they write, so drift self-heals; the PS1s useDetectionMethod=Alwaysand are idempotent. - The dispatcher (
Invoke-DisplayKioskDispatch.ps1, generated bybuild_dispatcher_script()) readsC:\Enrollment\display-type.txt, maps the subtype through the data-drivenDISPLAY_TYPE_TARGETStable (Dashboard ->/shopfloor, Lobby ->/tv, 3DPrintRoom ->/parts-kiosk), and writes an all-users Startup shortcut (ShopDB Kiosk.lnk) launching Edge--kioskfullscreen at{BaseUrl}{route}. It does NOT Start-Process Edge (see gotchas). Base URL comes from HKLMBaseUrl, falling back to the WJ host. seed_display_scope(publish=False):replace_scope_draft, flush (entries need entryids), thenservice.store_inline_payload(...)for each script entry (setspayloadsource='inline',payloadsha256,payloadref), optionallyservice.publish_scope(...), commit. Draft rebuild is idempotent; publish always creates a NEW version.
Run it on the server:
cd C:\inetpub\wwwroot\shopdb
$env:FLASK_APP = 'shopdb'
'from plugins.geenforce.seed_display_scope import seed_display_scope; print(seed_display_scope(publish=True))' | venv\Scripts\python -m flask shell
Expected: {scopeid, entrycount: 6, entrytypes: [Registry x4, PS1, PS1], dispatchersha256, alwaysonsha256, publishedversion: N}.
Publishing
service.publish_scope freezes the draft (rendered by
serializer.scope_to_json) into an immutable ManifestPublishedVersion and
flips iscurrent. Clients only ever see published versions.
rollback_scope re-currents an older version. Also available over the API
(POST /scopes/<id>/publish, permission geenforce.publish) and the
GE-Enforce UI.
Attaching payloads
- Inline (<= 1 MB):
service.store_inline_payloadin code, orPOST /api/geenforce/entries/<entryid>/payload(multipart file). - Blob (
http):flask geenforce add-payload <filepath>prints the sha256; setPayloadSource=http+PayloadSha256(+PayloadReffor the filename/extension) on the entry.
Publish AFTER attaching - the published JSON is what carries the
PayloadSha256 the client fetches, and blob access for resource-bound tokens
is checked against the CURRENT published manifest.
5. The on-PC client and engine
Config: HKLM:\SOFTWARE\GE\ShopDB
| Value | Used by | Notes |
|---|---|---|
BaseUrl |
enforce client + kiosk dispatcher | e.g. https://tsgwp00525.wjs.geaerospace.net/shopdb. Required. |
ApiToken |
enforce client | geenforce.fetch (+ report) PAT. OPTIONAL - a token-less client relies on the IP allowlist (Get-ShopdbConfig treats BaseUrl-only as valid). |
CollectorKey |
Report-AssetToShopDB.ps1 |
collector.ingest PAT. REQUIRED for asset reporting (allowlist does not cover the collector). |
The key's ACL is restricted to SYSTEM + Administrators (the bootstrap does this) so the kiosk auto-login user cannot read the PATs.
The pieces on disk (kiosk layout, C:\ProgramData\GE-Enforce)
Invoke-ShopdbEnforce.ps1- the runnerShopdbEnforceClient.psm1- the client modulelib\Install-FromManifest.ps1- the engine (>= 2.6)Report-AssetToShopDB.ps1- the asset collector- Cache:
C:\ProgramData\ShopDB\geenforce\(<scope>.json,.etag,.version,payloads\), logsC:\Logs\Shopfloor\
Scheduled tasks (SYSTEM, RunLevel Highest)
| Task | Runs | Interval |
|---|---|---|
ShopDB GE-Enforce |
Invoke-ShopdbEnforce.ps1 -Scope <scope> -EnginePath <engine> -BaseUrl <url> |
AtStartup + every 15 min |
ShopDB Asset Report |
Report-AssetToShopDB.ps1 |
AtStartup + every 60 min |
Tokens are NOT in the task arguments (visible in task XML) - the scripts read them from HKLM.
The runner flow (Invoke-ShopdbEnforce.ps1)
Get-ShopdbConfig(params override registry). No BaseUrl -> exit 0, retry next cycle.Sync-ShopdbManifest -Scope <scope>: ETag-conditional GET; 200 validates the JSON before overwriting the cache (a proxy error page served as 200 must not clobber last-known-good); 304 or any network failure -> cached copy. Nothing at all -> Windows event log entry (sourceShopdbEnforce, id 1001) plus a best-effort failure report so it is visible server-side, then exit 0.-ShadowMode(with-ShareManifestPath):Compare-ShopdbShadowlogs name/order diffs, engine runs against the SHARE (zero behavior change). This is the first step of every cutover.- Cutover mode: optional
-IncludeCommonmerges the fleetcommonscope (Merge-ShopdbManifests: common's unique entries first, pctype wins on Name conflict). OFF by default - a scope is enforced ALONE and displays are self-sufficient. ThenResolve-ShopdbPayloadsstages http/inline payloads (section 3). - Engine call (the integration point):
& $EnginePath -ManifestPath <resolved> -PCType $Scope -InstallerRoot <payloads dir> -LogFile <log>. ConvertTo-ShopdbSummarynormalizes whatever came back (summary object, array of emitted objects, bare int, $null) into@{Installed;Skipped;Failed;Filtered;Results;EnforcerVersion}, thenNew-ShopdbReportmaps to the lowercase wire contract andSend-ShopdbReportPOSTs it. All best-effort; the whole script exits 0 no matter what (fail-safe: a broken web app never breaks a PC).
The engine contract (Install-FromManifest.ps1, lib 2.6)
Mandatory params: -ManifestPath, -InstallerRoot, -LogFile; optional
-PCType, -PCSubType. Entry Types: MSI, EXE, CMD/BAT, PS1, INF, File,
Registry. Detection: Registry, File, FileVersion, Hash, MarkerFile,
ValueMatches, pnputil, Always. Filters: PCTypes (with old/new-name alias
groups), TargetHostnames, TargetMachineNumbers, _CmmVersion. Exit 0/1/2
unchanged for the SMB path; NEW in the API cutover: the engine ends with
Write-Output of a summary pscustomobject
(Installed/Skipped/Failed/Filtered/EnforcerVersion/Results), which is the
only thing on the success stream (logs go via Write-Host), so
& $EnginePath ... captures it cleanly.
SelfHealed on a result means a REAL drift correction (a detected-missing
entry that got reinstalled). Always/no-detection entries install every cycle
by design and are not flagged, so the server-side status derivation
(service.record_enforcement_report: failed > selfhealed > ok) stays honest.
The display dispatcher: server-resolved role, file fallback
For kiosks, per-subtype behavior does not fork the scope: ONE scope
(gea-shopfloor-display), one inline dispatcher entry (built by
plugins/geenforce/seed_display_scope.py) that resolves what the display should
show at enforce time, in two steps:
- Server (authoritative):
GET $KioskBaseUrl/api/dashboarddefaults/display-role?fqdn=<own-fqdn>. This is a PUBLIC endpoint (no token). The server matches the FQDN against thedashboarddefaultstable (IP fallback) and returns{role, path, businessunitid, businessunit}. Roles:dashboard,lobby,partskiosk. Changing a display's job is now a server-side edit; no touch on the PC. - Fallback (offline, or unmapped): the local
C:\Enrollment\display-type.txtvalue against theDISPLAY_TYPE_TARGETSmap baked into the script. If neither resolves, the dispatcher logs and configures nothing.
The FQDN is built as F<BIOS serial>.<domain> (GE device naming); the domain
comes from HKLM DisplayFqdnDomain or the built-in default
(device.geaerospace.net). DetectionMethod = Always, but the script is
idempotent: it rewrites the all-users Startup shortcut (never Start-Process -
see gotchas) only when the resolved target changed.
Legacy autostart self-heal
The old GE Aerospace Dashboard / Lobby Display Inno installers planted three
autostarts each: a Public-Desktop .lnk, an all-users Startup .lnk, and an
HKLM ...\CurrentVersion\Run value, all launching Edge at now-dead URLs
(/shopfloor-dashboard/, /tv-dashboard/) which 404 to a white screen. Because
those installers were 32-bit, the Run value was WOW64-redirected into
HKLM\SOFTWARE\Wow6432Node\...\Run, invisible to 64-bit tooling - the reason it
survived earlier cleanup. The dispatcher (build_dispatcher_script in
seed_display_scope.py) now sweeps, every enforce cycle: both the native and
Wow6432Node registry views, every loaded user hive (HKU), Run + RunOnce +
Policies\Explorer\Run, matching by the legacy value names AND by any value
pointing at the old URLs; plus every per-user and common Startup folder; then
kills any running old-URL Edge. A read-only locator,
pxe-images/github/find-legacy-kiosk-autostart.ps1, hunts all these locations
(and Edge startup-URL policy, scheduled tasks, Assigned Access) when a straggler
persists.
The kiosk shortcut is a direct Edge shortcut (no launcher/VBS); the fix ships by
re-publishing the code-authored gea-shopfloor-display scope
(seed_display_scope(publish=True)), not an import-share.
6. Bootstrap for share-less PCs
A share-less PC cannot pull its first files from the share, so the bootstrap itself is downloadable from the web app.
Install-ShopdbKiosk.ps1 (source of truth:
/home/camp/pxe-images/shopdb-migration/kiosk-installer/) is hosted at
C:\inetpub\wwwroot\shopdb\installers\kiosk\ and downloadable at
{BaseUrl}/installers/kiosk/Install-ShopdbKiosk.ps1. Run elevated on the PC:
Set-ExecutionPolicy Bypass -Scope Process -Force
$u = 'https://tsgwp00525.wjs.geaerospace.net/shopdb/installers/kiosk/Install-ShopdbKiosk.ps1'
Invoke-RestMethod $u -OutFile "$env:TEMP\Install-ShopdbKiosk.ps1"
& "$env:TEMP\Install-ShopdbKiosk.ps1" -DisplayType Lobby -CollectorKey 'shopdb_pat_...'
# add -ShopdbToken 'shopdb_pat_...' only if the subnet is NOT allowlisted
What it does (idempotent; re-running is also the manual update path):
- Writes
C:\Enrollment\display-type.txt(the subtype) andC:\Enrollment\pc-type.txt(the scope, defaultgea-shopfloor-display). - Writes HKLM
BaseUrl[+ApiToken] +CollectorKey, then locks the key ACL to SYSTEM + Administrators. - Downloads runner + module + engine + collector from
{BaseUrl}/installers/kiosk/over HTTPS (TLS 1.2 forced). - Registers the two SYSTEM tasks (section 5).
- Starts both once so the PC is live immediately.
IIS prerequisite: the installers\kiosk web.config MUST carry
<staticContent> MIME maps for .ps1/.psm1 (text/plain) or IIS 404.3s
the downloads (see gotchas).
Delivery options for the bootstrap itself:
- Imaging-baked: the display image runs it (or lays down the same state) at
imaging time - see
project-display-self-contained. - Installer push: Intune/hand-run the one-liner above on an already-deployed
PC. This is how the pilot kiosks were done
(
shopdb-migration/run-on-kiosk-F.txt).
7. Asset reporting via the collector
Report-AssetToShopDB.ps1 (in the kiosk bundle; also deployed in the SMB
common\ scope for the share fleet) POSTs the PC's identity to:
POST {BaseUrl}/api/collector/computers
X-API-Key: <collector.ingest PAT or COLLECTOR_API_KEY env key>
Auth (shopdb/core/api/collector.py, _check_collector_auth): a managed
token scoped collector.ingest (Bearer or X-API-Key) OR the
COLLECTOR_API_KEY/COLLECTOR_API_KEY_COMPUTERS env key. The GE-Enforce IP
allowlist does NOT apply here - the collector always needs a key, read from
HKLM CollectorKey (or the manifest entry's Args -ApiKey).
Schema (plugins/computers/plugin.py, get_collector_schema): identity field
hostname (required); optional machinenumber, pctype, pcsubtype,
serialnumber, loggedinuser, lastboottime, lastcheckin, ipaddress,
vendorname, modelnumber, osname, installedsoftware, defaultprinter,
printers. All lowercase concatenated (the project naming convention).
apply_collector_payload upserts idempotently by hostname (falls back to
Asset.assetnumber), creates the Asset+Computer when missing, maps
machinenumber -> Asset.assetnumber (imaging placeholder 9999 skipped both
client- and server-side), pctype -> ComputerType via the settings mapping,
and creates Vendor/Model/OS rows as needed. Fields not posted are not touched -
a partial read never blanks a good value, so the script only includes fields it
actually resolved.
Client details worth keeping: machine number resolution order is eDNC registry
MachineNo (WOW6432Node then native) -> C:\Enrollment\cmm\cmmid.txt ->
C:\Enrollment\machine-number.txt; the reported ipaddress is filtered to
the corp ranges (same two CIDRs as the allowlist) so a machine-LAN controller
NIC never lands in shopdb.
8. HARD-WON GOTCHAS
Read this section before touching ANY of the moving parts. Every bullet cost real debugging time. Format: symptom -> cause -> fix.
- PS crash "property 'X' cannot be found" under Set-StrictMode ->
the module runs
Set-StrictMode -Version Latest, and engine results/summaries arrive as EITHER hashtables or PSCustomObjects with varying key casing; direct$obj.Keyaccess on an absent key throws -> route every dynamic property read throughGet-ShopdbProperty(handles both shapes, case-insensitive, returns $null when absent). Never dot into parsed JSON or engine output directly. - Register-ScheduledTask rejects the repeating trigger -> passing
-RepetitionDuration [TimeSpan]::MaxValueserializes to an out-of-range Duration the Task Scheduler XML schema rejects -> use-RepetitionIntervalALONE; it defaults to indefinite repetition (verified Win11 / PS 5.1). SeeRegister-SystemTaskinInstall-ShopdbKiosk.ps1. - Engine exits 2 / "InstallerRoot not found" ->
-InstallerRootand-LogFileare MANDATORY engine params and InstallerRoot must EXIST -> the runner always passes both and pre-creates the payloads dir before the engine call. Any new caller must do the same. - http payload "not found: C:...\C:..." (path doubling) -> the engine
resolves entry paths as
Join-Path $InstallerRoot <field>; writing the staged payload's ABSOLUTE path into the entry made the engine double it ->Resolve-ShopdbPayloadswrites the LEAF filename only, and the runner sets-InstallerRootto the payloads cache dir. Keep those two in lockstep. - Manifest fetch "works" but parsing fails / cache garbage -> if the
manifest is served with a non-JSON content type, PowerShell 5.1
Invoke-WebRequest.Contentcomes back as abyte[]instead of a string -> the server route returnsmimetype='application/json'(seeget_manifest); any mock server or proxy in the chain must do the same. The client also validates JSON before overwriting last-known-good. - Bootstrap download 404 (HTTP 404.3) -> IIS refuses to serve unknown
static extensions;
.ps1/.psm1have no default MIME map -> add<staticContent><mimeMap fileExtension=".ps1" mimeType="text/plain" />(and.psm1) in theinstallers\kioskweb.config, with<remove>first if inherited. - IP allowlist spoofable / mysteriously not matching -> raw
X-Forwarded-Foris attacker-controlled (proxies append; first hop is the caller's to write) ->_ip_allowlistedusesrequest.remote_addrvia_trusted_client_ip, which is only correct because the IIS URL-Rewrite rule OVERWRITES X-Forwarded-For with REMOTE_ADDR and waitress trusts only 127.0.0.1 as proxy. The rule is a hard dependency: never remove it, and verify the spoof is closed after server changes (curl -H "X-Forwarded-For: 10.134.48.10"from a non-allowlisted host must get 401). - Kiosk browser never appears though the dispatcher "ran fine" -> the
enforce task runs as SYSTEM in session 0, which has no interactive
desktop;
Start-Process msedge.exeopens INVISIBLY there -> write an all-users Startup shortcut (C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Startup\ShopDB Kiosk.lnk) and let the auto-login user launch it in a visible session. This is why the dispatcher is shortcut-based. - TLS/transport errors on older images ("could not create SSL/TLS secure
channel") -> Windows PowerShell 5.1 does not reliably negotiate TLS 1.2
by default -> every network helper calls
Set-ShopdbTls([Net.ServicePointManager]::SecurityProtocol = Tls12) first; the bootstrap and collector force it too. Any new script that touches the API must do the same. - Secrets readable by the kiosk user / visible in Task Scheduler ->
default HKLM\SOFTWARE ACL grants BUILTIN\Users read, and task arguments
are world-readable in the task XML -> restrict the
HKLM:\SOFTWARE\GE\ShopDBkey ACL to SYSTEM + Administrators (the bootstrap does), and NEVER put a token in a task's-Argumentstring - scripts readApiToken/CollectorKeyfrom HKLM at run time. - Enforcement Reports show 0/0/0 with no per-entry detail -> the engine
historically returned nothing on the success stream, so the runner had no
counts to report -> the 2.6 engine emits the summary object as its ONLY
Write-Output(all logging is Write-Host), andConvertTo-ShopdbSummarytolerates non-compliant engines by zero-filling. If reports go 0/0/0 again, the engine on that PC is pre-summary - update it. - Two kiosk browsers fighting / stale kiosk launch after retarget -> old
installs left their own Startup launchers behind, in several flavors ->
the dispatcher's sweep must match ALL of: single- AND double-dash
-kioskarguments (the regex-kioskmatches both), shortcuts whose args carry shopdb URLs (tsgwp00525,/shopdb/, the deadshopfloor-dashboardroute), the imaging installers'GE Aerospace Dashboard*/GE Aerospace Lobby*shortcut names, and.urlfiles pointing at the kiosk routes. Extend the sweep whenever a new launcher naming appears.
9. How this was verified
Two complementary verification passes; keep BOTH for future cohorts because they catch different bug classes:
- Code review finds logic bugs: the XFF spoof hole, the StrictMode
absent-key crashes, the status-derivation trap (
installed>0is not self-heal), the report casing mismatch. - VM smoke test finds integration/OS-version bugs: the RepetitionDuration serialization rejection, the .ps1 MIME 404, session-0 invisibility, TLS negotiation, byte[] vs string response content - none of which a read of the code surfaces.
The VM rig:
- The win11 virt-manager VM (see
project-win11-vm/reference-vm-qga-as-systemmemory), driven by/home/camp/pxe-images/ednc-bins/qga-run.py- qemu guest agentguest-exec, which runs PowerShell AS SYSTEM. That matters: the scheduled tasks run as SYSTEM, so testing as SYSTEM reproduced the session-0 and profile-less behaviors an interactive test would have masked. - A mock HTTP server on the host exposing
/api/geenforce/manifest(servedapplication/json),/api/geenforce/payload/<sha>, and capturing the/api/geenforce/reportPOST body. This exercised the full client loop - ETag/304, cache fallback (kill the server mid-test), payload hash verification, resolved-manifest rewrite, engine run, summary -> report mapping - without touching prod. - Full pass = bootstrap installer run end to end in the VM, then assert: both tasks registered, HKLM written + ACLed, manifest cached, payloads staged by sha, Startup shortcut written, report captured with real counts.
10. Deploy
TWO independent channels. Confusing them is the classic mistake: the client/ engine/collector/bootstrap are pxe-images SHARE artifacts, NOT deployed by the git pipeline.
Channel 1: backend + frontend (the git .cmd pipeline)
Prod is air-gapped from dev; code moves via a git bundle on the share
(\\172.16.9.9\pxe-images\github\shopdb-flask-pub.bundle) and the .cmd
scripts in /home/camp/pxe-images/github/, run on the work PC:
pull-shopdb-bundle.cmd- fetch the bundle into the local clone (ff-only).update-shopdb-github.cmd- push the clone to GitHub.update-dev-server.cmd- robocopy the clone toX:(dev/opstree) + the/ops-base frontend dist. Validate on/opsFIRST.update-prod-server.cmd- robocopy toY:(C:\inetpub\wwwroot\shopdb)- the
/shopdb-base dist (frontend-dist-subpath-shopdb). Both scripts carry instance guards (web.config MOUNT_PATH check) - do not bypass them.
- the
Then RDP: Restart-WebAppPool shopdbflask-prod, and if migrations/deps
changed: flask db upgrade, flask plugin upgrade-all, flask seed permissions, flask seed settings.
(The fast path used during the pilot - robocopy just the changed plugin files
from shopdb-migration\prod-patch-geenforce\ + pool restart, per
deploy-server-patch.txt - works, but the same commits must ALSO go through
the bundle pipeline or prod drifts from git.)
Channel 2: client + engine + collector + bootstrap (the kiosk bundle)
These live at /home/camp/pxe-images/shopdb-migration/kiosk-installer/ and
deploy by robocopy from the work PC (Z: = share, Y: = prod app dir):
robocopy Z:\shopdb-migration\kiosk-installer Y:\installers\kiosk /E
That directory (bundle contents: Install-ShopdbKiosk.ps1,
Invoke-ShopdbEnforce.ps1, ShopdbEnforceClient.psm1,
lib\Install-FromManifest.ps1, Report-AssetToShopDB.ps1, web.config with
the MIME maps) IS the distribution point - PCs download from
{BaseUrl}/installers/kiosk/. Reference copies of the client kit also live in
the repo at plugins/geenforce/client/ and the engine's source of truth is
/home/camp/pxe-images/common/lib/Install-FromManifest.ps1; when the engine
or client changes, update the kiosk bundle copy too (nothing syncs it
automatically). PCs pick up new bytes by re-running the bootstrap one-liner.
Server prerequisites (once per site, all three or token-less clients 401)
- Publish the scope(s) -
seed_display_scope(publish=True)orflask geenforce publish <scope>. - Seed
geenforce_allowed_cidrs=10.134.48.0/23,10.48.249.0/26(Settings rail > GE-Enforce Settings, or SQL upsert intosettings). - Mint tokens (Settings > API Tokens, Restrict permissions ON):
collector.ingest(required, the kiosk-CollectorKey) andgeenforce.fetch(fallback for non-allowlisted subnets; resource-bind it to the scope). - Keep the IIS URL-Rewrite XFF-overwrite rule enabled (section 2).
11. PLAYBOOK: extending to a new pc-type
Checklist for cutting any of the remaining scopes (gea-shopfloor-cmm,
-collections, -keyence, -genspect, -heattreat, -partmarker,
-nocollections, common) over to the API.
- Decide the delivery model. Does this pc-type keep SMB access? If yes,
the cheap cutover is manifest-over-API + payloads-still-smb (entries stay
smb, nothing to upload, the engine resolves share paths as today). Only a genuinely share-less PC needs http/inline payload conversion. Note the payload endpoint's 512 MB default ceiling (GEENFORCE_PAYLOAD_MAX_BYTES) before promising huge installers over HTTPS. - Get the scope into shopdb. Existing share manifest:
flask geenforce paritythenflask geenforce import-share --scope <name>. New/reworked scope: author in code followingseed_display_scope.py(registry heals with ValueMatches detection, idempotent Always PS1s, data-driven tables for anything per-subtype). - Convert payloads (share-less only). Small scripts/configs ->
store_inline_payload/ the entry payload upload endpoint. Installers ->flask geenforce add-payload <file>, setPayloadSource=http+PayloadSha256+PayloadRefon the entry. Remember:PayloadRef's extension decides the staged filename's extension. - Publish. New version every publish; clients converge within one
enforce cycle. Verify with
curl "{BaseUrl}/api/geenforce/manifest?pctype=<scope>"from an allowlisted host. - Auth for the PCs. Subnet already inside
10.134.48.0/23,10.48.249.0/26-> token-less, nothing to do. New subnet -> add its CIDR togeenforce_allowed_cidrs(Settings rail validates). Not network-trustable -> mint ageenforce.fetchtoken resource-bound to the scope and deliver it to HKLMApiToken. - Bootstrap the client. Share-attached fleet: adapt the dispatcher /
Install-GEEnforce.ps1path (the pilot flow inshopdb-migration/kiosk-api-pilot.txt: shadow first, then flip, then DISABLE the old share enforce task so the two do not fight). Share-less: theInstall-ShopdbKiosk.ps1pattern - generalize-Scopeand skip the display-only pieces. Decide-IncludeCommon: displays run without it; a non-display share-less PC that needs the fleet-wide common entries over HTTPS turns it on AND requires common's entries to be payload-converted first (an SMB-payload common entry will fail on a share-less PC). - Run SHADOW mode first on one pilot PC
(
-ShadowMode -ShareManifestPath <share manifest>): fetch + compare + report with zero behavior change. Watch the shadow diff log lines and the Enforcement Reports row before flipping. - Re-read section 8 (gotchas). Especially: LEAF filenames, StrictMode property access, SYSTEM/session-0, task trigger serialization, MIME maps if you host new downloadables.
- Verify on the VM (section 9) before the pilot PC: bootstrap +
enforce cycle against a mock or the dev
/opsinstance, as SYSTEM via qga-run.py. - Pilot one PC, then the cohort. Keep the rollback in your pocket:
disable/remove the new task, re-enable the share task, remove HKLM
BaseUrl- the share path is untouched by all of this. - pc-type mapping. Make sure the collector's pctype mapping
(computers plugin settings,
pctypemap) covers the scope name so asset reports do not warnno ComputerType mapping.
12. Open items / TODO
- Name resolution for reported users.
loggedinuserlands as a bare username; resolving it to a display name depends on either thewjf_employeesFirst_Name/Last_Namedata or shopdb User accounts existing for shopfloor users. Not wired up; reports show raw usernames until it is. - Old imaging-installer registry cleanup. Displays imaged before the API cutover carry leftover state from the old imaging-time kiosk installers (superseded shortcuts are already swept by the dispatcher; stale registry values are not yet cleaned). A cleanup entry in the display scope is the natural vehicle.
- The SMB fleet is still on the share. Only displays/kiosks are on the API. cmm/collections/keyence/genspect/heattreat/partmarker/nocollections/ common still enforce from SFLD; section 11 is the path. Shadow mode makes each migration observable before it changes anything.
- 3DPrintRoom kiosk target is a placeholder.
DISPLAY_TYPE_TARGETSpoints it at/parts-kiosk; confirm the real route with the floor team before publishing to production 3D-print-room displays (flagged inseed_display_scope.py). - Fast-path prod patches vs git. The
prod-patch-geenforcerobocopy path can leave prod ahead of the repo; reconcile by pushing the same changes through the bundle pipeline (section 10, channel 1).