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

@@ -240,6 +240,19 @@ export const endpointsApi = {
}
return api.post(`/endpoints/${assetId}/agentupgrade`, body)
},
// Per-PC credential administration (admin role, re-checked server-side).
// blockreenroll keeps the PC from enrolling again until allowReenroll.
revokeCredential(assetId, opts = {}) {
const body = {}
if (opts.blockreenroll) {
body.blockreenroll = true
if (opts.reason) body.reason = opts.reason
}
return api.post(`/endpoints/enrollment/assets/${assetId}/revoke`, body)
},
allowReenroll(assetId) {
return api.post(`/endpoints/enrollment/assets/${assetId}/allow-reenroll`)
},
listCommands(assetId) {
return api.get(`/endpoints/${assetId}/commands`)
},

View File

@@ -4,7 +4,7 @@
// (landing) read the merged catalog so plugins add settings cards with no core
// edit. Fetched once into a module-level ref and shared across callers.
import { ref, computed } from 'vue'
import { Ruler, Wrench, Cog, Package, Settings, Puzzle, SlidersHorizontal, Palette, Bell, Network, Printer, Droplets, Terminal, KeyRound } from 'lucide-vue-next'
import { Ruler, Wrench, Cog, Package, Settings, Puzzle, SlidersHorizontal, Palette, Bell, Network, Printer, Droplets, Terminal, KeyRound, ShieldCheck, BadgeCheck } from 'lucide-vue-next'
import api from '../api'
import router from '../router'
import { settingsGroups } from '../views/settings/settingsNav'
@@ -28,6 +28,9 @@ const iconMap = {
// The endpoints agent-key card has asked for 'key' since it shipped and has
// been silently drawing a puzzle piece the whole time.
key: KeyRound,
// Server Certificate asked for 'shield-check' and drew the puzzle fallback.
'shield-check': ShieldCheck,
'badge-check': BadgeCheck,
}
function resolveIcon(key) {

View File

@@ -83,6 +83,25 @@
<span class="triage-dot"></span>{{ liveness.label }}
</span>
</div>
<!-- Per-PC credential controls. Admin only: every other reader
gets the state in the Endpoint status card, and the server
re-checks the role on both actions. -->
<div v-if="canManageCredential && credential" class="hero-credential">
<span class="hero-detail-label hero-status-label">Credential</span>
<span class="credential-state" :class="credential.state">
{{ credential.statelabel }}
</span>
<div class="hero-endpoint-actions">
<button v-if="credential.state === 'enrolled'"
class="btn btn-danger btn-sm credential-revoke"
:disabled="credentialbusy"
@click="openRevokeCredential">Revoke credential</button>
<button v-if="credential.reenrollblocked"
class="btn btn-secondary btn-sm credential-allow"
:disabled="credentialbusy"
@click="confirmAllowReenroll">Allow re-enrollment</button>
</div>
</div>
<div v-if="canCommand" class="hero-endpoint-actions">
<button @click="refreshStatus" class="btn btn-secondary btn-sm"
:disabled="restarting || refreshing">
@@ -1007,6 +1026,39 @@
</button>
</template>
</Modal>
<Modal v-model="showrevokecredential" title="Revoke endpoint credential" size="medium">
<p>
The credential for
<strong>{{ computer?.assetnumber || computer?.name || 'this PC' }}</strong>
stops working immediately. {{ revokeCredentialOutcome }}
</p>
<div class="form-group">
<label class="hold-lock-check">
<input v-model="revokeblockreenroll" type="checkbox" class="credential-block" />
<span>Keep this PC from enrolling again</span>
</label>
<small class="form-hint">
Use before rolling the PC back to an older agent, or for a compromised
PC. It stays off per-PC credentials until someone allows re-enrollment
here or on the Enrollment page.
</small>
</div>
<div v-if="revokeblockreenroll" class="form-group">
<label for="revokereason">Reason (optional)</label>
<input id="revokereason" v-model="revokereason" maxlength="64"
class="form-control" placeholder="rollback" />
</div>
<template #footer>
<button class="btn btn-secondary" @click="showrevokecredential = false">
Cancel
</button>
<button class="btn btn-danger" :disabled="credentialbusy"
@click="submitRevokeCredential">
{{ credentialbusy ? 'Revoking...' : 'Revoke credential' }}
</button>
</template>
</Modal>
</div>
</template>
@@ -1061,6 +1113,9 @@ const agentversion = ref(null)
// floor on the server cannot leave this page disagreeing with the API.
const agentcurrent = ref(null)
const actionminimums = ref({})
// Per-PC credential state from the same endpoint read. The server withholds
// the prefix and timestamps from readers below endpoints.fleet.
const credential = ref(null)
const installedApps = ref([])
const assetPanels = ref(null)
@@ -1502,6 +1557,7 @@ async function loadLiveness(assetid) {
agentversion.value = state.agentversion || null
agentcurrent.value = state.agentcurrent || null
actionminimums.value = state.actionminimums || {}
credential.value = state.credential || null
} catch (error) {
// 404 = no agent on this PC, 403 = the viewer cannot read telemetry.
// Neither is a page error; the chip just does not appear.
@@ -1509,6 +1565,62 @@ async function loadLiveness(assetid) {
agentversion.value = null
agentcurrent.value = null
actionminimums.value = {}
credential.value = null
}
}
// Revoke and allow-reenroll are require_role('admin') on the server.
const canManageCredential = computed(() => authStore.isAdmin)
const credentialbusy = ref(false)
const showrevokecredential = ref(false)
const revokeblockreenroll = ref(false)
const revokereason = ref('')
const revokeCredentialOutcome = computed(() => (revokeblockreenroll.value
? 'It keeps reporting on the shared key while that is allowed, and does not enroll again.'
: 'Its agent falls back to the shared key and enrolls again on its own, if the shared key is allowed.'))
function openRevokeCredential() {
revokeblockreenroll.value = false
revokereason.value = ''
showrevokecredential.value = true
}
async function submitRevokeCredential() {
if (!computer.value?.assetid) return
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
credentialbusy.value = true
try {
await endpointsApi.revokeCredential(computer.value.assetid, {
blockreenroll: revokeblockreenroll.value,
reason: revokereason.value.trim()
})
toast.success(`Credential revoked for ${label}`)
showrevokecredential.value = false
await loadLiveness(computer.value.assetid)
await assetPanels.value?.refresh()
} catch (error) {
toast.error(apiError(error, `Could not revoke the credential for ${label}`))
} finally {
credentialbusy.value = false
}
}
async function confirmAllowReenroll() {
if (!computer.value?.assetid) return
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
if (!confirm(`Allow ${label} to enroll again?\n\n`
+ 'Its agent enrolls on its next attempt if it runs agent 1.20 or later.')) return
credentialbusy.value = true
try {
await endpointsApi.allowReenroll(computer.value.assetid)
toast.success(`${label} may enroll again`)
await loadLiveness(computer.value.assetid)
await assetPanels.value?.refresh()
} catch (error) {
toast.error(apiError(error, `Could not allow re-enrollment for ${label}`))
} finally {
credentialbusy.value = false
}
}
@@ -2393,6 +2505,22 @@ async function confirmRetire() {
justify-content: flex-end;
gap: 0.5rem;
}
.hero-credential {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.45rem;
}
.credential-state {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-light);
}
.credential-state.enrolled { color: var(--success-dark); }
.credential-state.pending { color: var(--warning-dark); }
.credential-state.reenrollblocked { color: var(--danger); }
[data-theme="dark"] .credential-state.enrolled { color: var(--success); }
[data-theme="dark"] .credential-state.pending { color: var(--warning); }
/* Warning-toned, not error-toned: a hold is a deliberate, audited state, not a
fault. It should read as "someone did this on purpose" at a glance. */
.policy-holds {
@@ -3052,7 +3180,8 @@ async function confirmRetire() {
align-items: flex-start;
width: 100%;
}
.hero-status { align-items: flex-start; }
.hero-status,
.hero-credential { align-items: flex-start; }
.hero-endpoint-actions { justify-content: flex-start; }
}
.copy-btn:hover {

View File

@@ -0,0 +1,58 @@
/**
* Per-PC credential controls on PC Details.
*
* Revoke and allow-reenroll are admin-role routes on the server. The page reads
* the credential from the same endpoint state call the liveness chip uses, and
* offers the two actions to admins only; every other reader gets the state in
* the Endpoint status card, where the server has already redacted the details.
*
* Asserted against the file text, matching pcDetailRunScripts.spec.js.
*/
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
const here = dirname(fileURLToPath(import.meta.url))
const source = readFileSync(join(here, 'PCDetail.vue'), 'utf8')
// Relative to the staged copy under src/.plugins-staged/, which is what runs.
const api = readFileSync(join(here, '..', '..', '..', 'api', 'index.js'), 'utf8')
describe('PCDetail credential controls', () => {
it('gates the controls on the admin role', () => {
expect(source).toMatch(/const canManageCredential = computed\(\(\) => authStore\.isAdmin\)/)
expect(source).toMatch(/v-if="canManageCredential && credential"/)
})
it('reads the credential from the endpoint state response', () => {
const loader = source.slice(source.indexOf('async function loadLiveness'))
const body = loader.slice(0, loader.indexOf('\n}\n'))
expect(body).toMatch(/credential\.value = state\.credential \|\| null/)
})
it('offers revoke only for an enrolled PC and allow only for a blocked one', () => {
expect(source).toMatch(/v-if="credential\.state === 'enrolled'"[\s\S]{0,120}credential-revoke/)
expect(source).toMatch(/v-if="credential\.reenrollblocked"[\s\S]{0,120}credential-allow/)
})
it('offers the keep-from-enrolling option and sends it with the reason', () => {
expect(source).toMatch(/v-model="revokeblockreenroll"/)
expect(source).toMatch(/Keep this PC from enrolling again/)
const handler = source.slice(source.indexOf('async function submitRevokeCredential'))
const body = handler.slice(0, handler.indexOf('\n}\n'))
expect(body).toMatch(/blockreenroll: revokeblockreenroll\.value/)
expect(body).toMatch(/reason: revokereason\.value\.trim\(\)/)
})
it('calls the existing enrollment routes', () => {
expect(api).toMatch(/`\/endpoints\/enrollment\/assets\/\$\{assetId\}\/revoke`/)
expect(api).toMatch(/`\/endpoints\/enrollment\/assets\/\$\{assetId\}\/allow-reenroll`/)
expect(api).toMatch(/if \(opts\.blockreenroll\) \{\s*body\.blockreenroll = true/)
})
it('confirms before allowing re-enrollment', () => {
const handler = source.slice(source.indexOf('async function confirmAllowReenroll'))
const body = handler.slice(0, handler.indexOf('\n}\n'))
expect(body).toMatch(/if \(!confirm\(/)
})
})

View File

@@ -727,6 +727,14 @@ Read needs `endpoints.fleet`; writes need the admin role and are audited.
- `PUT /api/endpoints/enrollment/settings` `{"sharedkeyallowed": bool, "autoenroll": bool}`
- `GET /api/endpoints/enrollment/migration` (`page`, `perpage` <= 100, `state`, `search`)
- `POST /api/endpoints/enrollment/migration/wave` `{"assetids": [...]}` or `{"next": n}` (n <= 100)
- `GET /api/endpoints` rows carry `credentialstate` (`enrolled`,
`reenrollblocked`, `pending`, `notreporting`, `sharedkey`) and accept a
`credentialstate` filter. `GET /api/endpoints/<assetid>` carries a
`credential` object in the same vocabulary; below `endpoints.fleet` only its
state is filled in.
- `GET /api/endpoints/settings/server-cert/live` (`endpoints.fleet`) reads the
certificate presented at `site_base_url` and returns `presentedspkisha256`,
`presentedsha1thumbprint` and `matchesfile`, or `{"configured": false}`.
- Migration blockers include `server certificate not trusted by this PC`: the
PC's last upgrade download failed on the certificate chain (the server only
knows from the failure the PC reported). Trust the certificate

View File

@@ -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])

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

View File

@@ -21,6 +21,30 @@ root, `<pin>` a server key pin, `<pilotbay>` / `<pilothost>` the pilot PC.
turns it off. An enrolled PC is always refused the shared key
(`401 SHARED_KEY_REFUSED`).
## Where to look in the app
- **Settings -> Endpoints -> Enrollment**: Migration tab (readiness, waves) and
Credentials tab (enrolled PCs, pending requests, un-enroll, allow
re-enrollment, the shared key and auto-enroll switches).
- **Settings -> Endpoints** (the agent key page): the shared key PCs use until
they enroll, with an enrolled-of-total summary and the pending request count.
Regenerate and revoke there say how many PCs are not enrolled and would be
affected.
- **Settings -> Endpoints -> Server Certificate**: the certificate file's pin
and thumbprint, and **What PCs receive**, a live check of the certificate the
site's base URL (`site_base_url`) actually presents. When the two differ, pin
the presented one.
- **Fleet** (sidebar, under Assets): the **Credential** column (Enrolled,
Pending, Re-enroll blocked, Shared key (not enrolled), Not reporting) and a
filter on it.
- **PC detail**: the Endpoint status card shows the PC's credential state (with
prefix, issued, last used, enrolled via and block reason for
`endpoints.fleet` holders). Admins get **Revoke credential** (with **Keep this
PC from enrolling again**) and **Allow re-enrollment** beside the status chip.
- **Dashboard**: "N PCs waiting for enrollment approval" (warning, links to
Enrollment) and "PCs blocked from re-enrolling over 7 days" (info, counted
from when the credential was revoked).
## Before you start
1. Server deployed with the endpoints migration `endpoints0023credentials`

View File

@@ -5,15 +5,17 @@
</div>
<p class="setting-description">
Manage how shop-floor endpoint agents authenticate to this server, build
the agent for this site, and set what it reports and how often.
Manage the shared key PCs use until they enroll, build the agent for this
site, and set what it reports and how often. Each enrolled PC
authenticates with its own credential instead; see Settings &gt; Endpoints
&gt; Enrollment.
</p>
<!-- ====================================================================
Key management
Shared key
================================================================= -->
<section class="settings-section">
<h3>Key management</h3>
<h3>Shared key (used until PCs enroll)</h3>
<p class="key-warning">
STORE YOUR KEY SECURELY. IT IS SHOWN ONLY ONCE AND CANNOT BE RECOVERED.
</p>
@@ -25,12 +27,40 @@
paste the raw key into a chat message or a plain file.
</p>
<p class="field-hint">
The endpoint agent reports with a single managed key scoped to endpoint
telemetry only. It is separate from every other collector, so rotating
or revoking it stops all endpoint-agent traffic immediately without
touching the PC or GE-Enforce collectors.
Every PC installed with the shared key holds this same key. It lets a
PC that has not enrolled yet report and ask for its own credential. An enrolled PC is refused it and
uses its credential instead, so rotating or revoking the key only
affects PCs that have not enrolled. It is scoped to endpoint telemetry
and separate from every other collector.
</p>
<div v-if="enrollment" class="card form-card enrollment-summary">
<div class="enrollment-counts">
<span class="enrollment-count">
<strong>{{ enrollment.enrolled }}</strong> of {{ enrollment.total }} PCs enrolled
</span>
<span class="enrollment-count">
<strong>{{ enrollment.notenrolled }}</strong> still on the shared key
</span>
<span class="enrollment-count">
<strong>{{ enrollment.requests }}</strong>
pending enrollment {{ enrollment.requests === 1 ? 'request' : 'requests' }}
</span>
</div>
<div class="progress-track">
<div class="progress-fill" :style="{ width: enrolledPercent + '%' }"></div>
</div>
<p class="field-hint">
<template v-if="!enrollment.sharedkeyallowed">
<strong>The shared key is switched off</strong> in Enrollment, so PCs
that have not enrolled already cannot report with it.
</template>
Approve requests, move PCs over and retire the shared key in
<router-link :to="{ name: 'endpoints-enrollment-settings' }">
Settings &gt; Endpoints &gt; Enrollment</router-link>.
</p>
</div>
<div class="card form-card">
<div v-if="message" class="settings-success">{{ message }}</div>
<div v-if="error" class="error-message">{{ error }}</div>
@@ -49,8 +79,8 @@
<template v-else>Not used yet.</template>
</p>
<p v-else class="field-hint">
No endpoint agent key is active. Generate one to let agents report
with a managed credential.
No shared key is active. PCs that have not enrolled cannot report
or auto-enroll until one is generated.
</p>
</div>
@@ -85,9 +115,10 @@
<div v-if="secret" class="card form-card secret-card">
<h4>Save this key now</h4>
<p class="field-hint">
Anyone with this key can report as an endpoint agent for this site.
Treat it like a password. If it is lost you must regenerate, which
revokes the old key and forces every agent to be reconfigured.
Anyone with this key can report as a not-yet-enrolled endpoint agent
for this site and ask to enroll. Treat it like a password. If it is
lost you must regenerate, which revokes the old key; PCs that have not
enrolled then need the new one.
</p>
<p class="field-hint">
<strong>Recommended:</strong> before you leave this page, open the
@@ -110,53 +141,57 @@
<summary><h4>How it works</h4></summary>
<ul class="field-hint info-list">
<li>
<strong>Generate</strong> mints a fresh key and, on the first
generation, turns off the bootstrap <code>.env</code> fallback so
the managed key becomes the only accepted credential. The full
secret is shown once - store it in the agent config as the
<code>X-API-Key</code> header.
<strong>Until a PC enrolls</strong> it reports with this shared key.
Agents from 1.20 use it once more to ask for their own credential,
and from then on send that credential instead.
</li>
<li>
<strong>Regenerate</strong> is a hard cutover: the previous key is
revoked in the same moment the new one is minted. Every deployed
agent must be reconfigured with the new key before it can report
again.
<strong>Enrolled PCs ignore it.</strong> The server refuses the shared
key for any PC that holds a credential, so regenerating or revoking
it does not touch them.
</li>
<li>
<strong>Revoke</strong> deactivates the key and leaves it off. All
endpoint-agent reporting stops until you generate a new key. This is
the kill switch.
<strong>Generate</strong> mints a key and, the first time, turns off
the bootstrap <code>.env</code> fallback. The full secret is shown
once; it goes into the agent config as the <code>X-API-Key</code>
header.
</li>
<li>
<strong>Regenerate</strong> revokes the previous key in the same
moment. PCs that have not enrolled must be given the new key before
they can report or enroll again.
</li>
<li>
<strong>Revoke</strong> leaves no shared key. PCs that have not
enrolled stop reporting and can no longer auto-enroll; enrolled PCs
carry on.
</li>
</ul>
</details>
<details class="collapsible">
<summary><h4>When to rotate</h4></summary>
<summary><h4>When to rotate or retire</h4></summary>
<ul class="field-hint info-list">
<li>
<strong>An installer or agent left the secure environment.</strong>
If a build carrying the key is copied off the shop floor - onto an
engineer's laptop, a USB stick, or a machine that leaves the site -
treat the key as compromised and regenerate immediately.
<strong>At cutover.</strong> Once every PC has enrolled, switch the
shared key off in Enrollment, clear it from every place it lives,
then regenerate so any copy you missed is dead. The steps are in the
per-PC credentials rollout runbook.
</li>
<li>
<strong>A device is lost, stolen, or decommissioned</strong> while
still holding the key, and you cannot confirm the key was wiped.
<strong>An installer or key left the secure environment.</strong> A
build or file carrying the key copied onto a laptop, a USB stick or a
machine that leaves the site. Regenerate; only PCs that have not
enrolled need the new key.
</li>
<li>
<strong>Staff offboarding.</strong> Someone who could read the key
from a config or build pipeline is leaving; rotate as part of the
offboarding checklist.
from a config or build pipeline is leaving.
</li>
<li>
<strong>Scheduled hygiene.</strong> Rotate on a routine cadence
(for example quarterly) so a silently leaked key has a bounded
lifetime.
</li>
<li>
<strong>Suspicious traffic.</strong> The Fleet view shows reports
from a host you do not recognize - revoke now, investigate, then
regenerate and redeploy to known-good endpoints.
<strong>Suspicious traffic.</strong> The Fleet view shows reports from
a host you do not recognize. Revoke now, investigate, then regenerate
for the PCs that still need it.
</li>
</ul>
</details>
@@ -532,7 +567,7 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import api from '@/api'
@@ -545,6 +580,9 @@ const secret = ref('')
const copied = ref(false)
const message = ref('')
const error = ref('')
// Enrollment progress, from the same counts the Enrollment page shows. Null
// when the read fails; the key controls work without it.
const enrollment = ref(null)
// Adoption tool: build a one-shot, agentless enrollment download.
const adoptbaseurl = ref('')
@@ -613,6 +651,40 @@ function filenameFromResponse(response, fallback) {
return match ? decodeURIComponent(match[1]) : fallback
}
const enrolledPercent = computed(() => {
const total = enrollment.value?.total || 0
return total ? Math.round((enrollment.value.enrolled / total) * 100) : 0
})
async function loadEnrollment() {
try {
const response = await api.get('/endpoints/enrollment', { params: { perpage: 1 } })
const data = response.data.data || {}
const counts = data.counts || {}
const enrolled = counts.enrolled || 0
// pending PCs asked but hold no credential yet: still on the shared key.
const notenrolled = (counts.notenrolled || 0) + (counts.pending || 0)
enrollment.value = {
enrolled,
notenrolled,
total: enrolled + notenrolled,
requests: (data.requests || []).filter(
(entry) => entry.status === 'pending' && entry.reason !== 'reenroll blocked').length,
sharedkeyallowed: data.settings?.sharedkeyallowed !== false
}
} catch (loadError) {
enrollment.value = null
console.error(loadError)
}
}
// The number of PCs a change to the shared key reaches, for the confirms.
function notEnrolledPhrase() {
if (!enrollment.value) return 'Every PC that has not enrolled'
const count = enrollment.value.notenrolled
return `${count} PC${count === 1 ? '' : 's'} not enrolled`
}
function applyStatus(data) {
key.value = data.key || null
envkeyfallback.value = !!data.envkeyfallback
@@ -632,6 +704,7 @@ onMounted(async () => {
} finally {
loading.value = false
}
loadEnrollment()
try {
const response = await api.get('/endpoints/siteagent')
const status = response.data.data || {}
@@ -656,8 +729,9 @@ function applyBinaryStatus(binkey, current, versions) {
async function regenerate() {
if (key.value &&
!window.confirm('Regenerate the key? The current key stops working '
+ 'immediately and every agent must be reconfigured.')) {
!window.confirm('Regenerate the shared key? The current key stops working '
+ `immediately. ${notEnrolledPhrase()} will need the new key before they `
+ 'can report or enroll. Enrolled PCs are not affected.')) {
return
}
busy.value = true
@@ -684,8 +758,9 @@ async function regenerate() {
}
async function revoke() {
if (!window.confirm('Revoke the key? All endpoint-agent reporting stops '
+ 'until a new key is generated.')) {
if (!window.confirm('Revoke the shared key? '
+ `${notEnrolledPhrase()} will lose access: they stop reporting and cannot `
+ 'enroll until a new key is generated. Enrolled PCs keep working.')) {
return
}
busy.value = true
@@ -959,6 +1034,24 @@ function editConfiguration() {
}
.secret-value { padding: 0.5rem; background: var(--color-surface-2, #1e1e1e); }
.settings-section { margin-bottom: 2rem; }
.enrollment-counts {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.5rem;
margin-bottom: 0.5rem;
}
.enrollment-count { font-size: 0.9rem; }
.progress-track {
height: 6px;
border-radius: 3px;
background: var(--color-border, #333);
overflow: hidden;
margin-bottom: 0.5rem;
}
.progress-fill {
height: 100%;
background: var(--color-success, #4caf50);
}
.settings-section > h3 { margin-bottom: 0.25rem; }
.info-list { margin: 0.25rem 0 0; padding-left: 1.25rem; }
.info-list li { margin-bottom: 0.5rem; }

View File

@@ -41,6 +41,12 @@
<option value="quiet">Quiet</option>
<option value="never">Never reported</option>
</select>
<select v-model="credentialstate" class="form-control fleet-filter" @change="applyFilters">
<option value="">All credentials</option>
<option v-for="option in credentialOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<select v-model="computertypeid" class="form-control fleet-filter" @change="applyFilters">
<option value="">All PC types</option>
<option v-for="type in computertypes" :key="type.computertypeid" :value="type.computertypeid">
@@ -103,6 +109,7 @@
<th>PC Type</th>
<th>Health</th>
<th>Agent Version</th>
<th>Credential</th>
<th>Logged-in User</th>
<th>Uptime</th>
<th>Last Reported</th>
@@ -137,12 +144,17 @@
<span v-else>-</span>
</td>
<td>{{ item.agentversion || '-' }}</td>
<td>
<span v-if="item.credentialstate" class="credential-chip"
:class="item.credentialstate">{{ credentialLabel(item.credentialstate) }}</span>
<span v-else>-</span>
</td>
<td>{{ item.loggedinuser || '-' }}</td>
<td>{{ item.uptimedisplay || '-' }}</td>
<td>{{ formatRelative(item.lastreportedat) }}</td>
</tr>
<tr v-if="endpoints.length === 0">
<td :colspan="cancommand ? 11 : 10" style="text-align: center; color: var(--text-light);">
<td :colspan="cancommand ? 12 : 11" style="text-align: center; color: var(--text-light);">
No endpoints found
</td>
</tr>
@@ -192,6 +204,17 @@ const sort = ref('status')
const summary = ref(null)
const criticalcount = ref(0)
const computertypeid = ref('')
// Which credential the PC reports with, computed server-side so the filter
// holds across pages. Read-only here: revoke and re-enrollment live on the PC
// detail page and the Enrollment settings page.
const credentialstate = ref('')
const credentialOptions = [
{ value: 'enrolled', label: 'Enrolled' },
{ value: 'pending', label: 'Pending' },
{ value: 'reenrollblocked', label: 'Re-enroll blocked' },
{ value: 'sharedkey', label: 'Shared key (not enrolled)' },
{ value: 'notreporting', label: 'Not reporting' }
]
const locationid = ref('')
const problemsonly = ref(false)
const computertypes = ref([])
@@ -329,6 +352,7 @@ async function loadEndpoints() {
if (search.value) params.search = search.value
if (status.value) params.status = status.value
if (sort.value) params.sort = sort.value
if (credentialstate.value) params.credentialstate = credentialstate.value
if (computertypeid.value) params.computertypeid = computertypeid.value
if (locationid.value) params.locationid = locationid.value
@@ -368,6 +392,11 @@ function openPc(item) {
}
}
function credentialLabel(value) {
const option = credentialOptions.find(entry => entry.value === value)
return option ? option.label : value
}
function statusLabel(value) {
return { reporting: 'Reporting', quiet: 'Quiet', never: 'Never' }[value] || '-'
}
@@ -394,6 +423,7 @@ function setStatus(value) {
function clearFilters() {
status.value = ''
credentialstate.value = ''
problemsonly.value = false
applyFilters()
}
@@ -515,6 +545,22 @@ function changePerPage(newPerPage) {
.fleet-filter { max-width: 200px; }
.credential-chip {
display: inline-block;
font-size: 0.78rem;
font-weight: 500;
padding: 0.1rem 0.5rem;
border-radius: 10px;
background: var(--bg);
color: var(--text-light);
white-space: nowrap;
}
.credential-chip.enrolled { color: var(--success-dark); }
.credential-chip.pending { color: var(--warning-dark); }
.credential-chip.reenrollblocked { color: var(--danger); }
[data-theme="dark"] .credential-chip.enrolled { color: var(--success); }
[data-theme="dark"] .credential-chip.pending { color: var(--warning); }
.bulk-bar {
display: flex;
align-items: center;

View File

@@ -5,9 +5,10 @@
</div>
<p class="setting-description">
Manage the TLS certificate that nginx uses for HTTPS (port 5001).
Agents validate this certificate using Windows trusted roots or
thumbprint pinning (ServerCertThumbprint in appsettings.json).
The TLS certificate PCs see when they connect to this site, whether it is
served by IIS or by the Docker stack. Its public key pin goes into the
Set-ShopDBServerPins.ps1 entry, and the certificate itself into the
trusted root entry for agents older than 1.20.
</p>
<!-- Certificate status -->
@@ -97,6 +98,56 @@
</div>
</section>
<!-- Live check: what the public address really presents -->
<section class="settings-section">
<h3>What PCs receive</h3>
<p class="field-hint">
Connects to this site's base URL and reads the certificate it presents,
the way a PC does. An IIS binding or a proxy in front of the app can
present a different certificate than the file above.
</p>
<div class="card form-card">
<div class="form-group live-check">
<label>Presented public key pin (SPKI SHA-256)</label>
<p v-if="liveLoading" class="field-hint">Checking {{ live?.url || 'the site address' }}...</p>
<template v-else-if="live">
<p v-if="!live.configured" class="field-hint live-unconfigured">
No site base URL is set (site_base_url, under Settings, Email /
SMTP, or in the setup wizard), so there is no address to check.
</p>
<template v-else>
<p class="field-hint">Checked <span class="mono">{{ live.url }}</span></p>
<p v-if="live.error" class="live-error">{{ live.error }}</p>
<template v-else>
<p class="field-hint mono" :class="live.matchesfile ? 'live-match' : 'live-mismatch'">
{{ live.presentedspkisha256 }}
</p>
<p v-if="live.matchesfile" class="live-match live-status">
Matches the pin of the certificate file above.
</p>
<div v-else class="live-warning">
<strong>The site presents a different certificate than the file above.</strong>
PCs will be pinned to the certificate they actually receive; use the
presented pin.
<div class="live-pins">
<span>File pin:</span>
<span class="mono">{{ cert?.spkisha256 || 'no certificate file' }}</span>
<span>Presented pin:</span>
<span class="mono">{{ live.presentedspkisha256 }}</span>
<span>Presented thumbprint:</span>
<span class="mono">{{ live.presentedsha1thumbprint }}</span>
</div>
</div>
</template>
</template>
</template>
<button class="btn btn-secondary btn-sm live-recheck" :disabled="liveLoading" @click="loadLive">
{{ liveLoading ? 'Checking...' : 'Recheck' }}
</button>
</div>
</div>
</section>
<!-- Generate self-signed certificate -->
<section class="settings-section">
<h3>Generate self-signed certificate</h3>
@@ -299,6 +350,8 @@ const busy = ref(false)
const message = ref('')
const error = ref('')
const cert = ref(null)
const live = ref(null)
const liveLoading = ref(false)
const thumbprintCopied = ref(false)
const pinCopied = ref(false)
@@ -315,8 +368,28 @@ const uploadForm = ref({
onMounted(() => {
loadCertificate()
loadLive()
})
// What the site's public address actually presents. A proxy or IIS binding
// can serve a different certificate than the file this page manages, and a PC
// pins what it receives.
async function loadLive() {
liveLoading.value = true
try {
const response = await api.get('/endpoints/settings/server-cert/live')
live.value = response.data.data || null
} catch (err) {
live.value = {
configured: true,
url: live.value?.url || '',
error: `Could not run the check: ${err.message}`
}
} finally {
liveLoading.value = false
}
}
async function loadCertificate() {
loading.value = true
error.value = ''
@@ -357,6 +430,7 @@ async function generateCertificate() {
if (response.data.status === 'success') {
message.value = 'Certificate generated successfully. Nginx is restarting...'
await loadCertificate()
loadLive()
} else {
error.value = response.data.data?.error?.message || 'Failed to generate certificate'
}
@@ -405,6 +479,7 @@ async function uploadCertificate() {
if (response.data.status === 'success') {
message.value = 'Certificate uploaded successfully. Nginx is restarting...'
await loadCertificate()
loadLive()
// Clear file inputs
document.getElementById('cert-file').value = ''
document.getElementById('key-file').value = ''
@@ -529,6 +604,29 @@ pre.code-block {
margin-left: 20px;
}
.live-match { color: var(--success-dark); }
.live-mismatch { color: var(--danger); }
.live-status { font-size: 13px; margin: 4px 0; }
[data-theme="dark"] .live-match { color: var(--success); }
.live-error,
.live-warning {
margin: 8px 0;
padding: 10px 12px;
border-left: 3px solid var(--danger);
background: rgba(220, 53, 69, 0.1);
color: var(--danger);
font-size: 13px;
}
.live-pins {
display: grid;
grid-template-columns: max-content 1fr;
gap: 4px 12px;
margin-top: 8px;
word-break: break-all;
}
.info-list li {
margin-bottom: 12px;
}

View File

@@ -0,0 +1,133 @@
/**
* Endpoints (shared key) settings page.
*
* The shared key only matters to PCs that have not enrolled, so the page must
* say how far enrollment has got, and the regenerate and revoke confirms must
* name how many PCs the change actually reaches.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises, RouterLinkStub } from '@vue/test-utils'
vi.mock('@/api', () => ({
default: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() }
}))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
import api from '@/api'
import AgentKeySettings from './AgentKeySettings.vue'
const KEY = { tokenprefix: 'abcd', createddate: null, lastusedat: null }
function enrollmentPayload(settings = { sharedkeyallowed: true, autoenroll: true }) {
return {
data: {
data: {
settings,
counts: { enrolled: 30, notenrolled: 8, pending: 2 },
computers: [],
requests: [
{ requestid: 1, status: 'pending', reason: 'serial mismatch' },
{ requestid: 2, status: 'pending', reason: 'awaiting approval' },
{ requestid: 3, status: 'pending', reason: 'reenroll blocked' },
{ requestid: 4, status: 'issued', reason: null }
]
}
}
}
}
async function render(payload = enrollmentPayload()) {
api.get.mockImplementation((path) => {
if (path === '/endpoints/agentkey') {
return Promise.resolve({ data: { data: { key: KEY, envkeyfallback: false } } })
}
if (path === '/endpoints/enrollment') return Promise.resolve(payload)
return Promise.resolve({ data: { data: {} } })
})
api.post.mockResolvedValue({ data: { data: { key: KEY } } })
api.delete.mockResolvedValue({ data: { data: { key: null } } })
const wrapper = mount(AgentKeySettings, { global: { stubs: { RouterLink: RouterLinkStub } } })
await flushPromises()
return wrapper
}
describe('AgentKeySettings shared key section', () => {
beforeEach(() => {
api.get.mockReset()
api.post.mockReset()
api.delete.mockReset()
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('titles the section for the per-PC model', async () => {
const wrapper = await render()
expect(wrapper.text()).toContain('Shared key (used until PCs enroll)')
expect(wrapper.text()).not.toContain('Key management')
expect(wrapper.text()).toMatch(/Enrolled PCs ignore it/)
expect(wrapper.text()).toMatch(/At cutover/)
})
it('shows enrollment progress from one small enrollment read', async () => {
const wrapper = await render()
expect(api.get).toHaveBeenCalledWith('/endpoints/enrollment', { params: { perpage: 1 } })
const summary = wrapper.find('.enrollment-summary').text()
expect(summary).toContain('30 of 40 PCs enrolled')
expect(summary).toContain('10 still on the shared key')
// A blocked PC asking again is not a request anyone has to decide.
expect(summary).toContain('2 pending enrollment requests')
const link = wrapper.findComponent(RouterLinkStub)
expect(link.props('to')).toEqual({ name: 'endpoints-enrollment-settings' })
})
it('regenerate confirm names the PCs that need the new key', async () => {
const confirmSpy = vi.fn(() => true)
vi.stubGlobal('confirm', confirmSpy)
const wrapper = await render()
const button = wrapper.findAll('button').find(b => b.text() === 'Regenerate key')
await button.trigger('click')
await flushPromises()
expect(confirmSpy.mock.calls[0][0]).toMatch(/10 PCs not enrolled will need the new key/)
expect(confirmSpy.mock.calls[0][0]).toMatch(/Enrolled PCs are not affected/)
expect(api.post).toHaveBeenCalledWith('/endpoints/agentkey/regenerate')
})
it('revoke confirm names the PCs that lose access', async () => {
const confirmSpy = vi.fn(() => true)
vi.stubGlobal('confirm', confirmSpy)
const wrapper = await render()
const button = wrapper.findAll('button').find(b => b.text() === 'Revoke key')
await button.trigger('click')
await flushPromises()
expect(confirmSpy.mock.calls[0][0]).toMatch(/10 PCs not enrolled will lose access/)
expect(api.delete).toHaveBeenCalledWith('/endpoints/agentkey')
})
it('sends nothing when revoke is declined', async () => {
vi.stubGlobal('confirm', vi.fn(() => false))
const wrapper = await render()
const button = wrapper.findAll('button').find(b => b.text() === 'Revoke key')
await button.trigger('click')
await flushPromises()
expect(api.delete).not.toHaveBeenCalled()
})
it('still manages the key when the enrollment read fails', async () => {
api.get.mockImplementation((path) => {
if (path === '/endpoints/enrollment') return Promise.reject(new Error('403'))
if (path === '/endpoints/agentkey') {
return Promise.resolve({ data: { data: { key: KEY, envkeyfallback: false } } })
}
return Promise.resolve({ data: { data: {} } })
})
vi.stubGlobal('confirm', vi.fn(() => true))
vi.spyOn(console, 'error').mockImplementation(() => {})
const wrapper = mount(AgentKeySettings, { global: { stubs: { RouterLink: RouterLinkStub } } })
await flushPromises()
expect(wrapper.find('.enrollment-summary').exists()).toBe(false)
const button = wrapper.findAll('button').find(b => b.text() === 'Revoke key')
expect(button.exists()).toBe(true)
})
})

View File

@@ -0,0 +1,99 @@
/**
* Fleet list Credential column.
*
* The state is computed by the list route so that filtering by it survives
* pagination; the page must send the filter to the server rather than narrowing
* the rows it already has, and must render the server's value, not derive one.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { ref } from 'vue'
vi.mock('@/api', () => ({
endpointsApi: {
list: vi.fn(),
dashboardSummary: vi.fn(),
dashboardStability: vi.fn(),
enqueueCommand: vi.fn()
},
computersApi: { types: { listAll: vi.fn(() => Promise.resolve([])) } },
locationsApi: { listAll: vi.fn(() => Promise.resolve([])) }
}))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/stores/auth', () => ({
useAuthStore: () => ({ isAdmin: true, hasPermission: () => true })
}))
vi.mock('@/composables/toast', () => ({
useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn() })
}))
vi.mock('@/composables/listQuery', () => ({
useListQuery: () => ({
page: ref(1), search: ref(''), setPage: vi.fn(), setSearch: vi.fn()
})
}))
vi.mock('@/components/PaginationBar.vue', () => ({
default: { name: 'PaginationBar', template: '<div />' }
}))
import { endpointsApi } from '@/api'
import EndpointsList from './EndpointsList.vue'
function row(assetid, hostname, credentialstate) {
return {
assetid, hostname, computerid: assetid, status: 'reporting', machines: [],
health: null, credentialstate
}
}
beforeEach(() => {
vi.clearAllMocks()
endpointsApi.dashboardSummary.mockResolvedValue({ data: { data: null } })
endpointsApi.dashboardStability.mockResolvedValue({ data: { data: [] } })
endpointsApi.list.mockResolvedValue({
data: {
data: [
row(1, 'PC-ENROLLED', 'enrolled'),
row(2, 'PC-PENDING', 'pending'),
row(3, 'PC-BLOCKED', 'reenrollblocked'),
row(4, 'PC-SHARED', 'sharedkey'),
row(5, 'PC-SILENT', 'notreporting')
],
meta: { pagination: { totalpages: 1 } }
}
})
})
describe('EndpointsList credential column', () => {
it('renders a chip per server state', async () => {
const wrapper = mount(EndpointsList)
await flushPromises()
const chips = wrapper.findAll('.credential-chip').map(chip => chip.text())
expect(chips).toEqual([
'Enrolled', 'Pending', 'Re-enroll blocked', 'Shared key (not enrolled)',
'Not reporting'
])
expect(wrapper.find('.credential-chip.reenrollblocked').exists()).toBe(true)
expect(wrapper.findAll('th').map(th => th.text())).toContain('Credential')
})
it('sends the credential filter to the server', async () => {
const wrapper = mount(EndpointsList)
await flushPromises()
const select = wrapper.findAll('select.fleet-filter')
.find(element => element.text().includes('All credentials'))
await select.setValue('sharedkey')
await flushPromises()
const params = endpointsApi.list.mock.calls.at(-1)[0]
expect(params.credentialstate).toBe('sharedkey')
expect(params.perpage).toBe(20)
})
it('offers no credential actions on the list', async () => {
const wrapper = mount(EndpointsList)
await flushPromises()
expect(wrapper.text()).not.toMatch(/Revoke|Allow re-enrollment/)
})
})

View File

@@ -0,0 +1,98 @@
/**
* Server Certificate live check.
*
* A PC pins the certificate the site presents, which a proxy or IIS binding can
* make different from the file this page manages. The page must show the
* presented pin, warn plainly when it differs, and let the admin recheck.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
vi.mock('@/api', () => ({
default: { get: vi.fn(), post: vi.fn() }
}))
import api from '@/api'
import ServerCertSettings from './ServerCertSettings.vue'
const FILEPIN = 'a'.repeat(64)
const OTHERPIN = 'b'.repeat(64)
function certificate() {
return {
data: {
status: 'success',
data: {
subject_cn: 'shopdb.example.test', issuer: 'shopdb.example.test',
not_before: null, not_after: null, thumbprint: 'AA:BB',
spkisha256: FILEPIN, sha1thumbprint: 'CC', san_list: [],
expires_in_days: 300, is_self_signed: true
}
}
}
}
async function render(live) {
api.get.mockImplementation((path) => Promise.resolve(
path === '/endpoints/settings/server-cert/live'
? { data: { status: 'success', data: live } }
: certificate()))
const wrapper = mount(ServerCertSettings)
await flushPromises()
return wrapper
}
describe('ServerCertSettings live check', () => {
beforeEach(() => {
api.get.mockReset()
})
it('shows the presented pin in green when it matches the file', async () => {
const wrapper = await render({
configured: true, url: 'https://shopdb.example.test', presentedspkisha256: FILEPIN,
presentedsha1thumbprint: 'CC', matchesfile: true, error: null
})
const check = wrapper.find('.live-check')
expect(check.find('.live-match').text()).toContain(FILEPIN)
expect(check.text()).toContain('Matches the pin of the certificate file above')
expect(check.find('.live-warning').exists()).toBe(false)
})
it('warns in red with both pins when the site presents another certificate', async () => {
const wrapper = await render({
configured: true, url: 'https://shopdb.example.test', presentedspkisha256: OTHERPIN,
presentedsha1thumbprint: 'DD', matchesfile: false, error: null
})
const warning = wrapper.find('.live-warning')
expect(warning.exists()).toBe(true)
expect(warning.text()).toContain(
'PCs will be pinned to the certificate they actually receive; use the presented pin')
expect(warning.text()).toContain(FILEPIN)
expect(warning.text()).toContain(OTHERPIN)
expect(wrapper.find('.live-mismatch').exists()).toBe(true)
})
it('says when no site address is configured', async () => {
const wrapper = await render({ configured: false })
expect(wrapper.find('.live-unconfigured').exists()).toBe(true)
})
it('shows the check error', async () => {
const wrapper = await render({
configured: true, url: 'https://shopdb.example.test', presentedspkisha256: null,
presentedsha1thumbprint: null, matchesfile: false, error: 'Could not read the certificate'
})
expect(wrapper.find('.live-error').text()).toContain('Could not read the certificate')
})
it('rechecks on demand', async () => {
const wrapper = await render({ configured: false })
const before = api.get.mock.calls.filter(
([path]) => path === '/endpoints/settings/server-cert/live').length
await wrapper.find('.live-recheck').trigger('click')
await flushPromises()
const after = api.get.mock.calls.filter(
([path]) => path === '/endpoints/settings/server-cert/live').length
expect(after).toBe(before + 1)
})
})

View File

@@ -341,8 +341,9 @@ class EndpointsPlugin(BasePlugin):
'to': '/settings/endpoints/agentkey',
'icon': 'key',
'title': 'Endpoints',
'description': 'Manage the endpoint agent key, build the site '
'agent, and configure what it reports.',
'description': 'The shared agent key PCs use until they '
'enroll, the site agent installer builder, and '
'what the agent reports.',
'position': 27,
},
# Its own card rather than a section of the agent key page: the
@@ -365,15 +366,15 @@ class EndpointsPlugin(BasePlugin):
'to': '/settings/endpoints/server-cert',
'icon': 'shield-check',
'title': 'Server Certificate',
'description': 'Manage the TLS certificate nginx uses for HTTPS. '
'Agents validate this certificate via pinning or '
'Windows trusted roots.',
'description': 'The certificate PCs see when they connect, on '
'IIS or Docker. Source of the server pin and the '
'trusted root entry for agents.',
'position': 29,
},
{
'group': 'Endpoints',
'to': '/settings/endpoints/enrollment',
'icon': 'key',
'icon': 'badge-check',
'title': 'Enrollment',
'description': 'Move agents to per-PC credentials, approve '
'enrollment requests, and retire the shared key.',
@@ -828,6 +829,46 @@ class EndpointsPlugin(BasePlugin):
'link': '/pcs/{computerid}',
},
},
# One row, not one per PC: the approval itself happens on the
# Enrollment page, where the asset picker is.
{
'id': 'endpoints-enrollment-pending',
'viewall': '/settings/endpoints/enrollment',
'title': 'Enrollment requests waiting for approval',
'shortname': 'Enrollment',
'endpoint': '/api/endpoints/dashboard/enrollmentpending',
'render': 'exceptions',
'severity': 'warning',
'permission': 'endpoints.fleet',
'empty': 'hide',
'position': 24,
'map': {
'title': 'summary',
'detail': '',
'link': '/settings/endpoints/enrollment',
},
},
# Info, not the queue: a block is deliberate (a rollback), and a
# week is only the point where it may have been forgotten.
{
'id': 'endpoints-reenroll-blocked',
'viewall': '/settings/endpoints/enrollment',
'title': 'PCs blocked from re-enrolling over 7 days',
'shortname': 'Re-enroll blocked',
'endpoint': '/api/endpoints/dashboard/reenrollblocked',
'render': 'exceptions',
'severity': 'info',
'permission': 'endpoints.fleet',
'empty': 'hide',
'position': 60,
'map': {
'title': 'hostname',
'detail': 'reason',
'meta': [{'key': 'blockeddays', 'label': 'blocked',
'suffix': ' days'}],
'link': '/pcs/{computerid}',
},
},
]
# ---- ADR-006 collector contract -------------------------------------

View File

@@ -73,6 +73,8 @@ def test_a_viewall_target_is_one_of_the_known_pages(app):
'/pcs', '/machines', '/printers', '/network', '/notifications',
'/geenforce', '/reports/toner', '/reports/warranty',
'/reports/pc-relationships', '/reports/calibration', '/reports',
# plugins/endpoints/frontend/routes.js, readable by endpoints.fleet
'/settings/endpoints/enrollment',
}
with app.app_context():
for pluginname, card in _declared(app):

View File

@@ -0,0 +1,525 @@
"""Admin surfaces for per-PC endpoint credentials.
The fleet list, the PC detail status and the dashboard all describe the same
fact - which credential a PC reports with - so they share one state vocabulary:
enrolled, reenrollblocked, pending, notreporting, sharedkey. The list computes
it in SQL so a filter survives pagination; PC detail redacts everything but the
state for a reader below endpoints.fleet; the server certificate live check
reads the host from settings, never from the request.
"""
import hashlib
import os
from datetime import datetime, timedelta, timezone
import pytest
from werkzeug.security import generate_password_hash
from shopdb.core.models import Asset, AssetType, Setting
def _now():
return datetime.now(timezone.utc).replace(tzinfo=None)
def _pc(db, hostname, reported=True, serialnumber='SER'):
from plugins.computers.models import Computer
from plugins.endpoints.models import EndpointState
atype = AssetType.query.filter_by(assettype='computer').first()
if atype is None:
atype = AssetType(assettype='computer', pluginname='computers',
tablename='computers')
db.session.add(atype)
db.session.flush()
asset = Asset(assetnumber=hostname, name=hostname, serialnumber=serialnumber,
assettypeid=atype.assettypeid, isactive=True)
db.session.add(asset)
db.session.flush()
computer = Computer(assetid=asset.assetid, hostname=hostname)
db.session.add(computer)
db.session.add(EndpointState(
assetid=asset.assetid, hostname=hostname, agentversion='1.20.0',
lastreportedat=_now() if reported else None))
db.session.commit()
return asset.assetid
def _credential(db, assetid, revoked=False, blocked=False, reason=None,
revokedat=None, prefix='sdbe_abcdefg'):
from plugins.endpoints.models import EndpointCredential
row = EndpointCredential(
assetid=assetid, credentialhash=hashlib.sha256(
str(assetid).encode()).hexdigest(),
credentialprefix=prefix, enrolledvia='auto', issuedat=_now(),
lastusedat=_now(),
revokedat=(revokedat or _now()) if revoked else None,
revokedby='admin' if revoked else None,
reenrollblocked=blocked, reenrollblockedreason=reason)
db.session.add(row)
db.session.commit()
return row
def _request(db, hostname, matchedassetid=None, reason='awaiting approval',
status='pending'):
from plugins.endpoints.models import EndpointEnrollRequest
row = EndpointEnrollRequest(
hostname=hostname, serialnumber='SER', enrollnonce='n' * 40,
status=status, reason=reason, matchedassetid=matchedassetid,
requestedat=_now(), lastseenat=_now())
db.session.add(row)
db.session.commit()
return row
def _login(db, client, username, permissions=()):
from shopdb.core.models import User, Role, Permission
Permission.seed()
db.session.flush()
role = Role(rolename=username + 'role')
db.session.add(role)
db.session.flush()
for name in permissions:
perm = Permission.query.filter_by(name=name).first()
assert perm is not None, name
role.permissions.append(perm)
user = User(username=username, email=username + '@test.local',
passwordhash=generate_password_hash('testpass'))
user.roles.append(role)
db.session.add(user)
db.session.commit()
response = client.post('/api/auth/login',
json={'username': username, 'password': 'testpass'})
return {'Authorization': 'Bearer ' + response.get_json()['data']['access_token']}
@pytest.fixture
def fleet(db):
"""One PC in every credential state."""
ids = {
'ENROLLED': _pc(db, 'ENROLLED'),
'BLOCKED': _pc(db, 'BLOCKED'),
'PENDING': _pc(db, 'PENDING'),
'SILENT': _pc(db, 'SILENT', reported=False),
'SHARED': _pc(db, 'SHARED'),
'REVOKED': _pc(db, 'REVOKED'),
}
_credential(db, ids['ENROLLED'])
_credential(db, ids['BLOCKED'], revoked=True, blocked=True, reason='rollback')
# A blocked PC keeps asking; its request must not turn it into pending.
_request(db, 'BLOCKED', ids['BLOCKED'], reason='reenroll blocked')
_request(db, 'pending')
_credential(db, ids['REVOKED'], revoked=True)
return ids
EXPECTED = {
'ENROLLED': 'enrolled',
'BLOCKED': 'reenrollblocked',
'PENDING': 'pending',
'SILENT': 'notreporting',
'SHARED': 'sharedkey',
'REVOKED': 'sharedkey',
}
# -- fleet list ---------------------------------------------------------------
def test_fleet_list_carries_the_credential_state(client, db, auth_headers, fleet):
response = client.get('/api/endpoints?perpage=100', headers=auth_headers)
assert response.status_code == 200, response.get_data(as_text=True)
states = {row['hostname']: row['credentialstate']
for row in response.get_json()['data']}
assert states == EXPECTED
@pytest.mark.parametrize('state', sorted(set(EXPECTED.values())))
def test_fleet_list_filters_by_credential_state(client, db, auth_headers, fleet,
state):
response = client.get(f'/api/endpoints?credentialstate={state}',
headers=auth_headers)
assert response.status_code == 200
body = response.get_json()
hosts = sorted(row['hostname'] for row in body['data'])
assert hosts == sorted(h for h, s in EXPECTED.items() if s == state)
assert body['meta']['pagination']['total'] == len(hosts)
def test_credential_filter_keeps_paging(client, db, auth_headers, fleet):
response = client.get('/api/endpoints?credentialstate=sharedkey&perpage=1'
'&sort=hostname', headers=auth_headers)
body = response.get_json()
assert [row['hostname'] for row in body['data']] == ['REVOKED']
assert body['meta']['pagination']['total'] == 2
def test_fleet_list_state_costs_no_query_per_row(client, db, app, auth_headers,
fleet):
from sqlalchemy import event
statements = []
def record(_connection, _cursor, statement, *_args):
if 'endpointcredentials' in statement or 'endpointenrollrequests' in statement:
statements.append(statement)
engine = db.engine
event.listen(engine, 'before_cursor_execute', record)
try:
client.get('/api/endpoints?perpage=100', headers=auth_headers)
small = len(statements)
for index in range(10):
_pc(db, f'EXTRA-{index:02d}')
statements.clear()
client.get('/api/endpoints?perpage=100', headers=auth_headers)
large = len(statements)
finally:
event.remove(engine, 'before_cursor_execute', record)
assert small == large
assert large <= 2
def test_unknown_credential_filter_is_ignored_like_status(client, db,
auth_headers, fleet):
response = client.get('/api/endpoints?credentialstate=bogus',
headers=auth_headers)
assert response.status_code == 200
assert len(response.get_json()['data']) == len(EXPECTED)
# -- PC detail ----------------------------------------------------------------
def test_endpoint_detail_credential_for_fleet_holders(client, db, auth_headers,
fleet):
data = client.get(f"/api/endpoints/{fleet['BLOCKED']}",
headers=auth_headers).get_json()['data']
credential = data['credential']
assert credential['state'] == 'reenrollblocked'
assert credential['statelabel'] == 'Re-enroll blocked'
assert credential['credentialprefix'] == 'sdbe_abcdefg'
assert credential['issuedat'] and credential['lastusedat']
assert credential['enrolledvia'] == 'auto'
assert credential['reenrollblocked'] is True
assert credential['reenrollblockedreason'] == 'rollback'
@pytest.mark.parametrize('hostname', sorted(EXPECTED))
def test_endpoint_detail_state_matches_the_list(client, db, auth_headers, fleet,
hostname):
data = client.get(f'/api/endpoints/{fleet[hostname]}',
headers=auth_headers).get_json()['data']
assert data['credential']['state'] == EXPECTED[hostname]
def test_endpoint_detail_redacts_below_fleet(client, db, fleet):
headers = _login(db, client, 'viewer', ['endpoints.view'])
response = client.get(f"/api/endpoints/{fleet['ENROLLED']}", headers=headers)
assert response.status_code == 200
credential = response.get_json()['data']['credential']
assert credential['state'] == 'enrolled'
for key in ('credentialprefix', 'issuedat', 'lastusedat', 'revokedat',
'enrolledvia', 'reenrollblockedreason'):
assert credential[key] is None, key
panel = client.get(f"/api/endpoints/asset/{fleet['ENROLLED']}/status",
headers=headers).get_json()['data']
labels = {field['label']: field['value'] for field in panel['fields']}
assert labels['Credential'] == 'Enrolled'
assert 'Credential prefix' not in labels
assert 'Credential issued' not in labels
assert 'Credential last used' not in labels
def test_guest_with_endpoints_view_sees_state_only(client, db, fleet):
from shopdb.core.models import Permission, Role
Permission.seed()
Role.seedguest()
db.session.commit()
guest = Role.guest()
guest.permissions = Permission.query.filter_by(name='endpoints.view').all()
db.session.commit()
response = client.get(f"/api/endpoints/asset/{fleet['BLOCKED']}/status")
assert response.status_code == 200, response.get_data(as_text=True)
data = response.get_json()['data']
labels = {field['label']: field['value'] for field in data['fields']}
assert labels['Credential'] == 'Re-enroll blocked'
assert 'Re-enroll block reason' not in labels
assert data['credential']['credentialprefix'] is None
assert data['credential']['reenrollblockedreason'] is None
def test_status_panel_shows_credential_fields_to_fleet_holders(client, db,
auth_headers,
fleet):
panel = client.get(f"/api/endpoints/asset/{fleet['BLOCKED']}/status",
headers=auth_headers).get_json()['data']
labels = {field['label']: field for field in panel['fields']}
assert labels['Credential']['value'] == 'Re-enroll blocked'
assert labels['Credential prefix']['value'] == 'sdbe_abcdefg...'
assert labels['Credential prefix']['mono'] is True
assert labels['Credential issued']['format'] == 'datetime'
assert labels['Credential last used']['format'] == 'datetime'
assert labels['Enrolled via']['value'] == 'auto'
assert labels['Re-enroll block reason']['value'] == 'rollback'
# -- server certificate live check -------------------------------------------
def _certificate_pem_and_der():
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import NameOID
key = ec.generate_private_key(ec.SECP256R1())
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'live.example.test')])
now = datetime.now(timezone.utc)
certificate = (x509.CertificateBuilder()
.subject_name(name).issuer_name(name)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - timedelta(days=1))
.not_valid_after(now + timedelta(days=30))
.sign(key, hashes.SHA256()))
spki = key.public_key().public_bytes(
serialization.Encoding.DER,
serialization.PublicFormat.SubjectPublicKeyInfo)
return (certificate.public_bytes(serialization.Encoding.PEM),
certificate.public_bytes(serialization.Encoding.DER),
hashlib.sha256(spki).hexdigest(),
certificate.fingerprint(hashes.SHA1()).hex().upper())
class _FakeSocket:
def __init__(self, der=None):
self.der = der
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def getpeercert(self, binary_form=False):
assert binary_form is True
return self.der
@pytest.fixture
def tls(monkeypatch, tmp_path):
"""Replace the socket and ssl layer; record what the route connected to."""
import socket
import ssl
from plugins.endpoints.api import routes as endpoint_routes
certdir = tmp_path / 'certs'
certdir.mkdir()
monkeypatch.setattr(endpoint_routes, '_server_cert_dir', lambda: certdir)
state = {'der': None, 'connected': [], 'wrapped': [], 'error': None}
def create_connection(address, timeout=None):
state['connected'].append((address, timeout))
if state['error'] is not None:
raise state['error']
return _FakeSocket()
def wrap_socket(self, sock, server_hostname=None, **_kwargs):
state['wrapped'].append((server_hostname, self.verify_mode,
self.check_hostname))
return _FakeSocket(state['der'])
monkeypatch.setattr(socket, 'create_connection', create_connection)
monkeypatch.setattr(ssl.SSLContext, 'wrap_socket', wrap_socket)
state['certdir'] = certdir
return state
LIVE_URL = '/api/endpoints/settings/server-cert/live'
def test_live_check_unconfigured(client, db, auth_headers, tls):
response = client.get(LIVE_URL, headers=auth_headers)
assert response.status_code == 200
assert response.get_json()['data'] == {'configured': False}
assert tls['connected'] == []
def test_live_check_matches_the_file(client, db, auth_headers, tls):
import ssl
pem, der, pin, sha1 = _certificate_pem_and_der()
(tls['certdir'] / 'server.crt').write_bytes(pem)
tls['der'] = der
Setting.set('site_base_url', 'https://shopdb.example.test:8443/shopdb')
db.session.commit()
response = client.get(LIVE_URL, headers={**auth_headers,
'Host': 'attacker.example'})
assert response.status_code == 200, response.get_data(as_text=True)
data = response.get_json()['data']
assert data == {
'configured': True,
'url': 'https://shopdb.example.test:8443/shopdb',
'presentedspkisha256': pin,
'presentedsha1thumbprint': sha1,
'matchesfile': True,
'error': None,
}
# Host from the setting, never the request; 5 s; read-only handshake.
assert tls['connected'] == [(('shopdb.example.test', 8443), 5)]
assert tls['wrapped'] == [('shopdb.example.test', ssl.CERT_NONE, False)]
def test_live_check_reports_a_different_certificate(client, db, auth_headers,
tls):
filepem, _der, filepin, _sha1 = _certificate_pem_and_der()
_pem, liveder, livepin, _livesha1 = _certificate_pem_and_der()
(tls['certdir'] / 'server.crt').write_bytes(filepem)
tls['der'] = liveder
Setting.set('site_base_url', 'https://shopdb.example.test')
db.session.commit()
data = client.get(LIVE_URL, headers=auth_headers).get_json()['data']
assert data['presentedspkisha256'] == livepin != filepin
assert data['matchesfile'] is False
assert data['error'] is None
assert tls['connected'][0][0] == ('shopdb.example.test', 443)
def test_live_check_no_file_never_matches(client, db, auth_headers, tls):
_pem, der, pin, _sha1 = _certificate_pem_and_der()
tls['der'] = der
Setting.set('site_base_url', 'https://10.0.0.5')
db.session.commit()
data = client.get(LIVE_URL, headers=auth_headers).get_json()['data']
assert data['presentedspkisha256'] == pin
assert data['matchesfile'] is False
# No SNI for an address.
assert tls['wrapped'][0][0] is None
def test_live_check_connection_failure(client, db, auth_headers, tls):
tls['error'] = OSError('Connection refused')
Setting.set('site_base_url', 'https://shopdb.example.test')
db.session.commit()
response = client.get(LIVE_URL, headers=auth_headers)
assert response.status_code == 200
data = response.get_json()['data']
assert data['configured'] is True
assert data['presentedspkisha256'] is None
assert data['matchesfile'] is False
assert 'Connection refused' in data['error']
def test_live_check_refuses_plain_http(client, db, auth_headers, tls):
Setting.set('site_base_url', 'http://shopdb.example.test')
db.session.commit()
data = client.get(LIVE_URL, headers=auth_headers).get_json()['data']
assert data['configured'] is True
assert 'https' in data['error']
assert tls['connected'] == []
def test_live_check_allows_fleet_holders(client, db, tls):
headers = _login(db, client, 'fleetreader', ['endpoints.fleet'])
assert client.get(LIVE_URL, headers=headers).status_code == 200
# -- dashboard ----------------------------------------------------------------
def test_pending_enrollment_attention_row(client, db, auth_headers, fleet):
response = client.get('/api/endpoints/dashboard/enrollmentpending',
headers=auth_headers)
assert response.status_code == 200
# The blocked PC's own request is expected noise, not work.
assert response.get_json()['data'] == [
{'summary': '1 PC waiting for enrollment approval', 'pendingcount': 1}]
_request(db, 'ANOTHER')
data = client.get('/api/endpoints/dashboard/enrollmentpending',
headers=auth_headers).get_json()['data']
assert data[0]['summary'] == '2 PCs waiting for enrollment approval'
def test_pending_enrollment_attention_hides_when_nothing_waits(client, db,
auth_headers):
response = client.get('/api/endpoints/dashboard/enrollmentpending',
headers=auth_headers)
assert response.get_json()['data'] == []
def test_reenroll_blocked_attention_after_seven_days(client, db, auth_headers):
from plugins.computers.models import Computer
fresh = _pc(db, 'FRESH')
stale = _pc(db, 'STALE')
_credential(db, fresh, revoked=True, blocked=True,
revokedat=_now() - timedelta(days=2))
_credential(db, stale, revoked=True, blocked=True, reason='rollback',
revokedat=_now() - timedelta(days=9))
data = client.get('/api/endpoints/dashboard/reenrollblocked',
headers=auth_headers).get_json()['data']
computerid = Computer.query.filter_by(assetid=stale).one().computerid
assert data == [{'computerid': computerid, 'assetid': stale,
'hostname': 'STALE', 'blockeddays': 9,
'reason': 'rollback'}]
@pytest.mark.parametrize('url', [
'/api/endpoints/dashboard/enrollmentpending',
'/api/endpoints/dashboard/reenrollblocked',
LIVE_URL,
])
def test_new_reads_need_a_permission(client, db, member_headers, url):
assert client.get(url, headers=member_headers).status_code == 403
assert client.get(url).status_code == 401
def test_dashboard_widgets_declare_the_enrollment_items():
from plugins.endpoints.plugin import EndpointsPlugin
widgets = {w['id']: w for w in EndpointsPlugin().get_dashboard_widgets()}
pending = widgets['endpoints-enrollment-pending']
assert pending['severity'] == 'warning'
assert pending['endpoint'] == '/api/endpoints/dashboard/enrollmentpending'
assert pending['map']['link'] == '/settings/endpoints/enrollment'
blocked = widgets['endpoints-reenroll-blocked']
assert blocked['severity'] == 'info'
assert blocked['map']['link'] == '/pcs/{computerid}'
for widget in (pending, blocked):
assert widget['permission'] == 'endpoints.fleet'
assert widget['empty'] == 'hide'
# -- settings cards -----------------------------------------------------------
def test_settings_cards_describe_the_new_model_and_their_icons_render():
from plugins.endpoints.plugin import EndpointsPlugin
cards = {card['to']: card for card in EndpointsPlugin().get_settings_cards()}
cert = cards['/settings/endpoints/server-cert']
assert 'nginx' not in cert['description'].lower()
assert 'IIS' in cert['description'] and 'Docker' in cert['description']
assert 'shared' in cards['/settings/endpoints/agentkey']['description']
enrollment = cards['/settings/endpoints/enrollment']
assert enrollment['icon'] != cards['/settings/endpoints/agentkey']['icon']
catalog = os.path.join(os.path.dirname(__file__), '..', '..', 'frontend',
'src', 'composables', 'settingsCatalog.js')
with open(os.path.normpath(catalog), encoding='utf-8') as handle:
source = handle.read()
for card in cards.values():
icon = card['icon']
assert f"'{icon}':" in source or f" {icon}:" in source, icon