Files
shopdb-flask/agent/installer/install-shopdbagent.ps1
cproudlock 784964c907 Bring the wilm branch into main: endpoints plugin, agent, policies, CSV import
Ports the corporate-GitHub wilm branch onto main, keeping the two branches
separate. Thirteen upstream commits arrive with their authorship intact; the
review fixes and the portability work below are on top.

What arrives:

  endpoints plugin  - a ShopDB agent reporting endpoint state, telemetry,
                      services and interfaces, with a command channel the agent
                      long-polls. Ingest stays on the generic collector route
                      (ADR-006); the agent key is a UI-managed single-scope
                      token whose revocation is an instant kill switch.
  computers policies - Configuration and Business Policies (RMM-style), scope
                      trees validated at write time, actions checked against
                      the endpoints command allowlist. Feature-gated: every
                      route 404s while the setting is off, so enabling it can
                      never half-expose the surface.
  asset CSV import  - templates, a collector script carrying the site path, and
                      a validate-then-commit import. A plugin can own a virtual
                      column plus a post-write row hook, which is how a PC's
                      machine number becomes a controls link without a
                      hand-built relationships sheet.
  agent/            - the Windows agent and its installer.

Contract 0.21.0 -> 0.24.0, additive throughout: register_import_hook (0.22),
mint/list/revoke_service_token (0.23), collector_authorized (0.24). The stability
table had no rows for any of them and now does.

MySQL 5.6 portability. The branch stored three policy columns as sa.JSON, the
only JSON columns in the product, and 5.6 has no JSON type: the migration failed
with a bare syntax error near the word JSON. They are TEXT now, through a
JSONText type decorator, so all seventeen call sites still read and write dicts.
The convention elsewhere is already TEXT (guides.blocksjson,
geenforce.manifestjson).

Review fixes carried on top:

  - six policy GETs were jwt_required(optional=True), which authenticates a
    caller without checking anything; they require computers.view now, and six
    redundant jwt_required decorators are gone
  - the agent installer took a default BaseUrl and CollectorKey, so a mistyped
    run silently pointed an endpoint at the wrong instance; both are mandatory
    and a non-https BaseUrl throws unless -AllowInsecureHttp is passed
  - a committing CSV import wrote no audit row

Documentation: the 34 new routes are described in docs/api-inventory.json, which
is hand-maintained on purpose, and the generated spec now covers 331 paths and
477 operations across 22 surfaces. DEVELOPMENT-SETUP's 1071 troubleshooting row
said "use MySQL 8 for dev" and now gives the actual fix, including the trap that
5.6 downgrades ROW_FORMAT=DYNAMIC to Compact with a warning instead of an error.

Full suite 2123 passed, vitest 260 passed, all seventeen plugin chains migrate
clean on MySQL 5.6 and the naming, docs, parity and read-surface gates are green.
2026-08-28 16:17:17 -04:00

356 lines
17 KiB
PowerShell

# install-shopdbagent.ps1
# ShopDB Endpoint Agent installer for the test endpoint. Run from an ELEVATED
# (admin) PowerShell on the endpoint. Provisions the registry secrets, copies
# the agent files to Program Files, runs a one-shot smoke test, then installs
# and starts the Windows service.
#
# Secrets are provisioned to HKLM\SOFTWARE\GE\ShopDB. On locked-down hosts
# (CyberArk/EPM) the agent may be blocked from READING that hive, so the same
# values are also stamped into the installed appsettings.json as a fallback.
#
# The agent and tray are published as single-file self-contained executables,
# so copying the two .exe files plus appsettings.json is the complete payload.
[CmdletBinding()]
param(
# ShopDB server root URL for this instance. REQUIRED - there is no default,
# because a default here means one site's hostname shipped to every site
# (ADR-015) and, worse, a working install pointed at somebody else's server.
#
# https is required. The collector key travels in the X-API-Key header and
# every telemetry payload follows it, so plain http puts both on the wire in
# clear. Pass -AllowInsecureHttp only for a local test instance.
[Parameter(Mandatory = $true)]
[string] $BaseUrl,
# Endpoints-only collector key: this instance's .env COLLECTOR_API_KEY_ENDPOINTS.
# REQUIRED and never defaulted - a key in source is a key in git history, and
# this one is also the command channel's kill switch.
[Parameter(Mandatory = $true)]
[string] $CollectorKey,
# Permit an http:// BaseUrl. For a local test instance only.
[switch] $AllowInsecureHttp,
[string] $InstallDir = "C:\Program Files\ShopDB\EndpointAgent",
[string] $ServiceName = "ShopDbEndpointAgent",
# Interactive tray app that shows the system-tray icon and the on-demand
# "Send Status now" action. Separate process from the SYSTEM service (a
# service cannot draw a tray icon in a user session). Pass -NoTray to skip.
[string] $TrayExeName = "shopdb-endpoint-tray.exe",
[switch] $NoTray,
# Add a Microsoft Defender path exclusion for the install directory. The
# agent and tray are unsigned single-file self-contained executables, which
# Defender heuristics (and the tray's GDI/user32 icon P/Invoke) can flag as
# a false positive and quarantine, killing the tray before its icon appears.
# Pass -NoDefenderExclusion to skip (e.g. where policy manages exclusions
# centrally or the binaries are code-signed).
[switch] $NoDefenderExclusion
)
# Refuse cleartext unless it was asked for explicitly. The key is a bearer
# credential for the whole fleet's telemetry and command channel.
if ($BaseUrl -notmatch '^https://' -and -not $AllowInsecureHttp) {
throw "BaseUrl must be https:// (got '$BaseUrl'). Pass -AllowInsecureHttp for a local test instance."
}
$ErrorActionPreference = "Stop"
$sourceDir = Split-Path -Parent $MyInvocation.MyCommand.Path
function Assert-Admin {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw "This script must be run from an elevated (Run as administrator) PowerShell."
}
}
Assert-Admin
if ([string]::IsNullOrWhiteSpace($BaseUrl)) {
throw "BaseUrl is not set. Pass -BaseUrl 'http://<host>:<port>' when running the script."
}
# Write HKLM values through the explicit 64-bit registry view. If this script
# runs under 32-bit PowerShell (some management tooling launches x86), the
# PSDrive HKLM: silently redirects writes into WOW6432Node. The 64-bit agent
# service and 64-bit Explorer (the logon Run key) then read the 64-bit view and
# see nothing, so config "goes missing" and the tray never autostarts. Pinning
# the view removes that dependency on the launching shell's bitness.
function Set-Hklm64Value {
param(
[Parameter(Mandatory)] [string] $SubKey,
[Parameter(Mandatory)] [string] $Name,
[Parameter(Mandatory)] [string] $Value
)
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey(
[Microsoft.Win32.RegistryHive]::LocalMachine,
[Microsoft.Win32.RegistryView]::Registry64)
try {
$key = $base.CreateSubKey($SubKey)
try {
$key.SetValue($Name, $Value, [Microsoft.Win32.RegistryValueKind]::String)
}
finally { $key.Dispose() }
}
finally { $base.Dispose() }
}
# Tear down any previously installed agent BEFORE touching files. The agent and
# tray are single-file self-contained exes; while the old service or tray is
# running it holds those .exe files open, so Copy-Item over them fails with a
# file-in-use error. Stopping the service, deleting it, and killing any lingering
# agent/tray processes up front makes the install a clean overwrite. Best-effort
# throughout: a missing service or process is the normal fresh-install case.
function Uninstall-PreviousAgent {
param(
[Parameter(Mandatory)] [string] $Service,
[Parameter(Mandatory)] [string] $TrayExe,
[Parameter(Mandatory)] [string] $TargetDir
)
$existing = Get-Service -Name $Service -ErrorAction SilentlyContinue
if ($existing) {
Write-Host " stopping and removing existing service $Service ..."
if ($existing.Status -ne "Stopped") {
try { Stop-Service -Name $Service -Force -ErrorAction Stop }
catch { Write-Warning " could not stop service: $($_.Exception.Message)" }
}
# sc.exe delete only marks the service for deletion; the SCM finishes the
# removal once every handle closes. Give it a moment so the subsequent
# sc.exe create does not hit a "marked for deletion" (1072) error.
sc.exe delete $Service | Out-Null
Start-Sleep -Seconds 2
}
# Kill any lingering worker or tray processes that would keep the exes
# locked. Match by the installed exe base names; the service host process is
# shopdbagent, the tray is the TrayExe base name.
$trayBase = [System.IO.Path]::GetFileNameWithoutExtension($TrayExe)
foreach ($name in @("shopdbagent", $trayBase)) {
$procs = Get-Process -Name $name -ErrorAction SilentlyContinue
foreach ($p in $procs) {
Write-Host " killing lingering process $($p.ProcessName) (pid $($p.Id)) ..."
try { Stop-Process -Id $p.Id -Force -ErrorAction Stop }
catch { Write-Warning " could not kill $($p.ProcessName): $($_.Exception.Message)" }
}
}
# Also kill anything still running out of the target install directory,
# regardless of exe name. A tray from an older build (different exe name) or
# one launched from a stale path would otherwise survive the name-based sweep
# above, keep its exe locked, and leave a duplicate icon once step 5 starts
# the fresh tray. Match on the executable path so we only touch our own dir.
$targetPrefix = $TargetDir.TrimEnd('\') + '\'
foreach ($p in (Get-Process -ErrorAction SilentlyContinue)) {
$path = $null
try { $path = $p.Path } catch { $path = $null }
if ($path -and $path.StartsWith($targetPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
Write-Host " killing process from install dir $($p.ProcessName) (pid $($p.Id)) ..."
try { Stop-Process -Id $p.Id -Force -ErrorAction Stop }
catch { Write-Warning " could not kill $($p.ProcessName): $($_.Exception.Message)" }
}
}
# Brief settle so file handles are released before the copy overwrites them.
Start-Sleep -Milliseconds 500
}
Write-Host "== ShopDB Endpoint Agent install ==" -ForegroundColor Cyan
Write-Host "BaseUrl : $BaseUrl"
Write-Host "InstallDir : $InstallDir"
Write-Host "ServiceName : $ServiceName"
# 0. Clear the Mark-of-the-Web on the SOURCE payload first. The operator
# typically extracts installer.zip into Downloads, so every file carries a
# Zone.Identifier ADS. SmartScreen reads that and blocks the unsigned exes with
# "Windows protected your PC" the first time they run - including the [3/5]
# smoke test below, which launches shopdbagent.exe out of $sourceDir before the
# installed copy exists. Unblocking the source folder up front keeps the smoke
# test (and any manual run from the extract folder) clean; the installed copies
# get unblocked again in step 2a after they land in Program Files. Best-effort:
# a file with no MotW is a no-op.
Write-Host "`n[0a] Clearing Mark-of-the-Web on the extracted installer files ..."
Get-ChildItem -Path $sourceDir -File | ForEach-Object {
try { Unblock-File -Path $_.FullName -ErrorAction Stop }
catch { Write-Warning " could not unblock $($_.Name): $($_.Exception.Message)" }
}
# 0. Uninstall any previous agent first, so the file copy is a clean overwrite
# and does not fail on exes locked by a running service or tray.
Write-Host "`n[0/5] Removing any previously installed agent ..."
Uninstall-PreviousAgent -Service $ServiceName -TrayExe $TrayExeName -TargetDir $InstallDir
# 1. Provision registry secrets (64-bit view, see Set-Hklm64Value).
$regPath = "HKLM:\SOFTWARE\GE\ShopDB"
Write-Host "`n[1/5] Provisioning registry $regPath (Registry64) ..."
Set-Hklm64Value -SubKey "SOFTWARE\GE\ShopDB" -Name "BaseUrl" -Value $BaseUrl
Set-Hklm64Value -SubKey "SOFTWARE\GE\ShopDB" -Name "CollectorKey" -Value $CollectorKey
# 2. Copy agent files to the install directory. The agent and tray are
# single-file self-contained executables, so the .exe plus appsettings.json is
# the whole payload; there are no side-by-side runtime DLLs to copy.
Write-Host "[2/5] Copying agent files to $InstallDir ..."
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
$agentSource = Join-Path $sourceDir "shopdbagent.exe"
if (-not (Test-Path $agentSource)) {
throw "shopdbagent.exe not found in $sourceDir. Unzip the full installer package and run from that folder."
}
Copy-Item -Path $agentSource -Destination $InstallDir -Force
$appsettings = Join-Path $sourceDir "appsettings.json"
$installedSettings = Join-Path $InstallDir "appsettings.json"
if (Test-Path $appsettings) {
Copy-Item -Path $appsettings -Destination $InstallDir -Force
}
# Copy the interactive tray app next to the agent so its same-dir resolution
# finds shopdbagent.exe for the "Send Status now" action. Optional: absent on a
# service-only (headless) build.
$trayInstalled = $null
if (-not $NoTray) {
$traySource = Join-Path $sourceDir $TrayExeName
if (Test-Path $traySource) {
Copy-Item -Path $traySource -Destination $InstallDir -Force
$trayInstalled = Join-Path $InstallDir $TrayExeName
Write-Host " copied tray app $TrayExeName"
}
else {
Write-Warning " tray app $TrayExeName not found in $sourceDir; skipping tray (service still installs)"
}
}
# Fallback for locked-down endpoints: some hosts (CyberArk/EPM) gate the HKLM
# hive so the agent cannot READ the values the registry step just wrote. Stamp
# BaseUrl/CollectorKey into the installed appsettings.json too. The agent reads
# appsettings first, then lets the registry override it, so this is a safe
# no-op where the registry read works and the rescue where it does not.
if (Test-Path $installedSettings) {
try {
$json = Get-Content $installedSettings -Raw | ConvertFrom-Json
$json.baseurl = $BaseUrl
$json.apikey = $CollectorKey
$json | ConvertTo-Json -Depth 10 | Set-Content -Path $installedSettings -Encoding UTF8
Write-Host " stamped BaseUrl/CollectorKey into appsettings.json (registry-read fallback)"
}
catch {
Write-Warning " could not stamp appsettings.json: $($_.Exception.Message)"
}
}
$exePath = Join-Path $InstallDir "shopdbagent.exe"
# 2a. Strip the Mark-of-the-Web from the copied files. Windows tags files
# extracted from a downloaded zip with a Zone.Identifier alternate data stream;
# SmartScreen reads that and blocks the unsigned exe with "Windows protected
# your PC" the first time it runs, and the operator sees only a "Don't run"
# button. Unblock-File removes the ADS so the smoke test and the tray launch
# start clean. Best-effort: a file with no MotW is a no-op.
Write-Host "[2a/5] Clearing Mark-of-the-Web on installed files ..."
Get-ChildItem -Path $InstallDir -File | ForEach-Object {
try { Unblock-File -Path $_.FullName -ErrorAction Stop }
catch { Write-Warning " could not unblock $($_.Name): $($_.Exception.Message)" }
}
# 2b. Defender path exclusion for the install dir. Unsigned single-file
# self-contained binaries (and the tray's icon P/Invoke) can trip Defender
# heuristics and get quarantined, which is why the tray dies before showing an
# icon. Excluding the fixed install directory is the least-privilege fix short
# of code-signing. Best-effort: Set-MpPreference is absent on servers without
# Defender and can be blocked by tamper protection; a failure is not fatal.
if (-not $NoDefenderExclusion) {
Write-Host "[2b/5] Adding Defender path exclusion for $InstallDir ..."
if (Get-Command Add-MpPreference -ErrorAction SilentlyContinue) {
try {
Add-MpPreference -ExclusionPath $InstallDir -ErrorAction Stop
Write-Host " excluded $InstallDir from Defender scanning."
}
catch {
Write-Warning " could not add Defender exclusion (tamper protection or policy?): $($_.Exception.Message)"
Write-Warning " if the tray is quarantined, code-sign the binaries or add the exclusion via policy."
}
}
else {
Write-Host " Defender cmdlets not present; skipping exclusion."
}
}
# 3. One-shot smoke test (exact production collection path, then exits).
Write-Host "[3/5] Running one-shot smoke test (--once) ..."
$proc = Start-Process -FilePath $exePath -ArgumentList "--once" -WorkingDirectory $InstallDir -Wait -PassThru -NoNewWindow
Write-Host " smoke test exit code: $($proc.ExitCode)"
$logDir = Join-Path $env:ProgramData "ShopDB\EndpointAgent\logs"
$logFile = Join-Path $logDir ("agent-{0}.log" -f (Get-Date -Format "yyyyMMdd"))
if (Test-Path $logFile) {
Write-Host " --- last log lines ---" -ForegroundColor DarkGray
Get-Content $logFile -Tail 20 | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray }
}
if ($proc.ExitCode -ne 0) {
throw "Smoke test failed (exit $($proc.ExitCode)). Check $logFile before installing the service."
}
# 4. Install the Windows service. Any previous instance was already stopped,
# deleted, and its processes killed in step 0, so this is a clean create. Guard
# against the rare case where the SCM has not finished a pending deletion yet.
Write-Host "[4/5] Installing service $ServiceName ..."
$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($existing) {
Write-Host " service still present (pending deletion); removing again ..."
if ($existing.Status -ne "Stopped") { Stop-Service -Name $ServiceName -Force }
sc.exe delete $ServiceName | Out-Null
Start-Sleep -Seconds 2
}
$binPath = '"' + $exePath + '"'
sc.exe create $ServiceName binPath= $binPath start= auto DisplayName= "ShopDB Endpoint Agent" | Out-Null
sc.exe description $ServiceName "Collects endpoint telemetry for ShopDB and runs allowlisted WebUI commands." | Out-Null
sc.exe failure $ServiceName reset= 86400 actions= restart/5000/restart/5000/restart/5000 | Out-Null
Start-Service -Name $ServiceName
Write-Host " service started."
# 5. Register the interactive tray app to launch at user logon (per-machine).
Write-Host "[5/5] Registering tray app for logon ..."
if ($trayInstalled -and (Test-Path $trayInstalled)) {
# Register in the 64-bit Run key so 64-bit Explorer fires it at logon. A
# 32-bit write would land in WOW6432Node\...\Run, which Explorer ignores.
Set-Hklm64Value -SubKey "SOFTWARE\Microsoft\Windows\CurrentVersion\Run" `
-Name "ShopDbEndpointTray" -Value ('"' + $trayInstalled + '"')
Write-Host " tray app registered to start at logon (Registry64): $trayInstalled"
# Start it now so the icon appears without waiting for the next logon. The
# Run key only fires at logon, so a fresh install would otherwise show no
# tray until the operator signs out and back in.
#
# The installer runs elevated, so a direct Start-Process would launch the
# tray ELEVATED too. That is wrong for a tray app (it should run at the
# user's medium integrity) and, on EPM-managed hosts (CyberArk), elevating an
# unsigned exe triggers a broker prompt that comes back as Win32 1223
# "The operation was canceled by the user" - which is exactly what we hit.
# Launching through explorer.exe hands the exe to the already-running user
# shell, which spawns it de-elevated in the interactive session, sidestepping
# the elevation broker entirely.
$interactiveUser = $env:USERNAME
if ($interactiveUser -and $interactiveUser -ne "SYSTEM") {
try {
# explorer.exe <path> re-parents the launch under the user shell at
# medium integrity; it returns immediately regardless of the tray's
# own lifetime, so there is nothing to wait on here.
Start-Process -FilePath "explorer.exe" -ArgumentList "`"$trayInstalled`"" | Out-Null
Write-Host " tray app started for the current session (de-elevated via explorer)."
}
catch {
Write-Warning " could not start tray now: $($_.Exception.Message); it will start at next logon."
}
}
else {
Write-Host " no interactive user session; tray will start at next logon."
}
}
else {
Write-Host " tray skipped (headless install)."
}
Write-Host "`nInstall complete." -ForegroundColor Green
Write-Host "Service '$ServiceName' is running and set to auto-start."
Write-Host "Logs: $logDir"