Three holes in the enrollment path, all of the same shape: a decision the admin made did not survive contact with a PC that simply asked again. REJECTION WAS UNDONE BY A NEW NONCE. A rejected request re-armed itself as a fresh cycle when the PC came back with a different nonce, and with the shared key and auto-enroll on it was then issued the credential the admin had just refused. Rejection now binds to the endpoint identity - hostname plus BIOS serial - not to the nonce, so the PC stays refused however often it reinstalls. An admin clears it deliberately, through the approve path that already exists; a genuinely different PC is untouched. THE PENDING QUEUE COULD BE FILLED UNTIL IT REFUSED EVERY NEW PC. /enroll takes no auth by design - a bay that has never spoken to the server has nothing to authenticate with - so anything that can reach the server could sit at the cap and lock out real enrollments fleet-wide. Rows nobody has touched for seven days now age out before the cap refuses anyone, and the count of what went is audited. Per-identity dedup and the cap itself were already there and are left alone. REVOKE DID NOT STOP A LIVE AGENT. It fell back to the shared key and the server admitted it again, so revoking a compromised PC changed nothing while that key still worked. plugin.endpoints.revokeddeniessharedkey makes a revoked host refused on the shared key too. That switch DEFAULTS OFF, and that is the important part. Turning it on would silence any bay revoked for a key rotation, and would break the documented revoke-then-rollback path to an older agent - on an air-gapped site, mid migration, that is a fleet outage. A never-enrolled bay is never affected either way: with no credential row at all the shared key still works, which is the whole un-migrated fleet. The checkbox sits next to the other two enrollment switches and says what turning it on costs, because a setting that can stop the floor should not be reachable only through the API.
1370 lines
58 KiB
Python
1370 lines
58 KiB
Python
"""Per-PC endpoint credentials: enrollment, administration and fleet migration.
|
|
|
|
Agent-facing:
|
|
POST /enroll issue a credential or record a pending request
|
|
POST /collect the generic collector ingest, under this prefix
|
|
|
|
Admin (endpoints.fleet to read, admin role to change):
|
|
GET /enrollment credential state per computer + requests
|
|
POST /enrollment/requests/<id>/approve | reject
|
|
POST /enrollment/assets/<assetid>/revoke body {"blockreenroll", "reason"}
|
|
POST /enrollment/assets/<assetid>/allow-reenroll lift a re-enrollment block
|
|
PUT /enrollment/settings
|
|
GET /enrollment/migration readiness of every computer for agent 1.20
|
|
POST /enrollment/migration/wave push the agent upgrade to a batch of PCs
|
|
|
|
Dashboard (endpoints.fleet):
|
|
GET /dashboard/enrollmentpending one row while enroll requests await an admin
|
|
GET /dashboard/reenrollblocked PCs blocked from re-enrolling over 7 days
|
|
|
|
A PC moves onto its own credential by running an agent that enrolls. The
|
|
migration surface computes, per computer, whether the existing agent upgrade
|
|
can be pushed to it, and pushes it in waves through the same enqueue path the
|
|
single-asset agentupgrade route uses.
|
|
"""
|
|
|
|
import hmac
|
|
import re
|
|
from collections import Counter
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from flask import current_app, request
|
|
from flask_jwt_extended import current_user
|
|
|
|
from shopdb.api import (
|
|
db, Asset, AuditLog, Script, Setting, error_response, success_response,
|
|
ErrorCodes, require_permission, require_role, collector_authorized,
|
|
collector_ingest, client_ip, get_pagination_params,
|
|
)
|
|
|
|
from ..commands import (
|
|
ACTIVE_LOCK_FOR, ALLOWED_ACTIONS_FOR, agent_meets_minimum,
|
|
minimum_agent_version_for, parse_agent_version,
|
|
)
|
|
from ..models import (
|
|
EndpointCommand, EndpointCredential, EndpointEnrollRequest,
|
|
EndpointMigrationWave, EndpointState,
|
|
)
|
|
from . import agentauth
|
|
from .ratelimit import ratelimited, rate_limit_response
|
|
from .routes import endpoints_bp
|
|
|
|
# First agent that enrolls itself.
|
|
CREDENTIAL_AGENT_VERSION = '1.20.0'
|
|
|
|
CODE_ENROLL_NONCE_MISMATCH = 'ENROLL_NONCE_MISMATCH'
|
|
CODE_ENROLL_PENDING_FULL = 'ENROLL_PENDING_FULL'
|
|
|
|
# Fixed-window per-IP limiter on /enroll, which takes no auth by design and
|
|
# writes a row for every new (hostname, serialnumber) pair it has not seen.
|
|
# Sized for a whole shop floor behind one address, not for one PC: an agent
|
|
# waiting for approval re-asks about once a minute, so 300 in 60 seconds covers
|
|
# roughly 300 machines sharing a NAT address, or a re-enrollment wave across a
|
|
# smaller room, while still turning a scripted loop into a 429 within seconds.
|
|
# Same shape and cache as the bootstrap and login limiters (see .ratelimit).
|
|
ENROLL_RATELIMIT_MAX = 300
|
|
ENROLL_RATELIMIT_WINDOW_SECONDS = 60
|
|
|
|
# Ceiling on rows in the pending queue. The rate limit bounds how FAST an
|
|
# unauthenticated caller can write; this bounds how MANY rows exist at all, so a
|
|
# slow drip from many addresses cannot grow the table without limit or bury the
|
|
# genuine requests an admin has to find in the approval queue.
|
|
#
|
|
# The cap refuses only a request for a hostname and serial we have never seen.
|
|
# An existing row - pending, approved, rejected - still updates and still hands
|
|
# out an approved credential, so a real fleet at the cap keeps enrolling while
|
|
# an admin drains the queue, and the refusal cannot lock anybody out of a PC
|
|
# that is already in the workflow.
|
|
PENDING_CAP_SETTING = 'plugin.endpoints.maxpendingenrollments'
|
|
DEFAULT_PENDING_CAP = 500
|
|
|
|
# How long a pending request may go untouched before the cap treats it as
|
|
# abandoned. An agent waiting for approval re-asks about once a minute, so a row
|
|
# nothing has touched for a week is a PC that was re-imaged, renamed or scrapped,
|
|
# or a junk row somebody wrote at the open door. At the cap those are dropped to
|
|
# make room before a PC nobody has seen before is refused, so a queue that filled
|
|
# with abandoned rows drains itself instead of blocking every new PC on site.
|
|
PENDING_STALE_AFTER = timedelta(days=7)
|
|
|
|
_NONCE_PATTERN = re.compile(r'^[A-Za-z0-9_-]{32,128}$')
|
|
|
|
REASON_UNKNOWN_HOSTNAME = 'unknown hostname'
|
|
REASON_DUPLICATE_HOSTNAME = 'duplicate hostname'
|
|
REASON_NO_SERIAL = 'no serial on record'
|
|
REASON_SERIAL_MISMATCH = 'serial mismatch'
|
|
REASON_ALREADY_ENROLLED = 'already enrolled'
|
|
REASON_AWAITING_APPROVAL = 'awaiting approval'
|
|
REASON_NONCE_CHANGED = 'nonce changed'
|
|
REASON_REJECTED = 'rejected'
|
|
REASON_REENROLL_BLOCKED = 'reenroll blocked'
|
|
|
|
# What an approval did to the computer's hostname. Reported back so the UI can
|
|
# say so, and so an admin can tell a rename from a no-op.
|
|
HOSTNAME_UNCHANGED = 'unchanged'
|
|
HOSTNAME_SET = 'set'
|
|
HOSTNAME_OVERWRITTEN = 'overwritten'
|
|
HOSTNAME_MESSAGES = {
|
|
HOSTNAME_UNCHANGED: 'Enroll request approved',
|
|
HOSTNAME_SET: 'Enroll request approved; the hostname is now recorded on '
|
|
'that computer',
|
|
HOSTNAME_OVERWRITTEN: 'Enroll request approved; that computer was renamed '
|
|
'to the hostname the PC reports',
|
|
}
|
|
|
|
MIGRATION_STATES = ('enrolled', 'pending', 'upgrading', 'ready', 'blocked',
|
|
'notreporting')
|
|
ENROLLMENT_STATES = ('enrolled', 'notenrolled', 'pending', 'revoked')
|
|
|
|
BLOCKER_OFFLINE = 'offline'
|
|
BLOCKER_NO_SERIAL = 'no serial on record'
|
|
BLOCKER_DUPLICATE_HOSTNAME = 'duplicate hostname'
|
|
BLOCKER_LOCKED = 'commands locked'
|
|
BLOCKER_NO_PACKAGE = 'no agent package in store'
|
|
BLOCKER_TOO_OLD = 'agent too old for remote upgrade'
|
|
BLOCKER_POLICY = 'run_script not permitted by policy'
|
|
BLOCKER_AWAITING_ENROLLMENT = 'awaiting enrollment'
|
|
BLOCKER_REENROLL_BLOCKED = 'reenroll blocked'
|
|
BLOCKER_CERT_UNTRUSTED = 'server certificate not trusted by this PC'
|
|
|
|
# How agents up to 1.19 word a download refused over the server certificate.
|
|
# Only a PC's own recorded failure shows it; the server cannot read a trust store.
|
|
_TLS_TRUST_FAILURES = (
|
|
'ssl connection could not be established',
|
|
'untrustedroot',
|
|
'remotecertificatechainerrors',
|
|
'the remote certificate is invalid',
|
|
'could not establish trust relationship',
|
|
)
|
|
|
|
_OFFLINE_AFTER = timedelta(hours=24)
|
|
_WAVE_MAXIMUM = 100
|
|
_RECENT_REQUESTS = 50
|
|
_BLOCKED_ATTENTION_AFTER = timedelta(days=7)
|
|
|
|
# Per-PC credential state as the fleet list and PC detail show it. First match
|
|
# wins, in this order.
|
|
CREDENTIAL_ENROLLED = 'enrolled'
|
|
CREDENTIAL_REENROLL_BLOCKED = 'reenrollblocked'
|
|
CREDENTIAL_PENDING = 'pending'
|
|
CREDENTIAL_NOT_REPORTING = 'notreporting'
|
|
CREDENTIAL_SHARED_KEY = 'sharedkey'
|
|
CREDENTIAL_STATES = (CREDENTIAL_ENROLLED, CREDENTIAL_REENROLL_BLOCKED,
|
|
CREDENTIAL_PENDING, CREDENTIAL_NOT_REPORTING,
|
|
CREDENTIAL_SHARED_KEY)
|
|
CREDENTIAL_STATE_LABELS = {
|
|
CREDENTIAL_ENROLLED: 'Enrolled',
|
|
CREDENTIAL_REENROLL_BLOCKED: 'Re-enroll blocked',
|
|
CREDENTIAL_PENDING: 'Pending',
|
|
CREDENTIAL_NOT_REPORTING: 'Not reporting',
|
|
CREDENTIAL_SHARED_KEY: 'Shared key (not enrolled)',
|
|
}
|
|
|
|
|
|
def _naive_utcnow():
|
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
|
|
|
|
|
def _utciso(value):
|
|
return value.isoformat() + 'Z' if value else None
|
|
|
|
|
|
def _username():
|
|
return getattr(current_user, 'username', None)
|
|
|
|
|
|
def _computer_model():
|
|
from plugins.computers.models import Computer
|
|
return Computer
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Agent-facing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@endpoints_bp.route('/collect', methods=['POST'])
|
|
def agent_collect():
|
|
"""Agent telemetry ingest. Same handler as POST /api/collector/endpoints."""
|
|
return collector_ingest('endpoints')
|
|
|
|
|
|
def _pending(row, reason, matchedassetid=None):
|
|
row.status = EndpointEnrollRequest.STATUS_PENDING
|
|
row.reason = reason
|
|
row.matchedassetid = matchedassetid
|
|
db.session.commit()
|
|
return success_response(
|
|
{'status': 'pending', 'requestid': row.requestid, 'reason': reason},
|
|
http_code=202)
|
|
|
|
|
|
def issue_credential(assetid, enrolledvia):
|
|
"""Issue (or re-issue after revoke) the credential for one asset.
|
|
|
|
Returns the secret. Only its hash is stored. Caller commits.
|
|
"""
|
|
secret = agentauth.generate_credential()
|
|
row = EndpointCredential.query.filter_by(assetid=assetid).first()
|
|
if row is None:
|
|
row = EndpointCredential(assetid=assetid)
|
|
db.session.add(row)
|
|
row.credentialhash = agentauth.hash_credential(secret)
|
|
row.credentialprefix = secret[:agentauth.DISPLAY_PREFIX_LENGTH]
|
|
row.enrolledvia = enrolledvia
|
|
row.issuedat = _naive_utcnow()
|
|
row.lastusedat = None
|
|
row.revokedat = None
|
|
row.revokedby = None
|
|
return secret, row
|
|
|
|
|
|
def _issue(row, assetid, enrolledvia):
|
|
secret, credential = issue_credential(assetid, enrolledvia)
|
|
row.status = EndpointEnrollRequest.STATUS_ISSUED
|
|
row.matchedassetid = assetid
|
|
row.reason = None
|
|
AuditLog.log('created', 'EndpointCredential', entityid=assetid,
|
|
entityname=row.hostname,
|
|
details='endpoint credential {} issued ({}), request {}'.format(
|
|
credential.credentialprefix, enrolledvia, row.requestid))
|
|
db.session.commit()
|
|
return success_response(
|
|
{'status': 'enrolled', 'credential': secret, 'assetid': assetid},
|
|
message='Endpoint enrolled', http_code=201)
|
|
|
|
|
|
def _text_field(payload, name, maximum, required):
|
|
value = payload.get(name)
|
|
if value is None:
|
|
value = ''
|
|
if not isinstance(value, str):
|
|
return None, f'{name} must be a string'
|
|
value = value.strip()
|
|
if required and not value:
|
|
return None, f'{name} is required'
|
|
if len(value) > maximum:
|
|
return None, f'{name} must be at most {maximum} characters'
|
|
return value, None
|
|
|
|
|
|
def pending_cap():
|
|
"""How many pending enroll requests this site will hold. 0 = no cap."""
|
|
try:
|
|
value = int(Setting.get(PENDING_CAP_SETTING,
|
|
default=DEFAULT_PENDING_CAP))
|
|
except (TypeError, ValueError):
|
|
return DEFAULT_PENDING_CAP
|
|
return max(value, 0)
|
|
|
|
|
|
def _pending_queue_is_full():
|
|
"""True when a NEW enroll request would push the queue past its cap.
|
|
|
|
Counted, not paged: the answer is a single number and the route only asks
|
|
on first contact for a hostname and serial, not on the repeat asks that
|
|
make up almost all the traffic.
|
|
"""
|
|
cap = pending_cap()
|
|
if not cap:
|
|
return False
|
|
pending = (EndpointEnrollRequest.query
|
|
.filter(EndpointEnrollRequest.status
|
|
== EndpointEnrollRequest.STATUS_PENDING)
|
|
.count())
|
|
return pending >= cap
|
|
|
|
|
|
def _drop_stale_pending():
|
|
"""Delete pending requests nothing has touched in PENDING_STALE_AFTER.
|
|
|
|
Only PENDING rows: an admin decision (approved, rejected, issued) is never
|
|
aged out, so a rejection keeps standing however long the PC argues with it.
|
|
Returns how many rows went, for the caller to decide whether the cap is
|
|
still reached.
|
|
"""
|
|
cutoff = _naive_utcnow() - PENDING_STALE_AFTER
|
|
stale = (EndpointEnrollRequest.query
|
|
.filter(EndpointEnrollRequest.status
|
|
== EndpointEnrollRequest.STATUS_PENDING,
|
|
EndpointEnrollRequest.lastseenat < cutoff)
|
|
.order_by(EndpointEnrollRequest.lastseenat.asc())
|
|
.all())
|
|
if not stale:
|
|
return 0
|
|
for row in stale:
|
|
db.session.delete(row)
|
|
current_app.logger.info(
|
|
'endpoint enroll: aged out %d pending requests untouched for %d days',
|
|
len(stale), PENDING_STALE_AFTER.days)
|
|
AuditLog.log(
|
|
'deleted', 'EndpointEnrollRequest',
|
|
entityname=stale[0].hostname,
|
|
details='aged out {0} pending enroll requests untouched for {1} '
|
|
'days, to make room under the pending cap'.format(
|
|
len(stale), PENDING_STALE_AFTER.days))
|
|
db.session.commit()
|
|
return len(stale)
|
|
|
|
|
|
def _pending_queue_full_response(hostname, serialnumber):
|
|
"""Refuse a new row, say so plainly in the log and leave an audit trail."""
|
|
cap = pending_cap()
|
|
current_app.logger.warning(
|
|
'endpoint enroll refused from %s: pending queue is at its cap of %d '
|
|
'(hostname %s, serial %s). Approve or reject requests in Settings > '
|
|
'Endpoints > Enrollment, or raise %s.',
|
|
client_ip(request), cap, hostname, serialnumber, PENDING_CAP_SETTING)
|
|
AuditLog.log(
|
|
'updated', 'EndpointEnrollRequest', entityname=hostname,
|
|
details='enroll refused from {0}: pending queue at cap {1} '
|
|
'(hostname {2}, serial {3})'.format(
|
|
client_ip(request), cap, hostname, serialnumber))
|
|
db.session.commit()
|
|
return error_response(
|
|
CODE_ENROLL_PENDING_FULL,
|
|
'This site is not accepting new enrollment requests right now. '
|
|
'An administrator has to clear the pending queue.',
|
|
http_code=503)
|
|
|
|
|
|
@endpoints_bp.route('/enroll', methods=['POST'])
|
|
def enroll_endpoint():
|
|
"""A PC asks for its own credential.
|
|
|
|
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.
|
|
|
|
Unauthenticated and row-writing, so it carries both bounds an open door
|
|
needs: a per-IP fixed-window rate limit on how fast, and a settings-backed
|
|
cap on how many pending rows can exist. Neither touches a PC already in the
|
|
workflow - the cap refuses only a hostname and serial never seen before.
|
|
"""
|
|
if ratelimited('endpointenrollratelimit', 'ENDPOINTS_ENROLL_RATELIMIT',
|
|
ENROLL_RATELIMIT_MAX, ENROLL_RATELIMIT_WINDOW_SECONDS):
|
|
return rate_limit_response(
|
|
'Too many enrollment requests. Try again later.')
|
|
|
|
sharedkeyauthenticated = False
|
|
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):
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
|
|
|
hostname, problem = _text_field(payload, 'hostname', 255, True)
|
|
if problem is None:
|
|
serialnumber, problem = _text_field(payload, 'serialnumber', 128, True)
|
|
if problem is None:
|
|
deviceid, problem = _text_field(payload, 'deviceid', 64, False)
|
|
if problem is None:
|
|
agentversion, problem = _text_field(payload, 'agentversion', 32, False)
|
|
if problem is None:
|
|
nonce = payload.get('enrollnonce')
|
|
if not isinstance(nonce, str) or not _NONCE_PATTERN.match(nonce):
|
|
problem = 'enrollnonce must be 32-128 characters of A-Z a-z 0-9 _ -'
|
|
if problem is not None:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, problem)
|
|
|
|
now = _naive_utcnow()
|
|
sourceip = client_ip(request)
|
|
|
|
row = (EndpointEnrollRequest.query
|
|
.filter(db.func.lower(EndpointEnrollRequest.hostname) == hostname.lower(),
|
|
db.func.lower(EndpointEnrollRequest.serialnumber)
|
|
== serialnumber.lower())
|
|
.order_by(EndpointEnrollRequest.requestid.desc())
|
|
.first())
|
|
|
|
if row is None:
|
|
# First contact for this hostname and serial. This is the only branch
|
|
# that grows the table, so it is the only one the cap guards. Every
|
|
# other contact reuses the one row this identity already owns, which is
|
|
# what keeps a single PC from holding a queue's worth of them.
|
|
if _pending_queue_is_full():
|
|
# Abandoned rows come out before a real PC is turned away.
|
|
_drop_stale_pending()
|
|
if _pending_queue_is_full():
|
|
return _pending_queue_full_response(hostname, serialnumber)
|
|
row = EndpointEnrollRequest(
|
|
hostname=hostname, serialnumber=serialnumber, enrollnonce=nonce,
|
|
status=EndpointEnrollRequest.STATUS_PENDING, requestedat=now)
|
|
db.session.add(row)
|
|
else:
|
|
samenonce = hmac.compare_digest(row.enrollnonce, nonce)
|
|
if row.status == EndpointEnrollRequest.STATUS_REJECTED:
|
|
# A rejection binds to the IDENTITY - this hostname and serial - not
|
|
# to the nonce it was rejected with. A new installation on the same
|
|
# PC brings a new nonce, and re-arming the row on it would put the
|
|
# request back in the queue and, with the shared key and auto-enroll
|
|
# on, hand the retry the credential an admin had just refused.
|
|
# Nothing the PC sends clears a rejection; an admin approving the
|
|
# request does.
|
|
#
|
|
# The nonce is still recorded, because a rejected row hands nothing
|
|
# out and an admin who does approve it later has to bind that
|
|
# approval to the installation asking NOW, not to the one that was
|
|
# refused weeks ago.
|
|
row.enrollnonce = nonce
|
|
row.lastseenat = now
|
|
row.sourceip = sourceip
|
|
if agentversion:
|
|
row.agentversion = agentversion
|
|
db.session.commit()
|
|
return success_response(
|
|
{'status': 'pending', 'requestid': row.requestid,
|
|
'reason': REASON_REJECTED}, http_code=202)
|
|
if row.status == EndpointEnrollRequest.STATUS_APPROVED:
|
|
row.lastseenat = now
|
|
row.sourceip = sourceip
|
|
if not samenonce:
|
|
db.session.commit()
|
|
return error_response(
|
|
CODE_ENROLL_NONCE_MISMATCH,
|
|
'This enrollment was approved for a different agent '
|
|
'installation', http_code=403)
|
|
if agentauth.active_credential_for(row.matchedassetid) is not None:
|
|
return _pending(row, REASON_ALREADY_ENROLLED, row.matchedassetid)
|
|
if agentauth.reenroll_blocked(row.matchedassetid):
|
|
return _pending(row, REASON_REENROLL_BLOCKED, row.matchedassetid)
|
|
if row.matchedassetid is None or db.session.get(Asset, row.matchedassetid) is None:
|
|
return _pending(row, REASON_UNKNOWN_HOSTNAME)
|
|
return _issue(row, row.matchedassetid,
|
|
EndpointCredential.ENROLLED_APPROVED)
|
|
if row.status == EndpointEnrollRequest.STATUS_PENDING and not samenonce:
|
|
# First nonce wins; a second installation needs an admin.
|
|
row.lastseenat = now
|
|
row.sourceip = sourceip
|
|
return _pending(row, REASON_NONCE_CHANGED, row.matchedassetid)
|
|
if row.status != EndpointEnrollRequest.STATUS_PENDING:
|
|
# Issued earlier and the credential has since been revoked: a new
|
|
# enrollment cycle bound to this nonce. Rejected rows never reach
|
|
# here - they returned above, whatever nonce they carried.
|
|
row.enrollnonce = nonce
|
|
row.requestedat = now
|
|
row.decidedat = None
|
|
row.decidedby = None
|
|
|
|
row.lastseenat = now
|
|
row.sourceip = sourceip
|
|
if deviceid:
|
|
row.deviceid = deviceid
|
|
if agentversion:
|
|
row.agentversion = agentversion
|
|
db.session.flush()
|
|
|
|
Computer = _computer_model()
|
|
matches = (Computer.query
|
|
.filter(db.func.lower(Computer.hostname) == hostname.lower())
|
|
.all())
|
|
if not matches:
|
|
return _pending(row, REASON_UNKNOWN_HOSTNAME)
|
|
if len(matches) > 1:
|
|
return _pending(row, REASON_DUPLICATE_HOSTNAME)
|
|
|
|
assetid = matches[0].assetid
|
|
asset = db.session.get(Asset, assetid)
|
|
recorded = ((asset.serialnumber if asset is not None else None) or '').strip()
|
|
if not recorded:
|
|
return _pending(row, REASON_NO_SERIAL, assetid)
|
|
if recorded.lower() != serialnumber.lower():
|
|
return _pending(row, REASON_SERIAL_MISMATCH, assetid)
|
|
if agentauth.active_credential_for(assetid) is not None:
|
|
return _pending(row, REASON_ALREADY_ENROLLED, assetid)
|
|
if agentauth.reenroll_blocked(assetid):
|
|
return _pending(row, REASON_REENROLL_BLOCKED, assetid)
|
|
if sharedkeyauthenticated and agentauth.auto_enroll_enabled():
|
|
return _issue(row, assetid, EndpointCredential.ENROLLED_AUTO)
|
|
return _pending(row, REASON_AWAITING_APPROVAL, assetid)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Admin: credentials and requests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _computers():
|
|
"""(assetid, hostname, serialnumber) for every active computer."""
|
|
Computer = _computer_model()
|
|
return (db.session.query(Computer.assetid, Computer.hostname,
|
|
Asset.serialnumber)
|
|
.join(Asset, Asset.assetid == Computer.assetid)
|
|
.filter(Asset.isactive.is_(True))
|
|
.order_by(db.func.lower(Computer.hostname))
|
|
.all())
|
|
|
|
|
|
def _pending_requests():
|
|
return (EndpointEnrollRequest.query
|
|
.filter_by(status=EndpointEnrollRequest.STATUS_PENDING)
|
|
.all())
|
|
|
|
|
|
def _pending_index(requests):
|
|
assetids = {r.matchedassetid for r in requests if r.matchedassetid}
|
|
hostnames = {(r.hostname or '').lower() for r in requests}
|
|
return assetids, hostnames
|
|
|
|
|
|
def _is_pending(assetid, hostname, index):
|
|
assetids, hostnames = index
|
|
return assetid in assetids or (hostname or '').lower() in hostnames
|
|
|
|
|
|
def _awaiting_admin_filter():
|
|
"""Pending requests an admin can act on.
|
|
|
|
A PC blocked from re-enrolling keeps asking on purpose; those rows are
|
|
expected, not work.
|
|
"""
|
|
return db.and_(
|
|
EndpointEnrollRequest.status == EndpointEnrollRequest.STATUS_PENDING,
|
|
db.or_(EndpointEnrollRequest.reason.is_(None),
|
|
EndpointEnrollRequest.reason != REASON_REENROLL_BLOCKED))
|
|
|
|
|
|
def credential_state_expression(assetid, hostname, lastreportedat):
|
|
"""SQL CASE giving CREDENTIAL_STATES for a row, evaluated in the query.
|
|
|
|
Correlated EXISTS on endpointcredentials and endpointenrollrequests, so a
|
|
paged list filters and labels in one statement instead of per row.
|
|
"""
|
|
active = (db.select(EndpointCredential.credentialid)
|
|
.where(EndpointCredential.assetid == assetid,
|
|
EndpointCredential.revokedat.is_(None))
|
|
.exists())
|
|
blocked = (db.select(EndpointCredential.credentialid)
|
|
.where(EndpointCredential.assetid == assetid,
|
|
EndpointCredential.reenrollblocked.is_(True))
|
|
.exists())
|
|
pending = (db.select(EndpointEnrollRequest.requestid)
|
|
.where(EndpointEnrollRequest.status
|
|
== EndpointEnrollRequest.STATUS_PENDING,
|
|
db.or_(EndpointEnrollRequest.matchedassetid == assetid,
|
|
db.func.lower(EndpointEnrollRequest.hostname)
|
|
== db.func.lower(hostname)))
|
|
.exists())
|
|
return db.case(
|
|
(active, CREDENTIAL_ENROLLED),
|
|
(blocked, CREDENTIAL_REENROLL_BLOCKED),
|
|
(pending, CREDENTIAL_PENDING),
|
|
(lastreportedat.is_(None), CREDENTIAL_NOT_REPORTING),
|
|
else_=CREDENTIAL_SHARED_KEY,
|
|
)
|
|
|
|
|
|
def credential_detail(assetid, hostname, lastreportedat, detailed):
|
|
"""Credential state of one PC, same rules as credential_state_expression.
|
|
|
|
detailed False withholds the prefix, timestamps, enrollment path and block
|
|
reason, leaving the state alone.
|
|
"""
|
|
credential = EndpointCredential.query.filter_by(assetid=assetid).first()
|
|
if credential is not None and credential.revokedat is None:
|
|
state = CREDENTIAL_ENROLLED
|
|
elif credential is not None and credential.reenrollblocked:
|
|
state = CREDENTIAL_REENROLL_BLOCKED
|
|
elif (EndpointEnrollRequest.query
|
|
.filter(EndpointEnrollRequest.status
|
|
== EndpointEnrollRequest.STATUS_PENDING,
|
|
db.or_(EndpointEnrollRequest.matchedassetid == assetid,
|
|
db.func.lower(EndpointEnrollRequest.hostname)
|
|
== (hostname or '').lower()))
|
|
.first()) is not None:
|
|
state = CREDENTIAL_PENDING
|
|
elif lastreportedat is None:
|
|
state = CREDENTIAL_NOT_REPORTING
|
|
else:
|
|
state = CREDENTIAL_SHARED_KEY
|
|
|
|
detail = {
|
|
'state': state,
|
|
'statelabel': CREDENTIAL_STATE_LABELS[state],
|
|
'credentialprefix': None,
|
|
'issuedat': None,
|
|
'lastusedat': None,
|
|
'revokedat': None,
|
|
'enrolledvia': None,
|
|
'reenrollblocked': bool(credential is not None
|
|
and credential.reenrollblocked),
|
|
'reenrollblockedreason': None,
|
|
}
|
|
if detailed and credential is not None:
|
|
detail.update({
|
|
'credentialprefix': credential.credentialprefix,
|
|
'issuedat': _utciso(credential.issuedat),
|
|
'lastusedat': _utciso(credential.lastusedat),
|
|
'revokedat': _utciso(credential.revokedat),
|
|
'enrolledvia': credential.enrolledvia,
|
|
'reenrollblockedreason': (credential.reenrollblockedreason
|
|
if credential.reenrollblocked else None),
|
|
})
|
|
return detail
|
|
|
|
|
|
def _search_filter(rows, search, *keys):
|
|
if not search:
|
|
return rows
|
|
needle = search.lower()
|
|
return [row for row in rows
|
|
if any(needle in str(row.get(key) or '').lower() for key in keys)]
|
|
|
|
|
|
def _page(rows):
|
|
page, perpage = get_pagination_params()
|
|
total = len(rows)
|
|
start = (page - 1) * perpage
|
|
totalpages = (total + perpage - 1) // perpage if perpage else 0
|
|
return rows[start:start + perpage], {
|
|
'page': page, 'perpage': perpage, 'total': total,
|
|
'totalpages': totalpages, 'hasnext': page < totalpages,
|
|
'hasprev': page > 1,
|
|
}
|
|
|
|
|
|
def _settings_payload():
|
|
return {'sharedkeyallowed': agentauth.shared_key_allowed(),
|
|
'autoenroll': agentauth.auto_enroll_enabled(),
|
|
'revokeddeniessharedkey': agentauth.revoked_denies_shared_key(),
|
|
'maxpendingenrollments': pending_cap()}
|
|
|
|
|
|
@endpoints_bp.route('/enrollment', methods=['GET'])
|
|
@require_permission('endpoints.fleet')
|
|
def get_enrollment():
|
|
"""Credential state of every computer, plus pending and recent requests."""
|
|
credentials = {c.assetid: c for c in EndpointCredential.query.all()}
|
|
pending = _pending_requests()
|
|
index = _pending_index(pending)
|
|
|
|
rows = []
|
|
for assetid, hostname, serialnumber in _computers():
|
|
credential = credentials.get(assetid)
|
|
blocked = bool(credential is not None and credential.reenrollblocked)
|
|
if credential is not None and credential.revokedat is None:
|
|
state = 'enrolled'
|
|
elif blocked:
|
|
# Its agent keeps asking; those requests are expected, not pending work.
|
|
state = 'revoked'
|
|
elif _is_pending(assetid, hostname, index):
|
|
state = 'pending'
|
|
elif credential is not None:
|
|
state = 'revoked'
|
|
else:
|
|
state = 'notenrolled'
|
|
rows.append({
|
|
'assetid': assetid,
|
|
'hostname': hostname,
|
|
'serialnumber': serialnumber,
|
|
'state': state,
|
|
'credentialprefix': credential.credentialprefix if credential else None,
|
|
'issuedat': _utciso(credential.issuedat) if credential else None,
|
|
'lastusedat': _utciso(credential.lastusedat) if credential else None,
|
|
'enrolledvia': credential.enrolledvia if credential else None,
|
|
'reenrollblocked': blocked,
|
|
'reenrollblockedreason': (credential.reenrollblockedreason
|
|
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'],
|
|
'pendingcomputers': counts['pending'],
|
|
'pending': len(pending),
|
|
}
|
|
|
|
state = (request.args.get('state') or '').strip().lower()
|
|
if state:
|
|
if state not in ENROLLMENT_STATES:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
f'Unknown state filter: {state!r}')
|
|
rows = [row for row in rows if row['state'] == state]
|
|
rows = _search_filter(rows, (request.args.get('search') or '').strip(),
|
|
'hostname', 'serialnumber')
|
|
pagerows, pagination = _page(rows)
|
|
|
|
recent = (EndpointEnrollRequest.query
|
|
.filter(EndpointEnrollRequest.status
|
|
!= EndpointEnrollRequest.STATUS_PENDING)
|
|
.order_by(EndpointEnrollRequest.lastseenat.desc())
|
|
.limit(_RECENT_REQUESTS)
|
|
.all())
|
|
requests_payload = [
|
|
r.to_dict()
|
|
for r in sorted(pending, key=lambda r: r.lastseenat or r.requestedat,
|
|
reverse=True) + recent
|
|
]
|
|
|
|
return success_response({
|
|
'settings': _settings_payload(),
|
|
'counts': summary,
|
|
'computers': pagerows,
|
|
'requests': requests_payload,
|
|
}, meta={'pagination': pagination})
|
|
|
|
|
|
@endpoints_bp.route('/enrollment/requests/<int:requestid>/approve',
|
|
methods=['POST'])
|
|
@require_role('admin')
|
|
def approve_enroll_request(requestid):
|
|
"""Bind a pending request to the asset the admin picked.
|
|
|
|
The computer record also has to CARRY the request's hostname, or the PC
|
|
403s on every call after it collects its credential: bind_hostname resolves
|
|
the hostname the agent sends to an asset and compares it with the
|
|
credential's asset, and a computer with no hostname resolves to nothing.
|
|
The pending reason for a one-off PC is by definition `unknown hostname`, so
|
|
this is the normal case, not the exception.
|
|
|
|
Approving is also the only way out of a rejection: a rejected request keeps
|
|
refusing its PC whatever nonce it sends, and this is the deliberate admin
|
|
step that lifts it. The approval binds to the nonce of that PC's most recent
|
|
contact, so an agent reinstalled while it was refused still collects it.
|
|
|
|
Body: {"assetid": 123, "sethostname": false}. A computer with no hostname
|
|
is stamped with the request's. A computer carrying a DIFFERENT hostname is
|
|
refused with 409 naming both, until the caller repeats the approval with
|
|
"sethostname": true. Either change is audited, and the response says which
|
|
of unchanged / set / overwritten happened.
|
|
"""
|
|
row = db.session.get(EndpointEnrollRequest, requestid)
|
|
if row is None:
|
|
return error_response(ErrorCodes.NOT_FOUND,
|
|
f'No enroll request {requestid}', http_code=404)
|
|
if row.status == EndpointEnrollRequest.STATUS_ISSUED:
|
|
return error_response(ErrorCodes.CONFLICT,
|
|
'A credential was already issued for this request',
|
|
http_code=409)
|
|
|
|
payload = request.get_json(silent=True) or {}
|
|
assetid = payload.get('assetid')
|
|
if isinstance(assetid, bool) or not isinstance(assetid, int):
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'assetid must be an integer')
|
|
sethostname = payload.get('sethostname', False)
|
|
if not isinstance(sethostname, bool):
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'sethostname must be true or false')
|
|
Computer = _computer_model()
|
|
computer = Computer.query.filter_by(assetid=assetid).first()
|
|
if computer is None:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
f'Asset {assetid} is not a computer')
|
|
if agentauth.active_credential_for(assetid) is not None:
|
|
return error_response(ErrorCodes.CONFLICT,
|
|
'That computer is already enrolled; revoke its '
|
|
'credential first', http_code=409)
|
|
if agentauth.reenroll_blocked(assetid):
|
|
return error_response(ErrorCodes.CONFLICT,
|
|
'That computer is blocked from enrolling again; '
|
|
'allow re-enrollment first', http_code=409)
|
|
|
|
requesthostname = (row.hostname or '').strip()
|
|
currenthostname = (computer.hostname or '').strip()
|
|
hostnameresult = HOSTNAME_UNCHANGED
|
|
if requesthostname and not currenthostname:
|
|
hostnameresult = HOSTNAME_SET
|
|
elif requesthostname and currenthostname.lower() != requesthostname.lower():
|
|
if not sethostname:
|
|
return error_response(
|
|
ErrorCodes.CONFLICT,
|
|
'That computer is recorded as {0}, but the request comes from '
|
|
'{1}. Approving as it stands leaves the PC unable to '
|
|
'authenticate. Approve again with "sethostname": true to '
|
|
'rename the computer to {1}.'.format(currenthostname,
|
|
requesthostname),
|
|
details={'hostnameconflict': True,
|
|
'computerhostname': currenthostname,
|
|
'requesthostname': requesthostname},
|
|
http_code=409)
|
|
hostnameresult = HOSTNAME_OVERWRITTEN
|
|
|
|
if hostnameresult != HOSTNAME_UNCHANGED:
|
|
computer.hostname = requesthostname
|
|
AuditLog.log('updated', 'Computer', entityid=computer.computerid,
|
|
entityname=requesthostname,
|
|
changes={'hostname': {'old': currenthostname or None,
|
|
'new': requesthostname}},
|
|
details='hostname {0} from enroll request {1}'.format(
|
|
hostnameresult, row.requestid))
|
|
|
|
wasrejected = row.status == EndpointEnrollRequest.STATUS_REJECTED
|
|
row.status = EndpointEnrollRequest.STATUS_APPROVED
|
|
row.matchedassetid = assetid
|
|
row.reason = None
|
|
row.decidedat = _naive_utcnow()
|
|
row.decidedby = _username()
|
|
AuditLog.log('updated', 'EndpointEnrollRequest', entityid=row.requestid,
|
|
entityname=row.hostname,
|
|
details='enroll request {0} for asset {1}, hostname {2}'
|
|
.format('approved out of a rejection' if wasrejected
|
|
else 'approved', assetid, hostnameresult))
|
|
db.session.commit()
|
|
approved = row.to_dict()
|
|
approved['hostnameresult'] = hostnameresult
|
|
return success_response(approved, message=HOSTNAME_MESSAGES[hostnameresult])
|
|
|
|
|
|
@endpoints_bp.route('/enrollment/requests/<int:requestid>/reject',
|
|
methods=['POST'])
|
|
@require_role('admin')
|
|
def reject_enroll_request(requestid):
|
|
row = db.session.get(EndpointEnrollRequest, requestid)
|
|
if row is None:
|
|
return error_response(ErrorCodes.NOT_FOUND,
|
|
f'No enroll request {requestid}', http_code=404)
|
|
if row.status == EndpointEnrollRequest.STATUS_ISSUED:
|
|
return error_response(ErrorCodes.CONFLICT,
|
|
'A credential was already issued for this request; '
|
|
'revoke the credential instead', http_code=409)
|
|
row.status = EndpointEnrollRequest.STATUS_REJECTED
|
|
row.decidedat = _naive_utcnow()
|
|
row.decidedby = _username()
|
|
AuditLog.log('updated', 'EndpointEnrollRequest', entityid=row.requestid,
|
|
entityname=row.hostname, details='enroll request rejected')
|
|
db.session.commit()
|
|
return success_response(row.to_dict(), message='Enroll request rejected')
|
|
|
|
|
|
@endpoints_bp.route('/enrollment/assets/<int:assetid>/revoke', methods=['POST'])
|
|
@require_role('admin')
|
|
def revoke_endpoint_credential(assetid):
|
|
"""Un-enroll a PC. Its agent gets CREDENTIAL_INVALID and enrolls again.
|
|
|
|
Body (optional) {"blockreenroll": true, "reason": "rollback"} also keeps the
|
|
PC from enrolling again until POST .../allow-reenroll. That is the step
|
|
before rolling a PC back to an agent that only knows the shared key: without
|
|
it the current agent re-enrolls within seconds and the older agent is then
|
|
refused. A PC whose credential is already revoked can still be blocked.
|
|
|
|
Whether a revoked PC may still report on the shared key is the site setting
|
|
plugin.endpoints.revokeddeniessharedkey. It is off here, so today it can -
|
|
which is what makes the rollback above work, and what keeps a rotation from
|
|
silencing the PC while it waits for approval.
|
|
"""
|
|
payload = request.get_json(silent=True) or {}
|
|
if not isinstance(payload, dict):
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'Body must be a JSON object')
|
|
blockreenroll = payload.get('blockreenroll', False)
|
|
if not isinstance(blockreenroll, bool):
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'blockreenroll must be true or false')
|
|
reason = None
|
|
if blockreenroll:
|
|
reason, problem = _text_field(payload, 'reason', 64, False)
|
|
if problem is not None:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, problem)
|
|
reason = reason or None
|
|
|
|
credential = agentauth.active_credential_for(assetid)
|
|
if credential is None and blockreenroll:
|
|
credential = EndpointCredential.query.filter_by(assetid=assetid).first()
|
|
if credential is None:
|
|
return error_response(ErrorCodes.NOT_FOUND,
|
|
'This computer has no active credential',
|
|
http_code=404)
|
|
|
|
wasactive = credential.revokedat is None
|
|
if wasactive:
|
|
credential.revokedat = _naive_utcnow()
|
|
credential.revokedby = _username()
|
|
details = []
|
|
if wasactive:
|
|
details.append('endpoint credential {} revoked'.format(
|
|
credential.credentialprefix))
|
|
if blockreenroll:
|
|
credential.reenrollblocked = True
|
|
credential.reenrollblockedreason = reason
|
|
details.append('re-enrollment blocked ({})'.format(reason or 'no reason'))
|
|
AuditLog.log('deleted' if wasactive else 'updated', 'EndpointCredential',
|
|
entityid=assetid, entityname=credential.credentialprefix,
|
|
details='; '.join(details))
|
|
db.session.commit()
|
|
return success_response(credential.to_dict(),
|
|
message='Endpoint credential revoked')
|
|
|
|
|
|
@endpoints_bp.route('/enrollment/assets/<int:assetid>/allow-reenroll',
|
|
methods=['POST'])
|
|
@require_role('admin')
|
|
def allow_endpoint_reenroll(assetid):
|
|
"""Lift a re-enrollment block. The PC enrolls on its next attempt."""
|
|
credential = EndpointCredential.query.filter_by(assetid=assetid).first()
|
|
if credential is None:
|
|
return error_response(ErrorCodes.NOT_FOUND,
|
|
'This computer has no credential record',
|
|
http_code=404)
|
|
if not credential.reenrollblocked:
|
|
return success_response(credential.to_dict(),
|
|
message='Re-enrollment was not blocked')
|
|
previous = credential.reenrollblockedreason
|
|
credential.reenrollblocked = False
|
|
credential.reenrollblockedreason = None
|
|
AuditLog.log('updated', 'EndpointCredential', entityid=assetid,
|
|
entityname=credential.credentialprefix,
|
|
changes={'reenrollblocked': {'old': True, 'new': False}},
|
|
details='re-enrollment allowed again (was blocked: {})'.format(
|
|
previous or 'no reason'))
|
|
db.session.commit()
|
|
return success_response(credential.to_dict(),
|
|
message='Re-enrollment allowed')
|
|
|
|
|
|
@endpoints_bp.route('/enrollment/settings', methods=['PUT'])
|
|
@require_role('admin')
|
|
def update_enrollment_settings():
|
|
"""Change the enrollment settings.
|
|
|
|
revokeddeniessharedkey is off by default, which is what the fleet does
|
|
today: a PC whose credential was revoked falls back to the shared key, so a
|
|
credential rotation, and the rollback to an older agent that revoke +
|
|
blockreenroll exists for, both keep reporting. Turning it on makes a revoke
|
|
an eviction - that PC reports nothing until it is allowed to enroll again -
|
|
and is only safe once every PC here holds its own credential.
|
|
"""
|
|
from shopdb.api import Setting
|
|
|
|
payload = request.get_json(silent=True) or {}
|
|
changes = {}
|
|
for key, settingkey in (
|
|
('sharedkeyallowed', agentauth.SHARED_KEY_SETTING),
|
|
('autoenroll', agentauth.AUTO_ENROLL_SETTING),
|
|
('revokeddeniessharedkey',
|
|
agentauth.REVOKED_DENIES_SHARED_KEY_SETTING)):
|
|
if key not in payload:
|
|
continue
|
|
if not isinstance(payload[key], bool):
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
f'{key} must be true or false')
|
|
changes[key] = (settingkey, payload[key], 'boolean')
|
|
|
|
if 'maxpendingenrollments' in payload:
|
|
value = payload['maxpendingenrollments']
|
|
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'maxpendingenrollments must be 0 or a positive whole number')
|
|
changes['maxpendingenrollments'] = (PENDING_CAP_SETTING, value,
|
|
'integer')
|
|
|
|
if not changes:
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'Send sharedkeyallowed, autoenroll, revokeddeniessharedkey '
|
|
'and/or maxpendingenrollments')
|
|
|
|
before = _settings_payload()
|
|
for key, (settingkey, value, valuetype) in changes.items():
|
|
Setting.set(settingkey, value, valuetype=valuetype,
|
|
category='plugin.endpoints')
|
|
after = _settings_payload()
|
|
AuditLog.log('updated', 'Setting', entityname='endpoint enrollment',
|
|
changes={key: {'old': before[key], 'new': after[key]}
|
|
for key in changes},
|
|
details='endpoint enrollment settings changed')
|
|
db.session.commit()
|
|
return success_response(after, message='Enrollment settings saved')
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Migration of PCs already in the fleet
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _version_below(reported, target):
|
|
parsed = parse_agent_version(reported)
|
|
wanted = parse_agent_version(target)
|
|
if parsed is None or wanted is None:
|
|
return True
|
|
width = max(len(parsed), len(wanted))
|
|
return (tuple(parsed) + (0,) * (width - len(parsed))
|
|
< tuple(wanted) + (0,) * (width - len(wanted)))
|
|
|
|
|
|
def _upgrade_scriptid():
|
|
from .agentupgradescript import UPGRADE_SCRIPT_NAME
|
|
|
|
script = Script.query.filter_by(scriptname=UPGRADE_SCRIPT_NAME).first()
|
|
return script.scriptid if script is not None else None
|
|
|
|
|
|
def _store_has_package():
|
|
from . import agentstore
|
|
|
|
current = agentstore.current_version('agent')
|
|
if current is None:
|
|
return agentstore.current_path('agent') is not None
|
|
return not _version_below(current, CREDENTIAL_AGENT_VERSION)
|
|
|
|
|
|
def _latest_commands(assetids, target=None):
|
|
"""The newest run_script command per asset (optionally for one target)."""
|
|
if not assetids:
|
|
return {}
|
|
newest = (db.session.query(db.func.max(EndpointCommand.endpointcommandid))
|
|
.filter(EndpointCommand.assetid.in_(assetids),
|
|
EndpointCommand.action == 'run_script'))
|
|
if target is not None:
|
|
newest = newest.filter(EndpointCommand.target == target)
|
|
newest = newest.group_by(EndpointCommand.assetid)
|
|
return {command.assetid: command for command in EndpointCommand.query
|
|
.filter(EndpointCommand.endpointcommandid.in_(newest)).all()}
|
|
|
|
|
|
def _is_tls_trust_failure(command):
|
|
if command is None or command.status != EndpointCommand.STATUS_FAILED:
|
|
return False
|
|
text = '{} {}'.format(command.resultmessage or '',
|
|
command.resultjson or '').lower()
|
|
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).
|
|
|
|
Returns a list of dicts ordered by hostname: assetid, hostname,
|
|
agentversion, lastreportedat, state, blockers.
|
|
"""
|
|
computers = _computers()
|
|
hostnamecounts = Counter((hostname or '').lower()
|
|
for _assetid, hostname, _serial in computers)
|
|
if assetids is not None:
|
|
wanted = set(assetids)
|
|
computers = [row for row in computers if row[0] in wanted]
|
|
if not computers:
|
|
return []
|
|
|
|
ids = [row[0] for row in computers]
|
|
credentialrows = EndpointCredential.query.filter(
|
|
EndpointCredential.assetid.in_(ids)).all()
|
|
active = {c.assetid for c in credentialrows if c.revokedat is None}
|
|
reenrollblocked = {c.assetid for c in credentialrows
|
|
if c.reenrollblocked and c.revokedat is not None}
|
|
index = _pending_index(_pending_requests())
|
|
states = {s.assetid: s for s in
|
|
EndpointState.query.filter(EndpointState.assetid.in_(ids)).all()}
|
|
|
|
scriptid = _upgrade_scriptid()
|
|
upgrading = set()
|
|
if scriptid is not None:
|
|
upgrading = {row[0] for row in db.session.query(EndpointCommand.assetid)
|
|
.filter(EndpointCommand.assetid.in_(ids),
|
|
EndpointCommand.action == 'run_script',
|
|
EndpointCommand.target == f'script:{scriptid}',
|
|
EndpointCommand.status.in_(
|
|
(EndpointCommand.STATUS_PENDING,
|
|
EndpointCommand.STATUS_RUNNING)))}
|
|
|
|
lastscripts = _latest_commands(ids)
|
|
|
|
haspackage = _store_has_package()
|
|
minimum = minimum_agent_version_for('run_script')
|
|
offlinecutoff = _naive_utcnow() - _OFFLINE_AFTER
|
|
|
|
result = []
|
|
for assetid, hostname, serialnumber in computers:
|
|
state = states.get(assetid)
|
|
agentversion = state.agentversion if state is not None else None
|
|
lastreportedat = state.lastreportedat if state is not None else None
|
|
blockers = []
|
|
if assetid in active:
|
|
status = 'enrolled'
|
|
elif assetid in reenrollblocked:
|
|
status = 'blocked'
|
|
blockers.append(BLOCKER_REENROLL_BLOCKED)
|
|
elif _is_pending(assetid, hostname, index):
|
|
status = 'pending'
|
|
elif assetid in upgrading:
|
|
status = 'upgrading'
|
|
elif state is None:
|
|
status = 'notreporting'
|
|
else:
|
|
if lastreportedat is None or lastreportedat < offlinecutoff:
|
|
blockers.append(BLOCKER_OFFLINE)
|
|
if not (serialnumber or '').strip():
|
|
blockers.append(BLOCKER_NO_SERIAL)
|
|
if hostnamecounts[(hostname or '').lower()] > 1:
|
|
blockers.append(BLOCKER_DUPLICATE_HOSTNAME)
|
|
if ACTIVE_LOCK_FOR(assetid) is not None:
|
|
blockers.append(BLOCKER_LOCKED)
|
|
if not haspackage:
|
|
blockers.append(BLOCKER_NO_PACKAGE)
|
|
if minimum and not agent_meets_minimum(agentversion, minimum):
|
|
blockers.append(BLOCKER_TOO_OLD)
|
|
if _is_tls_trust_failure(lastscripts.get(assetid)):
|
|
blockers.append(BLOCKER_CERT_UNTRUSTED)
|
|
if not _version_below(agentversion, CREDENTIAL_AGENT_VERSION):
|
|
blockers.append(BLOCKER_AWAITING_ENROLLMENT)
|
|
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({
|
|
'assetid': assetid,
|
|
'hostname': hostname,
|
|
'agentversion': agentversion,
|
|
'lastreportedat': _utciso(lastreportedat),
|
|
'state': status,
|
|
'blockers': blockers,
|
|
})
|
|
return result
|
|
|
|
|
|
def _wave_progress(wave, scriptid):
|
|
"""Live progress of one wave.
|
|
|
|
failed counts a member only while it is still stuck: its newest agent
|
|
upgrade (from any wave or a single push) failed or expired, it is not
|
|
enrolled, and it does not report an agent at or above the credential
|
|
version. A PC a later wave upgraded stops counting here.
|
|
"""
|
|
members = wave.assetidlist
|
|
enrolled = set()
|
|
failed = set()
|
|
queued = set()
|
|
if members:
|
|
enrolled = {c.assetid for c in EndpointCredential.query.filter(
|
|
EndpointCredential.assetid.in_(members),
|
|
EndpointCredential.revokedat.is_(None)).all()}
|
|
if scriptid is not None:
|
|
target = f'script:{scriptid}'
|
|
queued = {row[0] for row in db.session.query(EndpointCommand.assetid)
|
|
.filter(EndpointCommand.assetid.in_(members),
|
|
EndpointCommand.action == 'run_script',
|
|
EndpointCommand.target == target,
|
|
EndpointCommand.createdat >= wave.createdat)
|
|
.distinct()}
|
|
versions = dict(db.session.query(EndpointState.assetid,
|
|
EndpointState.agentversion)
|
|
.filter(EndpointState.assetid.in_(members)).all())
|
|
failed = {assetid for assetid, command
|
|
in _latest_commands(members, target).items()
|
|
if assetid not in enrolled
|
|
and command.status in (EndpointCommand.STATUS_FAILED,
|
|
EndpointCommand.STATUS_EXPIRED)
|
|
and _version_below(versions.get(assetid),
|
|
CREDENTIAL_AGENT_VERSION)}
|
|
return {
|
|
'waveid': wave.waveid,
|
|
'createdat': _utciso(wave.createdat),
|
|
'createdby': wave.createdby,
|
|
'size': len(members),
|
|
'queued': len(queued),
|
|
'enrolled': len(enrolled),
|
|
'failed': len(failed),
|
|
'done': len(enrolled) + len(failed) >= len(members),
|
|
}
|
|
|
|
|
|
@endpoints_bp.route('/enrollment/migration', methods=['GET'])
|
|
@require_permission('endpoints.fleet')
|
|
def get_enrollment_migration():
|
|
"""Per-computer readiness to move onto per-PC credentials, plus waves."""
|
|
rows = compute_readiness()
|
|
counts = Counter(row['state'] for row in rows)
|
|
|
|
state = (request.args.get('state') or '').strip().lower()
|
|
if state:
|
|
if state not in MIGRATION_STATES:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
f'Unknown state filter: {state!r}')
|
|
rows = [row for row in rows if row['state'] == state]
|
|
rows = _search_filter(rows, (request.args.get('search') or '').strip(),
|
|
'hostname', 'agentversion')
|
|
pagerows, pagination = _page(rows)
|
|
|
|
scriptid = _upgrade_scriptid()
|
|
waves = (EndpointMigrationWave.query
|
|
.order_by(EndpointMigrationWave.createdat.desc())
|
|
.limit(_RECENT_REQUESTS)
|
|
.all())
|
|
|
|
return success_response({
|
|
'target': CREDENTIAL_AGENT_VERSION,
|
|
'counts': {name: counts[name] for name in MIGRATION_STATES},
|
|
'computers': pagerows,
|
|
'waves': [_wave_progress(wave, scriptid) for wave in waves],
|
|
}, meta={'pagination': pagination})
|
|
|
|
|
|
def _response_message(response):
|
|
try:
|
|
body = response.get_json() or {}
|
|
except Exception: # noqa: BLE001 - reason text only
|
|
return 'enqueue refused'
|
|
error = ((body.get('data') or {}).get('error') or {})
|
|
return error.get('message') or body.get('message') or 'enqueue refused'
|
|
|
|
|
|
@endpoints_bp.route('/enrollment/migration/wave', methods=['POST'])
|
|
@require_role('admin')
|
|
def create_migration_wave():
|
|
"""Push the agent upgrade to a batch of READY PCs.
|
|
|
|
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. 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
|
|
|
|
payload = request.get_json(silent=True) or {}
|
|
requested = payload.get('assetids')
|
|
nextcount = payload.get('next')
|
|
|
|
if requested is not None:
|
|
if (not isinstance(requested, list) or not requested
|
|
or not all(isinstance(v, int) and not isinstance(v, bool)
|
|
for v in requested)):
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'assetids must be a non-empty list of integers')
|
|
if len(requested) > _WAVE_MAXIMUM:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
f'A wave holds at most {_WAVE_MAXIMUM} PCs')
|
|
candidates = list(dict.fromkeys(requested))
|
|
elif nextcount is not None:
|
|
if (isinstance(nextcount, bool) or not isinstance(nextcount, int)
|
|
or nextcount < 1 or nextcount > _WAVE_MAXIMUM):
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
f'next must be an integer from 1 to {_WAVE_MAXIMUM}')
|
|
candidates = [row['assetid'] for row in compute_readiness()
|
|
if row['state'] == 'ready'][:nextcount]
|
|
else:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'Send assetids or next')
|
|
|
|
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})
|
|
else:
|
|
ready.append(assetid)
|
|
|
|
queued = []
|
|
waveid = None
|
|
if ready:
|
|
startedat = _naive_utcnow().replace(microsecond=0)
|
|
registration, seeded, error = prepare_agent_upgrade()
|
|
if error is not None:
|
|
return error
|
|
for assetid in ready:
|
|
response = queue_prepared_agent_upgrade(assetid, registration,
|
|
seeded, {})
|
|
if getattr(response, 'status_code', None) == 201:
|
|
queued.append(assetid)
|
|
else:
|
|
db.session.rollback()
|
|
skipped.append({'assetid': assetid,
|
|
'reason': _response_message(response)})
|
|
|
|
if queued:
|
|
wave = EndpointMigrationWave(
|
|
createdat=startedat, createdby=_username(),
|
|
assetids=','.join(str(v) for v in queued))
|
|
db.session.add(wave)
|
|
db.session.flush()
|
|
waveid = wave.waveid
|
|
AuditLog.log('created', 'EndpointMigrationWave', entityid=waveid,
|
|
entityname='agent {}'.format(registration['agentversion']),
|
|
details='migration wave queued agent upgrade for {} PC(s): {}'
|
|
.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,
|
|
'retried': retried},
|
|
message='{} PC(s) queued'.format(len(queued)),
|
|
http_code=201 if queued else 200)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Dashboard
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@endpoints_bp.route('/dashboard/enrollmentpending', methods=['GET'])
|
|
@require_permission('endpoints.fleet')
|
|
def dashboard_enrollment_pending():
|
|
"""One queue row while enroll requests wait for an admin, else nothing.
|
|
|
|
A count rather than a row per PC: the decision is made on the Enrollment
|
|
page, where the asset picker is, so the dashboard only has to say go there.
|
|
"""
|
|
count = (db.session.query(db.func.count(EndpointEnrollRequest.requestid))
|
|
.filter(_awaiting_admin_filter())
|
|
.scalar()) or 0
|
|
if not count:
|
|
return success_response([])
|
|
return success_response([{
|
|
'summary': '{} PC{} waiting for enrollment approval'.format(
|
|
count, '' if count == 1 else 's'),
|
|
'pendingcount': count,
|
|
}])
|
|
|
|
|
|
@endpoints_bp.route('/dashboard/reenrollblocked', methods=['GET'])
|
|
@require_permission('endpoints.fleet')
|
|
def dashboard_reenroll_blocked():
|
|
"""PCs kept from re-enrolling for more than 7 days.
|
|
|
|
A block is the first step of a rollback and is meant to be lifted again.
|
|
There is no separate block timestamp: revokedat is when the credential
|
|
stopped working, which is when the rollback started.
|
|
"""
|
|
Computer = _computer_model()
|
|
cutoff = _naive_utcnow() - _BLOCKED_ATTENTION_AFTER
|
|
now = _naive_utcnow()
|
|
rows = (db.session.query(EndpointCredential, Computer.computerid,
|
|
Computer.hostname)
|
|
.join(Computer, Computer.assetid == EndpointCredential.assetid)
|
|
.join(Asset, Asset.assetid == EndpointCredential.assetid)
|
|
.filter(Asset.isactive.is_(True),
|
|
EndpointCredential.reenrollblocked.is_(True),
|
|
EndpointCredential.revokedat.isnot(None),
|
|
EndpointCredential.revokedat < cutoff)
|
|
.order_by(EndpointCredential.revokedat.asc())
|
|
.limit(50)
|
|
.all())
|
|
return success_response([{
|
|
'computerid': computerid,
|
|
'assetid': credential.assetid,
|
|
'hostname': hostname,
|
|
'blockeddays': (now - credential.revokedat).days,
|
|
'reason': credential.reenrollblockedreason,
|
|
} for credential, computerid, hostname in rows])
|