Make rollback, first upgrades and pin changes work as tested on Windows

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.
This commit is contained in:
cproudlock
2026-09-15 13:37:37 -04:00
parent 8eee6f19d8
commit 70d5531b4d
20 changed files with 1713 additions and 151 deletions

View File

@@ -9,7 +9,9 @@ and, when the route names a PC, binds the caller through bind_hostname().
X-API-Key The shared fleet key (or a collector-scoped managed
token). Accepted only while
plugin.endpoints.sharedkeyallowed is on, and never for a
PC that holds an active credential.
PC that holds an active credential. A PC blocked
from re-enrolling has no active credential, so an
older agent on it keeps working on the shared key.
Only the sha256 of a credential is stored. The full secret exists once, in the
enroll response.
@@ -79,6 +81,18 @@ def active_credential_for(assetid):
.first())
def reenroll_blocked(assetid):
"""True when an admin blocked this asset from enrolling again."""
from ..models import EndpointCredential
if assetid is None:
return False
return (EndpointCredential.query
.filter(EndpointCredential.assetid == assetid,
EndpointCredential.reenrollblocked.is_(True))
.first()) is not None
def resolve_credential(secret, touch=True):
"""Asset id for an active credential, else None."""
from ..models import EndpointCredential

View File

@@ -7,7 +7,8 @@ Agent-facing:
Admin (endpoints.fleet to read, admin role to change):
GET /enrollment credential state per computer + requests
POST /enrollment/requests/<id>/approve | reject
POST /enrollment/assets/<assetid>/revoke
POST /enrollment/assets/<assetid>/revoke body {"blockreenroll", "reason"}
POST /enrollment/assets/<assetid>/allow-reenroll lift a re-enrollment block
PUT /enrollment/settings
GET /enrollment/migration readiness of every computer for agent 1.20
POST /enrollment/migration/wave push the agent upgrade to a batch of PCs
@@ -58,6 +59,7 @@ REASON_ALREADY_ENROLLED = 'already enrolled'
REASON_AWAITING_APPROVAL = 'awaiting approval'
REASON_NONCE_CHANGED = 'nonce changed'
REASON_REJECTED = 'rejected'
REASON_REENROLL_BLOCKED = 'reenroll blocked'
MIGRATION_STATES = ('enrolled', 'pending', 'upgrading', 'ready', 'blocked',
'notreporting')
@@ -71,6 +73,18 @@ BLOCKER_NO_PACKAGE = 'no agent package in store'
BLOCKER_TOO_OLD = 'agent too old for remote upgrade'
BLOCKER_POLICY = 'run_script not permitted by policy'
BLOCKER_AWAITING_ENROLLMENT = 'awaiting enrollment'
BLOCKER_REENROLL_BLOCKED = 'reenroll blocked'
BLOCKER_CERT_UNTRUSTED = 'server certificate not trusted by this PC'
# How agents up to 1.19 word a download refused over the server certificate.
# Only a PC's own recorded failure shows it; the server cannot read a trust store.
_TLS_TRUST_FAILURES = (
'ssl connection could not be established',
'untrustedroot',
'remotecertificatechainerrors',
'the remote certificate is invalid',
'could not establish trust relationship',
)
_OFFLINE_AFTER = timedelta(hours=24)
_WAVE_MAXIMUM = 100
@@ -224,6 +238,8 @@ def enroll_endpoint():
'installation', http_code=403)
if agentauth.active_credential_for(row.matchedassetid) is not None:
return _pending(row, REASON_ALREADY_ENROLLED, row.matchedassetid)
if agentauth.reenroll_blocked(row.matchedassetid):
return _pending(row, REASON_REENROLL_BLOCKED, row.matchedassetid)
if row.matchedassetid is None or db.session.get(Asset, row.matchedassetid) is None:
return _pending(row, REASON_UNKNOWN_HOSTNAME)
return _issue(row, row.matchedassetid,
@@ -274,6 +290,8 @@ def enroll_endpoint():
return _pending(row, REASON_SERIAL_MISMATCH, assetid)
if agentauth.active_credential_for(assetid) is not None:
return _pending(row, REASON_ALREADY_ENROLLED, assetid)
if agentauth.reenroll_blocked(assetid):
return _pending(row, REASON_REENROLL_BLOCKED, assetid)
if sharedkeyauthenticated and agentauth.auto_enroll_enabled():
return _issue(row, assetid, EndpointCredential.ENROLLED_AUTO)
return _pending(row, REASON_AWAITING_APPROVAL, assetid)
@@ -347,8 +365,12 @@ def get_enrollment():
rows = []
for assetid, hostname, serialnumber in _computers():
credential = credentials.get(assetid)
blocked = bool(credential is not None and credential.reenrollblocked)
if credential is not None and credential.revokedat is None:
state = 'enrolled'
elif blocked:
# Its agent keeps asking; those requests are expected, not pending work.
state = 'revoked'
elif _is_pending(assetid, hostname, index):
state = 'pending'
elif credential is not None:
@@ -364,6 +386,9 @@ def get_enrollment():
'issuedat': _utciso(credential.issuedat) if credential else None,
'lastusedat': _utciso(credential.lastusedat) if credential else None,
'enrolledvia': credential.enrolledvia if credential else None,
'reenrollblocked': blocked,
'reenrollblockedreason': (credential.reenrollblockedreason
if blocked else None),
})
counts = Counter(row['state'] for row in rows)
@@ -430,6 +455,10 @@ def approve_enroll_request(requestid):
return error_response(ErrorCodes.CONFLICT,
'That computer is already enrolled; revoke its '
'credential first', http_code=409)
if agentauth.reenroll_blocked(assetid):
return error_response(ErrorCodes.CONFLICT,
'That computer is blocked from enrolling again; '
'allow re-enrollment first', http_code=409)
row.status = EndpointEnrollRequest.STATUS_APPROVED
row.matchedassetid = assetid
@@ -467,23 +496,83 @@ def reject_enroll_request(requestid):
@endpoints_bp.route('/enrollment/assets/<int:assetid>/revoke', methods=['POST'])
@require_role('admin')
def revoke_endpoint_credential(assetid):
"""Un-enroll a PC. Its agent gets CREDENTIAL_INVALID and enrolls again."""
"""Un-enroll a PC. Its agent gets CREDENTIAL_INVALID and enrolls again.
Body (optional) {"blockreenroll": true, "reason": "rollback"} also keeps the
PC from enrolling again until POST .../allow-reenroll. That is the step
before rolling a PC back to an agent that only knows the shared key: without
it the current agent re-enrolls within seconds and the older agent is then
refused. A PC whose credential is already revoked can still be blocked.
"""
payload = request.get_json(silent=True) or {}
if not isinstance(payload, dict):
return error_response(ErrorCodes.VALIDATION_ERROR,
'Body must be a JSON object')
blockreenroll = payload.get('blockreenroll', False)
if not isinstance(blockreenroll, bool):
return error_response(ErrorCodes.VALIDATION_ERROR,
'blockreenroll must be true or false')
reason = None
if blockreenroll:
reason, problem = _text_field(payload, 'reason', 64, False)
if problem is not None:
return error_response(ErrorCodes.VALIDATION_ERROR, problem)
reason = reason or None
credential = agentauth.active_credential_for(assetid)
if credential is None and blockreenroll:
credential = EndpointCredential.query.filter_by(assetid=assetid).first()
if credential is None:
return error_response(ErrorCodes.NOT_FOUND,
'This computer has no active credential',
http_code=404)
credential.revokedat = _naive_utcnow()
credential.revokedby = _username()
AuditLog.log('deleted', 'EndpointCredential', entityid=assetid,
entityname=credential.credentialprefix,
details='endpoint credential {} revoked'.format(
credential.credentialprefix))
wasactive = credential.revokedat is None
if wasactive:
credential.revokedat = _naive_utcnow()
credential.revokedby = _username()
details = []
if wasactive:
details.append('endpoint credential {} revoked'.format(
credential.credentialprefix))
if blockreenroll:
credential.reenrollblocked = True
credential.reenrollblockedreason = reason
details.append('re-enrollment blocked ({})'.format(reason or 'no reason'))
AuditLog.log('deleted' if wasactive else 'updated', 'EndpointCredential',
entityid=assetid, entityname=credential.credentialprefix,
details='; '.join(details))
db.session.commit()
return success_response(credential.to_dict(),
message='Endpoint credential revoked')
@endpoints_bp.route('/enrollment/assets/<int:assetid>/allow-reenroll',
methods=['POST'])
@require_role('admin')
def allow_endpoint_reenroll(assetid):
"""Lift a re-enrollment block. The PC enrolls on its next attempt."""
credential = EndpointCredential.query.filter_by(assetid=assetid).first()
if credential is None:
return error_response(ErrorCodes.NOT_FOUND,
'This computer has no credential record',
http_code=404)
if not credential.reenrollblocked:
return success_response(credential.to_dict(),
message='Re-enrollment was not blocked')
previous = credential.reenrollblockedreason
credential.reenrollblocked = False
credential.reenrollblockedreason = None
AuditLog.log('updated', 'EndpointCredential', entityid=assetid,
entityname=credential.credentialprefix,
changes={'reenrollblocked': {'old': True, 'new': False}},
details='re-enrollment allowed again (was blocked: {})'.format(
previous or 'no reason'))
db.session.commit()
return success_response(credential.to_dict(),
message='Re-enrollment allowed')
@endpoints_bp.route('/enrollment/settings', methods=['PUT'])
@require_role('admin')
def update_enrollment_settings():
@@ -546,6 +635,28 @@ def _store_has_package():
return not _version_below(current, CREDENTIAL_AGENT_VERSION)
def _latest_commands(assetids, target=None):
"""The newest run_script command per asset (optionally for one target)."""
if not assetids:
return {}
newest = (db.session.query(db.func.max(EndpointCommand.endpointcommandid))
.filter(EndpointCommand.assetid.in_(assetids),
EndpointCommand.action == 'run_script'))
if target is not None:
newest = newest.filter(EndpointCommand.target == target)
newest = newest.group_by(EndpointCommand.assetid)
return {command.assetid: command for command in EndpointCommand.query
.filter(EndpointCommand.endpointcommandid.in_(newest)).all()}
def _is_tls_trust_failure(command):
if command is None or command.status != EndpointCommand.STATUS_FAILED:
return False
text = '{} {}'.format(command.resultmessage or '',
command.resultjson or '').lower()
return any(marker in text for marker in _TLS_TRUST_FAILURES)
def compute_readiness(assetids=None):
"""Migration state for every active computer (or just assetids).
@@ -562,8 +673,11 @@ def compute_readiness(assetids=None):
return []
ids = [row[0] for row in computers]
active = {c.assetid for c in EndpointCredential.query
.filter(EndpointCredential.revokedat.is_(None)).all()}
credentialrows = EndpointCredential.query.filter(
EndpointCredential.assetid.in_(ids)).all()
active = {c.assetid for c in credentialrows if c.revokedat is None}
reenrollblocked = {c.assetid for c in credentialrows
if c.reenrollblocked and c.revokedat is not None}
index = _pending_index(_pending_requests())
states = {s.assetid: s for s in
EndpointState.query.filter(EndpointState.assetid.in_(ids)).all()}
@@ -579,6 +693,8 @@ def compute_readiness(assetids=None):
(EndpointCommand.STATUS_PENDING,
EndpointCommand.STATUS_RUNNING)))}
lastscripts = _latest_commands(ids)
haspackage = _store_has_package()
minimum = minimum_agent_version_for('run_script')
offlinecutoff = _naive_utcnow() - _OFFLINE_AFTER
@@ -591,6 +707,9 @@ def compute_readiness(assetids=None):
blockers = []
if assetid in active:
status = 'enrolled'
elif assetid in reenrollblocked:
status = 'blocked'
blockers.append(BLOCKER_REENROLL_BLOCKED)
elif _is_pending(assetid, hostname, index):
status = 'pending'
elif assetid in upgrading:
@@ -610,6 +729,8 @@ def compute_readiness(assetids=None):
blockers.append(BLOCKER_NO_PACKAGE)
if minimum and not agent_meets_minimum(agentversion, minimum):
blockers.append(BLOCKER_TOO_OLD)
if _is_tls_trust_failure(lastscripts.get(assetid)):
blockers.append(BLOCKER_CERT_UNTRUSTED)
if not _version_below(agentversion, CREDENTIAL_AGENT_VERSION):
blockers.append(BLOCKER_AWAITING_ENROLLMENT)
if not blockers and 'run_script' not in ALLOWED_ACTIONS_FOR(
@@ -628,6 +749,13 @@ def compute_readiness(assetids=None):
def _wave_progress(wave, scriptid):
"""Live progress of one wave.
failed counts a member only while it is still stuck: its newest agent
upgrade (from any wave or a single push) failed or expired, it is not
enrolled, and it does not report an agent at or above the credential
version. A PC a later wave upgraded stops counting here.
"""
members = wave.assetidlist
enrolled = set()
failed = set()
@@ -637,21 +765,23 @@ def _wave_progress(wave, scriptid):
EndpointCredential.assetid.in_(members),
EndpointCredential.revokedat.is_(None)).all()}
if scriptid is not None:
commands = (EndpointCommand.query
.filter(EndpointCommand.assetid.in_(members),
EndpointCommand.action == 'run_script',
EndpointCommand.target == f'script:{scriptid}',
EndpointCommand.createdat >= wave.createdat)
.order_by(EndpointCommand.createdat.asc())
.all())
latest = {}
for command in commands:
queued.add(command.assetid)
latest[command.assetid] = command
failed = {assetid for assetid, command in latest.items()
target = f'script:{scriptid}'
queued = {row[0] for row in db.session.query(EndpointCommand.assetid)
.filter(EndpointCommand.assetid.in_(members),
EndpointCommand.action == 'run_script',
EndpointCommand.target == target,
EndpointCommand.createdat >= wave.createdat)
.distinct()}
versions = dict(db.session.query(EndpointState.assetid,
EndpointState.agentversion)
.filter(EndpointState.assetid.in_(members)).all())
failed = {assetid for assetid, command
in _latest_commands(members, target).items()
if assetid not in enrolled
and command.status in (EndpointCommand.STATUS_FAILED,
EndpointCommand.STATUS_EXPIRED)}
EndpointCommand.STATUS_EXPIRED)
and _version_below(versions.get(assetid),
CREDENTIAL_AGENT_VERSION)}
return {
'waveid': wave.waveid,
'createdat': _utciso(wave.createdat),

View File

@@ -4164,6 +4164,14 @@ def acknowledge_fleet_alert(notification_id: int):
return error_response(ErrorCodes.VALIDATION_ERROR,
'hostname does not match provided assetid')
# Same targeting as the /alerts list. An alert this PC was never shown
# answers exactly like an unknown one, so the route cannot probe scopes.
facts = _endpoint_scope_facts(hostname) or {}
if not _alert_targets_endpoint(notification.alertscope, facts):
return error_response(ErrorCodes.NOT_FOUND,
f'Notification {notification_id} not found',
http_code=404)
# Check if already acknowledged (idempotent)
existing = (NotificationAcknowledgement.query
.filter_by(notificationid=notification_id, assetid=assetid)
@@ -4196,6 +4204,21 @@ def acknowledge_fleet_alert(notification_id: int):
# Server certificate management
# =============================================================================
def _sha1_thumbprint(pembytes):
"""Uppercase hex SHA-1 of a PEM certificate, as Windows shows a thumbprint.
None when the file does not parse.
"""
from cryptography import x509
from cryptography.hazmat.primitives import hashes
try:
certificate = x509.load_pem_x509_certificate(pembytes)
except ValueError:
return None
return certificate.fingerprint(hashes.SHA1()).hex().upper()
def _spki_sha256(pembytes):
"""Lowercase hex SHA-256 of a PEM certificate's SubjectPublicKeyInfo.
@@ -4299,6 +4322,7 @@ def get_server_certificate():
'not_after': not_after,
'thumbprint': thumbprint,
'spkisha256': _spki_sha256(cert_path.read_bytes()),
'sha1thumbprint': _sha1_thumbprint(cert_path.read_bytes()),
'san_list': san_list,
'expires_in_days': expires_in_days,
'is_self_signed': is_self_signed
@@ -4316,6 +4340,36 @@ def get_server_certificate():
http_code=500)
@endpoints_bp.route('/settings/server-cert/download', methods=['GET'])
@require_permission('endpoints.fleet')
def download_server_certificate():
"""The PUBLIC server certificate as a DER .cer file, never the key.
For PCs on agents older than 1.20, which check the certificate chain when
they download the upgrade script: import this into the PC's Trusted Root
store before a migration wave reaches it.
"""
from cryptography import x509
from cryptography.hazmat.primitives import serialization
cert_path = _server_cert_dir() / 'server.crt'
if not cert_path.exists():
return error_response(ErrorCodes.NOT_FOUND,
'Server certificate not found', http_code=404)
try:
certificate = x509.load_pem_x509_certificate(cert_path.read_bytes())
except ValueError:
return error_response(ErrorCodes.INTERNAL_ERROR,
'The server certificate file does not parse',
http_code=500)
body = certificate.public_bytes(serialization.Encoding.DER)
response = current_app.response_class(body, mimetype='application/pkix-cert')
response.headers['Content-Disposition'] = \
'attachment; filename="shopdb-server.cer"'
response.headers['Cache-Control'] = 'no-store'
return response
_CERT_HOSTNAME_PATTERN = re.compile(
r'^(\*\.)?(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?'
r'(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$')

View File

@@ -21,11 +21,18 @@ 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
@@ -34,9 +41,12 @@ from flask import request, current_app, send_file
from shopdb.api import (success_response, error_response, ErrorCodes,
require_permission)
from .routes import endpoints_bp
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')
@@ -110,6 +120,49 @@ def _validate_pem_cert(raw):
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.
@@ -175,20 +228,42 @@ FILES
USAGE
1. Copy this whole folder to the target endpoint.
2. Right-click install.ps1 and Run with PowerShell as administrator. It
writes the server URL and collector key to HKLM\\SOFTWARE\\GE\\ShopDB,
{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
install.ps1 carries a live collector key in its default -CollectorKey value.
Treat this package as a secret. The key is not recoverable from the ShopDB
server after it was generated; regenerate the key in the UI to revoke it.
"""
{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):
@@ -196,7 +271,9 @@ def _staged_path(relpath):
return os.path.join(current_app.instance_path, relpath)
def _install_script(baseurl, collectorkey, cadenceseconds, certfilename=None):
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
@@ -206,10 +283,34 @@ def _install_script(baseurl, collectorkey, cadenceseconds, certfilename=None):
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)
collectorkey_literal = json.dumps(collectorkey)
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
@@ -219,7 +320,7 @@ def _install_script(baseurl, collectorkey, cadenceseconds, certfilename=None):
[CmdletBinding()]
param(
[string] $BaseUrl = {baseurl},
[string] $CollectorKey = {collectorkey},
{keyparam} [string[]] $ServerPins = {serverpins},
[string] $InstallDir = "C:\\Program Files\\ShopDB\\EndpointAgent",
[string] $ServiceName = "ShopDbEndpointAgent",
[string] $TrayExeName = "shopdb-endpoint-tray.exe",
@@ -251,9 +352,8 @@ if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administra
if ([string]::IsNullOrWhiteSpace($BaseUrl)) {{
throw "BaseUrl is not set. Pass -BaseUrl 'http://<host>:<port>' when running the script."
}}
if ([string]::IsNullOrWhiteSpace($CollectorKey)) {{
throw "CollectorKey is not set. Regenerate the key in the endpoints UI and rebuild."
}}
{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
@@ -280,10 +380,55 @@ function Set-HklmValueBothViews {{
}}
}}
# 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
@@ -306,21 +451,48 @@ foreach ($name in @("shopdbagent", $trayBase)) {{
# 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
Set-HklmValueBothViews -SubKey "SOFTWARE\\GE\\ShopDB" -Name "CollectorKey" -Value $CollectorKey
{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) {{
Write-Host " trusting bundled server certificate $CertFileName ..."
try {{
Import-Certificate -FilePath $certPath -CertStoreLocation Cert:\\LocalMachine\\Root | Out-Null
$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)" }}
}}
catch {{ Write-Warning "could not import server certificate: $($_.Exception.Message)" }}
}}
else {{
Write-Warning "server certificate $CertFileName named but not found beside install.ps1."
@@ -578,7 +750,7 @@ 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
@@ -601,8 +773,8 @@ if (-not $NoTray -and (Test-Path (Join-Path $InstallDir $TrayExeName))) {{
# 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. install.ps1 carries the live
# collector key in its default parameter, so it must not linger on the endpoint.
# 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
@@ -618,8 +790,8 @@ $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 (carried the collector key)." }}
catch {{ Write-Warning "could not remove install.ps1 - delete it manually, it contains the collector key: $($_.Exception.Message)" }}
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
@@ -639,7 +811,7 @@ try {{
$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}}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss zzz"), $env:COMPUTERNAME, $env:USERNAME, $ledgerAgentVersion, $ledgerTrayVersion, $InstallDir, $taskState
$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
@@ -655,7 +827,13 @@ else {{
}}
""".format(
baseurl=baseurl_literal,
collectorkey=collectorkey_literal,
keyparam=keyparam,
keycheck=keycheck,
keywrite=keywrite,
sharedkeymode=sharedkeymode,
enrollnotice=enrollnotice,
serverpins=pins_literal,
trustcert='$true' if trustcertinroot else '$false',
certfile=certfile_literal,
)
@@ -672,6 +850,8 @@ def _siteagent_status_payload():
'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'),
@@ -694,7 +874,12 @@ def package_siteagent():
"""Bundle the site agent as a downloadable ZIP.
Accepts either JSON or multipart/form-data with fields baseurl,
collectorkey, cadenceseconds, and bundlecert. A multipart request may also
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
@@ -717,11 +902,25 @@ def package_siteagent():
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 baseurl or not collectorkey:
if not includesharedkey:
collectorkey = ''
if not baseurl or (includesharedkey and not collectorkey):
return error_response(
ErrorCodes.VALIDATION_ERROR,
'baseurl and collectorkey are required',
'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:
@@ -739,6 +938,8 @@ def package_siteagent():
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
@@ -758,14 +959,43 @@ def package_siteagent():
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))
archive.writestr('README.txt', _README)
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')

View File

@@ -126,6 +126,11 @@ public static class ShopDbServerPinValidator
return Pins.Contains(LastPresented);
}
public static bool IsPinned(string pin)
{
return !String.IsNullOrEmpty(pin) && Pins.Contains(pin);
}
// Walks the DER: Certificate > TBSCertificate > [0] version (optional),
// serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo.
public static string SpkiSha256Hex(byte[] der)
@@ -264,8 +269,20 @@ function Get-ShopDbHttpFailure($ErrorRecord) {
try { $code = [string](($text | ConvertFrom-Json).data.error.code) } catch {}
}
$presented = ''
if ('ShopDbServerPinValidator' -as [type]) { $presented = [string][ShopDbServerPinValidator]::LastPresented }
return @{ Status = $status; Code = $code; PresentedPin = $presented }
$matched = $false
if ('ShopDbServerPinValidator' -as [type]) {
$presented = [string][ShopDbServerPinValidator]::LastPresented
$matched = [ShopDbServerPinValidator]::IsPinned($presented)
}
# Innermost exception text: the outer WebException hides the real cause
# (unexpected EOF, connection reset, trust failure).
$message = ''
$exception = $ErrorRecord.Exception
while ($exception) {
if ($exception.Message) { $message = $exception.Message }
$exception = $exception.InnerException
}
return @{ Status = $status; Code = $code; PresentedPin = $presented; PinMatched = $matched; Message = $message }
}
function Write-ShopDbAuthFailure($Failure) {
@@ -285,7 +302,14 @@ function Write-ShopDbAuthFailure($Failure) {
}
default {
if ($Failure.Status -eq 0 -and $Failure.PresentedPin) {
Log "ERROR TLS refused: server key pin $($Failure.PresentedPin) is not in ServerPins"
# A pin was seen, but only an absent pin is a pin failure. A
# matched pin means the handshake passed and something later
# failed (server dropped mid-transfer, timeout).
if ($Failure.PinMatched) {
Log "ERROR request failed after the server key pin matched ($($Failure.PresentedPin)): $($Failure.Message)"
} else {
Log "ERROR TLS refused: server key pin $($Failure.PresentedPin) is not in ServerPins"
}
}
}
}