A token scoped to the new collector.ingest permission is a collector service token: the collector endpoints accept it via X-API-Key or Bearer alongside the env fleet keys (which remain the fallback), giving the fleet credential rotation, revocation, and last-used visibility from the API Tokens page. Containment holds both ways: a collector token authorizes nothing else, and no other credential gains collector access. Shared token validation refactored out of the auth shim; a Collector service token quick-preset in the create modal; integration guide documents minting, rotation via site-config.json, and the service-identity pattern. 765 tests pass; live acceptance matrix verified. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
611 lines
30 KiB
Markdown
611 lines
30 KiB
Markdown
# Collector integration (PC auto-update)
|
|
|
|
How the shopfloor PC fleet pushes inventory into shopdb-flask, replacing the
|
|
classic ASP `api.asp?action=updateCompleteAsset` path. The generic collector
|
|
contract is defined in ADR-006; this doc is the operational reference for wiring
|
|
a real caller (the GE-Enforce fleet agent) to it.
|
|
|
|
- Server code: `shopdb/core/api/collector.py`
|
|
- Computers schema + upsert: `plugins/computers/plugin.py`
|
|
(`get_collector_schema` / `apply_collector_payload`)
|
|
- Contract rationale: `docs/adr/ADR-006-collector-contract.md`
|
|
|
|
---
|
|
|
|
## 1. How it works now
|
|
|
|
### Auth model (header-only, env keys, fail-closed)
|
|
|
|
- The API key travels in the `X-API-Key` request header. Nothing else is
|
|
accepted. The old `?api_key=<key>` querystring fallback has been removed on
|
|
every collector endpoint (see the breaking-change section below).
|
|
- Keys come from environment variables (never the database, never a file the app
|
|
serves):
|
|
- `COLLECTOR_API_KEY_<PLUGINNAME>` - per-plugin override, uppercased plugin
|
|
name (e.g. `COLLECTOR_API_KEY_COMPUTERS`).
|
|
- `COLLECTOR_API_KEY` - shared fallback used when no per-plugin key is set.
|
|
- Resolution order per request: per-plugin key first, then the shared key
|
|
(`_plugin_api_key` in `collector.py`).
|
|
- Fail-closed: if neither variable is set server-side, the endpoint returns
|
|
HTTP 500 `Collector API key not configured` and rejects every request. An
|
|
unconfigured server never silently accepts unauthenticated data.
|
|
- A caller that sends the wrong key (or no key) gets HTTP 401 `Invalid API key`.
|
|
- In addition to the env keys, a managed API token scoped to `collector.ingest`
|
|
is accepted as a collector credential on every collector endpoint. See
|
|
"Managed collector tokens" below; env keys remain the fallback.
|
|
|
|
### Managed collector tokens (recommended)
|
|
|
|
Alongside the env keys, every collector endpoint (`/api/collector/<plugin>`,
|
|
`/pc`, `/apps`, `/heartbeat`, `/bulk`, `/status`) also accepts a **managed API
|
|
token** (PAT) scoped to the `collector.ingest` permission. The env keys stay
|
|
supported as a bootstrap/legacy fallback - nothing breaks - but a managed token
|
|
is the preferred credential because it can be minted, rotated, and revoked from
|
|
the UI (Settings > API Tokens) and its use shows up in `lastusedat` and the
|
|
audit log.
|
|
|
|
What makes a token a collector service token: it is scoped to ONLY
|
|
`collector.ingest`. That scope authorizes the collector ingest API and NOTHING
|
|
else. The existing scoped-token machinery contains it automatically - a scoped
|
|
token passes `require_permission` only for its listed permissions and is denied
|
|
on every role-gated (`require_role`) endpoint and on import mode, and
|
|
`collector.ingest` gates no normal route. So a collector token that leaks cannot
|
|
be used to read or write anything through the regular API; it can only submit
|
|
collector payloads.
|
|
|
|
Both wire transports are accepted (send whichever is convenient; GE-Enforce
|
|
sends `X-API-Key` today, so that stays ergonomic):
|
|
|
|
```
|
|
POST /api/collector/computers
|
|
X-API-Key: shopdb_pat_<40 hex>
|
|
```
|
|
or
|
|
```
|
|
POST /api/collector/computers
|
|
Authorization: Bearer shopdb_pat_<40 hex>
|
|
```
|
|
|
|
An unscoped PAT, or a PAT scoped to some other permission, is NOT a collector
|
|
token and is rejected (401) - only `collector.ingest` in the scope list counts.
|
|
A revoked or expired token is rejected (401) on both transports.
|
|
|
|
#### How to mint one (admin flow)
|
|
|
|
The simplest contained flow: an **admin** mints the token, scoped to
|
|
`collector.ingest`. Because the token is scoped, the admin-role bypass is
|
|
suspended for it, so the token is contained to the collector API even though its
|
|
owner is an admin - it cannot act with admin authority anywhere.
|
|
|
|
1. Settings > API Tokens > New Token.
|
|
2. Click the **Collector service token** preset (pre-selects only
|
|
`collector.ingest`), name it (e.g. `wj-fleet-collector`), optionally set an
|
|
expiry, Create.
|
|
3. Copy the `shopdb_pat_...` secret (shown once) and deploy it to the fleet the
|
|
same way as the env key: the `collectorApiKey` field in per-site
|
|
`site-config.json` (see "Delivering the API key to clients" below). The
|
|
client sends it in `X-API-Key` exactly as it sends an env key today - no
|
|
client code change.
|
|
|
|
Service identity (documented, not built): if you prefer a non-admin owner,
|
|
create a dedicated low-privilege user (e.g. `svc-collector`) whose role holds
|
|
only `collector.ingest`, plus `apitokens.create` if that user is to mint its own
|
|
token. The scope ceiling then caps any token it mints at `collector.ingest`.
|
|
The admin-minted route above is simpler and equally contained, so it is the
|
|
recommended default.
|
|
|
|
#### Rotation
|
|
|
|
Managed tokens rotate without a fleet re-image:
|
|
|
|
1. Mint a new collector token (steps above).
|
|
2. Deploy it via `site-config.json` (`collectorApiKey`) - update the one per-site
|
|
value.
|
|
3. Confirm the new token is in use: watch its `lastusedat` climb in Settings >
|
|
API Tokens (and the old token's `lastusedat` go stale).
|
|
4. Revoke the old token once traffic has moved. Revocation is immediate.
|
|
|
|
### Generic endpoint contract: `POST /api/collector/<plugin>`
|
|
|
|
One dynamic route serves every enabled plugin that returns a collector schema.
|
|
For the PC fleet that is `POST /api/collector/computers`.
|
|
|
|
Request flow inside `generic_collect`:
|
|
|
|
1. Look up the plugin's schema. Unknown / disabled plugin -> HTTP 404
|
|
`No collector registered for plugin <plugin>`.
|
|
2. Resolve and check the API key (per-plugin then shared, as above).
|
|
3. Parse the JSON body. Missing or non-object body -> HTTP 400 `No data provided`.
|
|
4. Identity-field resolution: the schema names an `identityfield`
|
|
(`hostname` for computers). If that field is missing or blank in the payload,
|
|
HTTP 400 `<identityfield> is required`.
|
|
5. Idempotent upsert: the plugin's `apply_collector_payload` finds the existing
|
|
asset by identity and updates it, or inserts a new one. Same identity on a
|
|
later submission updates the same row - re-imaging a PC does not duplicate it,
|
|
and existing asset relationships are preserved.
|
|
6. Audit logging: every accepted submission writes an `AuditLog` row
|
|
(`action` = created/updated, entity `Collector`, `details = collector:<plugin>
|
|
action=<action>`), then commits.
|
|
|
|
Response body (HTTP 200), wrapped in the standard envelope
|
|
`{status, data, message, meta}`, with the collector result under `data`:
|
|
|
|
```json
|
|
{
|
|
"status": "success",
|
|
"data": {
|
|
"status": "ok",
|
|
"action": "created",
|
|
"assetid": 12345,
|
|
"identityvalue": "WJRP2335",
|
|
"warnings": ["unknown operating system: Microsoft Windows 11 Enterprise 23H2 (build 22631)"]
|
|
},
|
|
"message": "computers collector created",
|
|
"meta": { "timestamp": "...", "requestid": "..." }
|
|
}
|
|
```
|
|
|
|
`action` is `created`, `updated`, or `noop`. `warnings` is a list of soft
|
|
problems (unmapped pc-type, unknown OS, unknown app, un-stored pcsubtype) that
|
|
did NOT fail the request - the row was still written.
|
|
|
|
### Error responses
|
|
|
|
Errors use the same envelope with `status: "error"` and the detail under
|
|
`data.error`:
|
|
|
|
```json
|
|
{ "status": "error", "data": { "error": { "code": "UNAUTHORIZED", "message": "Invalid API key" } }, "meta": { } }
|
|
```
|
|
|
|
The plugin can raise `ValueError` for controlled validation failures; its message
|
|
is returned verbatim (HTTP 400). Any other exception is caught, logged
|
|
server-side, the transaction is rolled back, and the caller gets a generic
|
|
HTTP 500 `Internal error processing collector payload` with no internal detail
|
|
(by design - check the server log to see what actually failed).
|
|
|
|
### Schema discovery: `GET /api/collector/_schemas`
|
|
|
|
Returns the collector schema for every enabled plugin. This route is
|
|
JWT-protected (an interactive/admin token), NOT API-key protected - it is for
|
|
humans and tooling introspecting what payloads are accepted, not for the
|
|
headless collectors themselves.
|
|
|
|
### Legacy endpoints (computers-only, predate ADR-006)
|
|
|
|
These still exist for the older PowerShell callers and all require the same
|
|
`X-API-Key` header:
|
|
|
|
| Endpoint | Purpose |
|
|
|---|---|
|
|
| `POST /api/collector/pc` | Update one PC matched by `hostname` (lastboot, user, serial). |
|
|
| `POST /api/collector/apps` | Update installed apps for a PC (known apps only). |
|
|
| `POST /api/collector/heartbeat` | Record check-in for one or many hostnames. |
|
|
| `POST /api/collector/bulk` | Update many PCs in one call. |
|
|
| `GET /api/collector/status` | Liveness + endpoint list. |
|
|
|
|
New integrations should target `POST /api/collector/computers`, not these. Per
|
|
ADR-006 the legacy `/pc` path is deprecated and slated for removal before v1.0.
|
|
|
|
### Computers plugin field mapping
|
|
|
|
Payload naming follows the project convention (lowercase concatenated). The
|
|
identity field is `hostname`. All other fields are optional; an omitted field
|
|
leaves the existing value untouched (patch-style), so a bare report never blanks
|
|
a column.
|
|
|
|
| Payload field | Type | Server behaviour (`apply_collector_payload`) |
|
|
|---|---|---|
|
|
| `hostname` (required) | string | Identity. Matches `Computer.hostname` (case-insensitive), then falls back to `Asset.assetnumber`. New asset created if no match. |
|
|
| `machinenumber` | string | Business tag -> `Asset.assetnumber`. The placeholder `9999` and empty string are skipped; when skipped a new PC falls back to `assetnumber = hostname`. On an existing PC a real value updates `assetnumber`. |
|
|
| `pctype` | string | `gea-shopfloor-*` imaging type -> `Computer.computertypeid` via the configurable `pctypemap` settings. Unmapped value -> warning, not error. |
|
|
| `pcsubtype` | string | Accepted but not stored yet -> warning. |
|
|
| `serialnumber` | string | `Asset.serialnumber`. |
|
|
| `loggedinuser` | string | `Computer.loggedinuser`. (`currentuser` is also accepted as a legacy alias.) |
|
|
| `lastboottime` | ISO-8601 datetime | `Computer.lastboottime`. Unparseable -> warning. |
|
|
| `lastcheckin` | ISO-8601 datetime | Accepted (heartbeat semantics). `lastreporteddate` is set server-side on every call regardless. |
|
|
| `ipaddress` | string | Primary `Communication` row (`isprimary=True`, type `IP`). Updated in place or created. |
|
|
| `vendorname` | string | `Computer.vendorid`. Vendor row auto-created if missing (free vocab). |
|
|
| `modelnumber` | string | `Computer.modelnumberid`, scoped to the vendor when known. Model row auto-created if missing. |
|
|
| `osname` | string | `Computer.osid`. Controlled vocab: looked up in `operatingsystems`, NOT auto-created. Unknown value -> warning (row still written, `osid` left unset). |
|
|
| `installedsoftware` | array of `{name, version}` | `ComputerInstalledApp` rows for applications shopdb already tracks. Unknown app name -> warning, skipped. |
|
|
|
|
Schema source of truth: `get_collector_schema` in `plugins/computers/plugin.py`.
|
|
If you change the payload, change it there and re-check this table.
|
|
|
|
### pc-type mapping (configurable per site)
|
|
|
|
`pctype` (e.g. `gea-shopfloor-cmm`) maps to a shopdb Computer Type through
|
|
`pctypemap_<pxetype>` settings (Settings > Collector PC Types).
|
|
Defaults live in `plugins/computers/pctypemap.py` and are seeded on plugin
|
|
install; edit per site in the UI. Unmapped pc-types produce a warning, not a
|
|
failure.
|
|
|
|
### Classic api.asp field mapping (for porting the PowerShell reporter)
|
|
|
|
The fleet's classic-ASP reporter posts form fields to
|
|
`api.asp?action=updateCompleteAsset`. Map them to the collector JSON as follows:
|
|
|
|
| Classic `updateCompleteAsset` form field | Collector field |
|
|
|---|---|
|
|
| `hostname` | `hostname` |
|
|
| `machineNo` | `machinenumber` |
|
|
| `pcType` | `pctype` |
|
|
| `serialNumber` | `serialnumber` |
|
|
| `loggedInUser` | `loggedinuser` |
|
|
| `lastBootUpTime` / `lastBootTime` | `lastboottime` |
|
|
| `manufacturer` | `vendorname` |
|
|
| `model` | `modelnumber` |
|
|
| `osVersion` | `osname` |
|
|
| `installedApps` | `installedsoftware` |
|
|
|
|
Not carried over (no current home in the computers schema): warranty fields, DNC
|
|
config, multi-NIC detail beyond the single primary IP, VNC/WinRM flags. The
|
|
classic reporter posts a full `networkInterfaces` array; the collector accepts
|
|
only one `ipaddress`, so pick the corp/routable NIC (see the corp-range gate in
|
|
the PowerShell below).
|
|
|
|
---
|
|
|
|
## 2. Breaking change: querystring `api_key` removed
|
|
|
|
The API key must now be sent in the `X-API-Key` header. The `?api_key=<key>`
|
|
querystring form has been removed on every collector endpoint
|
|
(`/api/collector/<plugin>`, `/pc`, `/apps`, `/heartbeat`, `/bulk`, `/status`).
|
|
Querystring keys leak into web-server access logs, proxy history, and browser
|
|
history. Header-only keeps the secret out of those logs.
|
|
|
|
Before (no longer works - the key is ignored and the request is rejected 401):
|
|
|
|
```
|
|
POST /api/collector/computers?api_key=SECRET
|
|
Content-Type: application/json
|
|
|
|
{ "hostname": "WJRP2335", "machinenumber": "2335" }
|
|
```
|
|
|
|
After (correct):
|
|
|
|
```
|
|
POST /api/collector/computers
|
|
X-API-Key: SECRET
|
|
Content-Type: application/json
|
|
|
|
{ "hostname": "WJRP2335", "machinenumber": "2335" }
|
|
```
|
|
|
|
PowerShell before/after:
|
|
|
|
```powershell
|
|
# BEFORE (broken)
|
|
Invoke-RestMethod -Uri "https://$SiteHost/api/collector/computers?api_key=$key" `
|
|
-Method Post -Body $json -ContentType 'application/json'
|
|
|
|
# AFTER (correct)
|
|
Invoke-RestMethod -Uri "https://$SiteHost/api/collector/computers" `
|
|
-Method Post -Body $json -ContentType 'application/json' `
|
|
-Headers @{ 'X-API-Key' = $key }
|
|
```
|
|
|
|
Any caller still putting `api_key` in the URL must move it to the header.
|
|
|
|
---
|
|
|
|
## 3. GE-Enforce implementation guide
|
|
|
|
GE-Enforce is the SYSTEM-context fleet agent that runs every enforcement cycle on
|
|
each shopfloor PC (scheduled task, at-logon + periodic). It lives OUTSIDE this
|
|
repo. The facts below are drawn from the real scripts so the payload matches what
|
|
the PC already knows:
|
|
|
|
- `playbook/shopfloor-setup/common/GE-Enforce.ps1` - the enforcement pass. At the
|
|
end of each run it writes a status JSON to
|
|
`<share>\_outputs\logs\<hostname>\status.json` (interim transport; the fleet
|
|
dashboard reads those files). It logs via `Write-EnforceLog` to
|
|
`C:\Logs\Shopfloor\enforce-YYYYMMDD.log`. Its machine-number resolution is:
|
|
registry `HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\Dnc\General` then
|
|
`HKLM:\SOFTWARE\GE Aircraft Engines\Dnc\General`, property `MachineNo`,
|
|
skipping the `9999` placeholder, then `C:\Enrollment\machine-number.txt` as
|
|
fallback. hostname is `[System.Environment]::MachineName` (live NetBIOS name,
|
|
not `$env:COMPUTERNAME`, which goes stale after a post-image rename).
|
|
- `playbook/shopfloor-setup/gea-shopfloor-collections/Report-AssetToShopDB.ps1` -
|
|
the existing HTTP reporter that POSTs to the classic ASP
|
|
`api.asp?action=updateCompleteAsset`. It runs every cycle as a `Type=PS1`
|
|
manifest entry (DetectionMethod `Always`) under the SYSTEM task, logs to
|
|
`C:\Logs\Shopfloor\report-asset-YYYYMMDD.log`, always exits 0, and reads the
|
|
same machine number (reg, then `cmm\cmmid.txt`, then `machine-number.txt`) plus
|
|
BIOS serial, OS caption, make/model, logged-in user, and corp-NIC IP from WMI.
|
|
The function below is the direct-to-flask analogue of this reporter.
|
|
- `playbook/shopfloor-setup/Shopfloor/lib/Update-MachineNumber.ps1` - keeps the
|
|
reg `MachineNo` and `C:\Enrollment\machine-number.txt` in sync when a tech
|
|
reassigns a bay, so both sources agree.
|
|
|
|
### Paste-ready reporter
|
|
|
|
Drop this in as a `Type=PS1` / DetectionMethod `Always` manifest entry (mirroring
|
|
`Report-AssetToShopDB.ps1`), or dot-source `Send-ShopdbCollectorReport` from
|
|
GE-Enforce.ps1 and call it at the end of the pass. It reads the machine number
|
|
exactly the way GE-Enforce already does, builds a payload matching the computers
|
|
collector schema, and POSTs with the `X-API-Key` header over TLS 1.2. Every
|
|
field name below was checked against `get_collector_schema` in
|
|
`plugins/computers/plugin.py`.
|
|
|
|
The `X-API-Key` value can be EITHER a `COLLECTOR_API_KEY[_COMPUTERS]` env key OR
|
|
a managed token scoped to `collector.ingest` (a `shopdb_pat_...` secret; see
|
|
"Managed collector tokens"). The script is identical for both - it just carries
|
|
whatever `collectorApiKey` the site-config supplies - so switching a site from an
|
|
env key to a managed token (and rotating it) is a config change, not a script
|
|
change.
|
|
|
|
```powershell
|
|
# Send-ShopdbCollectorReport.ps1
|
|
# Reports this PC's identity to shopdb-flask via POST /api/collector/computers.
|
|
# Runs as SYSTEM from the GE-Enforce cycle. Always exits without throwing;
|
|
# failures are logged, never fatal.
|
|
|
|
function Get-ShopdbCollectorApiKey {
|
|
# NEVER hardcode the key in this script (it lives on the share). Read it
|
|
# from a per-site value the SYSTEM/machine account can already reach.
|
|
# Preferred: a field in the site-config.json GE-Enforce already parses.
|
|
# Fallback: a machine-local file staged at imaging, ACL'd to SYSTEM.
|
|
$key = ''
|
|
$siteCfg = 'C:\Enrollment\site-config.json'
|
|
if (Test-Path -LiteralPath $siteCfg) {
|
|
try {
|
|
$cfg = Get-Content -LiteralPath $siteCfg -Raw | ConvertFrom-Json
|
|
if ($cfg.collectorApiKey) { $key = "$($cfg.collectorApiKey)".Trim() }
|
|
} catch {}
|
|
}
|
|
if (-not $key) {
|
|
$keyFile = 'C:\Enrollment\collector-api-key.txt'
|
|
if (Test-Path -LiteralPath $keyFile) {
|
|
try { $key = (Get-Content -LiteralPath $keyFile -First 1 -ErrorAction Stop).Trim() } catch {}
|
|
}
|
|
}
|
|
return $key
|
|
}
|
|
|
|
function Get-ShopdbMachineNumber {
|
|
# Same resolution GE-Enforce.ps1 / Report-AssetToShopDB.ps1 use:
|
|
# eDNC registry (skip 9999 placeholder) then C:\Enrollment\machine-number.txt.
|
|
$machineNumber = ''
|
|
foreach ($rp in @(
|
|
'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\Dnc\General',
|
|
'HKLM:\SOFTWARE\GE Aircraft Engines\Dnc\General'
|
|
)) {
|
|
if ($machineNumber) { break }
|
|
if (Test-Path $rp) {
|
|
try {
|
|
$v = (Get-ItemProperty -Path $rp -Name MachineNo -ErrorAction Stop).MachineNo
|
|
if ($v -and "$v".Trim() -ne '9999') { $machineNumber = "$v".Trim() }
|
|
} catch {}
|
|
}
|
|
}
|
|
if (-not $machineNumber -and (Test-Path 'C:\Enrollment\machine-number.txt')) {
|
|
try {
|
|
$v = (Get-Content 'C:\Enrollment\machine-number.txt' -First 1 -ErrorAction Stop).Trim()
|
|
if ($v -and $v -ne '9999') { $machineNumber = $v }
|
|
} catch {}
|
|
}
|
|
return $machineNumber
|
|
}
|
|
|
|
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 }
|
|
)
|
|
function ConvertTo-Uint32([string]$ip) {
|
|
$bytes = ([System.Net.IPAddress]::Parse($ip)).GetAddressBytes()
|
|
[Array]::Reverse($bytes)
|
|
return [BitConverter]::ToUInt32($bytes, 0)
|
|
}
|
|
try {
|
|
$ips = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop |
|
|
Where-Object { $_.IPAddress -notmatch '^169\.254' -and $_.IPAddress -ne '127.0.0.1' }
|
|
foreach ($ipo in $ips) {
|
|
$ipInt = ConvertTo-Uint32 $ipo.IPAddress
|
|
foreach ($r in $allowedRanges) {
|
|
$netInt = ConvertTo-Uint32 $r.Network
|
|
$mask = [uint32]([math]::Pow(2, 32) - [math]::Pow(2, 32 - $r.PrefixLen))
|
|
if (($ipInt -band $mask) -eq ($netInt -band $mask)) { return $ipo.IPAddress }
|
|
}
|
|
}
|
|
} catch {}
|
|
return ''
|
|
}
|
|
|
|
function Send-ShopdbCollectorReport {
|
|
param(
|
|
[string]$SiteHost = 'tsgwp00525.wjs.geaerospace.net',
|
|
[string]$ApiKey = (Get-ShopdbCollectorApiKey),
|
|
[int]$TimeoutSec = 30,
|
|
[string]$LogFile = ('C:\Logs\Shopfloor\collector-{0}.log' -f (Get-Date -Format 'yyyyMMdd'))
|
|
)
|
|
|
|
$logDir = Split-Path -Parent $LogFile
|
|
if (-not (Test-Path $logDir)) {
|
|
New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null
|
|
}
|
|
function Write-CollectorLog([string]$msg) {
|
|
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
|
|
"$ts $msg" | Tee-Object -FilePath $LogFile -Append | Out-Null
|
|
}
|
|
|
|
Write-CollectorLog '=== Report to shopdb-flask collector ==='
|
|
|
|
if (-not $ApiKey) {
|
|
Write-CollectorLog 'ERROR no collector API key (site-config.json / collector-api-key.txt) - skipping.'
|
|
return
|
|
}
|
|
|
|
# PowerShell 5.1 does not always negotiate TLS 1.2 by default.
|
|
try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch {}
|
|
|
|
# --- Gather identity (matches the computers collector schema) ---
|
|
$hostname = [System.Environment]::MachineName
|
|
if (-not $hostname) { $hostname = $env:COMPUTERNAME }
|
|
|
|
$machineNumber = Get-ShopdbMachineNumber
|
|
$ipAddress = Get-ShopdbCorpIPv4
|
|
|
|
$serialNumber = ''
|
|
try {
|
|
$serialNumber = "$((Get-CimInstance -ClassName Win32_BIOS -ErrorAction Stop).SerialNumber)".Trim()
|
|
} catch { Write-CollectorLog "WARN BIOS serial read failed: $($_.Exception.Message)" }
|
|
|
|
$vendorName = ''; $modelNumber = ''; $loggedInUser = ''
|
|
try {
|
|
$cs = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop
|
|
$vendorName = "$($cs.Manufacturer)".Trim()
|
|
$modelNumber = "$($cs.Model)".Trim()
|
|
# UserName is COMPUTERNAME\user or DOMAIN\user; keep the bare username.
|
|
if ($cs.UserName) { $loggedInUser = ($cs.UserName -split '\\')[-1].Trim() }
|
|
} catch { Write-CollectorLog "WARN ComputerSystem read failed: $($_.Exception.Message)" }
|
|
|
|
$osName = ''; $lastBootTime = ''
|
|
try {
|
|
$os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop
|
|
$osName = "$($os.Caption)".Trim()
|
|
$dv = ''
|
|
try { $dv = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -Name DisplayVersion -ErrorAction Stop).DisplayVersion } catch {}
|
|
if ($dv) { $osName += " $dv" }
|
|
if ($os.BuildNumber) { $osName += " (build $($os.BuildNumber))" }
|
|
$osName = $osName.Trim()
|
|
# ISO-8601 so the server's datetime parse accepts it.
|
|
try { $lastBootTime = $os.LastBootUpTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') } catch {}
|
|
} catch { Write-CollectorLog "WARN OS read failed: $($_.Exception.Message)" }
|
|
|
|
$pcType = ''
|
|
if (Test-Path -LiteralPath 'C:\Enrollment\pc-type.txt') {
|
|
try { $pcType = (Get-Content -LiteralPath 'C:\Enrollment\pc-type.txt' -First 1 -ErrorAction Stop).Trim() } catch {}
|
|
}
|
|
$pcSubType = ''
|
|
if (Test-Path -LiteralPath 'C:\Enrollment\pc-subtype.txt') {
|
|
try { $pcSubType = (Get-Content -LiteralPath 'C:\Enrollment\pc-subtype.txt' -First 1 -ErrorAction Stop).Trim() } catch {}
|
|
}
|
|
|
|
# --- Build payload. Field names MUST match get_collector_schema exactly. ---
|
|
$payload = @{ hostname = $hostname }
|
|
if ($machineNumber) { $payload['machinenumber'] = $machineNumber }
|
|
if ($pcType) { $payload['pctype'] = $pcType }
|
|
if ($pcSubType) { $payload['pcsubtype'] = $pcSubType }
|
|
if ($serialNumber) { $payload['serialnumber'] = $serialNumber }
|
|
if ($loggedInUser) { $payload['loggedinuser'] = $loggedInUser }
|
|
if ($lastBootTime) { $payload['lastboottime'] = $lastBootTime }
|
|
if ($ipAddress) { $payload['ipaddress'] = $ipAddress }
|
|
if ($vendorName) { $payload['vendorname'] = $vendorName }
|
|
if ($modelNumber) { $payload['modelnumber'] = $modelNumber }
|
|
if ($osName) { $payload['osname'] = $osName }
|
|
$payload['lastcheckin'] = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
|
|
# installedsoftware is optional; add an array of @{ name=..; version=.. }
|
|
# here if the manifest introspection is wired to real Application names.
|
|
|
|
$uri = "https://$SiteHost/api/collector/computers"
|
|
$json = $payload | ConvertTo-Json -Depth 5
|
|
$headers = @{ 'X-API-Key' = $ApiKey }
|
|
|
|
Write-CollectorLog ("POST {0} host={1} machineNo={2} pcType={3} serial={4} ip={5}" -f `
|
|
$uri, $hostname, $machineNumber, $pcType, $serialNumber, $ipAddress)
|
|
|
|
try {
|
|
$resp = Invoke-RestMethod -Uri $uri -Method Post -Body $json `
|
|
-ContentType 'application/json' -Headers $headers `
|
|
-TimeoutSec $TimeoutSec -ErrorAction Stop
|
|
$d = $resp.data
|
|
Write-CollectorLog ("OK action={0} assetid={1} identity={2} warnings={3}" -f `
|
|
$d.action, $d.assetid, $d.identityvalue, ((@($d.warnings)) -join '; '))
|
|
} catch {
|
|
$code = $null
|
|
try { $code = [int]$_.Exception.Response.StatusCode.value__ } catch {}
|
|
Write-CollectorLog "ERROR POST failed (http=$code): $($_.Exception.Message)"
|
|
}
|
|
}
|
|
|
|
# Entry point when run as a standalone manifest PS1 entry:
|
|
Send-ShopdbCollectorReport
|
|
```
|
|
|
|
### Where the call slots into the GE-Enforce run
|
|
|
|
Put the POST at the END of the enforcement pass, after the enforcement scopes are
|
|
processed and the PC identity is settled. Two placements, pick one:
|
|
|
|
- Preferred - a sibling manifest entry, mirroring `Report-AssetToShopDB.ps1`:
|
|
add `Send-ShopdbCollectorReport.ps1` as a `Type=PS1`, DetectionMethod `Always`
|
|
manifest entry in `common\manifest.json`. It runs every cycle under the SYSTEM
|
|
task, keeps GE-Enforce.ps1 untouched, and gets its own log file. This matches
|
|
the established pattern the classic reporter already uses.
|
|
- Alternative - inline: dot-source the function and call
|
|
`Send-ShopdbCollectorReport` inside GE-Enforce.ps1's status-write-back `try`
|
|
block (right after `status.json` is written, near the end of the pass). Use
|
|
this only if you want the HTTP push to share GE-Enforce's own log and lifecycle.
|
|
|
|
Either way, run it ALONGSIDE the existing `status.json` share write (and
|
|
alongside the classic `Report-AssetToShopDB.ps1`) during rollout - do not remove
|
|
the share write until the HTTP path is proven.
|
|
|
|
### Delivering the API key to clients
|
|
|
|
Do NOT hardcode the key in the script - the script itself lives on the SFLD
|
|
share, so a literal key there is effectively published to everyone with share
|
|
read. Two viable options:
|
|
|
|
1. Per-site value the machine account already reads. GE-Enforce already parses
|
|
`C:\Enrollment\site-config.json` (its local copy of the per-site config on the
|
|
share). Add one field, `collectorApiKey`, populated per site. The script reads
|
|
it with the machine/SYSTEM identity it already runs as. Rotation = update the
|
|
one config value at the site, no re-image, no script redeploy.
|
|
2. Baked at imaging into a machine-local file (`C:\Enrollment\collector-api-key.txt`)
|
|
or an HKLM value, ACL'd to SYSTEM/Administrators, written by the enrollment
|
|
pipeline. Tighter blast radius (the key never sits on the share at all), but
|
|
rotation requires touching every PC or re-imaging.
|
|
|
|
Recommendation: use option 1 (`collectorApiKey` in the per-site config), matching
|
|
how GE-Enforce already sources its share paths and mirroring how SFLD credentials
|
|
are provisioned per site. It keeps the secret out of source control and off the
|
|
script, sits at the same trust boundary as everything else the SYSTEM agent
|
|
already reads, and supports rotation without a fleet touch. `Get-ShopdbCollectorApiKey`
|
|
above already prefers this and falls back to the imaging-baked file, so a site
|
|
can start on option 1 and tighten to option 2 later with no code change. The
|
|
server side pairs this with `COLLECTOR_API_KEY_COMPUTERS` (per-plugin) so the PC
|
|
fleet's key is scoped to the computers collector only.
|
|
|
|
### Staged rollout
|
|
|
|
1. Keep everything as-is: `status.json` share write + classic
|
|
`Report-AssetToShopDB.ps1` continue running. Provision `collectorApiKey` per
|
|
site and set `COLLECTOR_API_KEY_COMPUTERS` on the shopdb-flask server.
|
|
2. Add `Send-ShopdbCollectorReport` as a new manifest entry on a handful of pilot
|
|
bays (one per pc-type: collections, cmm, keyence, waxtrace, ...). It logs to
|
|
`C:\Logs\Shopfloor\collector-YYYYMMDD.log` and cannot break enforcement (it
|
|
never throws, exits cleanly).
|
|
3. Verify on the server: pilot PCs appear/update in shopdb-flask, `action` is
|
|
`created`/`updated`, warnings are understood (map any unmapped `pctype`, add
|
|
any missing OS strings to the `operatingsystems` vocab). Cross-check the
|
|
collector log's `OK` lines against the audit log.
|
|
4. Roll the manifest entry out fleet-wide (it deploys from `common\`, so every
|
|
pc-type gets it). Continue running both the share write and the HTTP POST.
|
|
5. Once shopdb-flask is the system of record and stable across a full imaging
|
|
cycle, retire the classic `Report-AssetToShopDB.ps1` and, if desired, the
|
|
`status.json` share write.
|
|
|
|
---
|
|
|
|
## 4. Troubleshooting
|
|
|
|
| Symptom | HTTP | Cause / fix |
|
|
|---|---|---|
|
|
| `Invalid API key` | 401 | The `X-API-Key` header is missing or wrong. Confirm the client key matches `COLLECTOR_API_KEY_COMPUTERS` (or the shared `COLLECTOR_API_KEY`) on the server. Check the key is being read (`Get-ShopdbCollectorApiKey` returned non-empty). Remember the querystring form no longer works. |
|
|
| `Collector API key not configured` | 500 | Fail-closed: neither `COLLECTOR_API_KEY_COMPUTERS` nor `COLLECTOR_API_KEY` is set in the server environment. Set one and restart the app. This is a SERVER config gap, not a client problem. |
|
|
| `No collector registered for plugin computers` | 404 | The computers plugin is disabled or not loaded on that instance. Enable it. |
|
|
| `hostname is required` / `No data provided` | 400 | Empty body, non-JSON body, or missing identity field. Check `-ContentType 'application/json'` and that `hostname` is set. |
|
|
| `Internal error processing collector payload` | 500 | Generic by design - the server does not leak the cause to the caller. The real error (DB, unexpected exception) is in the server log (`Collector upsert failed for computers`). Check there. |
|
|
| `warnings` present but `action` is created/updated | 200 | Soft issues only; the row WAS written. Common: unmapped `pctype` (fix the pctypemap setting), unknown `osname` (add it to the `operatingsystems` vocab), unknown app name, `pcsubtype` not stored. No action needed unless the warning matters to you. |
|
|
|
|
Client-side log for the fleet reporter: `C:\Logs\Shopfloor\collector-YYYYMMDD.log`.
|
|
Server-side: the Flask app log (the collector logs upsert failures and per-plugin
|
|
schema failures there).
|