Files
shopdb-flask/agent/installer/install-shopdbagent.ps1
benjamin.baker af133372b4 Gate endpoint UI refresh on fresh telemetry and land endpoint agent
A command reaching 'succeeded' only proves the agent ran the action, not that it posted a new telemetry snapshot. Reloading panels on command success alone re-rendered stale service/process rows, so a stopped process could still show running right after Refresh Status.

Add a lightweight telemetrymeta probe exposing lastreportedat and gate both the header Refresh Status action and inline row Start/Stop on that stamp advancing past a pre-action baseline. Polling is bounded so an offline or idle agent never hangs the button, and messaging distinguishes a confirmed refresh from a queued one.

Bring the .NET endpoint agent source (service, tray, installer) into the repo so Cameron can merge and test, with an agent/.gitignore keeping bin/obj/publish out. Includes the DncMain launch-path fix, tray single-file host env scrub, and best-effort file logger.
2026-08-28 15:07:20 -04:00

338 lines
16 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. Defaulted to the test instance;
# pass -BaseUrl "http://<host>:<port>" to override.
[string] $BaseUrl = "http://H1HWNL84.device.geaerospace.net:5001",
# Endpoints-only collector key (this instance's .env
# COLLECTOR_API_KEY_ENDPOINTS).
[string] $CollectorKey = "l755ddRw1iSeOcSTh9N9wXM4gGT7N6WIVN9crOY6uZU",
[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
)
$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"