"""Site agent generator packaging. Builds a deployable, instance-locked endpoint-agent package for this site. An admin supplies the server base URL, the managed endpoint agent key (minted in the key generator above), and the reporting cadence; this route returns a ZIP carrying the prebuilt shopdbagent.exe, a non-secret appsettings.json, an install.ps1 that provisions the credentials, and a README. This module is deliberately self-contained so it can be maintained alongside the endpoint agent without editing routes.py. It attaches two admin-gated routes to the existing endpoints blueprint. The container has no .NET SDK, so - exactly like the adoption tool - the agent exe is built out of band and staged under instance/siteagent/. When a prebuilt binary is present it ships in the ZIP; otherwise the ZIP still carries the config and installer so an operator can drop the exe in beside them. Per repo policy the live CollectorKey and BaseUrl are provisioned into the Windows registry (HKLM\\SOFTWARE\\GE\\ShopDB), never baked into appsettings.json. The generated install.ps1 writes those two registry values from the operator's inputs; appsettings.json ships with an empty baseurl and a placeholder apikey so the agent fails closed until provisioned. The collector key is embedded only in the install script's default parameter and is never stored or logged here. Per-PC credentials: the shared key is optional per build. A package built without it carries no key at all; the agent then asks to enroll and an admin approves it. Every package pins the server's public key (ServerPins, the lowercase hex SHA-256 of the leaf SubjectPublicKeyInfo), optionally with a second "next" pin for a planned certificate rotation. """ import hashlib import io import json import os import re import ssl import zipfile from flask import request, current_app, send_file from shopdb.api import (success_response, error_response, ErrorCodes, require_permission) from .routes import endpoints_bp, _spki_sha256 from .agentauth import shared_key_allowed from . import agentstore _PIN_PATTERN = re.compile(r'^[0-9a-f]{64}$') # Prebuilt, staged agent binaries. Built out of band and dropped under # instance/siteagent/ (mirrors the adoption tool's STAGED_EXE_RELPATH model). 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 # terminates TLS with certs/server.crt). When a site fronts the app with its own # reverse proxy the app cannot see the proxy's cert, so bundling is opt-in and # only offered when a readable cert file is actually found here. Resolution order: # the SHOPDB_TLS_CERT env var, then the mounted container path, then the repo # path. Only a PEM public certificate is ever read - never a private key. _CERT_CANDIDATES = ( os.environ.get('SHOPDB_TLS_CERT') or '', '/app/certs/server.crt', os.path.join(os.getcwd(), 'certs', 'server.crt'), ) def _server_cert_path(): """Path to a readable public TLS cert to bundle, or None if none exists.""" for candidate in _CERT_CANDIDATES: if candidate and os.path.isfile(candidate): return candidate return None def _validate_pem_cert(raw): """Return normalized PEM text for a valid X.509 certificate, else None. Accepts operator-uploaded .crt/.pem bytes. Parses every PEM certificate block through the stdlib ssl module (which round-trips it to DER, so junk or a non-certificate PEM such as a private key is rejected). The returned text is the concatenation of the re-encoded blocks, so a leaf-plus-chain upload is preserved while any trailing garbage is dropped. """ try: text = raw.decode('ascii') except (UnicodeDecodeError, AttributeError): return None marker = '-----BEGIN CERTIFICATE-----' if marker not in text: return None blocks = [] start = text.find(marker) while start != -1: end = text.find('-----END CERTIFICATE-----', start) if end == -1: return None end += len('-----END CERTIFICATE-----') block = text[start:end] + '\n' try: ssl.PEM_cert_to_DER_cert(block) except (ssl.SSLError, ValueError): return None blocks.append(block) start = text.find(marker, end) if not blocks: return None return ''.join(blocks) def _leaf_certificate_facts(pembytes): """(spkisha256, isca) for the FIRST certificate in a PEM, else (None, None). The first block is the leaf in a leaf-plus-chain file, which is the cert the agent pins and the one Import-Certificate brings into the store. """ from cryptography import x509 try: text = pembytes.decode('ascii') except (UnicodeDecodeError, AttributeError): return None, None start = text.find('-----BEGIN CERTIFICATE-----') end = text.find('-----END CERTIFICATE-----', start) if start == -1 or end == -1: return None, None block = (text[start:end + len('-----END CERTIFICATE-----')] + '\n').encode('ascii') try: certificate = x509.load_pem_x509_certificate(block) except ValueError: return None, None try: isca = bool(certificate.extensions.get_extension_for_class( x509.BasicConstraints).value.ca) except x509.ExtensionNotFound: isca = False return _spki_sha256(block), isca def _current_server_facts(): """(spkisha256, isca) of this server's own leaf certificate, else (None, None).""" path = _server_cert_path() if not path: return None, None try: with open(path, 'rb') as handle: return _leaf_certificate_facts(handle.read()) except OSError: return None, None def _current_server_pin(): """Pin of this server's own leaf certificate, or None when not found. The SPKI pin does not depend on basicConstraints, so a CA-flagged self-signed certificate is pinned like any other leaf. """ return _current_server_facts()[0] # Shown to the admin at build time (response header and UI) when the pinned # certificate carries basicConstraints CA:TRUE. CA_CERT_BUILD_WARNING = ( 'The server certificate has basicConstraints CA:TRUE, so install.ps1 will ' 'not import it into LocalMachine\\Root. The package still pins it: agents ' '1.20 and newer trust the server by pin alone. Agents older than 1.20 and ' 'PowerShell 5.1 downloads without pin support will not trust this server. ' 'Regenerate the server certificate in Settings > Endpoints > Server ' 'certificate (the generator does not set CA), or trust a non-CA ' 'certificate on those PCs with Install-ShopDBServerCertificate.ps1.') CA_CERT_BOTH_PINS_WARNING = ( 'The uploaded CA-flagged certificate does not match this server\'s own ' 'certificate, so both pins were included in ServerPins: the uploaded ' 'certificate and the server\'s own certificate.') CA_WARNING_HEADER = 'X-ShopDB-Build-Warning' # The non-secret runtime config shipped in the package. baseurl and apikey stay # empty/placeholder so the agent fails closed until the registry is provisioned # by install.ps1. Cadence is stamped from the operator's input at build time. _BASE_APPSETTINGS = { 'baseurl': '', 'apikey': 'REPLACE_WITH_COLLECTOR_KEY', 'cadenceseconds': 900, 'jitterseconds': 60, 'startupjitterseconds': 30, 'adoptonfirstrun': True, 'utilitiespath': '', 'logpath': '', 'monitors': { 'processes': [ {'name': 'dncMain.exe', 'app': 'eDNC'}, {'name': 'udc.exe', 'app': 'Universal Data Collector'}, {'name': 'agent.exe', 'app': 'MTConnect'}, ], 'services': [ {'name': 'MTConnect UDC Agent', 'app': 'MTConnect'}, ], }, 'launchpaths': { 'DncMain.exe': 'C:\\Program Files (x86)\\Dnc\\bin\\DncMain.exe', 'UDC.exe': 'C:\\Program Files\\UDC\\UDC.exe', 'agent.exe': 'C:\\ProgramData\\udc\\mtconnect_udc\\agent\\run.bat', }, # Approved scheduled task paths for scheduledtask_run. Shipped empty, which # means the agent falls back to ScheduledTaskPolicy.DefaultApprovedTaskPaths # (the Corporate Tools set). Listing entries here REPLACES those defaults, so # a site can narrow or widen the set without an agent rebuild. Anything not # matched fails closed. 'approvedtaskpaths': [], } _README = """ShopDB endpoint agent (site build) WHAT THIS IS An instance-locked endpoint agent for this site. It installs as a Windows service, reports identity, hardware, and installed-software inventory on a fixed cadence, and executes delivery commands (scripts, app updates) issued from this ShopDB server. Unlike the adoption tool it stays resident. FILES shopdbagent.exe The agent service host (present only if the server had a prebuilt copy staged). shopdb-endpoint-tray.exe Optional interactive tray app (present when staged). 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 into the machine Trusted Root store so the agent can validate its outbound TLS connection to the server. USAGE 1. Copy this whole folder to the target endpoint. 2. Right-click install.ps1 and Run with PowerShell as administrator. It {provision} to HKLM\\SOFTWARE\\GE\\ShopDB, 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. {enrollment} UNINSTALL Run uninstall-shopdbagent.ps1 from an elevated PowerShell to fully remove the agent, tray autostart, Start Menu shortcut, and credentials. SECURITY {security}""" _README_SHAREDKEY = { True: { 'provision': 'writes the server URL, server pins, and collector key', 'enrollment': '', 'security': ( ' install.ps1 carries a live collector key in its default -CollectorKey value.\n' ' Treat this package as a secret. The key is not recoverable from the ShopDB\n' ' server after it was generated; regenerate the key in the UI to revoke it.\n'), }, False: { 'provision': 'writes the server URL and server pins', 'enrollment': ( ' This package carries NO shared key. After install the PC requests\n' ' enrollment; approve it in Settings > Endpoints > Enrollment.\n'), 'security': ( ' This package holds no secret. Each PC receives its own credential when\n' ' an admin approves its enrollment request; an existing enrolled\n' ' credential on the PC is never touched by install.ps1.\n'), }, } def _readme(includesharedkey): return _README.format(**_README_SHAREDKEY[bool(includesharedkey)]) def _staged_path(relpath): """Absolute path to a staged binary under the instance directory.""" return os.path.join(current_app.instance_path, relpath) def _install_script(baseurl, collectorkey, cadenceseconds, certfilename=None, serverpins=None, includesharedkey=True, trustcertinroot=True): """The install.ps1 that provisions credentials and installs the service. The operator's server URL and collector key are stamped as the default parameter values so a double-click install needs no arguments. The key is written only to the registry (HKLM\\SOFTWARE\\GE\\ShopDB), never into appsettings.json, honoring the registry-provisioning contract. When a server certificate is bundled, certfilename names it and the script imports it into the machine Trusted Root store so the agent's outbound TLS to a self-signed or internal-CA server validates. serverpins are written as REG_MULTI_SZ ServerPins. With includesharedkey False the collector key is left out of the script entirely (no parameter, no registry write) and the installer tells the operator to approve the enrollment request. trustcertinroot False (a CA certificate) skips the Root import; the script also re-checks basicConstraints on the endpoint. """ baseurl_literal = json.dumps(baseurl) certfile_literal = json.dumps(certfilename or '') pins_literal = '@(' + ', '.join(json.dumps(pin) for pin in (serverpins or [])) + ')' if includesharedkey: keyparam = ' [string] $CollectorKey = {0},\n'.format(json.dumps(collectorkey)) keycheck = ( 'if ([string]::IsNullOrWhiteSpace($CollectorKey)) {\n' ' throw "CollectorKey is not set. Regenerate the key in the endpoints UI and rebuild."\n' '}\n') keywrite = 'Set-HklmValueBothViews -SubKey "SOFTWARE\\GE\\ShopDB" -Name "CollectorKey" -Value $CollectorKey\n' sharedkeymode = 'included' enrollnotice = '' else: keyparam = '' keycheck = '' keywrite = ( 'if (-not (Test-Path (Join-Path $env:ProgramData "ShopDB\\EndpointAgent\\credential\\credential.bin"))) {\n' ' Write-Host " no shared key in this package; the agent will request enrollment." -ForegroundColor Yellow\n' '}\n') sharedkeymode = 'omitted' enrollnotice = ( 'if (-not (Test-Path (Join-Path $env:ProgramData "ShopDB\\EndpointAgent\\credential\\credential.bin"))) {\n' ' Write-Host " This PC will request enrollment. Approve it in ' 'Settings > Endpoints > Enrollment." -ForegroundColor Yellow\n' '}\n') return """# install.ps1 # ShopDB endpoint agent installer. Run from an ELEVATED (admin) PowerShell on # the endpoint. Provisions the registry credentials, copies the agent to # Program Files, and installs and starts the Windows service. The server URL and # collector key are stamped as defaults by the site agent generator. [CmdletBinding()] param( [string] $BaseUrl = {baseurl}, {keyparam} [string[]] $ServerPins = {serverpins}, [string] $InstallDir = "C:\\Program Files\\ShopDB\\EndpointAgent", [string] $ServiceName = "ShopDbEndpointAgent", [string] $TrayExeName = "shopdb-endpoint-tray.exe", [string] $CertFileName = {certfile}, [switch] $NoTray, # Scheduled task that runs the user-context probe INSIDE the operator's own # logon session. Mapped drives are per logon token, so this is the only # reliable way to collect them; see the registration block below. [string] $UserContextTaskName = "ShopDB user-context probe", [int] $UserContextTaskMinutes = 30, # The account the probe task runs as. Must match the account whose profile # the agent reads the spool from (FileLoggerProvider.ShopFloorUserName), or # the probe writes session data the service never looks at. [string] $UserContextTaskUser = "ShopFloor", [switch] $NoUserContextTask ) $ErrorActionPreference = "Stop" $sourceDir = Split-Path -Parent $MyInvocation.MyCommand.Path $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." }} if ([string]::IsNullOrWhiteSpace($BaseUrl)) {{ throw "BaseUrl is not set. Pass -BaseUrl 'http://:' when running the script." }} {keycheck}$sharedKeyMode = "{sharedkeymode}" $trustCertInRoot = {trustcert} # Write HKLM values to BOTH the 64-bit and 32-bit (WOW6432Node) registry views. # The agent reads Registry64 first then Registry32 and uses the first view that # yields a value, so a stale WOW6432Node copy from a prior install (for example # an old http:// BaseUrl) would silently win if we only wrote the 64-bit view. # Writing both keeps the two views in lockstep so re-provisioning cannot be # shadowed by a leftover value. function Set-HklmValueBothViews {{ param( [Parameter(Mandatory)] [string] $SubKey, [Parameter(Mandatory)] [string] $Name, [Parameter(Mandatory)] [string] $Value ) foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64, [Microsoft.Win32.RegistryView]::Registry32)) {{ $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey( [Microsoft.Win32.RegistryHive]::LocalMachine, $view) try {{ $key = $base.CreateSubKey($SubKey) try {{ $key.SetValue($Name, $Value, [Microsoft.Win32.RegistryValueKind]::String) }} finally {{ $key.Dispose() }} }} finally {{ $base.Dispose() }} }} }} # Lock SOFTWARE\\GE\\ShopDB to SYSTEM and Administrators, in both views. # # WHY. The shared fleet key is written here and stays until every consumer is # on a per-PC credential, and the DACL this key inherits by default grants # BUILTIN\\Users read - so any logged-on user on any bay could read the fleet # key, which is the exact exposure the per-PC credential migration exists to # close. Everything that legitimately reads this key runs as SYSTEM (the agent # service, the GE-Enforce reporters) or as an admin (the adoption tool, # Set-ShopDBServerPins.ps1). The tray reads no registry at all - it talks to the # service over the named pipe - so nothing loses access here. # # It also protects ServerPins from a standard user, which is what makes the # registry safe to treat as authoritative for pinning. function Protect-HklmShopdbKey {{ param([Parameter(Mandatory)] [string] $SubKey) $rights = [System.Security.AccessControl.RegistryRights] foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64, [Microsoft.Win32.RegistryView]::Registry32)) {{ $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey( [Microsoft.Win32.RegistryHive]::LocalMachine, $view) try {{ $key = $base.OpenSubKey( $SubKey, [Microsoft.Win32.RegistryKeyPermissionCheck]::ReadWriteSubTree, $rights::ChangePermissions -bor $rights::ReadPermissions) if (-not $key) {{ continue }} try {{ $acl = $key.GetAccessControl( [System.Security.AccessControl.AccessControlSections]::Access) # Break inheritance and DROP the inherited entries rather than # copying them in: copying is what silently preserves the # BUILTIN\\Users read this function exists to remove. $acl.SetAccessRuleProtection($true, $false) foreach ($rule in @($acl.Access)) {{ $null = $acl.RemoveAccessRule($rule) }} foreach ($sid in @("S-1-5-18", "S-1-5-32-544")) {{ $who = New-Object System.Security.Principal.SecurityIdentifier($sid) $acl.AddAccessRule((New-Object System.Security.AccessControl.RegistryAccessRule( $who, $rights::FullControl, [System.Security.AccessControl.InheritanceFlags]::ContainerInherit, [System.Security.AccessControl.PropagationFlags]::None, [System.Security.AccessControl.AccessControlType]::Allow))) }} $key.SetAccessControl($acl) }} finally {{ $key.Dispose() }} }} finally {{ $base.Dispose() }} }} }} # REG_MULTI_SZ in both views, compare-before-write so a reinstall with the same # pins leaves the value (and its last-write time) alone. function Set-HklmMultiStringBothViews {{ param( [Parameter(Mandatory)] [string] $SubKey, [Parameter(Mandatory)] [string] $Name, [Parameter(Mandatory)] [string[]] $Value ) $wanted = ($Value -join "`n") foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64, [Microsoft.Win32.RegistryView]::Registry32)) {{ $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey( [Microsoft.Win32.RegistryHive]::LocalMachine, $view) try {{ $key = $base.CreateSubKey($SubKey) try {{ $same = $false $existingValue = $key.GetValue($Name, $null) if ($null -ne $existingValue -and $key.GetValueKind($Name) -eq [Microsoft.Win32.RegistryValueKind]::MultiString) {{ $same = ((@($existingValue) -join "`n") -ceq $wanted) }} if ($same) {{ Write-Host " $Name unchanged ($view)" }} else {{ $key.SetValue($Name, [string[]] $Value, [Microsoft.Win32.RegistryValueKind]::MultiString) Write-Host " $Name written ($view)" }} }} finally {{ $key.Dispose() }} }} finally {{ $base.Dispose() }} }} }} Write-Host "== ShopDB endpoint agent install ==" -ForegroundColor Cyan Write-Host "BaseUrl : $BaseUrl" Write-Host "InstallDir : $InstallDir" Write-Host "ServiceName : $ServiceName" Write-Host "Shared key : $sharedKeyMode" # Per-PC credential lives under ProgramData\\ShopDB\\EndpointAgent\\credential and # belongs to the agent. This installer never writes or deletes it, so a reinstall # or upgrade keeps an enrolled PC enrolled. $credentialDir = Join-Path $env:ProgramData "ShopDB\\EndpointAgent\\credential" if (Test-Path (Join-Path $credentialDir "credential.bin")) {{ Write-Host " existing enrolled credential found; kept as is." }} # 0. Stop and remove any previous install so the file copy is a clean overwrite. $existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue if ($existing) {{ Write-Host "[0/6] Removing existing service $ServiceName ..." if ($existing.Status -ne "Stopped") {{ try {{ Stop-Service -Name $ServiceName -Force -ErrorAction Stop }} catch {{ Write-Warning "could not stop service: $($_.Exception.Message)" }} }} sc.exe delete $ServiceName | Out-Null Start-Sleep -Seconds 2 }} $trayBase = [System.IO.Path]::GetFileNameWithoutExtension($TrayExeName) foreach ($name in @("shopdbagent", $trayBase)) {{ foreach ($p in (Get-Process -Name $name -ErrorAction SilentlyContinue)) {{ try {{ Stop-Process -Id $p.Id -Force -ErrorAction Stop }} catch {{ }} }} }} # 1. Provision registry credentials (both 64-bit and 32-bit views). Write-Host "[1/6] Provisioning registry HKLM:\\SOFTWARE\\GE\\ShopDB (Registry64 + Registry32) ..." Set-HklmValueBothViews -SubKey "SOFTWARE\\GE\\ShopDB" -Name "BaseUrl" -Value $BaseUrl {keywrite} # Server public-key pins (current, plus next during a planned rotation). An # empty list leaves any ServerPins already on the PC untouched. $pinList = @($ServerPins | Where-Object {{ -not [string]::IsNullOrWhiteSpace($_) }}) if ($pinList.Count -gt 0) {{ Set-HklmMultiStringBothViews -SubKey "SOFTWARE\\GE\\ShopDB" -Name "ServerPins" -Value $pinList }} else {{ Write-Warning "no server pin in this package; ServerPins left as is." }} # Harden the key AFTER the writes: the DACL is replaced, not merged, so writing # first and locking second keeps this correct whether or not the key pre-existed. try {{ Protect-HklmShopdbKey -SubKey "SOFTWARE\\GE\\ShopDB" Write-Host " HKLM\\SOFTWARE\\GE\\ShopDB restricted to SYSTEM and Administrators." }} catch {{ Write-Warning "could not restrict HKLM\\SOFTWARE\\GE\\ShopDB: $($_.Exception.Message)" Write-Warning "the shared key on this PC stays readable by any logged-on user until that is fixed." }} # 1a. Trust the bundled server certificate, if one shipped. The agent's outbound # TLS to a self-signed or internal-CA server fails closed until the machine # trusts the cert. Importing into LocalMachine\\Root covers the SYSTEM service. # No-op when the deployment uses a publicly trusted (or already-trusted internal) # certificate, in which case no cert is bundled. # Kept for agents older than 1.20 and PowerShell download paths that do not pin. # Only a leaf is trusted: a CA in Root would trust everything it signs. if (-not [string]::IsNullOrWhiteSpace($CertFileName)) {{ $certPath = Join-Path $sourceDir $CertFileName if (Test-Path $certPath) {{ $certIsCa = -not $trustCertInRoot if (-not $certIsCa) {{ try {{ $probe = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($certPath) foreach ($ext in $probe.Extensions) {{ if ($ext -is [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension] -and $ext.CertificateAuthority) {{ $certIsCa = $true }} }} }} catch {{ }} }} if ($certIsCa) {{ Write-Warning "server certificate $CertFileName is a CA certificate; not importing it into LocalMachine\\Root." if ($pinList.Count -gt 0) {{ Write-Warning "ServerPins were written: agents 1.20 and newer trust the server by pin alone." }} Write-Warning "Agents older than 1.20, and PowerShell 5.1 downloads without pin support, will NOT trust this server." Write-Warning "Fix: regenerate the server certificate with the ShopDB generator (Settings > Endpoints > Server certificate; it does not set CA)," Write-Warning "or have an admin trust a non-CA server certificate on this PC with Install-ShopDBServerCertificate.ps1." }} else {{ Write-Host " trusting bundled server certificate $CertFileName ..." try {{ Import-Certificate -FilePath $certPath -CertStoreLocation Cert:\\LocalMachine\\Root | Out-Null }} catch {{ Write-Warning "could not import server certificate: $($_.Exception.Message)" }} }} }} else {{ Write-Warning "server certificate $CertFileName named but not found beside install.ps1." }} }} # 2. Copy agent files to the install directory. Write-Host "[2/6] Copying agent files to $InstallDir ..." New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null Get-ChildItem -Path $sourceDir -File | ForEach-Object {{ try {{ Unblock-File -Path $_.FullName -ErrorAction Stop }} catch {{ }} }} $agentSource = Join-Path $sourceDir "shopdbagent.exe" if (-not (Test-Path $agentSource)) {{ throw "shopdbagent.exe not found in $sourceDir. This package did not ship a prebuilt agent; stage the exe beside install.ps1 and rerun." }} Copy-Item -Path $agentSource -Destination $InstallDir -Force $appsettings = Join-Path $sourceDir "appsettings.json" if (Test-Path $appsettings) {{ Copy-Item -Path $appsettings -Destination $InstallDir -Force }} 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 + '"') # Export the branded icon from the tray exe so the shortcut shows the # ShopDB brand instead of the generic exe icon. The tray draws its icon # at runtime (no embedded Win32 icon), so we ask it to write an .ico into # the install dir and point the shortcut at it. $iconPath = Join-Path $InstallDir "shopdb-agent.ico" try {{ & $installedTray --export-icon $iconPath | Out-Null }} catch {{ Write-Warning "could not export shortcut icon: $($_.Exception.Message)" }} # 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" if (Test-Path $iconPath) {{ $shortcut.IconLocation = "$iconPath,0" }} $shortcut.Save() Write-Host " created Start Menu shortcut: $shortcutPath" }} catch {{ Write-Warning "could not create Start Menu shortcut: $($_.Exception.Message)" }} # Deliberately NOT launched here. This installer runs elevated, so # Start-Process would hand the tray an ADMIN token, and drive letters # are per logon token: the probe would then enumerate the installing # admin's drive table, not the operator's. Measured on a live endpoint, # the same two shares reported Unavailable from the elevated shell and # OK from the operator's own session, in both Win32_NetworkConnection # and Get-SmbMapping. Launching via explorer.exe does not fix this # either - it de-elevates but does NOT change account. # # The Run key above is the correct autostart: it fires at a genuine # logon, in the operator's own session. Collection no longer depends on # the tray at all - the scheduled task below owns it. Write-Host " tray will start at next logon (not launched from this elevated install)." }} }} # 2b. Register the user-context probe as a scheduled task owned by the SHOPFLOOR # USER. This is what makes mapped drives, user listeners and idle time # collectable: it is the only mechanism that starts a process in the operator's # own session with the operator's own token. # # RunLevel Limited is load-bearing, not a hardening nicety - the unelevated # token is precisely what sees the operator's mapped drives. # # UserId, NOT GroupId. A group-principal task is registered against the group # rather than against any one account, and a member of that group cannot invoke # it on demand: Start-ScheduledTask from the ShopFloor session returns access # denied, so the task only ever ran on its own trigger. That is exactly the bug # where "Refresh Status" appeared to do nothing. Registering to the specific # account makes the task startable by that account, which is what both the tray # and any manual retry depend on. The group form is kept only as a fallback for # a host where the account cannot be resolved - it still collects on a timer, # it just cannot be kicked on demand. $taskState = "skipped" if (-not $NoUserContextTask) {{ Write-Host " registering user-context probe task ..." $taskAgentExe = Join-Path $InstallDir "shopdbagent.exe" $taskRegistered = $false $taskDetail = "no principal accepted" try {{ $taskAction = New-ScheduledTaskAction -Execute $taskAgentExe -Argument "--collect-usercontext" # AtLogOn covers the normal case. The repeating trigger matters just as # much: AtLogOn does NOT fire for a session that is already signed in, # which is every install onto a machine someone is currently using. $taskTriggers = @(New-ScheduledTaskTrigger -AtLogOn) if ($UserContextTaskMinutes -gt 0) {{ $taskTriggers += New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes $UserContextTaskMinutes) }} $taskSettings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 5) # Try the machine-qualified account first, then the bare name. Interactive # LogonType so the task joins the existing interactive session instead of # creating a separate one with its own (empty) drive table. $taskCandidates = @( @{{ Kind = "user"; Id = "$env:COMPUTERNAME\\$UserContextTaskUser" }}, @{{ Kind = "user"; Id = $UserContextTaskUser }}, @{{ Kind = "group"; Id = "S-1-5-32-545" }}, @{{ Kind = "group"; Id = "BUILTIN\\Users" }} ) foreach ($candidate in $taskCandidates) {{ try {{ if ($candidate.Kind -eq "user") {{ $taskPrincipal = New-ScheduledTaskPrincipal -UserId $candidate.Id -LogonType Interactive -RunLevel Limited -ErrorAction Stop }} else {{ $taskPrincipal = New-ScheduledTaskPrincipal -GroupId $candidate.Id -RunLevel Limited -ErrorAction Stop }} Register-ScheduledTask -TaskName $UserContextTaskName -Action $taskAction -Trigger $taskTriggers -Principal $taskPrincipal -Settings $taskSettings -Force -ErrorAction Stop | Out-Null $taskRegistered = $true $taskDetail = "$($candidate.Kind):$($candidate.Id)" break }} catch {{ $taskDetail = $_.Exception.Message }} }} }} catch {{ $taskDetail = "could not build the task: $($_.Exception.Message)" }} if ($taskRegistered) {{ # Read the task back rather than trusting Register-ScheduledTask to have # returned cleanly: policy or a management agent can rewrite or drop a # task after registration, and a task that exists but runs elevated # looks healthy in the scheduler UI while producing wrong data. $verified = Get-ScheduledTask -TaskName $UserContextTaskName -ErrorAction SilentlyContinue if (-not $verified) {{ $taskState = "UNVERIFIED:task not found after registration" Write-Warning " user-context task registered but cannot be read back." }} elseif ([string] $verified.Principal.RunLevel -eq "Highest") {{ $taskState = "UNVERIFIED:elevated" Write-Warning " user-context task runs ELEVATED; mapped drives would be collected from the wrong token." }} elseif ($verified.Principal.GroupId) {{ # Registered, will collect on its triggers, but cannot be started on # demand by the operator - so "Refresh Status" will fall back to the # timer. Surfaced rather than reported as ok, because the symptom # (stale session data that never responds to a refresh) is otherwise # indistinguishable from a broken probe. $taskState = "DEGRADED:group principal $($verified.Principal.GroupId)" Write-Warning " user-context task fell back to a GROUP principal; on-demand refresh will not work, only the $UserContextTaskMinutes-minute interval." }} else {{ $taskState = "ok:$($verified.Principal.UserId)/$($verified.Principal.RunLevel)" Write-Host " user-context task registered and verified: $taskState" }} # AtLogOn will not fire for an already-signed-in session, so kick it once # rather than leaving a fresh install blind until the repeat interval. try {{ Start-ScheduledTask -TaskName $UserContextTaskName -ErrorAction Stop Write-Host " user-context task started once for the current session." }} catch {{ Write-Warning " could not start the task now: $($_.Exception.Message); it runs at next logon or interval." }} }} else {{ # Not fatal: the service still checks in, only the session-scoped fields # are missing, and the ledger records why. $taskState = "FAILED:$taskDetail" Write-Warning " could not register the user-context task: $taskDetail" Write-Warning " mapped drives and idle time will not be collected." }} }} # 2c. Start the tray in the operator's session. # # The Run key registered above only fires at LOGON, so installing onto a machine # someone is already signed in to leaves no tray until they sign out and back in. # The installer cannot start it directly - it is elevated, and the tray must run # unelevated in the user's own session - so we borrow the same mechanism that # solves this for the probe: a transient scheduled task with a Limited group # principal, run once and then deleted. # # Registered without a trigger; it exists only to be invoked immediately. The # tray is single-instance, so if one is somehow already running this is a no-op. if (-not $NoTray -and (Test-Path (Join-Path $InstallDir $TrayExeName))) {{ $launchTaskName = "ShopDB tray first launch" try {{ $launchExe = Join-Path $InstallDir $TrayExeName $launchAction = New-ScheduledTaskAction -Execute $launchExe -WorkingDirectory $InstallDir $launchSettings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries $launched = $false foreach ($groupId in @("S-1-5-32-545", "BUILTIN\\Users")) {{ try {{ $launchPrincipal = New-ScheduledTaskPrincipal -GroupId $groupId -RunLevel Limited -ErrorAction Stop Register-ScheduledTask -TaskName $launchTaskName -Action $launchAction -Principal $launchPrincipal -Settings $launchSettings -Force -ErrorAction Stop | Out-Null Start-ScheduledTask -TaskName $launchTaskName -ErrorAction Stop $launched = $true break }} catch {{ }} }} # Give the task a moment to spawn the tray before removing it: deleting a # task that is still starting can kill the process it just launched. if ($launched) {{ Start-Sleep -Seconds 3 Write-Host " tray started in the operator's session." }} else {{ Write-Warning " could not start the tray now; it will start at next logon via the Run key." }} # Transient by design: this is a one-shot launcher, not an autostart. # The Run key owns autostart, and leaving this behind would mean two # things racing to start the same single-instance tray at every logon. try {{ Unregister-ScheduledTask -TaskName $launchTaskName -Confirm:$false -ErrorAction Stop }} catch {{ Write-Warning " could not remove the transient launch task '$launchTaskName'; remove it manually." }} }} catch {{ Write-Warning " could not start the tray now: $($_.Exception.Message); it will start at next logon." }} }} # 3. Install and start the service. The agent supports its own service install. Write-Host "[3/6] Installing and starting the service ..." $installedAgent = Join-Path $InstallDir "shopdbagent.exe" $binPath = '"' + $installedAgent + '"' sc.exe create $ServiceName binPath= $binPath start= auto DisplayName= "ShopDB Endpoint Agent" | Out-Null sc.exe start $ServiceName | Out-Null # 4. Confirm the service (and tray, when installed) actually reached a running # state before we destroy the installer. sc.exe start returns before the service # is up, and a failed start would otherwise leave a half-installed endpoint with # the secret-bearing install.ps1 already deleted. Poll with a short backoff and # retry the start a few times; only proceed to cleanup once healthy. Write-Host "[4/6] Verifying agent runtime ..." $serviceOk = $false for ($attempt = 1; $attempt -le 5; $attempt++) {{ Start-Sleep -Seconds 3 $svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue if ($svc -and $svc.Status -eq "Running") {{ $serviceOk = $true; break }} Write-Host " service not running yet (attempt $attempt/5); retrying start ..." try {{ Start-Service -Name $ServiceName -ErrorAction Stop }} catch {{ }} }} if (-not $serviceOk) {{ throw "Service $ServiceName did not reach Running after 5 attempts. Leaving install.ps1 in place for diagnosis; re-run once resolved." }} Write-Host " service is running." -ForegroundColor Green {enrollnotice} $trayBase2 = [System.IO.Path]::GetFileNameWithoutExtension($TrayExeName) if (-not $NoTray -and (Test-Path (Join-Path $InstallDir $TrayExeName))) {{ # Report only; never relaunch from here. This block used to Start-Process the # tray when it was not found running, which would hand it this installer's # ADMIN token and reintroduce the wrong-drive-table bug. The launch task # above is the only place the tray is started during install, and it starts # it correctly - unelevated, in the operator's session. $trayFound = $false for ($attempt = 1; $attempt -le 3; $attempt++) {{ if (Get-Process -Name $trayBase2 -ErrorAction SilentlyContinue) {{ $trayFound = $true; break }} Start-Sleep -Seconds 2 }} if ($trayFound) {{ Write-Host " tray is running." -ForegroundColor Green }} else {{ Write-Warning "tray did not confirm running; it will start at next logon via the Run key." }} }} # 5. Preserve the install artifacts (minus the secret-bearing install.ps1) under # ProgramData so the uninstaller and appsettings/cert are available later, then # scrub install.ps1 from wherever it was run. A shared-key build carries the live # key in its default parameter, so it must not linger on the endpoint. Write-Host "[5/6] Archiving install artifacts and removing install.ps1 ..." $dataDir = Join-Path $env:ProgramData "ShopDB\\EndpointAgent" New-Item -ItemType Directory -Force -Path $dataDir | Out-Null foreach ($name in @("uninstall-shopdbagent.ps1", "appsettings.json", "README.txt", $CertFileName)) {{ if ([string]::IsNullOrWhiteSpace($name)) {{ continue }} $src = Join-Path $sourceDir $name if (Test-Path $src) {{ try {{ Copy-Item -Path $src -Destination $dataDir -Force }} catch {{ Write-Warning "could not archive $name : $($_.Exception.Message)" }} }} }} $uninstaller = Join-Path $dataDir "uninstall-shopdbagent.ps1" $installSelf = Join-Path $sourceDir "install.ps1" if (Test-Path $installSelf) {{ try {{ Remove-Item -Path $installSelf -Force -ErrorAction Stop; Write-Host " removed install.ps1." }} catch {{ Write-Warning "could not remove install.ps1 - delete it manually: $($_.Exception.Message)" }} }} Write-Host "[6/6] Done." -ForegroundColor Green # Append one line per install, including reinstalls and repeat installs of the # same version. The user-context task state is recorded because it is the single # point of failure for session-scoped collection: if mapped drives go missing # later, this answers "was the task ever registered on this host, and as what" # without needing the endpoint back. The ledger is a record, not a gate - a # failure to append must never fail an otherwise-good install. try {{ $ledgerDir = Join-Path $env:ProgramData "ShopDB\\Logs" New-Item -ItemType Directory -Force -Path $ledgerDir | Out-Null $ledgerPath = Join-Path $ledgerDir "shopdbagent-installs.log" $ledgerAgentVersion = "unknown" if (Test-Path $installedAgent) {{ $ledgerAgentVersion = (Get-Item $installedAgent).VersionInfo.FileVersion }} $ledgerTrayVersion = "not-installed" $ledgerTrayPath = Join-Path $InstallDir $TrayExeName if (Test-Path $ledgerTrayPath) {{ $ledgerTrayVersion = (Get-Item $ledgerTrayPath).VersionInfo.FileVersion }} $ledgerLine = "{{0}} host={{1}} installer={{2}} service={{3}} tray={{4}} dir={{5}} usercontexttask={{6}} sharedkey={{7}} serverpins={{8}}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss zzz"), $env:COMPUTERNAME, $env:USERNAME, $ledgerAgentVersion, $ledgerTrayVersion, $InstallDir, $taskState, $sharedKeyMode, $pinList.Count Add-Content -Path $ledgerPath -Value $ledgerLine -Encoding UTF8 Write-Host "Install ledger: $ledgerPath" Write-Host " $ledgerLine" -ForegroundColor DarkGray }} catch {{ Write-Warning "could not append to the install ledger: $($_.Exception.Message)" }} if (Test-Path $uninstaller) {{ Write-Host "Uninstaller: $uninstaller" -ForegroundColor Cyan Write-Host " To remove the agent, run that script from an elevated PowerShell." }} else {{ Write-Warning "uninstaller was not bundled; fetch uninstall-shopdbagent.ps1 from the ShopDB repo scripts/ folder if needed." }} """.format( baseurl=baseurl_literal, keyparam=keyparam, keycheck=keycheck, keywrite=keywrite, sharedkeymode=sharedkeymode, enrollnotice=enrollnotice, serverpins=pins_literal, trustcert='$true' if trustcertinroot else '$false', certfile=certfile_literal, ) def build_package_zip(baseurl, collectorkey, cadenceseconds, appsettings, certfilename=None, cert_pem=None, certpath=None, serverpins=None, includesharedkey=True, trustcertinroot=True): """Assemble the agent package ZIP in memory. The single place the archive is laid out, so the admin download route and the token-authenticated bootstrap download cannot drift apart in what they ship. Callers own validation, pin resolution and the appsettings they pass. """ buffer = io.BytesIO() with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as archive: archive.writestr('appsettings.json', json.dumps(appsettings, indent=2) + '\n') archive.writestr('install.ps1', _install_script(baseurl.rstrip('/'), collectorkey, cadenceseconds, certfilename, serverpins=serverpins, includesharedkey=includesharedkey, trustcertinroot=trustcertinroot)) archive.writestr('README.txt', _readme(includesharedkey)) if os.path.isfile(UNINSTALLER_PATH): archive.write(UNINSTALLER_PATH, 'uninstall-shopdbagent.ps1') agent = agentstore.current_path('agent') if agent and os.path.isfile(agent): archive.write(agent, 'shopdbagent.exe') tray = agentstore.current_path('tray') if tray and os.path.isfile(tray): archive.write(tray, 'shopdb-endpoint-tray.exe') if cert_pem: archive.writestr('server.crt', cert_pem) elif certpath: archive.write(certpath, 'server.crt') buffer.seek(0) return buffer def package_download_name(): """Versioned download filename for the package, or the plain one.""" agentversion = agentstore.current_version('agent') return ('shopdb-endpoint-agent-{0}.zip'.format(agentversion) if agentversion else 'shopdb-endpoint-agent.zip') # Filenames the package ships the staged binaries under, keyed by store binkey. STAGED_EXE_NAMES = { 'agent': 'shopdbagent.exe', 'tray': 'shopdb-endpoint-tray.exe', } def staged_binary_hashes(): """{binkey: lowercase sha256} for every staged binary, '' when none. The bootstrap script bakes these in and re-checks the downloaded ZIP against them on the endpoint, so a package swapped in transit is refused before anything is executed. Hashed from the file rather than read out of the store manifest so a legacy flat-file staging is covered too. """ hashes = {} for binkey in STAGED_EXE_NAMES: path = agentstore.current_path(binkey) digest = '' if path and os.path.isfile(path): hasher = hashlib.sha256() with open(path, 'rb') as handle: for chunk in iter(lambda: handle.read(65536), b''): hasher.update(chunk) digest = hasher.hexdigest() hashes[binkey] = digest return hashes def build_keyless_package(baseurl, cadenceseconds=900): """The package the bootstrap installer downloads: never a collector key. Pins this server exactly as the admin builder does with no upload - the server's own leaf certificate - and bundles that certificate when the app can read it, which is the self-signed topology where the endpoint would otherwise fail its own TLS validation. """ appsettings = dict(_BASE_APPSETTINGS) appsettings['cadenceseconds'] = cadenceseconds appsettings['apikey'] = '' certpath = _server_cert_path() certfilename = 'server.crt' if certpath else None currentpin, pinisca = _current_server_facts() serverpins = [pin for pin in (currentpin,) if pin] appsettings['serverpins'] = serverpins buffer = build_package_zip( baseurl, '', cadenceseconds, appsettings, certfilename=certfilename, certpath=certpath, serverpins=serverpins, includesharedkey=False, trustcertinroot=not pinisca) warning = CA_CERT_BUILD_WARNING if (currentpin and pinisca) else None return buffer, warning def _siteagent_status_payload(): """Whether a prebuilt agent binary is staged for packaging. Reports both the legacy flat-file staging and the versioned store so the UI can show current versions and an expandable retained-version history. """ agent_staged = agentstore.current_path('agent') is not None tray_staged = agentstore.current_path('tray') is not None return { 'agentstaged': agent_staged, 'traystaged': tray_staged, 'certavailable': _server_cert_path() is not None, 'serverpin': _current_server_pin(), 'sharedkeyallowed': shared_key_allowed(), 'agentcurrent': agentstore.current_version('agent'), 'traycurrent': agentstore.current_version('tray'), 'agentversions': agentstore.list_versions('agent'), 'trayversions': agentstore.list_versions('tray'), 'agentlegacy': agentstore.legacy_status('agent'), 'traylegacy': agentstore.legacy_status('tray'), } @endpoints_bp.route('/siteagent', methods=['GET']) @require_permission('endpoints.agentpackage') def get_siteagent(): """Report whether the site agent build has a prebuilt binary staged.""" return success_response(_siteagent_status_payload()) @endpoints_bp.route('/siteagent/package', methods=['POST']) @require_permission('endpoints.agentpackage') def package_siteagent(): """Bundle the site agent as a downloadable ZIP. Accepts either JSON or multipart/form-data with fields baseurl, collectorkey, cadenceseconds, bundlecert, includesharedkey, and nextserverpin. includesharedkey defaults to the site setting plugin.endpoints.sharedkeyallowed; when off, collectorkey is ignored and nothing in the package carries it. nextserverpin (64 lowercase hex) is shipped beside the current server pin for a planned certificate rotation. A multipart request may also carry a certfile upload: an operator-supplied .crt/.pem that is validated as X.509 and bundled so the installer trusts it as a root on the endpoint. This is the reverse-proxy path where the app cannot see its own TLS cert. When no cert is uploaded but bundlecert is set and this app terminates TLS with a readable certificate, that auto-detected cert ships instead. Returns an in-memory ZIP with the staged shopdbagent.exe (and tray when present), a non-secret appsettings.json, install.ps1, and README.txt. The collector key is embedded only in install.ps1 as its default parameter and is never stored or logged. When the pinned certificate is CA-flagged the response carries an X-ShopDB-Build-Warning header explaining that only pinning (agents 1.20 and newer) will trust the server. """ if request.form or request.files: source = request.form certupload = request.files.get('certfile') else: source = request.get_json(silent=True) or {} certupload = None baseurl = (source.get('baseurl') or '').strip() collectorkey = (source.get('collectorkey') or '').strip() cadenceseconds = source.get('cadenceseconds', 900) bundlecert = str(source.get('bundlecert', '')).lower() in ('1', 'true', 'yes', 'on') rawinclude = source.get('includesharedkey') if rawinclude is None or str(rawinclude).strip() == '': includesharedkey = shared_key_allowed() else: includesharedkey = str(rawinclude).lower() in ('1', 'true', 'yes', 'on') nextserverpin = (source.get('nextserverpin') or '').strip() if not includesharedkey: collectorkey = '' if not baseurl or (includesharedkey and not collectorkey): return error_response( ErrorCodes.VALIDATION_ERROR, 'baseurl and collectorkey are required' if includesharedkey else 'baseurl is required', http_code=400) if nextserverpin and not _PIN_PATTERN.match(nextserverpin): return error_response( ErrorCodes.VALIDATION_ERROR, 'nextserverpin must be 64 lowercase hex characters', http_code=400) try: cadenceseconds = int(cadenceseconds) except (TypeError, ValueError): return error_response( ErrorCodes.VALIDATION_ERROR, 'cadenceseconds must be an integer', http_code=400) if cadenceseconds < 60: return error_response( ErrorCodes.VALIDATION_ERROR, 'cadenceseconds must be at least 60', http_code=400) appsettings = dict(_BASE_APPSETTINGS) appsettings['cadenceseconds'] = cadenceseconds if not includesharedkey: appsettings['apikey'] = '' # An operator-uploaded cert wins over the auto-detected server cert: it is # the explicit reverse-proxy answer where the app cannot see its own TLS # cert. Validate it as X.509 and bundle the normalized PEM. Fall back to the # app-owned cert only when bundlecert is set and no upload was provided. cert_pem = None certpath = None if certupload is not None and certupload.filename: cert_pem = _validate_pem_cert(certupload.read()) if cert_pem is None: return error_response( ErrorCodes.VALIDATION_ERROR, 'certfile is not a valid PEM X.509 certificate', http_code=400) elif bundlecert: certpath = _server_cert_path() certfilename = 'server.crt' if (cert_pem or certpath) else None # Pin the leaf the server presents: the bundled/uploaded leaf, else this # server's own certificate. The SPKI pin ignores basicConstraints, so a # CA-flagged self-signed cert is still pinned; the CA flag only gates the # Root import. trustcertinroot = True currentpin = None pinisca = False if certfilename: if cert_pem: bundledbytes = cert_pem.encode('ascii') else: with open(certpath, 'rb') as handle: bundledbytes = handle.read() currentpin, bundledisca = _leaf_certificate_facts(bundledbytes) trustcertinroot = not bundledisca pinisca = bool(bundledisca) # An uploaded CA-flagged cert may be the server's own self-signed cert or an # internal CA root sent by mistake. Ship the server's own pin beside it so # the agent (which accepts any listed pin) works either way. serverownpin = None if cert_pem and pinisca: serverownpin = _current_server_pin() if serverownpin == currentpin: serverownpin = None if currentpin is None: currentpin, pinisca = _current_server_facts() warnings = [CA_CERT_BUILD_WARNING] if (currentpin and pinisca) else [] if serverownpin: warnings.append(CA_CERT_BOTH_PINS_WARNING) if nextserverpin and currentpin is None: return error_response( ErrorCodes.VALIDATION_ERROR, 'nextserverpin needs a current server pin; bundle or upload the ' 'server leaf certificate', http_code=400) serverpins = [pin for pin in (currentpin, serverownpin, nextserverpin) if pin] serverpins = list(dict.fromkeys(serverpins)) appsettings['serverpins'] = serverpins buffer = build_package_zip( baseurl, collectorkey, cadenceseconds, appsettings, certfilename=certfilename, cert_pem=cert_pem, certpath=certpath, serverpins=serverpins, includesharedkey=includesharedkey, trustcertinroot=trustcertinroot) # Stamp the staged agent version into the download filename so a DT operator # can see which agent build a package carries at a glance. This is cosmetic: # install.ps1 finds the exe by its fixed name inside the ZIP, so the download # name never affects the install. Falls back to the unversioned name when no # managed version is current (only a legacy flat file is staged). download_name = package_download_name() response = send_file( buffer, mimetype='application/zip', as_attachment=True, download_name=download_name) if warnings: response.headers[CA_WARNING_HEADER] = ' '.join(warnings) return response