Show per-PC credentials across the admin interface
Some checks failed
CI / backend (push) Has been cancelled
CI / naming (push) Has been cancelled
CI / gates (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / lean-build (push) Has been cancelled

- 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:
cproudlock
2026-09-15 14:09:08 -04:00
parent da4b224ac1
commit c2ffc591bb
17 changed files with 1732 additions and 61 deletions

View File

@@ -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])?)*$')