diff --git a/agent/ShopDbEndpointAgent.Tray/Program.cs b/agent/ShopDbEndpointAgent.Tray/Program.cs index 80e0f0cd..6e4e5840 100644 --- a/agent/ShopDbEndpointAgent.Tray/Program.cs +++ b/agent/ShopDbEndpointAgent.Tray/Program.cs @@ -1,6 +1,8 @@ +using System.Threading; + namespace ShopDbEndpointAgent.Tray; -// Interactive user-session tray app (SCAFFOLD). Separate process from the +// Interactive user-session tray app (SCAFFOLD). // headless Windows Service: the service owns telemetry, this owns the operator // surface - a system-tray icon and the 5-click-max ServiceNow ticket flow // (DESIGN-DECISIONS-agent.md section 6). This iteration wires the tray and a @@ -8,9 +10,23 @@ namespace ShopDbEndpointAgent.Tray; // is deferred until that decision is made server-side. internal static class Program { + // Machine-wide single-instance guard. The installer starts the tray from an + // HKLM Run value for every user at logon, and an operator can also launch it + // from the Start Menu shortcut, so a second copy is easy to spawn. A named + // mutex in the Global namespace keeps exactly one tray per session: a later + // launch that cannot own the mutex exits immediately instead of stacking a + // duplicate icon. Global\ scopes across sessions on the same machine. + private const string SingleInstanceMutexName = @"Global\ShopDbEndpointAgentTray"; + [STAThread] private static void Main() { + using var singleInstance = new Mutex(true, SingleInstanceMutexName, out bool owned); + if (!owned) + { + return; + } + ApplicationConfiguration.Initialize(); Application.Run(new TrayContext()); } diff --git a/docker-compose.yml b/docker-compose.yml index dc2e63e3..3d28a3ff 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -99,6 +99,11 @@ services: retries: 3 volumes: - ./plugins:/app/plugins:ro + # Standalone operator scripts shipped inside generated bundles (the + # endpoint-agent uninstaller). Read-only, same pattern as plugins: the + # site-agent packaging route reads uninstall-shopdbagent.ps1 from here at + # download time, so it must be visible in the container. + - ./scripts:/app/scripts:ro # Self-signed TLS material for the dev/test https listener. Read-only; # generated on the host under ./certs and never baked into the image. - ./certs:/app/certs:ro diff --git a/plugins/endpoints/api/siteagent.py b/plugins/endpoints/api/siteagent.py index a585c315..9fa82544 100644 --- a/plugins/endpoints/api/siteagent.py +++ b/plugins/endpoints/api/siteagent.py @@ -40,6 +40,14 @@ from .routes import endpoints_bp STAGED_AGENT_RELPATH = os.path.join('siteagent', 'shopdbagent.exe') STAGED_TRAY_RELPATH = os.path.join('siteagent', 'shopdb-endpoint-tray.exe') +# The standalone uninstaller lives in the repo tree (not staged per-instance), +# so it ships in every bundle. Resolve it relative to this file: +# plugins/endpoints/api/siteagent.py -> repo root is three levels up. +UNINSTALLER_PATH = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname( + os.path.abspath(__file__))))), + 'scripts', 'uninstall-shopdbagent.ps1') + # Public TLS certificate the agent must trust when this deployment terminates # TLS itself with a self-signed or otherwise untrusted certificate. We can only # auto-bundle when THIS app owns the cert file (the dev topology, where gunicorn @@ -143,6 +151,12 @@ FILES appsettings.json Non-secret runtime config (cadence, monitors, launch paths). No credentials. install.ps1 Provisions the credentials and installs the service. + uninstall-shopdbagent.ps1 + Standalone uninstaller. Run elevated to reverse the + install: stops and deletes the service, kills agent + and tray processes, removes the tray autostart Run + key, the Start Menu shortcut, and the credential + hive, and deletes the install directory. server.crt Server TLS certificate (present only for self-signed or internal-CA deployments, either auto-detected or uploaded by the operator). install.ps1 imports it @@ -153,7 +167,13 @@ USAGE 1. Copy this whole folder to the target endpoint. 2. Right-click install.ps1 and Run with PowerShell as administrator. It writes the server URL and collector key to HKLM\\SOFTWARE\\GE\\ShopDB, - copies the agent into Program Files, and starts the service. + copies the agent into Program Files, and starts the service. It also + registers the tray to autostart for every user at logon and adds a + "ShopDB Agent" Start Menu shortcut for manual relaunch. + +UNINSTALL + Run uninstall-shopdbagent.ps1 from an elevated PowerShell to fully remove the + agent, tray autostart, Start Menu shortcut, and credentials. SECURITY install.ps1 carries a live collector key in its default -CollectorKey value. @@ -305,6 +325,36 @@ if (-not $NoTray) {{ $traySource = Join-Path $sourceDir $TrayExeName if (Test-Path $traySource) {{ Copy-Item -Path $traySource -Destination $InstallDir -Force + $installedTray = Join-Path $InstallDir $TrayExeName + + # Autostart the tray for every user at logon via the HKLM Run key (both + # 64-bit and 32-bit views, matching the credential hive). This is the + # reliable launch guarantee: a plain Start Menu shortcut does not + # autostart, and the SYSTEM service cannot cleanly spawn into the + # interactive session. The tray itself is single-instance, so a stray + # second launch is harmless. + Set-HklmValueBothViews -SubKey "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run" -Name "ShopDbEndpointTray" -Value ('"' + $installedTray + '"') + + # Start Menu shortcut so operators can relaunch the tray on demand. This + # is discoverability only, not autostart. Written to the machine-wide + # (All Users) Programs folder. + try {{ + $commonPrograms = [Environment]::GetFolderPath([Environment+SpecialFolder]::CommonPrograms) + $shortcutPath = Join-Path $commonPrograms "ShopDB Agent.lnk" + $shell = New-Object -ComObject WScript.Shell + $shortcut = $shell.CreateShortcut($shortcutPath) + $shortcut.TargetPath = $installedTray + $shortcut.WorkingDirectory = $InstallDir + $shortcut.Description = "ShopDB Endpoint Agent tray" + $shortcut.Save() + Write-Host " created Start Menu shortcut: $shortcutPath" + }} + catch {{ Write-Warning "could not create Start Menu shortcut: $($_.Exception.Message)" }} + + # Launch the tray once now so it appears without waiting for the next + # logon. Single-instance guard makes a duplicate launch a no-op. + try {{ Start-Process -FilePath $installedTray -WorkingDirectory $InstallDir }} + catch {{ Write-Warning "could not start tray: $($_.Exception.Message)" }} }} }} @@ -420,6 +470,9 @@ def package_siteagent(): cadenceseconds, certfilename)) archive.writestr('README.txt', _README) + if os.path.isfile(UNINSTALLER_PATH): + archive.write(UNINSTALLER_PATH, 'uninstall-shopdbagent.ps1') + agent = _staged_path(STAGED_AGENT_RELPATH) if os.path.isfile(agent): archive.write(agent, 'shopdbagent.exe') diff --git a/scripts/uninstall-shopdbagent.ps1 b/scripts/uninstall-shopdbagent.ps1 index b4819ff3..95fa7724 100644 --- a/scripts/uninstall-shopdbagent.ps1 +++ b/scripts/uninstall-shopdbagent.ps1 @@ -114,6 +114,14 @@ Start-Sleep -Seconds 1 Write-Host "[3/6] Removing registry entries ..." Remove-HklmBothViews -SubKey "SOFTWARE\Microsoft\Windows\CurrentVersion\Run" -Name "ShopDbEndpointTray" Remove-HklmBothViews -SubKey "SOFTWARE\GE\ShopDB" + +# Remove the All-Users Start Menu shortcut the site-agent installer created. +$commonPrograms = [Environment]::GetFolderPath([Environment+SpecialFolder]::CommonPrograms) +$shortcutPath = Join-Path $commonPrograms "ShopDB Agent.lnk" +if (Test-Path $shortcutPath) { + try { Remove-Item -Path $shortcutPath -Force -ErrorAction Stop; Write-Host " removed Start Menu shortcut $shortcutPath" } + catch { Write-Warning " could not remove Start Menu shortcut: $($_.Exception.Message)" } +} # Drop the parent GE key only if we emptied it (some sites nest siblings under # GE such as SFLD; never tree-delete a key we do not own). foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64,