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