Let key-less PCs ask to enroll, and let an admin retry a trust-blocked PC

Found in the Windows VM re-test.

- A PC installed without the shared key got 401 from POST
  /api/endpoints/enroll while the shared key was allowed, so no request was
  recorded and no admin could approve it. Enroll now never refuses: a valid
  shared key only decides auto-issue on a clean match; no key or an invalid
  key creates or updates the one pending request for that hostname and
  serial. An approved request still issues only to its own nonce.
- A PC blocked with "server certificate not trusted by this PC" could never be
  queued again after trust was fixed, because the blocker comes from its last
  failed upgrade. Naming the PC in a wave now queues it when trust is its only
  blocker, and the response lists it under "retried"; "next n" still skips
  it. The Migration table lets such a PC be selected.
- counts.pending on GET /enrollment counts pending requests, including ones
  for unknown hostnames; counts.pendingcomputers keeps the per-computer figure.
This commit is contained in:
cproudlock
2026-09-15 15:01:57 -04:00
parent 22d06abb70
commit 1c2ba677c3
9 changed files with 293 additions and 42 deletions

View File

@@ -28,7 +28,7 @@ import re
from collections import Counter
from datetime import datetime, timedelta, timezone
from flask import request
from flask import current_app, request
from flask_jwt_extended import current_user
from shopdb.api import (
@@ -204,16 +204,24 @@ def _text_field(payload, name, maximum, required):
def enroll_endpoint():
"""A PC asks for its own credential.
Shared-key authenticated while plugin.endpoints.sharedkeyallowed is on.
With it off the route takes no auth and only hands out a credential an
admin already approved for this exact hostname, serial and nonce.
Takes no auth. A PC installed without the shared key must still reach an
admin, so a missing or invalid key never refuses the call: it records or
updates the pending request for this hostname and serial. The shared key
only decides auto-enroll: a valid key, while plugin.endpoints.sharedkeyallowed
and plugin.endpoints.autoenroll are on, may issue on a clean match. Without
one, only a credential an admin already approved for this exact hostname,
serial and nonce is handed out.
"""
sharedkeyauthenticated = False
if agentauth.shared_key_allowed():
error = collector_authorized('endpoints')
if error is not None:
return error
sharedkeyauthenticated = True
presented = bool(request.headers.get('X-API-Key')
or request.headers.get('Authorization'))
if presented and agentauth.shared_key_allowed():
if collector_authorized('endpoints') is None:
sharedkeyauthenticated = True
else:
current_app.logger.warning(
'endpoint enroll from %s presented an invalid API key; '
'treated as unauthenticated', client_ip(request))
payload = request.get_json(silent=True)
if not isinstance(payload, dict):
@@ -505,11 +513,14 @@ def get_enrollment():
if blocked else None),
})
# Computer counts by state, except pending: that is every pending request,
# including those naming no known computer, so it matches the request list.
counts = Counter(row['state'] for row in rows)
summary = {
'enrolled': counts['enrolled'],
'notenrolled': counts['notenrolled'] + counts['revoked'],
'pending': counts['pending'],
'pendingcomputers': counts['pending'],
'pending': len(pending),
}
state = (request.args.get('state') or '').strip().lower()
@@ -771,6 +782,16 @@ def _is_tls_trust_failure(command):
return any(marker in text for marker in _TLS_TRUST_FAILURES)
def is_trust_retryable(blockers):
"""True when nothing blocks the PC but, at most, the certificate trust failure.
The server only learns about trust from the PC's last failed download, so
that blocker cannot clear by itself. An admin who fixed trust may queue the
PC by id; a wave picking the next ready PCs never does.
"""
return all(blocker == BLOCKER_CERT_UNTRUSTED for blocker in blockers)
def compute_readiness(assetids=None):
"""Migration state for every active computer (or just assetids).
@@ -847,8 +868,8 @@ def compute_readiness(assetids=None):
blockers.append(BLOCKER_CERT_UNTRUSTED)
if not _version_below(agentversion, CREDENTIAL_AGENT_VERSION):
blockers.append(BLOCKER_AWAITING_ENROLLMENT)
if not blockers and 'run_script' not in ALLOWED_ACTIONS_FOR(
assetid=assetid):
if (is_trust_retryable(blockers)
and 'run_script' not in ALLOWED_ACTIONS_FOR(assetid=assetid)):
blockers.append(BLOCKER_POLICY)
status = 'blocked' if blockers else 'ready'
result.append({
@@ -955,7 +976,9 @@ def create_migration_wave():
Body {"assetids": [...]} or {"next": n}. Readiness is re-checked per PC; the
agent is packaged once for the whole wave, then each PC is queued through
the same path as POST /<asset>/agentupgrade.
the same path as POST /<asset>/agentupgrade. An explicit id whose only
blocker is the certificate trust failure is queued too and listed in
retried: the admin is saying trust was fixed.
"""
from .routes import prepare_agent_upgrade, queue_prepared_agent_upgrade
@@ -987,10 +1010,15 @@ def create_migration_wave():
readiness = {row['assetid']: row for row in compute_readiness(candidates)}
skipped = []
ready = []
retrying = set()
for assetid in candidates:
row = readiness.get(assetid)
if row is None:
skipped.append({'assetid': assetid, 'reason': 'not a computer'})
elif (requested is not None and row['state'] == 'blocked'
and row['blockers'] and is_trust_retryable(row['blockers'])):
retrying.add(assetid)
ready.append(assetid)
elif row['state'] != 'ready':
reason = ', '.join(row['blockers']) if row['blockers'] else row['state']
skipped.append({'assetid': assetid, 'reason': reason})
@@ -1027,8 +1055,10 @@ def create_migration_wave():
.format(len(queued), wave.assetids))
db.session.commit()
retried = [assetid for assetid in queued if assetid in retrying]
return success_response(
{'waveid': waveid, 'queued': queued, 'skipped': skipped},
{'waveid': waveid, 'queued': queued, 'skipped': skipped,
'retried': retried},
message='{} PC(s) queued'.format(len(queued)),
http_code=201 if queued else 200)