diff --git a/docs/COLLECTOR-INTEGRATION.md b/docs/COLLECTOR-INTEGRATION.md index 04e5b79..21ac939 100644 --- a/docs/COLLECTOR-INTEGRATION.md +++ b/docs/COLLECTOR-INTEGRATION.md @@ -470,8 +470,8 @@ function Get-ShopdbCorpIPv4 { # Pick the corp/AESFMA NIC IP. Same allowed-range gate as # Report-AssetToShopDB.ps1 - update the ranges if the site re-VLANs. $allowedRanges = @( - @{ Network = '10.134.48.0'; PrefixLen = 23 }, - @{ Network = '10.48.249.0'; PrefixLen = 26 } + @{ Network = '192.0.2.0'; PrefixLen = 24 }, + @{ Network = '198.51.100.0'; PrefixLen = 26 } ) function ConvertTo-Uint32([string]$ip) { $bytes = ([System.Net.IPAddress]::Parse($ip)).GetAddressBytes() @@ -495,7 +495,7 @@ function Get-ShopdbCorpIPv4 { function Send-ShopdbCollectorReport { param( - [string]$SiteHost = 'tsgwp00525.wjs.geaerospace.net', + [string]$SiteHost = 'shopdb.example.net', [string]$ApiKey = (Get-ShopdbCollectorApiKey), [int]$TimeoutSec = 30, [string]$LogFile = ('C:\Logs\Shopfloor\collector-{0}.log' -f (Get-Date -Format 'yyyyMMdd')) diff --git a/docs/PILOT-DEPLOY.md b/docs/PILOT-DEPLOY.md index b97802a..76c416c 100644 --- a/docs/PILOT-DEPLOY.md +++ b/docs/PILOT-DEPLOY.md @@ -19,7 +19,7 @@ Related: [`IMPORT-ADOPTION.md`](IMPORT-ADOPTION.md) (import model), `wjf_employees`. Take fresh dumps at import time - the classic app is live. - [ ] Target MySQL 8, utf8mb4 (charset is contract, ADR-004). Old MySQL <5.7 needs `innodb_large_prefix=ON` + Barracuda. -- [ ] Decide the pilot URL (e.g. `shopdb-pilot.wjs.geaerospace.net`) - separate +- [ ] Decide the pilot URL (e.g. `shopdb-pilot.example.net`) - separate from the classic app; do not reuse its hostname yet. - [ ] Confirm the import decisions still hold (see the loader README / the import plan): assetnumber fallback + skip-dups, metrology routing, diff --git a/docs/adr/ADR-015-site-specific-configuration.md b/docs/adr/ADR-015-site-specific-configuration.md index 1bb3ad9..d7c319e 100644 --- a/docs/adr/ADR-015-site-specific-configuration.md +++ b/docs/adr/ADR-015-site-specific-configuration.md @@ -17,9 +17,12 @@ That is not a hypothetical drift. A scan of `plugins/` and `shopdb/` for a site host, a site FQDN or a site network returns 19 hits, of which roughly 8 are a shipped default or a hardcode rather than a comment: -- `plugins/geenforce/seed_display_scope.py` bakes `https://tsgwp00525.wjs. - geaerospace.net/shopdb` and `device.geaerospace.net` into an inline PowerShell - payload. A sister site cannot override either without editing the source. +- `plugins/geenforce/seed_display_scope.py` baked one site's own ShopDB URL and + `device.geaerospace.net` into an inline PowerShell payload. A sister site + could not override either without editing the source. FIXED 2026-08-14: the + kiosk base URL now comes from the `site_base_url` setting at seed time and + from HKLM at run time, and the dispatcher refuses rather than guessing when + neither is set. - `plugins/backups/services/registry.py` defaults the backup share root to a WJ UNC path. - `shopdb/core/api/settings.py` ships `device.geaerospace.net` and diff --git a/docs/api-inventory.json b/docs/api-inventory.json index 03429f6..16b943c 100644 --- a/docs/api-inventory.json +++ b/docs/api-inventory.json @@ -349,7 +349,7 @@ "purpose": "Global search across assets, applications, KB, employees, notifications, custom fields, hostnames, IPs/subnets, vendor/model/type; ServiceNOW ticket prefixes and smart redirects; results capped at 50, types filterable via search__enabled settings", "auth": "jwt-optional", "params": "q (required, 2-200 chars)", - "example": "curl 'http://localhost:5001/api/search?q=tsgwp00525'" + "example": "curl 'http://localhost:5001/api/search?q=WKSTN0042'" }, { "method": "GET", @@ -405,7 +405,7 @@ "purpose": "Resolve the business unit for a kiosk/lobby display by FQDN first then IP (caller IP used when ipaddress omitted); null businessunitid when unmapped", "auth": "none", "params": "fqdn (optional), ipaddress (optional, defaults to client IP)", - "example": "curl 'http://localhost:5001/api/dashboarddefaults/visitor-location?fqdn=display01.wjs.geaerospace.net'" + "example": "curl 'http://localhost:5001/api/dashboarddefaults/visitor-location?fqdn=display01.example.net'" }, { "method": "GET", @@ -453,7 +453,7 @@ "purpose": "Generic collector ingest (ADR-006): schema-validated identity field, idempotent upsert via the plugin's apply_collector_payload; audit-logged; 404 when no collector registered for the plugin", "auth": "api-key (X-API-Key: per-plugin COLLECTOR_API_KEY_ or shared COLLECTOR_API_KEY, or a collector.ingest-scoped managed PAT via Bearer/X-API-Key)", "params": "body: JSON payload whose schema identityfield (e.g. hostname) is required; rest is plugin-defined", - "example": "curl -X POST http://localhost:5001/api/collector/computers -H 'X-API-Key: $KEY' -H 'Content-Type: application/json' -d '{\"hostname\":\"tsgwp00525\",\"serialnumber\":\"ABC123\"}'" + "example": "curl -X POST http://localhost:5001/api/collector/computers -H 'X-API-Key: $KEY' -H 'Content-Type: application/json' -d '{\"hostname\":\"WKSTN0042\",\"serialnumber\":\"ABC123\"}'" }, { "method": "GET", @@ -469,7 +469,7 @@ "purpose": "Legacy computers-specific ingest: update one PC matched by hostname (or asset number) - lastreporteddate, lastboottime, loggedinuser, serialnumber", "auth": "api-key", "params": "body: hostname (required), lastboottime (ISO), currentuser, serialnumber", - "example": "curl -X POST http://localhost:5001/api/collector/pc -H 'X-API-Key: $KEY' -H 'Content-Type: application/json' -d '{\"hostname\":\"tsgwp00525\",\"currentuser\":\"212345678\"}'" + "example": "curl -X POST http://localhost:5001/api/collector/pc -H 'X-API-Key: $KEY' -H 'Content-Type: application/json' -d '{\"hostname\":\"WKSTN0042\",\"currentuser\":\"212345678\"}'" }, { "method": "POST", @@ -477,7 +477,7 @@ "purpose": "Update installed applications for one PC; only apps already in the Application table are tracked, others skipped; returns created/updated/skipped counts", "auth": "api-key", "params": "body: hostname (required), apps [{appname, version}] (required)", - "example": "curl -X POST http://localhost:5001/api/collector/apps -H 'X-API-Key: $KEY' -H 'Content-Type: application/json' -d '{\"hostname\":\"tsgwp00525\",\"apps\":[{\"appname\":\"PC-DMIS\",\"version\":\"2023.2\"}]}'" + "example": "curl -X POST http://localhost:5001/api/collector/apps -H 'X-API-Key: $KEY' -H 'Content-Type: application/json' -d '{\"hostname\":\"WKSTN0042\",\"apps\":[{\"appname\":\"PC-DMIS\",\"version\":\"2023.2\"}]}'" }, { "method": "POST", @@ -1600,7 +1600,7 @@ "auth": "jwt-optional", "params": "hostname in path (exact match)", "purpose": "Get computer record by hostname", - "example": "curl 'http://localhost:5001/api/computers/by-hostname/tsgwp00525'" + "example": "curl 'http://localhost:5001/api/computers/by-hostname/WKSTN0042'" }, { "method": "POST", @@ -1904,7 +1904,7 @@ "purpose": "Record one PC's enforcement cycle: applied manifest version plus per-entry self-heal outcomes (installed/skipped/failed); returns reportid + status.", "auth": "api-key (managed service token with geenforce.report scope) OR IP allowlist", "params": "JSON body: hostname (required); remainder parsed by service.record_enforcement_report (scopename, phase, appliedversion, enforcerversion, status, per-entry results, counts); 400 on ValueError", - "example": "curl -X POST -H 'X-API-Key: $TOKEN' -H 'Content-Type: application/json' -d '{\"hostname\":\"tsgwp00525\",\"scopename\":\"cmm\",\"appliedversion\":4,\"results\":[{\"entryname\":\"7zip\",\"action\":\"installed\"}]}' http://localhost:5001/api/geenforce/report" + "example": "curl -X POST -H 'X-API-Key: $TOKEN' -H 'Content-Type: application/json' -d '{\"hostname\":\"WKSTN0042\",\"scopename\":\"cmm\",\"appliedversion\":4,\"results\":[{\"entryname\":\"7zip\",\"action\":\"installed\"}]}' http://localhost:5001/api/geenforce/report" }, { "method": "GET", @@ -2000,7 +2000,7 @@ "purpose": "Simulate which draft entries would apply to a given machine profile and why the rest are filtered out (PCTypes/TargetHostnames/TargetMachineNumbers/_CmmVersion), using the engine-mirror filters.", "auth": "jwt + permission:geenforce.manage", "params": "path: scopeid; query (all optional): pctype (defaults to scopename), subtype, hostname, machinenumber, cmmversion; phase comes from the scope", - "example": "curl -H 'Authorization: Bearer $JWT' 'http://localhost:5001/api/geenforce/scopes/3/simulate?hostname=tsgwp00525&cmmversion=2023.2'" + "example": "curl -H 'Authorization: Bearer $JWT' 'http://localhost:5001/api/geenforce/scopes/3/simulate?hostname=WKSTN0042&cmmversion=2023.2'" }, { "method": "GET", diff --git a/docs/examples/toner-dashboard.html b/docs/examples/toner-dashboard.html index 4f6726d..26e34aa 100644 --- a/docs/examples/toner-dashboard.html +++ b/docs/examples/toner-dashboard.html @@ -251,7 +251,7 @@

Base path

Instances are served under a subpath, so the API is at - https://tsgwp00525.wjs.geaerospace.net/shopdb/api/... - production on + https://shopdb.example.net/shopdb/api/... - production on this server - or /ops/api/... for the dev instance beside it, not at the domain root. Make the base a variable; do not hardcode /api.

@@ -275,7 +275,7 @@

Minimal example

-
const BASE = 'https://tsgwp00525.wjs.geaerospace.net/shopdb';   // no trailing /api
+
const BASE = 'https://shopdb.example.net/shopdb';   // no trailing /api
 
 async function lowSupplies() {
   const response = await fetch(`${BASE}/api/printers/lowsupplies`);
@@ -296,7 +296,7 @@ lowSupplies().then(data => {
 
 

Polling, with the failure cases handled

-
const BASE = 'https://tsgwp00525.wjs.geaerospace.net/shopdb';
+
const BASE = 'https://shopdb.example.net/shopdb';
 const POLL_MS = 3 * 60 * 1000;      // server caches 5 min; faster buys nothing
 
 async function tick() {
@@ -369,8 +369,8 @@ async function printerIdByIp(ip) {
 
 
- +

Enter a base URL and press Load.

diff --git a/docs/geenforce-api-cutover.md b/docs/geenforce-api-cutover.md index 1a657e4..5e07ea2 100644 --- a/docs/geenforce-api-cutover.md +++ b/docs/geenforce-api-cutover.md @@ -38,7 +38,7 @@ Contents: ## 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\`). +SFLD SMB share (`\\shopdb.example.net\shared\dt\shopfloor\`). Every PC mounts the share with Azure-DSC-provisioned SFLD credentials, reads `\manifest.json`, and runs the engine (`Install-FromManifest.ps1`). That works for the domain fleet but is a hard @@ -64,11 +64,11 @@ West Jefferson facts used throughout this doc: | Fact | Value | |------|-------| -| Prod host | `tsgwp00525.wjs.geaerospace.net` | +| Prod host | `shopdb.example.net` | | App mount | `/shopdb` (IIS, app dir `C:\inetpub\wwwroot\shopdb`, pool `shopdbflask-prod`) | -| BaseUrl clients use | `https://tsgwp00525.wjs.geaerospace.net/shopdb` | +| BaseUrl clients use | `https://shopdb.example.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) | +| Client allowlist CIDRs | `192.0.2.0/24,198.51.100.0/26` (this site s corp / shopfloor subnets - examples) | | Dev/staging instance | `/ops` mount, DB `shopdb_flask_dev`, pool `shopdbflask` | --- @@ -140,7 +140,7 @@ 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 +sends `X-Forwarded-For: 192.0.2.5` gets `remote_addr = 192.0.2.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 @@ -292,7 +292,7 @@ is checked against the CURRENT published manifest. | Value | Used by | Notes | |-------|---------|-------| -| `BaseUrl` | enforce client + kiosk dispatcher | e.g. `https://tsgwp00525.wjs.geaerospace.net/shopdb`. Required. | +| `BaseUrl` | enforce client + kiosk dispatcher | e.g. `https://shopdb.example.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). | @@ -423,7 +423,7 @@ the imaging share (`shopdb-migration/kiosk-installer/`)) is hosted at ``` Set-ExecutionPolicy Bypass -Scope Process -Force -$u = 'https://tsgwp00525.wjs.geaerospace.net/shopdb/installers/kiosk/Install-ShopdbKiosk.ps1' +$u = 'https://shopdb.example.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 @@ -537,7 +537,7 @@ real debugging time. Format: symptom -> cause -> fix. 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 + (`curl -H "X-Forwarded-For: 192.0.2.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 @@ -569,7 +569,7 @@ real debugging time. Format: symptom -> cause -> fix. 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` + shopdb URLs (`SHOPDBHOST`, `/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. @@ -662,7 +662,7 @@ automatically). PCs pick up new bytes by re-running the bootstrap one-liner. 1. Publish the scope(s) - `seed_display_scope(publish=True)` or `flask geenforce publish `. -2. Seed `geenforce_allowed_cidrs` = `10.134.48.0/23,10.48.249.0/26` +2. Seed `geenforce_allowed_cidrs` = `192.0.2.0/24,198.51.100.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 @@ -728,7 +728,7 @@ Checklist for cutting any of the remaining scopes (`gea-shopfloor-cmm`, `curl "{BaseUrl}/api/geenforce/manifest?pctype="` 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 + `192.0.2.0/24,198.51.100.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`. diff --git a/docs/llms.txt b/docs/llms.txt index 6f0e7c5..8dea40b 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -42,7 +42,7 @@ Do NOT hand-write CSV templates - generate them. User accounts are deliberately not CSV-importable. ## Base URL -Prod (West Jefferson): `https://tsgwp00525.wjs.geaerospace.net/shopdb` +Prod (West Jefferson): `https://shopdb.example.net/shopdb` All API paths are under `/api` (e.g. `/api/assets`). Dev: `http://localhost:5001`. ## Auth diff --git a/docs/openapi.json b/docs/openapi.json index 8751581..34b3c13 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -6,10 +6,6 @@ "description": "Asset-management API (core + plugins). Responses use a `success_response` envelope: `{status, data, meta}`. Auth: Bearer JWT (login or a managed PAT) for `jwt`/`admin`/`permission:*`; `X-API-Key` for collector/managed-token endpoints; public endpoints need neither." }, "servers": [ - { - "url": "https://tsgwp00525.wjs.geaerospace.net/shopdb", - "description": "WJ prod" - }, { "url": "/", "description": "relative to the deployed mount" @@ -852,7 +848,7 @@ "core-platform" ], "summary": "Global search across assets, applications, KB, employees, notifications, custom fields, hostnames, IPs/subnets, vendor/m", - "description": "Global search across assets, applications, KB, employees, notifications, custom fields, hostnames, IPs/subnets, vendor/model/type; ServiceNOW ticket prefixes and smart redirects; results capped at 50, types filterable via search__enabled settings\n\n**Auth:** jwt-optional\n\n**Params:** q (required, 2-200 chars)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/search?q=tsgwp00525'\n```", + "description": "Global search across assets, applications, KB, employees, notifications, custom fields, hostnames, IPs/subnets, vendor/model/type; ServiceNOW ticket prefixes and smart redirects; results capped at 50, types filterable via search__enabled settings\n\n**Auth:** jwt-optional\n\n**Params:** q (required, 2-200 chars)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/search?q=WKSTN0042'\n```", "security": [ { "bearerAuth": [] @@ -977,7 +973,7 @@ "core-platform" ], "summary": "Resolve the business unit for a kiosk/lobby display by FQDN first then IP (caller IP used when ipaddress omitted); null ", - "description": "Resolve the business unit for a kiosk/lobby display by FQDN first then IP (caller IP used when ipaddress omitted); null businessunitid when unmapped\n\n**Auth:** none\n\n**Params:** fqdn (optional), ipaddress (optional, defaults to client IP)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/dashboarddefaults/visitor-location?fqdn=display01.wjs.geaerospace.net'\n```", + "description": "Resolve the business unit for a kiosk/lobby display by FQDN first then IP (caller IP used when ipaddress omitted); null businessunitid when unmapped\n\n**Auth:** none\n\n**Params:** fqdn (optional), ipaddress (optional, defaults to client IP)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/dashboarddefaults/visitor-location?fqdn=display01.example.net'\n```", "security": [], "responses": { "200": { @@ -1079,7 +1075,7 @@ "core-platform" ], "summary": "Generic collector ingest (ADR-006): schema-validated identity field, idempotent upsert via the plugin's apply_collector_", - "description": "Generic collector ingest (ADR-006): schema-validated identity field, idempotent upsert via the plugin's apply_collector_payload; audit-logged; 404 when no collector registered for the plugin\n\n**Auth:** api-key (X-API-Key: per-plugin COLLECTOR_API_KEY_ or shared COLLECTOR_API_KEY, or a collector.ingest-scoped managed PAT via Bearer/X-API-Key)\n\n**Params:** body: JSON payload whose schema identityfield (e.g. hostname) is required; rest is plugin-defined\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/collector/computers -H 'X-API-Key: $KEY' -H 'Content-Type: application/json' -d '{\"hostname\":\"tsgwp00525\",\"serialnumber\":\"ABC123\"}'\n```", + "description": "Generic collector ingest (ADR-006): schema-validated identity field, idempotent upsert via the plugin's apply_collector_payload; audit-logged; 404 when no collector registered for the plugin\n\n**Auth:** api-key (X-API-Key: per-plugin COLLECTOR_API_KEY_ or shared COLLECTOR_API_KEY, or a collector.ingest-scoped managed PAT via Bearer/X-API-Key)\n\n**Params:** body: JSON payload whose schema identityfield (e.g. hostname) is required; rest is plugin-defined\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/collector/computers -H 'X-API-Key: $KEY' -H 'Content-Type: application/json' -d '{\"hostname\":\"WKSTN0042\",\"serialnumber\":\"ABC123\"}'\n```", "security": [ { "apiKeyAuth": [] @@ -1117,7 +1113,7 @@ "core-platform" ], "summary": "Legacy computers-specific ingest: update one PC matched by hostname (or asset number) - lastreporteddate, lastboottime, ", - "description": "Legacy computers-specific ingest: update one PC matched by hostname (or asset number) - lastreporteddate, lastboottime, loggedinuser, serialnumber\n\n**Auth:** api-key\n\n**Params:** body: hostname (required), lastboottime (ISO), currentuser, serialnumber\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/collector/pc -H 'X-API-Key: $KEY' -H 'Content-Type: application/json' -d '{\"hostname\":\"tsgwp00525\",\"currentuser\":\"212345678\"}'\n```", + "description": "Legacy computers-specific ingest: update one PC matched by hostname (or asset number) - lastreporteddate, lastboottime, loggedinuser, serialnumber\n\n**Auth:** api-key\n\n**Params:** body: hostname (required), lastboottime (ISO), currentuser, serialnumber\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/collector/pc -H 'X-API-Key: $KEY' -H 'Content-Type: application/json' -d '{\"hostname\":\"WKSTN0042\",\"currentuser\":\"212345678\"}'\n```", "security": [ { "apiKeyAuth": [] @@ -1136,7 +1132,7 @@ "core-platform" ], "summary": "Update installed applications for one PC; only apps already in the Application table are tracked, others skipped; return", - "description": "Update installed applications for one PC; only apps already in the Application table are tracked, others skipped; returns created/updated/skipped counts\n\n**Auth:** api-key\n\n**Params:** body: hostname (required), apps [{appname, version}] (required)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/collector/apps -H 'X-API-Key: $KEY' -H 'Content-Type: application/json' -d '{\"hostname\":\"tsgwp00525\",\"apps\":[{\"appname\":\"PC-DMIS\",\"version\":\"2023.2\"}]}'\n```", + "description": "Update installed applications for one PC; only apps already in the Application table are tracked, others skipped; returns created/updated/skipped counts\n\n**Auth:** api-key\n\n**Params:** body: hostname (required), apps [{appname, version}] (required)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/collector/apps -H 'X-API-Key: $KEY' -H 'Content-Type: application/json' -d '{\"hostname\":\"WKSTN0042\",\"apps\":[{\"appname\":\"PC-DMIS\",\"version\":\"2023.2\"}]}'\n```", "security": [ { "apiKeyAuth": [] @@ -3654,7 +3650,7 @@ "plugin-computers" ], "summary": "Get computer record by hostname", - "description": "Get computer record by hostname\n\n**Auth:** jwt-optional\n\n**Params:** hostname in path (exact match)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/computers/by-hostname/tsgwp00525'\n```", + "description": "Get computer record by hostname\n\n**Auth:** jwt-optional\n\n**Params:** hostname in path (exact match)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/computers/by-hostname/WKSTN0042'\n```", "security": [ { "bearerAuth": [] @@ -4255,7 +4251,7 @@ "plugin-geenforce" ], "summary": "Record one PC's enforcement cycle: applied manifest version plus per-entry self-heal outcomes (installed/skipped/failed)", - "description": "Record one PC's enforcement cycle: applied manifest version plus per-entry self-heal outcomes (installed/skipped/failed); returns reportid + status.\n\n**Auth:** api-key (managed service token with geenforce.report scope) OR IP allowlist\n\n**Params:** JSON body: hostname (required); remainder parsed by service.record_enforcement_report (scopename, phase, appliedversion, enforcerversion, status, per-entry results, counts); 400 on ValueError\n\n**Example:**\n```\ncurl -X POST -H 'X-API-Key: $TOKEN' -H 'Content-Type: application/json' -d '{\"hostname\":\"tsgwp00525\",\"scopename\":\"cmm\",\"appliedversion\":4,\"results\":[{\"entryname\":\"7zip\",\"action\":\"installed\"}]}' http://localhost:5001/api/geenforce/report\n```", + "description": "Record one PC's enforcement cycle: applied manifest version plus per-entry self-heal outcomes (installed/skipped/failed); returns reportid + status.\n\n**Auth:** api-key (managed service token with geenforce.report scope) OR IP allowlist\n\n**Params:** JSON body: hostname (required); remainder parsed by service.record_enforcement_report (scopename, phase, appliedversion, enforcerversion, status, per-entry results, counts); 400 on ValueError\n\n**Example:**\n```\ncurl -X POST -H 'X-API-Key: $TOKEN' -H 'Content-Type: application/json' -d '{\"hostname\":\"WKSTN0042\",\"scopename\":\"cmm\",\"appliedversion\":4,\"results\":[{\"entryname\":\"7zip\",\"action\":\"installed\"}]}' http://localhost:5001/api/geenforce/report\n```", "security": [ { "apiKeyAuth": [] @@ -4475,7 +4471,7 @@ "plugin-geenforce" ], "summary": "Simulate which draft entries would apply to a given machine profile and why the rest are filtered out (PCTypes/TargetHos", - "description": "Simulate which draft entries would apply to a given machine profile and why the rest are filtered out (PCTypes/TargetHostnames/TargetMachineNumbers/_CmmVersion), using the engine-mirror filters.\n\n**Auth:** jwt + permission:geenforce.manage\n\n**Params:** path: scopeid; query (all optional): pctype (defaults to scopename), subtype, hostname, machinenumber, cmmversion; phase comes from the scope\n\n**Example:**\n```\ncurl -H 'Authorization: Bearer $JWT' 'http://localhost:5001/api/geenforce/scopes/3/simulate?hostname=tsgwp00525&cmmversion=2023.2'\n```", + "description": "Simulate which draft entries would apply to a given machine profile and why the rest are filtered out (PCTypes/TargetHostnames/TargetMachineNumbers/_CmmVersion), using the engine-mirror filters.\n\n**Auth:** jwt + permission:geenforce.manage\n\n**Params:** path: scopeid; query (all optional): pctype (defaults to scopename), subtype, hostname, machinenumber, cmmversion; phase comes from the scope\n\n**Example:**\n```\ncurl -H 'Authorization: Bearer $JWT' 'http://localhost:5001/api/geenforce/scopes/3/simulate?hostname=WKSTN0042&cmmversion=2023.2'\n```", "security": [ { "bearerAuth": [] diff --git a/docs/proposals/ge-enforce-plugin.md b/docs/proposals/ge-enforce-plugin.md index e3094ff..4fb96b5 100644 --- a/docs/proposals/ge-enforce-plugin.md +++ b/docs/proposals/ge-enforce-plugin.md @@ -6,7 +6,7 @@ Author: planning session 2026-07-12. ## 1. What this is Today GE-Enforce is a PowerShell manifest engine that reads per-PC-type -`manifest.json` files off an SMB share (`\\tsgwp00525.wjs.geaerospace.net\ +`manifest.json` files off an SMB share (`\\shopdb.example.net\ shared\dt\shopfloor\`). Each logon, a scheduled task running as SYSTEM mounts the share, reads the manifest for the machine's PC type, and installs or self-heals apps, files, drivers, registry values, and scripts. A parallel @@ -42,7 +42,7 @@ edited JSON on a file share. Source of truth for these field names (do not invent others): -- Schema: `pxe-images/tsgwp00525-v2/shared/dt/shopfloor/_meta/manifest-schema.json` +- Schema: `pxe-images/SHOPDBHOST-v2/shared/dt/shopfloor/_meta/manifest-schema.json` - Engine: `pxe-images/common/lib/Install-FromManifest.ps1` - Dispatcher: `.../shopfloor/common/GE-Enforce.ps1` - Architecture: `pxe/docs/ge-enforce-v2-architecture.md` diff --git a/frontend/src/utils/siteSettings.js b/frontend/src/utils/siteSettings.js index 22180db..c7ec67e 100644 --- a/frontend/src/utils/siteSettings.js +++ b/frontend/src/utils/siteSettings.js @@ -119,7 +119,7 @@ export async function getServicenowUrls() { // Printer hostname template. {ip} is replaced with the dash-separated IP. export async function getPrinterHostnameTemplate() { - return getSetting('printer_hostname_template', 'Printer-{ip}.printer.geaerospace.net') + return getSetting('printer_hostname_template', 'Printer-{ip}.printer.geaerospace.net') // ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override. } // Email domain appended to a contact SSO to build email/Teams links diff --git a/frontend/src/views/SetupWizard.vue b/frontend/src/views/SetupWizard.vue index 4b94396..a4898dd 100644 --- a/frontend/src/views/SetupWizard.vue +++ b/frontend/src/views/SetupWizard.vue @@ -19,7 +19,7 @@

Used for the dashboard header and QR / absolute links.

- +
diff --git a/frontend/src/views/settings/EmailSettings.vue b/frontend/src/views/settings/EmailSettings.vue index eff3672..9daa4f0 100644 --- a/frontend/src/views/settings/EmailSettings.vue +++ b/frontend/src/views/settings/EmailSettings.vue @@ -143,7 +143,7 @@ diff --git a/frontend/src/views/settings/SiteSettings.vue b/frontend/src/views/settings/SiteSettings.vue index d1c399a..77a7722 100644 --- a/frontend/src/views/settings/SiteSettings.vue +++ b/frontend/src/views/settings/SiteSettings.vue @@ -113,7 +113,7 @@ const HELP = { usb_directory_mode: 'Where USB checkout data lives: selfhosted (tables in this app) or external (a separate cmmc_usb database via CMMC_USB_DB_* environment variables).', setup_complete: 'Set automatically when the first-run setup wizard finishes. Turning it off sends admins back to the /setup wizard on next login.', employeeid_pattern: 'Regular expression that a scanned/typed employee ID must match to be recognized. Default: ^\\d{9}$ (9 digits). An invalid regex is ignored and the default is used.', - printer_hostname_template: 'Template for generating printer hostnames from an IP. Use {ip} where the dash-separated IP goes. Example: Printer-{ip}.printer.geaerospace.net', + printer_hostname_template: 'Template for generating printer hostnames from an IP. Use {ip} where the dash-separated IP goes. Example: Printer-{ip}.printer.geaerospace.net', // ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override. contact_email_domain: 'Email domain appended to a support contact SSO to build email (sso@domain) and Microsoft Teams chat links. Example: geaerospace.com. Leave blank to hide the contact action buttons.', dualpath_single_machine: 'Treat a Dualpath pair (a dual-bay machine with one controller) as a single machine in lists, counts, and the floor map. Both bay records are always kept; detail pages stay per-bay with a sibling banner. Enter true or false. Default: true.', site_timezone: 'IANA timezone for this site. Notification start/end times are shown and entered in this zone, and daily-reset notification expiry is computed here. Default: America/New_York.' diff --git a/plugins/computers/api/routes.py b/plugins/computers/api/routes.py index 5d5b43c..1351d76 100644 --- a/plugins/computers/api/routes.py +++ b/plugins/computers/api/routes.py @@ -316,8 +316,8 @@ def list_display_kiosks(): ctype = ComputerType.query.filter_by(computertype=display_type_name).first() if not ctype: return success_response([]) - domain = (Setting.get('display_fqdn_domain', 'device.geaerospace.net') - or 'device.geaerospace.net').strip().strip('.') + domain = (Setting.get('display_fqdn_domain', 'device.geaerospace.net') # ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override. + or 'device.geaerospace.net').strip().strip('.') # ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override. rows = (db.session.query(Computer).join(Asset) .filter(Computer.computertypeid == ctype.computertypeid, Asset.isactive == True) diff --git a/plugins/geenforce/client/Install-GEEnforce.ps1 b/plugins/geenforce/client/Install-GEEnforce.ps1 index 2f4b6e6..0a6fe6c 100644 --- a/plugins/geenforce/client/Install-GEEnforce.ps1 +++ b/plugins/geenforce/client/Install-GEEnforce.ps1 @@ -65,8 +65,8 @@ .EXAMPLE .\Install-GEEnforce.ps1 -PCType gea-shopfloor-cmm -MachineNumber 0615 ` - -CmmVersion 2019 -ShareRoot \\server\share\dt\shopfloor -Site "West Jefferson" ` - -ShopdbUrl https://shopdb.site.geaerospace.net -ShopdbToken shopdb_pat_xxx ` + -CmmVersion 2019 -ShareRoot \\server\share\dt\shopfloor -Site "Main Plant" ` + -ShopdbUrl https://shopdb.example.net -ShopdbToken shopdb_pat_xxx ` -EngineSource \\server\share\dt\shopfloor\common #> [CmdletBinding()] diff --git a/plugins/geenforce/client/Invoke-ShopdbShadow.ps1 b/plugins/geenforce/client/Invoke-ShopdbShadow.ps1 index f3d9e0b..606538d 100644 --- a/plugins/geenforce/client/Invoke-ShopdbShadow.ps1 +++ b/plugins/geenforce/client/Invoke-ShopdbShadow.ps1 @@ -20,10 +20,30 @@ # Runs as SYSTEM under GE-Enforce, from an entry gated to the test bays. # Fail-safe: exits 0 on every path - shadowing must never stop a bay enforcing. +param( + # This site's ShopDB. Optional: a bay that already carries a BaseUrl in the + # registry keeps it, so the fleet needs this only on the first cycle or when + # the address changes. There is deliberately NO default - see below. + [string] $BaseUrl = '' +) + $ErrorActionPreference = 'Continue' +# RE-ENTRANCY GUARD. This entry lives IN the manifest the shadow run enforces, +# so without it the thing recurses without bound: engine runs the manifest -> +# reaches this entry -> we invoke the runner -> the runner runs the engine +# against the SAME manifest -> reaches this entry again. Measured on the test +# bays 2026-08-13: 347 nested cycles in 36 minutes, one every five seconds, +# until the share unmounted. The task-based predecessor never hit this because +# it registered a task instead of invoking the runner. +# +# An environment variable, because it is inherited by every child process and +# therefore covers the nested engine and runner without a file to clean up or a +# stale lock to age out. Set for THIS process tree only. +if ($env:SHOPDB_SHADOW_ACTIVE -eq '1') { exit 0 } +$env:SHOPDB_SHADOW_ACTIVE = '1' + $InstallDir = 'C:\Program Files\GE\Shopfloor' -$BaseUrl = 'https://tsgwp00525.wjs.geaerospace.net/shopdb' $LegacyTask = 'ShopDB GE-Enforce (shadow)' function Write-ShadowLog { @@ -66,12 +86,30 @@ try { } } - # BaseUrl only when it differs, so a hand-set value is not churned. + # THE BAY'S OWN VALUE WINS. This used to hold one site's ShopDB URL as a + # compiled-in constant and write it whenever the registry disagreed. At the + # site it was written for that reads as "heal drift"; anywhere else it reads + # as "overwrite this site's address on every enforce cycle", and the site + # cannot win because the cycle repeats. A second plant could not point its + # own bays at its own server. + # + # So: an explicitly passed -BaseUrl seeds the value, an existing registry + # value is never touched, and with neither there is nothing honest to write. $regPath = 'HKLM:\SOFTWARE\GE\ShopDB' if (-not (Test-Path $regPath)) { New-Item -Path $regPath -Force | Out-Null } - if ((Get-ItemProperty -Path $regPath -Name BaseUrl -ErrorAction SilentlyContinue).BaseUrl -ne $BaseUrl) { + $current = (Get-ItemProperty -Path $regPath -Name BaseUrl -ErrorAction SilentlyContinue).BaseUrl + if (-not $current) { + if (-not $BaseUrl) { + Write-ShadowLog "no BaseUrl in $regPath and none passed - set it, or pass -BaseUrl in the manifest entry. Skipping this cycle." + exit 0 + } Set-ItemProperty -Path $regPath -Name BaseUrl -Value $BaseUrl - Write-ShadowLog "BaseUrl set to $BaseUrl" + Write-ShadowLog "BaseUrl seeded as $BaseUrl" + } elseif ($BaseUrl -and $BaseUrl -ne $current) { + # Say it, do not do it. A changed address is a real event, and it should + # be a deliberate one, not a side effect of whatever the manifest last + # carried. + Write-ShadowLog "BaseUrl in registry is $current; -BaseUrl passed $BaseUrl. Keeping the registry value." } Write-ShadowLog "shadowing $scope against $shareManifest" @@ -83,3 +121,8 @@ catch { Write-ShadowLog "FAILED: $_" exit 0 } +finally { + # Clear it so the NEXT enforce cycle shadows again. Without this the guard + # would latch for the life of the process tree and shadow would run once. + $env:SHOPDB_SHADOW_ACTIVE = $null +} diff --git a/plugins/geenforce/client/ShopdbEnforceClient.psm1 b/plugins/geenforce/client/ShopdbEnforceClient.psm1 index 234006c..930a8f7 100644 --- a/plugins/geenforce/client/ShopdbEnforceClient.psm1 +++ b/plugins/geenforce/client/ShopdbEnforceClient.psm1 @@ -14,7 +14,7 @@ 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 + BaseUrl e.g. https://shopdb.example.net ApiToken a geenforce.fetch (+ geenforce.report) managed service token, provisioned the same way as SFLD creds (Azure DSC). #> diff --git a/plugins/geenforce/frontend/views/GeEnforceSettings.vue b/plugins/geenforce/frontend/views/GeEnforceSettings.vue index b0bcb21..06ece4f 100644 --- a/plugins/geenforce/frontend/views/GeEnforceSettings.vue +++ b/plugins/geenforce/frontend/views/GeEnforceSettings.vue @@ -15,11 +15,11 @@ class="cidr-input" rows="5" spellcheck="false" - placeholder="10.134.48.0/23 10.48.249.0/26" + placeholder="192.0.2.0/24 198.51.100.0/26" > One CIDR (or a plain IP) per line, or comma-separated. Example: - 10.134.48.0/23, 10.48.249.0/26. A caller from any of these + 192.0.2.0/24, 198.51.100.0/26. A caller from any of these networks may pull manifests + payloads and post reports with no token. diff --git a/plugins/geenforce/seed_display_scope.py b/plugins/geenforce/seed_display_scope.py index 6223e51..8291986 100644 --- a/plugins/geenforce/seed_display_scope.py +++ b/plugins/geenforce/seed_display_scope.py @@ -84,6 +84,20 @@ def _relaunch_window_targets_comment(): return pairs +def _site_base_url(): + """This site's public base URL from settings, or '' when it is unset. + + Wrapped because the script builders are also called with no application + context (the seed tests build the text and check it compiles), and a + settings lookup there raises rather than returning a default. + """ + try: + from shopdb.api import Setting + return (Setting.get('site_base_url', '') or '').strip().rstrip('/') + except Exception: + return '' + + def build_dispatcher_script(): """Return the inline dispatcher PowerShell as text. @@ -96,6 +110,11 @@ def build_dispatcher_script(): table_lines.append(f" '{display_type}' = '{route}'") table_body = ';\n'.join(table_lines) + # Baked in at seed time because the emitted script runs on a kiosk with no + # route back to the database. Blank when the site has not set it, and the + # dispatcher refuses rather than guessing - see the comment on $KioskBaseUrl. + kiosk_base_url = _site_base_url() + return f"""# Invoke-DisplayKioskDispatch.ps1 -- gea-shopfloor-display dispatcher. # # Reads C:\\Enrollment\\display-type.txt and ensures an all-users Startup @@ -122,12 +141,19 @@ $DisplayTypeTargets = @{{ # Base URL the kiosk browser opens; the route from the table is appended. Read # from HKLM (the value the GE-Enforce client is configured with) so it stays -# site-agnostic; falls back to the West Jefferson shopdb host. -$KioskBaseUrl = 'https://tsgwp00525.wjs.geaerospace.net/shopdb' +# site-agnostic. The fallback is THIS site's site_base_url setting, baked in +# when the scope was seeded - it used to be one particular plant's host, which +# meant a kiosk anywhere else with no registry value quietly opened a server it +# has no business reaching. +$KioskBaseUrl = '{kiosk_base_url}' try {{ $shopdbConfig = Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\GE\\ShopDB' -Name BaseUrl -ErrorAction Stop if ($shopdbConfig.BaseUrl) {{ $KioskBaseUrl = ([string]$shopdbConfig.BaseUrl).TrimEnd('/') }} }} catch {{}} +if (-not $KioskBaseUrl) {{ + Write-Host 'no ShopDB base URL: set HKLM:\\SOFTWARE\\GE\\ShopDB BaseUrl, or set site_base_url in Settings and re-publish the display scope.' + exit 0 +}} # PREFERRED: resolve this display's role from the server by its FQDN, so a change # in Settings > Dashboard Defaults takes effect with no reimage / local edit. The @@ -136,7 +162,7 @@ try {{ $path = $null $serial = '' try {{ $serial = ([string](Get-CimInstance -ClassName Win32_BIOS -ErrorAction Stop).SerialNumber).Trim() }} catch {{}} -$fqdnDomain = 'device.geaerospace.net' +$fqdnDomain = 'device.geaerospace.net' # ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override. try {{ $ddom = Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\GE\\ShopDB' -Name DisplayFqdnDomain -ErrorAction Stop if ($ddom.DisplayFqdnDomain) {{ $fqdnDomain = ([string]$ddom.DisplayFqdnDomain).Trim().Trim('.') }} @@ -200,8 +226,15 @@ Get-ChildItem -LiteralPath $startup -Filter '*.lnk' -ErrorAction SilentlyContinu # -kiosk matches both '-kiosk' and '--kiosk'. Also catch any browser # shortcut pointing at a shopdb kiosk URL (incl. the dead # shopfloor-dashboard route + --app variants). + # The host comes from $KioskBaseUrl, not from a literal: this used + # to name one site's server, so at any other site a legacy shortcut + # pointing at that site's own ShopDB was not recognised and never + # cleaned up. + $kioskHost = '' + try {{ $kioskHost = ([uri]$KioskBaseUrl).Host }} catch {{}} $kioskArgs = ($existing.Arguments -match '(?i)-kiosk') -or ` - ($existing.Arguments -match '(?i)tsgwp00525|/shopdb/|shopfloor-dashboard') + ($kioskHost -and $existing.Arguments -like "*$kioskHost*") -or ` + ($existing.Arguments -match '(?i)/shopdb/|shopfloor-dashboard') if ($isBrowser -and $kioskArgs) {{ $drop = $true }} }} catch {{}} }} diff --git a/plugins/printedparts/frontend/views/PartsKiosk.vue b/plugins/printedparts/frontend/views/PartsKiosk.vue index d161083..01f95b9 100644 --- a/plugins/printedparts/frontend/views/PartsKiosk.vue +++ b/plugins/printedparts/frontend/views/PartsKiosk.vue @@ -97,7 +97,7 @@ import { withBase } from '@/utils/basePath' import TouchKeypad from '@/components/TouchKeypad.vue' // Leading text on the physical labels, shown before the number box so operators -// type only the digits. It was hardcoded to 'WJ', which is West Jefferson's tag +// type only the digits. It was hardcoded to 'WJ', which is one site's tag // format and nobody else's - on another site's kiosk it told operators to expect // letters that are not on their labels. Display only: the lookup sends whatever // was typed, unprefixed. diff --git a/plugins/printedparts/plugin.py b/plugins/printedparts/plugin.py index 1ceaa15..9e75d2d 100644 --- a/plugins/printedparts/plugin.py +++ b/plugins/printedparts/plugin.py @@ -136,8 +136,8 @@ class PrintedpartsPlugin(BasePlugin): 'description': 'Leading text on the physical gage-lab labels, ' 'shown at the kiosk before the number box so ' 'operators type only the digits. Empty shows no ' - 'prefix. Site-specific: West Jefferson labels ' - 'read WJ', + 'prefix. Site-specific: set it to whatever this ' + "site's labels are printed with, e.g. WJ", }, { 'key': 'printedparts_default_threshold', diff --git a/scripts/check-naming-and-style.sh b/scripts/check-naming-and-style.sh index 95a5181..61eca2f 100755 --- a/scripts/check-naming-and-style.sh +++ b/scripts/check-naming-and-style.sh @@ -140,21 +140,40 @@ fi # cannot see and did not choose. Use a setting with a NEUTRAL default, a # site-namespaced directory (scripts/site_imports//), or seed data. # -# REPORT ONLY for now: this prints findings and does not fail the build, so the -# existing backlog can be worked off deliberately rather than blocking every -# commit the day it lands. Flip SITE_LITERALS_ENFORCE to 1 to make it a gate. -SITE_LITERALS_ENFORCE=${SITE_LITERALS_ENFORCE:-0} +# ENFORCING. It was report-only while the backlog was worked off, and the hit +# count then did not move for weeks - a rule that only prints is read as no rule. +# Set SITE_LITERALS_ENFORCE=0 to drop back to reporting for a local run. +SITE_LITERALS_ENFORCE=${SITE_LITERALS_ENFORCE:-1} echo "==> Checking for site-specific literals in product code (ADR-015)..." -# Deliberately narrow: a real site hostname, a site FQDN, or a site network. -# Fleet-wide vocabulary (gea-shopfloor-*) is NOT matched - it is overridable +# A real site hostname, a site FQDN, a site name or a site network. +# +# SCOPE. It used to look at .py/.vue/.js under plugins/ and shopdb/ only, and +# every literal that actually reached a second site was somewhere else: the +# PowerShell clients, the installer, the seeds, generated JSON. Case-sensitive +# too, so Tsgwp00525 passed. Both fixed - the scan is only worth having where +# the leaks are. +# +# Fleet-wide vocabulary (gea-shopfloor-*) is NOT matched: it is overridable # through the pctypemap settings and is not one site's data. +# +# A line may declare itself deliberate with a trailing `ADR-015-OK: ` +# marker. That is for an organisation-wide default that is genuinely right for +# every site and configurable anyway - not for "we will fix it later". The +# marker makes the claim visible in review; silence would not. SITE_PATTERNS='tsgwp00525|\.geaerospace\.net|\bwjs\b|West Jefferson|10\.134\.48\.|10\.48\.249\.' -SITE_HITS=$(grep -rPn "$SITE_PATTERNS" "${EXCLUDES[@]}" \ +SITE_HITS=$(grep -rPni "$SITE_PATTERNS" "${EXCLUDES[@]}" \ --include='*.py' --include='*.vue' --include='*.js' \ - plugins/ shopdb/ 2>/dev/null \ + --include='*.ps1' --include='*.psm1' --include='*.sh' --include='*.iss' \ + --include='*.json' --include='*.html' \ + plugins/ shopdb/ scripts/ deploy/ frontend/src/ tools/ 2>/dev/null \ | grep -v '/tests\?/' \ - | grep -v 'site_imports/' || true) + | grep -v 'site_imports/' \ + | grep -v 'installer/bundle/' \ + | grep -v '\.plugins-staged/' \ + | grep -v 'check-naming-and-style\.sh' \ + | grep -v 'export-github\.sh' \ + | grep -v 'ADR-015-OK' || true) if [ -n "$SITE_HITS" ]; then COUNT=$(echo "$SITE_HITS" | wc -l) if [ "$SITE_LITERALS_ENFORCE" = "1" ]; then diff --git a/scripts/gen_openapi.py b/scripts/gen_openapi.py index 638cebd..91fc0cf 100644 --- a/scripts/gen_openapi.py +++ b/scripts/gen_openapi.py @@ -20,6 +20,23 @@ OUT = os.path.join(REPO, 'docs', 'openapi.json') VERBS = ('get', 'post', 'put', 'patch', 'delete', 'head', 'options') +def _servers(): + """Servers block for the spec: the relative mount, plus this site's own URL + if one was supplied. + + SHOPDB_PUBLIC_URL is read from the environment rather than stored, because + the generated spec is published to a public wiki - a site URL baked into the + generator ends up in everyone's documentation, including sites it is wrong + for. + """ + servers = [] + siteurl = (os.environ.get('SHOPDB_PUBLIC_URL') or '').strip().rstrip('/') + if siteurl: + servers.append({'url': siteurl, 'description': 'this site'}) + servers.append({'url': '/', 'description': 'relative to the deployed mount'}) + return servers + + def _product_version(): """Read __version__ out of shopdb/__init__.py without importing the app. @@ -94,10 +111,12 @@ def build(surfaces): '`X-API-Key` for collector/managed-token endpoints; public endpoints ' 'need neither.'), }, - 'servers': [ - {'url': 'https://tsgwp00525.wjs.geaerospace.net/shopdb', 'description': 'WJ prod'}, - {'url': '/', 'description': 'relative to the deployed mount'}, - ], + # One site's production hostname was hardcoded here, so every generated + # spec published it to the public wiki and offered a second site a + # server it cannot reach. The relative mount is the only server this + # generator can honestly name; a site that wants its own in the spec + # sets SHOPDB_PUBLIC_URL when regenerating. + 'servers': _servers(), 'components': {'securitySchemes': { 'bearerAuth': {'type': 'http', 'scheme': 'bearer', 'bearerFormat': 'JWT', 'description': 'Login token or a managed Personal Access Token (scoped).'}, diff --git a/shopdb/core/api/settings.py b/shopdb/core/api/settings.py index 2f1acdb..b98c5ec 100644 --- a/shopdb/core/api/settings.py +++ b/shopdb/core/api/settings.py @@ -2,7 +2,7 @@ import os -from flask import Blueprint, request, current_app, send_from_directory +from flask import Blueprint, request, current_app from flask_jwt_extended import jwt_required, get_jwt_identity from werkzeug.utils import secure_filename @@ -11,6 +11,7 @@ from shopdb.core.models import Setting, AuditLog from shopdb.utils.responses import success_response, error_response, ErrorCodes from shopdb.utils.authz import require_permission, require_role +from shopdb.utils.uploads import send_upload settings_bp = Blueprint('settings', __name__) @@ -189,7 +190,7 @@ def upload_map_blueprint(): @settings_bp.route('/map-blueprint/', methods=['GET']) def serve_map_blueprint(filename): """Serve an uploaded blueprint image (public - kiosks read it).""" - return send_from_directory(_map_dir(), filename) + return send_upload(_map_dir(), filename) @settings_bp.route('/branding-logo', methods=['POST']) @@ -234,7 +235,7 @@ def upload_branding_logo(): @settings_bp.route('/branding/', methods=['GET']) def serve_branding_logo(filename): """Serve an uploaded branding logo (public - kiosks/print pages read it).""" - return send_from_directory(_branding_dir(), filename) + return send_upload(_branding_dir(), filename) @settings_bp.route('', methods=['GET']) @@ -520,10 +521,10 @@ def build_default_settings(): }, { 'key': 'pc_access_domain', - 'value': 'device.geaerospace.net', + 'value': 'device.geaerospace.net', # ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override. 'valuetype': 'string', 'category': 'site', - 'description': 'Domain appended to a PC hostname to build remote-access links (host.device.geaerospace.net). Blank = use the hostname as-is.' + 'description': 'Domain appended to a PC hostname to build remote-access links (host.device.geaerospace.net). Blank = use the hostname as-is.' # ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override. }, { 'key': 'employeeid_pattern', @@ -541,7 +542,7 @@ def build_default_settings(): }, { 'key': 'printer_hostname_template', - 'value': 'Printer-{ip}.printer.geaerospace.net', + 'value': 'Printer-{ip}.printer.geaerospace.net', # ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override. 'valuetype': 'string', 'category': 'site', 'description': 'Template for a printer hostname built from its IP. {ip} is the dash-separated IP address.' diff --git a/shopdb/core/models/dashboarddefault.py b/shopdb/core/models/dashboarddefault.py index 711f9aa..6a3d805 100644 --- a/shopdb/core/models/dashboarddefault.py +++ b/shopdb/core/models/dashboarddefault.py @@ -50,10 +50,10 @@ def normalize_display_role(value): return LEGACY_DISPLAY_ROLES.get(lowered) # GE device naming: a PC's DNS name is 'F' + its BIOS serial under the device -# domain, e.g. FABC1234.device.geaerospace.net. The domain is a setting so other +# domain, e.g. FABC1234.device.geaerospace.net. The domain is a setting so other # ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override. # sites can point elsewhere; the collector already reports the serial, so the # server derives the stable FQDN without the kiosk having to report it. -DEFAULT_DISPLAY_FQDN_DOMAIN = 'device.geaerospace.net' +DEFAULT_DISPLAY_FQDN_DOMAIN = 'device.geaerospace.net' # ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override. def derive_display_fqdn(serialnumber): diff --git a/shopdb/utils/mysql_charset.py b/shopdb/utils/mysql_charset.py index b5f071e..0e020e6 100644 --- a/shopdb/utils/mysql_charset.py +++ b/shopdb/utils/mysql_charset.py @@ -5,8 +5,8 @@ purpose: the hook has to be registered before any CreateTable is compiled, and every place that creates tables needs it. WHY. Without it a CREATE TABLE inherits the SERVER's default charset. A MySQL box -defaulting to latin1 - common on older installs, and the West Jefferson 5.6 -server is one - silently builds a latin1 schema that drifts from the utf8mb4 +defaulting to latin1 - common on older installs, and the 5.6 server this was +ported from is one - silently builds a latin1 schema that drifts from the utf8mb4 production target. Nothing fails at create time; it surfaces later as mangled characters, or as a join between a utf8mb4 and a latin1 column that cannot use an index. diff --git a/tests/test_docs_publishable.py b/tests/test_docs_publishable.py index 84f4339..c5dbc61 100644 --- a/tests/test_docs_publishable.py +++ b/tests/test_docs_publishable.py @@ -49,6 +49,14 @@ FORBIDDEN = [ ('root' + 'password', 'contains a database root password'), (r'\b' + 'cla' + 'ude' + r'\b', 'names an LLM assistant'), (r'\b' + 'anthro' + 'pic' + r'\b', 'names an LLM vendor'), + # ADR-015. The wiki is public and the product is multi-site: one plant's + # server name, its FQDN or its internal networks are neither ours to publish + # nor meaningful to any other site reading these pages. Assembled from + # fragments for the same reason as the terms above. + ('tsg' + 'wp00525', 'names a production server'), + (r'\bwjs\.' + r'geaerospace\.net\b', 'names a site FQDN'), + (r'10\.134\.48\.', 'names an internal network'), + (r'10\.48\.249\.', 'names an internal network'), ] # Generated API surface. Not prose, not hand-edited, and regenerated from the diff --git a/tools/export-github.sh b/tools/export-github.sh index 0df5317..530a814 100755 --- a/tools/export-github.sh +++ b/tools/export-github.sh @@ -138,7 +138,12 @@ fi sed -i "s/rootpassword/changeme/g" shopdb/config.py 2>/dev/null || true # --- 3. scrub gate: refuse to commit if anything internal leaks --- -LEAKS=$(grep -rlIiE 'claude|anthropic|fable 5|gitea|proudtech|home/camp|rootpassword' \ +# The site patterns are ADR-015's, and they belong here as much as the internal +# tooling names do: this repository is public and the product is multi-site, so +# one plant's server name or internal networks reaching it is both a disclosure +# and a lie to every other site. They were absent, and a bundled plugin shipped +# a production UNC path to GitHub for a fortnight before anyone noticed. +LEAKS=$(grep -rlIiE 'claude|anthropic|fable 5|gitea|proudtech|home/camp|rootpassword|tsgwp00525|wjs\.geaerospace\.net|10\.134\.48\.|10\.48\.249\.' \ --exclude-dir=.git . || true) if [ -n "$LEAKS" ]; then echo "SCRUB GATE FAILED - internal references in:"; echo "$LEAKS"; exit 1