main was 16 days and 259 commits behind the line the product is actually built on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It was neither current nor curated - just stale. This brings it forward and, at the same time, makes it mean something: main is what a receiving site installs, and nothing else. WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides, knowledgebase, machines, network, notifications, printers, slides, tools, warranty - plus applications, which is manifest-less core and always ships. The only manifest dependency inside that set is endpoints on computers, and both are in it, so the closure is self-contained. WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb stay on the site line. So do the Windows installer tree, the VM rehearsal helpers, the agent tooling config, one site's data loader, the internal audit and findings records, and the planner spreadsheet. 1,135 tracked files against 1,438. A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped: deploy/site-profile-universal.json still named all seventeen plugins. It is the file docs/DEPLOY.md tells a site to provision from, so a fresh install would have failed at `flask plugin apply-profile` with PluginError - the first thing a receiving site does, broken by the branch meant to serve them. It now names the twelve that exist, and its comment says the two must agree. agent/ was never refreshed. Whoever populated this branch copied shopdb/, plugins/, frontend/ and tests/ from the site line and left the agent tree at its September state: 22 C# and PowerShell sources missing, including AgentCredentials, EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the site line had deleted still present. The old tree is internally consistent, so it compiles, and nothing in CI builds it - every gate was green over a server that speaks enrollment and per-PC credentials to a bundled agent source that cannot. Resynced whole. DECISIONS THIS ENCODES, rather than defers: - The plugin-authoring lab builds printedparts, whose manifest depends on employees, so it could not stay. The lab remains on the site line and main ships no build-along tutorial. That is a real loss for an adopting developer and is recorded here rather than discovered later. - Recognition, Training and Recertification notifications are no longer seeded. Each splits one notification into a card per employee and renders a photo, which needs a plugin this branch does not bundle. Seeding them would offer an author a type that cannot resolve anybody. A site that installs employees gets them from that plugin's own seed. - Windows/IIS is documented as the legacy path throughout, and the README leads with Docker, because that is what a receiving site gets. The API description is regenerated for THIS branch - 347 paths and 483 operations, down from 415 and 573 - so the published contract describes what is here rather than what the site line serves. docs/CONFIG.md lost the two measuring-tool label keys the code no longer declares, which two lanes had decided opposite ways. Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run rather than skip, and every index line count is derived from the file it names.
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])
|