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

@@ -276,3 +276,140 @@ def test_wave_without_ready_pcs_creates_nothing(client, db, app, auth_headers):
def test_wave_validation(client, db, app, auth_headers, body):
assert client.post(WAVE_URL, json=body,
headers=auth_headers).status_code == 400
def test_a_reenroll_blocked_pc_is_blocked_not_ready(client, db, app,
auth_headers):
from plugins.endpoints.api.enrollment import issue_credential
from plugins.endpoints.models import EndpointEnrollRequest
_publish_agent()
assetid = _pc(db, 'PC-ROLLBACK')
issue_credential(assetid, 'auto')
db.session.commit()
response = client.post(
f'/api/endpoints/enrollment/assets/{assetid}/revoke',
json={'blockreenroll': True, 'reason': 'rollback'},
headers=auth_headers)
assert response.status_code == 200, response.get_data(as_text=True)
# Its agent keeps asking; that request must not turn the row into pending.
db.session.add(EndpointEnrollRequest(
hostname='PC-ROLLBACK', serialnumber='SER', enrollnonce='n' * 40,
status='pending', reason='reenroll blocked', matchedassetid=assetid,
requestedat=_now(), lastseenat=_now()))
db.session.commit()
row = _by_host(_rows(client, auth_headers))['PC-ROLLBACK']
assert row['state'] == 'blocked'
assert row['blockers'] == ['reenroll blocked']
wave = client.post(WAVE_URL, json={'assetids': [assetid]},
headers=auth_headers).get_json()['data']
assert wave['skipped'] == [{'assetid': assetid,
'reason': 'reenroll blocked'}]
client.post(f'/api/endpoints/enrollment/assets/{assetid}/allow-reenroll',
headers=auth_headers)
assert _by_host(_rows(client, auth_headers))['PC-ROLLBACK']['state'] \
== 'pending'
@pytest.mark.parametrize('message,detail', [
('Script download failed: The SSL connection could not be established, '
'see inner exception.', None),
('Script download failed', {'error': 'The remote certificate is invalid '
'because of errors in the certificate chain: '
'UntrustedRoot'}),
('Invoke-WebRequest: Could not establish trust relationship for the '
'SSL/TLS secure channel.', None),
])
def test_a_tls_trust_failure_blocks_the_pc(client, db, app, auth_headers,
message, detail):
import json
from plugins.endpoints.models import EndpointCommand
_publish_agent()
assetid = _pc(db, 'PC-UNTRUSTED')
otherid = _pc(db, 'PC-OTHERFAIL')
agentregistry.register_current_agent_package()
scriptid = agentupgradescript.seed_upgrade_script()['scriptid']
db.session.add(EndpointCommand(
assetid=assetid, action='run_script', target=f'script:{scriptid}',
status=EndpointCommand.STATUS_FAILED, resultmessage=message,
resultjson=json.dumps(detail) if detail is not None else None,
completedat=_now()))
db.session.add(EndpointCommand(
assetid=otherid, action='run_script', target=f'script:{scriptid}',
status=EndpointCommand.STATUS_FAILED,
resultmessage='Script exited with code 1', completedat=_now()))
db.session.commit()
rows = _by_host(_rows(client, auth_headers))
assert rows['PC-UNTRUSTED']['state'] == 'blocked'
assert rows['PC-UNTRUSTED']['blockers'] == [
'server certificate not trusted by this PC']
assert rows['PC-OTHERFAIL']['state'] == 'ready'
def test_a_later_success_clears_the_tls_blocker(client, db, app, auth_headers):
from plugins.endpoints.models import EndpointCommand
_publish_agent()
assetid = _pc(db, 'PC-FIXED')
agentregistry.register_current_agent_package()
scriptid = agentupgradescript.seed_upgrade_script()['scriptid']
db.session.add(EndpointCommand(
assetid=assetid, action='run_script', target=f'script:{scriptid}',
status=EndpointCommand.STATUS_FAILED,
resultmessage='RemoteCertificateChainErrors', completedat=_now()))
db.session.flush()
db.session.add(EndpointCommand(
assetid=assetid, action='run_script', target='script:999',
status=EndpointCommand.STATUS_SUCCEEDED, completedat=_now()))
db.session.commit()
assert _by_host(_rows(client, auth_headers))['PC-FIXED']['state'] == 'ready'
def test_wave_failed_forgets_a_pc_a_later_wave_upgraded(client, db, app,
auth_headers):
from plugins.endpoints.models import EndpointCommand, EndpointState
_publish_agent()
upgraded = _pc(db, 'PC-UPGRADED')
retried = _pc(db, 'PC-RETRIED')
stuck = _pc(db, 'PC-STUCK')
first = client.post(WAVE_URL, json={'assetids': [upgraded, retried, stuck]},
headers=auth_headers)
assert first.status_code == 201, first.get_data(as_text=True)
for command in EndpointCommand.query.all():
command.status = EndpointCommand.STATUS_FAILED
db.session.commit()
assert _rows(client, auth_headers)['waves'][0]['failed'] == 3
second = client.post(WAVE_URL, json={'assetids': [upgraded, retried]},
headers=auth_headers)
assert second.status_code == 201, second.get_data(as_text=True)
# The later wave replaced the agent on PC-UPGRADED, which restarted before
# reporting, so its command expired; it has not enrolled yet. PC-RETRIED's
# push reported success and is waiting to enroll on its old version string.
latest = {command.assetid: command for command in EndpointCommand.query
.filter_by(status=EndpointCommand.STATUS_PENDING).all()}
latest[upgraded].status = EndpointCommand.STATUS_EXPIRED
latest[retried].status = EndpointCommand.STATUS_SUCCEEDED
EndpointState.query.filter_by(assetid=upgraded).one().agentversion = '1.20.0'
db.session.commit()
failed = {wave['waveid']: wave['failed']
for wave in _rows(client, auth_headers)['waves']}
assert failed == {first.get_json()['data']['waveid']: 1,
second.get_json()['data']['waveid']: 0}
# A newer failure counts again, in every wave the PC belongs to.
latest[retried].status = EndpointCommand.STATUS_FAILED
db.session.commit()
failed = {wave['waveid']: wave['failed']
for wave in _rows(client, auth_headers)['waves']}
assert failed == {first.get_json()['data']['waveid']: 2,
second.get_json()['data']['waveid']: 1}