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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user