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:
@@ -240,6 +240,19 @@ export const endpointsApi = {
|
|||||||
}
|
}
|
||||||
return api.post(`/endpoints/${assetId}/agentupgrade`, body)
|
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) {
|
listCommands(assetId) {
|
||||||
return api.get(`/endpoints/${assetId}/commands`)
|
return api.get(`/endpoints/${assetId}/commands`)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// (landing) read the merged catalog so plugins add settings cards with no core
|
// (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.
|
// edit. Fetched once into a module-level ref and shared across callers.
|
||||||
import { ref, computed } from 'vue'
|
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 api from '../api'
|
||||||
import router from '../router'
|
import router from '../router'
|
||||||
import { settingsGroups } from '../views/settings/settingsNav'
|
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
|
// The endpoints agent-key card has asked for 'key' since it shipped and has
|
||||||
// been silently drawing a puzzle piece the whole time.
|
// been silently drawing a puzzle piece the whole time.
|
||||||
key: KeyRound,
|
key: KeyRound,
|
||||||
|
// Server Certificate asked for 'shield-check' and drew the puzzle fallback.
|
||||||
|
'shield-check': ShieldCheck,
|
||||||
|
'badge-check': BadgeCheck,
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveIcon(key) {
|
function resolveIcon(key) {
|
||||||
|
|||||||
@@ -83,6 +83,25 @@
|
|||||||
<span class="triage-dot"></span>{{ liveness.label }}
|
<span class="triage-dot"></span>{{ liveness.label }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<div v-if="canCommand" class="hero-endpoint-actions">
|
||||||
<button @click="refreshStatus" class="btn btn-secondary btn-sm"
|
<button @click="refreshStatus" class="btn btn-secondary btn-sm"
|
||||||
:disabled="restarting || refreshing">
|
:disabled="restarting || refreshing">
|
||||||
@@ -1007,6 +1026,39 @@
|
|||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</Modal>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -1061,6 +1113,9 @@ const agentversion = ref(null)
|
|||||||
// floor on the server cannot leave this page disagreeing with the API.
|
// floor on the server cannot leave this page disagreeing with the API.
|
||||||
const agentcurrent = ref(null)
|
const agentcurrent = ref(null)
|
||||||
const actionminimums = ref({})
|
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 installedApps = ref([])
|
||||||
const assetPanels = ref(null)
|
const assetPanels = ref(null)
|
||||||
@@ -1502,6 +1557,7 @@ async function loadLiveness(assetid) {
|
|||||||
agentversion.value = state.agentversion || null
|
agentversion.value = state.agentversion || null
|
||||||
agentcurrent.value = state.agentcurrent || null
|
agentcurrent.value = state.agentcurrent || null
|
||||||
actionminimums.value = state.actionminimums || {}
|
actionminimums.value = state.actionminimums || {}
|
||||||
|
credential.value = state.credential || null
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 404 = no agent on this PC, 403 = the viewer cannot read telemetry.
|
// 404 = no agent on this PC, 403 = the viewer cannot read telemetry.
|
||||||
// Neither is a page error; the chip just does not appear.
|
// Neither is a page error; the chip just does not appear.
|
||||||
@@ -1509,6 +1565,62 @@ async function loadLiveness(assetid) {
|
|||||||
agentversion.value = null
|
agentversion.value = null
|
||||||
agentcurrent.value = null
|
agentcurrent.value = null
|
||||||
actionminimums.value = {}
|
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;
|
justify-content: flex-end;
|
||||||
gap: 0.5rem;
|
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
|
/* 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. */
|
fault. It should read as "someone did this on purpose" at a glance. */
|
||||||
.policy-holds {
|
.policy-holds {
|
||||||
@@ -3052,7 +3180,8 @@ async function confirmRetire() {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
.hero-status { align-items: flex-start; }
|
.hero-status,
|
||||||
|
.hero-credential { align-items: flex-start; }
|
||||||
.hero-endpoint-actions { justify-content: flex-start; }
|
.hero-endpoint-actions { justify-content: flex-start; }
|
||||||
}
|
}
|
||||||
.copy-btn:hover {
|
.copy-btn:hover {
|
||||||
|
|||||||
58
plugins/computers/frontend/views/pcDetailCredential.spec.js
Normal file
58
plugins/computers/frontend/views/pcDetailCredential.spec.js
Normal 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\(/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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}`
|
- `PUT /api/endpoints/enrollment/settings` `{"sharedkeyallowed": bool, "autoenroll": bool}`
|
||||||
- `GET /api/endpoints/enrollment/migration` (`page`, `perpage` <= 100, `state`, `search`)
|
- `GET /api/endpoints/enrollment/migration` (`page`, `perpage` <= 100, `state`, `search`)
|
||||||
- `POST /api/endpoints/enrollment/migration/wave` `{"assetids": [...]}` or `{"next": n}` (n <= 100)
|
- `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
|
- Migration blockers include `server certificate not trusted by this PC`: the
|
||||||
PC's last upgrade download failed on the certificate chain (the server only
|
PC's last upgrade download failed on the certificate chain (the server only
|
||||||
knows from the failure the PC reported). Trust the certificate
|
knows from the failure the PC reported). Trust the certificate
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ Admin (endpoints.fleet to read, admin role to change):
|
|||||||
GET /enrollment/migration readiness of every computer for agent 1.20
|
GET /enrollment/migration readiness of every computer for agent 1.20
|
||||||
POST /enrollment/migration/wave push the agent upgrade to a batch of PCs
|
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
|
A PC moves onto its own credential by running an agent that enrolls. The
|
||||||
migration surface computes, per computer, whether the existing agent upgrade
|
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
|
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)
|
_OFFLINE_AFTER = timedelta(hours=24)
|
||||||
_WAVE_MAXIMUM = 100
|
_WAVE_MAXIMUM = 100
|
||||||
_RECENT_REQUESTS = 50
|
_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():
|
def _naive_utcnow():
|
||||||
@@ -329,6 +352,97 @@ def _is_pending(assetid, hostname, index):
|
|||||||
return assetid in assetids or (hostname or '').lower() in hostnames
|
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):
|
def _search_filter(rows, search, *keys):
|
||||||
if not search:
|
if not search:
|
||||||
return rows
|
return rows
|
||||||
@@ -917,3 +1031,59 @@ def create_migration_wave():
|
|||||||
{'waveid': waveid, 'queued': queued, 'skipped': skipped},
|
{'waveid': waveid, 'queued': queued, 'skipped': skipped},
|
||||||
message='{} PC(s) queued'.format(len(queued)),
|
message='{} PC(s) queued'.format(len(queued)),
|
||||||
http_code=201 if queued else 200)
|
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.
|
operator reads first.
|
||||||
"""
|
"""
|
||||||
from plugins.computers.models import Computer, ComputerType
|
from plugins.computers.models import Computer, ComputerType
|
||||||
|
from .enrollment import CREDENTIAL_STATES, credential_state_expression
|
||||||
|
|
||||||
page, per_page = get_pagination_params(request)
|
page, per_page = get_pagination_params(request)
|
||||||
cutoff = _naive_utcnow() - timedelta(hours=_quiet_hours())
|
cutoff = _naive_utcnow() - timedelta(hours=_quiet_hours())
|
||||||
@@ -245,10 +246,16 @@ def list_endpoints():
|
|||||||
else_=2,
|
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,
|
query = (db.session.query(EndpointState, Computer.computerid,
|
||||||
Computer.computertypeid, ComputerType.computertype,
|
Computer.computertypeid, ComputerType.computertype,
|
||||||
Asset.assetnumber, Asset.name,
|
Asset.assetnumber, Asset.name,
|
||||||
statusrank.label('statusrank'))
|
statusrank.label('statusrank'),
|
||||||
|
credentialstate.label('credentialstate'))
|
||||||
.join(Asset, Asset.assetid == EndpointState.assetid)
|
.join(Asset, Asset.assetid == EndpointState.assetid)
|
||||||
.join(Computer, Computer.assetid == EndpointState.assetid)
|
.join(Computer, Computer.assetid == EndpointState.assetid)
|
||||||
.outerjoin(ComputerType,
|
.outerjoin(ComputerType,
|
||||||
@@ -271,6 +278,10 @@ def list_endpoints():
|
|||||||
if wanted is not None:
|
if wanted is not None:
|
||||||
query = query.filter(statusrank == wanted)
|
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'):
|
if computertypeid := request.args.get('computertypeid'):
|
||||||
query = query.filter(Computer.computertypeid == computertypeid)
|
query = query.filter(Computer.computertypeid == computertypeid)
|
||||||
if locationid := request.args.get('locationid'):
|
if locationid := request.args.get('locationid'):
|
||||||
@@ -300,7 +311,7 @@ def list_endpoints():
|
|||||||
|
|
||||||
data = []
|
data = []
|
||||||
for (state, computerid, computertypeid, computertype, assetnumber, alias,
|
for (state, computerid, computertypeid, computertype, assetnumber, alias,
|
||||||
rank) in items:
|
rank, credential) in items:
|
||||||
row = state.to_dict()
|
row = state.to_dict()
|
||||||
row['computerid'] = computerid
|
row['computerid'] = computerid
|
||||||
row['assetnumber'] = assetnumber
|
row['assetnumber'] = assetnumber
|
||||||
@@ -313,6 +324,7 @@ def list_endpoints():
|
|||||||
row['status'] = ('never', 'quiet', 'reporting')[rank]
|
row['status'] = ('never', 'quiet', 'reporting')[rank]
|
||||||
row['machines'] = machines.get(state.assetid, [])
|
row['machines'] = machines.get(state.assetid, [])
|
||||||
row['health'] = health.get(state.assetid)
|
row['health'] = health.get(state.assetid)
|
||||||
|
row['credentialstate'] = credential
|
||||||
data.append(row)
|
data.append(row)
|
||||||
|
|
||||||
return paginated_response(data, page, per_page, total)
|
return paginated_response(data, page, per_page, total)
|
||||||
@@ -431,6 +443,21 @@ def _health_for_assets(states, computertypes):
|
|||||||
return out
|
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'])
|
@endpoints_bp.route('/<int:asset_id>', methods=['GET'])
|
||||||
@require_permission('endpoints.view')
|
@require_permission('endpoints.view')
|
||||||
def get_endpoint(asset_id: int):
|
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
|
# the enqueue guard refuses against these same numbers, so a copy in the
|
||||||
# frontend would silently disagree the moment a floor was raised here.
|
# frontend would silently disagree the moment a floor was raised here.
|
||||||
data['actionminimums'] = action_minimum_versions()
|
data['actionminimums'] = action_minimum_versions()
|
||||||
|
data['credential'] = _credential_for_state(state)
|
||||||
|
|
||||||
# Localize timestamps to site timezone
|
# Localize timestamps to site timezone
|
||||||
for ts_field in ['lastreportedat', 'lasteventat', 'created_at', 'updated_at']:
|
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,
|
state.lastreportedat.isoformat() + 'Z' if state.lastreportedat else None,
|
||||||
fmt='datetime'),
|
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, '')]
|
fields = [f for f in fields if f['value'] not in (None, '')]
|
||||||
|
|
||||||
# Informational only, and appended after the null-filter above because it
|
# Informational only, and appended after the null-filter above because it
|
||||||
@@ -654,6 +693,7 @@ def panel_status(asset_id: int):
|
|||||||
'agentbehind': bool(agentbehind),
|
'agentbehind': bool(agentbehind),
|
||||||
'agentversion': state.agentversion,
|
'agentversion': state.agentversion,
|
||||||
'agentcurrent': published,
|
'agentcurrent': published,
|
||||||
|
'credential': credential,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -4370,6 +4410,97 @@ def download_server_certificate():
|
|||||||
return response
|
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(
|
_CERT_HOSTNAME_PATTERN = re.compile(
|
||||||
r'^(\*\.)?(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?'
|
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])?)*$')
|
r'(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$')
|
||||||
|
|||||||
@@ -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
|
turns it off. An enrolled PC is always refused the shared key
|
||||||
(`401 SHARED_KEY_REFUSED`).
|
(`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
|
## Before you start
|
||||||
|
|
||||||
1. Server deployed with the endpoints migration `endpoints0023credentials`
|
1. Server deployed with the endpoints migration `endpoints0023credentials`
|
||||||
|
|||||||
@@ -5,15 +5,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="setting-description">
|
<p class="setting-description">
|
||||||
Manage how shop-floor endpoint agents authenticate to this server, build
|
Manage the shared key PCs use until they enroll, build the agent for this
|
||||||
the agent for this site, and set what it reports and how often.
|
site, and set what it reports and how often. Each enrolled PC
|
||||||
|
authenticates with its own credential instead; see Settings > Endpoints
|
||||||
|
> Enrollment.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- ====================================================================
|
<!-- ====================================================================
|
||||||
Key management
|
Shared key
|
||||||
================================================================= -->
|
================================================================= -->
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h3>Key management</h3>
|
<h3>Shared key (used until PCs enroll)</h3>
|
||||||
<p class="key-warning">
|
<p class="key-warning">
|
||||||
STORE YOUR KEY SECURELY. IT IS SHOWN ONLY ONCE AND CANNOT BE RECOVERED.
|
STORE YOUR KEY SECURELY. IT IS SHOWN ONLY ONCE AND CANNOT BE RECOVERED.
|
||||||
</p>
|
</p>
|
||||||
@@ -25,12 +27,40 @@
|
|||||||
paste the raw key into a chat message or a plain file.
|
paste the raw key into a chat message or a plain file.
|
||||||
</p>
|
</p>
|
||||||
<p class="field-hint">
|
<p class="field-hint">
|
||||||
The endpoint agent reports with a single managed key scoped to endpoint
|
Every PC installed with the shared key holds this same key. It lets a
|
||||||
telemetry only. It is separate from every other collector, so rotating
|
PC that has not enrolled yet report and ask for its own credential. An enrolled PC is refused it and
|
||||||
or revoking it stops all endpoint-agent traffic immediately without
|
uses its credential instead, so rotating or revoking the key only
|
||||||
touching the PC or GE-Enforce collectors.
|
affects PCs that have not enrolled. It is scoped to endpoint telemetry
|
||||||
|
and separate from every other collector.
|
||||||
</p>
|
</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 > Endpoints > Enrollment</router-link>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card form-card">
|
<div class="card form-card">
|
||||||
<div v-if="message" class="settings-success">{{ message }}</div>
|
<div v-if="message" class="settings-success">{{ message }}</div>
|
||||||
<div v-if="error" class="error-message">{{ error }}</div>
|
<div v-if="error" class="error-message">{{ error }}</div>
|
||||||
@@ -49,8 +79,8 @@
|
|||||||
<template v-else>Not used yet.</template>
|
<template v-else>Not used yet.</template>
|
||||||
</p>
|
</p>
|
||||||
<p v-else class="field-hint">
|
<p v-else class="field-hint">
|
||||||
No endpoint agent key is active. Generate one to let agents report
|
No shared key is active. PCs that have not enrolled cannot report
|
||||||
with a managed credential.
|
or auto-enroll until one is generated.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -85,9 +115,10 @@
|
|||||||
<div v-if="secret" class="card form-card secret-card">
|
<div v-if="secret" class="card form-card secret-card">
|
||||||
<h4>Save this key now</h4>
|
<h4>Save this key now</h4>
|
||||||
<p class="field-hint">
|
<p class="field-hint">
|
||||||
Anyone with this key can report as an endpoint agent for this site.
|
Anyone with this key can report as a not-yet-enrolled endpoint agent
|
||||||
Treat it like a password. If it is lost you must regenerate, which
|
for this site and ask to enroll. Treat it like a password. If it is
|
||||||
revokes the old key and forces every agent to be reconfigured.
|
lost you must regenerate, which revokes the old key; PCs that have not
|
||||||
|
enrolled then need the new one.
|
||||||
</p>
|
</p>
|
||||||
<p class="field-hint">
|
<p class="field-hint">
|
||||||
<strong>Recommended:</strong> before you leave this page, open the
|
<strong>Recommended:</strong> before you leave this page, open the
|
||||||
@@ -110,53 +141,57 @@
|
|||||||
<summary><h4>How it works</h4></summary>
|
<summary><h4>How it works</h4></summary>
|
||||||
<ul class="field-hint info-list">
|
<ul class="field-hint info-list">
|
||||||
<li>
|
<li>
|
||||||
<strong>Generate</strong> mints a fresh key and, on the first
|
<strong>Until a PC enrolls</strong> it reports with this shared key.
|
||||||
generation, turns off the bootstrap <code>.env</code> fallback so
|
Agents from 1.20 use it once more to ask for their own credential,
|
||||||
the managed key becomes the only accepted credential. The full
|
and from then on send that credential instead.
|
||||||
secret is shown once - store it in the agent config as the
|
|
||||||
<code>X-API-Key</code> header.
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Regenerate</strong> is a hard cutover: the previous key is
|
<strong>Enrolled PCs ignore it.</strong> The server refuses the shared
|
||||||
revoked in the same moment the new one is minted. Every deployed
|
key for any PC that holds a credential, so regenerating or revoking
|
||||||
agent must be reconfigured with the new key before it can report
|
it does not touch them.
|
||||||
again.
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Revoke</strong> deactivates the key and leaves it off. All
|
<strong>Generate</strong> mints a key and, the first time, turns off
|
||||||
endpoint-agent reporting stops until you generate a new key. This is
|
the bootstrap <code>.env</code> fallback. The full secret is shown
|
||||||
the kill switch.
|
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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details class="collapsible">
|
<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">
|
<ul class="field-hint info-list">
|
||||||
<li>
|
<li>
|
||||||
<strong>An installer or agent left the secure environment.</strong>
|
<strong>At cutover.</strong> Once every PC has enrolled, switch the
|
||||||
If a build carrying the key is copied off the shop floor - onto an
|
shared key off in Enrollment, clear it from every place it lives,
|
||||||
engineer's laptop, a USB stick, or a machine that leaves the site -
|
then regenerate so any copy you missed is dead. The steps are in the
|
||||||
treat the key as compromised and regenerate immediately.
|
per-PC credentials rollout runbook.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>A device is lost, stolen, or decommissioned</strong> while
|
<strong>An installer or key left the secure environment.</strong> A
|
||||||
still holding the key, and you cannot confirm the key was wiped.
|
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>
|
||||||
<li>
|
<li>
|
||||||
<strong>Staff offboarding.</strong> Someone who could read the key
|
<strong>Staff offboarding.</strong> Someone who could read the key
|
||||||
from a config or build pipeline is leaving; rotate as part of the
|
from a config or build pipeline is leaving.
|
||||||
offboarding checklist.
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Scheduled hygiene.</strong> Rotate on a routine cadence
|
<strong>Suspicious traffic.</strong> The Fleet view shows reports from
|
||||||
(for example quarterly) so a silently leaked key has a bounded
|
a host you do not recognize. Revoke now, investigate, then regenerate
|
||||||
lifetime.
|
for the PCs that still need it.
|
||||||
</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.
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
@@ -532,7 +567,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
|
|
||||||
@@ -545,6 +580,9 @@ const secret = ref('')
|
|||||||
const copied = ref(false)
|
const copied = ref(false)
|
||||||
const message = ref('')
|
const message = ref('')
|
||||||
const error = 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.
|
// Adoption tool: build a one-shot, agentless enrollment download.
|
||||||
const adoptbaseurl = ref('')
|
const adoptbaseurl = ref('')
|
||||||
@@ -613,6 +651,40 @@ function filenameFromResponse(response, fallback) {
|
|||||||
return match ? decodeURIComponent(match[1]) : 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) {
|
function applyStatus(data) {
|
||||||
key.value = data.key || null
|
key.value = data.key || null
|
||||||
envkeyfallback.value = !!data.envkeyfallback
|
envkeyfallback.value = !!data.envkeyfallback
|
||||||
@@ -632,6 +704,7 @@ onMounted(async () => {
|
|||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
loadEnrollment()
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/endpoints/siteagent')
|
const response = await api.get('/endpoints/siteagent')
|
||||||
const status = response.data.data || {}
|
const status = response.data.data || {}
|
||||||
@@ -656,8 +729,9 @@ function applyBinaryStatus(binkey, current, versions) {
|
|||||||
|
|
||||||
async function regenerate() {
|
async function regenerate() {
|
||||||
if (key.value &&
|
if (key.value &&
|
||||||
!window.confirm('Regenerate the key? The current key stops working '
|
!window.confirm('Regenerate the shared key? The current key stops working '
|
||||||
+ 'immediately and every agent must be reconfigured.')) {
|
+ `immediately. ${notEnrolledPhrase()} will need the new key before they `
|
||||||
|
+ 'can report or enroll. Enrolled PCs are not affected.')) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
busy.value = true
|
busy.value = true
|
||||||
@@ -684,8 +758,9 @@ async function regenerate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function revoke() {
|
async function revoke() {
|
||||||
if (!window.confirm('Revoke the key? All endpoint-agent reporting stops '
|
if (!window.confirm('Revoke the shared key? '
|
||||||
+ 'until a new key is generated.')) {
|
+ `${notEnrolledPhrase()} will lose access: they stop reporting and cannot `
|
||||||
|
+ 'enroll until a new key is generated. Enrolled PCs keep working.')) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
busy.value = true
|
busy.value = true
|
||||||
@@ -959,6 +1034,24 @@ function editConfiguration() {
|
|||||||
}
|
}
|
||||||
.secret-value { padding: 0.5rem; background: var(--color-surface-2, #1e1e1e); }
|
.secret-value { padding: 0.5rem; background: var(--color-surface-2, #1e1e1e); }
|
||||||
.settings-section { margin-bottom: 2rem; }
|
.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; }
|
.settings-section > h3 { margin-bottom: 0.25rem; }
|
||||||
.info-list { margin: 0.25rem 0 0; padding-left: 1.25rem; }
|
.info-list { margin: 0.25rem 0 0; padding-left: 1.25rem; }
|
||||||
.info-list li { margin-bottom: 0.5rem; }
|
.info-list li { margin-bottom: 0.5rem; }
|
||||||
|
|||||||
@@ -41,6 +41,12 @@
|
|||||||
<option value="quiet">Quiet</option>
|
<option value="quiet">Quiet</option>
|
||||||
<option value="never">Never reported</option>
|
<option value="never">Never reported</option>
|
||||||
</select>
|
</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">
|
<select v-model="computertypeid" class="form-control fleet-filter" @change="applyFilters">
|
||||||
<option value="">All PC types</option>
|
<option value="">All PC types</option>
|
||||||
<option v-for="type in computertypes" :key="type.computertypeid" :value="type.computertypeid">
|
<option v-for="type in computertypes" :key="type.computertypeid" :value="type.computertypeid">
|
||||||
@@ -103,6 +109,7 @@
|
|||||||
<th>PC Type</th>
|
<th>PC Type</th>
|
||||||
<th>Health</th>
|
<th>Health</th>
|
||||||
<th>Agent Version</th>
|
<th>Agent Version</th>
|
||||||
|
<th>Credential</th>
|
||||||
<th>Logged-in User</th>
|
<th>Logged-in User</th>
|
||||||
<th>Uptime</th>
|
<th>Uptime</th>
|
||||||
<th>Last Reported</th>
|
<th>Last Reported</th>
|
||||||
@@ -137,12 +144,17 @@
|
|||||||
<span v-else>-</span>
|
<span v-else>-</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ item.agentversion || '-' }}</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.loggedinuser || '-' }}</td>
|
||||||
<td>{{ item.uptimedisplay || '-' }}</td>
|
<td>{{ item.uptimedisplay || '-' }}</td>
|
||||||
<td>{{ formatRelative(item.lastreportedat) }}</td>
|
<td>{{ formatRelative(item.lastreportedat) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="endpoints.length === 0">
|
<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
|
No endpoints found
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -192,6 +204,17 @@ const sort = ref('status')
|
|||||||
const summary = ref(null)
|
const summary = ref(null)
|
||||||
const criticalcount = ref(0)
|
const criticalcount = ref(0)
|
||||||
const computertypeid = ref('')
|
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 locationid = ref('')
|
||||||
const problemsonly = ref(false)
|
const problemsonly = ref(false)
|
||||||
const computertypes = ref([])
|
const computertypes = ref([])
|
||||||
@@ -329,6 +352,7 @@ async function loadEndpoints() {
|
|||||||
if (search.value) params.search = search.value
|
if (search.value) params.search = search.value
|
||||||
if (status.value) params.status = status.value
|
if (status.value) params.status = status.value
|
||||||
if (sort.value) params.sort = sort.value
|
if (sort.value) params.sort = sort.value
|
||||||
|
if (credentialstate.value) params.credentialstate = credentialstate.value
|
||||||
if (computertypeid.value) params.computertypeid = computertypeid.value
|
if (computertypeid.value) params.computertypeid = computertypeid.value
|
||||||
if (locationid.value) params.locationid = locationid.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) {
|
function statusLabel(value) {
|
||||||
return { reporting: 'Reporting', quiet: 'Quiet', never: 'Never' }[value] || '-'
|
return { reporting: 'Reporting', quiet: 'Quiet', never: 'Never' }[value] || '-'
|
||||||
}
|
}
|
||||||
@@ -394,6 +423,7 @@ function setStatus(value) {
|
|||||||
|
|
||||||
function clearFilters() {
|
function clearFilters() {
|
||||||
status.value = ''
|
status.value = ''
|
||||||
|
credentialstate.value = ''
|
||||||
problemsonly.value = false
|
problemsonly.value = false
|
||||||
applyFilters()
|
applyFilters()
|
||||||
}
|
}
|
||||||
@@ -515,6 +545,22 @@ function changePerPage(newPerPage) {
|
|||||||
|
|
||||||
.fleet-filter { max-width: 200px; }
|
.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 {
|
.bulk-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -5,9 +5,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="setting-description">
|
<p class="setting-description">
|
||||||
Manage the TLS certificate that nginx uses for HTTPS (port 5001).
|
The TLS certificate PCs see when they connect to this site, whether it is
|
||||||
Agents validate this certificate using Windows trusted roots or
|
served by IIS or by the Docker stack. Its public key pin goes into the
|
||||||
thumbprint pinning (ServerCertThumbprint in appsettings.json).
|
Set-ShopDBServerPins.ps1 entry, and the certificate itself into the
|
||||||
|
trusted root entry for agents older than 1.20.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Certificate status -->
|
<!-- Certificate status -->
|
||||||
@@ -97,6 +98,56 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 -->
|
<!-- Generate self-signed certificate -->
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h3>Generate self-signed certificate</h3>
|
<h3>Generate self-signed certificate</h3>
|
||||||
@@ -299,6 +350,8 @@ const busy = ref(false)
|
|||||||
const message = ref('')
|
const message = ref('')
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const cert = ref(null)
|
const cert = ref(null)
|
||||||
|
const live = ref(null)
|
||||||
|
const liveLoading = ref(false)
|
||||||
const thumbprintCopied = ref(false)
|
const thumbprintCopied = ref(false)
|
||||||
const pinCopied = ref(false)
|
const pinCopied = ref(false)
|
||||||
|
|
||||||
@@ -315,8 +368,28 @@ const uploadForm = ref({
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadCertificate()
|
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() {
|
async function loadCertificate() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
@@ -357,6 +430,7 @@ async function generateCertificate() {
|
|||||||
if (response.data.status === 'success') {
|
if (response.data.status === 'success') {
|
||||||
message.value = 'Certificate generated successfully. Nginx is restarting...'
|
message.value = 'Certificate generated successfully. Nginx is restarting...'
|
||||||
await loadCertificate()
|
await loadCertificate()
|
||||||
|
loadLive()
|
||||||
} else {
|
} else {
|
||||||
error.value = response.data.data?.error?.message || 'Failed to generate certificate'
|
error.value = response.data.data?.error?.message || 'Failed to generate certificate'
|
||||||
}
|
}
|
||||||
@@ -405,6 +479,7 @@ async function uploadCertificate() {
|
|||||||
if (response.data.status === 'success') {
|
if (response.data.status === 'success') {
|
||||||
message.value = 'Certificate uploaded successfully. Nginx is restarting...'
|
message.value = 'Certificate uploaded successfully. Nginx is restarting...'
|
||||||
await loadCertificate()
|
await loadCertificate()
|
||||||
|
loadLive()
|
||||||
// Clear file inputs
|
// Clear file inputs
|
||||||
document.getElementById('cert-file').value = ''
|
document.getElementById('cert-file').value = ''
|
||||||
document.getElementById('key-file').value = ''
|
document.getElementById('key-file').value = ''
|
||||||
@@ -529,6 +604,29 @@ pre.code-block {
|
|||||||
margin-left: 20px;
|
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 {
|
.info-list li {
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|||||||
133
plugins/endpoints/frontend/views/agentKeySettings.spec.js
Normal file
133
plugins/endpoints/frontend/views/agentKeySettings.spec.js
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
99
plugins/endpoints/frontend/views/endpointsList.spec.js
Normal file
99
plugins/endpoints/frontend/views/endpointsList.spec.js
Normal 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/)
|
||||||
|
})
|
||||||
|
})
|
||||||
98
plugins/endpoints/frontend/views/serverCertSettings.spec.js
Normal file
98
plugins/endpoints/frontend/views/serverCertSettings.spec.js
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -341,8 +341,9 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
'to': '/settings/endpoints/agentkey',
|
'to': '/settings/endpoints/agentkey',
|
||||||
'icon': 'key',
|
'icon': 'key',
|
||||||
'title': 'Endpoints',
|
'title': 'Endpoints',
|
||||||
'description': 'Manage the endpoint agent key, build the site '
|
'description': 'The shared agent key PCs use until they '
|
||||||
'agent, and configure what it reports.',
|
'enroll, the site agent installer builder, and '
|
||||||
|
'what the agent reports.',
|
||||||
'position': 27,
|
'position': 27,
|
||||||
},
|
},
|
||||||
# Its own card rather than a section of the agent key page: the
|
# 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',
|
'to': '/settings/endpoints/server-cert',
|
||||||
'icon': 'shield-check',
|
'icon': 'shield-check',
|
||||||
'title': 'Server Certificate',
|
'title': 'Server Certificate',
|
||||||
'description': 'Manage the TLS certificate nginx uses for HTTPS. '
|
'description': 'The certificate PCs see when they connect, on '
|
||||||
'Agents validate this certificate via pinning or '
|
'IIS or Docker. Source of the server pin and the '
|
||||||
'Windows trusted roots.',
|
'trusted root entry for agents.',
|
||||||
'position': 29,
|
'position': 29,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'group': 'Endpoints',
|
'group': 'Endpoints',
|
||||||
'to': '/settings/endpoints/enrollment',
|
'to': '/settings/endpoints/enrollment',
|
||||||
'icon': 'key',
|
'icon': 'badge-check',
|
||||||
'title': 'Enrollment',
|
'title': 'Enrollment',
|
||||||
'description': 'Move agents to per-PC credentials, approve '
|
'description': 'Move agents to per-PC credentials, approve '
|
||||||
'enrollment requests, and retire the shared key.',
|
'enrollment requests, and retire the shared key.',
|
||||||
@@ -828,6 +829,46 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
'link': '/pcs/{computerid}',
|
'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 -------------------------------------
|
# ---- ADR-006 collector contract -------------------------------------
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ def test_a_viewall_target_is_one_of_the_known_pages(app):
|
|||||||
'/pcs', '/machines', '/printers', '/network', '/notifications',
|
'/pcs', '/machines', '/printers', '/network', '/notifications',
|
||||||
'/geenforce', '/reports/toner', '/reports/warranty',
|
'/geenforce', '/reports/toner', '/reports/warranty',
|
||||||
'/reports/pc-relationships', '/reports/calibration', '/reports',
|
'/reports/pc-relationships', '/reports/calibration', '/reports',
|
||||||
|
# plugins/endpoints/frontend/routes.js, readable by endpoints.fleet
|
||||||
|
'/settings/endpoints/enrollment',
|
||||||
}
|
}
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
for pluginname, card in _declared(app):
|
for pluginname, card in _declared(app):
|
||||||
|
|||||||
525
tests/test_plugins/test_endpoints_credential_admin_ui.py
Normal file
525
tests/test_plugins/test_endpoints_credential_admin_ui.py
Normal 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
|
||||||
Reference in New Issue
Block a user