Files
shopdb-flask/docs/geenforce-api-cutover.md
cproudlock 2c415a1712
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
fix(installer): correct a false security claim, and clear the should-fix list
CLIENT IP / SPOOFABILITY. docs/geenforce-api-cutover.md claimed that removing the
IIS rewrite rule made the allowlist fail closed and that it does NOT become
spoofable. The opposite is true. IIS never sets X-Forwarded-For on its own; the
rule is the only thing that does. Remove it and IIS still forwards whatever
X-Forwarded-For the CALLER sent, waitress trusts it because it arrives from
127.0.0.1, and remote_addr becomes attacker-controlled - so a token-less caller
can fetch manifests from anywhere on the network. The document and the
_trusted_client_ip docstring now say so, waitress runs with
--trusted-proxy-count=1, and stage 5 checks the rule is actually live rather than
assuming it. The wizard question is rephrased to something an operator can verify
with their network team instead of guessing at.

NON-ASCII. The style gate only ever checked .py/.vue/.js/.ts, so documentation
accumulated em-dashes, arrows and box-drawing characters against this repo's own
convention - including in files added this week. Cleaned, and the gate now uses
INCLUDES_ALL so Markdown, JSON and YAML are covered.

PLUGIN DEFAULTS. The wizard pre-ticked measuringtools and printedparts, both of
which ship default_enabled=false, so every site taking the defaults installed and
enabled them against their manifests. Inno has no JSON parser so the list must be
hardcoded, but tests/test_installer_defaults.py now fails when it drifts.

UPGRADES. The payload copy merges, so a plugin dropped from a site's profile kept
its code forever - which defeats a lean build and leaves core's optional-import
guards succeeding for a plugin the site no longer has. Stale plugin directories
are now deregistered and removed before the copy.

add-plugin used 'plugin install', which for the five default_enabled=false
plugins left them installed but DISABLED - and printed a green success line
anyway. It now goes through apply-profile, and the success line is gated on the
exit code. Invoke-Flask records its own exit status, because $LASTEXITCODE keeps
a stale value when flask.exe is missing and no native command runs.

CHARSET. The utf8mb4 compiler hook lived inline in migrations/env.py, so it
covered the CORE chain only: plugin baselines inherited the server default, which
on a latin1 server means two charsets in one database. It is now
shopdb/utils/mysql_charset.py, imported by both, and preflight reports the
database's default charset.

BACKUP HONESTY. The dump was described as 'all of your asset data'. Uploaded
branding and floor-map images live in instance\ on disk, not in the database, so
a restore from the .sql alone comes back with no map. backup now archives
instance\ alongside it and says both are needed.

VERSIONING. AppVersion was hardcoded at 0.9.0 while the product, the frontend and
the newest tag said 0.7.0 - and 0.9.0 collides with a retired contract version.
Both builders now generate version.iss from shopdb/__init__.py.

Smaller: rollback overwrites .env before deleting it, as uninstall already did;
appcmd unlocks are scoped to this site's location rather than server-wide, with
the wide unlock as a fallback; DEVELOPMENT-SETUP says Python 3.14; the README
plugin list gains printedparts; prune-schema --force is documented as
first-provisioning-only; HTTPS is documented as not-the-default with the steps to
add it; the DBA SQL is on the wizard's database page; the features page says
unticking does not remove an installed feature; and the installer README states
that bundle-lock cannot vouch for the exe itself - that needs signing or an
out-of-band hash, neither of which is wired up.
2026-08-03 14:57:38 -04:00

752 lines
39 KiB
Markdown

# 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 plugin
- `docs/GE-ENFORCE-CLIENT.md` - client fetch/report contract
- `docs/GE-ENFORCE-DEPLOY.md` - what must land on a PC
- `docs/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:
1. [Overview and why](#1-overview-and-why)
2. [Server architecture](#2-server-architecture)
3. [Delivery models: smb vs http/inline payloads](#3-delivery-models-smb-vs-httpinline-payloads)
4. [Authoring a scope](#4-authoring-a-scope)
5. [The on-PC client and engine](#5-the-on-pc-client-and-engine)
6. [Bootstrap for share-less PCs](#6-bootstrap-for-share-less-pcs)
7. [Asset reporting via the collector](#7-asset-reporting-via-the-collector)
8. [HARD-WON GOTCHAS](#8-hard-won-gotchas)
9. [How this was verified](#9-how-this-was-verified)
10. [Deploy](#10-deploy)
11. [PLAYBOOK: extending to a new pc-type](#11-playbook-extending-to-a-new-pc-type)
12. [Open items / TODO](#12-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):
1. A managed service token with the scope (`geenforce.fetch` for
manifest/payload, `geenforce.report` for report), sent as `X-API-Key` or a
Bearer PAT (`authorized_service_token`). A token may carry
`resourcescopelist` bindings: 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 to `gea-shopfloor-display` (see
GE-ENFORCE-DISPLAY.md).
2. The IP allowlist: `_ip_allowlisted()` checks the caller against the setting
`geenforce_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:
1. The IIS URL-Rewrite rule in the `/shopdb` web.config OVERWRITES (not
appends) the inbound `X-Forwarded-For` with `REMOTE_ADDR`, the real TCP
peer.
2. waitress runs with `--trusted-proxy=127.0.0.1
--trusted-proxy-headers=x-forwarded-for`, so it derives `remote_addr` from
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 its
`remote_addr` is its own real peer address. Either way, real client IP.
**If the IIS rule is ever removed, the allowlist becomes SPOOFABLE.** This
document previously claimed the opposite; it was wrong, and the reasoning matters.
IIS does not set `X-Forwarded-For` on its own - the rewrite rule is the only
thing that does. Remove the rule and IIS still *forwards* whatever
`X-Forwarded-For` the caller sent. waitress trusts that header because it arrives
from `127.0.0.1`, which is IIS, and sets `remote_addr` from it. So a caller who
sends `X-Forwarded-For: 10.134.48.5` gets `remote_addr = 10.134.48.5`, matches
the allowlist and fetches manifests token-less from anywhere on the network.
The rule is not a nicety that improves logging. It is the control that makes
`remote_addr` trustworthy, and everything downstream - the allowlist, the
dashboard visitor-location lookup, per-host login rate limiting - depends on it.
Three consequences worth stating plainly:
- The Windows installer enables the rule when told IIS faces clients directly
(`-ClientIpSource direct`), installs URL Rewrite from the bundle to make that
possible offline, and its stage-5 check fails if the rule is not live.
- On a hand-built server, verify it: `deploy/windows/web.config` must have the
`<rewrite>` block ACTIVE, not inside the `SHOPDB-CLIENTIP` comment markers.
- Behind a real reverse proxy the rule is the wrong answer, because `REMOTE_ADDR`
is then the proxy. There, the proxy must set `X-Forwarded-For` itself and be
the only thing that can reach IIS. `-ClientIpSource proxy` covers that case.
---
## 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:
1. For each entry with `PayloadSha256` and `PayloadSource` http/inline, call
`Get-ShopdbPayload`: download to
`C:\ProgramData\ShopDB\geenforce\payloads\<sha><ext>` (ext from
`PayloadRef`), verify the sha256, keep it as a content-addressed
last-known-good cache (a cache hit only counts if the bytes still hash
right).
2. Rewrite the entry's path field to the LEAF filename of the staged file
(`Split-Path -Leaf`) - NOT the absolute path. Field by Type:
`Installer` for MSI/EXE/CMD/BAT/INF, `Script` for PS1, `Source` for File.
3. Write a sibling `<scope>.resolved.json` manifest 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()`): four
`Type=Registry` drift-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 use
`DetectionMethod=ValueMatches` against the same path/name they write, so
drift self-heals; the PS1s use `DetectionMethod=Always` and are idempotent.
- The dispatcher (`Invoke-DisplayKioskDispatch.ps1`, generated by
`build_dispatcher_script()`) reads `C:\Enrollment\display-type.txt`, maps
the subtype through the data-driven `DISPLAY_TYPE_TARGETS` table
(Dashboard -> `/shopfloor`, Lobby -> `/tv`, 3DPrintRoom -> `/parts-kiosk`),
and writes an all-users Startup shortcut (`ShopDB Kiosk.lnk`) launching Edge
`--kiosk` fullscreen at `{BaseUrl}{route}`. It does NOT Start-Process Edge
(see gotchas). Base URL comes from HKLM `BaseUrl`, falling back to the WJ
host.
- `seed_display_scope(publish=False)`: `replace_scope_draft`, flush (entries
need entryids), then `service.store_inline_payload(...)` for each script
entry (sets `payloadsource='inline'`, `payloadsha256`, `payloadref`),
optionally `service.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_payload` in code, or
`POST /api/geenforce/entries/<entryid>/payload` (multipart file).
- Blob (`http`): `flask geenforce add-payload <filepath>` prints the sha256;
set `PayloadSource=http` + `PayloadSha256` (+ `PayloadRef` for 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 runner
- `ShopdbEnforceClient.psm1` - the client module
- `lib\Install-FromManifest.ps1` - the engine (>= 2.6)
- `Report-AssetToShopDB.ps1` - the asset collector
- Cache: `C:\ProgramData\ShopDB\geenforce\` (`<scope>.json`, `.etag`,
`.version`, `payloads\`), logs `C:\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)
1. `Get-ShopdbConfig` (params override registry). No BaseUrl -> exit 0, retry
next cycle.
2. `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 (source `ShopdbEnforce`,
id 1001) plus a best-effort failure report so it is visible server-side,
then exit 0.
3. `-ShadowMode` (with `-ShareManifestPath`): `Compare-ShopdbShadow` logs
name/order diffs, engine runs against the SHARE (zero behavior change).
This is the first step of every cutover.
4. Cutover mode: optional `-IncludeCommon` merges the fleet `common` scope
(`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. Then `Resolve-ShopdbPayloads` stages http/inline payloads
(section 3).
5. Engine call (the integration point):
`& $EnginePath -ManifestPath <resolved> -PCType $Scope -InstallerRoot <payloads dir> -LogFile <log>`.
6. `ConvertTo-ShopdbSummary` normalizes whatever came back (summary object,
array of emitted objects, bare int, $null) into
`@{Installed;Skipped;Failed;Filtered;Results;EnforcerVersion}`, then
`New-ShopdbReport` maps to the lowercase wire contract and
`Send-ShopdbReport` POSTs 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:
1. **Server (authoritative):** `GET
$KioskBaseUrl/api/dashboarddefaults/display-role?fqdn=<own-fqdn>`. This is a
PUBLIC endpoint (no token). The server matches the FQDN against the
`dashboarddefaults` table (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.
2. **Fallback (offline, or unmapped):** the local
`C:\Enrollment\display-type.txt` value against the `DISPLAY_TYPE_TARGETS` map
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):
1. Writes `C:\Enrollment\display-type.txt` (the subtype) and
`C:\Enrollment\pc-type.txt` (the scope, default `gea-shopfloor-display`).
2. Writes HKLM `BaseUrl` [+ `ApiToken`] + `CollectorKey`, then locks the key
ACL to SYSTEM + Administrators.
3. Downloads runner + module + engine + collector from
`{BaseUrl}/installers/kiosk/` over HTTPS (TLS 1.2 forced).
4. Registers the two SYSTEM tasks (section 5).
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.Key` access on an absent key throws ->
route every dynamic property read through `Get-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]::MaxValue` serializes to an out-of-range
Duration the Task Scheduler XML schema rejects -> use
`-RepetitionInterval` ALONE; it defaults to indefinite repetition
(verified Win11 / PS 5.1). See `Register-SystemTask` in
`Install-ShopdbKiosk.ps1`.
- **Engine exits 2 / "InstallerRoot not found"** -> `-InstallerRoot` and
`-LogFile` are 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-ShopdbPayloads` writes the LEAF filename only, and the runner sets
`-InstallerRoot` to 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` `.Content` comes back as a `byte[]` instead of a string
-> the server route returns `mimetype='application/json'` (see
`get_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`/`.psm1` have no default MIME map -> add
`<staticContent><mimeMap fileExtension=".ps1" mimeType="text/plain" />`
(and `.psm1`) in the `installers\kiosk` web.config, with `<remove>` first
if inherited.
- **IP allowlist spoofable / mysteriously not matching** -> raw
`X-Forwarded-For` is attacker-controlled (proxies append; first hop is the
caller's to write) -> `_ip_allowlisted` uses `request.remote_addr` via
`_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.exe` opens 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\ShopDB` key ACL to SYSTEM + Administrators (the
bootstrap does), and NEVER put a token in a task's `-Argument` string -
scripts read `ApiToken`/`CollectorKey` from 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), and
`ConvertTo-ShopdbSummary` tolerates 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 `-kiosk`
arguments (the regex `-kiosk` matches both), shortcuts whose args carry
shopdb URLs (`tsgwp00525`, `/shopdb/`, the dead `shopfloor-dashboard`
route), the imaging installers' `GE Aerospace Dashboard*` / `GE Aerospace
Lobby*` shortcut names, and `.url` files 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>0` is 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-system` memory), driven by
`/home/camp/pxe-images/ednc-bins/qga-run.py` - qemu guest agent
`guest-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` (served
`application/json`), `/api/geenforce/payload/<sha>`, and capturing the
`/api/geenforce/report` POST 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:
1. `pull-shopdb-bundle.cmd` - fetch the bundle into the local clone
(ff-only).
2. `update-shopdb-github.cmd` - push the clone to GitHub.
3. `update-dev-server.cmd` - robocopy the clone to `X:` (dev `/ops` tree) +
the `/ops`-base frontend dist. Validate on `/ops` FIRST.
4. `update-prod-server.cmd` - robocopy to `Y:` (`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.
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)
1. Publish the scope(s) - `seed_display_scope(publish=True)` or
`flask geenforce publish <scope>`.
2. Seed `geenforce_allowed_cidrs` = `10.134.48.0/23,10.48.249.0/26`
(Settings rail > GE-Enforce Settings, or SQL upsert into `settings`).
3. Mint tokens (Settings > API Tokens, Restrict permissions ON):
`collector.ingest` (required, the kiosk `-CollectorKey`) and
`geenforce.fetch` (fallback for non-allowlisted subnets; resource-bind it
to the scope).
4. 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.
1. **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.
2. **Get the scope into shopdb.** Existing share manifest:
`flask geenforce parity` then `flask geenforce import-share --scope
<name>`. New/reworked scope: author in code following
`seed_display_scope.py` (registry heals with ValueMatches detection,
idempotent Always PS1s, data-driven tables for anything per-subtype).
3. **Convert payloads (share-less only).** Small scripts/configs ->
`store_inline_payload` / the entry payload upload endpoint. Installers ->
`flask geenforce add-payload <file>`, set
`PayloadSource=http` + `PayloadSha256` + `PayloadRef` on the entry.
Remember: `PayloadRef`'s extension decides the staged filename's
extension.
4. **Publish.** New version every publish; clients converge within one
enforce cycle. Verify with
`curl "{BaseUrl}/api/geenforce/manifest?pctype=<scope>"` from an
allowlisted host.
5. **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 to `geenforce_allowed_cidrs` (Settings rail validates).
Not network-trustable -> mint a `geenforce.fetch` token resource-bound to
the scope and deliver it to HKLM `ApiToken`.
6. **Bootstrap the client.** Share-attached fleet: adapt the dispatcher /
`Install-GEEnforce.ps1` path (the pilot flow in
`shopdb-migration/kiosk-api-pilot.txt`: shadow first, then flip, then
DISABLE the old share enforce task so the two do not fight). Share-less:
the `Install-ShopdbKiosk.ps1` pattern - generalize `-Scope` and 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).
7. **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.
8. **Re-read section 8 (gotchas).** Especially: LEAF filenames, StrictMode
property access, SYSTEM/session-0, task trigger serialization, MIME maps
if you host new downloadables.
9. **Verify on the VM** (section 9) before the pilot PC: bootstrap +
enforce cycle against a mock or the dev `/ops` instance, as SYSTEM via
qga-run.py.
10. **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.
11. **pc-type mapping.** Make sure the collector's pctype mapping
(computers plugin settings, `pctypemap`) covers the scope name so asset
reports do not warn `no ComputerType mapping`.
---
## 12. Open items / TODO
- **Name resolution for reported users.** `loggedinuser` lands as a bare
username; resolving it to a display name depends on either the
`wjf_employees` `First_Name`/`Last_Name` data 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_TARGETS`
points it at `/parts-kiosk`; confirm the real route with the floor team
before publishing to production 3D-print-room displays (flagged in
`seed_display_scope.py`).
- **Fast-path prod patches vs git.** The `prod-patch-geenforce` robocopy
path can leave prod ahead of the repo; reconcile by pushing the same
changes through the bundle pipeline (section 10, channel 1).