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 31ed9a5fde
commit da4b224ac1
20 changed files with 1713 additions and 151 deletions

View File

@@ -267,6 +267,102 @@ def test_credential_is_invalid_after_revoke(client, db, app, keyheaders,
assert reissued != secret
def test_revoke_with_block_keeps_the_pc_off_credentials(client, db, app,
keyheaders,
auth_headers):
from plugins.endpoints.models import EndpointCredential
assetid = _computer(db, 'BAY-01')
secret = _credential(client, keyheaders)
revoke = client.post(f'/api/endpoints/enrollment/assets/{assetid}/revoke',
json={'blockreenroll': True, 'reason': 'rollback'},
headers=auth_headers)
assert revoke.status_code == 200, revoke.get_data(as_text=True)
data = revoke.get_json()['data']
assert data['revokedat'] is not None
assert data['reenrollblocked'] is True
assert data['reenrollblockedreason'] == 'rollback'
# The current agent asks again at once: refused, with the reason.
again = _enroll(client, keyheaders)
assert again.status_code == 202, again.get_data(as_text=True)
assert again.get_json()['data']['reason'] == 'reenroll blocked'
assert client.get('/api/endpoints/alerts',
headers={'X-Endpoint-Credential': secret}).status_code == 401
# An older agent on the shared key keeps working.
assert client.get('/api/endpoints/alerts?hostname=BAY-01',
headers=keyheaders).status_code == 200
listing = client.get('/api/endpoints/enrollment',
headers=auth_headers).get_json()['data']
row = next(r for r in listing['computers'] if r['assetid'] == assetid)
assert row['reenrollblocked'] is True
assert row['reenrollblockedreason'] == 'rollback'
assert row['state'] == 'revoked'
# Blocked also means an approval cannot hand one out.
requestid = again.get_json()['data']['requestid']
approve = client.post(
f'/api/endpoints/enrollment/requests/{requestid}/approve',
json={'assetid': assetid}, headers=auth_headers)
assert approve.status_code == 409
allow = client.post(
f'/api/endpoints/enrollment/assets/{assetid}/allow-reenroll',
headers=auth_headers)
assert allow.status_code == 200, allow.get_data(as_text=True)
assert allow.get_json()['data']['reenrollblocked'] is False
row = EndpointCredential.query.filter_by(assetid=assetid).one()
assert row.reenrollblockedreason is None
reissued = _credential(client, keyheaders)
assert reissued != secret
def test_blocked_pc_under_shared_key_off_is_refused_everything(
client, db, app, keyheaders, auth_headers):
assetid = _computer(db, 'BAY-01')
_credential(client, keyheaders)
client.post(f'/api/endpoints/enrollment/assets/{assetid}/revoke',
json={'blockreenroll': True}, headers=auth_headers)
_setting(db, 'plugin.endpoints.sharedkeyallowed', False)
refused = client.get('/api/endpoints/alerts?hostname=BAY-01',
headers=keyheaders)
assert refused.status_code == 401
assert _code(refused) == 'SHARED_KEY_REFUSED'
def test_revoke_block_validation_and_revoked_rows(client, db, app, keyheaders,
auth_headers):
assetid = _computer(db, 'BAY-01')
nocredential = _computer(db, 'BAY-02', serialnumber='SER-2')
url = f'/api/endpoints/enrollment/assets/{assetid}/revoke'
_credential(client, keyheaders)
assert client.post(url, json={'blockreenroll': 'yes'},
headers=auth_headers).status_code == 400
assert client.post(url, json={'blockreenroll': True, 'reason': 'x' * 65},
headers=auth_headers).status_code == 400
# Plain revoke, then block the already-revoked credential afterwards.
assert client.post(url, headers=auth_headers).status_code == 200
assert client.post(url, headers=auth_headers).status_code == 404
blocked = client.post(url, json={'blockreenroll': True},
headers=auth_headers)
assert blocked.status_code == 200
assert blocked.get_json()['data']['reenrollblocked'] is True
assert client.post(
f'/api/endpoints/enrollment/assets/{nocredential}/revoke',
json={'blockreenroll': True}, headers=auth_headers).status_code == 404
assert client.post(
f'/api/endpoints/enrollment/assets/{nocredential}/allow-reenroll',
headers=auth_headers).status_code == 404
def test_garbage_credential_is_invalid(client, db, app):
response = client.get('/api/endpoints/commands/next',
headers={'X-Endpoint-Credential': 'sdbe_nope'})
@@ -349,7 +445,7 @@ def test_command_result_for_own_command_needs_no_hostname(client, db, app,
assert response.status_code == 200, response.get_data(as_text=True)
def _fleet_alert(db):
def _fleet_alert(db, alertscope=None):
from plugins.notifications.models import Notification, NotificationType
ntype = NotificationType(typename='Fleet Alert', typecolor='#d93f3f',
@@ -358,7 +454,7 @@ def _fleet_alert(db):
db.session.flush()
note = Notification(notificationtypeid=ntype.notificationtypeid,
notification='Line 3 is down', alertfleet=True,
alerttimeout=60, isactive=True)
alerttimeout=60, isactive=True, alertscope=alertscope)
db.session.add(note)
db.session.commit()
return note.notificationid
@@ -402,6 +498,37 @@ def test_alert_ack_with_no_assetid_means_the_caller(client, db, app,
notificationid=notificationid, assetid=assetid).count() == 1
def test_alert_ack_refuses_an_alert_not_scoped_to_the_caller(
client, db, app, keyheaders):
from plugins.notifications.models import NotificationAcknowledgement
_computer(db, 'BAY-01')
_computer(db, 'BAY-02', serialnumber='SER-2')
_computer(db, 'BAY-03', serialnumber='SER-3')
secret = _credential(client, keyheaders)
notificationid = _fleet_alert(db, alertscope={'hostname': ['BAY-02',
'BAY-03']})
url = f'/api/endpoints/alerts/{notificationid}/acknowledge'
unknown = client.post('/api/endpoints/alerts/999999/acknowledge', json={},
headers={'X-Endpoint-Credential': secret})
bycredential = client.post(url, json={},
headers={'X-Endpoint-Credential': secret})
assert bycredential.status_code == 404
assert bycredential.get_json()['data']['error']['code'] == \
unknown.get_json()['data']['error']['code']
bysharedkey = client.post(url, json={'hostname': 'BAY-03'},
headers=keyheaders)
assert bysharedkey.status_code == 201, bysharedkey.get_data(as_text=True)
_computer(db, 'BAY-04', serialnumber='SER-4')
outside = client.post(url, json={'hostname': 'BAY-04'}, headers=keyheaders)
assert outside.status_code == 404
assert NotificationAcknowledgement.query.filter_by(
notificationid=notificationid).count() == 1
def test_collector_computers_refuses_another_hostname(client, db, app,
keyheaders):
_computer(db, 'BAY-01')
@@ -487,6 +614,8 @@ def test_contract_exports_answer_through_the_endpoints_plugin(client, db, app,
('post', '/api/endpoints/enrollment/requests/1/approve'),
('post', '/api/endpoints/enrollment/requests/1/reject'),
('post', '/api/endpoints/enrollment/assets/1/revoke'),
('post', '/api/endpoints/enrollment/assets/1/allow-reenroll'),
('get', '/api/endpoints/settings/server-cert/download'),
('put', '/api/endpoints/enrollment/settings'),
('post', '/api/endpoints/enrollment/migration/wave'),
])
@@ -586,4 +715,17 @@ def test_server_cert_reports_the_spki_pin(client, db, auth_headers, tmp_path,
response = client.get('/api/endpoints/settings/server-cert',
headers=auth_headers)
assert response.status_code == 200, response.get_data(as_text=True)
assert response.get_json()['data']['spkisha256'] == expected
data = response.get_json()['data']
assert data['spkisha256'] == expected
sha1 = certificate.fingerprint(hashes.SHA1()).hex().upper()
assert data['sha1thumbprint'] == sha1
assert ':' not in data['sha1thumbprint']
download = client.get('/api/endpoints/settings/server-cert/download',
headers=auth_headers)
assert download.status_code == 200
assert download.mimetype == 'application/pkix-cert'
assert 'attachment' in download.headers['Content-Disposition']
assert '.cer' in download.headers['Content-Disposition']
assert download.data == certificate.public_bytes(serialization.Encoding.DER)
assert b'PRIVATE' not in download.data