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