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:
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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])?)*$')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user