# 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://:' 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 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"