Found in an end-to-end test on a Windows 11 VM against a self-signed server.
- Rollback: a 1.20 agent re-enrolls within a second of an un-enroll, so an
older agent pushed afterwards arrived on an enrolled PC and was refused.
Revoke now takes {"blockreenroll": true, "reason": ...}; a blocked PC is
never issued a credential and keeps working on the shared key until
POST .../assets/<assetid>/allow-reenroll. The Enrollment page offers the
block when un-enrolling and shows blocked PCs with an allow button.
- First upgrade of old agents: agents before 1.20 check the server
certificate when downloading the upgrade, so a store wave failed on PCs that
do not trust a self-signed server while the Migration tab said ready. A
failed download now marks the PC blocked with "server certificate not
trusted by this PC". New Install-ShopDBServerCertificate.ps1 (GE-Enforce,
SYSTEM) adds a non-CA server certificate to the trusted roots after checking
its thumbprint; the server certificate page offers a .cer download and the
SHA-1 thumbprint.
- The generated installer writes ServerPins (with an optional next pin for a
planned rotation), can leave the shared key out so the PC waits for
approval, never touches an existing credential, and skips the trusted-root
import for a CA certificate.
- The agent re-reads server pins about every 60 seconds and recycles pooled
connections within 2 minutes, so a pin change needs no restart.
- An alert can only be acknowledged by a PC it targets.
- A wave no longer counts a PC as failed after a later wave upgraded it.
- The scripts report a pin mismatch only when the pin really is missing, and
otherwise log the real error.
- Runbook: trust step before the first wave, rollback order, pin re-read,
verifying the pin against the live URL; agent contract updated.
1028 lines
48 KiB
Python
1028 lines
48 KiB
Python
"""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 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_pin():
|
|
"""Pin of this server's own leaf certificate, or None when not found/CA."""
|
|
path = _server_cert_path()
|
|
if not path:
|
|
return None
|
|
try:
|
|
with open(path, 'rb') as handle:
|
|
pin, isca = _leaf_certificate_facts(handle.read())
|
|
except OSError:
|
|
return None
|
|
return None if isca else pin
|
|
|
|
|
|
# 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 = (
|
|
'Write-Host " no shared key in this package; the agent will request enrollment." -ForegroundColor Yellow\n')
|
|
sharedkeymode = 'omitted'
|
|
enrollnotice = (
|
|
'Write-Host " This PC will request enrollment. Approve it in '
|
|
'Settings > Endpoints > Enrollment." -ForegroundColor Yellow\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://<host>:<port>' 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() }}
|
|
}}
|
|
}}
|
|
|
|
# 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."
|
|
}}
|
|
|
|
# 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."
|
|
}}
|
|
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 _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.
|
|
"""
|
|
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 bundled leaf when there is one; a CA bundle cannot be pinned (the
|
|
# agent hashes the leaf) so fall back to this server's own certificate.
|
|
trustcertinroot = True
|
|
currentpin = None
|
|
if certfilename:
|
|
if cert_pem:
|
|
bundledbytes = cert_pem.encode('ascii')
|
|
else:
|
|
with open(certpath, 'rb') as handle:
|
|
bundledbytes = handle.read()
|
|
bundledpin, bundledisca = _leaf_certificate_facts(bundledbytes)
|
|
trustcertinroot = not bundledisca
|
|
if not bundledisca:
|
|
currentpin = bundledpin
|
|
if currentpin is None:
|
|
currentpin = _current_server_pin()
|
|
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, nextserverpin) if pin]
|
|
serverpins = list(dict.fromkeys(serverpins))
|
|
appsettings['serverpins'] = serverpins
|
|
|
|
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)
|
|
# 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).
|
|
agentversion = agentstore.current_version('agent')
|
|
download_name = ('shopdb-endpoint-agent-{0}.zip'.format(agentversion)
|
|
if agentversion else 'shopdb-endpoint-agent.zip')
|
|
return send_file(
|
|
buffer,
|
|
mimetype='application/zip',
|
|
as_attachment=True,
|
|
download_name=download_name)
|