Show per-PC credentials across the admin interface
- Endpoints (shared key) page: the key is presented as what PCs use until they enroll, with enrollment progress, a link to Enrollment, rewritten guidance, and rotate/revoke confirmations that say how many PCs are not enrolled yet. - Settings cards describe the server certificate as what PCs actually receive, on IIS or Docker; Enrollment has its own icon (the icon set lacked the one the Server Certificate card already asked for). - Server Certificate page checks the certificate the site presents at its public address and compares its pin with the certificate file, warning when they differ. The host comes from site settings only; the check is a TLS handshake, never an HTTP request. - Fleet list: Credential column and filter (enrolled, pending, re-enroll blocked, shared key, not reporting), computed in the list query. - PC detail: credential state, prefix and dates for fleet readers, state only for others, with admin Revoke (optionally blocking re-enrollment) and Allow re-enrollment. - Dashboard: pending enrollment requests, and PCs blocked from re-enrolling for more than seven days. - Runbook: where to find each of these in the app.
This commit is contained in:
@@ -13,6 +13,10 @@ Admin (endpoints.fleet to read, admin role to change):
|
||||
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
|
||||
@@ -89,6 +93,25 @@ _TLS_TRUST_FAILURES = (
|
||||
_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():
|
||||
@@ -329,6 +352,97 @@ def _is_pending(assetid, hostname, 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
|
||||
@@ -917,3 +1031,59 @@ def create_migration_wave():
|
||||
{'waveid': waveid, 'queued': queued, 'skipped': skipped},
|
||||
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])
|
||||
|
||||
@@ -233,6 +233,7 @@ def list_endpoints():
|
||||
operator reads first.
|
||||
"""
|
||||
from plugins.computers.models import Computer, ComputerType
|
||||
from .enrollment import CREDENTIAL_STATES, credential_state_expression
|
||||
|
||||
page, per_page = get_pagination_params(request)
|
||||
cutoff = _naive_utcnow() - timedelta(hours=_quiet_hours())
|
||||
@@ -245,10 +246,16 @@ def list_endpoints():
|
||||
else_=2,
|
||||
)
|
||||
|
||||
# Evaluated in the same statement as correlated EXISTS, so the column and
|
||||
# its filter cost no query per row and survive pagination.
|
||||
credentialstate = credential_state_expression(
|
||||
EndpointState.assetid, Computer.hostname, EndpointState.lastreportedat)
|
||||
|
||||
query = (db.session.query(EndpointState, Computer.computerid,
|
||||
Computer.computertypeid, ComputerType.computertype,
|
||||
Asset.assetnumber, Asset.name,
|
||||
statusrank.label('statusrank'))
|
||||
statusrank.label('statusrank'),
|
||||
credentialstate.label('credentialstate'))
|
||||
.join(Asset, Asset.assetid == EndpointState.assetid)
|
||||
.join(Computer, Computer.assetid == EndpointState.assetid)
|
||||
.outerjoin(ComputerType,
|
||||
@@ -271,6 +278,10 @@ def list_endpoints():
|
||||
if wanted is not None:
|
||||
query = query.filter(statusrank == wanted)
|
||||
|
||||
wantedcredential = (request.args.get('credentialstate') or '').strip().lower()
|
||||
if wantedcredential in CREDENTIAL_STATES:
|
||||
query = query.filter(credentialstate == wantedcredential)
|
||||
|
||||
if computertypeid := request.args.get('computertypeid'):
|
||||
query = query.filter(Computer.computertypeid == computertypeid)
|
||||
if locationid := request.args.get('locationid'):
|
||||
@@ -300,7 +311,7 @@ def list_endpoints():
|
||||
|
||||
data = []
|
||||
for (state, computerid, computertypeid, computertype, assetnumber, alias,
|
||||
rank) in items:
|
||||
rank, credential) in items:
|
||||
row = state.to_dict()
|
||||
row['computerid'] = computerid
|
||||
row['assetnumber'] = assetnumber
|
||||
@@ -313,6 +324,7 @@ def list_endpoints():
|
||||
row['status'] = ('never', 'quiet', 'reporting')[rank]
|
||||
row['machines'] = machines.get(state.assetid, [])
|
||||
row['health'] = health.get(state.assetid)
|
||||
row['credentialstate'] = credential
|
||||
data.append(row)
|
||||
|
||||
return paginated_response(data, page, per_page, total)
|
||||
@@ -431,6 +443,21 @@ def _health_for_assets(states, computertypes):
|
||||
return out
|
||||
|
||||
|
||||
def _credential_for_state(state):
|
||||
"""Per-PC credential state for one endpoint, redacted below endpoints.fleet.
|
||||
|
||||
The state itself is as public as the rest of the status panel. The prefix,
|
||||
timestamps, enrollment path and block reason are fleet administration, so a
|
||||
reader holding only endpoints.view (a guest, where a site grants that) gets
|
||||
the state and nothing else.
|
||||
"""
|
||||
from .enrollment import credential_detail
|
||||
|
||||
hostname = _hostname_for_asset(state.assetid) or state.hostname
|
||||
return credential_detail(state.assetid, hostname, state.lastreportedat,
|
||||
detailed=caller_holds('endpoints.fleet'))
|
||||
|
||||
|
||||
@endpoints_bp.route('/<int:asset_id>', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def get_endpoint(asset_id: int):
|
||||
@@ -469,6 +496,7 @@ def get_endpoint(asset_id: int):
|
||||
# the enqueue guard refuses against these same numbers, so a copy in the
|
||||
# frontend would silently disagree the moment a floor was raised here.
|
||||
data['actionminimums'] = action_minimum_versions()
|
||||
data['credential'] = _credential_for_state(state)
|
||||
|
||||
# Localize timestamps to site timezone
|
||||
for ts_field in ['lastreportedat', 'lasteventat', 'created_at', 'updated_at']:
|
||||
@@ -625,6 +653,17 @@ def panel_status(asset_id: int):
|
||||
state.lastreportedat.isoformat() + 'Z' if state.lastreportedat else None,
|
||||
fmt='datetime'),
|
||||
]
|
||||
credential = _credential_for_state(state)
|
||||
fields.extend([
|
||||
field('Credential', credential['statelabel']),
|
||||
field('Credential prefix',
|
||||
credential['credentialprefix'] + '...'
|
||||
if credential['credentialprefix'] else None, mono=True),
|
||||
field('Credential issued', credential['issuedat'], fmt='datetime'),
|
||||
field('Credential last used', credential['lastusedat'], fmt='datetime'),
|
||||
field('Enrolled via', credential['enrolledvia']),
|
||||
field('Re-enroll block reason', credential['reenrollblockedreason']),
|
||||
])
|
||||
fields = [f for f in fields if f['value'] not in (None, '')]
|
||||
|
||||
# Informational only, and appended after the null-filter above because it
|
||||
@@ -654,6 +693,7 @@ def panel_status(asset_id: int):
|
||||
'agentbehind': bool(agentbehind),
|
||||
'agentversion': state.agentversion,
|
||||
'agentcurrent': published,
|
||||
'credential': credential,
|
||||
})
|
||||
|
||||
|
||||
@@ -4370,6 +4410,97 @@ def download_server_certificate():
|
||||
return response
|
||||
|
||||
|
||||
_LIVE_CHECK_TIMEOUT_SECONDS = 5
|
||||
|
||||
|
||||
def _presented_certificate(host, port):
|
||||
"""DER bytes of the leaf certificate a TLS server presents, never trusted.
|
||||
|
||||
Verification is off ONLY so a self-signed or internal-CA certificate can be
|
||||
read at all; nothing is sent over the connection and nothing it returns is
|
||||
believed except the certificate bytes, which are hashed and compared.
|
||||
"""
|
||||
import ipaddress
|
||||
import socket
|
||||
import ssl
|
||||
|
||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
context.check_hostname = False
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
try:
|
||||
ipaddress.ip_address(host)
|
||||
servername = None
|
||||
except ValueError:
|
||||
servername = host
|
||||
with socket.create_connection((host, port),
|
||||
timeout=_LIVE_CHECK_TIMEOUT_SECONDS) as raw:
|
||||
with context.wrap_socket(raw, server_hostname=servername) as secured:
|
||||
return secured.getpeercert(binary_form=True)
|
||||
|
||||
|
||||
@endpoints_bp.route('/settings/server-cert/live', methods=['GET'])
|
||||
@require_permission('endpoints.fleet')
|
||||
def live_server_certificate():
|
||||
"""The certificate PCs actually receive from this site's public address.
|
||||
|
||||
The file on disk is what this app manages, but a PC pins whatever the site
|
||||
presents: an IIS binding, a reverse proxy or a load balancer in front of the
|
||||
app can serve a different certificate. The host comes from the
|
||||
site_base_url setting and never from the request, and only a TLS handshake
|
||||
is made (no HTTP request), so there is no redirect to follow.
|
||||
"""
|
||||
from urllib.parse import urlsplit
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
|
||||
baseurl = str(Setting.get('site_base_url') or '').strip()
|
||||
if not baseurl:
|
||||
return success_response({'configured': False})
|
||||
|
||||
result = {
|
||||
'configured': True,
|
||||
'url': baseurl,
|
||||
'presentedspkisha256': None,
|
||||
'presentedsha1thumbprint': None,
|
||||
'matchesfile': False,
|
||||
'error': None,
|
||||
}
|
||||
try:
|
||||
parts = urlsplit(baseurl)
|
||||
host = parts.hostname
|
||||
port = parts.port or 443
|
||||
except ValueError:
|
||||
host = None
|
||||
if not host:
|
||||
result['error'] = 'site_base_url is not a valid URL'
|
||||
return success_response(result)
|
||||
if (parts.scheme or '').lower() != 'https':
|
||||
result['error'] = 'site_base_url does not use https, so no certificate is presented'
|
||||
return success_response(result)
|
||||
|
||||
try:
|
||||
der = _presented_certificate(host, port)
|
||||
certificate = x509.load_der_x509_certificate(der)
|
||||
except Exception as exc: # noqa: BLE001 - reported to the admin, not raised
|
||||
result['error'] = 'Could not read the certificate from {}:{} ({})'.format(
|
||||
host, port, (str(exc) or exc.__class__.__name__)[:200])
|
||||
return success_response(result)
|
||||
|
||||
spki = certificate.public_key().public_bytes(
|
||||
serialization.Encoding.DER,
|
||||
serialization.PublicFormat.SubjectPublicKeyInfo)
|
||||
result['presentedspkisha256'] = hashlib.sha256(spki).hexdigest()
|
||||
result['presentedsha1thumbprint'] = certificate.fingerprint(
|
||||
hashes.SHA1()).hex().upper()
|
||||
|
||||
certpath = _server_cert_dir() / 'server.crt'
|
||||
if certpath.exists():
|
||||
filepin = _spki_sha256(certpath.read_bytes())
|
||||
result['matchesfile'] = bool(
|
||||
filepin and hmac.compare_digest(filepin, result['presentedspkisha256']))
|
||||
return success_response(result)
|
||||
|
||||
|
||||
_CERT_HOSTNAME_PATTERN = re.compile(
|
||||
r'^(\*\.)?(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?'
|
||||
r'(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$')
|
||||
|
||||
Reference in New Issue
Block a user