Make main the lean product a site receives

main was 16 days and 259 commits behind the line the product is actually built
on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It
was neither current nor curated - just stale. This brings it forward and, at the
same time, makes it mean something: main is what a receiving site installs, and
nothing else.

WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides,
knowledgebase, machines, network, notifications, printers, slides, tools,
warranty - plus applications, which is manifest-less core and always ships. The
only manifest dependency inside that set is endpoints on computers, and both are
in it, so the closure is self-contained.

WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb
stay on the site line. So do the Windows installer tree, the VM rehearsal
helpers, the agent tooling config, one site's data loader, the internal audit
and findings records, and the planner spreadsheet. 1,135 tracked files against
1,438.

A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped:

deploy/site-profile-universal.json still named all seventeen plugins. It is the
file docs/DEPLOY.md tells a site to provision from, so a fresh install would
have failed at `flask plugin apply-profile` with PluginError - the first thing a
receiving site does, broken by the branch meant to serve them. It now names the
twelve that exist, and its comment says the two must agree.

agent/ was never refreshed. Whoever populated this branch copied shopdb/,
plugins/, frontend/ and tests/ from the site line and left the agent tree at its
September state: 22 C# and PowerShell sources missing, including AgentCredentials,
EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the
site line had deleted still present. The old tree is internally consistent, so it
compiles, and nothing in CI builds it - every gate was green over a server that
speaks enrollment and per-PC credentials to a bundled agent source that cannot.
Resynced whole.

DECISIONS THIS ENCODES, rather than defers:

- The plugin-authoring lab builds printedparts, whose manifest depends on
  employees, so it could not stay. The lab remains on the site line and main
  ships no build-along tutorial. That is a real loss for an adopting developer
  and is recorded here rather than discovered later.
- Recognition, Training and Recertification notifications are no longer seeded.
  Each splits one notification into a card per employee and renders a photo,
  which needs a plugin this branch does not bundle. Seeding them would offer an
  author a type that cannot resolve anybody. A site that installs employees gets
  them from that plugin's own seed.
- Windows/IIS is documented as the legacy path throughout, and the README leads
  with Docker, because that is what a receiving site gets.

The API description is regenerated for THIS branch - 347 paths and 483
operations, down from 415 and 573 - so the published contract describes what is
here rather than what the site line serves. docs/CONFIG.md lost the two
measuring-tool label keys the code no longer declares, which two lanes had
decided opposite ways.

Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run
rather than skip, and every index line count is derived from the file it names.
This commit is contained in:
cproudlock
2026-09-19 11:24:40 -04:00
parent cb3975049c
commit 534f179ee6
675 changed files with 115759 additions and 60239 deletions

View File

@@ -30,10 +30,7 @@
<span v-if="app.isprinter" class="badge badge-lg badge-secondary">Printer App</span>
<span v-if="app.ishidden" class="badge badge-lg badge-dark">Hidden</span>
</div>
<div class="hero-links" v-if="app.applicationlink || app.installpath || app.documentationpath">
<a v-if="app.applicationlink" :href="fileHref(app.applicationlink)" target="_blank" class="hero-link">
<span class="link-icon">&#x1F517;</span> Launch Application
</a>
<div class="hero-links" v-if="app.installpath || app.documentationpath">
<a v-if="app.installpath" :href="fileHref(app.installpath)" target="_blank" class="hero-link">
<span class="link-icon">&#x2B07;</span> Download Files
</a>
@@ -61,6 +58,13 @@
<span v-else>{{ app.supportteamname || '-' }}</span>
</span>
</div>
<div class="info-row" v-if="app.applicationlink">
<span class="info-label">Application Link</span>
<!-- Reference only, deliberately not a link. It tells an
operator where to go for new versions; it is not a launch
or download action and must not read as one. -->
<span class="info-value mono reference-value">{{ app.applicationlink }}</span>
</div>
<div class="info-row" v-if="app.contacts && app.contacts.length">
<span class="info-label">Contacts</span>
<span class="info-value">
@@ -432,6 +436,11 @@ function kbKeywords(keywords) {
}
/* Support contacts stack one per line */
.reference-value {
word-break: break-all;
user-select: all;
}
.contact-line {
display: block;
}

View File

@@ -69,11 +69,70 @@
<input type="checkbox" v-model="form.isrequired" />
Required on all PCs
</label>
<label>
<input type="checkbox" v-model="form.ismonitorable" />
Monitorable
</label>
<label>
<input type="checkbox" v-model="form.ishidden" />
Hidden
</label>
</div>
<!-- The server rejects a monitorable app missing either field, so the
two inputs appear with the box rather than being hidden away in
another section where the rejection would read as a bug. -->
<div v-if="form.ismonitorable" class="monitor-fields">
<div class="form-group">
<label for="monitorprocessname">Process Name</label>
<input
id="monitorprocessname"
v-model="form.monitorprocessname"
type="text"
class="form-control"
placeholder="e.g. notepad.exe"
/>
<small class="form-hint">
The image name as it appears in Task Manager. This is what the
agent watches for.
</small>
</div>
<div class="form-group">
<label for="monitorlaunchpath">Launch Path</label>
<input
id="monitorlaunchpath"
v-model="form.monitorlaunchpath"
type="text"
class="form-control"
placeholder="e.g. C:\Program Files\Vendor\app.exe"
/>
<small class="form-hint">
Run on the endpoint when the process is found missing. A local
path on the PC, not a share.
</small>
</div>
</div>
<!-- Only shown when the user is newly ticking the box. An app that
was already required has an existing policy, so re-saving it is
not a new deployment and does not need re-acknowledging. -->
<div v-if="showsPolicyWarning" class="policy-warning">
<strong>This creates a software install policy.</strong>
<p>
Marking <em>{{ form.appname || 'this application' }}</em> as required
on all PCs creates a configuration policy that installs it on every
non-server endpoint it applies to. The policy is not added to a
business policy automatically: it installs nothing until an admin
adds it to an active business policy. From then on, installation
happens without further prompting on each machine as it checks in.
</p>
<label>
<input type="checkbox" v-model="acknowledgePolicy" />
I understand this creates a policy that will install the application
on all matched PCs once it is added to a business policy.
</label>
</div>
</div>
<h4 style="margin-top: 1.5rem; margin-bottom: 1rem;">Links & Paths</h4>
@@ -103,7 +162,7 @@
</div>
<div class="form-group">
<label for="installpath">Install Path</label>
<label for="installpath">Installer path</label>
<input
id="installpath"
v-model="form.installpath"
@@ -132,7 +191,29 @@
</div>
<small class="form-hint">
Up to 500MB. Anything larger belongs on the share - put its path in
Install Path instead. Downloads require a login.
Installer path instead. Downloads require a login.
</small>
</div>
<div class="form-group">
<label for="version">Version</label>
<input
id="version"
v-model="form.version"
type="text"
class="form-control"
placeholder="e.g. 2.4.1.0"
/>
<small class="form-hint">
<span v-if="detectedVersion">
Read from the installer. Change it if the vendor numbers releases
differently.
</span>
<span v-else>
Read from the installer where one is present. MSI and archive
files do not carry a version, so type it in to keep a later
re-import from looking like new software.
</span>
</small>
</div>
@@ -181,11 +262,26 @@
<div v-if="error" class="error-message">{{ error }}</div>
<div v-if="versionWarning" class="policy-warning">{{ versionWarning }}</div>
<div style="display: flex; gap: 0.5rem; margin-top: 1.5rem;">
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : 'Save Application' }}
</button>
<router-link to="/applications" class="btn btn-secondary">Cancel</router-link>
<!-- Edit only: there is nothing to strip from an application that has
not been created yet. Pushed to the right and styled as a danger
action so it is not mistaken for Cancel. -->
<button
v-if="isEdit"
type="button"
class="btn btn-danger"
style="margin-left: auto;"
:disabled="busyRevert"
@click="revertToRecord"
>
{{ busyRevert ? 'Reverting...' : 'Revert to Record' }}
</button>
</div>
</form>
</div>
@@ -215,16 +311,32 @@ const form = ref({
islicenced: false,
isprinter: false,
isrequired: false,
ismonitorable: false,
monitorlaunchpath: '',
monitorprocessname: '',
ishidden: false,
applicationlink: '',
documentationpath: '',
installpath: '',
image: '',
applicationnotes: ''
applicationnotes: '',
// Not a column on applications - it creates/updates an AppVersion row on
// save. Held here so the field can sit next to the installer that produced
// it, which is where the operator is looking when it matters.
version: ''
})
const supportTeams = ref([])
// The value as saved, so we can tell a NEW deployment from a re-save of an
// application that was already required. Only the former warrants a warning.
const wasRequired = ref(false)
const acknowledgePolicy = ref(false)
const showsPolicyWarning = computed(
() => form.value.isrequired && !wasRequired.value
)
// Files are chosen before the application exists, so they are held here and
// uploaded once there is an id to attach them to (see saveApplication).
const pendingImage = ref(null)
@@ -234,6 +346,12 @@ const uploadedPackage = ref('')
const packageProgress = ref(null)
const busyImage = ref(false)
const busyPackage = ref(false)
const busyRevert = ref(false)
const detectedVersion = ref('')
const versionWarning = ref('')
// Versions already on the record, so saving an unchanged form does not try to
// re-create one and trip the duplicate check.
const existingVersions = ref([])
const PACKAGE_ACCEPT = '.exe,.msi,.msp,.zip,.7z,.cab,.iso,.appx,.msix,.ps1,.bat,.txt,.pdf'
const MAX_PACKAGE_BYTES = 500 * 1024 * 1024
@@ -286,6 +404,60 @@ async function removePackage() {
}
}
// Strips the application back to a bare catalogue record. Distinct from Delete
// on the list: the row, name and knowledgebase links survive, so this is the
// action for "stop deploying this" rather than "this never existed". Still
// irreversible, so it uses the same typed-OK gate the backend enforces.
async function revertToRecord() {
const answer = prompt(
'Remove all policy associations and uploaded executables? This action '
+ 'cannot be reversed. Type OK to proceed')
if ((answer || '').trim().toUpperCase() !== 'OK') return
busyRevert.value = true
error.value = ''
try {
const response = await applicationsApi.revertToRecord(
route.params.id, (answer || '').trim())
const app = response.data.data
// Re-seed from the server rather than guessing which fields it cleared.
// The image is deliberately untouched: revert removes executables and
// policy associations, not the record's icon.
form.value.installpath = app.installpath || ''
form.value.isrequired = app.isrequired
// Revert drops every AppVersion row, so the field must not keep showing a
// version that no longer exists.
form.value.version = ''
uploadedPackage.value = ''
pendingPackage.value = null
detectedVersion.value = ''
versionWarning.value = ''
} catch (err) {
error.value = apiError(err, 'Failed to revert the application')
} finally {
busyRevert.value = false
}
}
// Records the typed version against the application, if it is new. Creating a
// version that already exists is not an error worth failing the save over -
// re-saving a form without touching the field is the normal case, and the
// backend answers 409, which would otherwise surface as "failed to save" on a
// save that in fact succeeded.
async function saveVersion(appid) {
const version = (form.value.version || '').trim()
if (!version) return
if (existingVersions.value.some(v => v.version === version)) return
try {
await applicationsApi.createVersion(appid, { version })
existingVersions.value = [{ version }, ...existingVersions.value]
} catch (err) {
if (err.response?.status === 409) return
throw err
}
}
// The image field holds either a served URL (new) or a bare filename from the
// classic site, which is still rendered out of /images/applications/.
function imageSrcFor(value) {
@@ -314,14 +486,24 @@ onMounted(async () => {
islicenced: app.islicenced || false,
isprinter: app.isprinter || false,
isrequired: app.isrequired || false,
ismonitorable: app.ismonitorable || false,
monitorlaunchpath: app.monitorlaunchpath || '',
monitorprocessname: app.monitorprocessname || '',
ishidden: app.ishidden || false,
applicationlink: app.applicationlink || '',
documentationpath: app.documentationpath || '',
installpath: app.installpath || '',
image: app.image || '',
applicationnotes: app.applicationnotes || ''
applicationnotes: app.applicationnotes || '',
// The detail payload already carries versions newest-first, so the
// current one comes free rather than costing another request.
version: (app.versions && app.versions.length)
? (app.versions[0].version || '')
: ''
}
existingVersions.value = app.versions || []
imagePreview.value = imageSrcFor(app.image)
wasRequired.value = app.isrequired || false
if ((app.installpath || '').startsWith('/api/applications/package/')) {
uploadedPackage.value = app.installpath.split('/').pop()
}
@@ -336,6 +518,27 @@ onMounted(async () => {
async function saveApplication() {
error.value = ''
if (showsPolicyWarning.value && !acknowledgePolicy.value) {
error.value = 'Please acknowledge that this will install the application '
+ 'on all matched PCs before saving.'
return
}
// Mirrors _validate_monitorable on the server. Checked here too so the
// operator is told which field is missing while they are still looking at it,
// rather than after a round trip.
if (form.value.ismonitorable) {
if (!(form.value.monitorprocessname || '').trim()) {
error.value = 'A monitorable application needs a process name.'
return
}
if (!(form.value.monitorlaunchpath || '').trim()) {
error.value = 'A monitorable application needs a launch path.'
return
}
}
saving.value = true
try {
@@ -347,12 +550,22 @@ async function saveApplication() {
islicenced: form.value.islicenced,
isprinter: form.value.isprinter,
isrequired: form.value.isrequired,
ismonitorable: form.value.ismonitorable,
// Cleared when the box is unticked so a later re-tick cannot silently
// revive a stale process name the operator can no longer see.
monitorlaunchpath: form.value.ismonitorable
? (form.value.monitorlaunchpath || null)
: null,
monitorprocessname: form.value.ismonitorable
? (form.value.monitorprocessname || null)
: null,
ishidden: form.value.ishidden,
applicationlink: form.value.applicationlink || null,
documentationpath: form.value.documentationpath || null,
installpath: form.value.installpath || null,
image: form.value.image || null,
applicationnotes: form.value.applicationnotes || null
applicationnotes: form.value.applicationnotes || null,
acknowledgepolicy: showsPolicyWarning.value
}
let appid = route.params.id
@@ -372,12 +585,33 @@ async function saveApplication() {
}
if (pendingPackage.value) {
packageProgress.value = 0
await applicationsApi.uploadPackage(appid, pendingPackage.value,
percent => { packageProgress.value = percent })
const uploaded = await applicationsApi.uploadPackage(
appid, pendingPackage.value,
percent => { packageProgress.value = percent })
pendingPackage.value = null
packageProgress.value = null
// An installer with no readable version resource is how duplicate
// version rows got created: the operator saved, saw no version, and
// imported the same file again. MSIs and archives are not PE files and
// never carry one, so ask rather than guess.
detectedVersion.value = uploaded.data.data.detectedversion || ''
if (detectedVersion.value && !form.value.version.trim()) {
form.value.version = detectedVersion.value
}
if (!form.value.version.trim()) {
versionWarning.value =
`No version could be read from ${uploaded.data.data.uploadedfilename}. `
+ 'Enter one in the Version field above so a later import is not '
+ 'mistaken for new software.'
// The application and installer are already saved; only the version is
// outstanding, so stay on the form rather than losing that work.
return
}
}
await saveVersion(appid)
router.push('/applications')
} catch (err) {
console.error('Error saving application:', err)
@@ -449,4 +683,39 @@ async function saveApplication() {
font-size: 0.8rem;
color: var(--text-light, #666);
}
.policy-warning {
margin-top: 1rem;
padding: 0.75rem 1rem;
border: 1px solid var(--warning);
border-left-width: 4px;
border-radius: 4px;
/* Derived from the real token rather than a fixed tint: a hardcoded light
value here survives the theme switch and renders light-on-light in dark
mode. --warning-bg does not exist in style.css. */
background: color-mix(in srgb, var(--warning) 12%, var(--bg-card-solid));
color: var(--text);
}
.policy-warning p {
margin: 0.5rem 0 0.75rem;
font-size: 0.9rem;
}
.policy-warning label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-weight: 600;
}
.monitor-fields {
margin-top: 1rem;
padding: 0.75rem 1rem;
border: 1px solid var(--border);
border-left: 4px solid var(--warning);
border-radius: 4px;
background: color-mix(in srgb, var(--warning) 6%, var(--bg-card-solid));
}
</style>

View File

@@ -44,9 +44,6 @@
<a v-if="app.installpath" :href="fileHref(app.installpath)" target="_blank" title="Download Installation Files" class="icon-download">
&#x2B07;
</a>
<a v-else-if="app.applicationlink" :href="fileHref(app.applicationlink)" target="_blank" title="Application Link" class="icon-link">
&#x1F517;
</a>
</td>
<td class="icon-cell">
<a v-if="app.documentationpath" :href="fileHref(app.documentationpath)" target="_blank" title="View Documentation" class="icon-docs">
@@ -73,6 +70,13 @@
>
View
</router-link>
<button
v-if="candelete"
class="btn btn-danger btn-sm"
@click="deleteApplication(app)"
>
Delete
</button>
</td>
</tr>
<tr v-if="applications.length === 0">
@@ -98,14 +102,21 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { applicationsApi } from '@/api'
import { useAuthStore } from '@/stores/auth'
import PaginationBar from '@/components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
import { fileHref } from '@/utils/basePath'
const applications = ref([])
const loading = ref(true)
// Mirrors the backend guard on the endpoint rather than checking for the admin
// role, so a non-admin role granted applications.delete gets the button too and
// nobody is shown a control that would only 403.
const authStore = useAuthStore()
const candelete = computed(() => authStore.hasPermission('applications.delete'))
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadApplications })
const filter = ref('installable')
const totalPages = ref(1)
@@ -164,6 +175,31 @@ function debouncedSearch() {
}, 300)
}
// This permanently drops the row, its versions and its uploaded installer, so
// the prompt says so plainly and requires the word OK to be typed. A plain
// confirm() is one misplaced click away from destroying licensed software.
async function deleteApplication(app) {
const warning = app.isrequired
? `\n\n"${app.appname}" is currently required on all PCs. Removing it also `
+ 'withdraws the policy that installs it.'
: ''
const answer = prompt(
`Permanently delete "${app.appname}", its versions and its uploaded `
+ `executables? This action cannot be reversed.${warning}\n\n`
+ 'Type OK to proceed')
if ((answer || '').trim().toUpperCase() !== 'OK') return
try {
await applicationsApi.delete(app.appid, (answer || '').trim())
loadApplications()
} catch (error) {
console.error('Error deleting application:', error)
alert(error.response?.data?.data?.error?.message
|| 'Failed to remove the application.')
}
}
function goToPage(p) {
setPage(p)
loadApplications()
@@ -192,10 +228,6 @@ function changePerPage(newPerPage) {
color: var(--success);
}
.icon-link {
color: var(--link);
}
.icon-docs {
color: var(--secondary);
}

View File

@@ -71,7 +71,7 @@ class BackupRevision(db.Model):
# that send a manifest describing what their snapshot contains (the bytes
# still live on the share; this is the index, not the content).
# MEDIUMTEXT holding serialized
# JSON rather than a native JSON column, matching geenforce.manifestjson.
# JSON rather than a native JSON column, matching guides.blocksjson.
# Text also means the bytes come back exactly as written, so the canonical
# key ordering the codec produces survives the round trip - a native JSON
# column would renormalize it and make diffs between revisions unstable.

View File

@@ -14,18 +14,91 @@ allowlist, so a bad tree or an unknown action never reaches the database.
from flask import request
from shopdb.api import (db, AuditLog, success_response, error_response,
ErrorCodes, require_permission)
ErrorCodes, require_permission, caller_holds)
from .routes import computers_bp
from ..models import (ConfigurationPolicy, ConfigurationPolicyAction,
BusinessPolicy, BusinessPolicyItem, POLICY_SOURCE_LOCAL,
POLICY_SOURCE_AUTOAPP, POLICY_SOURCE_SHIPPED,
ACTION_MODE_DESIRED, ACTION_MODE_GATE)
from ..policyflag import policy_flag_enabled
from ..services.apppolicy import disown_if_generated
from ..services.policyscope import validate_scope, ScopeError
_MODES = {ACTION_MODE_DESIRED, ACTION_MODE_GATE}
def _elevated_denial(actionnames):
"""Refuse unless the caller may author every elevated action in actionnames.
A policy is a deferred instruction to run something. Gating only the enqueue
route left authoring wide open: computers.edit alone could author a policy
carrying run_script and let the agent execute it, which is the whole point of
splitting the big four undone. The permission to CAUSE an action must be the
same permission required to RUN it.
caller_holds, not current_user.haspermission: a scoped personal token is a
narrower caller than its owner, and asking the owner is the exact bypass
require_permission exists to prevent.
"""
from plugins.endpoints.commands import elevated_permission_for
missing = []
for name in sorted(set(actionnames)):
elevated = elevated_permission_for(name)
if elevated and not caller_holds(elevated) and elevated not in missing:
missing.append(elevated)
if not missing:
return None
return error_response(
ErrorCodes.FORBIDDEN,
'This policy carries actions you may not author. Missing: '
+ ', '.join(missing),
http_code=403)
def _requested_actionnames(actions):
return [(row.get('action') or '').strip() for row in actions or []]
def _policy_actionnames(policy):
return [a.action for a in policy.actions]
def _composed_actionnames(configpolicyids):
"""Every action reachable through a set of configuration policies.
Composition is an authoring act: adding a configuration policy to a business
policy causes its actions to run on everything the business policy targets,
without the composer ever naming those actions.
"""
names = []
for cpid in configpolicyids:
policy = db.session.get(ConfigurationPolicy, cpid)
if policy:
names.extend(_policy_actionnames(policy))
return names
def _changes_effect(data, policy=None):
"""Whether this payload changes WHAT a policy does, rather than how it reads.
Scope widening, reactivation, priority and composition all change the effect
of an already-authored action without touching the action list, so gating the
action payload alone would leave every one of them open. Renaming a policy or
editing its description changes nothing that runs, so it stays ungated.
"""
for field in ('actions', 'items', 'scopejson', 'priority'):
if field in data:
return True
if 'isactive' in data:
# Only reactivation matters. Deactivating a policy cannot widen anything,
# and refusing it would strand a live policy nobody present can switch off.
was = getattr(policy, 'isactive', False) if policy is not None else False
return bool(data['isactive']) and not was
return False
def _feature_gate():
"""Return a 404 response when the policy feature is off, else None."""
if not policy_flag_enabled():
@@ -155,6 +228,10 @@ def create_configuration_policy():
return error_response(ErrorCodes.VALIDATION_ERROR,
f'Invalid scope: {exc}')
denial = _elevated_denial(_requested_actionnames(data.get('actions')))
if denial:
return denial
policy = ConfigurationPolicy(
name=name,
description=data.get('description'),
@@ -183,12 +260,30 @@ def update_configuration_policy(policy_id: int):
if not policy:
return error_response(ErrorCodes.NOT_FOUND,
'Configuration policy not found', http_code=404)
if policy.source != POLICY_SOURCE_LOCAL:
# An autoapp row is generated from an Application's "Required on all PCs"
# checkbox and IS editable: editing it is precisely how an admin takes
# ownership. A shipped row is editable for the same reason - the product
# ships a starting point, not an opinion about one site's floor - and keeps
# source='shipped' so provenance survives the edit. Genuinely external
# sources (a future managed owner) stay read-only.
if policy.source not in (POLICY_SOURCE_LOCAL, POLICY_SOURCE_AUTOAPP,
POLICY_SOURCE_SHIPPED):
return error_response(ErrorCodes.CONFLICT,
'Policy is managed externally and is read-only',
http_code=409)
data = request.get_json() or {}
# Both sides. The incoming actions are what the caller is asking to cause;
# the existing ones are what a scope widening or reactivation would newly
# unleash on machines that were never in range before.
if _changes_effect(data, policy):
names = list(_policy_actionnames(policy))
if 'actions' in data:
names.extend(_requested_actionnames(data['actions']))
denial = _elevated_denial(names)
if denial:
return denial
if 'name' in data:
newname = (data['name'] or '').strip()
if newname and newname != policy.name:
@@ -215,6 +310,10 @@ def update_configuration_policy(policy_id: int):
if err:
return err
# Hand-editing a generated policy transfers ownership: from here on the
# application checkbox will not delete it, it will warn instead.
disown_if_generated(policy)
AuditLog.log('updated', 'ConfigurationPolicy', entityname=policy.name)
db.session.commit()
return success_response(_configpolicy_dict(policy),
@@ -231,7 +330,11 @@ def delete_configuration_policy(policy_id: int):
if not policy:
return error_response(ErrorCodes.NOT_FOUND,
'Configuration policy not found', http_code=404)
if policy.source != POLICY_SOURCE_LOCAL:
# As with update: a generated or shipped row is the site's to remove, an
# externally managed one is not. A site that does not run MTConnect should
# be able to delete its monitor policy outright rather than carry it.
if policy.source not in (POLICY_SOURCE_LOCAL, POLICY_SOURCE_AUTOAPP,
POLICY_SOURCE_SHIPPED):
return error_response(ErrorCodes.CONFLICT,
'Policy is managed externally and is read-only',
http_code=409)
@@ -242,6 +345,11 @@ def delete_configuration_policy(policy_id: int):
ErrorCodes.CONFLICT,
f'Cannot delete: used by {inuse} business policy item(s)',
http_code=409)
# Deleting a gate policy REMOVES a restriction, so it changes what endpoints
# will run just as surely as authoring one does.
denial = _elevated_denial(_policy_actionnames(policy))
if denial:
return denial
db.session.delete(policy)
AuditLog.log('deleted', 'ConfigurationPolicy', entityname=policy.name)
db.session.commit()
@@ -324,6 +432,12 @@ def create_business_policy():
priority=data.get('priority', 100),
source=POLICY_SOURCE_LOCAL,
)
requested = [row.get('configurationpolicyid')
for row in data.get('items') or []]
denial = _elevated_denial(_composed_actionnames(
[cpid for cpid in requested if cpid is not None]))
if denial:
return denial
err = _apply_items(policy, data.get('items'))
if err:
return err
@@ -349,6 +463,20 @@ def update_business_policy(policy_id: int):
'Policy is managed externally and is read-only',
http_code=409)
data = request.get_json() or {}
# Composition is the quiet escalation: attaching a configuration policy runs
# its actions everywhere this business policy reaches, without the composer
# naming a single one of them.
if _changes_effect(data, policy):
cpids = [it.configurationpolicyid for it in policy.items]
for row in data.get('items') or []:
cpid = row.get('configurationpolicyid')
if cpid is not None:
cpids.append(cpid)
denial = _elevated_denial(_composed_actionnames(cpids))
if denial:
return denial
if 'name' in data:
newname = (data['name'] or '').strip()
if newname and newname != policy.name:
@@ -387,6 +515,10 @@ def delete_business_policy(policy_id: int):
return error_response(ErrorCodes.CONFLICT,
'Policy is managed externally and is read-only',
http_code=409)
denial = _elevated_denial(_composed_actionnames(
[it.configurationpolicyid for it in policy.items]))
if denial:
return denial
db.session.delete(policy)
AuditLog.log('deleted', 'BusinessPolicy', entityname=policy.name)
db.session.commit()
@@ -405,16 +537,29 @@ def list_policy_actions():
Returns command actions tagged kind='command'. Monitor attachments are not
listed here: the UI builds that branch from the endpoints monitor catalog
(endpointsApi.monitors) so newly created monitors appear without a reload.
Actions the caller may not author are marked rather than hidden. Hiding them
would make the editor look like the capability does not exist; marking them
says it exists and is not yours, which is the difference between a confusing
UI and an honest one. The authoring gate is enforced server-side regardless.
"""
gate = _feature_gate()
if gate:
return gate
from plugins.endpoints.commands import COMMAND_ALLOWLIST
return success_response([
{'action': name, 'label': meta.get('label'),
'description': meta.get('description'), 'kind': 'command'}
for name, meta in COMMAND_ALLOWLIST.items()
])
from plugins.endpoints.commands import (COMMAND_ALLOWLIST,
elevated_permission_for)
catalog = []
for name, meta in COMMAND_ALLOWLIST.items():
elevated = elevated_permission_for(name)
catalog.append({
'action': name,
'label': meta.get('label'),
'description': meta.get('description'),
'kind': 'command',
'requiredpermission': elevated,
'mayauthor': not elevated or caller_holds(elevated),
})
return success_response(catalog)
@computers_bp.route('/policies/effective/<int:asset_id>', methods=['GET'])

View File

@@ -17,11 +17,18 @@
# to Asset.assetnumber; the placeholder 9999 is skipped server-side.
#
# AUTH: the collector API does NOT honor the GE-Enforce IP allowlist (that only
# covers the geenforce fetch/report endpoints). It needs a collector.ingest key,
# sent as the X-API-Key header. The key is read from HKLM:\SOFTWARE\GE\ShopDB
# CollectorKey (same secret store the GE-Enforce client uses; provisioned at
# imaging), or overridden via the manifest entry's Args -ApiKey. Never bake the
# key into the manifest JSON on the share.
# covers the geenforce fetch/report endpoints). Once the endpoint agent (1.20+)
# has enrolled this PC, its per-PC credential is read from the agent's
# SYSTEM-only credential.bin and sent as X-Endpoint-Credential; the server then
# refuses the shared key for this PC (401 SHARED_KEY_REFUSED). Before that, a
# collector.ingest key is sent as the X-API-Key header, read from
# HKLM:\SOFTWARE\GE\ShopDB CollectorKey (same secret store the GE-Enforce client
# uses; provisioned at imaging), or overridden via the manifest entry's Args
# -ApiKey. Never bake the key into the manifest JSON on the share.
#
# PINS: when HKLM:\SOFTWARE\GE\ShopDB ServerPins is set, the server's TLS key
# must match one of them or nothing is sent. See
# plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md.
#
# Deployed in common\ (runs on EVERY shopfloor pc-type). Non-DNC bays report
# identity with no machineNo, so no PC-to-machine link is built - by design.
@@ -70,6 +77,271 @@ function Log([string]$msg) {
"$ts $msg" | Tee-Object -FilePath $logFile -Append | Out-Null
}
# ---------------------------------------------------------------------------
# Per-PC credential and server pin. Kept in step with the same block in
# Report-AssetToShopDB.ps1, Report-PrintersToShopDB.ps1 and
# upgrade-shopdbagent.ps1: each runs standalone, so the text is duplicated.
#
# Credential: the endpoint agent (1.20+) enrolls and stores its own credential
# in credential.bin, DPAPI LocalMachine, readable only by SYSTEM and
# Administrators. When present it is sent as X-Endpoint-Credential and the
# shared key is not sent. Without it the shared CollectorKey goes as X-API-Key.
#
# Pins: HKLM\SOFTWARE\GE\ShopDB ServerPins (REG_MULTI_SZ), lowercase hex
# SHA-256 of the server leaf certificate's SubjectPublicKeyInfo. When any pin
# is set the TLS handshake is accepted only if the leaf key matches one.
# ---------------------------------------------------------------------------
$ShopDbCredentialFile = Join-Path $env:ProgramData 'ShopDB\EndpointAgent\credential\credential.bin'
$ShopDbPinSource = @'
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Security;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
public static class ShopDbServerPinValidator
{
private static readonly HashSet<string> Pins = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
public static string LastPresented;
public static void Install(string[] pins)
{
Pins.Clear();
foreach (string pin in pins)
{
if (!String.IsNullOrEmpty(pin)) { Pins.Add(pin); }
}
ServicePointManager.ServerCertificateValidationCallback = Validate;
}
public static bool Validate(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors errors)
{
if (Pins.Count == 0) { return errors == SslPolicyErrors.None; }
if (certificate == null) { return false; }
try
{
LastPresented = SpkiSha256Hex(certificate.GetRawCertData());
}
catch (Exception)
{
LastPresented = null;
return false;
}
return Pins.Contains(LastPresented);
}
public static bool IsPinned(string pin)
{
return !String.IsNullOrEmpty(pin) && Pins.Contains(pin);
}
// Walks the DER: Certificate > TBSCertificate > [0] version (optional),
// serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo.
public static string SpkiSha256Hex(byte[] der)
{
int length;
int position = EnterSequence(der, 0, out length);
position = EnterSequence(der, position, out length);
if (der[position] == 0xA0) { position = Skip(der, position); }
for (int field = 0; field < 5; field++) { position = Skip(der, position); }
if (der[position] != 0x30) { throw new FormatException("SubjectPublicKeyInfo not found"); }
int end = Skip(der, position);
using (SHA256 sha = SHA256.Create())
{
byte[] hash = sha.ComputeHash(der, position, end - position);
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
}
}
private static int ReadLength(byte[] der, int position, out int length)
{
int first = der[position];
if (first < 0x80) { length = first; return position + 1; }
int count = first & 0x7F;
if (count == 0 || count > 4) { throw new FormatException("Unsupported DER length"); }
length = 0;
for (int i = 1; i <= count; i++) { length = (length << 8) | der[position + i]; }
return position + 1 + count;
}
private static int EnterSequence(byte[] der, int position, out int length)
{
if (der[position] != 0x30) { throw new FormatException("Expected SEQUENCE"); }
return ReadLength(der, position + 1, out length);
}
private static int Skip(byte[] der, int position)
{
int length;
int content = ReadLength(der, position + 1, out length);
return content + length;
}
}
'@
function Get-ShopDbServerPins {
# Both registry views, like CollectorKey. Anything not 64 hex chars is dropped.
$pins = @()
foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64, [Microsoft.Win32.RegistryView]::Registry32)) {
try {
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $view)
try {
$key = $base.OpenSubKey('SOFTWARE\GE\ShopDB')
if ($key) {
try {
foreach ($value in @($key.GetValue('ServerPins'))) {
if ($null -eq $value) { continue }
$pin = ([string]$value -replace '[\s:]', '').ToLowerInvariant()
if ($pin -match '^[0-9a-f]{64}$' -and $pins -notcontains $pin) { $pins += $pin }
}
} finally { $key.Dispose() }
}
} finally { $base.Dispose() }
} catch {}
}
return ,$pins
}
function Test-ShopDbServerPinsValue {
# True when ServerPins exists in either registry view, even empty. The
# registry then owns the pin list and appsettings pins are ignored, the
# same rule the agent follows.
foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64, [Microsoft.Win32.RegistryView]::Registry32)) {
try {
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $view)
try {
$key = $base.OpenSubKey('SOFTWARE\GE\ShopDB')
if ($key) {
try {
if ($key.GetValueNames() -contains 'ServerPins') { return $true }
} finally { $key.Dispose() }
}
} finally { $base.Dispose() }
} catch {}
}
return $false
}
function Enable-ShopDbServerPins([string[]]$Pins) {
# True when pinning is active. False means pins are set but could not be
# enforced; the caller must not send anything then.
if ($PSVersionTable.PSEdition -eq 'Core') {
# PowerShell 7 web cmdlets ignore ServicePointManager, so the check
# would silently not happen.
Log 'ERROR ServerPins is set; pin checking needs Windows PowerShell 5.1 (powershell.exe), not pwsh'
return $false
}
try {
if (-not ('ShopDbServerPinValidator' -as [type])) {
Add-Type -TypeDefinition $ShopDbPinSource -ErrorAction Stop
}
[ShopDbServerPinValidator]::Install($Pins)
return $true
} catch {
Log "ERROR ServerPins is set but pin checking could not be enabled: $($_.Exception.Message)"
return $false
}
}
function Get-ShopDbEndpointCredential([string]$TargetUrl) {
# Empty when there is none or it cannot be used for this URL.
if (-not (Test-Path -LiteralPath $ShopDbCredentialFile)) { return '' }
try {
Add-Type -AssemblyName System.Security -ErrorAction Stop
$protected = [System.IO.File]::ReadAllBytes($ShopDbCredentialFile)
$plain = [System.Security.Cryptography.ProtectedData]::Unprotect(
$protected, $null, [System.Security.Cryptography.DataProtectionScope]::LocalMachine)
$record = [System.Text.Encoding]::UTF8.GetString($plain) | ConvertFrom-Json
} catch {
Log "WARN could not read the endpoint credential ($ShopDbCredentialFile): $($_.Exception.Message)"
return ''
}
$credential = [string]$record.credential
if (-not $credential.StartsWith('sdbe_')) {
Log 'WARN endpoint credential file has no usable credential; ignoring it'
return ''
}
if (-not $TargetUrl.StartsWith('https://', [System.StringComparison]::OrdinalIgnoreCase)) {
Log "WARN not sending the endpoint credential over a non-https URL ($TargetUrl)"
return ''
}
# Never hand a credential to a server other than the one that issued it.
$issuer = ([string]$record.baseurl).Trim().TrimEnd('/')
if ($issuer -and -not ($TargetUrl.TrimEnd('/') + '/').StartsWith($issuer + '/', [System.StringComparison]::OrdinalIgnoreCase)) {
Log "WARN endpoint credential was issued by $issuer, not the target $TargetUrl; not sending it"
return ''
}
return $credential
}
function Get-ShopDbHttpFailure($ErrorRecord) {
# Status and error envelope code (data.error.code) from a failed request.
$status = 0
$code = ''
$text = ''
try { $status = [int]$ErrorRecord.Exception.Response.StatusCode } catch {}
if ($ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) {
$text = $ErrorRecord.ErrorDetails.Message
} else {
try {
$reader = New-Object System.IO.StreamReader($ErrorRecord.Exception.Response.GetResponseStream())
$text = $reader.ReadToEnd()
$reader.Dispose()
} catch {}
}
if ($text) {
try { $code = [string](($text | ConvertFrom-Json).data.error.code) } catch {}
}
$presented = ''
$matched = $false
if ('ShopDbServerPinValidator' -as [type]) {
$presented = [string][ShopDbServerPinValidator]::LastPresented
$matched = [ShopDbServerPinValidator]::IsPinned($presented)
}
# Innermost exception text: the outer WebException hides the real cause
# (unexpected EOF, connection reset, trust failure).
$message = ''
$exception = $ErrorRecord.Exception
while ($exception) {
if ($exception.Message) { $message = $exception.Message }
$exception = $exception.InnerException
}
return @{ Status = $status; Code = $code; PresentedPin = $presented; PinMatched = $matched; Message = $message }
}
function Write-ShopDbAuthFailure($Failure) {
switch ($Failure.Code) {
'CREDENTIAL_INVALID' {
Log ('ERROR 401 CREDENTIAL_INVALID: the server does not accept this PC''s endpoint credential ' +
'(revoked or unknown). The endpoint agent deletes it and re-enrolls on its own.')
}
'SHARED_KEY_REFUSED' {
Log ('ERROR 401 SHARED_KEY_REFUSED: the server refuses the shared key for this PC, because ' +
'the PC is enrolled or the shared key is switched off. Reports need the endpoint agent ' +
"credential: check agent 1.20+ is installed, running and enrolled ($ShopDbCredentialFile).")
}
'ASSET_MISMATCH' {
Log ('ERROR 403 ASSET_MISMATCH: this PC''s credential belongs to a different asset than the ' +
'hostname in the report. Check the hostname and the enrollment in ShopDB.')
}
default {
if ($Failure.Status -eq 0 -and $Failure.PresentedPin) {
# A pin was seen, but only an absent pin is a pin failure. A
# matched pin means the handshake passed and something later
# failed (server dropped mid-transfer, timeout).
if ($Failure.PinMatched) {
Log "ERROR request failed after the server key pin matched ($($Failure.PresentedPin)): $($Failure.Message)"
} else {
Log "ERROR TLS refused: server key pin $($Failure.PresentedPin) is not in ServerPins"
}
}
}
}
}
# Server from the GE-Enforce config hive when not passed via Args. Any site
# running this script is running the enforcement client, which cannot work
# without BaseUrl, so it is present wherever this is deployed.
@@ -102,11 +374,21 @@ if (-not $ApiKey) {
} catch {}
}
}
if (-not $ApiKey) {
Log 'ERROR no collector key (HKLM:\SOFTWARE\GE\ShopDB CollectorKey or -ApiKey). Skipping.'
$EndpointCredential = Get-ShopDbEndpointCredential $ApiUrl
if (-not $EndpointCredential -and -not $ApiKey) {
Log 'ERROR no endpoint credential and no collector key (HKLM:\SOFTWARE\GE\ShopDB CollectorKey or -ApiKey). Skipping.'
exit 0
}
$serverPins = Get-ShopDbServerPins
if ($serverPins.Count -gt 0) {
if (-not (Enable-ShopDbServerPins $serverPins)) {
Log 'ERROR refusing to report without the server pin check. Skipping.'
exit 0
}
Log "server pin check on ($($serverPins.Count) pin(s))"
}
# -AllowedRanges and the CollectorRanges registry value are ACCEPTED AND
# IGNORED. Deciding which NIC is the PC's address moved to the server, where
# the site's addressing actually lives: this script now reports every NIC and
@@ -326,14 +608,27 @@ $json = $body | ConvertTo-Json -Compress -Depth 4
Log ("POST {0} host={1} serial={2} pcType={3} make={4} model={5} os={6} boot={7} machineNo={8} user={9} ip={10} deviceId={11}" -f `
$ApiUrl, $hostname, $serialNumber, $pcType, $manufacturer, $model, $osVersion, $lastBootTime, $machineNo, $loggedInUser, $corpIp, $deviceId)
try {
$resp = Invoke-RestMethod -Uri $ApiUrl -Method Post -Body $json `
-ContentType 'application/json' `
-Headers @{ 'X-API-Key' = $ApiKey } `
-TimeoutSec $TimeoutSec -ErrorAction Stop
Log ("RESPONSE {0}" -f ($resp | ConvertTo-Json -Compress -Depth 4))
} catch {
Log "ERROR POST failed: $($_.Exception.Message)"
# Credential first; the shared key only when there is no credential, or when
# the server has just refused the credential (revoked: the shared key is
# admitted again for this PC until the agent re-enrolls). Never both at once.
$attempts = @()
if ($EndpointCredential) { $attempts += ,@('credential', @{ 'X-Endpoint-Credential' = $EndpointCredential }) }
if ($ApiKey) { $attempts += ,@('shared key', @{ 'X-API-Key' = $ApiKey }) }
foreach ($attempt in $attempts) {
try {
$resp = Invoke-RestMethod -Uri $ApiUrl -Method Post -Body $json `
-ContentType 'application/json' `
-Headers $attempt[1] `
-TimeoutSec $TimeoutSec -ErrorAction Stop
Log ("RESPONSE ({0}) {1}" -f $attempt[0], ($resp | ConvertTo-Json -Compress -Depth 4))
break
} catch {
Log "ERROR POST with $($attempt[0]) failed: $($_.Exception.Message)"
$failure = Get-ShopDbHttpFailure $_
Write-ShopDbAuthFailure $failure
if ($failure.Code -ne 'CREDENTIAL_INVALID') { break }
}
}
exit 0

View File

@@ -0,0 +1,264 @@
#requires -Version 5.1
<#
.SYNOPSIS
Seed HKLM:\SOFTWARE\GE\ShopDB\DeviceId from whatever identity a bay already
carries, so device identity can move off C:\Enrollment and into the
registry.
.DESCRIPTION
Phase 3 of docs/proposals/device-identity.md. The registry is the
destination because it survives the profile cleanup and disk hygiene that
eats C:\Enrollment, is readable as SYSTEM with no file ACL to get wrong, and
is already where the collector looks for BaseUrl and ApiToken.
This script does the one-time backfill: it reads the identity a bay holds
today and writes it to DeviceId. It is idempotent and safe to run every
GE-Enforce cycle.
THE SOURCE DEPENDS ON THE PC TYPE, AND THAT IS THE WHOLE POINT.
DeviceId names the DEVICE hanging off this PC. A machine number is a
different fact - "which bay is this" rather than "which device is this" -
and the two coincide for exactly one family:
CMM bays the machine number IS the instrument id (CMM4), which is
why cmmid.txt has always doubled as identity. Copying it
is correct.
part markers the machine number is the OPERATION (0613), and several
markers serve one operation. Copying it would name the
operation as the device. The collector refuses that
("asset X is not a Part Marker; not linked") because a
machine filed under a device label reads as a marker
everywhere downstream. So machine number is NEVER a
source here.
Keyence, no per-bay id exists at all. asset-id.txt is the only
Genspect, identity these have ever had.
wax-trace
everything not a device bay. DeviceId is not consumed for these
else pc-types, so writing one is a landmine for the day that
pc-type gains a device spec. Writes nothing.
9999 is the unassigned placeholder and is skipped everywhere, matching
Report-AssetToShopDB.ps1 and the server.
.PARAMETER Force
Overwrite a DeviceId that is already set to a DIFFERENT value.
Off by default on purpose. asset-id.txt is written ONCE at imaging and
nothing updates it when a device is swapped, so a stale file overwriting a
corrected registry value would re-introduce the exact defect this move is
meant to fix. A disagreement is reported and left alone for a human.
.PARAMETER PCType
Override the pc-type instead of reading C:\Enrollment\pc-type.txt. For
testing and for a bay whose file is missing.
.PARAMETER DeviceId
Write this value directly, skipping resolution. Still honours -Force and
the 9999 rule.
.PARAMETER LogPath
Append a transcript here as well as writing to the pipeline.
.EXAMPLE
.\Set-ShopDBDeviceId.ps1 -WhatIf
Show what would be written on this bay without touching the registry.
.EXAMPLE
.\Set-ShopDBDeviceId.ps1
Seed DeviceId. Safe to run repeatedly and from a GE-Enforce PS1 entry.
.NOTES
Exit codes:
0 wrote a value, it was already correct, or this is not a device bay
1 an error stopped the write
2 an existing DeviceId disagrees with the resolved value; needs a human
or -Force
#>
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[switch]$Force,
[string]$PCType,
[string]$DeviceId,
[string]$LogPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$RegPath = 'HKLM:\SOFTWARE\GE\ShopDB'
$RegValueName = 'DeviceId'
$Placeholder = '9999'
function Log {
param([string]$Message)
$line = "{0} [Set-ShopDBDeviceId] {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message
Write-Output $line
if ($LogPath) {
try { Add-Content -LiteralPath $LogPath -Value $line -ErrorAction Stop } catch { }
}
}
function Read-FirstLine {
# One line, trimmed, empty when absent or unreadable. A bay missing a file
# is the normal case, not an error.
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) { return '' }
try {
$v = [string](Get-Content -LiteralPath $Path -First 1 -ErrorAction Stop)
return $v.Trim()
} catch {
Log "WARN could not read ${Path}: $($_.Exception.Message)"
return ''
}
}
function Get-MachineNoFromRegistry {
# eDNC/NTLARS, WOW6432Node first, matching Report-AssetToShopDB.ps1.
foreach ($rp in @(
'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General',
'HKLM:\SOFTWARE\GE Aircraft Engines\DNC\General'
)) {
try {
if (Test-Path $rp) {
$v = [string](Get-ItemProperty -Path $rp -Name MachineNo -ErrorAction Stop).MachineNo
if ($v) {
$v = $v.Trim()
if ($v -and $v -ne $Placeholder) { return $v }
}
}
} catch {
Log "WARN could not read MachineNo from ${rp}: $($_.Exception.Message)"
}
}
return ''
}
# Which pc-types drive a device, and whether the machine number is a legitimate
# identity for that device. Mirrors SUBORDINATE_DEVICE_MAP in
# plugins/computers/pctypemap.py. A site that added its own device pc-type
# through a subordinatedevice_<pctype> setting adds it here too.
$DeviceFamilies = @{
'gea-shopfloor-cmm' = @{ Label = 'CMM'; MachineNoIsDevice = $true }
'gea-shopfloor-keyence' = @{ Label = 'Vision System'; MachineNoIsDevice = $false }
'gea-shopfloor-genspect' = @{ Label = 'Genspect'; MachineNoIsDevice = $false }
'gea-shopfloor-waxtrace' = @{ Label = 'Form Tracer'; MachineNoIsDevice = $false }
'gea-shopfloor-partmarker'= @{ Label = 'Part Marker'; MachineNoIsDevice = $false }
}
# --- 1. what kind of bay is this -------------------------------------------
if (-not $PCType) { $PCType = Read-FirstLine 'C:\Enrollment\pc-type.txt' }
if (-not $PCType) {
Log 'no pc-type on this PC (C:\Enrollment\pc-type.txt missing or empty); nothing to do'
exit 0
}
if (-not $DeviceFamilies.ContainsKey($PCType)) {
Log "pc-type '$PCType' does not drive a device; DeviceId not written"
exit 0
}
$family = $DeviceFamilies[$PCType]
Log "pc-type '$PCType' drives a $($family.Label)"
# --- 2. resolve the identity ------------------------------------------------
$resolved = ''
$source = ''
if ($DeviceId) {
$resolved = $DeviceId.Trim()
$source = '-DeviceId parameter'
} else {
# Most stable identity first, and the machine number only where it really
# names the device.
if ($family.MachineNoIsDevice) {
$v = Read-FirstLine 'C:\Enrollment\cmm\cmmid.txt'
if ($v -and $v -ne $Placeholder) { $resolved = $v; $source = 'C:\Enrollment\cmm\cmmid.txt' }
if (-not $resolved) {
$v = Get-MachineNoFromRegistry
if ($v) { $resolved = $v; $source = 'eDNC MachineNo registry' }
}
}
if (-not $resolved) {
$v = Read-FirstLine 'C:\Enrollment\asset-id.txt'
if ($v -and $v -ne $Placeholder) { $resolved = $v; $source = 'C:\Enrollment\asset-id.txt' }
}
if (-not $resolved) {
# The 0.12.0 name, before the file was generalised.
$v = Read-FirstLine 'C:\Enrollment\measuringtool-id.txt'
if ($v -and $v -ne $Placeholder) { $resolved = $v; $source = 'C:\Enrollment\measuringtool-id.txt' }
}
}
if (-not $resolved) {
# Nothing to copy. Silence is the right answer: the collector still adopts
# or mints as it does today, and inventing a value here would name a device
# that nobody can account for.
Log "no identity found for this $($family.Label) bay; DeviceId not written"
exit 0
}
if ($resolved -eq $Placeholder) {
Log "resolved value is the $Placeholder placeholder; DeviceId not written"
exit 0
}
Log "resolved DeviceId '$resolved' from $source"
# --- 3. write it ------------------------------------------------------------
$existing = ''
try {
if (Test-Path $RegPath) {
$prop = Get-ItemProperty -Path $RegPath -Name $RegValueName -ErrorAction SilentlyContinue
if ($prop) { $existing = [string]$prop.$RegValueName; $existing = $existing.Trim() }
}
} catch {
Log "WARN could not read existing ${RegValueName}: $($_.Exception.Message)"
}
if ($existing -eq $resolved) {
Log "DeviceId already '$resolved'; nothing to do"
exit 0
}
if ($existing -and -not $Force) {
# The registry value wins by default. C:\Enrollment is written once at
# imaging and never updated on a device swap, so the file is the more
# likely one to be wrong.
Log "REFUSING to overwrite: DeviceId is '$existing', $source says '$resolved'"
Log 'Re-run with -Force if the file is right, or fix the file if the registry is.'
exit 2
}
try {
if (-not (Test-Path $RegPath)) {
if ($PSCmdlet.ShouldProcess($RegPath, 'Create registry key')) {
New-Item -Path $RegPath -Force | Out-Null
Log "created $RegPath"
}
}
$action = if ($existing) { "Set $RegValueName '$existing' -> '$resolved'" }
else { "Set $RegValueName '$resolved'" }
if ($PSCmdlet.ShouldProcess($RegPath, $action)) {
New-ItemProperty -Path $RegPath -Name $RegValueName -Value $resolved `
-PropertyType String -Force | Out-Null
Log "wrote $RegPath\$RegValueName = '$resolved'"
} else {
Log "WHATIF would write $RegPath\$RegValueName = '$resolved'"
}
} catch {
Log "ERROR could not write ${RegValueName}: $($_.Exception.Message)"
exit 1
}
exit 0

View File

@@ -17,9 +17,13 @@
v-for="opt in filteredCommands"
:key="opt.action"
class="action-option"
:class="{ 'action-locked': opt.mayauthor === false }"
@mousedown.prevent="selectCommand(opt)"
>
{{ opt.label || opt.action }}
<span v-if="opt.mayauthor === false" class="action-lock-note">
needs {{ opt.requiredpermission }}
</span>
</div>
<div
class="action-option action-branch"
@@ -171,6 +175,9 @@ function enterMonitorLevel() {
}
function selectCommand(opt) {
// The server refuses this action anyway; declining here turns a confusing 403
// on save into an obvious "you cannot pick that" at the point of choosing.
if (opt.mayauthor === false) return
emit('update:modelValue', opt.action)
open.value = false
level.value = 'command'
@@ -254,6 +261,8 @@ function cancelCreate() {
}
.action-option { padding: 8px 12px; cursor: pointer; color: var(--text); }
.action-option:hover { background: var(--bg); }
.action-locked { opacity: 0.55; cursor: not-allowed; }
.action-lock-note { font-size: 0.75rem; color: var(--text-light); margin-left: 6px; }
.action-branch { font-style: italic; }
.action-create { font-weight: 600; color: var(--primary); }
.action-divider { height: 1px; background: var(--border); margin: 2px 0; }

View File

@@ -42,8 +42,8 @@
</span>
</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" :disabled="p.source !== 'local'" @click="openModal(p)">Edit</button>
<button class="btn btn-danger btn-sm" :disabled="p.source !== 'local'" @click="remove(p)">Delete</button>
<button class="btn btn-secondary btn-sm" :disabled="!iseditable(p)" @click="openModal(p)">Edit</button>
<button class="btn btn-danger btn-sm" :disabled="!iseditable(p)" @click="remove(p)">Delete</button>
</td>
</tr>
<tr v-if="policies.length === 0">
@@ -140,7 +140,7 @@ const error = ref('')
const form = ref(emptyForm())
function emptyForm() {
return { name: '', description: '', scopejson: { type: 'true' }, actions: [] }
return { name: '', description: '', scopejson: { type: 'allpcs' }, actions: [] }
}
onMounted(() => loadData())
@@ -174,7 +174,9 @@ async function loadData() {
}
if (types.status === 'fulfilled') {
pctypes.value = types.value.data.data || []
// Plain array, same as applications below. Reading .data.data threw
// inside this try, so apps, actions and monitors never got assigned.
pctypes.value = types.value || []
} else {
failures.push('PC types')
console.error('Error loading PC types:', types.reason)
@@ -219,7 +221,7 @@ function openModal(item = null) {
form.value = {
name: item.name || '',
description: item.description || '',
scopejson: item.scopejson ? JSON.parse(JSON.stringify(item.scopejson)) : { type: 'true' },
scopejson: item.scopejson ? JSON.parse(JSON.stringify(item.scopejson)) : { type: 'allpcs' },
actions: (item.actions || []).map(a => ({
action: a.action, mode: a.mode || 'gate',
desiredstate: a.desiredstate, params: a.params, intervalminutes: a.intervalminutes
@@ -285,6 +287,18 @@ async function save() {
}
}
// Mirrors the API guard in plugins/computers/api/policy_routes.py: local,
// autoapp and shipped rows are all editable. A shipped row is a starting point
// the product supplies, not a fixed opinion, and it keeps source='shipped' so
// provenance survives the edit. Only a genuinely external managed owner is
// read-only. Disabling on source==='local' alone stranded shipped rows behind
// dead buttons while the API was accepting the write.
const EDITABLE_SOURCES = ['local', 'autoapp', 'shipped']
function iseditable(p) {
return EDITABLE_SOURCES.includes(p.source)
}
async function remove(p) {
if (!confirm(`Delete configuration policy "${p.name}"?`)) return
try {

File diff suppressed because it is too large Load Diff

View File

@@ -573,12 +573,14 @@ onMounted(async () => {
operatingsystemsApi.list({ perpage: 100 }),
computersApi.protocols.list(),
businessunitsApi.listAll(),
// Handles its own failure: a site without printers still gets a form.
])
pcTypes.value = ptRes.data.data || []
statuses.value = statusRes.data.data || []
vendors.value = vendorRes.data.data || []
// listAll() pages past the server's 100-row cap and returns the ROWS, not
// an axios response, so these are read directly. Reading .data.data here
// throws and takes the whole form down with it.
vendors.value = vendorRes || []
models.value = allModels
locations.value = locRes || []
operatingsystems.value = osRes.data.data || []
@@ -636,7 +638,11 @@ onMounted(async () => {
}
} catch (err) {
console.error('Error loading data:', err)
error.value = 'Failed to load data'
// A client-side TypeError has no err.response, so apiError() would report
// only the fallback and hide which field broke. Name it either way.
error.value = err?.response
? apiError(err, 'Failed to load data')
: `Failed to load data: ${err?.message || err}`
} finally {
loading.value = false
}

View File

@@ -7,16 +7,14 @@
<div class="section-card">
<div class="setting-group">
<p class="setting-description">
This page has been retired. Imaging pc-type handling now lives in
GE-Enforce, where each imaging PC type is a manifest scope with its own
Computer Type. Manage it from the GE-Enforce section instead.
This page has been retired. An imaging PC type is now decided by
whatever provisions the machine, not configured here.
</p>
<p class="setting-description">
The collector still applies the built-in pc-type to Computer Type
defaults, so existing enrollment keeps working; there is nothing to
configure here anymore.
</p>
<RouterLink class="btn" to="/geenforce/manifests">Open GE-Enforce</RouterLink>
</div>
</div>
</div>
@@ -24,7 +22,7 @@
<script setup>
// Deprecated page. Kept only so the /settings/pctypemapping route and old
// bookmarks still resolve; GE-Enforce (scope computertypeid) supersedes the old
// collector pc-type mapping UI. See ADR-012.
import { RouterLink } from 'vue-router'
// bookmarks still resolve. The "open the tool that replaced this" link went
// with the plugin that served it, and a button to a route this build has no
// page for is worse than no button.
</script>

View File

@@ -107,8 +107,9 @@ onMounted(() => loadData())
async function loadData() {
loading.value = true
try {
const response = await computersApi.types.listAll({ active: false })
pcTypes.value = response.data.data || []
// listAll() pages past the server's 100-row cap and returns the ROWS, not
// an axios response - there is no .data to unwrap.
pcTypes.value = await computersApi.types.listAll({ active: false })
} catch (err) {
console.error('Error loading PC types:', err)
} finally {

View File

@@ -33,6 +33,7 @@
<thead>
<tr>
<th>Asset Tag</th>
<th>Alias</th>
<th>Hostname</th>
<th>Serial Number</th>
<th>PC Type</th>
@@ -45,6 +46,7 @@
<tbody>
<tr v-for="item in computers" :key="item.assetid" class="clickable-row" @click="$router.push(`/pcs/${item.computer?.computerid || item.assetid}`)">
<td>{{ item.assetnumber }}</td>
<td>{{ item.name || '-' }}</td>
<td>{{ item.computer?.hostname || '-' }}</td>
<td class="mono">{{ item.serialnumber || '-' }}</td>
<td>{{ item.computer?.computertypename || '-' }}</td>

View File

@@ -2,7 +2,8 @@
<div class="scope-node">
<div class="scope-node-row">
<select v-model="node.type" class="form-control scope-type" @change="onTypeChange">
<option value="true">Matches everything</option>
<option value="allpcs">All PCs (excludes servers)</option>
<option v-if="node.type === 'true'" value="true">Matches everything (incl. servers)</option>
<option value="pctype">PC type is</option>
<option value="app">App is installed</option>
<option value="and">ALL of (AND)</option>
@@ -71,20 +72,20 @@ function onTypeChange() {
delete node.nodes
delete node.node
if (node.type === 'and' || node.type === 'or') {
node.nodes = [{ type: 'true' }]
node.nodes = [{ type: 'allpcs' }]
} else if (node.type === 'not') {
node.node = { type: 'true' }
node.node = { type: 'allpcs' }
}
}
function addChild() {
props.node.nodes.push({ type: 'true' })
props.node.nodes.push({ type: 'allpcs' })
}
function removeChild(index) {
props.node.nodes.splice(index, 1)
if (props.node.nodes.length === 0) {
props.node.nodes.push({ type: 'true' })
props.node.nodes.push({ type: 'allpcs' })
}
}
</script>

View File

@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'fs'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
const here = dirname(fileURLToPath(import.meta.url))
const view = readFileSync(join(here, 'ConfigurationPoliciesList.vue'), 'utf8')
// The API decides what may be edited; the buttons only reflect that decision.
// These drifted once: policy_routes.py was widened to accept shipped rows while
// the buttons still disabled on source !== 'local', so every shipped policy sat
// behind a dead Edit button and the change looked like it had never rolled.
// If the API guard changes, EDITABLE_SOURCES here changes with it.
describe('the configuration policy buttons follow the API guard', () => {
it('treats shipped as editable, not read-only', () => {
expect(view).toContain("EDITABLE_SOURCES = ['local', 'autoapp', 'shipped']")
})
it('does not gate the buttons on local alone', () => {
expect(view).not.toContain(":disabled=\"p.source !== 'local'\"")
})
it('gates edit and delete on the same rule', () => {
const disabled = view.match(/:disabled="!iseditable\(p\)"/g) || []
expect(disabled).toHaveLength(2)
})
})

View File

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

View File

@@ -0,0 +1,95 @@
/**
* Run Scripts tab on PC Details.
*
* The thing worth pinning here is the SEPARATION. run_script and
* scheduledtask_run map to different grants in plugins/endpoints/commands.py
* (endpoints.runscript and endpoints.corptools), split out of a single
* endpoints.deploy bundle precisely so that being able to trigger a corporate
* tool does not also mean being able to run arbitrary PowerShell as SYSTEM.
*
* That split lives in the backend. The UI can quietly undo it by hanging the
* script picker off the Corporate Tools tab, whose only gate is
* endpoints.corptools - and the failure would be invisible in review, because
* the page would look correct and the server would simply 403 the push. These
* tests fail if someone merges the two.
*
* Asserted against the file text, matching systemScriptsSettings.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')
describe('PCDetail Run Scripts tab', () => {
it('gates the tab on endpoints.runscript, not the Corporate Tools grant', () => {
expect(source).toMatch(
/const canRunScript = computed\(\(\) => authStore\.hasPermission\('endpoints\.runscript'\)\)/)
})
it('pushes run_script from its own tab, not from the Corporate Tools block', () => {
// The corptools block must not gain a run_script call: that would put the
// action behind endpoints.corptools in the UI while the server still
// demands endpoints.runscript.
const corptools = source.slice(
source.indexOf("activegroup === 'corptools'"),
source.indexOf("activegroup === 'runscripts'"))
expect(corptools).not.toMatch(/run_script/)
expect(source).toMatch(/activegroup === 'runscripts'/)
})
it('enqueues run_script with a script-id target', () => {
expect(source).toMatch(/'run_script', `script:\$\{script\.scriptid\}`/)
})
it('requires a typed confirmation before running', () => {
// Every script on this tab is arbitrary code, so unlike Corporate Tools
// there is no safe branch that gets a plain OK button.
const handler = source.slice(source.indexOf('async function confirmRunScript'))
const body = handler.slice(0, handler.indexOf('\n}\n'))
expect(body).toMatch(/Type RUN to confirm/)
expect(body).toMatch(/typed !== 'RUN'/)
})
it('warns that a script runs as SYSTEM before it is sent', () => {
const tab = source.slice(source.indexOf("activegroup === 'runscripts'"))
expect(tab.slice(0, 2000)).toMatch(/runs as SYSTEM/)
})
it('shows the run context and working directory read-only', () => {
// Both are authoring decisions made in the library. Offering an override
// here would mean the script that ran is not the script that was reviewed,
// so they must be rendered as facts and never bound with v-model.
const tab = source.slice(source.indexOf("activegroup === 'runscripts'"))
const block = tab.slice(0, tab.indexOf('runscripts-actions'))
expect(block).toMatch(/selectedscript\.workingdirectory/)
expect(block).toMatch(/selectedscript\.runcontext/)
expect(block).not.toMatch(/v-model="[^"]*workingdirectory/)
expect(block).not.toMatch(/v-model="[^"]*runcontext/)
})
it('offers only scripts that have a body', () => {
// A bodyless script is rejected at enqueue by _resolve_run_script, so
// listing it would offer a button whose only outcome is an error.
expect(source).toMatch(/filter\(\(script\) => script\.hasbody\)/)
})
it('reports the push as queued rather than as finished', () => {
// The agent collects the command on its next check-in; claiming it ran
// would assert an outcome nothing has observed.
const handler = source.slice(source.indexOf('async function confirmRunScript'))
expect(handler.slice(0, handler.indexOf('\n}\n'))).toMatch(/queued for/)
})
it('styles the Run Scripts tab from defined theme tokens only', () => {
// --text-primary/--text-secondary/--bg-secondary are not defined by this
// app's theme. var() then falls through to the fallback, or to inherit,
// which reads fine in light mode and misrenders for dark-mode users - the
// failure only shows up on the theme most of the shop floor runs.
const runscripts = source.slice(source.indexOf('.corptools-intro'))
expect(runscripts).not.toMatch(/--text-primary|--text-secondary|--bg-secondary|--surface/)
expect(runscripts).not.toMatch(/var\(--[a-z-]+,\s*#/)
})
})

View File

@@ -25,7 +25,7 @@ depends_on = None
# TEXT rather than sa.JSON on purpose. MySQL 5.6 has no JSON type - it arrived
# in 5.7 - and this migration failed there with a bare syntax error near the
# word JSON. Every other JSON-bearing column in the product is TEXT for the same
# reason (guides.blocksjson, geenforce.manifestjson); the model reads and writes
# reason (guides.blocksjson); the model reads and writes
# dicts through the JSONText type decorator, so nothing above the database
# changes.

View File

@@ -0,0 +1,68 @@
"""Add policyactionholds (break-glass desired-state suppression).
Backs the admin override: when an admin break-glasses past a policy denial on a
desired-mode action, a hold row suspends reconciliation of that action on that
endpoint until expiresat, so the override is not immediately undone by the next
drift-correcting enqueue.
Rows are never swept: expiry is evaluated at resolve time against expiresat, and
an expired row stays as the audit record of what was suppressed and why.
Idempotent: the create_table is skipped when the table already exists (e.g. a
test DB built by db.create_all()).
Revision ID: computers0005policyactionholds
Revises: computers0004policies
"""
from alembic import op
import sqlalchemy as sa
revision = 'computers0005policyactionholds'
down_revision = 'computers0004policies'
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
existing = set(insp.get_table_names())
if 'policyactionholds' not in existing:
op.create_table(
'policyactionholds',
sa.Column('policyactionholdid', sa.Integer(), nullable=False),
sa.Column('assetid', sa.Integer(), nullable=False),
sa.Column('action', sa.String(length=100), nullable=False),
sa.Column('expiresat', sa.DateTime(), nullable=False),
sa.Column('createdbyuserid', sa.Integer(), nullable=True),
sa.Column('createdbyusername', sa.String(length=100), nullable=True),
sa.Column('reason', sa.Text(), nullable=False),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False,
server_default=sa.true()),
sa.PrimaryKeyConstraint('policyactionholdid'),
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'],
ondelete='CASCADE'),
sa.ForeignKeyConstraint(['createdbyuserid'], ['users.userid'],
ondelete='SET NULL'),
)
op.create_index('idx_policyactionhold_asset', 'policyactionholds',
['assetid'])
op.create_index('idx_policyactionhold_expiry', 'policyactionholds',
['expiresat'])
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
existing = set(insp.get_table_names())
if 'policyactionholds' in existing:
op.drop_index('idx_policyactionhold_expiry',
table_name='policyactionholds')
op.drop_index('idx_policyactionhold_asset',
table_name='policyactionholds')
op.drop_table('policyactionholds')

View File

@@ -0,0 +1,85 @@
"""Allow indefinite policy action holds (policyactionholds.expiresat nullable).
0005 created expiresat NOT NULL because every hold was time-boxed: a hold that
nobody remembered to clear would expire on its own and policy would resume. That
self-healing property was the point of the column.
Admins now need to hold an action for work that cannot be bounded up front, so a
null expiresat is permitted and means indefinite - the hold stands until it is
released by hand. Everything reading this table must test
`expiresat IS NULL OR expiresat > now`; testing only the comparison silently
drops indefinite holds and lets policy resume against an admin who asked it not
to.
No backfill: existing rows all carry a real expiry and keep it. Widening
NOT NULL to NULL rewrites no data.
Idempotent: the alter is skipped when the column is already nullable, so a test
DB built by db.create_all() from the current model is left alone.
Revision ID: computers0006holdindefinite
Revises: computers0005policyactionholds
The id is kept short deliberately: alembic_version.version_num is VARCHAR(32),
and a longer id applies the DDL and then fails on the stamp, leaving the DB
migrated but recorded as un-migrated.
"""
from alembic import op
import sqlalchemy as sa
revision = 'computers0006holdindefinite'
down_revision = 'computers0005policyactionholds'
branch_labels = None
depends_on = None
def _expiresat_is_nullable(bind):
"""True when policyactionholds.expiresat already permits null.
Returns None when the table is absent entirely, which lets both directions
no-op rather than raising on a DB where the computers chain has not built
the table yet.
"""
insp = sa.inspect(bind)
if 'policyactionholds' not in set(insp.get_table_names()):
return None
for column in insp.get_columns('policyactionholds'):
if column['name'] == 'expiresat':
return bool(column.get('nullable'))
return None
def upgrade():
bind = op.get_bind()
nullable = _expiresat_is_nullable(bind)
if nullable is None or nullable:
return
op.alter_column('policyactionholds', 'expiresat',
existing_type=sa.DateTime(),
nullable=True)
def downgrade():
"""Re-impose NOT NULL, giving any indefinite hold a bounded expiry.
A straight alter would fail on the rows this feature exists to create, so
indefinite holds are first stamped with the current time - expiring them
rather than destroying them. That is the conservative direction: on
downgrade, policy resuming is safer than reconciliation staying suppressed
by a row the older code cannot represent. The audit row survives either way.
"""
bind = op.get_bind()
nullable = _expiresat_is_nullable(bind)
if nullable is None or not nullable:
return
op.execute(sa.text(
'UPDATE policyactionholds '
'SET expiresat = CURRENT_TIMESTAMP '
'WHERE expiresat IS NULL'))
op.alter_column('policyactionholds', 'expiresat',
existing_type=sa.DateTime(),
nullable=False)

View File

@@ -14,8 +14,11 @@ from .policy import (
BusinessPolicyItem,
POLICY_SOURCE_LOCAL,
POLICY_SOURCE_SHIPPED,
POLICY_SOURCE_AUTOAPP,
ACTION_MODE_DESIRED,
ACTION_MODE_GATE,
PolicyActionHold,
DEFAULT_HOLD_MINUTES,
)
__all__ = [
@@ -30,6 +33,9 @@ __all__ = [
'BusinessPolicyItem',
'POLICY_SOURCE_LOCAL',
'POLICY_SOURCE_SHIPPED',
'POLICY_SOURCE_AUTOAPP',
'ACTION_MODE_DESIRED',
'ACTION_MODE_GATE',
'PolicyActionHold',
'DEFAULT_HOLD_MINUTES',
]

View File

@@ -3,7 +3,7 @@
MySQL 5.6 has no JSON type - it arrived in 5.7 - and a site is still running
5.6, where `sa.JSON()` fails at migration time with a bare syntax error near the
word JSON. Every other JSON-bearing column in this product is already TEXT for
that reason: guides' `blocksjson`, GE-Enforce's `manifestjson`.
that reason: guides' `blocksjson`, backups' payload column.
This keeps the Python side identical. A model attribute still holds a dict or a
list, still assigns and reads like one; only the storage changes. The

View File

@@ -27,12 +27,35 @@ from .jsontext import JSONText
# owner. Kept a plain string, not an enum column, so adding a source is data.
POLICY_SOURCE_LOCAL = 'local'
# Product-owned rows we ship (the shopfloor app monitor bundle). Same read-only
# treatment as any non-local source: the CRUD API refuses to edit or delete
# them, the seeder reconciles them. Distinct from 'local' so a site's own
# policies are never confused with what the product shipped.
# Rows the product seeds (the shopfloor monitor policies, one per monitor).
# Distinct from 'local' so a site's own policies are never confused with what
# the product shipped, and so the UI can badge the origin.
#
# A shipped row is EDITABLE and DELETABLE, unlike a genuinely external source.
# The product ships a starting point, not an opinion about one site's floor: a
# plant that runs no MTConnect must be able to drop that policy. source stays
# 'shipped' through an edit, so provenance is a fact about where the row came
# from rather than a claim about who controls it now.
#
# Nothing reconciles these rows. The seeding migrations are insert-if-absent
# (they SELECT by name and return early if it exists), so an upgrade cannot
# stomp a site's edits. That property is load-bearing now that the rows are
# editable - do not replace it with a reconciling seeder without adding a
# dirty flag first.
POLICY_SOURCE_SHIPPED = 'shipped'
# Rows generated FOR US by another surface rather than authored by hand: today
# the "Required on all PCs" checkbox on an Application. Distinct from 'local' so
# unchecking the box knows which policy it owns and may safely remove.
#
# It doubles as the "untouched" test. An autoapp row is one nobody has
# customised, so it is safe to delete when the box is unchecked. The moment an
# admin edits it through the policy CRUD API it becomes 'local' - their work
# now, not ours - and unchecking warns instead of deleting. That keeps the
# generated policy convenient without letting it silently destroy hand-authored
# changes.
POLICY_SOURCE_AUTOAPP = 'autoapp'
class ConfigurationPolicy(BaseModel):
"""A single reusable policy: a scope plus the actions it applies.
@@ -202,3 +225,82 @@ class BusinessPolicyItem(BaseModel):
def __repr__(self):
return (f"<BusinessPolicyItem bp={self.businesspolicyid} "
f"cp={self.configurationpolicyid}>")
# Default life of a break-glass suppression hold, used when the caller names no
# window. Long enough to comfortably outlast one maintenance window, short
# enough that nobody has to remember to clear it: policy resumes on its own.
#
# A caller MAY name any window instead, including none at all (expiresat NULL,
# an indefinite hold). That is a deliberate escape hatch for work that cannot be
# bounded up front, and it forfeits the self-healing this default exists to
# provide - see the class docstring.
DEFAULT_HOLD_MINUTES = 240
class PolicyActionHold(BaseModel):
"""A time-boxed suspension of desired-state reconciliation for one action.
An admin break-glass override on a `gate`-mode action is a one-shot - policy
only said "not exposed", and nothing reverses it. An override on a
`desired`-mode action is a fight: the admin stops a service and the
reconciliation loop restarts it on the next check-in, so the override looks
broken in production. A hold records that fight's outcome server-side, and
the resolver drops the held action out of the desired set until it expires.
Expiry is evaluated at RESOLVE time against expiresat, never by a sweeper
job. A sweeper that stalls would silently extend every hold it failed to
clear, which is policy disabled by accident - the failure mode this design
exists to avoid. Rows are left behind after expiry deliberately: an expired
hold is the audit record of what was suppressed, by whom, and why.
expiresat is NULLABLE, and null means indefinite: the hold applies until an
admin releases it by hand. This is the one case where the self-healing
property above does not hold, so treat it as the sharp edge it is - an
indefinite hold silently disables reconciliation of that action forever, and
the endpoint will keep reporting healthy while it drifts. Nothing in the
system surfaces a stale hold; the only guard is the admin who placed it.
Every read of this table must therefore test `expiresat IS NULL OR
expiresat > now`. Testing only the comparison drops indefinite holds, which
would let policy quietly resume against an admin who asked it not to.
"""
__tablename__ = 'policyactionholds'
policyactionholdid = db.Column(db.Integer, primary_key=True)
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
nullable=False,
)
# Allowlisted action name, same vocabulary as ConfigurationPolicyAction.
action = db.Column(db.String(100), nullable=False)
# Naive UTC, matching BaseModel timestamps. Null means indefinite - the
# hold stands until released by hand.
expiresat = db.Column(db.DateTime, nullable=True)
# Who placed it. The id goes null if the user is deleted, but the username
# snapshot survives: a break-glass record that forgets who used it when the
# account is removed is worthless at review time.
createdbyuserid = db.Column(
db.Integer,
db.ForeignKey('users.userid', ondelete='SET NULL'),
nullable=True,
)
createdbyusername = db.Column(db.String(100), nullable=True)
reason = db.Column(db.Text, nullable=False)
__table_args__ = (
db.Index('idx_policyactionhold_asset', 'assetid'),
db.Index('idx_policyactionhold_expiry', 'expiresat'),
)
def __repr__(self):
until = self.expiresat or 'indefinite'
return (f"<PolicyActionHold asset={self.assetid} {self.action} "
f"until={until}>")

View File

@@ -6,7 +6,7 @@ import logging
from pathlib import Path
from typing import List, Dict, Optional, Type
from flask import Flask, Blueprint
from flask import Flask, Blueprint, g
import click
from shopdb.plugins.base import BasePlugin, PluginMeta
@@ -218,6 +218,16 @@ class ComputersPlugin(BasePlugin):
self._register_import_hook()
logger.info(f"Computers plugin initialized (v{self.meta.version})")
def get_settings_routes(self) -> List[Dict]:
"""Register Operating Systems management page in Model Catalog."""
return [{
'hub': 'model-catalog',
'path': 'operatingsystems',
'name': 'model-catalog-os',
'tab': 'Operating Systems',
'component': '.plugins-staged/computers/views/OperatingSystemsList.vue'
}]
# -- bulk-import machine link -------------------------------------------
def _register_import_hook(self):
@@ -444,6 +454,7 @@ class ComputersPlugin(BasePlugin):
"""
return {
'identityfield': 'hostname',
'acceptsagentcredential': True,
'fields': {
'hostname': {'type': 'string', 'required': True},
'machinenumber': {'type': 'string'},
@@ -714,6 +725,13 @@ class ComputersPlugin(BasePlugin):
comp = (Computer.query.join(Asset, Asset.assetid == Computer.assetid)
.filter(Asset.assetnumber.ilike(hostname)).first())
# A per-PC credential reports for its own PC only, and never creates one.
if (getattr(g, 'agentauth', None) == 'credential'
and (comp is None
or comp.assetid != getattr(g, 'agentassetid', None))):
raise PermissionError(
'The payload names a different endpoint than the credential')
action = 'updated'
if not comp:
atype = AssetType.query.filter_by(assettype='computer').first()
@@ -1721,6 +1739,14 @@ class ComputersPlugin(BasePlugin):
rel.isactive = False
return []
# Plugin-optional by contract, and on this line the option is OFF: no
# measuringtools plugin is bundled, so this import always fails and a
# metrology PC reports as an ordinary PC with no instrument asset and no
# tool link. That is the right answer here - ADR-018 says a CMM is a
# machine - and the whole branch stays because the contract is that a
# site MAY install a measuringtools plugin of its own, at which point
# this adopts and links exactly as it always did. The warning rides back
# on the collector response so the silence is at least explained.
try:
from plugins.measuringtools.models import MeasuringTool
except ImportError:
@@ -2307,3 +2333,16 @@ class ComputersPlugin(BasePlugin):
today should weigh it.
"""
return ['computers.view']
def get_asset_type_descriptor(self):
"""Return descriptor for computer types management in Asset Type Hub."""
return {
'assettype': 'computer',
'subtype_endpoint': '/computers/types',
'subtype_id_field': 'computertypeid',
'subtype_label_field': 'computertype',
'subtype_description_field': 'description',
'subtype_color_field': 'color',
'optional_tabs': [],
'edit_permission': 'computers.manage',
}

View File

@@ -0,0 +1,202 @@
"""Generate a Configuration Policy from an Application's "Required on all PCs".
Ticking that box on an application should not merely flag it in the compliance
report - it should actually enforce the app onto every PC. This module owns the
one policy that represents that intent, so the applications API does not have to
know how policies are shaped.
Ownership and the "untouched" rule
----------------------------------
A generated policy carries source=POLICY_SOURCE_AUTOAPP, which marks it as ours
to manage. Unticking the box removes it - but ONLY while it still carries that
source. If an admin edits the policy through the policy CRUD API it flips to
'local', and from then on unticking leaves it alone and reports that it was
kept. Deleting someone's customised enforcement rule because a checkbox changed
on a different screen would be a genuinely destructive surprise, and the box is
not a reliable signal that they wanted that.
Not attached to a Business Policy
---------------------------------
The generated policy is NOT added to any Business Policy. The resolver only
applies Configuration Policies reached through an active Business Policy, so
until an admin adds it to one, the generated policy installs nothing. The API
response and the acknowledgement text say so rather than promising a fleet
install that will not happen.
Scope
-----
Generated policies use the `allpcs` node, not `true`: "required on all PCs"
means the PCs people work at, and pushing an installer to a server because it
appeared in the app catalog is not what the checkbox says. See policyscope.py.
"""
from shopdb.api import db
from ..models import (
ConfigurationPolicy,
ConfigurationPolicyAction,
POLICY_SOURCE_AUTOAPP,
POLICY_SOURCE_LOCAL,
ACTION_MODE_DESIRED,
)
# The action a generated policy carries. update_app is the allowlisted verb that
# installs a pinned app package on an endpoint (plugins/endpoints/commands.py),
# so it is what "this app is required here" reduces to in enforcement terms.
REQUIRED_APP_ACTION = 'update_app'
# Name prefix for generated rows. ConfigurationPolicy.name is unique and NOT
# NULL, so generated names must be deterministic (we have to find the row again)
# and distinctive enough not to collide with a hand-authored policy.
AUTO_POLICY_PREFIX = 'Required app:'
def auto_policy_name(application):
"""The deterministic policy name for an application."""
return '{} {}'.format(AUTO_POLICY_PREFIX, application.appname)
def _action_appid(action_row):
"""The appid an action's params name, or None."""
params = action_row.params
if not isinstance(params, dict):
return None
try:
return int(params.get('appid'))
except (TypeError, ValueError):
return None
def policies_referencing_app(appid):
"""Every ConfigurationPolicy with an action whose params name this appid.
Params are a JSON text column (MySQL 5.6 has no JSON type to query), so the
match is done in Python over the actions that carry params at all.
"""
if appid is None:
return []
found = {}
rows = (ConfigurationPolicyAction.query
.filter(ConfigurationPolicyAction.params.isnot(None))
.all())
for action_row in rows:
if _action_appid(action_row) == int(appid):
policy = action_row.configurationpolicy
if policy is not None:
found[policy.configurationpolicyid] = policy
return [found[key] for key in sorted(found)]
def find_auto_policy(application):
"""Return the policy generated for this app, whoever owns it now.
Keyed on the appid in the generated update_app action's params, not on the
name: the name embeds appname, so renaming the application used to orphan
its policy (the next untick could not find it, and a re-tick generated a
second one). A policy counts as generated when it still carries the autoapp
source or the generated name prefix, so a row flipped to 'local' by an edit
is still found and callers can tell "customised" from "absent". The name
lookup stays as a fallback for a row whose params were edited away.
"""
candidates = [
policy for policy in policies_referencing_app(application.appid)
if policy.source == POLICY_SOURCE_AUTOAPP
or (policy.name or '').startswith(AUTO_POLICY_PREFIX)
]
if candidates:
candidates.sort(key=lambda policy: (
policy.source != POLICY_SOURCE_AUTOAPP,
policy.configurationpolicyid))
return candidates[0]
return ConfigurationPolicy.query.filter_by(
name=auto_policy_name(application)
).first()
def sync_required_app_policy(application):
"""Create or remove the generated policy to match application.isrequired.
Returns a short status string for the API to surface to the user:
'created' - a new policy now enforces this app
'removed' - the generated policy was deleted
'kept' - a customised policy was left in place deliberately
'unchanged' - nothing needed doing
"""
existing = find_auto_policy(application)
if application.isrequired:
if existing is not None:
# Already enforced. Deliberately does NOT rewrite a customised row
# back to our defaults; re-ticking a box should not silently revert
# an admin's edits. A still-generated row does follow an app rename
# so its name keeps describing what it installs.
wanted = auto_policy_name(application)
if (existing.source == POLICY_SOURCE_AUTOAPP
and existing.name != wanted
and ConfigurationPolicy.query.filter_by(name=wanted).first() is None):
existing.name = wanted
return 'unchanged'
policy = ConfigurationPolicy(
name=auto_policy_name(application),
description=(
'Auto-generated from the "Required on all PCs" checkbox on '
'application "{}". Editing this policy makes it yours: it will '
'no longer be removed automatically when that box is '
'unchecked.'.format(application.appname)
),
scopejson={'type': 'allpcs'},
source=POLICY_SOURCE_AUTOAPP,
)
db.session.add(policy)
db.session.flush()
db.session.add(ConfigurationPolicyAction(
configurationpolicyid=policy.configurationpolicyid,
action=REQUIRED_APP_ACTION,
mode=ACTION_MODE_DESIRED,
desiredstate={'installed': True},
params={'appid': application.appid},
))
return 'created'
# Not required.
if existing is None:
return 'unchanged'
if existing.source != POLICY_SOURCE_AUTOAPP:
# Someone customised it. Theirs now - leave it and say so.
return 'kept'
db.session.delete(existing)
return 'removed'
def disown_if_generated(policy):
"""Flip a generated policy to 'local' because it is being hand-edited.
Called from the policy CRUD write paths. After this, the application
checkbox no longer owns the row and will not delete it.
"""
if policy is not None and policy.source == POLICY_SOURCE_AUTOAPP:
policy.source = POLICY_SOURCE_LOCAL
return True
return False
def deactivate_policies_for_app(appid):
"""Deactivate every remaining policy whose actions name a deleted appid.
Called when an application is deleted, after the generated policy has been
withdrawn. What is left is either the customised ('kept') generated policy
or a hand-authored one; both would keep trying to install an application
that no longer exists. They are deactivated rather than deleted - they are
someone's work - and returned so the API can name them.
"""
deactivated = []
for policy in policies_referencing_app(appid):
if policy in db.session.deleted:
continue
if policy.isactive:
policy.isactive = False
deactivated.append(policy.name)
return deactivated

View File

@@ -20,7 +20,7 @@ The result is an EffectivePolicy: a flat, resolved map of action name -> decisio
and the allowlist need.
"""
from .policyscope import evaluate_scope
from .policyscope import SERVER_ICON, evaluate_scope
class EffectiveAction:
@@ -57,17 +57,39 @@ class EffectivePolicy:
actions maps action name -> EffectiveAction (first-match-wins already
applied). Convenience views expose the gate allowlist and desired-state set
that enforcement and the endpoints seam consume.
heldactions are action names under an unexpired break-glass hold. They are
withheld from desired_actions (reconciliation stops fighting the admin) but
deliberately LEFT IN allowed_actions: a hold suspends drift correction, it
does not revoke permission to run the action by hand.
"""
def __init__(self, actions):
def __init__(self, actions, heldactions=None):
self.actions = actions
self.heldactions = set(heldactions or ())
def allowed_actions(self):
"""Set of action names permitted on this endpoint (gate + desired)."""
return set(self.actions.keys())
def desired_actions(self):
"""action name -> EffectiveAction for mode == 'desired' only."""
"""action name -> EffectiveAction for mode == 'desired' only.
Held actions are excluded, so the reconciler does not re-enqueue the
very change an admin just broke glass to make.
"""
return {name: eff for name, eff in self.actions.items()
if eff.mode == 'desired' and name not in self.heldactions}
def holdable_actions(self):
"""Desired-mode action names, INCLUDING ones already held.
Deliberately not desired_actions(): that view hides held actions, so
building a "what can I hold" picker from it would drop an action the
moment it was held and make the current state unreadable. Only
desired-mode actions appear, because a hold suspends reconciliation and
gate-mode actions have none to suspend.
"""
return {name: eff for name, eff in self.actions.items()
if eff.mode == 'desired'}
@@ -78,15 +100,21 @@ class EffectivePolicy:
def build_endpoint_context(computer):
"""Resolve a Computer row into the context the scope evaluator needs.
Returns {'computertypeid': int|None, 'appids': set[int]}.
Returns {'computertypeid': int|None, 'appids': set[int], 'isserver': bool}.
"""
appids = set()
for link in getattr(computer, 'installedapps', None) or ():
if getattr(link, 'isactive', True) and link.appid is not None:
appids.add(link.appid)
# Server designation rides on the computer type's icon slug; policyscope
# owns that rule, we just resolve it for the endpoint. An unset type is not
# a server.
computertype = getattr(computer, 'computertype', None)
isserver = getattr(computertype, 'icon', None) == SERVER_ICON
return {
'computertypeid': getattr(computer, 'computertypeid', None),
'appids': appids,
'isserver': isserver,
}
@@ -165,7 +193,35 @@ def effective_policy_for(assetid):
return EffectivePolicy({})
business_policies = BusinessPolicy.query.filter_by(isactive=True).all()
return build_effective_policy(computer, business_policies)
effective = build_effective_policy(computer, business_policies)
effective.heldactions = _held_actions_for(assetid)
return effective
def _held_actions_for(assetid):
"""Action names under an unexpired break-glass hold for this endpoint.
Expiry is compared here, at resolve time, rather than swept by a background
job. A sweeper that stalled would silently keep every hold it failed to
clear alive, which is reconciliation disabled by accident - so the row's own
expiresat is the single authority on whether it still applies.
A null expiresat is an INDEFINITE hold and is always active. It must be
matched explicitly: a bare `expiresat > now` drops null rows, which would
resume reconciliation of exactly the actions an admin pinned open-endedly -
the reconciler restarting a service someone deliberately stopped.
"""
from datetime import datetime
from sqlalchemy import or_
from ..models import PolicyActionHold
rows = (PolicyActionHold.query
.filter(PolicyActionHold.assetid == assetid,
PolicyActionHold.isactive.is_(True),
or_(PolicyActionHold.expiresat.is_(None),
PolicyActionHold.expiresat > datetime.utcnow()))
.all())
return {row.action for row in rows}
def _monitor_action_ids(effective):

View File

@@ -11,7 +11,21 @@ Node grammar (all dict nodes carry a "type"):
{"type": "not", "node": <node>}
{"type": "pctype", "id": <computertypeid:int>}
{"type": "app", "id": <appid:int>}
{"type": "true"} # matches everything (an unscoped/global policy)
{"type": "allpcs"} # every PC EXCEPT those whose type is designated a server
{"type": "true"} # matches everything, servers included
"allpcs" vs "true": authoring an estate-wide policy almost always means "every
PC an operator sits at", not "every computer we have a row for". Those differ by
the servers, where a reboot or a script push is a materially different event.
"true" is retained because a stored scope of None is equivalent to it and older
policies were written with it, but the builder offers "All PCs" instead.
Server designation is read from the computer type's ICON slug ('server'). That
field is nominally a UI hint, which is a real wart - it is load-bearing here. It
was chosen over matching the type NAME because a site can rename "Server" but a
renamed type keeps its icon, and over a new isserver column because that needs a
migration this did not warrant. If server designation ever becomes a first-class
flag, this is the single place that reads it.
Kept pure and dependency-free (no db, no Flask) so it is trivially unit-tested
and so a future GE-Enforce assembly engine can reuse the exact same evaluator on
@@ -22,8 +36,12 @@ the exact same AST when it composes policies.
# recursion. Ten levels is far past any policy a human would author.
MAX_SCOPE_DEPTH = 10
# The computer-type icon slug that designates a type as a server. See the module
# docstring for why the icon carries this.
SERVER_ICON = 'server'
_LOGICAL = {'and', 'or', 'not'}
_LEAF = {'pctype', 'app', 'true'}
_LEAF = {'pctype', 'app', 'true', 'allpcs'}
class ScopeError(ValueError):
@@ -60,7 +78,7 @@ def validate_scope(node, _depth=0):
ident = node.get('id')
if not isinstance(ident, int) or isinstance(ident, bool):
raise ScopeError(f'{ntype} requires an integer "id"')
# 'true' carries no payload.
# 'true' and 'allpcs' carry no payload.
def evaluate_scope(node, context):
@@ -69,6 +87,7 @@ def evaluate_scope(node, context):
context is expected to expose:
context['computertypeid'] -> int or None
context['appids'] -> set/collection of installed appids
context['isserver'] -> bool, whether the type is server-designated
Returns True when the endpoint falls in scope. An empty/None scope means the
policy is unscoped and matches everything (same as {"type": "true"}), which
@@ -80,6 +99,10 @@ def evaluate_scope(node, context):
ntype = node['type']
if ntype == 'true':
return True
if ntype == 'allpcs':
# Absent designation means "not a server": an endpoint with no type set
# is an ordinary PC until something says otherwise.
return not context.get('isserver', False)
if ntype == 'and':
return all(evaluate_scope(child, context) for child in node['nodes'])
if ntype == 'or':

View File

@@ -1,133 +0,0 @@
# Employees plugin - directory database contract
Read-only lookups against a **separate** employee/HR directory database. Powers:
- Employee search (add people to a notification, browse the directory)
- Single + batch SSO lookup
- Recognition notifications (name + photo on the shopfloor / lobby displays)
The plugin never writes to this database. Use a read-only account.
> **This is the integration most likely to differ per site.** HR / directory
> systems vary widely, so expect to map a site's schema to the contract below -
> the `CREATE VIEW` recipe at the end is the normal way to do it.
## Connection
Credentials resolve **settings-first, then environment**, except the password
which is env-only (never stored in the app database).
| Field | Setting key (editable in the setup wizard) | Env var (fallback) |
| -------- | ------------------------------------------ | ---------------------- |
| Host | `employee_db_host` | `EMPLOYEE_DB_HOST` |
| Database | `employee_db_name` | `EMPLOYEE_DB_NAME` |
| User | `employee_db_user` | `EMPLOYEE_DB_USER` |
| Password | (not stored) | `EMPLOYEE_DB_PASSWORD` |
Set the password in `.env`; the setup wizard's Features step shows the exact
line to paste. Engine is MySQL/MariaDB (pymysql).
## Required schema
The plugin runs these queries verbatim, so a site's database must expose a
table (or **view** - see below) named `employees` with these columns:
| Column | Type | Notes |
| ------------ | ------------------- | ------------------------------------------------- |
| `SSO` | INT | Unique person id. Lookups require it to be numeric |
| `First_Name` | VARCHAR | Displayed as the given name |
| `Last_Name` | VARCHAR | Displayed as the surname; sort key |
| `Team` | VARCHAR | Team / group label |
| `Role` | VARCHAR | Job title / role |
| `Picture` | VARCHAR | **Image filename** (see Photos) |
Queries actually executed:
```sql
-- search
SELECT SSO, First_Name, Last_Name, Team, Role, Picture
FROM employees
WHERE First_Name LIKE %s OR Last_Name LIKE %s OR CAST(SSO AS CHAR) LIKE %s
ORDER BY Last_Name, First_Name LIMIT %s;
-- single / batch
SELECT SSO, First_Name, Last_Name, Team, Role, Picture FROM employees WHERE SSO = %s;
SELECT SSO, First_Name, Last_Name, Team, Role, Picture FROM employees WHERE SSO IN (...);
```
Rows are returned to the API **with these exact column names**. The frontend
(EmployeeSearch, NotificationForm, EmployeeDetail, ShopfloorDashboard) reads
`SSO`, `First_Name`, `Last_Name`, `Team`, `Role`, `Picture` as-is - do not
rename them in the response.
## Photos
`Picture` holds an **image filename** (e.g. `123456.jpg`), not a path or blob.
The app renders it as `/static/employees/<Picture>`, so the image files must
live in the app's `static/employees/` directory. Leave `Picture` empty/NULL for
people with no photo; the UI falls back to initials.
## Two ways to provide the directory
**Option A - map an existing HR/directory database** (see the view recipe
below). Use this when the site already has a system of record for people.
**Option B - self-hosted directory (managed in-app)** for sites with no HR
database. Set `employee_directory_mode` to `selfhosted` and manage people under
**Settings > Employee Directory** (add / edit / delete + CSV import) - no SQL
needed. The app owns a `directoryemployees` table (migration 7d16); the lookup
APIs read it automatically in this mode.
If you would rather load it with SQL, the canonical table is equivalent to:
```sql
CREATE DATABASE shopdb_directory CHARACTER SET utf8mb4;
USE shopdb_directory;
CREATE TABLE employees (
SSO INT NOT NULL PRIMARY KEY,
First_Name VARCHAR(100) NOT NULL,
Last_Name VARCHAR(100) NOT NULL,
Team VARCHAR(100) NULL,
Role VARCHAR(100) NULL,
Picture VARCHAR(255) NULL
);
-- add people (or bulk-load from CSV with LOAD DATA INFILE)
INSERT INTO employees (SSO, First_Name, Last_Name, Team, Role, Picture)
VALUES (123456, 'Jane', 'Doe', 'Inspection', 'Quality Tech', '123456.jpg');
```
Put photo files (named as in `Picture`) under the app's `static/employees/`.
In self-hosted mode the `directoryemployees` table lives in the main app DB, so
no separate database is required.
## Adapting a different site schema (recommended: a view)
Sites whose HR/directory database uses different table or column names should
**not** be forced to rename anything. Instead, create a read-only **view**
named `employees` that maps local columns to the names above:
```sql
CREATE VIEW employees AS
SELECT
person_id AS SSO,
given_name AS First_Name,
surname AS Last_Name,
department AS Team,
job_title AS Role,
photo_filename AS Picture
FROM hr_people
WHERE active = 1;
```
Grant the app's read-only user `SELECT` on the view. No app code changes -
point `employee_db_*` at that database and the plugin works.
Notes:
- `SSO` must be numeric (single/batch lookup validate `isdigit()`).
- Column-name case follows your database's identifier casing; match the names
above exactly on case-sensitive platforms.
- If the directory is unreachable or the `employees` object is missing, lookups
return a 500 and the rest of the app keeps working (the feature degrades, it
does not crash the app).

View File

@@ -1,5 +0,0 @@
"""Employees plugin package."""
from .plugin import EmployeesPlugin
__all__ = ['EmployeesPlugin']

View File

@@ -1,5 +0,0 @@
"""Employees plugin API."""
from .routes import employees_bp
__all__ = ['employees_bp']

View File

@@ -1,682 +0,0 @@
"""Employee lookup API endpoints.
These read from the separate employee directory DB (employee_connection is core
infrastructure exposed via shopdb.api, shared with search + notifications). The
endpoints are intentionally reachable by the unauthenticated shopfloor kiosk
displays (recognition wall), so they are not JWT-gated; keep them read-only and
never return more than the directory fields below.
"""
import csv
import glob
import io
import logging
import os
from flask import Blueprint, request, current_app
from flask_jwt_extended import jwt_required
from werkzeug.utils import secure_filename
from shopdb.api import (
db,
success_response,
error_response,
ErrorCodes,
employee_connection,
require_role,
)
from shopdb.api import Setting, intarg
from shopdb.api import send_upload
from ..models import DirectoryEmployee
from shopdb.api import require_permission
logger = logging.getLogger(__name__)
employees_bp = Blueprint('employees', __name__)
# Columns safe to expose to the directory/recognition UI
_FIELDS = 'SSO, First_Name, Last_Name, Team, Role, Picture'
# Uploaded self-hosted employee photos live in the instance dir and are served
# publicly (kiosk recognition/recertification cards read them without auth).
EMPLOYEE_PHOTO_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp'}
# URL prefix a served upload resolves to (self-hosted mode).
EMPLOYEE_PHOTO_URL_PREFIX = '/api/employees/photo/'
# Fallback URL prefix external HR relative picture paths resolve under when
# the employee_photo_base_url setting is unset. Sites whose photos live on
# another host (e.g. the classic EmployeeDBAPP) set the setting to a full URL
# such as https://host/EmployeeDBAPP/images/ instead.
EMPLOYEE_PHOTO_STATIC_PREFIX = '/static/employees/'
def _employeephoto_dir():
return os.path.join(current_app.instance_path, 'employeephotos')
def _selfhosted():
"""True when the directory is the app-owned table, not an external HR DB."""
row = Setting.query.filter_by(key='employee_directory_mode').first()
return (row.value if row and row.value else 'external').lower() == 'selfhosted'
def _require_selfhosted():
"""Guard for management endpoints - only valid in self-hosted mode."""
if not _selfhosted():
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Directory is in external mode; manage people in the source HR database.',
http_code=400)
return None
def _require_selfhosted_photo():
"""Guard for photo write endpoints - 409 when the directory is external.
In external mode the photo is owned by the HR database (read-only
pass-through), so upload/delete cannot apply here."""
if not _selfhosted():
return error_response(
ErrorCodes.CONFLICT,
'Employee directory is external; photos are supplied by the HR '
'database and cannot be uploaded or deleted here.',
http_code=409)
return None
def _external_photo_url(picture):
"""Turn an external HR Picture value into a usable URL, or None.
Absolute URLs and already-rooted paths pass through untouched (future
full-URL HR feeds); a bare relative path is served under the static prefix
(current WJ convention, e.g. 'Support/210009518.png')."""
if not picture:
return None
text = str(picture).strip()
if not text:
return None
if text.startswith(('http://', 'https://', '/')):
return text
from shopdb.api import Setting
base = (Setting.get('employee_photo_base_url') or '').strip() \
or EMPLOYEE_PHOTO_STATIC_PREFIX
return base.rstrip('/') + '/' + text.lstrip('/')
def _hr_picture(sso):
"""Raw Picture value for an SSO from the external HR directory. None on miss."""
try:
conn = employee_connection()
with conn.cursor() as cur:
cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(sso),))
row = cur.fetchone()
conn.close()
return row.get('Picture') if row else None
except Exception:
return None
def resolve_employee_display_name(sso):
"""Display name ("First Last") for an SSO in either directory mode.
The shopfloor feed uses this as a live fallback when a notification has
no stored employeename (e.g. imported without the employee source).
None on any miss."""
if sso is None or not str(sso).isdigit():
return None
# 1. The employee directory (selfhosted table or external wjf_employees).
if _selfhosted():
emp = db.session.get(DirectoryEmployee, int(sso))
if emp:
name = f'{emp.firstname} {emp.lastname}'.strip()
if name:
return name
else:
try:
conn = employee_connection()
with conn.cursor() as cur:
cur.execute(
'SELECT First_Name, Last_Name FROM employees WHERE SSO = %s',
(int(sso),))
row = cur.fetchone()
conn.close()
if row:
first = row.get('First_Name') or ''
last = row.get('Last_Name') or ''
name = f'{first.strip()} {last.strip()}'.strip()
if name:
return name
except Exception:
# Logged, not swallowed. This is the branch that fails on a live
# site - an unreachable HR host, a rotated credential, a renamed
# column - and a bare pass made all of those look identical to
# "no such employee": a lowercase SSO where a name should be, and
# no photo, with nothing written down anywhere.
current_app.logger.exception(
'Employee directory lookup failed for SSO %s', sso)
# 2. Fallback: a shopdb login account for this SSO (e.g. a user from another
# location not carried in the directory). Their name lives on the User
# record; GE usernames are the SSO. Photo still falls back to the GE mark.
try:
from shopdb.api import User
user = User.query.filter_by(username=str(sso)).first()
if user:
name = f'{(user.firstname or "").strip()} {(user.lastname or "").strip()}'.strip()
if name:
return name
except Exception:
current_app.logger.exception(
'User-account name fallback failed for SSO %s', sso)
return None
def resolve_employee_photo_url(sso, external_picture=None):
"""Single resolver both consumers share: the display photo URL for an SSO.
Self-hosted: the served upload URL when the employee has an uploaded photo,
else None (the legacy Picture text field does not drive the photo here).
External: the HR-supplied Picture resolved to a URL (pass external_picture to
avoid a re-query), else None. Returns None on any miss or bad SSO."""
if sso is None or not str(sso).isdigit():
return None
if _selfhosted():
emp = db.session.get(DirectoryEmployee, int(sso))
if emp and emp.photofilename:
return EMPLOYEE_PHOTO_URL_PREFIX + emp.photofilename
return None
picture = external_picture if external_picture is not None else _hr_picture(sso)
return _external_photo_url(picture)
def _with_photo_url(employee):
"""Add the resolved photourl to an employee dict (self-hosted or external)."""
employee['photourl'] = resolve_employee_photo_url(
employee.get('SSO'), employee.get('Picture'))
return employee
@employees_bp.route('/resolve/<sso>', methods=['GET'])
@require_permission('employees.view')
@jwt_required()
def resolve_employee(sso):
"""Resolve one SSO to a name and photo, and say HOW.
Built because the failure it diagnoses is invisible. The shopfloor board
resolves names and photos through two functions that returned None on any
problem, so an unreachable HR host, a rotated credential and a genuinely
unknown SSO all produced the same thing: a lowercase SSO where a name
should be, and no photo. Nothing distinguished them and nothing was logged.
This returns the same answer the board gets, plus which source produced it,
which mode the directory is in, and the error when a source failed. It is
the difference between "the board is broken" and "the HR host refused the
connection at 09:12".
"""
result = {
'sso': sso,
'mode': 'selfhosted' if _selfhosted() else 'external',
'name': None,
'photourl': None,
'source': None,
'error': None,
}
if sso is None or not str(sso).isdigit():
result['error'] = 'SSO must be digits'
return success_response(result)
if _selfhosted():
employee = db.session.get(DirectoryEmployee, int(sso))
if employee:
name = '{} {}'.format(employee.firstname or '',
employee.lastname or '').strip()
if name:
result['name'] = name
result['source'] = 'directory'
else:
try:
conn = employee_connection()
with conn.cursor() as cur:
cur.execute(
'SELECT First_Name, Last_Name FROM employees WHERE SSO = %s',
(int(sso),))
row = cur.fetchone()
conn.close()
if row:
name = '{} {}'.format((row.get('First_Name') or '').strip(),
(row.get('Last_Name') or '').strip()).strip()
if name:
result['name'] = name
result['source'] = 'hrdirectory'
else:
result['error'] = 'No row in the HR directory for this SSO'
except Exception as exception:
# Reported, not hidden. This string is the whole point of the
# endpoint.
result['error'] = '{}: {}'.format(type(exception).__name__, exception)
current_app.logger.exception(
'Employee directory lookup failed for SSO %s', sso)
if not result['name']:
from shopdb.api import User
user = User.query.filter_by(username=str(sso)).first()
if user:
name = '{} {}'.format((user.firstname or '').strip(),
(user.lastname or '').strip()).strip()
if name:
result['name'] = name
result['source'] = 'useraccount'
result['photourl'] = resolve_employee_photo_url(sso)
if result['name'] is None and result['error'] is None:
result['error'] = 'No name found in the directory or in a user account'
return success_response(result)
@employees_bp.route('/search', methods=['GET'])
@require_permission('employees.view')
def search_employees():
"""
Search employees by name.
Query parameters:
- q: Search query (searches first and last name)
- limit: Max results (default 10)
"""
query = request.args.get('q', '').strip()
limit = min(intarg('limit', 10), 50)
if len(query) < 2:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Search query must be at least 2 characters'
)
# A multi-word query is a FULL NAME. Each word was matched against first
# name or last name on its own, so "Dana Reyes" found nobody: no single
# column contains that string. Somebody typing the whole name - which is
# what a person does before pressing Enter, rather than picking from the
# list - got an empty result and their entry became free text linked to no
# employee record.
words = query.split()
if _selfhosted():
rows = DirectoryEmployee.query
if len(words) > 1:
# Every word has to appear in the name, in either column, so
# "Reyes Dana" works as well as "Dana Reyes".
for word in words:
term = f'%{word}%'
rows = rows.filter(db.or_(
DirectoryEmployee.firstname.ilike(term),
DirectoryEmployee.lastname.ilike(term)))
else:
term = f'%{query}%'
rows = rows.filter(db.or_(
DirectoryEmployee.firstname.ilike(term),
DirectoryEmployee.lastname.ilike(term),
db.cast(DirectoryEmployee.sso, db.String).ilike(term)))
rows = (rows
.order_by(DirectoryEmployee.lastname, DirectoryEmployee.firstname)
.limit(limit).all())
return success_response([_with_photo_url(e.to_dict()) for e in rows])
try:
conn = employee_connection()
with conn.cursor() as cur:
if len(words) > 1:
# Same rule as the self-hosted branch: every word must appear
# somewhere in the name, so a full name finds the person.
clauses = ' AND '.join(
['(First_Name LIKE %s OR Last_Name LIKE %s)'] * len(words))
params = []
for word in words:
params.extend([f'%{word}%', f'%{word}%'])
params.append(limit)
cur.execute(f'''
SELECT {_FIELDS}
FROM employees
WHERE {clauses}
ORDER BY Last_Name, First_Name
LIMIT %s
''', tuple(params))
else:
cur.execute(f'''
SELECT {_FIELDS}
FROM employees
WHERE First_Name LIKE %s
OR Last_Name LIKE %s
OR CAST(SSO AS CHAR) LIKE %s
ORDER BY Last_Name, First_Name
LIMIT %s
''', (f'%{query}%', f'%{query}%', f'%{query}%', limit))
employees = cur.fetchall()
conn.close()
return success_response([_with_photo_url(e) for e in employees])
except Exception:
logger.exception('Employee search failed')
return error_response(
ErrorCodes.INTERNAL_ERROR,
'Employee lookup failed',
http_code=500
)
@employees_bp.route('/lookup/<sso>', methods=['GET'])
@require_permission('employees.view')
def lookup_employee(sso):
"""Look up a single employee by SSO."""
if not sso.isdigit():
return error_response(
ErrorCodes.VALIDATION_ERROR,
'SSO must be numeric'
)
if _selfhosted():
emp = db.session.get(DirectoryEmployee, int(sso))
if not emp:
return error_response(ErrorCodes.NOT_FOUND,
f'Employee with SSO {sso} not found', http_code=404)
return success_response(_with_photo_url(emp.to_dict()))
try:
conn = employee_connection()
with conn.cursor() as cur:
cur.execute(
f'SELECT {_FIELDS} FROM employees WHERE SSO = %s',
(int(sso),)
)
employee = cur.fetchone()
conn.close()
if not employee:
return error_response(
ErrorCodes.NOT_FOUND,
f'Employee with SSO {sso} not found',
http_code=404
)
return success_response(_with_photo_url(employee))
except Exception:
logger.exception('Employee lookup failed for SSO %s', sso)
return error_response(
ErrorCodes.INTERNAL_ERROR,
'Employee lookup failed',
http_code=500
)
@employees_bp.route('/lookup', methods=['GET'])
@require_permission('employees.view')
def lookup_employees():
"""
Look up multiple employees by SSO list.
Query parameters:
- sso: Comma-separated list of SSOs
"""
sso_list = request.args.get('sso', '')
ssos = [s.strip() for s in sso_list.split(',') if s.strip().isdigit()]
if not ssos:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'At least one valid SSO is required'
)
if _selfhosted():
rows = DirectoryEmployee.query.filter(
DirectoryEmployee.sso.in_([int(s) for s in ssos])).all()
employees = [_with_photo_url(e.to_dict()) for e in rows]
names = ', '.join(f"{e['First_Name'].strip()} {e['Last_Name'].strip()}"
for e in employees)
return success_response({'employees': employees, 'names': names})
try:
conn = employee_connection()
with conn.cursor() as cur:
placeholders = ','.join(['%s'] * len(ssos))
cur.execute(
f'SELECT {_FIELDS} FROM employees WHERE SSO IN ({placeholders})',
[int(s) for s in ssos]
)
employees = cur.fetchall()
conn.close()
employees = [_with_photo_url(e) for e in employees]
names = ', '.join(
f"{e['First_Name'].strip()} {e['Last_Name'].strip()}"
for e in employees
)
return success_response({
'employees': employees,
'names': names
})
except Exception:
logger.exception('Employee multi-lookup failed')
return error_response(
ErrorCodes.INTERNAL_ERROR,
'Employee lookup failed',
http_code=500
)
# =============================================================================
# Self-hosted directory management (only when directory_mode=selfhosted)
# =============================================================================
@employees_bp.route('/directory', methods=['GET'])
@require_permission('employees.view')
def list_directory():
"""Full self-hosted directory (for the management page)."""
guard = _require_selfhosted()
if guard:
return guard
rows = (DirectoryEmployee.query
.order_by(DirectoryEmployee.lastname, DirectoryEmployee.firstname).all())
return success_response([_with_photo_url(e.to_dict()) for e in rows])
def _employee_from_payload(data):
"""Build kwargs from a payload accepting either external-style (SSO,
First_Name...) or plain (sso, firstname...) keys."""
def pick(*keys):
for key in keys:
if data.get(key) not in (None, ''):
return data.get(key)
return None
return {
'sso': pick('sso', 'SSO'),
'firstname': pick('firstname', 'First_Name'),
'lastname': pick('lastname', 'Last_Name'),
'team': pick('team', 'Team'),
'role': pick('role', 'Role'),
'picture': pick('picture', 'Picture'),
}
@employees_bp.route('/directory', methods=['POST'])
@jwt_required()
@require_role('admin')
def create_directory_employee():
guard = _require_selfhosted()
if guard:
return guard
fields = _employee_from_payload(request.get_json() or {})
if not (fields['sso'] and fields['firstname'] and fields['lastname']):
return error_response(ErrorCodes.VALIDATION_ERROR, 'sso, firstname and lastname are required')
try:
sso = int(fields['sso'])
except (ValueError, TypeError):
return error_response(ErrorCodes.VALIDATION_ERROR, 'sso must be numeric')
if db.session.get(DirectoryEmployee, sso):
return error_response(ErrorCodes.CONFLICT, f'SSO {sso} already exists', http_code=409)
emp = DirectoryEmployee(sso=sso, firstname=fields['firstname'], lastname=fields['lastname'],
team=fields['team'], role=fields['role'], picture=fields['picture'])
db.session.add(emp)
db.session.commit()
return success_response(emp.to_dict(), message='Employee added', http_code=201)
@employees_bp.route('/directory/<int:sso>', methods=['PUT'])
@jwt_required()
@require_role('admin')
def update_directory_employee(sso):
guard = _require_selfhosted()
if guard:
return guard
emp = db.session.get(DirectoryEmployee, sso)
if not emp:
return error_response(ErrorCodes.NOT_FOUND, 'Employee not found', http_code=404)
fields = _employee_from_payload(request.get_json() or {})
if fields['firstname']:
emp.firstname = fields['firstname']
if fields['lastname']:
emp.lastname = fields['lastname']
for key in ('team', 'role', 'picture'):
if key in (request.get_json() or {}) or fields[key] is not None:
setattr(emp, key, fields[key])
db.session.commit()
return success_response(emp.to_dict(), message='Employee updated')
@employees_bp.route('/directory/<int:sso>', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_directory_employee(sso):
guard = _require_selfhosted()
if guard:
return guard
emp = db.session.get(DirectoryEmployee, sso)
if not emp:
return error_response(ErrorCodes.NOT_FOUND, 'Employee not found', http_code=404)
db.session.delete(emp)
db.session.commit()
return success_response(message='Employee removed')
@employees_bp.route('/directory/import', methods=['POST'])
@jwt_required()
@require_role('admin')
def import_directory():
"""Bulk upsert from CSV. Accepts headers SSO,First_Name,Last_Name,Team,Role,
Picture (case-insensitive; sso/firstname/... also accepted)."""
guard = _require_selfhosted()
if guard:
return guard
text = ''
if 'file' in request.files:
text = request.files['file'].read().decode('utf-8-sig', errors='replace')
else:
data = request.get_json(silent=True) or {}
text = data.get('csv', '')
if not text.strip():
return error_response(ErrorCodes.VALIDATION_ERROR, 'No CSV provided')
reader = csv.DictReader(io.StringIO(text))
# Normalize headers to lower for tolerant matching.
added = updated = skipped = 0
for raw in reader:
row = {(k or '').strip().lower(): (v or '').strip() for k, v in raw.items()}
sso_raw = row.get('sso') or row.get('sso ')
first = row.get('first_name') or row.get('firstname')
last = row.get('last_name') or row.get('lastname')
if not (sso_raw and sso_raw.isdigit() and first and last):
skipped += 1
continue
sso = int(sso_raw)
team = row.get('team') or None
role = row.get('role') or None
picture = row.get('picture') or None
emp = db.session.get(DirectoryEmployee, sso)
if emp:
emp.firstname, emp.lastname, emp.team, emp.role, emp.picture = first, last, team, role, picture
updated += 1
else:
db.session.add(DirectoryEmployee(sso=sso, firstname=first, lastname=last,
team=team, role=role, picture=picture))
added += 1
db.session.commit()
return success_response({'added': added, 'updated': updated, 'skipped': skipped},
message=f'Import done: {added} added, {updated} updated, {skipped} skipped.')
# =============================================================================
# Self-hosted employee photos (upload/replace/delete + public serve)
# =============================================================================
@employees_bp.route('/<int:sso>/photo', methods=['POST'])
@jwt_required()
@require_role('admin')
def upload_employee_photo(sso):
"""Upload (or replace) the photo for a self-hosted directory employee.
multipart/form-data: file=<image>. Saves to the instance employeephotos dir
as photo-<sso><ext> (one photo per person) and points photofilename at it.
Re-upload replaces the old file even when the extension changes. External
mode is a 409 (photo is owned by the HR database)."""
guard = _require_selfhosted_photo()
if guard:
return guard
emp = db.session.get(DirectoryEmployee, sso)
if not emp:
return error_response(ErrorCodes.NOT_FOUND, 'Employee not found', http_code=404)
upload = request.files.get('file')
if not upload or not upload.filename:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided')
ext = os.path.splitext(upload.filename)[1].lower()
if ext not in EMPLOYEE_PHOTO_EXTENSIONS:
return error_response(ErrorCodes.VALIDATION_ERROR,
f'Unsupported image type {ext}')
photodir = _employeephoto_dir()
os.makedirs(photodir, exist_ok=True)
# Wipe any prior photo-<sso>.* so a new extension does not orphan the old one.
for old in glob.glob(os.path.join(photodir, secure_filename(f'photo-{sso}') + '.*')):
os.remove(old)
filename = secure_filename(f'photo-{sso}{ext}')
upload.save(os.path.join(photodir, filename))
emp.photofilename = filename
db.session.commit()
return success_response(_with_photo_url(emp.to_dict()), message='Employee photo uploaded')
@employees_bp.route('/photo/<path:filename>', methods=['GET'])
def serve_employee_photo(filename):
"""Serve an uploaded employee photo (public - kiosk cards read it)."""
return send_upload(_employeephoto_dir(), filename)
@employees_bp.route('/<int:sso>/photo', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_employee_photo(sso):
"""Clear an employee photo and delete the uploaded file. External mode 409s."""
guard = _require_selfhosted_photo()
if guard:
return guard
emp = db.session.get(DirectoryEmployee, sso)
if not emp:
return error_response(ErrorCodes.NOT_FOUND, 'Employee not found', http_code=404)
if emp.photofilename:
# secure_filename strips any traversal; the file lives in our dir only.
path = os.path.join(_employeephoto_dir(), secure_filename(emp.photofilename))
if os.path.exists(path):
os.remove(path)
emp.photofilename = None
db.session.commit()
return success_response(_with_photo_url(emp.to_dict()), message='Employee photo removed')

View File

@@ -1,23 +0,0 @@
/**
* Employees plugin frontend routes (ADR-013 Phase 4). Extracted from core.js -
* employee detail + the directory settings page belong to the employees plugin.
*/
export default [
{
path: 'employees/:sso',
name: 'employee-detail',
component: () => import('./views/EmployeeDetail.vue'),
meta: { plugin: 'employees' }
},
{
path: 'settings/employeedirectory',
name: 'employee-directory',
component: () => import('./views/EmployeeDirectory.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'employees' }
}
]
// No full-screen routes: every view in this plugin renders inside
// AppLayout. Declared so routes.gen.js can read `.toplevel` off the module
// without Rollup warning about a missing export.
export const toplevel = []

View File

@@ -1,364 +0,0 @@
<template>
<div class="detail-page">
<div v-if="loading" class="loading">Loading...</div>
<div v-else-if="error" class="error-message">{{ error }}</div>
<template v-else-if="employee">
<div class="hero-card">
<div class="hero-image" v-if="employee.photourl">
<img :src="employee.photourl" :alt="fullName" @error="onPhotoError" />
</div>
<div class="hero-image placeholder" v-else>
<img :src="fallbackAvatar" alt="GE Aerospace" class="ge-avatar-fallback-lg" />
</div>
<div class="hero-content">
<h1 class="hero-title">{{ fullName }}</h1>
<div class="hero-meta">
<span class="badge">SSO: {{ employee.SSO }}</span>
<span v-if="employee.Team" class="badge badge-primary">{{ employee.Team }}</span>
</div>
<div class="hero-details">
<p v-if="employee.Role"><strong>Role:</strong> {{ employee.Role }}</p>
</div>
<div class="hero-actions">
<router-link to="/" class="btn">Back to Dashboard</router-link>
</div>
</div>
</div>
<!-- Recognitions -->
<div class="section-card">
<h2 class="section-title">
Recognitions
<span v-if="recognitions.length > 0" class="count-badge">{{ recognitions.length }}</span>
</h2>
<div v-if="recognitionsLoading" class="loading">Loading...</div>
<div v-else-if="recognitions.length === 0" class="empty">
No recognitions yet.
</div>
<div v-else class="recognitions-list">
<div v-for="rec in displayedRecognitions" :key="rec.notificationid" class="recognition-card">
<div class="recognition-header">
<span class="badge" :style="{ backgroundColor: rec.typecolor || '#14abef' }">
{{ rec.typename || 'Recognition' }}
</span>
<span class="recognition-date">{{ formatDate(rec.startdate) }}</span>
</div>
<div class="recognition-message">{{ rec.notification }}</div>
</div>
<button
v-if="recognitions.length > recognitionsLimit && !showAllRecognitions"
class="btn btn-secondary show-more-btn"
@click="showAllRecognitions = true"
>
Show {{ recognitions.length - recognitionsLimit }} more
</button>
<button
v-if="showAllRecognitions && recognitions.length > recognitionsLimit"
class="btn btn-secondary show-more-btn"
@click="showAllRecognitions = false"
>
Show less
</button>
</div>
</div>
<!-- Currently Checked Out USB Devices -->
<div v-if="usbEnabled" class="section-card">
<h2 class="section-title">Checked Out USB Devices</h2>
<div v-if="usbLoading" class="loading">Loading...</div>
<div v-else-if="usbDevices.length === 0" class="empty">
No USB devices currently checked out.
</div>
<div v-else class="table-container">
<table>
<thead>
<tr>
<th>Device</th>
<th>Checked Out</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="checkout in usbDevices" :key="checkout.log_id">
<td>
<router-link :to="`/usb/${checkout.device_id}`">
{{ checkout.device_id }}
</router-link>
</td>
<td>{{ formatDate(checkout.timestamp) }}</td>
<td class="actions">
<button class="btn btn-small btn-success" @click="checkinDevice(checkout)">
Check In
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- USB Checkout History -->
<div v-if="usbEnabled" class="section-card">
<h2 class="section-title">USB Checkout History</h2>
<div v-if="historyLoading" class="loading">Loading...</div>
<div v-else-if="checkoutHistory.length === 0" class="empty">
No checkout history.
</div>
<div v-else class="table-container">
<table>
<thead>
<tr>
<th>Device</th>
<th>Checked Out</th>
<th>Device Status</th>
</tr>
</thead>
<tbody>
<tr v-for="record in displayedHistory" :key="record.log_id">
<td>
<router-link :to="`/usb/${record.device_id}`">
{{ record.device_id }}
</router-link>
</td>
<td>{{ formatDate(record.timestamp) }}</td>
<td>{{ record.device_status || '-' }}</td>
</tr>
</tbody>
</table>
<button
v-if="checkoutHistory.length > historyLimit && !showAllHistory"
class="btn btn-secondary show-more-btn"
@click="showAllHistory = true"
>
Show {{ checkoutHistory.length - historyLimit }} more
</button>
<button
v-if="showAllHistory && checkoutHistory.length > historyLimit"
class="btn btn-secondary show-more-btn"
@click="showAllHistory = false"
>
Show less
</button>
</div>
</div>
</template>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { withBase } from '@/utils/basePath'
import { employeesApi, usbApi, notificationsApi } from '@/api'
import { isPluginEnabled, loadEnabledPlugins } from '@/composables/enabledPlugins'
import { useToast } from '@/composables/toast'
import { setRecordTitle } from '@/composables/pageTitle'
const toast = useToast()
const route = useRoute()
const employee = ref(null)
const recognitions = ref([])
const usbDevices = ref([])
const checkoutHistory = ref([])
const usbEnabled = ref(true)
const loading = ref(true)
const recognitionsLoading = ref(true)
const usbLoading = ref(true)
const historyLoading = ref(true)
const error = ref('')
const recognitionsLimit = 5
const showAllRecognitions = ref(false)
const displayedRecognitions = computed(() => {
if (showAllRecognitions.value) {
return recognitions.value
}
return recognitions.value.slice(0, recognitionsLimit)
})
const historyLimit = 10
const showAllHistory = ref(false)
const displayedHistory = computed(() => {
if (showAllHistory.value) {
return checkoutHistory.value
}
return checkoutHistory.value.slice(0, historyLimit)
})
const fullName = computed(() => {
if (!employee.value) return ''
return `${employee.value.First_Name?.trim() || ''} ${employee.value.Last_Name?.trim() || ''}`.trim()
})
const fallbackAvatar = withBase('/ge-monogram.svg')
function onPhotoError(event) {
event.target.src = fallbackAvatar
event.target.classList.add('ge-avatar-fallback-lg')
}
const initials = computed(() => {
if (!employee.value) return '?'
const first = employee.value.First_Name?.trim()?.[0] || ''
const last = employee.value.Last_Name?.trim()?.[0] || ''
return (first + last).toUpperCase() || '?'
})
onMounted(async () => {
await loadEmployee()
// Skip the USB panels entirely when the usb plugin is disabled - its
// /api/usb routes 404 otherwise and spam the console.
await loadEnabledPlugins()
usbEnabled.value = isPluginEnabled('usb')
const tasks = [loadRecognitions()]
if (usbEnabled.value) tasks.push(loadUSBDevices(), loadCheckoutHistory())
await Promise.all(tasks)
})
async function loadEmployee() {
loading.value = true
try {
const response = await employeesApi.lookup(route.params.sso)
employee.value = response.data.data
setRecordTitle(employee.value?.name || employee.value?.sso)
} catch (err) {
console.error('Error loading employee:', err)
error.value = 'Employee not found'
} finally {
loading.value = false
}
}
async function loadRecognitions() {
recognitionsLoading.value = true
try {
const response = await notificationsApi.getEmployeeRecognitions(route.params.sso)
recognitions.value = response.data.data?.recognitions || []
} catch (err) {
console.error('Error loading recognitions:', err)
recognitions.value = []
} finally {
recognitionsLoading.value = false
}
}
async function loadUSBDevices() {
usbLoading.value = true
try {
// active check-out log rows for this badge
const response = await usbApi.getUserCheckouts(route.params.sso)
usbDevices.value = response.data.data || []
} catch (err) {
console.error('Error loading USB devices:', err)
} finally {
usbLoading.value = false
}
}
async function loadCheckoutHistory() {
historyLoading.value = true
try {
// all check-out log rows for this badge, newest first
const response = await usbApi.getUserCheckouts(route.params.sso, false)
checkoutHistory.value = response.data.data || []
} catch (err) {
console.error('Error loading checkout history:', err)
checkoutHistory.value = []
} finally {
historyLoading.value = false
}
}
async function checkinDevice(checkout) {
if (!confirm(`Check in ${checkout.device_id}?`)) return
try {
await usbApi.checkin(checkout.device_id, { badge: checkout.badge_number || route.params.sso })
await loadUSBDevices()
await loadCheckoutHistory()
} catch (err) {
console.error('Error checking in device:', err)
toast.error('Failed to check in device')
}
}
function formatDate(dateStr) {
if (!dateStr) return ''
return new Date(dateStr).toLocaleString()
}
</script>
<style scoped>
.hero-image.placeholder {
display: flex;
align-items: center;
justify-content: center;
background: var(--primary);
color: white;
}
.ge-avatar-fallback-lg {
width: 60%;
height: 60%;
object-fit: contain;
opacity: 0.85;
}
.initials {
font-size: 3rem;
font-weight: 600;
}
.recognitions-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.recognition-card {
padding: 1rem;
background: var(--bg);
border-radius: 8px;
border-left: 4px solid var(--primary);
}
.recognition-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.recognition-date {
color: var(--text-light);
font-size: 0.875rem;
}
.recognition-message {
white-space: pre-wrap;
line-height: 1.5;
}
.count-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.5rem;
height: 1.5rem;
padding: 0 0.5rem;
margin-left: 0.5rem;
background: var(--primary);
color: white;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
}
.show-more-btn {
width: 100%;
margin-top: 0.5rem;
}
</style>

View File

@@ -1,303 +0,0 @@
<template>
<div>
<div class="page-header">
<h2>Employee Directory</h2>
<div class="header-actions">
<button class="btn btn-secondary" @click="showImport = true" :disabled="!selfhosted">Import CSV</button>
<button class="btn btn-primary" @click="openModal()" :disabled="!selfhosted">+ Add Person</button>
</div>
</div>
<div v-if="!selfhosted" class="card">
<p class="hint">
The directory is in <strong>external</strong> mode - people come from a
separate HR database, so there is nothing to manage here. To manage
people in-app, set <code>employee_directory_mode</code> to
<code>selfhosted</code> under Site &amp; Facility settings, then reload.
</p>
</div>
<div v-else class="card">
<div class="filters">
<input v-model="search" type="text" class="form-control" placeholder="Search name or SSO..." />
<span class="result-count">{{ filtered.length }} of {{ items.length }}</span>
</div>
<div v-if="loading" class="muted">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr><th>SSO</th><th>Name</th><th>Team</th><th>Role</th><th>Photo</th><th>Actions</th></tr>
</thead>
<tbody>
<tr v-for="e in paginated" :key="e.SSO">
<td class="mono">{{ e.SSO }}</td>
<td>{{ e.First_Name }} {{ e.Last_Name }}</td>
<td>{{ e.Team || '-' }}</td>
<td>{{ e.Role || '-' }}</td>
<td>
<img :src="e.photourl || fallbackAvatar" alt="Photo" class="photo-thumb"
:class="{ 'ge-thumb-fallback': !e.photourl }" />
</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(e)">Edit</button>
<button class="btn btn-danger btn-sm" @click="remove(e)">Delete</button>
</td>
</tr>
<tr v-if="filtered.length === 0">
<td colspan="6" style="text-align:center;color:var(--text-light);">No people yet</td>
</tr>
</tbody>
</table>
</div>
<div v-if="totalPages > 1" class="pagination">
<button class="btn btn-secondary btn-sm" :disabled="page===1" @click="page--">Prev</button>
<span class="page-info">Page {{ page }} of {{ totalPages }}</span>
<button class="btn btn-secondary btn-sm" :disabled="page===totalPages" @click="page++">Next</button>
</div>
</template>
</div>
<!-- Add / edit modal -->
<div v-if="showModal" class="modal-overlay">
<div class="modal">
<div class="modal-header"><h3>{{ editing ? 'Edit' : 'Add' }} Person</h3></div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-row">
<div class="form-group">
<label>SSO *</label>
<input v-model="form.SSO" type="number" class="form-control" :disabled="!!editing" required />
</div>
<div class="form-group">
<label>Photo filename</label>
<input v-model="form.Picture" type="text" class="form-control" placeholder="123456.jpg" />
</div>
</div>
<div class="form-row">
<div class="form-group"><label>First name *</label><input v-model="form.First_Name" type="text" class="form-control" required /></div>
<div class="form-group"><label>Last name *</label><input v-model="form.Last_Name" type="text" class="form-control" required /></div>
</div>
<div class="form-row">
<div class="form-group"><label>Team</label><input v-model="form.Team" type="text" class="form-control" /></div>
<div class="form-group"><label>Role</label><input v-model="form.Role" type="text" class="form-control" /></div>
</div>
<div class="form-group">
<label>Photo</label>
<div class="photo-manage">
<img v-if="form.photourl" :src="form.photourl" alt="Employee photo" class="photo-thumb-lg" />
<div class="photo-actions">
<template v-if="editing">
<input
ref="photoFileInput"
type="file"
accept=".png,.jpg,.jpeg,.gif,.webp"
style="display: none"
@change="onPhotoSelected"
/>
<button type="button" class="btn btn-secondary btn-sm" :disabled="uploadingPhoto" @click="triggerPhotoUpload">
{{ uploadingPhoto ? 'Uploading...' : (form.photourl ? 'Replace' : 'Upload') }}
</button>
<button v-if="form.photourl" type="button" class="btn btn-danger btn-sm" @click="removePhoto">Remove</button>
</template>
<small v-else class="hint">Save the person first, then upload a photo.</small>
</div>
</div>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
</div>
</form>
</div>
</div>
<!-- CSV import modal -->
<div v-if="showImport" class="modal-overlay">
<div class="modal">
<div class="modal-header"><h3>Import CSV</h3></div>
<div class="modal-body">
<p class="hint">Headers: <code>SSO,First_Name,Last_Name,Team,Role,Picture</code>. Existing SSOs are updated.</p>
<input type="file" accept=".csv,text/csv" @change="onFile" />
<textarea v-model="csvText" class="form-control" rows="8" placeholder="or paste CSV here" style="margin-top:0.6rem;"></textarea>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showImport = false">Cancel</button>
<button class="btn btn-primary" :disabled="importing || !csvText.trim()" @click="doImport">{{ importing ? 'Importing...' : 'Import' }}</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { employeesApi, settingsApi } from '@/api'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
const toast = useToast()
const selfhosted = ref(false)
const items = ref([])
const loading = ref(true)
const search = ref('')
const page = ref(1)
const perPage = 25
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref(blank())
const showImport = ref(false)
const csvText = ref('')
const importing = ref(false)
const photoFileInput = ref(null)
const uploadingPhoto = ref(false)
function blank() { return { SSO: '', First_Name: '', Last_Name: '', Team: '', Role: '', Picture: '', photourl: '' } }
const filtered = computed(() => {
const term = search.value.trim().toLowerCase()
if (!term) return items.value
return items.value.filter(e =>
`${e.First_Name} ${e.Last_Name}`.toLowerCase().includes(term) ||
String(e.SSO).includes(term))
})
const totalPages = computed(() => Math.max(1, Math.ceil(filtered.value.length / perPage)))
const paginated = computed(() => filtered.value.slice((page.value - 1) * perPage, page.value * perPage))
watch(search, () => { page.value = 1 })
onMounted(async () => {
try {
const response = await settingsApi.get('employee_directory_mode')
selfhosted.value = (response.data?.data?.value || 'external') === 'selfhosted'
} catch (err) { /* default external */ }
if (selfhosted.value) await load()
else loading.value = false
})
async function load() {
loading.value = true
try {
const response = await employeesApi.directory.list()
items.value = response.data.data || []
} catch (err) {
toast.error(apiError(err, 'Could not load directory'))
} finally {
loading.value = false
}
}
function openModal(item = null) {
editing.value = item
form.value = item ? { ...item } : blank()
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
if (editing.value) await employeesApi.directory.update(editing.value.SSO, form.value)
else await employeesApi.directory.create(form.value)
closeModal()
load()
} catch (err) {
error.value = apiError(err, 'Failed to save')
} finally {
saving.value = false
}
}
function triggerPhotoUpload() {
photoFileInput.value?.click()
}
async function onPhotoSelected(event) {
const file = event.target.files?.[0]
if (!file || !editing.value) return
uploadingPhoto.value = true
try {
const response = await employeesApi.directory.uploadPhoto(editing.value.SSO, file)
// Backend returns the updated employee with photourl set to the served URL.
form.value.photourl = response.data.data.photourl || ''
form.value.photofilename = response.data.data.photofilename || ''
toast.success('Photo uploaded')
load()
} catch (err) {
toast.error(apiError(err, 'Failed to upload photo'))
} finally {
uploadingPhoto.value = false
if (photoFileInput.value) photoFileInput.value.value = ''
}
}
async function removePhoto() {
if (!editing.value) return
if (!confirm('Remove this photo?')) return
try {
await employeesApi.directory.removePhoto(editing.value.SSO)
form.value.photourl = ''
form.value.photofilename = ''
toast.success('Photo removed')
load()
} catch (err) {
toast.error(apiError(err, 'Failed to remove photo'))
}
}
async function remove(e) {
if (!confirm(`Remove ${e.First_Name} ${e.Last_Name}?`)) return
try {
await employeesApi.directory.remove(e.SSO)
load()
} catch (err) {
toast.error(apiError(err, 'Failed to delete'))
}
}
function onFile(event) {
const file = event.target.files[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => { csvText.value = reader.result }
reader.readAsText(file)
}
async function doImport() {
importing.value = true
try {
const response = await employeesApi.directory.importCsv(csvText.value)
toast.success(response.data?.message || 'Imported')
showImport.value = false
csvText.value = ''
load()
} catch (err) {
toast.error(apiError(err, 'Import failed'))
} finally {
importing.value = false
}
}
</script>
<style scoped>
.hint { color: var(--text-light); font-size: 0.9rem; }
.muted { color: var(--text-light); }
.mono { font-family: monospace; font-size: 0.85rem; }
.filters { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; }
.filters .form-control { max-width: 320px; }
.result-count { color: var(--text-light); font-size: 0.85rem; }
.form-row { display: flex; gap: 1rem; }
.form-row .form-group { flex: 1; }
.pagination { display: flex; align-items: center; justify-content: center; gap: 1rem; padding: 0.9rem 0 0.2rem; }
.page-info { color: var(--text-light); font-size: 0.85rem; }
.ge-thumb-fallback { object-fit: contain; padding: 2px; background: #fff; }
.photo-thumb { width: 36px; height: 36px; object-fit: cover; border-radius: 4px; border: 1px solid var(--border); }
.photo-manage { display: flex; align-items: center; gap: 1rem; }
.photo-thumb-lg { width: 80px; height: 80px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border); }
.photo-actions { display: flex; align-items: center; gap: 0.5rem; }
</style>

View File

@@ -1,15 +0,0 @@
{
"name": "employees",
"version": "1.0.0",
"description": "Read-only employee directory lookup (separate HR database)",
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=0.1.0,<1.0.0",
"api_prefix": "/api/employees",
"provides": {
"features": [
"employee_directory"
]
},
"default_enabled": false
}

View File

@@ -1,14 +0,0 @@
"""Alembic environment for the employees plugin migration chain.
Delegates to the shared runner in shopdb.plugins.alembic_template, which
filters the metadata to this plugin's tables and drives Alembic against the
per-plugin version table alembic_version_employees. See ADR-008 for the
ownership cutover between the core chain and per-plugin chains.
"""
import os
os.environ['PLUGIN_NAME'] = 'employees'
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
run_migrations()

View File

@@ -1,24 +0,0 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@@ -1,28 +0,0 @@
"""employees plugin anchor (ownership cutover).
Stamp-only no-op. The core Alembic chain (baseline .. 7d16_directoryemployees)
already created every table this plugin owns at the cutover point, so there is
nothing to build here. This revision gives the plugin's own chain a base that
`flask plugin upgrade-all` can stamp into alembic_version_employees. From this
anchor forward, new employees schema changes land as 000N revisions in this
directory, never in the core chain. See ADR-008.
"""
from alembic import op # noqa: F401
import sqlalchemy as sa # noqa: F401
# revision identifiers, used by Alembic.
revision = 'employees0001anchor'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# No-op: the core chain owns every table that exists at the cutover.
pass
def downgrade():
# No-op: this anchor never unwinds core-owned tables.
pass

View File

@@ -1,40 +0,0 @@
"""Add photofilename to directoryemployees (self-hosted employee photos).
The core chain (7d16_directoryemployees) created directoryemployees WITHOUT a
photofilename column. This plugin revision adds it, so BOTH fresh installs (core
chain builds the table, then this adds the column) and existing installs get it.
Guarded/idempotent: skips when the table is absent (plugin disabled) or the
column already exists (e.g. a test DB built by db.create_all() from the model).
Revision ID: employees0002photo
Revises: employees0001anchor
"""
from alembic import op
import sqlalchemy as sa
revision = 'employees0002photo'
down_revision = 'employees0001anchor'
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'directoryemployees' not in insp.get_table_names():
return
cols = {c['name'] for c in insp.get_columns('directoryemployees')}
if 'photofilename' not in cols:
op.add_column('directoryemployees',
sa.Column('photofilename', sa.String(length=255), nullable=True))
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'directoryemployees' not in insp.get_table_names():
return
cols = {c['name'] for c in insp.get_columns('directoryemployees')}
if 'photofilename' in cols:
op.drop_column('directoryemployees', 'photofilename')

View File

@@ -1,5 +0,0 @@
"""Employees plugin models."""
from .directory_employee import DirectoryEmployee
__all__ = ['DirectoryEmployee']

View File

@@ -1,43 +0,0 @@
"""Self-hosted employee directory.
For sites with no external HR database. When employee_directory_mode is
'selfhosted', the employee lookup APIs read this app-owned table instead of the
external directory, and the directory is managed in-app (CRUD + CSV import).
to_dict emits the same keys the external contract returns (SSO, First_Name,
Last_Name, Team, Role, Picture) so the frontend and both modes share one shape.
photofilename holds the basename of an uploaded photo (photo-<sso><ext>) served
from instance/employeephotos/. It is distinct from the legacy Picture text
field: in self-hosted mode the displayed photo comes from uploads (photofilename)
via the shared resolver, not from Picture.
"""
from shopdb.api import db
class DirectoryEmployee(db.Model):
__tablename__ = 'directoryemployees'
sso = db.Column(db.Integer, primary_key=True, autoincrement=False)
firstname = db.Column(db.String(100), nullable=False)
lastname = db.Column(db.String(100), nullable=False)
team = db.Column(db.String(100))
role = db.Column(db.String(100))
picture = db.Column(db.String(255))
# basename of an uploaded photo (photo-<sso><ext>); None when no upload
photofilename = db.Column(db.String(255))
def to_dict(self):
# Keys match the external employees contract the frontend consumes.
# photofilename is extra (self-hosted upload); the resolved display URL
# is added as photourl by the API layer via resolve_employee_photo_url.
return {
'SSO': self.sso,
'First_Name': self.firstname,
'Last_Name': self.lastname,
'Team': self.team,
'Role': self.role,
'Picture': self.picture,
'photofilename': self.photofilename,
}

View File

@@ -1,115 +0,0 @@
"""Employees plugin main class.
Blueprint-only plugin: a read-only employee directory lookup over a separate HR
database. No model (the directory is an external DB) and no AssetType. The
connection helper (employee_connection) stays core infrastructure in shopdb.api,
shared with search and the notifications shopfloor feed.
"""
import json
import logging
from pathlib import Path
from typing import List, Dict, Optional, Type
from flask import Flask, Blueprint
from shopdb.plugins.base import BasePlugin, PluginMeta
from .api import employees_bp
from .models import DirectoryEmployee
logger = logging.getLogger(__name__)
class EmployeesPlugin(BasePlugin):
"""Employees plugin - read-only HR directory lookup."""
def __init__(self):
self._manifest = self._load_manifest()
def _load_manifest(self) -> Dict:
"""Load plugin manifest from JSON file."""
manifest_path = Path(__file__).parent / 'manifest.json'
if manifest_path.exists():
with open(manifest_path, 'r') as f:
return json.load(f)
return {}
@property
def meta(self) -> PluginMeta:
"""Return plugin metadata."""
return PluginMeta(
name=self._manifest.get('name', 'employees'),
version=self._manifest.get('version', '1.0.0'),
description=self._manifest.get(
'description',
'Read-only employee directory lookup'
),
author=self._manifest.get('author', 'ShopDB Team'),
dependencies=self._manifest.get('dependencies', []),
core_version=self._manifest.get('core_version', '>=0.1.0,<1.0.0'),
api_prefix=self._manifest.get('api_prefix', '/api/employees'),
)
def get_permissions(self) -> List:
"""Reading the staff directory.
The directory is names, SSOs, photographs and who reports to whom. It
was reachable with no account at all, which is how a permissions
question ends up answered by nobody. One read permission: the write
side of this plugin is an import, run from the CLI.
"""
return [
('employees.view', 'View the employee directory', 'employees'),
]
def get_blueprint(self) -> Optional[Blueprint]:
"""Return Flask Blueprint with API routes."""
return employees_bp
def get_models(self) -> List[Type]:
"""Self-hosted directory table (used when directory_mode=selfhosted).
External mode reads a separate DB via employee_connection instead."""
return [DirectoryEmployee]
def get_provisioning_note(self) -> Optional[Dict]:
return {
'mode_setting': 'employee_directory_mode',
'tables': ['directoryemployees'],
'note': ('Enabling this in self-hosted mode creates a '
'"directoryemployees" table in the shopdb database (SSO, '
'First_Name, Last_Name, Team, Role, Picture). It is '
'referenced across the site: employee search, notification '
'recognition (name + photo on the shopfloor/lobby displays), '
'and USB check-in/out name resolution. Manage people under '
'Settings > Employee Directory, or point at an existing HR '
'database instead (external mode).'),
'docs': 'plugins/employees/README.md',
}
def get_config_schema(self) -> List[Dict]:
"""Employee directory DB connection. Host/name/user are settings the
wizard can edit; the password stays in .env (emitted, not stored)."""
return [
{'key': 'employee_photo_base_url', 'label': 'Photo base URL',
'type': 'text', 'secret': False,
'help': 'Where external HR relative Picture paths resolve, e.g. '
'https://host/EmployeeDBAPP/images/. Blank = this '
'site\'s /static/employees/.'},
{'key': 'employee_db_host', 'label': 'Employee DB host', 'type': 'text',
'secret': False, 'default': 'localhost',
'help': 'This DB must expose an "employees" table or view with columns '
'SSO, First_Name, Last_Name, Team, Role, Picture. '
'See plugins/employees/README.md.'},
{'key': 'employee_db_name', 'label': 'Employee DB name', 'type': 'text',
'secret': False, 'default': 'wjf_employees'},
{'key': 'employee_db_user', 'label': 'Employee DB user', 'type': 'text',
'secret': False},
{'key': 'employee_db_password', 'label': 'Employee DB password', 'type': 'password',
'secret': True, 'envvar': 'EMPLOYEE_DB_PASSWORD',
'help': 'Stored in .env, not the database. The wizard shows the line to paste.'},
]
def init_app(self, app: Flask, db_instance) -> None:
"""Initialize plugin with Flask app."""
logger.info(f"Employees plugin initialized (v{self.meta.version})")

View File

@@ -1,77 +0,0 @@
# Adoption tool spike
Status: spike. Built on the `origin/main` baseline so it can be cherry-picked as
a single unit once endpoint agent development is complete.
## What it is
A one-shot, agentless enrollment tool. An admin generates a small download from
the Endpoints key settings page; running the tool once on a PC creates a ShopDB
PC (Computer) and Endpoint entry using only the endpoints collector key. It is
NOT the endpoint agent: it installs nothing, runs no service, and sends no
ongoing telemetry. It targets sites that want an inventory entry without running
the full agent.
## How it works
1. Admin generates or reuses an endpoint agent key (existing `/agentkey` flow).
2. Admin opens the "Adoption tool" section, confirms the base URL (prefilled
from the browser origin) and pastes the collector key (prefilled when a key
was just generated, since the secret is only shown once).
3. `POST /api/endpoints/adoptiontool/package` (admin-gated) returns a ZIP with
`adopt.config.json`, `README.txt`, and `shopdb-adopt.exe` when a prebuilt
binary is staged on the server.
4. On the target PC, `shopdb-adopt.exe` reads the config beside it (or CLI args),
then POSTs to `/api/collector/endpoints` with `X-API-Key` and `adopt=true`.
The existing `_adopt_computer` path creates the Computer + EndpointState.
## Collection
The tool gathers the same identity, hardware, and SBOM subset the endpoint
agent collects, using identical WMI queries and wire keys so the server stores
it without translation:
- Identity: `hostname`, `loggedinuser`, `ipaddress`.
- Hardware: `serialnumber` (Win32_BIOS), `vendorname` / `pctype` / `modelnumber`
(Win32_ComputerSystem), `osname` and `lastboottime` (Win32_OperatingSystem).
- SBOM: `applications` = both Uninstall registry hives (64-bit + WOW6432Node),
de-duped `{displayname, displayversion, publisher}`. The server logs this as
an unpersisted key today (same as the agent) until the SBOM store lands.
The tool requires administrator rights (enforced by its app manifest
`requireAdministrator`, with a runtime elevation guard) because the machine
Uninstall hives and full WMI need elevation, or collection would be silently
partial.
## Cherry-pick boundary
The spike is deliberately isolated. Cherry-pick exactly:
- `agent/ShopDbAdoptionTool/` (new standalone tool project, incl. `app.manifest`)
- `agent/ShopDbEndpointAgent.slnx` (one line adding the project)
- `plugins/endpoints/api/adoption.py` (new module; attaches to `endpoints_bp`)
- `plugins/endpoints/api/__init__.py` (one-line import to register the route)
- `plugins/endpoints/frontend/views/AgentKeySettings.vue` (new UI section only)
- `plugins/endpoints/ADOPTION-TOOL-SPIKE.md` (this file)
`plugins/endpoints/api/routes.py` is intentionally untouched so the delivery
spike changes there do not tangle with this feature.
## Prebuilt exe distribution
The api container has no .NET SDK, so the package embeds the exe only when a
prebuilt copy is staged at `instance/adoptiontool/shopdb-adopt.exe`. Otherwise
the ZIP ships the config plus README (build the exe out of band, same model as
the endpoint agent installer bundle). Follow-up: a CI job that publishes
`shopdb-adopt.exe` and stages it into the image or instance path.
## Known follow-ups
- Agentless health signal: an adopted PC never reports again, so
`EndpointState.lastreportedat` reflects only the adoption moment. The fleet
health/dashboard logic should distinguish "agentless, adopted" from
"agent expected but silent" so these PCs do not read as permanently failed.
Left out of this spike to keep the server create-path unchanged for a clean
cherry-pick; the tool does not send an `agentless` marker yet because the
server does not persist one (`KNOWN_PAYLOAD_KEYS`).
- CI packaging of the prebuilt exe (see above).

View File

@@ -10,9 +10,10 @@ schema changes, update this file in the same change.
- Method/URL: `POST {baseurl}/api/collector/endpoints`
- Auth header: `X-API-Key: <collector key>` (Bearer token also accepted). The
key is a UI-managed, endpoints-only service token minted from Settings ->
Endpoint agent key (section 6). A bootstrap `.env` collector key still works
until the first UI key is generated, then it is switched off (section 6). No
querystring fallback.
Endpoint agent key (section 7). A bootstrap `.env` collector key still works
until the first UI key is generated, then it is switched off (section 7). No
querystring fallback. Agents 1.20 and later enroll and then send
`X-Endpoint-Credential` instead of the shared key (section 9).
- Body: `application/json`, a single flat object (NOT an array). One POST per
report per endpoint.
- Identity field: `hostname` (required, case-insensitive).
@@ -51,9 +52,69 @@ arrays. `adopt` is first-contact only.
}
],
"scheduledtasks": [ /* opaque array */ ],
"disks": [
{
"drive": "C:",
"label": "OS",
"filesystem": "NTFS",
"sizebytes": 511000000000,
"freebytes": 128000000000,
"usedbytes": 383000000000,
"freepercent": 25.0 // precomputed; a threshold is a direct compare
}
],
"bios": { /* opaque object: vendor, version, release date */ },
"memory": { /* opaque object: totalbytes, freebytes */ },
"processor": { /* opaque object: name, cores, logicalprocessors, maxclockmhz */ },
"osinfo": { /* opaque object: build, version, install date */ },
"domain": "shopfloor.example.com", // domain or workgroup membership
"pendingreboot": false, // omit when unknown; absent != false
"rebootreasons": [], // [] = checked, nothing pending; see notes
"bootinfo": {
"hiberbootenabled": false, // fast startup CONFIGURED
"lastboottype": "Cold" // what the last boot DID: Cold|Fast startup|Resume|Unknown
},
"defender": { // persisted, deliberately not surfaced
"serviceenabled": true,
"realtimeenabled": true,
"signatureagedays": 0, // real 0 = updated today; null = did not report
"antispywareagedays": 0,
"quickscanagedays": 0
},
"diskhealth": [ // per PHYSICAL device; see notes
{
"deviceid": "0",
"model": "Samsung SSD 990 PRO 1TB",
"mediatype": "SSD",
"bustype": "NVMe",
"healthstatus": "Healthy",
"predictfailure": false, // precomputed; null = drive did not answer
"source": "physicaldisk" // or "diskdrive"
}
],
"usercontext": { // omitted entirely when nobody is signed in
"netuse": [
{
"localname": "Z:",
"remotename": "\\\\fileserver\\programs",
"status": "OK",
"username": "SHOPFLOOR\\operator",
"persistent": true
}
],
"userlisteners": [
{ "localaddress": "0.0.0.0", "localport": 7878, "processid": 4512, "processname": "DncMain" }
],
"idleseconds": 45,
"username": "operator",
"elevated": false, // token elevation of the collecting process;
// a spool stamped true is REJECTED (see 5.3)
"collectedat": "2025-01-14T06:12:00Z"
},
"events": [
{
"eventid": 6008, // required per item; non-catalog IDs are dropped, not errored
"eventrecordid": 184412, // Windows record id; dedupe key, see notes
"eventchannel": "System",
"eventsource": "EventLog",
"eventtime": "2025-01-14T06:11:58Z",
@@ -81,6 +142,71 @@ Notes:
(`HKLM\SOFTWARE\[WOW6432Node\]GE Aircraft Engines\DNC\General:MachineNo`) or
the UDC `udc_settings_*.json` (`GeneralSettings.MachineNumber`), in that order
(DESIGN-DECISIONS-agent.md 9.4/9.5). `""` or `"9999"` means none.
- `eventrecordid` is the Windows event record id: monotonic and unique within a
channel on a machine. The agent uses it as a per-channel harvest watermark and
the server enforces a unique index on
`(assetid, eventchannel, eventrecordid)`, so one real event is stored exactly
once. It is nullable: an older agent omits it, and MySQL permits repeated
NULLs in a unique index, so those reports keep the previous append behavior
rather than failing ingest. Send it whenever the source record exposes one.
- `disks`/`bios`/`memory`/`processor`/`osinfo`/`usercontext` are stored verbatim
as JSON snapshots on the endpoint state, newest-wins. `domain` and
`pendingreboot` are scalar columns because the fleet view filters and alerts
on them.
- OMITTING a key is not the same as sending an empty value. Every one of these
fields is applied only when the key is PRESENT, so a report that omits it
preserves the last-known value. Send `null`/`[]` only when you mean "this is
genuinely empty now"; omit when you simply did not collect it this cycle.
- `pendingreboot` follows that rule strictly: omit it when the check failed. A
literal `false` asserts the endpoint is confirmed NOT holding a reboot.
- `rebootreasons` is NOT redundant with `pendingreboot`. That key is a single
bool OR-ed from three registry signals and cannot say which fired, and the
difference is operational: `pendingfilerename` means files are staged for
replacement on next boot, so the box is mid-servicing, while a lone
`componentbasedservicing` or `windowsupdate` key can sit set for weeks on a
machine nobody has rebooted. Same boolean, very different urgency. An EMPTY
array means checked and nothing pending; OMIT the key when the check failed.
- `bootinfo` carries two distinct facts that must not be collapsed:
`hiberbootenabled` is whether fast startup is CONFIGURED (registry, always
readable) and `lastboottype` is what the last boot ACTUALLY did (Kernel-Boot
event 27). The registry cannot tell you what the last boot did, and the event
may have aged out of the log, so neither substitutes for the other. This
exists because uptime LIES under fast startup: a box reports days of uptime
after what the operator experienced as a shutdown.
- `defender` is stored but deliberately NOT surfaced. Defender produces known
false positives in this environment, so wiring it to a card now would train
operators to ignore that card. Signature/scan ages are in DAYS; a real `0`
means updated today, so send `null` when Defender did not report rather than
defaulting to zero.
- `interfaces` entries gained `linkspeedbps` and `fullduplex`, paired on by MAC
from `MSFT_NetAdapter`. Both are `null` when link state was unreadable or the
adapter is disconnected - an unknown duplex must not read as half-duplex.
- `diskhealth` is per PHYSICAL device and is NOT the same list as `disks`, which
is per volume. One physical drive can back several volumes or none the
operator sees, so the two do not share a key and are not merged.
- `diskhealth` carries BOTH agent sources and the server does not reconcile
them. `source: "physicaldisk"` is MSFT_PhysicalDisk, the only source that
reports NVMe at all - the classic ATA SMART class
(`MSStorageDriver_FailurePredictStatus`) returns zero instances on an NVMe
machine, so an agent collecting only that would report an empty health list on
modern hardware and look like "no problems". `source: "diskdrive"` is
Win32_DiskDrive.Status, covering ATA/SATA and hosts where the Storage
namespace is unavailable. When both answer for one drive and disagree, both
rows are shown; that disagreement is the signal.
- `predictfailure` is precomputed by the agent so an alert is a boolean test
rather than a string match against a Windows enum the server would have to
keep in sync. `null` means the drive did not answer: unknown, NOT healthy.
`healthstatus` keeps the human-readable source value alongside it.
- `usercontext` is gathered by a probe run inside the signed-in operator's
session by the `ShopDB user-context probe` scheduled task (and, opportunistically,
by the tray), because mapped drives, user-owned listeners and idle time
do not exist in the agent's own LocalSystem session 0. It is omitted when no
one is signed in, which is a normal unattended state, not a fault. The
security-context split that makes this work is normative; see section 5.
- `ports` carries LISTENING sockets only. The full connection table is available
on demand via the `network_connections` command instead, because established
connections churn constantly and would change every report for no diagnostic
gain.
## 3. Response
@@ -137,13 +263,23 @@ public sealed class EndpointReport
[JsonPropertyName("ports")] public object[]? Ports { get; set; }
[JsonPropertyName("interfaces")] public object[]? Interfaces { get; set; }
[JsonPropertyName("scheduledtasks")]public object[]? ScheduledTasks { get; set; }
[JsonPropertyName("disks")] public object[]? Disks { get; set; }
[JsonPropertyName("bios")] public object? Bios { get; set; }
[JsonPropertyName("memory")] public object? Memory { get; set; }
[JsonPropertyName("processor")] public object? Processor { get; set; }
[JsonPropertyName("osinfo")] public object? OsInfo { get; set; }
[JsonPropertyName("domain")] public string? Domain { get; set; }
[JsonPropertyName("pendingreboot")] public bool? PendingReboot { get; set; }
[JsonPropertyName("usercontext")] public object? UserContext { get; set; }
[JsonPropertyName("applications")] public object[]? Applications { get; set; }
[JsonPropertyName("events")] public EndpointEventRecord[]? Events { get; set; }
}
public sealed class EndpointEventRecord
{
[JsonPropertyName("eventid")] public int EventId { get; set; }
[JsonPropertyName("eventchannel")] public string? EventChannel { get; set; }
[JsonPropertyName("eventid")] public int EventId { get; set; }
[JsonPropertyName("eventrecordid")] public long? EventRecordId { get; set; }
[JsonPropertyName("eventchannel")] public string? EventChannel { get; set; }
[JsonPropertyName("eventsource")] public string? EventSource { get; set; }
[JsonPropertyName("eventtime")] public DateTimeOffset? EventTime { get; set; }
[JsonPropertyName("eventmessage")] public string? EventMessage { get; set; }
@@ -163,7 +299,102 @@ public sealed class CollectorResponse
Serializer settings: `DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull`
so omitted fields are not sent (server upserts only present keys). Send UTC ISO-8601.
## 5. "Build site agent" settings flow (build cadence)
## 5. Security contexts, spool handoff and log destinations (NORMATIVE)
Three processes run as three different principals. Confusing them is what makes
`usercontext` silently empty, so the boundaries are fixed by contract.
| Process | Runs as | May write to |
|---|---|---|
| Service (`shopdbagent.exe`) | LocalSystem, session 0 | `%ProgramData%\ShopDB\EndpointAgent\logs\<date>-agent.log` |
| Tray + probe (`shopdb-endpoint-tray.exe`, `shopdbagent.exe --collect-usercontext`) | `ShopFloor` operator, interactive session, UNELEVATED (task principal `S-1-5-32-545` at `RunLevel Limited`) | `C:\Users\ShopFloor\ShopDB\` and `C:\Users\ShopFloor\ShopDB\Logs\<date>-trayagent.log` |
| Installer (`install.ps1`, generated by `_install_script()` in `plugins/endpoints/api/siteagent.py`) | Elevated admin, one-shot | `%ProgramData%\ShopDB\Logs\<datetime>-shopdbinstall.log` and the append-only ledger `%ProgramData%\ShopDB\Logs\shopdbagent-installs.log` |
Rules:
1. **The operator cannot write to `C:\Program Files` or to the SYSTEM-owned
`%ProgramData%\ShopDB\EndpointAgent\logs`.** Anything running in the user's
session MUST log under `C:\Users\ShopFloor\ShopDB\Logs`. The file log sink
degrades to a silent no-op when it cannot create its directory, so violating
this does not raise an error - it discards the log line and the failure
becomes invisible.
2. **User context is collected in the operator's own logon session**, because
mapped drives, user-owned listeners and idle time are per-session and do not
exist in session 0. The service MUST NOT try to descend into the session to
collect them. The authoritative collector is the `ShopDB user-context probe`
scheduled task, registered by the installer against the `BUILTIN\Users` group
(`S-1-5-32-545`) at `RunLevel Limited`, triggered at logon and every 30
minutes. The tray also probes on its own cadence and writes the same spool;
the two are belt-and-braces and the last writer wins.
3. **The probe MUST NOT run elevated, and a spool stamped `elevated` is
REJECTED.** Drive letters are a property of the logon token, not the machine:
an elevated process gets a different token to the operator's interactive one
and enumerates a different, usually empty, drive table. This was measured on a
live endpoint, where the same two shares reported `Unavailable` from an
elevated shell and `OK` from the operator's, in both `Win32_NetworkConnection`
and `Get-SmbMapping`. `--collect-usercontext` therefore refuses to run when
elevated (exit code 2) rather than producing a confident wrong answer, and the
installer MUST NOT start the tray or probe as a child process - it is
elevated, and `explorer.exe` de-elevates without changing ACCOUNT, so an
install run by an admin produced a probe running as that admin. It MUST
instead reach the operator's session through a scheduled task principal.
`RunLevel Limited` on the task is load-bearing for the same reason; a task
promoted to `Highest` reproduces the bug while still looking registered.
4. **The probe WRITES the spool; the service only READS it.** The spool PERSISTS
and is overwritten by each probe run. The service MUST NOT delete it: the
collector's last-known-good cache is in-memory only, so consuming the file
meant a service restart blanked mapped drives until the next probe cycle.
Staleness is bounded by the in-file `collectedat` stamp (max age 90 minutes),
not by deletion, so a probe that has stopped running still stops re-reporting
stale drives - it just does so on a timer instead of on a read.
5. **The spool location is fixed at `C:\Users\ShopFloor\ShopDB\usercontext.json`,
and writer and reader MUST derive it from the same helper.** Every shop-floor
endpoint uses the same local `ShopFloor` account. Resolving the path through
`WTSQueryUserToken` or through `%USERPROFILE%` is forbidden: the former is
privileged and endpoint protection blocks it, the latter is wrong under a
brokered or re-parented tray launch, and in both cases the writer and reader
disagree about the location with no error on either side.
6. **The probe MUST verify the spool exists after writing it.** A zero exit code
from `powershell.exe` proves the interpreter started, not that a file was
produced; success is reported only when the file is present on disk.
7. **A spool not stamped `ShopFloor` is REJECTED.**
collecting `username` and the service discards any spool whose stamp is
missing or different. Drive mappings from the wrong session are worse than
no drive mappings, so this fails loudly rather than degrading quietly.
8. **Losing user context is never fatal.** If the `ShopFloor` profile is absent
or unwritable, `usercontext` is omitted and telemetry, commands, inventory
and the command channel continue unaffected.
9. **`--collect-usercontext` MUST NOT touch privileged resources.** It runs as
the operator and only writes into that operator's own profile, so it is
handled before the service's bootstrap logger, fault monitor and config load.
It MUST NOT open the SYSTEM-owned log directory, register an EventLog source,
or read `CollectorKey` from HKLM - those are precisely the calls an EPM broker
intercepts, and the registry read would put the collector secret in a process
that has no need of it. It therefore also works on a host whose CollectorKey
has not been provisioned yet.
10. **The installer provisions** `C:\Users\ShopFloor\ShopDB\` and its `Logs`
subdirectory with `ShopFloor: Modify` before the probe first runs.
11. **Every install appends one line to the install ledger**, including
reinstalls and repeat installs of the same version, recording timestamp,
host, installing user, service version, tray version, install directory and
the user-context task state (`ok:<principal>/<runlevel>`, `UNVERIFIED:...`,
`FAILED:...` or `skipped`). The task is the single point of failure for
session-scoped collection, so its registration outcome MUST be recorded
where it survives the install transcript.
12. **Task registration MUST be verified by reading the task back**, not inferred
from `Register-ScheduledTask` returning without error. Policy or a
management agent can rewrite or drop a task after registration, and a task
that exists but runs with the wrong principal or run level looks healthy in
the scheduler UI while producing wrong data.
13. **All log filenames use `<stamp>-<component>.log`** so service, tray and
installer logs sort chronologically and collect with one date-leading glob.
The three log destinations are deliberately separate: an install is one-shot and
elevated, the service log is a rolling runtime log, and the tray log is the only
one written from the operator's session. Merging them makes an install
indistinguishable from runtime behaviour when reading a failure back.
## 6. "Build site agent" settings flow (build cadence)
Goal: an operator in ShopDB Settings produces an instance-locked `.exe` that
knows its server URL and its collector key, needing no manual config on the
@@ -190,7 +421,7 @@ Open item (from DESIGN-DECISIONS-agent.md 1a): whether `adopt` stays a
per-report flag or is promoted to a site setting (strict vs self-adopting
fleet). Default proposed: agent sends `adopt=true` on first run only.
## 6. UI-managed agent key (kill switch)
## 7. UI-managed agent key (kill switch)
The endpoint agent authenticates with a single UI-managed key scoped to ONLY
`collector.ingest.endpoints`, so rotating or revoking it stops all
@@ -215,13 +446,13 @@ The bootstrap `.env` key (`COLLECTOR_API_KEY[_ENDPOINTS]`) remains only as a
first-run path before a UI key exists; it cannot be the runtime kill switch
because container env is injected at boot and cannot be rotated live.
## 7. Command channel (WebUI -> agent)
## 8. Command channel (WebUI -> agent)
The WebUI can send an endpoint an ALLOWLISTED command (never an arbitrary
command string). Delivery is agent-initiated long-poll, so shop-floor PCs open
no inbound port: the agent makes an outbound request the server holds open until
a command is queued, runs it locally, and posts the result back. All three
routes reuse the same endpoints collector key (section 6) via `X-API-Key`, so
routes reuse the same endpoints collector key (section 7) via `X-API-Key`, so
the command channel shares the kill switch and adds no second secret.
Allowlisted actions (server source of truth: `plugins/endpoints/commands.py`):
@@ -230,6 +461,14 @@ Allowlisted actions (server source of truth: `plugins/endpoints/commands.py`):
(`C:\ProgramData\UDC\MTConnect_UDC\Agent\run.bat`).
- `mtconnect_stop` - stop the running MTConnect UDC agent process.
- `mtconnect_status` - report whether the MTConnect UDC agent is running.
- `network_connections` - return the full TCP connection table with owning
process ids and names. Read-only diagnostic. The result is capped at 500
connections with a `truncated` flag in `detail`, because an oversized payload
rejected by the server returns nothing at all and a partial answer is more
useful than a failed one. Standard reports carry listening ports only.
- `run_script` - run a reviewed PowerShell script from the script library. The
body is NOT carried on the command channel; the claim carries a descriptor and
the agent fetches the bytes separately (section 7.4).
### 7.1 Claim the next command (long-poll)
@@ -274,3 +513,444 @@ Allowlisted actions (server source of truth: `plugins/endpoints/commands.py`):
gated by user JWT + `endpoints.command`. Only one command may be in flight per
endpoint (a second enqueue returns HTTP 409 until the first finalizes).
### 7.4 `run_script` claim descriptor
A `run_script` claim carries a `script` object alongside the usual
`endpointcommandid` and `action`:
```jsonc
{
"scriptid": 12,
"scriptname": "sfld_autologon",
"installpath": "/api/endpoints/scriptbody/12", // fetch with X-API-Key
"sha256": "3f9a...", // 64 hex chars, lowercase
"runcontext": "system", // "system" | "user"
"workingdirectory": "C:\\Tools", // "" = agent staging folder
"errormode": "default" // "default" | "strict"
}
```
The agent MUST download `installpath`, verify the SHA-256 of the received bytes
against `sha256`, and refuse to run on mismatch. The body never travels on the
command channel so a queued command cannot be used to smuggle code past review.
`workingdirectory` is the folder the script runs from. Empty means the agent's
staging directory, which is correct for a self-contained script and wrong for
one invoking a separately-installed tool by relative path. A pinned folder that
does not exist MUST fail the command rather than falling back, so the script
never runs somewhere unintended.
`errormode` selects how the run is judged:
- `default` - PowerShell's normal behaviour. Most errors are non-terminating:
the script writes the error, continues, and still exits zero.
- `strict` - the agent prepends `$ErrorActionPreference = 'Stop'` so the first
error terminates the run and the exit code reflects reality.
`default` is the default precisely BECAUSE it is permissive. Every script
authored before this field existed relies on that behaviour, and some rely on it
deliberately - tolerating a failing cleanup, probing with a cmdlet that errors
when a thing is absent, looping past a bad item. Promoting `strict` globally
would make those bodies abort partway and leave endpoints half-configured, so it
is opt-in per script.
Two consequences the agent MUST honour:
- The strict prologue is applied AFTER digest verification, so the hash still
validates the bytes the server actually delivered.
- Because the prologue adds a line, an error reported on line N of a strict run
refers to line N-1 of the stored body. Surfaces showing the body should say so.
An agent that does not recognise an `errormode` value MUST treat it as
`default`. The server always sends the field explicitly, even at the default, so
an agent that received it and ignored it stays distinguishable from one that
never saw it.
#### Script output
Script stdout/stderr is ALWAYS written to the local agent log on the endpoint.
It is returned to the server in `detail.output` only when the command FAILED.
Routine output stays on the machine that produced it: shipping every successful
run back would put arbitrary script output, including whatever an author printed
while debugging, into the server's command history for no diagnostic gain.
Output is truncated to 8000 characters and flagged when truncated, because an
oversized payload rejected by the server returns nothing at all.
Timed-out runs do not carry output: the script is still running and still holds
the capture file open, so anything read at that moment would be misleading.
## 9. Per-PC credentials
Each endpoint authenticates with its OWN secret, readable only by SYSTEM on
that PC. The server derives the calling asset from the secret. The shared fleet
key (section 7) keeps working for PCs that have not enrolled until an admin
switches it off, and is refused for any PC that has enrolled. The rollout is in
`docs/PER-PC-CREDENTIALS-ROLLOUT.md`.
### 9.1 Headers
| State | Header | Value |
|---|---|---|
| Not enrolled | `X-API-Key` | the shared endpoints key, as today |
| Enrolled | `X-Endpoint-Credential` | `sdbe_` + 43 chars base64url (32 random bytes) |
Never both on one request. The server stores only the SHA-256 of the full
credential plus its first 12 characters (`credentialprefix`) for display.
### 9.2 Enroll
`POST {baseurl}/api/endpoints/enroll`. The route never refuses a call for its
authentication: a PC installed without the shared key still records a pending
request an admin can approve. `X-API-Key` only decides auto-issue: a valid
shared key while `plugin.endpoints.sharedkeyallowed` is true. No key, an invalid
key (logged by the server, never a 401 here) or the shared key switched off all
mean the call is unauthenticated, and it then only returns a credential for a
request an admin has approved for this hostname, serial and nonce. There is one
request row per hostname and serial (case-insensitive); repeated calls update it.
```jsonc
{
"hostname": "SHOP-PC-014", // 1-255
"serialnumber": "5CD1234ABC", // 1-128, Win32_BIOS.SerialNumber
"deviceid": null, // HKLM\SOFTWARE\GE\ShopDB DeviceId or null
"enrollnonce": "<43 chars>", // 32-128 [A-Za-z0-9_-], generated once, persisted
"agentversion": "1.20.0"
}
```
| Status | Body | Agent action |
|---|---|---|
| 201 | `{"status":"enrolled","credential":"sdbe_...","assetid":123}` | store, switch headers, no restart |
| 202 | `{"status":"pending","requestid":45,"reason":"serial mismatch"}` | retry about every 60 s for the first 10 min, then every 5 min to 30 min, then every 15 min indefinitely, with jitter |
| 403 `ENROLL_NONCE_MISMATCH` | error envelope | an approved request belongs to a different nonce; keep retrying, an admin must reject and re-approve |
| other | error envelope | back off 1, 5, 15 min (cap); a 429 or 5xx uses this backoff, not the pending schedule |
Auto-issue happens only when `plugin.endpoints.autoenroll` is true, the shared
key authenticated the call, the hostname matches exactly one computer, the
serial on record matches (trimmed, case-insensitive) and the asset has no active
credential. Otherwise the request is pending with one of the reasons `unknown
hostname`, `duplicate hostname`, `no serial on record`, `serial mismatch`,
`already enrolled`, `awaiting approval`, `nonce changed`, `reenroll blocked`.
`reenroll blocked` means an admin revoked the PC with `blockreenroll` (9.7); the
agent keeps retrying on its normal schedule and enrolls once an admin calls
`allow-reenroll`. The credential is in the 201 body exactly once.
### 9.3 Error codes
All use the standard error envelope (`data.error.code`).
| HTTP | Code | Meaning | Agent action |
|---|---|---|---|
| 401 | `CREDENTIAL_INVALID` | unknown or revoked credential | delete credential.bin, fall back to the shared key, wake enrollment; a `scriptbody` / `apppackage` download retries once (shared key, or a credential enrolled meanwhile) and the command result carries `detail.authfallback`; with no shared key the command fails saying so |
| 401 | `SHARED_KEY_REFUSED` | shared key sent, but this PC is enrolled or the shared key is switched off | wake enrollment; use the credential |
| 403 | `ASSET_MISMATCH` | the credential's asset differs from the hostname/assetid named in the request | log; do not retry the same request |
`ASSET_MISMATCH` also covers the case where the hostname the agent sends is on
NO computer record at all, which is a data problem on the server (an enrolled PC
whose computer record has no hostname) rather than a PC claiming to be another
one. The code and status are the same, so nothing changes for the agent, but the
message says which of the two it is and names the hostname. An admin fixes it by
setting the hostname on the enrolled computer; approving an enroll request does
that on its own (9.7).
Routes that authenticate this way: `POST /api/endpoints/collect` (same handler
as `/api/collector/endpoints`), `GET /api/endpoints/monitors/resolved`,
`GET /api/endpoints/commands/next`, `POST /api/endpoints/commands/<id>/result`,
`GET /api/endpoints/apppackage/...`, `GET /api/endpoints/scriptbody/<id>`,
`POST /api/endpoints/operatorreports`, `GET /api/endpoints/alerts`,
`POST /api/endpoints/alerts/<id>/acknowledge`. With a credential, a missing
hostname is allowed and the asset comes from the credential; an acknowledge with
assetid 0 or absent means the caller. Acknowledging an alert that does not
target the caller returns 404 `NOT_FOUND`, as for an unknown id.
The core collector
(`/api/collector/computers`, `/api/collector/printers`) accepts the same header
for the SYSTEM reporter scripts.
### 9.4 Storage on the endpoint
- `%ProgramData%\ShopDB\EndpointAgent\credential\` is ACLed SYSTEM and
Administrators only.
- `credential.bin` = DPAPI `LocalMachine` of UTF-8 JSON
`{"credential":"sdbe_...","assetid":123,"issuedat":"...","baseurl":"..."}`.
SYSTEM scripts read it with
`[System.Security.Cryptography.ProtectedData]::Unprotect(bytes, $null, 'LocalMachine')`
and must not send the credential to a URL outside `baseurl`.
- `enroll.json` = `{"enrollnonce":"...","createdat":"..."}`, plain, same folder.
- The shared key in the registry and appsettings.json is never deleted by the
agent; clearing it is a cutover step in the runbook.
### 9.5 Server pins
- `HKLM\SOFTWARE\GE\ShopDB` `ServerPins` (REG_MULTI_SZ, both registry views)
is authoritative whenever the value EXISTS in either view, even empty: its
pins (possibly none) are the whole set and `serverpins` / legacy
`servercertthumbprint` in appsettings.json are ignored. Only when the value
is absent in both views do the appsettings.json pins apply. The agent logs
which source is in effect (`registry` or `appsettings.json`) at startup and
on every change.
- `Set-ShopDBServerPins.ps1 -Pins <pins>` writes the set; `-None` writes an
empty value (registry authoritative, no pins, normal chain validation);
`-Clear` deletes the value (appsettings.json pins apply again). To retire an
old pin after a rotation, write only the new pin.
- A pin is the lowercase hex SHA-256 of the server leaf certificate's
SubjectPublicKeyInfo. The Server Certificate settings page shows it
(`spkisha256` on `GET /api/endpoints/settings/server-cert`);
`client/Get-ShopDBServerPin.ps1` computes it from a URL or a file;
`client/Set-ShopDBServerPins.ps1` writes it.
- The agent re-reads the pin set within about 60 seconds of a change; no
service restart is needed.
- The same `GET /api/endpoints/settings/server-cert` carries `sha1thumbprint`
(hex SHA-1 of the certificate), and
`GET /api/endpoints/settings/server-cert/download` returns the public
certificate as a `.cer`. `client/Install-ShopDBServerCertificate.ps1` checks
the file against that thumbprint and adds it to `LocalMachine\Root` on PCs
whose pre-1.20 agent validates the chain when it downloads the upgrade.
- Any pin configured: accept the handshake if and only if the leaf key hash is
in the set (chain and name errors ignored). No pin: require a clean Windows
validation.
### 9.6 Tray pipe
The tray holds no secret and talks to the SYSTEM agent over
`\\.\pipe\ShopDB.EndpointAgent.v1` (SYSTEM and Administrators full control,
INTERACTIVE read/write, 8 instances). One UTF-8 JSON object per line, request
then response, then close; request max 64 KB, read timeout 10 s.
| Request | Response |
|---|---|
| `{"op":"ping"}` | `{"ok":true,"agentversion":"1.20.0","enrolled":true,"assetid":123}` |
| `{"op":"sendstatus"}` | `{"ok":true}` or `{"ok":false,"error":"..."}` |
| `{"op":"sendreport","spoolpath":"C:\\Users\\<user>\\...\\report-xxxx"}` | `{"ok":true}` or `{"ok":false,"error":"..."}`; the path must be inside the caller's own profile and not a reparse point |
| `{"op":"alerts"}` | `{"ok":true,"alerts":[ ...same objects as GET /alerts... ]}` |
| `{"op":"ackalert","notificationid":42}` | `{"ok":true}` or `{"ok":false,"error":"..."}` |
The caller is identified from the pipe (impersonation user name and SID), never
from the request body.
### 9.7 Admin API (WebUI side, for reference)
Read needs `endpoints.fleet`; writes need the admin role and are audited.
- `GET /api/endpoints/enrollment` (`page`, `perpage` <= 100, `state`, `search`).
`counts.enrolled` and `counts.notenrolled` count computers by `state`
(`notenrolled` includes `revoked`); `counts.pendingcomputers` counts computers
in state `pending`; `counts.pending` is the number of pending enroll requests,
including requests that match no computer (unknown hostname) and requests from
PCs blocked from re-enrolling, so it equals the pending entries in `requests`.
- `POST /api/endpoints/enrollment/requests/<id>/approve`
`{"assetid": n, "sethostname": false}`. The approval also makes sure the
computer CARRIES the request's hostname, because the credential is bound by
hostname afterwards (9.3): a computer with none is stamped with it
(`hostnameresult` `set`), a computer already carrying the same one is left
alone (`unchanged`), and a computer carrying a DIFFERENT hostname is refused
with **409** naming both plus `details.hostnameconflict`, until the same call
is repeated with `"sethostname": true` (`overwritten`). Both changes are
audited against `Computer`. Approving without this left a one-off PC 403ing
on every call after it enrolled.
- `POST /api/endpoints/enrollment/requests/<id>/reject`
- `POST /api/endpoints/enrollment/assets/<assetid>/revoke` (un-enroll). Optional
body `{"blockreenroll": true, "reason": "rollback"}` (`reason` <= 64 chars,
used only with `blockreenroll`) also keeps the PC from enrolling again: its
enroll requests stay pending with `reenroll blocked`, and the Migration tab
shows it blocked with the same blocker. Works on a PC whose credential is
already revoked. Without the block a 1.20 agent re-enrolls within about a
second of a plain revoke.
- `POST /api/endpoints/enrollment/assets/<assetid>/allow-reenroll` lifts the
block; the PC enrolls on its next attempt. 404 when the PC has no credential
record; a PC that was not blocked returns success unchanged.
- `PUT /api/endpoints/enrollment/settings` `{"sharedkeyallowed": bool, "autoenroll": bool}`
- `GET /api/endpoints/enrollment/migration` (`page`, `perpage` <= 100, `state`, `search`)
- `POST /api/endpoints/enrollment/migration/wave` `{"assetids": [...]}` or `{"next": n}` (n <= 100).
Returns `{"waveid", "queued": [...], "skipped": [{"assetid", "reason"}], "retried": [...]}`.
`retried` lists queued PCs whose only blocker was
`server certificate not trusted by this PC` (see below).
- `GET /api/endpoints` rows carry `credentialstate` (`enrolled`,
`reenrollblocked`, `pending`, `notreporting`, `sharedkey`) and accept a
`credentialstate` filter. `GET /api/endpoints/<assetid>` carries a
`credential` object in the same vocabulary; below `endpoints.fleet` only its
state is filled in.
- `GET /api/endpoints/settings/server-cert/live` (`endpoints.fleet`) reads the
certificate presented at `site_base_url` and returns `presentedspkisha256`,
`presentedsha1thumbprint` and `matchesfile`, or `{"configured": false}`.
- Migration blockers include `server certificate not trusted by this PC`: the
PC's last upgrade download failed on the certificate chain (the server only
knows from the failure the PC reported). It stays shown until a later upgrade
succeeds, so it is retryable: trust the certificate
(`Install-ShopDBServerCertificate.ps1`) and queue the PC again by its id in
`{"assetids": [...]}`. An explicit id is queued when this is the PC's only
blocker and is reported in `retried`; `{"next": n}` never picks such a PC, and
a PC with any other blocker as well is still skipped.
### 9.8 One-line bootstrap install
For a ONE-OFF PC. A managed bay keeps getting the agent from GE-Enforce; this
is the path for a machine nobody is going to author a manifest entry for.
An admin mints a short-lived token in Settings > Endpoints > Build site agent >
One-line install and hands a technician one command, which is run in an
ELEVATED PowerShell on a PC with no agent:
```
powershell -ExecutionPolicy Bypass -c "try { New-Item -ItemType Directory -Force -Path 'C:\Program Files\ShopDB\bootstrap' | Out-Null; icacls 'C:\Program Files\ShopDB\bootstrap' /inheritance:r /grant '*S-1-5-32-544:(OI)(CI)F' '*S-1-5-18:(OI)(CI)F' | Out-Null; if ((Get-Variable LASTEXITCODE -ValueOnly) -ne 0) { Write-Host 'Refusing to run: could not restrict C:\Program Files\ShopDB\bootstrap to administrators (icacls failed). Fix the directory or delete it, then run the command again.' -ForegroundColor Red; exit 6 }; Remove-Item 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1' -Force -ErrorAction SilentlyContinue; Invoke-WebRequest -UseBasicParsing '<baseurl>/api/endpoints/siteagent/bootstrap.ps1' -Headers @{'X-Endpoint-Bootstrap-Token'='<token>'} -OutFile 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1'; if ((Get-Acl 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1').GetOwner([Security.Principal.SecurityIdentifier]).Value -notin @('S-1-5-32-544','S-1-5-18',[Security.Principal.WindowsIdentity]::GetCurrent().User.Value)) { Write-Host 'Refusing to run: the staged bootstrap script is not owned by an administrator.' -ForegroundColor Red; exit 5 }; & 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1' } finally { Remove-Item 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1' -Force -ErrorAction SilentlyContinue }"
```
Download then run, never `irm ... | iex`: Microsoft Defender scores the
piped-to-iex shape as `Trojan:Win32/Commando.A!ml` and kills the process before
a line of it runs (reproduced 5 of 5 on a default install, no ASR rules), and
every bay runs Defender. The rest of the shape is load-bearing too:
- the token rides in the `X-Endpoint-Bootstrap-Token` HEADER. A query string
lands verbatim in the IIS and waitress access logs, and a live install token
has no business sitting in a log file;
- staging is `C:\Program Files\ShopDB\bootstrap`, **not**
`C:\Windows\Temp`. That directory grants Users create-file and gives CREATOR
OWNER full control of what they create, so any standard user on the bay can
pre-plant `C:\Windows\Temp\shopdb-bootstrap.ps1` and wait for a tech to
paste the command, which then runs the planted content elevated;
- the line re-asserts an admin-only DACL on the directory it creates
(`icacls /inheritance:r` granting only the well-known SIDs `S-1-5-32-544` and
`S-1-5-18`, never group names, which a rename or a localized install would
break), and CHECKS that icacls succeeded: it is a native command, so it
reports failure only in an exit code and `$ErrorActionPreference` never
applies to it. Exit 6 with a red message, rather than downloading into a
directory that still inherits whatever the parent grants;
- it refuses (exit 5) to run a staged file owned by anyone but an
administrator, `SYSTEM`, or the elevated caller, which is what a pre-planted
file looks like;
- a literal staging path and no `$` anywhere - including the exit-code read,
which is `Get-Variable LASTEXITCODE -ValueOnly`. Pasting into an
already-running PowerShell would otherwise expand every `$` in the PARENT and
hand the child a broken line. As written it means the same in cmd.exe and in
PowerShell;
- single quotes for every inner literal, so the outer `-c "..."` is never
closed early;
- `try`/`finally`, so the dropped file is removed even when the script exits
non-zero or the download itself fails. The wrapper's own exit code is the
removal's, so read the script's printed result rather than `%ERRORLEVEL%`.
The same script is offered in the UI as a **Download .ps1** link, for a tech
who would rather save the file and run it. That link is the one caller that
cannot send a header, so it uses `?t=<token>&via=download`. Fetching the script
does not spend a use either way.
The `?t=` form without `via=download` is accepted ONLY so a bay provisioned
before this change - one still running a command pasted when the query form was
the published shape - keeps working. It is deprecated: every such fetch logs a
warning naming the caller's IP, and that warning is how the rollout finds bays
whose command needs re-issuing. Do not publish it, do not paste it into a new
bay, and do not build it by hand. A site that has re-issued all of its live
commands turns the query form off with
`plugin.endpoints.bootstrapquerytoken` = false, which also disables the
Download .ps1 link.
The key is declared in the endpoints plugin's `get_settings_defaults()`, so it
seeds as a boolean row, and it is flipped from Settings > Endpoints > Build
site agent > One-line install ("Accept the token in the URL"). It is read
through the core settings API, not a route of this plugin's own.
The token is only an authorization to FETCH the installer. The package it
serves carries no shared key, whatever `plugin.endpoints.sharedkeyallowed`
says, so the PC enrolls by the normal 9.2 path and waits in Pending until an
admin approves it.
Admin routes (admin role, audited):
- `POST /api/endpoints/siteagent/bootstrap/tokens` `{"hours": 24, "maxuses": 25}`
(`hours` 1-168, `maxuses` 1-500). Returns
`{"secret", "command", "scripturl", "token"}`. `command` carries the token in
the header; `scripturl` is the same route for the UI's Download .ps1 link, in
the `?t=<secret>&via=download` form a browser link can actually use. The
`via=download` marker is what keeps an admin's own click out of the
un-migrated-bay warning count - it is a hint, not authorization, and nothing
is granted by it; what matters is that a command generated before this change
cannot carry it. The secret is in the response exactly once; only its sha256
and a 12-character `tokenprefix` are stored.
- `GET /api/endpoints/siteagent/bootstrap/tokens` - prefix, created, expires,
`usecount` / `maxuses`, `revokedat`, `lastusedat`, `lastusedip` and a `state`
of `active`, `expired`, `exhausted` or `revoked`. Never a secret.
- `POST /api/endpoints/siteagent/bootstrap/tokens/<tokenid>/revoke`
Token-authenticated routes. No login; the token is the whole authorization,
because a PC with no agent has neither a credential nor a user session. It
travels in the `X-Endpoint-Bootstrap-Token` header:
| Route | Spends a use | Returns |
|---|---|---|
| `GET /api/endpoints/siteagent/bootstrap.ps1` | no | `text/plain` PowerShell, generated per token |
| `GET /api/endpoints/siteagent/bootstrap/package` | yes | the keyless agent package ZIP |
Both also accept `?t=<secret>` while
`plugin.endpoints.bootstrapquerytoken` is true (the default), for bays
provisioned before the header form and for the UI Download .ps1 link. A query
token without `via=download` logs a deprecation warning against the caller's
IP; the Download link's own fetches log at info instead and are audited as
`(query-string token, admin download link)`, so the warning count stays a list
of bays to re-issue. With the setting false, a query token answers **400**
naming the header.
Both answer **404** for a token that is unknown, malformed, expired, revoked or
exhausted, and for a request that presents no token at all - so the URL
confirms nothing about which tokens exist, and a bare probe cannot learn
whether this site still accepts the query form. Both are
rate-limited per IP (fixed window, `ENDPOINTS_BOOTSTRAP_RATELIMIT_MAX` in
`ENDPOINTS_BOOTSTRAP_RATELIMIT_WINDOW_SECONDS`, 429 `RATE_LIMITED` over
budget) and both write an audit row against `EndpointBootstrapToken` carrying
the caller's IP. Fetching the script deliberately does not spend a use: a
retried paste must not burn the download budget.
The generated script:
- refuses to run unelevated (exit 1) and says to reopen PowerShell as
administrator (the wrapper line has already refused with exit 6 if the
staging directory could not be locked down, or exit 5 if the staged file was
owned by someone else);
- refuses when the agent service already exists (exit 2) and says a managed bay
should get the agent from GE-Enforce, unless `SHOPDB_BOOTSTRAP_FORCE` is set
to anything but `0` in the same session;
- downloads the package to a temp directory over TLS with normal chain
validation (nothing relaxes certificate checking), expands it, and verifies
`shopdbagent.exe` and `shopdb-endpoint-tray.exe` against the SHA-256 values
the server baked in at generation time. A mismatch, a missing binary, or a
binary the server never staged refuses the install (exit 3);
- runs the package's `install.ps1` with no key, then deletes the temp
directory;
- prints the installed version and service state, and exits 4 when the service
is not Running;
- prints "This PC is waiting for approval - an admin approves it in Settings >
Endpoints > Enrollment", then watches
`%ProgramData%\ShopDB\EndpointAgent\credential\credential.bin` for up to two
minutes and prints "approved and enrolled" if the credential lands. This is a
LOCAL file watch, not a server poll: there is no public enrollment-status
route and adding one would hand an anonymous caller a way to probe the fleet.
Exit code 0 on install success even when the PC is still pending.
### 9.9 Fleet alert feed
`GET /api/endpoints/alerts?hostname=<host>` returns the alerts that should be
live on that PC right now (see the AUTHORITATIVE rule above). One object per
alert:
| Key | Type | Meaning |
|---|---|---|
| `notificationid` | int | the notification, and the id posted back to `/alerts/<id>/acknowledge` |
| `type` | string | notification type name |
| `typecolor` | string | CSS colour the tray paints the toast with |
| `title` | string | first line of the message, max 100 chars |
| `message` | string | full message |
| `timeout` | int or null | seconds the toast stays on screen |
Response keys match the column names, per the naming convention. Two extra keys
ride along for now:
| Legacy key | Same value as | Remove when |
|---|---|---|
| `notificationId` | `notificationid` | no tray older than 1.7.0 is left in the fleet |
| `typeColor` | `typecolor` | no tray older than 1.7.0 is left in the fleet |
A tray at 1.6.x fetches this feed itself and deserializes case-sensitively, so
serving only the lowercase keys makes every alert vanish on those machines with
no error raised anywhere. Both spellings therefore go down with identical
values until the fleet is upgraded, and the camelCase pair is deleted in the
same release that drops support for trays below 1.7.0. Any NEW field on this
object is lowercase only and never gets a camelCase twin.

View File

@@ -1,112 +0,0 @@
# Delivery testing notes (update_app + run_script)
Temporary field notes for exercising the endpoint delivery path end to end and
for handing the UI work to Cameron. ASCII only, no em-dashes, no emojis, per
repo style. Remove the dev test bench (view, route, nav entry) once the
production delivery UI lands.
## What exists today (the contract Cameron builds against)
Server allowlist (`plugins/endpoints/commands.py`):
- `run_script` - targetkind `script`. `target` is a script NAME the agent
already holds in its local `scriptpaths` allowlist. No body or path crosses
the wire.
- `update_app` - targetkind `app`. `target` is an `appversionid` that resolves
to a ShopDB-hosted installer package. The server pins the package by SHA-256
and passes this version's `silentargs`.
Both actions require `endpoints.deploy` ON TOP OF `endpoints.command`. Queueing
one is the actual authority (audited with the requesting user). The agent claims
it with its collector credential.
Enqueue call (same route as every other endpoint command):
```
POST /api/endpoints/<assetid>/commands
{ "action": "run_script", "target": "remove-network-drives" }
{ "action": "update_app", "target": "<appversionid>" }
```
Rules the UI must respect:
- At most one pending/running command per endpoint. A second enqueue returns
409 (treat as "already in flight", not a failure).
- `update_app` is validated at enqueue: an appversionid with no installer
package or unknown id is rejected 400 before it is queued.
- A version with no `sha256` has no uploaded package yet. Do not offer it for
`update_app`; the agent's integrity check will fail closed.
Read the result back:
```
GET /api/endpoints/<assetid>/commands
```
Each row carries `status` (pending/running/succeeded/failed), `resultmessage`
(short summary) and `resultjson` (structured detail: for run_script the exit
code and script path; for update_app the installer path, exit code and digest
check).
Agent-facing package download (collector-authenticated, not user JWT):
```
GET /api/endpoints/apppackage/<appversionid> (X-API-Key collector token)
```
## The dev test bench
Route: `/endpoints/delivery-test` (sidebar "Delivery test bench", gated on
`endpoints.deploy`). It is deliberately minimal: pick an endpoint, push a script
by name or push an app version, then watch the command log pane. There is no
script-library component yet, so `run_script` takes a free-text name; the agent
runs it only if that name is in its `scriptpaths`.
## Preparing the test endpoint
1. Copy the dev build to the test box:
- `agent\dev-bin\agent\shopdbagent.exe`
- `agent\dev-bin\agent\appsettings.json`
Stage them the same way the site installer does (service exe + appsettings).
2. Provision the collector credential and base URL. Per repo policy these are
NOT in appsettings.json; set the registry values under
`HKLM\SOFTWARE\GE\ShopDB` (`CollectorKey`, `BaseUrl`). The shipped
appsettings has `baseurl` empty and `apikey` = REPLACE_WITH_COLLECTOR_KEY so
the agent fails closed until provisioned.
3. Stage the sample script where appsettings points:
`C:\ProgramData\ShopDB\EndpointAgent\scripts\remove-network-drives.ps1`
(source: `agent\dev-bin\scripts\remove-network-drives.ps1`). The
`scriptpaths` entry in appsettings.json maps the name `remove-network-drives`
to that path.
4. For an `update_app` test, create the application in the Applications module,
upload its installer package (this stamps the version's `sha256`), and note
the version's `appversionid`.
## Collecting logs
Endpoint side (the agent):
- Rolling diagnostics log: `%ProgramData%\ShopDB\EndpointAgent\logs`
(override with `logpath` in appsettings). This is where run_script and
update_app record what they resolved, the digest check, and the installer or
script exit code.
- Windows Event Log source `ShopDB Endpoint Agent` for service faults.
Web UI / ShopDB side:
- The command log pane on the bench (or `GET /api/endpoints/<assetid>/commands`)
shows `status`, `resultmessage` and `resultjson` reported by the agent.
- Ingest observability: unknown/unpersisted telemetry keys are logged by the
endpoints plugin on the API container, so incoming agent data is visible in
the app logs even before UI persistence catches up.
- Audit log records each enqueue (`created EndpointCommand`, action + target +
asset) with the requesting user.
## Notes to capture during testing (for Cameron)
- Round-trip latency from enqueue to a terminal status, at the agent cadence.
- Whether `resultmessage` alone is enough for the operator, or the UI needs to
surface `resultjson` (exit codes, digest mismatches).
- How a 409 (already in flight) should read in the real UI.
- What a failed digest check or non-zero installer exit looks like end to end,
so the production UI can present it clearly.

View File

@@ -9,5 +9,10 @@ from . import siteagent # noqa: F401
from . import agentupload # noqa: F401
# Attaches the monitor catalog CRUD and resolve routes to endpoints_bp.
from . import monitors # noqa: F401
# Attaches per-PC credential enrollment, admin and migration routes.
from . import enrollment # noqa: F401
# Attaches the one-line bootstrap installer: token admin plus the two
# token-authenticated download routes.
from . import bootstrap # noqa: F401
__all__ = ['endpoints_bp']

View File

@@ -0,0 +1,293 @@
"""Agent authentication: per-PC credentials, with the shared key as fallback.
Every agent-facing endpoints route authenticates through authenticate_agent()
and, when the route names a PC, binds the caller through bind_hostname().
X-Endpoint-Credential The PC's own secret. The server derives the asset from
it; a hostname or assetid in the request that names a
different asset is refused with ASSET_MISMATCH.
X-API-Key The shared fleet key (or a collector-scoped managed
token). Accepted only while
plugin.endpoints.sharedkeyallowed is on, and never for a
PC that holds an active credential. A PC whose
credential was REVOKED falls back to the shared key,
which is how a rollback to an older agent works and how
the un-migrated fleet keeps reporting; a site that has
finished migrating turns
plugin.endpoints.revokeddeniessharedkey on and a revoked
PC is then refused until it enrolls again. A PC that
never enrolled has no credential row at all and is
never touched by either rule.
Only the sha256 of a credential is stored. The full secret exists once, in the
enroll response.
"""
import hashlib
import hmac
import re
import secrets
from datetime import datetime, timedelta, timezone
from flask import g, request
from shopdb.api import (
db, Setting, error_response, ErrorCodes, collector_authorized,
)
CREDENTIAL_HEADER = 'X-Endpoint-Credential'
CREDENTIAL_PREFIX = 'sdbe_'
# sdbe_ + 43 chars base64url (32 random bytes, unpadded).
_CREDENTIAL_PATTERN = re.compile(r'^sdbe_[A-Za-z0-9_-]{43}$')
DISPLAY_PREFIX_LENGTH = 12
SHARED_KEY_SETTING = 'plugin.endpoints.sharedkeyallowed'
AUTO_ENROLL_SETTING = 'plugin.endpoints.autoenroll'
# Off by default, which is what this site does today: revoking a credential
# drops the PC back to the shared key. Turning it on makes a revoke a real
# eviction - the PC reports nothing until it enrolls again - so it is only safe
# once the fleet is off the shared key, and it also closes the rollback path
# that revoke + blockreenroll exists for.
REVOKED_DENIES_SHARED_KEY_SETTING = 'plugin.endpoints.revokeddeniessharedkey'
CODE_CREDENTIAL_INVALID = 'CREDENTIAL_INVALID'
CODE_SHARED_KEY_REFUSED = 'SHARED_KEY_REFUSED'
CODE_ASSET_MISMATCH = 'ASSET_MISMATCH'
AUTH_CREDENTIAL = 'credential'
AUTH_SHARED_KEY = 'sharedkey'
# lastusedat is a display value, not an audit trail: one write per 5 minutes
# per PC keeps the long-poll from turning every claim into an UPDATE.
_TOUCH_INTERVAL = timedelta(minutes=5)
def _naive_utcnow():
return datetime.now(timezone.utc).replace(tzinfo=None)
def generate_credential():
return CREDENTIAL_PREFIX + secrets.token_urlsafe(32)
def hash_credential(secret):
return hashlib.sha256(secret.encode('utf-8')).hexdigest()
def shared_key_allowed():
return Setting.get(SHARED_KEY_SETTING, default=True) is not False
def auto_enroll_enabled():
return Setting.get(AUTO_ENROLL_SETTING, default=True) is not False
def revoked_denies_shared_key():
"""True when a revoked credential also bars the PC from the shared key."""
return Setting.get(REVOKED_DENIES_SHARED_KEY_SETTING,
default=False) is True
def active_credential_for(assetid):
from ..models import EndpointCredential
if assetid is None:
return None
return (EndpointCredential.query
.filter(EndpointCredential.assetid == assetid,
EndpointCredential.revokedat.is_(None))
.first())
def shared_key_denied_for(assetid):
"""True when this asset may not report on the shared key.
An active credential always denies it: the PC has its own secret and a
shared-key call for it is either an older agent or somebody else.
A revoked credential denies it only while revokeddeniessharedkey is on.
Today it does not, on purpose: revoke is also how an admin rotates a
credential or rolls a PC back to an agent that only knows the shared key
(revoke + blockreenroll), and denying by default would silence that PC
instead. A PC that never enrolled has no credential row and is never denied,
which is what keeps the un-migrated fleet reporting.
"""
from ..models import EndpointCredential
if assetid is None:
return False
row = (EndpointCredential.query
.filter(EndpointCredential.assetid == assetid)
.first())
if row is None:
return False
if row.revokedat is None:
return True
return revoked_denies_shared_key()
def reenroll_blocked(assetid):
"""True when an admin blocked this asset from enrolling again."""
from ..models import EndpointCredential
if assetid is None:
return False
return (EndpointCredential.query
.filter(EndpointCredential.assetid == assetid,
EndpointCredential.reenrollblocked.is_(True))
.first()) is not None
def resolve_credential(secret, touch=True):
"""Asset id for an active credential, else None."""
from ..models import EndpointCredential
if not secret or not _CREDENTIAL_PATTERN.match(secret):
return None
digest = hash_credential(secret)
row = EndpointCredential.query.filter_by(credentialhash=digest).first()
if row is None or row.revokedat is not None:
return None
if not hmac.compare_digest(row.credentialhash, digest):
return None
if touch:
now = _naive_utcnow()
if row.lastusedat is None or now - row.lastusedat >= _TOUCH_INTERVAL:
row.lastusedat = now
db.session.commit()
return row.assetid
def resolve_hostname(hostname):
"""Asset id of the one computer with this hostname (case-insensitive)."""
from plugins.computers.models import Computer
hostname = (hostname or '').strip()
if not hostname:
return None
computer = (Computer.query
.filter(db.func.lower(Computer.hostname) == hostname.lower())
.first())
return computer.assetid if computer is not None else None
def shared_key_refused_for(hostname):
"""True when the shared key may not report for this hostname."""
if not shared_key_allowed():
return True
return shared_key_denied_for(resolve_hostname(hostname))
def credential_invalid_response():
return error_response(
CODE_CREDENTIAL_INVALID,
'Endpoint credential is unknown or revoked; enroll again',
http_code=401)
def shared_key_refused_response():
return error_response(
CODE_SHARED_KEY_REFUSED,
'The shared key is not accepted for this endpoint; use its credential',
http_code=401)
def shared_key_revoked_response():
"""Same code and status; the operator needs to know which refusal it is.
The PC holds no credential and cannot use the shared key either, so an
admin has to let it enroll again. Nothing changes for the agent, which
keeps retrying either way.
"""
return error_response(
CODE_SHARED_KEY_REFUSED,
'This endpoint had its credential revoked, so the shared key is not '
'accepted for it; let it enroll again',
http_code=401)
def asset_mismatch_response():
return error_response(
CODE_ASSET_MISMATCH,
'The request names a different endpoint than the credential',
http_code=403)
def hostname_unrecorded_response(hostname):
"""Refused, but say which of the two failures this actually is.
The credential is good and the hostname is simply on no computer record, so
there is nothing to compare it with. That is a data problem on this server -
an enrolled PC whose computer record carries no hostname - not a PC claiming
to be another one. Same code and status, because nothing changes for the
agent: it keeps retrying, and an admin has to set the hostname.
"""
return error_response(
CODE_ASSET_MISMATCH,
'No computer record carries the hostname {0}, so this credential '
'cannot be matched to it. Set the hostname on the enrolled '
'computer.'.format(hostname),
http_code=403)
def authenticate_agent():
"""Authorize an agent request. Returns None when authorized, else a response.
Sets g.agentauth ('credential' or 'sharedkey') and g.agentassetid (the
credential's asset, or None until bind_hostname resolves one).
"""
g.agentauth = None
g.agentassetid = None
secret = request.headers.get(CREDENTIAL_HEADER)
if secret:
assetid = resolve_credential(secret.strip())
if assetid is None:
return credential_invalid_response()
g.agentauth = AUTH_CREDENTIAL
g.agentassetid = assetid
return None
error = collector_authorized('endpoints')
if error is not None:
return error
if not shared_key_allowed():
return shared_key_refused_response()
g.agentauth = AUTH_SHARED_KEY
return None
def is_credential_caller():
return getattr(g, 'agentauth', None) == AUTH_CREDENTIAL
def bind_hostname(hostname):
"""The asset this request acts for. Returns (assetid, error_response).
Credential: the credential's asset; a hostname naming another asset is an
ASSET_MISMATCH, a hostname on no computer record at all is refused with the
same code but its own message, and a missing hostname is fine.
Shared key: the hostname's asset (None when absent or unknown, for the
route to answer); an enrolled PC is refused, and so is one whose credential
was revoked while revokeddeniessharedkey is on.
"""
hostname = (hostname or '').strip()
if is_credential_caller():
if hostname:
named = resolve_hostname(hostname)
if named is None:
return None, hostname_unrecorded_response(hostname)
if named != g.agentassetid:
return None, asset_mismatch_response()
return g.agentassetid, None
if not hostname:
return None, None
assetid = resolve_hostname(hostname)
if shared_key_denied_for(assetid):
if active_credential_for(assetid) is None:
return None, shared_key_revoked_response()
return None, shared_key_refused_response()
g.agentassetid = assetid
return assetid, None

View File

@@ -0,0 +1,127 @@
"""Register staged endpoint agent builds as installable Applications.
The endpoint update_app push resolves an install source from the applications
table alone - it has no knowledge of the agent version store. Without a row
there, a build can be staged and current in agentstore and still be impossible
to push, which is exactly the gap found in the fleet: no application row existed
for the agent at all.
This module bridges the two by making the UPLOAD the single source of truth.
Staging a build repackages the current agent (plus tray) and rewrites the
application row's installpath and installersha256 in the same request, so the
row cannot describe a build that is no longer current. Nothing here is
hand-maintained; an operator never edits these rows.
"""
import os
import uuid
from shopdb.api import Application, application_package_location, db
from . import agentstore
# Reserved appname for the managed agent bundle. appname is unique, which is
# what makes the upsert idempotent: repeated uploads update one row rather than
# accumulating a row per build. Changing this string strands the existing row,
# so it is a constant rather than anything configurable.
AGENT_APPNAME = 'ShopDB Endpoint Agent'
AGENT_PACKAGE_FILENAME = 'shopdb-endpoint-agent.zip'
_AGENT_DESCRIPTION = (
'Managed ShopDB endpoint agent bundle. Created and kept current '
'automatically when an agent build is staged - do not edit by hand.'
)
def _package_path(appid):
directory, _prefix = application_package_location()
return os.path.join(directory, str(appid), AGENT_PACKAGE_FILENAME)
def get_agent_application():
"""The reserved agent Application row, or None when nothing is registered.
Read-only lookup for callers that need the appid to build a delivery URL
without causing a row to spring into existence as a side effect.
"""
return Application.query.filter_by(appname=AGENT_APPNAME).first()
def _get_or_create_app():
"""Return the reserved agent Application row, creating it when absent.
Does not commit: the caller commits once, after the package is written, so
a failed write cannot leave a row advertising an installer that is not
there.
"""
app = Application.query.filter_by(appname=AGENT_APPNAME).first()
if app:
return app, False
app = Application(
appname=AGENT_APPNAME,
appdescription=_AGENT_DESCRIPTION,
# Installable so it can be pushed; hidden so a machine-managed row does
# not surface in the software catalogue alongside real licensed apps.
isinstallable=True,
ishidden=True,
)
db.session.add(app)
# Flush rather than commit: the row needs its appid to build the package
# path, but the transaction stays open so the whole registration is atomic.
db.session.flush()
return app, True
def register_current_agent_package():
"""Repackage the current agent build and point the agent app row at it.
Returns a dict describing what was registered, or None when there is no
current agent build to package. Safe to call on every upload: it is an
upsert keyed on the reserved appname, and it rewrites the package file in
place.
"""
payload, manifest = agentstore.build_current_package()
if payload is None:
return None
app, created = _get_or_create_app()
path = _package_path(app.appid)
os.makedirs(os.path.dirname(path), exist_ok=True)
# Write to a temp name and replace, so an interrupted write cannot leave a
# truncated archive at the path the row already advertises. os.replace is
# atomic within a filesystem. The temp name is unique per call so two
# concurrent pushes (a batch wave) cannot interleave writes into one file.
temppath = '{}.{}.tmp'.format(path, uuid.uuid4().hex)
try:
with open(temppath, 'wb') as handle:
handle.write(payload)
os.replace(temppath, path)
except BaseException:
try:
os.remove(temppath)
except OSError:
pass
raise
_directory, urlprefix = application_package_location()
app.installpath = '{}{}/{}'.format(
urlprefix, app.appid, AGENT_PACKAGE_FILENAME)
# The digest of the ARCHIVE, which is what the agent downloads and verifies.
# The per-binary digests inside the manifest are for the install script to
# check its unpacked contents, and are not interchangeable with this one.
app.installersha256 = manifest['sha256']
db.session.commit()
return {
'appid': app.appid,
'appname': app.appname,
'created': created,
'installpath': app.installpath,
'sha256': manifest['sha256'],
'size': manifest['size'],
'agentversion': manifest['agentversion'],
'contents': manifest['contents'],
}

View File

@@ -35,6 +35,7 @@ import json
import os
import re
import struct
import zipfile
from datetime import datetime
from flask import current_app
@@ -470,3 +471,109 @@ def set_current(binkey, version):
manifest['current'] = version
_write_manifest(binkey, manifest)
return True
def version_sha256(binkey, version):
"""The recorded digest for a staged version, or None when not recorded.
Read from the manifest rather than recomputed: store_version already
hashed the exact bytes it wrote, so re-reading the file to hash it again
would only introduce a way for the two to disagree.
"""
for entry in _read_manifest(binkey).get('versions', []):
if entry.get('version') == version:
return entry.get('sha256')
return None
# Name the upgrade payload carries inside the archive. Fixed rather than derived
# from the staged filenames so the install script can address its contents
# without being told what to expect.
PACKAGE_MEMBER_NAMES = {
'agent': 'shopdbagent.exe',
'tray': 'shopdb-endpoint-tray.exe',
}
# Fixed member timestamp so packaging the same build twice yields byte-identical
# archives. The value is arbitrary (the DOS epoch zip supports); only its
# constancy matters. Real build provenance lives in the manifest's versions and
# digests, not in a zip entry's mtime.
_PACKAGE_TIMESTAMP = (1980, 1, 1, 0, 0, 0)
def build_current_package():
"""Zip the current agent build (plus the current tray) into one archive.
Returns (bytes, manifestdict) or (None, None) when no agent build is
current. The agent is required; the tray is included only when it too has
a current staged version, because an endpoint can legitimately run the
service with no tray and half a tray upgrade is worse than none.
The archive is built IN MEMORY and returned rather than written here: the
caller decides where it lands, and nothing under the version store gets a
derived artifact that retention would then have to reason about.
The returned manifest describes what went in - version and digest per
member - so the install script can verify it unpacked the build the server
meant to send, rather than trusting the archive's filename.
"""
agentversion = current_version('agent')
if not agentversion:
return None, None
members = []
agentpath = _version_exe_path('agent', agentversion)
if not os.path.isfile(agentpath):
return None, None
members.append(('agent', agentversion, agentpath))
trayversion = current_version('tray')
if trayversion:
traypath = _version_exe_path('tray', trayversion)
if os.path.isfile(traypath):
members.append(('tray', trayversion, traypath))
contents = {}
buffer = io.BytesIO()
# ZIP_DEFLATED, not ZIP_STORED: these are ~70 MB self-contained binaries
# carrying the whole .NET runtime, and the archive crosses the wire to
# every endpoint being upgraded.
with zipfile.ZipFile(buffer, 'w', compression=zipfile.ZIP_DEFLATED) as archive:
for binkey, version, path in members:
try:
with open(path, 'rb') as handle:
data = handle.read()
except OSError:
# A staged file that cannot be read means the package would be
# silently incomplete. Refuse the whole thing: a partial upgrade
# payload is how an endpoint ends up with a new service exe and
# a stale tray.
return None, None
name = PACKAGE_MEMBER_NAMES[binkey]
# Build the entry with a FIXED timestamp. zipfile otherwise stamps
# each member with the current local time, which makes the archive
# bytes - and therefore its digest - different on every call for
# identical input. update_app pins a download to a digest, so a
# drifting hash would invalidate a package an endpoint is already
# fetching. Same reason the mode is set explicitly.
info = zipfile.ZipInfo(name, date_time=_PACKAGE_TIMESTAMP)
info.compress_type = zipfile.ZIP_DEFLATED
info.external_attr = 0o644 << 16
archive.writestr(info, data)
contents[binkey] = {
'filename': name,
'version': version,
# Prefer the recorded digest so the package agrees with the
# manifest; fall back to hashing when an older manifest entry
# predates digest recording.
'sha256': (version_sha256(binkey, version)
or hashlib.sha256(data).hexdigest()),
}
payload = buffer.getvalue()
return payload, {
'agentversion': agentversion,
'contents': contents,
'sha256': hashlib.sha256(payload).hexdigest(),
'size': len(payload),
}

View File

@@ -0,0 +1,144 @@
"""Seed the agent self-upgrade script into the ShopDB script library.
run_script only delivers bodies that live in the script library, pinned by
SHA-256. The upgrade template therefore has to exist as a real Script row with
a stored body before any upgrade can be pushed.
The template is source-controlled at
plugins/endpoints/api/templates/upgrade-shopdbagent.ps1 and seeded from there
rather than typed into the library by hand, so the reviewed script and the one
endpoints actually run are the same artifact. Editing the row's body in the UI
is pointless - the next seed overwrites it.
It lives under the plugin, not under agent/, because the Docker image copies
shopdb/, plugins/, migrations/ and scripts/ but NOT agent/. A template read
from agent/ resolves fine on a developer checkout and then fails in the
deployed container, which is the worst place to discover it.
One placeholder is rendered at seed time: the agent application's appid, which
only exists once agentregistry has created the row. Rendering happens BEFORE
the digest is computed, so the stored body and its sha256 always agree; a
substitution done after hashing would fail the agent's verification.
"""
import hashlib
import os
import uuid
from shopdb.api import Script, db, script_body_location
from . import agentregistry
# Reserved script name. Unique in the library, which makes seeding idempotent
# and keeps a hand-made script from colliding with the managed one.
UPGRADE_SCRIPT_NAME = 'ShopDB Endpoint Agent Self-Upgrade'
_UPGRADE_DESCRIPTION = (
'Managed: downloads the current published agent bundle and replaces the '
'installed agent in place, then restarts the service. Seeded from source '
'control - edits here are overwritten.'
)
# Substituted with the agent application's appid so the script can fetch its
# payload from the existing collector-authenticated app-package route.
_APPID_TOKEN = '{{AGENT_APPID}}'
_TEMPLATE_FILENAME = 'upgrade-shopdbagent.ps1'
def template_path():
return os.path.join(os.path.dirname(os.path.abspath(__file__)),
'templates', _TEMPLATE_FILENAME)
def _script_dir():
"""Where script bodies live. Resolved through the contract surface helper
so this cannot drift from the directory the download route serves from."""
directory, _prefix = script_body_location()
return directory
def render_template(appid):
"""Return the upgrade script body with the appid substituted.
Raises if the placeholder is missing: that means the template was edited
in a way that breaks the download, and seeding a script that would fetch
from a literal '{{AGENT_APPID}}' path is worse than refusing.
"""
with open(template_path(), 'r', encoding='utf-8') as handle:
body = handle.read()
if _APPID_TOKEN not in body:
raise ValueError(
'upgrade template is missing the {} placeholder; refusing to seed '
'a script that cannot resolve its package'.format(_APPID_TOKEN))
return body.replace(_APPID_TOKEN, str(appid))
def seed_upgrade_script():
"""Create or refresh the managed upgrade script row and its body.
Returns a dict describing the seeded script, or None when the agent
application row does not exist yet (nothing to point the download at).
Idempotent: unchanged input rewrites nothing and leaves the digest alone.
"""
app = agentregistry.get_agent_application()
if app is None:
return None
body = render_template(app.appid).encode('utf-8')
digest = hashlib.sha256(body).hexdigest()
script = Script.query.filter_by(scriptname=UPGRADE_SCRIPT_NAME).first()
created = script is None
if created:
script = Script(
scriptname=UPGRADE_SCRIPT_NAME,
scriptdescription=_UPGRADE_DESCRIPTION,
# SYSTEM: the script stops a service, writes into Program Files,
# and edits HKLM. None of that is available in a user context.
runcontext='system',
# Failures always report; success is opt-in. Capture it here
# because an upgrade that "succeeded" but rolled back internally
# is only diagnosable from the transcript.
captureoutput=True,
)
db.session.add(script)
db.session.flush()
# Body filename follows the library convention (script-<id>.ps1) so the
# collector download route serves it like any other script.
filename = 'script-{}.ps1'.format(script.scriptid)
path = os.path.join(_script_dir(), filename)
unchanged = (script.filename == filename and script.sha256 == digest
and os.path.isfile(path))
if not unchanged:
os.makedirs(_script_dir(), exist_ok=True)
# Temp-then-replace so an interrupted write cannot leave a truncated
# body at the path the row's digest already claims to describe.
temppath = '{}.{}.tmp'.format(path, uuid.uuid4().hex)
try:
with open(temppath, 'wb') as handle:
handle.write(body)
os.replace(temppath, path)
except BaseException:
try:
os.remove(temppath)
except OSError:
pass
raise
script.filename = filename
script.sha256 = digest
script.scriptdescription = _UPGRADE_DESCRIPTION
db.session.commit()
return {
'scriptid': script.scriptid,
'scriptname': script.scriptname,
'created': created,
'updated': not unchanged,
'sha256': digest,
'bytes': len(body),
'appid': app.appid,
}

View File

@@ -15,6 +15,12 @@ Authorization boundary (deliberate):
command family from application deploys (endpoints.deploy) so app-push rights
never imply SYSTEM-level agent replacement. That push pipe is a later slice.
Staging also keeps the pushable application row current (see agentregistry):
whenever the current build changes, the bundle is repackaged and the row's
installpath/installersha256 are rewritten. Registration is deliberately NOT
allowed to fail the upload - the binary is already staged and audited by then,
so a packaging problem is reported in the response payload rather than raised.
Uploads are capped and PE-checked so a wrong or hostile file cannot be staged.
"""
@@ -24,12 +30,47 @@ from shopdb.api import (success_response, error_response, ErrorCodes, AuditLog,
require_permission)
from .routes import endpoints_bp
from . import agentstore
from . import agentstore, agentregistry
# Hard cap on an uploaded agent binary. The real agent is a few MB; this is a
# generous ceiling that still refuses an operator who points at a giant wrong
# file before it is buffered fully into memory-staged storage.
_MAX_UPLOAD_BYTES = 64 * 1024 * 1024
# Hard cap on an uploaded agent binary. This is a generous ceiling that still
# refuses an operator who points at a giant wrong file before it is buffered
# fully into memory-staged storage.
#
# 128 MB, not 64: the self-contained single-file tray publishes at ~69 MB
# (it carries the whole .NET runtime plus WinForms), which the old 64 MB cap
# rejected outright. The upload failed on SIZE, but the operator saw only a
# version-detection failure, because the size check returns before the PE is
# ever parsed. Config.MAX_CONTENT_LENGTH must stay above this - it bounds the
# whole multipart envelope, so a cap equal to it would reject a legal file on
# the boundary bytes alone.
_MAX_UPLOAD_BYTES = 128 * 1024 * 1024
def _sync_agent_registration():
"""Repackage and re-register the current build. Returns a status dict.
Called from every route that can change which build is current, so the
pushable application row can never describe a build that is no longer
staged. That drift is the whole failure this is meant to prevent, and it
would be silent - the row would keep pointing at bytes nobody selected.
Never raises. Staging has already succeeded on disk and been audited by the
time this runs; a packaging or database problem must not turn a successful
upload into an error response and leave the operator thinking the binary
was rejected. The failure is reported in the response payload instead, so
the UI can surface it without losing the upload.
"""
try:
registration = agentregistry.register_current_agent_package()
except Exception as exc: # noqa: BLE001 - reported, not swallowed
db.session.rollback()
return {'ok': False, 'error': str(exc)}
if registration is None:
# No current agent build to package. Normal when only a tray has been
# staged: the bundle is defined by the agent, so there is nothing to
# register yet.
return {'ok': False, 'error': 'no current agent build to package'}
return {'ok': True, **registration}
@endpoints_bp.route('/siteagent/binaries/<binkey>', methods=['GET'])
@@ -69,11 +110,6 @@ def upload_agent_binary(binkey):
ErrorCodes.VALIDATION_ERROR,
'binkey must be one of: agent, tray',
http_code=400)
if not agentstore.is_valid_version(version):
return error_response(
ErrorCodes.VALIDATION_ERROR,
'version must be a dotted-numeric string such as 1.6.3',
http_code=400)
upload = request.files.get('file')
if upload is None or not upload.filename:
@@ -94,6 +130,22 @@ def upload_agent_binary(binkey):
'uploaded file is not a Windows executable',
http_code=400)
# Gate the version at the PE, not at the raw input. Product versions carry
# semver build metadata (1.5.0+abcdef...) that is not a legal directory
# name, so the operator's typed string is only a hint. The dotted-numeric
# FILE version stamped in the binary's PE resource is the authority; fall
# back to it whenever the typed value is missing or not dotted-numeric, and
# only reject when neither yields a usable version.
if not agentstore.is_valid_version(version):
detected = agentstore.read_pe_file_version(data)
if agentstore.is_valid_version(detected):
version = detected
else:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'version must be a dotted-numeric string such as 1.6.3',
http_code=400)
result = agentstore.store_version(binkey, version, data,
make_current=makecurrent)
@@ -110,10 +162,16 @@ def upload_agent_binary(binkey):
'madecurrent': makecurrent})
db.session.commit()
# Keep the pushable application row in step with what is now current, in
# the same request. Doing it here rather than at push time means the row
# and the staged build cannot disagree in between.
registration = _sync_agent_registration()
return success_response({
'binary': binkey,
'current': result['current'],
'versions': result['versions'],
'registration': registration,
})
@@ -196,10 +254,12 @@ def adopt_legacy_agent_binary(binkey):
ErrorCodes.VALIDATION_ERROR,
'the legacy binary could not be adopted',
http_code=400)
registration = _sync_agent_registration()
return success_response({
'binary': binkey,
'current': result['current'],
'versions': result['versions'],
'registration': registration,
})
@@ -224,8 +284,13 @@ def set_current_agent_binary(binkey):
ErrorCodes.NOT_FOUND,
'that version is not staged',
http_code=404)
# A rollback changes the bytes the fleet should receive; repackage so the
# application row follows the pointer instead of pinning the build the
# operator just rolled away from.
registration = _sync_agent_registration()
return success_response({
'binary': binkey,
'current': agentstore.current_version(binkey),
'versions': agentstore.list_versions(binkey),
'registration': registration,
})

View File

@@ -0,0 +1,786 @@
"""One-line bootstrap installer for the endpoint agent.
An admin mints a short-lived token in Settings > Endpoints and hands a tech one
command. On a PC with no agent, in an ELEVATED PowerShell: the command
downloads bootstrap.ps1 to a file, runs it, and deletes it again.
That installs the agent and tray with NO shared key, so the PC asks to enroll
and waits for an admin to approve it in Settings > Endpoints > Enrollment. This
is the path for a ONE-OFF PC; a managed bay keeps getting the agent from
GE-Enforce, and the generated script says so when it finds an agent already
installed.
Routes
POST /siteagent/bootstrap/tokens admin, audited; secret once
GET /siteagent/bootstrap/tokens admin; never a secret
POST /siteagent/bootstrap/tokens/<id>/revoke admin, audited
GET /siteagent/bootstrap.ps1 token only; not a use
GET /siteagent/bootstrap/package token only; one use
The two token-authenticated routes take the token in the
X-Endpoint-Bootstrap-Token header, and still accept the deprecated ?t= form so
a command already pasted on a bay keeps working. They answer 404 for anything
not currently usable - unknown, malformed, expired, revoked, exhausted, or no
token presented at all - so the URL confirms nothing about which tokens exist,
nor about whether the query form is still accepted here. The one 400 is a real
query token offered at a site that has turned the query form off.
The admin UI's Download .ps1 link has to use the query form (a browser link
cannot send a header), so its URL carries a marker that tells it apart from a
bay still running a command pasted before this change. Only the bay logs the
deprecation warning, which is what the rollout counts un-migrated bays with.
Both routes are per-IP rate limited with the same fixed window shape the login
and payload-download limiters use, and both write an audit row carrying the
caller's IP.
"""
import hashlib
import json
import re
import secrets
from datetime import datetime, timedelta, timezone
from flask import Response, current_app, request, send_file
from shopdb.api import (
db, AuditLog, Setting, client_ip, error_response, success_response,
ErrorCodes, require_role,
)
from ..models import EndpointBootstrapToken
from . import siteagent
from .ratelimit import ratelimited, rate_limit_response
from .routes import endpoints_bp
# sdbb_ + 43 chars base64url (32 random bytes, unpadded). Same shape as the
# per-PC credential, different prefix so a leaked value is identifiable.
TOKEN_PREFIX = 'sdbb_'
_TOKEN_PATTERN = re.compile(r'^sdbb_[A-Za-z0-9_-]{43}$')
DISPLAY_PREFIX_LENGTH = 12
DEFAULT_HOURS = 24
DEFAULT_MAX_USES = 25
MINIMUM_HOURS = 1
MAXIMUM_HOURS = 168
MINIMUM_USES = 1
MAXIMUM_USES = 500
LIST_LIMIT = 100
SCRIPT_PATH = '/api/endpoints/siteagent/bootstrap.ps1'
PACKAGE_PATH = '/api/endpoints/siteagent/bootstrap/package'
# Where the one-line command stages the script before running it. A literal,
# not $env:TEMP: the line has to survive being pasted into an already-running
# PowerShell, which expands every $ inside the outer double quotes before the
# child process sees them.
#
# NOT C:\Windows\Temp. That directory grants Users create-file and gives
# CREATOR OWNER full control of what they create, so any standard user on the
# bay can pre-plant this exact path and wait for a tech to paste the command,
# which then runs the planted content elevated. Program Files is admin-only on
# a stock install, the command re-asserts an admin-only DACL on the directory
# it creates, and it refuses to run a staged file owned by anyone else.
COMMAND_STAGE_DIR = r'C:\Program Files\ShopDB\bootstrap'
COMMAND_SCRIPT_PATH = COMMAND_STAGE_DIR + r'\shopdb-bootstrap.ps1'
# Well-known SIDs, not names: the bays are domain-joined and a renamed or
# localized Administrators group would quietly break both the grant and the
# ownership test.
ADMINISTRATORS_SID = 'S-1-5-32-544'
SYSTEM_SID = 'S-1-5-18'
# The token the two public routes accept. A header, not ?t=: a query string
# lands verbatim in the reverse proxy and gunicorn access logs, and a live
# install token has no business sitting in a log file.
BOOTSTRAP_TOKEN_HEADER = 'X-Endpoint-Bootstrap-Token'
QUERY_TOKEN_ARG = 't'
# Where the token on this request came from. Kept apart because the rollout
# counts un-migrated bays by the deprecation warning SOURCE_QUERY logs: an
# admin clicking Download .ps1 in the browser must not land in that count, and
# a request carrying no token at all must not answer differently from one
# carrying a token nobody issued.
SOURCE_HEADER = 'header'
SOURCE_QUERY = 'query'
SOURCE_UI_LINK = 'uilink'
SOURCE_NONE = 'none'
# The marker on the admin Download .ps1 link. A hint, NOT authentication: a
# caller can add it by hand, and nothing is granted by it. What matters is the
# other direction - a one-line command generated before this change cannot
# carry it, so a request without it really is a bay to go and re-issue.
UI_LINK_ARG = 'via'
UI_LINK_VALUE = 'download'
# DEPRECATED, kept on purpose: every command already pasted on a bay, and the
# UI's "Download .ps1" link (a browser link cannot send a header), carry the
# token in the query string. Accepting it stays the default so a mid-rollout
# bay keeps working; a site that has re-issued its live commands turns it off.
# Turning it off also disables the download link.
#
# Declared in the endpoints plugin's get_settings_defaults() (so the row seeds
# boolean, not string) and flipped from Settings > Endpoints > Build site agent
# > One-line install, "Accept the token in the URL". A switch with no seed and
# no control would be inert on an air-gapped box.
QUERY_TOKEN_SETTING = 'plugin.endpoints.bootstrapquerytoken'
# Fixed-window per-IP limiter, same shape and cache extension as the login
# limiter in shopdb.core.api.auth and the GE-Enforce payload limiter. A tech
# fetching a script and a package is two hits; a scanner walking token guesses
# is thousands.
BOOTSTRAP_RATELIMIT_MAX = 30
BOOTSTRAP_RATELIMIT_WINDOW_SECONDS = 60
# The endpoint-side force switch. Set it in the same command to reinstall over
# an agent that is already there.
FORCE_VARIABLE = 'SHOPDB_BOOTSTRAP_FORCE'
_HOST_PATTERN = re.compile(r'^[A-Za-z0-9.-]+(?::[0-9]{1,5})?$')
def _naive_utcnow():
return datetime.now(timezone.utc).replace(tzinfo=None)
def _caller_ip():
"""The caller address for the audit rows and the deprecation warning.
Read the value before trusting it to identify a bay. client_ip() returns
remote_addr and nothing else, so it is only as good as what the layer in
front put there.
Under the bundled Docker stack that layer is nginx, which sets
X-Forwarded-For, and TRUSTED_PROXY_COUNT=1 on the api service makes ProxyFix
take the hop nginx appended - so this reads the real caller. Keep that count
equal to the number of proxies actually in front: add one without bumping it
and this reads the inner proxy instead.
Behind a reverse proxy that does NOT forward, or one the app was not told to
trust, every row and every warning reads the proxy's own address and the
un-migrated-bay list identifies no bay. That is a deployment mistake this
function cannot detect, which is why the token prefix below is carried as
well.
It is a real address only where the site enabled that rewrite rule (the
installer's -ClientIpSource direct), or set TRUSTED_PROXY_COUNT for a
front proxy that sets the header itself.
"""
return client_ip(request) or 'unknown'
def _ratelimited():
"""True when this caller is over budget for the current window.
The implementation moved to .ratelimit so the enrollment route shares it
rather than carrying a second copy. Budget name, config keys and defaults
are unchanged, so the behaviour here is identical.
"""
return ratelimited('endpointbootstrapratelimit',
'ENDPOINTS_BOOTSTRAP_RATELIMIT',
BOOTSTRAP_RATELIMIT_MAX,
BOOTSTRAP_RATELIMIT_WINDOW_SECONDS)
def _rate_limit_response():
return rate_limit_response('Too many bootstrap requests. Try again later.')
def _not_found():
"""The single answer for every unusable token, so nothing is confirmed."""
return error_response(ErrorCodes.NOT_FOUND, 'not found', http_code=404)
def _safe_host(value):
host = (value or '').strip()
return host if host and _HOST_PATTERN.match(host) else None
def bootstrap_base_url():
"""Base URL baked into the generated command and script.
Prefers the configured site_base_url, which already carries the scheme and
the mount path. Falls back to the forwarded host (only when a proxy is
actually declared) or the request Host plus the script root, forced to
https - the same resolution the printer installer batch generator uses,
and for the same reason: behind a reverse proxy the app sees http on a
loopback port and its url_root drops the mount.
"""
base = (Setting.get('site_base_url') or '').strip().rstrip('/')
if base:
return base
host = None
if current_app.config.get('TRUSTED_PROXY_COUNT'):
host = _safe_host(request.headers.get('X-Forwarded-Host'))
if not host:
host = _safe_host(request.host)
root = (request.script_root or '').rstrip('/')
if not host:
return root
return 'https://{0}{1}'.format(host, root)
def _base(baseurl=None):
resolved = baseurl if baseurl is not None else bootstrap_base_url()
return resolved.rstrip('/')
def bootstrap_script_url(secret, baseurl=None):
"""The UI's "Download .ps1" link: a plain file a tech can save and run.
Still the query-string form, because an <a download> cannot set a header.
The one-line command no longer uses this shape - see bootstrap_command.
It carries UI_LINK_ARG so the server can tell this click apart from a bay
running a pre-change command. Without that the admin's own Download link
fires the deprecation warning the rollout uses to FIND un-migrated bays,
and the count becomes noise the moment an admin mints a token.
"""
return '{0}{1}?{2}={3}&{4}={5}'.format(_base(baseurl), SCRIPT_PATH,
QUERY_TOKEN_ARG, secret,
UI_LINK_ARG, UI_LINK_VALUE)
def bootstrap_fetch_url(path, baseurl=None):
"""A token-free URL for the machine-to-machine fetches. The token rides in
BOOTSTRAP_TOKEN_HEADER so it never reaches an access log."""
return '{0}{1}'.format(_base(baseurl), path)
def bootstrap_command(secret, baseurl=None):
"""The one line a tech pastes into an elevated PowerShell.
Download to a file and run it, never `irm ... | iex`: Microsoft Defender
scores the piped-to-iex shape as Trojan:Win32/Commando.A!ml and kills the
process before a line of it runs. Every bay runs Defender.
Single quotes inside and no $ anywhere, so the one line means the same
whether it is pasted into cmd.exe or into a PowerShell prompt. The download
and the run are wrapped in try/finally so the staged file goes away even
when the script exits non-zero or the download itself fails.
Staging is the part worth being careful about, so the line creates
COMMAND_STAGE_DIR with an admin-only DACL, clears any leftover file, and
refuses (exit 5) to run a staged script that some other principal owns -
which is what a pre-planted file looks like. Well-known SIDs throughout,
and the owner may also be the elevated caller, since a file created by an
administrator is owned by that account rather than by the group.
The hardening is also CHECKED (exit 6). icacls is a native command, so it
reports failure only in an exit code: no exception is raised, and
$ErrorActionPreference never applies to it. Unchecked, an icacls that
cannot write the DACL leaves the directory inheriting whatever the parent
grants and the line downloads into it anyway - the silent version of the
hole this staging move was made to close. The same check also covers the
New-Item before it, because icacls on a directory that was never created
exits non-zero too.
Read as `Get-Variable LASTEXITCODE -ValueOnly`, not `$LASTEXITCODE`: the
whole line must stay free of `$`, or a paste into an already-running
PowerShell expands it in the PARENT and the child is handed `-ne 0` with
the parent's last exit code baked in.
"""
return (
'powershell -ExecutionPolicy Bypass -c "'
"try {{ New-Item -ItemType Directory -Force -Path '{stagedir}' "
'| Out-Null; '
"icacls '{stagedir}' /inheritance:r /grant '*{admin}:(OI)(CI)F' "
"'*{system}:(OI)(CI)F' | Out-Null; "
'if ((Get-Variable LASTEXITCODE -ValueOnly) -ne 0) '
"{{ Write-Host 'Refusing to run: could not restrict "
"{stagedir} to administrators (icacls failed). Fix the directory "
"or delete it, then run the command again.' -ForegroundColor Red; "
'exit 6 }}; '
"Remove-Item '{path}' -Force -ErrorAction SilentlyContinue; "
"Invoke-WebRequest -UseBasicParsing '{url}' "
"-Headers @{{'{header}'='{secret}'}} -OutFile '{path}'; "
"if ((Get-Acl '{path}').GetOwner("
'[Security.Principal.SecurityIdentifier]).Value -notin '
"@('{admin}','{system}',"
'[Security.Principal.WindowsIdentity]::GetCurrent().User.Value)) '
"{{ Write-Host 'Refusing to run: the staged bootstrap script is not "
"owned by an administrator.' -ForegroundColor Red; exit 5 }}; "
"& '{path}' }} finally {{ Remove-Item '{path}' -Force "
'-ErrorAction SilentlyContinue }}"'
).format(stagedir=COMMAND_STAGE_DIR,
path=COMMAND_SCRIPT_PATH,
url=bootstrap_fetch_url(SCRIPT_PATH, baseurl),
header=BOOTSTRAP_TOKEN_HEADER,
secret=secret,
admin=ADMINISTRATORS_SID,
system=SYSTEM_SID)
def _hash_token(secret):
return hashlib.sha256(secret.encode('utf-8')).hexdigest()
def _presented_token():
"""(secret, source) for the token this request carries.
Header first, query string second. The query form is deprecated but still
the only one a browser download link can use.
SOURCE_NONE when nothing was presented, which is NOT the same as an empty
query token: reporting a bare GET as 'query' made it answer 400 naming the
header, telling an anonymous caller whether this site had turned the query
form off, and contradicting the 404-for-everything-unusable rule at the top
of this module.
"""
header = (request.headers.get(BOOTSTRAP_TOKEN_HEADER) or '').strip()
if header:
return header, SOURCE_HEADER
query = (request.args.get(QUERY_TOKEN_ARG) or '').strip()
if not query:
return '', SOURCE_NONE
if request.args.get(UI_LINK_ARG) == UI_LINK_VALUE:
return query, SOURCE_UI_LINK
return query, SOURCE_QUERY
def _is_query_source(source):
"""Both query-string forms, the bay's and the admin download link's. The
setting is about a token in an access log, and the admin link puts one
there just as surely as a pasted command does."""
return source in (SOURCE_QUERY, SOURCE_UI_LINK)
def _query_token_allowed():
"""Defaults to True: today's pasted commands and the UI download link both
use the query form, and turning that off mid-rollout strands a bay."""
return bool(Setting.get(QUERY_TOKEN_SETTING, True))
def _query_token_refused():
"""Answered before any lookup, so it confirms nothing about any token."""
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Send the bootstrap token in the {0} header.'.format(
BOOTSTRAP_TOKEN_HEADER),
http_code=400)
def _note_token_source(source):
"""Audit suffix for a token that really resolved, and, for a query-string
token, one log line naming the caller and WHICH query path it was.
Only the bay warns. That warning is the rollout's un-migrated-bay counter
(see PER-PC-CREDENTIALS-ROLLOUT.md), so the admin's own Download .ps1 link
logs at info instead - otherwise every mint-and-test by an admin adds a
bay that does not exist.
Never the token itself, in either line: keeping it out of the logs is the
whole point of the header form.
"""
if source == SOURCE_UI_LINK:
current_app.logger.info(
'bootstrap script fetched by %s from the admin Download .ps1 '
'link; query-string token, but the admin browser path, NOT an '
'un-migrated bay', _caller_ip())
return ' (query-string token, admin download link)'
if source == SOURCE_QUERY:
current_app.logger.warning(
'bootstrap token presented in the query string by %s with no '
'admin download marker, so this is an un-migrated bay running a '
'command pasted before the header form; that form is deprecated '
'and slated for removal, re-issue the one-line install command',
_caller_ip())
return ' (query-string token, deprecated)'
return ''
def _resolve_token(secret):
"""The usable token with this secret, else None.
Unknown, malformed, expired, revoked and exhausted all resolve to None so
the caller can answer 404 for every one of them.
"""
if not _TOKEN_PATTERN.match(secret):
return None
row = EndpointBootstrapToken.query.filter_by(
tokenhash=_hash_token(secret)).first()
if row is None or not row.isusable(_naive_utcnow()):
return None
return row
def _audit(action, row, details):
AuditLog.log(action, 'EndpointBootstrapToken', entityid=row.tokenid,
entityname=row.tokenprefix,
details='{0} from {1}'.format(details, _caller_ip()))
# ---------------------------------------------------------------------------
# The generated script
# ---------------------------------------------------------------------------
# Why the wait loop watches a LOCAL file rather than a server route: there is no
# public "am I approved yet" endpoint, and adding one would hand an anonymous
# caller a way to probe enrollment state. The agent writes credential.bin the
# moment it collects an approved credential, so the file appearing IS the
# enrollment, observed without asking the server anything.
_SCRIPT_TEMPLATE = '''# ShopDB endpoint agent bootstrap
#
# Generated by the ShopDB server for one short-lived install token. Run from an
# ELEVATED Windows PowerShell. It downloads this server's agent package, checks
# the binaries against the hashes stamped in below, installs the agent and tray
# with NO shared key, and leaves the PC waiting for an admin to approve its
# enrollment in Settings > Endpoints > Enrollment.
#
# A managed bay should get the agent from GE-Enforce instead. This is the
# one-off path.
$ErrorActionPreference = "Stop"
$BootstrapPackageUrl = {packageurl}
$BootstrapPackageHeaders = @{{ {tokenheader} = {token} }}
$BootstrapServiceName = "ShopDbEndpointAgent"
$BootstrapInstallDir = "C:\\Program Files\\ShopDB\\EndpointAgent"
$BootstrapAgentSha256 = {agentsha}
$BootstrapTraySha256 = {traysha}
$BootstrapForceVariable = "{forcevariable}"
$BootstrapWaitSeconds = {waitseconds}
$BootstrapCredentialPath = Join-Path $env:ProgramData "ShopDB\\EndpointAgent\\credential\\credential.bin"
Write-Host "== ShopDB endpoint agent bootstrap ==" -ForegroundColor Cyan
# 1. Elevation. Every later step writes HKLM and Program Files.
$bootstrapIdentity = [Security.Principal.WindowsIdentity]::GetCurrent()
$bootstrapPrincipal = New-Object Security.Principal.WindowsPrincipal($bootstrapIdentity)
if (-not $bootstrapPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {{
Write-Host "This installer must run elevated." -ForegroundColor Red
Write-Host "Open PowerShell as administrator (right-click Windows PowerShell, Run as administrator) and paste the command again."
exit 1
}}
# 2. An agent already here means this PC is probably managed.
$bootstrapForce = $false
$bootstrapForceValue = [Environment]::GetEnvironmentVariable($BootstrapForceVariable)
if ($bootstrapForceValue -and $bootstrapForceValue -ne "0") {{ $bootstrapForce = $true }}
$bootstrapExisting = Get-Service -Name $BootstrapServiceName -ErrorAction SilentlyContinue
if ($bootstrapExisting -and -not $bootstrapForce) {{
Write-Host ("The ShopDB endpoint agent is already installed here (service " + $BootstrapServiceName + ", " + $bootstrapExisting.Status + ").") -ForegroundColor Yellow
Write-Host "A managed bay should get the agent from GE-Enforce, not from this one-line installer."
Write-Host ("To reinstall anyway, run this first in the same window, then paste the command again:")
Write-Host (" " + '$env:' + $BootstrapForceVariable + " = '1'")
exit 2
}}
$bootstrapAlreadyEnrolled = Test-Path $BootstrapCredentialPath
# 3. Download the package over TLS with normal chain validation. Nothing here
# relaxes certificate checking: a server this PC does not trust must be fixed,
# not bypassed.
try {{
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
}}
catch {{ }}
$bootstrapStamp = [DateTime]::UtcNow.ToString("yyyyMMddHHmmssfff")
$bootstrapTempDir = Join-Path $env:TEMP ("shopdb-bootstrap-" + $bootstrapStamp)
$bootstrapZipPath = Join-Path $bootstrapTempDir "package.zip"
$bootstrapExtractDir = Join-Path $bootstrapTempDir "package"
function Remove-BootstrapTemp {{
if (Test-Path $bootstrapTempDir) {{
try {{ Remove-Item -Path $bootstrapTempDir -Recurse -Force -ErrorAction Stop }}
catch {{ Write-Warning ("could not remove " + $bootstrapTempDir + "; delete it manually.") }}
}}
}}
function Test-BootstrapHash {{
param(
[Parameter(Mandatory)] [string] $Path,
[AllowEmptyString()] [string] $Expected,
[Parameter(Mandatory)] [string] $Label
)
$present = Test-Path $Path
if ([string]::IsNullOrWhiteSpace($Expected)) {{
# The server staged no such binary, so the package must not carry one.
if ($present) {{
Write-Host ($Label + " is in the package but this server staged none.") -ForegroundColor Red
return $false
}}
Write-Host (" " + $Label + ": not shipped by this server")
return $true
}}
if (-not $present) {{
Write-Host ($Label + " is missing from the downloaded package.") -ForegroundColor Red
return $false
}}
$actual = (Get-FileHash -Path $Path -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actual -ne $Expected.ToLowerInvariant()) {{
Write-Host ($Label + " does not match the expected SHA256.") -ForegroundColor Red
Write-Host (" expected " + $Expected)
Write-Host (" actual " + $actual)
return $false
}}
Write-Host (" " + $Label + ": sha256 verified")
return $true
}}
try {{
New-Item -ItemType Directory -Force -Path $bootstrapTempDir | Out-Null
Write-Host "[1/4] Downloading the agent package ..."
Invoke-WebRequest -Uri $BootstrapPackageUrl -Headers $BootstrapPackageHeaders -OutFile $bootstrapZipPath -UseBasicParsing
Write-Host "[2/4] Expanding and verifying ..."
Expand-Archive -Path $bootstrapZipPath -DestinationPath $bootstrapExtractDir -Force
$bootstrapOk = $true
if (-not (Test-BootstrapHash -Path (Join-Path $bootstrapExtractDir "shopdbagent.exe") -Expected $BootstrapAgentSha256 -Label "shopdbagent.exe")) {{ $bootstrapOk = $false }}
if (-not (Test-BootstrapHash -Path (Join-Path $bootstrapExtractDir "shopdb-endpoint-tray.exe") -Expected $BootstrapTraySha256 -Label "shopdb-endpoint-tray.exe")) {{ $bootstrapOk = $false }}
if (-not $bootstrapOk) {{
Remove-BootstrapTemp
Write-Host "Refusing to install an unverified package." -ForegroundColor Red
exit 3
}}
$bootstrapInstaller = Join-Path $bootstrapExtractDir "install.ps1"
if (-not (Test-Path $bootstrapInstaller)) {{
Remove-BootstrapTemp
Write-Host "The downloaded package carries no install.ps1." -ForegroundColor Red
exit 3
}}
# No key argument: this package has no -CollectorKey parameter at all.
Write-Host "[3/4] Installing ..."
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $bootstrapInstaller
$bootstrapInstallExit = $LASTEXITCODE
}}
catch {{
Remove-BootstrapTemp
Write-Host ("Bootstrap failed: " + $_.Exception.Message) -ForegroundColor Red
exit 3
}}
Remove-BootstrapTemp
# 4. Report what actually landed. The installer's own exit code is not enough:
# a PowerShell -File run can return 0 after a terminating error, so the service
# is read back directly.
Write-Host "[4/4] Result"
$bootstrapAgentPath = Join-Path $BootstrapInstallDir "shopdbagent.exe"
$bootstrapVersion = "not installed"
if (Test-Path $bootstrapAgentPath) {{
$bootstrapVersion = (Get-Item $bootstrapAgentPath).VersionInfo.FileVersion
}}
$bootstrapService = Get-Service -Name $BootstrapServiceName -ErrorAction SilentlyContinue
$bootstrapState = "not installed"
if ($bootstrapService) {{ $bootstrapState = [string] $bootstrapService.Status }}
Write-Host (" installed version : " + $bootstrapVersion)
Write-Host (" service state : " + $bootstrapState)
if (-not $bootstrapService -or $bootstrapState -ne "Running") {{
Write-Host "The agent service is not running. The install did not complete." -ForegroundColor Red
if ($bootstrapInstallExit) {{ Write-Host (" installer exit code: " + $bootstrapInstallExit) }}
exit 4
}}
if ($bootstrapAlreadyEnrolled) {{
Write-Host "This PC already holds an endpoint credential; no approval is needed." -ForegroundColor Green
exit 0
}}
Write-Host "This PC is waiting for approval - an admin approves it in Settings > Endpoints > Enrollment." -ForegroundColor Yellow
# Watch for the credential the agent writes once an admin approves it. Purely
# local: nothing is asked of the server, and a PC still pending after the wait
# is a normal outcome, not a failure.
$bootstrapDeadline = (Get-Date).AddSeconds($BootstrapWaitSeconds)
$bootstrapEnrolled = $false
while ((Get-Date) -lt $bootstrapDeadline) {{
if (Test-Path $BootstrapCredentialPath) {{ $bootstrapEnrolled = $true; break }}
Start-Sleep -Seconds 5
}}
if ($bootstrapEnrolled) {{
Write-Host "approved and enrolled" -ForegroundColor Green
}}
else {{
Write-Host "Still pending. The agent keeps asking on its own; nothing else is needed on this PC."
}}
exit 0
'''
def build_bootstrap_script(secret, baseurl=None):
"""The PowerShell text served for one token, with the URL and hashes baked in."""
base = _base(baseurl)
hashes = siteagent.staged_binary_hashes()
return _SCRIPT_TEMPLATE.format(
packageurl=json.dumps(bootstrap_fetch_url(PACKAGE_PATH, base)),
tokenheader=json.dumps(BOOTSTRAP_TOKEN_HEADER),
token=json.dumps(secret),
agentsha=json.dumps(hashes.get('agent', '')),
traysha=json.dumps(hashes.get('tray', '')),
forcevariable=FORCE_VARIABLE,
waitseconds=120,
)
# ---------------------------------------------------------------------------
# Admin API
# ---------------------------------------------------------------------------
def _username():
from flask_jwt_extended import current_user
return getattr(current_user, 'username', None)
@endpoints_bp.route('/siteagent/bootstrap/tokens', methods=['POST'])
@require_role('admin')
def create_bootstrap_token():
"""Mint one short-lived bootstrap token. The secret is returned ONCE."""
payload = request.get_json(silent=True) or {}
try:
hours = int(payload.get('hours', DEFAULT_HOURS))
maxuses = int(payload.get('maxuses', DEFAULT_MAX_USES))
except (TypeError, ValueError):
return error_response(ErrorCodes.VALIDATION_ERROR,
'hours and maxuses must be whole numbers',
http_code=400)
if not MINIMUM_HOURS <= hours <= MAXIMUM_HOURS:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'hours must be between {0} and {1}'.format(MINIMUM_HOURS,
MAXIMUM_HOURS),
http_code=400)
if not MINIMUM_USES <= maxuses <= MAXIMUM_USES:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'maxuses must be between {0} and {1}'.format(MINIMUM_USES,
MAXIMUM_USES),
http_code=400)
secret = TOKEN_PREFIX + secrets.token_urlsafe(32)
now = _naive_utcnow()
row = EndpointBootstrapToken(
tokenhash=_hash_token(secret),
tokenprefix=secret[:DISPLAY_PREFIX_LENGTH],
createdat=now,
createdby=_username(),
expiresat=now + timedelta(hours=hours),
maxuses=maxuses,
usecount=0,
)
db.session.add(row)
db.session.flush()
_audit('created', row,
'bootstrap token created, {0}h, {1} use(s)'.format(hours, maxuses))
db.session.commit()
return success_response(
{
'token': row.to_dict(now),
'secret': secret,
'command': bootstrap_command(secret),
'scripturl': bootstrap_script_url(secret),
},
message='Bootstrap token created. The command is shown once.',
http_code=201)
@endpoints_bp.route('/siteagent/bootstrap/tokens', methods=['GET'])
@require_role('admin')
def list_bootstrap_tokens():
"""Every bootstrap token, newest first. Never a secret."""
now = _naive_utcnow()
rows = (EndpointBootstrapToken.query
.order_by(EndpointBootstrapToken.tokenid.desc())
.limit(LIST_LIMIT).all())
return success_response({'tokens': [row.to_dict(now) for row in rows]})
@endpoints_bp.route('/siteagent/bootstrap/tokens/<int:tokenid>/revoke',
methods=['POST'])
@require_role('admin')
def revoke_bootstrap_token(tokenid):
"""Kill a bootstrap token now. Already-revoked is a no-op, not an error."""
row = db.session.get(EndpointBootstrapToken, tokenid)
if row is None:
return error_response(ErrorCodes.NOT_FOUND, 'not found', http_code=404)
now = _naive_utcnow()
if row.revokedat is None:
row.revokedat = now
_audit('updated', row, 'bootstrap token revoked')
db.session.commit()
return success_response({'token': row.to_dict(now)},
message='Bootstrap token revoked')
# ---------------------------------------------------------------------------
# Token-authenticated: no login, the token is the whole authorization. It
# rides in the X-Endpoint-Bootstrap-Token header; ?t= is still accepted.
# ---------------------------------------------------------------------------
@endpoints_bp.route('/siteagent/bootstrap.ps1', methods=['GET'])
def serve_bootstrap_script():
"""The PowerShell the one-line command downloads, runs and deletes.
The same URL is the UI's "Download .ps1" link, for a tech who would rather
save the file and run it than paste a command.
Fetching the script does NOT spend a use: a tech who pastes the command
twice, or a shell that retries, must not burn the budget the package
download is metered by.
"""
if _ratelimited():
return _rate_limit_response()
secret, source = _presented_token()
if _is_query_source(source) and not _query_token_allowed():
return _query_token_refused()
row = _resolve_token(secret)
if row is None:
return _not_found()
_audit('requested', row,
'bootstrap script served' + _note_token_source(source))
db.session.commit()
return Response(build_bootstrap_script(secret), mimetype='text/plain')
@endpoints_bp.route('/siteagent/bootstrap/package', methods=['GET'])
def download_bootstrap_package():
"""The keyless agent package the bootstrap script installs.
Never carries a collector key, whatever the shared-key setting says: a
bootstrap PC enrolls and waits for approval. Counts as one use.
"""
if _ratelimited():
return _rate_limit_response()
secret, source = _presented_token()
if _is_query_source(source) and not _query_token_allowed():
return _query_token_refused()
row = _resolve_token(secret)
if row is None:
return _not_found()
now = _naive_utcnow()
row.usecount = (row.usecount or 0) + 1
row.lastusedat = now
row.lastusedip = _caller_ip()[:45]
_audit('downloaded', row, 'bootstrap package, use {0} of {1}{2}'.format(
row.usecount, row.maxuses, _note_token_source(source)))
db.session.commit()
buffer, warning = siteagent.build_keyless_package(bootstrap_base_url())
response = send_file(
buffer,
mimetype='application/zip',
as_attachment=True,
download_name=siteagent.package_download_name())
if warning:
response.headers[siteagent.CA_WARNING_HEADER] = warning
return response

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,52 @@
"""Fixed-window per-IP rate limiting for the agent-facing routes.
One implementation, several budgets. The bootstrap installer routes and the
enrollment route are both unauthenticated by design - a PC with no agent has
neither a credential nor a user session - so the only thing standing between
them and a loop is a limiter. Each caller gets its own budget name so the two
surfaces cannot spend each other's allowance.
Same shape and same cache extension as the login limiter in
shopdb.core.api.auth. Under the default SimpleCache the counter is per-process,
so with N gunicorn workers the effective budget is N times the configured
maximum; that is defence in depth rather than a hard quota, and a shared cache
backend (Redis/memcached) tightens it to a true global budget.
"""
import time
from flask import current_app, request
from shopdb.api import cache, client_ip, error_response
def caller_ip():
return client_ip(request) or 'unknown'
def ratelimited(budget, configprefix, defaultmax, defaultwindow):
"""True when this caller is over budget for the current window.
budget cache key namespace, e.g. 'endpointenrollratelimit'
configprefix config key stem, e.g. 'ENDPOINTS_ENROLL_RATELIMIT'; reads
<prefix>_ENABLED, <prefix>_MAX and <prefix>_WINDOW_SECONDS
"""
if not current_app.config.get(configprefix + '_ENABLED', True):
return False
window = current_app.config.get(configprefix + '_WINDOW_SECONDS',
defaultwindow)
maxhits = current_app.config.get(configprefix + '_MAX', defaultmax)
# Time bucket makes this a fixed window: the key rolls over at each window
# boundary, so a per-hit set() cannot turn it into a sliding window.
bucket = int(time.time() // window) if window > 0 else 0
key = '{0}:{1}:{2}'.format(budget, caller_ip(), bucket)
count = cache.get(key) or 0
if count >= maxhits:
return True
cache.set(key, count + 1, timeout=window)
return False
def rate_limit_response(message):
"""The standard 429 shape every limiter in the product answers with."""
return error_response('RATE_LIMITED', message, http_code=429)

File diff suppressed because it is too large Load Diff

View File

@@ -21,11 +21,19 @@ The generated install.ps1 writes those two registry values from the operator's
inputs; appsettings.json ships with an empty baseurl and a placeholder apikey so
the agent fails closed until provisioned. The collector key is embedded only in
the install script's default parameter and is never stored or logged here.
Per-PC credentials: the shared key is optional per build. A package built
without it carries no key at all; the agent then asks to enroll and an admin
approves it. Every package pins the server's public key (ServerPins, the
lowercase hex SHA-256 of the leaf SubjectPublicKeyInfo), optionally with a
second "next" pin for a planned certificate rotation.
"""
import hashlib
import io
import json
import os
import re
import ssl
import zipfile
@@ -34,9 +42,12 @@ from flask import request, current_app, send_file
from shopdb.api import (success_response, error_response, ErrorCodes,
require_permission)
from .routes import endpoints_bp
from .routes import endpoints_bp, _spki_sha256
from .agentauth import shared_key_allowed
from . import agentstore
_PIN_PATTERN = re.compile(r'^[0-9a-f]{64}$')
# Prebuilt, staged agent binaries. Built out of band and dropped under
# instance/siteagent/ (mirrors the adoption tool's STAGED_EXE_RELPATH model).
STAGED_AGENT_RELPATH = os.path.join('siteagent', 'shopdbagent.exe')
@@ -110,6 +121,74 @@ def _validate_pem_cert(raw):
return None
return ''.join(blocks)
def _leaf_certificate_facts(pembytes):
"""(spkisha256, isca) for the FIRST certificate in a PEM, else (None, None).
The first block is the leaf in a leaf-plus-chain file, which is the cert
the agent pins and the one Import-Certificate brings into the store.
"""
from cryptography import x509
try:
text = pembytes.decode('ascii')
except (UnicodeDecodeError, AttributeError):
return None, None
start = text.find('-----BEGIN CERTIFICATE-----')
end = text.find('-----END CERTIFICATE-----', start)
if start == -1 or end == -1:
return None, None
block = (text[start:end + len('-----END CERTIFICATE-----')] + '\n').encode('ascii')
try:
certificate = x509.load_pem_x509_certificate(block)
except ValueError:
return None, None
try:
isca = bool(certificate.extensions.get_extension_for_class(
x509.BasicConstraints).value.ca)
except x509.ExtensionNotFound:
isca = False
return _spki_sha256(block), isca
def _current_server_facts():
"""(spkisha256, isca) of this server's own leaf certificate, else (None, None)."""
path = _server_cert_path()
if not path:
return None, None
try:
with open(path, 'rb') as handle:
return _leaf_certificate_facts(handle.read())
except OSError:
return None, None
def _current_server_pin():
"""Pin of this server's own leaf certificate, or None when not found.
The SPKI pin does not depend on basicConstraints, so a CA-flagged
self-signed certificate is pinned like any other leaf.
"""
return _current_server_facts()[0]
# Shown to the admin at build time (response header and UI) when the pinned
# certificate carries basicConstraints CA:TRUE.
CA_CERT_BUILD_WARNING = (
'The server certificate has basicConstraints CA:TRUE, so install.ps1 will '
'not import it into LocalMachine\\Root. The package still pins it: agents '
'1.20 and newer trust the server by pin alone. Agents older than 1.20 and '
'PowerShell 5.1 downloads without pin support will not trust this server. '
'Regenerate the server certificate in Settings > Endpoints > Server '
'certificate (the generator does not set CA), or trust a non-CA '
'certificate on those PCs with Install-ShopDBServerCertificate.ps1.')
CA_CERT_BOTH_PINS_WARNING = (
'The uploaded CA-flagged certificate does not match this server\'s own '
'certificate, so both pins were included in ServerPins: the uploaded '
'certificate and the server\'s own certificate.')
CA_WARNING_HEADER = 'X-ShopDB-Build-Warning'
# The non-secret runtime config shipped in the package. baseurl and apikey stay
# empty/placeholder so the agent fails closed until the registry is provisioned
# by install.ps1. Cadence is stamped from the operator's input at build time.
@@ -118,6 +197,7 @@ _BASE_APPSETTINGS = {
'apikey': 'REPLACE_WITH_COLLECTOR_KEY',
'cadenceseconds': 900,
'jitterseconds': 60,
'startupjitterseconds': 30,
'adoptonfirstrun': True,
'utilitiespath': '',
'logpath': '',
@@ -136,6 +216,12 @@ _BASE_APPSETTINGS = {
'UDC.exe': 'C:\\Program Files\\UDC\\UDC.exe',
'agent.exe': 'C:\\ProgramData\\udc\\mtconnect_udc\\agent\\run.bat',
},
# Approved scheduled task paths for scheduledtask_run. Shipped empty, which
# means the agent falls back to ScheduledTaskPolicy.DefaultApprovedTaskPaths
# (the Corporate Tools set). Listing entries here REPLACES those defaults, so
# a site can narrow or widen the set without an agent rebuild. Anything not
# matched fails closed.
'approvedtaskpaths': [],
}
_README = """ShopDB endpoint agent (site build)
@@ -168,20 +254,42 @@ FILES
USAGE
1. Copy this whole folder to the target endpoint.
2. Right-click install.ps1 and Run with PowerShell as administrator. It
writes the server URL and collector key to HKLM\\SOFTWARE\\GE\\ShopDB,
{provision} to HKLM\\SOFTWARE\\GE\\ShopDB,
copies the agent into Program Files, and starts the service. It also
registers the tray to autostart for every user at logon and adds a
"ShopDB Agent" Start Menu shortcut for manual relaunch.
{enrollment}
UNINSTALL
Run uninstall-shopdbagent.ps1 from an elevated PowerShell to fully remove the
agent, tray autostart, Start Menu shortcut, and credentials.
SECURITY
install.ps1 carries a live collector key in its default -CollectorKey value.
Treat this package as a secret. The key is not recoverable from the ShopDB
server after it was generated; regenerate the key in the UI to revoke it.
"""
{security}"""
_README_SHAREDKEY = {
True: {
'provision': 'writes the server URL, server pins, and collector key',
'enrollment': '',
'security': (
' install.ps1 carries a live collector key in its default -CollectorKey value.\n'
' Treat this package as a secret. The key is not recoverable from the ShopDB\n'
' server after it was generated; regenerate the key in the UI to revoke it.\n'),
},
False: {
'provision': 'writes the server URL and server pins',
'enrollment': (
' This package carries NO shared key. After install the PC requests\n'
' enrollment; approve it in Settings > Endpoints > Enrollment.\n'),
'security': (
' This package holds no secret. Each PC receives its own credential when\n'
' an admin approves its enrollment request; an existing enrolled\n'
' credential on the PC is never touched by install.ps1.\n'),
},
}
def _readme(includesharedkey):
return _README.format(**_README_SHAREDKEY[bool(includesharedkey)])
def _staged_path(relpath):
@@ -189,7 +297,9 @@ def _staged_path(relpath):
return os.path.join(current_app.instance_path, relpath)
def _install_script(baseurl, collectorkey, cadenceseconds, certfilename=None):
def _install_script(baseurl, collectorkey, cadenceseconds, certfilename=None,
serverpins=None, includesharedkey=True,
trustcertinroot=True):
"""The install.ps1 that provisions credentials and installs the service.
The operator's server URL and collector key are stamped as the default
@@ -199,10 +309,38 @@ def _install_script(baseurl, collectorkey, cadenceseconds, certfilename=None):
certificate is bundled, certfilename names it and the script imports it into
the machine Trusted Root store so the agent's outbound TLS to a self-signed
or internal-CA server validates.
serverpins are written as REG_MULTI_SZ ServerPins. With includesharedkey
False the collector key is left out of the script entirely (no parameter,
no registry write) and the installer tells the operator to approve the
enrollment request. trustcertinroot False (a CA certificate) skips the Root
import; the script also re-checks basicConstraints on the endpoint.
"""
baseurl_literal = json.dumps(baseurl)
collectorkey_literal = json.dumps(collectorkey)
certfile_literal = json.dumps(certfilename or '')
pins_literal = '@(' + ', '.join(json.dumps(pin) for pin in (serverpins or [])) + ')'
if includesharedkey:
keyparam = ' [string] $CollectorKey = {0},\n'.format(json.dumps(collectorkey))
keycheck = (
'if ([string]::IsNullOrWhiteSpace($CollectorKey)) {\n'
' throw "CollectorKey is not set. Regenerate the key in the endpoints UI and rebuild."\n'
'}\n')
keywrite = 'Set-HklmValueBothViews -SubKey "SOFTWARE\\GE\\ShopDB" -Name "CollectorKey" -Value $CollectorKey\n'
sharedkeymode = 'included'
enrollnotice = ''
else:
keyparam = ''
keycheck = ''
keywrite = (
'if (-not (Test-Path (Join-Path $env:ProgramData "ShopDB\\EndpointAgent\\credential\\credential.bin"))) {\n'
' Write-Host " no shared key in this package; the agent will request enrollment." -ForegroundColor Yellow\n'
'}\n')
sharedkeymode = 'omitted'
enrollnotice = (
'if (-not (Test-Path (Join-Path $env:ProgramData "ShopDB\\EndpointAgent\\credential\\credential.bin"))) {\n'
' Write-Host " This PC will request enrollment. Approve it in '
'Settings > Endpoints > Enrollment." -ForegroundColor Yellow\n'
'}\n')
return """# install.ps1
# ShopDB endpoint agent installer. Run from an ELEVATED (admin) PowerShell on
# the endpoint. Provisions the registry credentials, copies the agent to
@@ -212,12 +350,24 @@ def _install_script(baseurl, collectorkey, cadenceseconds, certfilename=None):
[CmdletBinding()]
param(
[string] $BaseUrl = {baseurl},
[string] $CollectorKey = {collectorkey},
{keyparam} [string[]] $ServerPins = {serverpins},
[string] $InstallDir = "C:\\Program Files\\ShopDB\\EndpointAgent",
[string] $ServiceName = "ShopDbEndpointAgent",
[string] $TrayExeName = "shopdb-endpoint-tray.exe",
[string] $CertFileName = {certfile},
[switch] $NoTray
[switch] $NoTray,
# Scheduled task that runs the user-context probe INSIDE the operator's own
# logon session. Mapped drives are per logon token, so this is the only
# reliable way to collect them; see the registration block below.
[string] $UserContextTaskName = "ShopDB user-context probe",
[int] $UserContextTaskMinutes = 30,
# The account the probe task runs as. Must match the account whose profile
# the agent reads the spool from (FileLoggerProvider.ShopFloorUserName), or
# the probe writes session data the service never looks at.
[string] $UserContextTaskUser = "ShopFloor",
[switch] $NoUserContextTask
)
$ErrorActionPreference = "Stop"
@@ -232,9 +382,8 @@ if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administra
if ([string]::IsNullOrWhiteSpace($BaseUrl)) {{
throw "BaseUrl is not set. Pass -BaseUrl 'http://<host>:<port>' when running the script."
}}
if ([string]::IsNullOrWhiteSpace($CollectorKey)) {{
throw "CollectorKey is not set. Regenerate the key in the endpoints UI and rebuild."
}}
{keycheck}$sharedKeyMode = "{sharedkeymode}"
$trustCertInRoot = {trustcert}
# Write HKLM values to BOTH the 64-bit and 32-bit (WOW6432Node) registry views.
# The agent reads Registry64 first then Registry32 and uses the first view that
@@ -261,10 +410,106 @@ function Set-HklmValueBothViews {{
}}
}}
# Lock SOFTWARE\\GE\\ShopDB to SYSTEM and Administrators, in both views.
#
# WHY. The shared fleet key is written here and stays until every consumer is
# on a per-PC credential, and the DACL this key inherits by default grants
# BUILTIN\\Users read - so any logged-on user on any bay could read the fleet
# key, which is the exact exposure the per-PC credential migration exists to
# close. Everything that legitimately reads this key runs as SYSTEM (the agent
# service, the GE-Enforce reporters) or as an admin (the adoption tool,
# Set-ShopDBServerPins.ps1). The tray reads no registry at all - it talks to the
# service over the named pipe - so nothing loses access here.
#
# It also protects ServerPins from a standard user, which is what makes the
# registry safe to treat as authoritative for pinning.
function Protect-HklmShopdbKey {{
param([Parameter(Mandatory)] [string] $SubKey)
$rights = [System.Security.AccessControl.RegistryRights]
foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64,
[Microsoft.Win32.RegistryView]::Registry32)) {{
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey(
[Microsoft.Win32.RegistryHive]::LocalMachine, $view)
try {{
$key = $base.OpenSubKey(
$SubKey,
[Microsoft.Win32.RegistryKeyPermissionCheck]::ReadWriteSubTree,
$rights::ChangePermissions -bor $rights::ReadPermissions)
if (-not $key) {{ continue }}
try {{
$acl = $key.GetAccessControl(
[System.Security.AccessControl.AccessControlSections]::Access)
# Break inheritance and DROP the inherited entries rather than
# copying them in: copying is what silently preserves the
# BUILTIN\\Users read this function exists to remove.
$acl.SetAccessRuleProtection($true, $false)
foreach ($rule in @($acl.Access)) {{ $null = $acl.RemoveAccessRule($rule) }}
foreach ($sid in @("S-1-5-18", "S-1-5-32-544")) {{
$who = New-Object System.Security.Principal.SecurityIdentifier($sid)
$acl.AddAccessRule((New-Object System.Security.AccessControl.RegistryAccessRule(
$who,
$rights::FullControl,
[System.Security.AccessControl.InheritanceFlags]::ContainerInherit,
[System.Security.AccessControl.PropagationFlags]::None,
[System.Security.AccessControl.AccessControlType]::Allow)))
}}
$key.SetAccessControl($acl)
}}
finally {{ $key.Dispose() }}
}}
finally {{ $base.Dispose() }}
}}
}}
# REG_MULTI_SZ in both views, compare-before-write so a reinstall with the same
# pins leaves the value (and its last-write time) alone.
function Set-HklmMultiStringBothViews {{
param(
[Parameter(Mandatory)] [string] $SubKey,
[Parameter(Mandatory)] [string] $Name,
[Parameter(Mandatory)] [string[]] $Value
)
$wanted = ($Value -join "`n")
foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64,
[Microsoft.Win32.RegistryView]::Registry32)) {{
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey(
[Microsoft.Win32.RegistryHive]::LocalMachine, $view)
try {{
$key = $base.CreateSubKey($SubKey)
try {{
$same = $false
$existingValue = $key.GetValue($Name, $null)
if ($null -ne $existingValue -and
$key.GetValueKind($Name) -eq [Microsoft.Win32.RegistryValueKind]::MultiString) {{
$same = ((@($existingValue) -join "`n") -ceq $wanted)
}}
if ($same) {{
Write-Host " $Name unchanged ($view)"
}}
else {{
$key.SetValue($Name, [string[]] $Value, [Microsoft.Win32.RegistryValueKind]::MultiString)
Write-Host " $Name written ($view)"
}}
}}
finally {{ $key.Dispose() }}
}}
finally {{ $base.Dispose() }}
}}
}}
Write-Host "== ShopDB endpoint agent install ==" -ForegroundColor Cyan
Write-Host "BaseUrl : $BaseUrl"
Write-Host "InstallDir : $InstallDir"
Write-Host "ServiceName : $ServiceName"
Write-Host "Shared key : $sharedKeyMode"
# Per-PC credential lives under ProgramData\\ShopDB\\EndpointAgent\\credential and
# belongs to the agent. This installer never writes or deletes it, so a reinstall
# or upgrade keeps an enrolled PC enrolled.
$credentialDir = Join-Path $env:ProgramData "ShopDB\\EndpointAgent\\credential"
if (Test-Path (Join-Path $credentialDir "credential.bin")) {{
Write-Host " existing enrolled credential found; kept as is."
}}
# 0. Stop and remove any previous install so the file copy is a clean overwrite.
$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
@@ -287,21 +532,65 @@ foreach ($name in @("shopdbagent", $trayBase)) {{
# 1. Provision registry credentials (both 64-bit and 32-bit views).
Write-Host "[1/6] Provisioning registry HKLM:\\SOFTWARE\\GE\\ShopDB (Registry64 + Registry32) ..."
Set-HklmValueBothViews -SubKey "SOFTWARE\\GE\\ShopDB" -Name "BaseUrl" -Value $BaseUrl
Set-HklmValueBothViews -SubKey "SOFTWARE\\GE\\ShopDB" -Name "CollectorKey" -Value $CollectorKey
{keywrite}
# Server public-key pins (current, plus next during a planned rotation). An
# empty list leaves any ServerPins already on the PC untouched.
$pinList = @($ServerPins | Where-Object {{ -not [string]::IsNullOrWhiteSpace($_) }})
if ($pinList.Count -gt 0) {{
Set-HklmMultiStringBothViews -SubKey "SOFTWARE\\GE\\ShopDB" -Name "ServerPins" -Value $pinList
}}
else {{
Write-Warning "no server pin in this package; ServerPins left as is."
}}
# Harden the key AFTER the writes: the DACL is replaced, not merged, so writing
# first and locking second keeps this correct whether or not the key pre-existed.
try {{
Protect-HklmShopdbKey -SubKey "SOFTWARE\\GE\\ShopDB"
Write-Host " HKLM\\SOFTWARE\\GE\\ShopDB restricted to SYSTEM and Administrators."
}}
catch {{
Write-Warning "could not restrict HKLM\\SOFTWARE\\GE\\ShopDB: $($_.Exception.Message)"
Write-Warning "the shared key on this PC stays readable by any logged-on user until that is fixed."
}}
# 1a. Trust the bundled server certificate, if one shipped. The agent's outbound
# TLS to a self-signed or internal-CA server fails closed until the machine
# trusts the cert. Importing into LocalMachine\\Root covers the SYSTEM service.
# No-op when the deployment uses a publicly trusted (or already-trusted internal)
# certificate, in which case no cert is bundled.
# Kept for agents older than 1.20 and PowerShell download paths that do not pin.
# Only a leaf is trusted: a CA in Root would trust everything it signs.
if (-not [string]::IsNullOrWhiteSpace($CertFileName)) {{
$certPath = Join-Path $sourceDir $CertFileName
if (Test-Path $certPath) {{
Write-Host " trusting bundled server certificate $CertFileName ..."
try {{
Import-Certificate -FilePath $certPath -CertStoreLocation Cert:\\LocalMachine\\Root | Out-Null
$certIsCa = -not $trustCertInRoot
if (-not $certIsCa) {{
try {{
$probe = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($certPath)
foreach ($ext in $probe.Extensions) {{
if ($ext -is [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension] -and
$ext.CertificateAuthority) {{ $certIsCa = $true }}
}}
}}
catch {{ }}
}}
if ($certIsCa) {{
Write-Warning "server certificate $CertFileName is a CA certificate; not importing it into LocalMachine\\Root."
if ($pinList.Count -gt 0) {{
Write-Warning "ServerPins were written: agents 1.20 and newer trust the server by pin alone."
}}
Write-Warning "Agents older than 1.20, and PowerShell 5.1 downloads without pin support, will NOT trust this server."
Write-Warning "Fix: regenerate the server certificate with the ShopDB generator (Settings > Endpoints > Server certificate; it does not set CA),"
Write-Warning "or have an admin trust a non-CA server certificate on this PC with Install-ShopDBServerCertificate.ps1."
}}
else {{
Write-Host " trusting bundled server certificate $CertFileName ..."
try {{
Import-Certificate -FilePath $certPath -CertStoreLocation Cert:\\LocalMachine\\Root | Out-Null
}}
catch {{ Write-Warning "could not import server certificate: $($_.Exception.Message)" }}
}}
catch {{ Write-Warning "could not import server certificate: $($_.Exception.Message)" }}
}}
else {{
Write-Warning "server certificate $CertFileName named but not found beside install.ps1."
@@ -362,10 +651,175 @@ if (-not $NoTray) {{
}}
catch {{ Write-Warning "could not create Start Menu shortcut: $($_.Exception.Message)" }}
# Launch the tray once now so it appears without waiting for the next
# logon. Single-instance guard makes a duplicate launch a no-op.
try {{ Start-Process -FilePath $installedTray -WorkingDirectory $InstallDir }}
catch {{ Write-Warning "could not start tray: $($_.Exception.Message)" }}
# Deliberately NOT launched here. This installer runs elevated, so
# Start-Process would hand the tray an ADMIN token, and drive letters
# are per logon token: the probe would then enumerate the installing
# admin's drive table, not the operator's. Measured on a live endpoint,
# the same two shares reported Unavailable from the elevated shell and
# OK from the operator's own session, in both Win32_NetworkConnection
# and Get-SmbMapping. Launching via explorer.exe does not fix this
# either - it de-elevates but does NOT change account.
#
# The Run key above is the correct autostart: it fires at a genuine
# logon, in the operator's own session. Collection no longer depends on
# the tray at all - the scheduled task below owns it.
Write-Host " tray will start at next logon (not launched from this elevated install)."
}}
}}
# 2b. Register the user-context probe as a scheduled task owned by the SHOPFLOOR
# USER. This is what makes mapped drives, user listeners and idle time
# collectable: it is the only mechanism that starts a process in the operator's
# own session with the operator's own token.
#
# RunLevel Limited is load-bearing, not a hardening nicety - the unelevated
# token is precisely what sees the operator's mapped drives.
#
# UserId, NOT GroupId. A group-principal task is registered against the group
# rather than against any one account, and a member of that group cannot invoke
# it on demand: Start-ScheduledTask from the ShopFloor session returns access
# denied, so the task only ever ran on its own trigger. That is exactly the bug
# where "Refresh Status" appeared to do nothing. Registering to the specific
# account makes the task startable by that account, which is what both the tray
# and any manual retry depend on. The group form is kept only as a fallback for
# a host where the account cannot be resolved - it still collects on a timer,
# it just cannot be kicked on demand.
$taskState = "skipped"
if (-not $NoUserContextTask) {{
Write-Host " registering user-context probe task ..."
$taskAgentExe = Join-Path $InstallDir "shopdbagent.exe"
$taskRegistered = $false
$taskDetail = "no principal accepted"
try {{
$taskAction = New-ScheduledTaskAction -Execute $taskAgentExe -Argument "--collect-usercontext"
# AtLogOn covers the normal case. The repeating trigger matters just as
# much: AtLogOn does NOT fire for a session that is already signed in,
# which is every install onto a machine someone is currently using.
$taskTriggers = @(New-ScheduledTaskTrigger -AtLogOn)
if ($UserContextTaskMinutes -gt 0) {{
$taskTriggers += New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes $UserContextTaskMinutes)
}}
$taskSettings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 5)
# Try the machine-qualified account first, then the bare name. Interactive
# LogonType so the task joins the existing interactive session instead of
# creating a separate one with its own (empty) drive table.
$taskCandidates = @(
@{{ Kind = "user"; Id = "$env:COMPUTERNAME\\$UserContextTaskUser" }},
@{{ Kind = "user"; Id = $UserContextTaskUser }},
@{{ Kind = "group"; Id = "S-1-5-32-545" }},
@{{ Kind = "group"; Id = "BUILTIN\\Users" }}
)
foreach ($candidate in $taskCandidates) {{
try {{
if ($candidate.Kind -eq "user") {{
$taskPrincipal = New-ScheduledTaskPrincipal -UserId $candidate.Id -LogonType Interactive -RunLevel Limited -ErrorAction Stop
}}
else {{
$taskPrincipal = New-ScheduledTaskPrincipal -GroupId $candidate.Id -RunLevel Limited -ErrorAction Stop
}}
Register-ScheduledTask -TaskName $UserContextTaskName -Action $taskAction -Trigger $taskTriggers -Principal $taskPrincipal -Settings $taskSettings -Force -ErrorAction Stop | Out-Null
$taskRegistered = $true
$taskDetail = "$($candidate.Kind):$($candidate.Id)"
break
}}
catch {{ $taskDetail = $_.Exception.Message }}
}}
}}
catch {{ $taskDetail = "could not build the task: $($_.Exception.Message)" }}
if ($taskRegistered) {{
# Read the task back rather than trusting Register-ScheduledTask to have
# returned cleanly: policy or a management agent can rewrite or drop a
# task after registration, and a task that exists but runs elevated
# looks healthy in the scheduler UI while producing wrong data.
$verified = Get-ScheduledTask -TaskName $UserContextTaskName -ErrorAction SilentlyContinue
if (-not $verified) {{
$taskState = "UNVERIFIED:task not found after registration"
Write-Warning " user-context task registered but cannot be read back."
}}
elseif ([string] $verified.Principal.RunLevel -eq "Highest") {{
$taskState = "UNVERIFIED:elevated"
Write-Warning " user-context task runs ELEVATED; mapped drives would be collected from the wrong token."
}}
elseif ($verified.Principal.GroupId) {{
# Registered, will collect on its triggers, but cannot be started on
# demand by the operator - so "Refresh Status" will fall back to the
# timer. Surfaced rather than reported as ok, because the symptom
# (stale session data that never responds to a refresh) is otherwise
# indistinguishable from a broken probe.
$taskState = "DEGRADED:group principal $($verified.Principal.GroupId)"
Write-Warning " user-context task fell back to a GROUP principal; on-demand refresh will not work, only the $UserContextTaskMinutes-minute interval."
}}
else {{
$taskState = "ok:$($verified.Principal.UserId)/$($verified.Principal.RunLevel)"
Write-Host " user-context task registered and verified: $taskState"
}}
# AtLogOn will not fire for an already-signed-in session, so kick it once
# rather than leaving a fresh install blind until the repeat interval.
try {{
Start-ScheduledTask -TaskName $UserContextTaskName -ErrorAction Stop
Write-Host " user-context task started once for the current session."
}}
catch {{ Write-Warning " could not start the task now: $($_.Exception.Message); it runs at next logon or interval." }}
}}
else {{
# Not fatal: the service still checks in, only the session-scoped fields
# are missing, and the ledger records why.
$taskState = "FAILED:$taskDetail"
Write-Warning " could not register the user-context task: $taskDetail"
Write-Warning " mapped drives and idle time will not be collected."
}}
}}
# 2c. Start the tray in the operator's session.
#
# The Run key registered above only fires at LOGON, so installing onto a machine
# someone is already signed in to leaves no tray until they sign out and back in.
# The installer cannot start it directly - it is elevated, and the tray must run
# unelevated in the user's own session - so we borrow the same mechanism that
# solves this for the probe: a transient scheduled task with a Limited group
# principal, run once and then deleted.
#
# Registered without a trigger; it exists only to be invoked immediately. The
# tray is single-instance, so if one is somehow already running this is a no-op.
if (-not $NoTray -and (Test-Path (Join-Path $InstallDir $TrayExeName))) {{
$launchTaskName = "ShopDB tray first launch"
try {{
$launchExe = Join-Path $InstallDir $TrayExeName
$launchAction = New-ScheduledTaskAction -Execute $launchExe -WorkingDirectory $InstallDir
$launchSettings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
$launched = $false
foreach ($groupId in @("S-1-5-32-545", "BUILTIN\\Users")) {{
try {{
$launchPrincipal = New-ScheduledTaskPrincipal -GroupId $groupId -RunLevel Limited -ErrorAction Stop
Register-ScheduledTask -TaskName $launchTaskName -Action $launchAction -Principal $launchPrincipal -Settings $launchSettings -Force -ErrorAction Stop | Out-Null
Start-ScheduledTask -TaskName $launchTaskName -ErrorAction Stop
$launched = $true
break
}}
catch {{ }}
}}
# Give the task a moment to spawn the tray before removing it: deleting a
# task that is still starting can kill the process it just launched.
if ($launched) {{
Start-Sleep -Seconds 3
Write-Host " tray started in the operator's session."
}}
else {{
Write-Warning " could not start the tray now; it will start at next logon via the Run key."
}}
# Transient by design: this is a one-shot launcher, not an autostart.
# The Run key owns autostart, and leaving this behind would mean two
# things racing to start the same single-instance tray at every logon.
try {{ Unregister-ScheduledTask -TaskName $launchTaskName -Confirm:$false -ErrorAction Stop }}
catch {{ Write-Warning " could not remove the transient launch task '$launchTaskName'; remove it manually." }}
}}
catch {{
Write-Warning " could not start the tray now: $($_.Exception.Message); it will start at next logon."
}}
}}
@@ -394,24 +848,31 @@ if (-not $serviceOk) {{
throw "Service $ServiceName did not reach Running after 5 attempts. Leaving install.ps1 in place for diagnosis; re-run once resolved."
}}
Write-Host " service is running." -ForegroundColor Green
{enrollnotice}
$trayBase2 = [System.IO.Path]::GetFileNameWithoutExtension($TrayExeName)
if (-not $NoTray -and (Test-Path (Join-Path $InstallDir $TrayExeName))) {{
$trayOk = $false
for ($attempt = 1; $attempt -le 5; $attempt++) {{
if (Get-Process -Name $trayBase2 -ErrorAction SilentlyContinue) {{ $trayOk = $true; break }}
Write-Host " tray not running yet (attempt $attempt/5); relaunching ..."
try {{ Start-Process -FilePath (Join-Path $InstallDir $TrayExeName) -WorkingDirectory $InstallDir }} catch {{ }}
# Report only; never relaunch from here. This block used to Start-Process the
# tray when it was not found running, which would hand it this installer's
# ADMIN token and reintroduce the wrong-drive-table bug. The launch task
# above is the only place the tray is started during install, and it starts
# it correctly - unelevated, in the operator's session.
$trayFound = $false
for ($attempt = 1; $attempt -le 3; $attempt++) {{
if (Get-Process -Name $trayBase2 -ErrorAction SilentlyContinue) {{ $trayFound = $true; break }}
Start-Sleep -Seconds 2
}}
if ($trayOk) {{ Write-Host " tray is running." -ForegroundColor Green }}
else {{ Write-Warning "tray did not confirm running; it will retry at next logon via the Run key." }}
if ($trayFound) {{
Write-Host " tray is running." -ForegroundColor Green
}}
else {{
Write-Warning "tray did not confirm running; it will start at next logon via the Run key."
}}
}}
# 5. Preserve the install artifacts (minus the secret-bearing install.ps1) under
# ProgramData so the uninstaller and appsettings/cert are available later, then
# scrub install.ps1 from wherever it was run. install.ps1 carries the live
# collector key in its default parameter, so it must not linger on the endpoint.
# scrub install.ps1 from wherever it was run. A shared-key build carries the live
# key in its default parameter, so it must not linger on the endpoint.
Write-Host "[5/6] Archiving install artifacts and removing install.ps1 ..."
$dataDir = Join-Path $env:ProgramData "ShopDB\\EndpointAgent"
New-Item -ItemType Directory -Force -Path $dataDir | Out-Null
@@ -427,11 +888,34 @@ $uninstaller = Join-Path $dataDir "uninstall-shopdbagent.ps1"
$installSelf = Join-Path $sourceDir "install.ps1"
if (Test-Path $installSelf) {{
try {{ Remove-Item -Path $installSelf -Force -ErrorAction Stop; Write-Host " removed install.ps1 (carried the collector key)." }}
catch {{ Write-Warning "could not remove install.ps1 - delete it manually, it contains the collector key: $($_.Exception.Message)" }}
try {{ Remove-Item -Path $installSelf -Force -ErrorAction Stop; Write-Host " removed install.ps1." }}
catch {{ Write-Warning "could not remove install.ps1 - delete it manually: $($_.Exception.Message)" }}
}}
Write-Host "[6/6] Done." -ForegroundColor Green
# Append one line per install, including reinstalls and repeat installs of the
# same version. The user-context task state is recorded because it is the single
# point of failure for session-scoped collection: if mapped drives go missing
# later, this answers "was the task ever registered on this host, and as what"
# without needing the endpoint back. The ledger is a record, not a gate - a
# failure to append must never fail an otherwise-good install.
try {{
$ledgerDir = Join-Path $env:ProgramData "ShopDB\\Logs"
New-Item -ItemType Directory -Force -Path $ledgerDir | Out-Null
$ledgerPath = Join-Path $ledgerDir "shopdbagent-installs.log"
$ledgerAgentVersion = "unknown"
if (Test-Path $installedAgent) {{ $ledgerAgentVersion = (Get-Item $installedAgent).VersionInfo.FileVersion }}
$ledgerTrayVersion = "not-installed"
$ledgerTrayPath = Join-Path $InstallDir $TrayExeName
if (Test-Path $ledgerTrayPath) {{ $ledgerTrayVersion = (Get-Item $ledgerTrayPath).VersionInfo.FileVersion }}
$ledgerLine = "{{0}} host={{1}} installer={{2}} service={{3}} tray={{4}} dir={{5}} usercontexttask={{6}} sharedkey={{7}} serverpins={{8}}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss zzz"), $env:COMPUTERNAME, $env:USERNAME, $ledgerAgentVersion, $ledgerTrayVersion, $InstallDir, $taskState, $sharedKeyMode, $pinList.Count
Add-Content -Path $ledgerPath -Value $ledgerLine -Encoding UTF8
Write-Host "Install ledger: $ledgerPath"
Write-Host " $ledgerLine" -ForegroundColor DarkGray
}}
catch {{ Write-Warning "could not append to the install ledger: $($_.Exception.Message)" }}
if (Test-Path $uninstaller) {{
Write-Host "Uninstaller: $uninstaller" -ForegroundColor Cyan
Write-Host " To remove the agent, run that script from an elevated PowerShell."
@@ -441,11 +925,120 @@ else {{
}}
""".format(
baseurl=baseurl_literal,
collectorkey=collectorkey_literal,
keyparam=keyparam,
keycheck=keycheck,
keywrite=keywrite,
sharedkeymode=sharedkeymode,
enrollnotice=enrollnotice,
serverpins=pins_literal,
trustcert='$true' if trustcertinroot else '$false',
certfile=certfile_literal,
)
def build_package_zip(baseurl, collectorkey, cadenceseconds, appsettings,
certfilename=None, cert_pem=None, certpath=None,
serverpins=None, includesharedkey=True,
trustcertinroot=True):
"""Assemble the agent package ZIP in memory.
The single place the archive is laid out, so the admin download route and
the token-authenticated bootstrap download cannot drift apart in what they
ship. Callers own validation, pin resolution and the appsettings they pass.
"""
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as archive:
archive.writestr('appsettings.json',
json.dumps(appsettings, indent=2) + '\n')
archive.writestr('install.ps1',
_install_script(baseurl.rstrip('/'), collectorkey,
cadenceseconds, certfilename,
serverpins=serverpins,
includesharedkey=includesharedkey,
trustcertinroot=trustcertinroot))
archive.writestr('README.txt', _readme(includesharedkey))
if os.path.isfile(UNINSTALLER_PATH):
archive.write(UNINSTALLER_PATH, 'uninstall-shopdbagent.ps1')
agent = agentstore.current_path('agent')
if agent and os.path.isfile(agent):
archive.write(agent, 'shopdbagent.exe')
tray = agentstore.current_path('tray')
if tray and os.path.isfile(tray):
archive.write(tray, 'shopdb-endpoint-tray.exe')
if cert_pem:
archive.writestr('server.crt', cert_pem)
elif certpath:
archive.write(certpath, 'server.crt')
buffer.seek(0)
return buffer
def package_download_name():
"""Versioned download filename for the package, or the plain one."""
agentversion = agentstore.current_version('agent')
return ('shopdb-endpoint-agent-{0}.zip'.format(agentversion)
if agentversion else 'shopdb-endpoint-agent.zip')
# Filenames the package ships the staged binaries under, keyed by store binkey.
STAGED_EXE_NAMES = {
'agent': 'shopdbagent.exe',
'tray': 'shopdb-endpoint-tray.exe',
}
def staged_binary_hashes():
"""{binkey: lowercase sha256} for every staged binary, '' when none.
The bootstrap script bakes these in and re-checks the downloaded ZIP
against them on the endpoint, so a package swapped in transit is refused
before anything is executed. Hashed from the file rather than read out of
the store manifest so a legacy flat-file staging is covered too.
"""
hashes = {}
for binkey in STAGED_EXE_NAMES:
path = agentstore.current_path(binkey)
digest = ''
if path and os.path.isfile(path):
hasher = hashlib.sha256()
with open(path, 'rb') as handle:
for chunk in iter(lambda: handle.read(65536), b''):
hasher.update(chunk)
digest = hasher.hexdigest()
hashes[binkey] = digest
return hashes
def build_keyless_package(baseurl, cadenceseconds=900):
"""The package the bootstrap installer downloads: never a collector key.
Pins this server exactly as the admin builder does with no upload - the
server's own leaf certificate - and bundles that certificate when the app
can read it, which is the self-signed topology where the endpoint would
otherwise fail its own TLS validation.
"""
appsettings = dict(_BASE_APPSETTINGS)
appsettings['cadenceseconds'] = cadenceseconds
appsettings['apikey'] = ''
certpath = _server_cert_path()
certfilename = 'server.crt' if certpath else None
currentpin, pinisca = _current_server_facts()
serverpins = [pin for pin in (currentpin,) if pin]
appsettings['serverpins'] = serverpins
buffer = build_package_zip(
baseurl, '', cadenceseconds, appsettings,
certfilename=certfilename, certpath=certpath,
serverpins=serverpins, includesharedkey=False,
trustcertinroot=not pinisca)
warning = CA_CERT_BUILD_WARNING if (currentpin and pinisca) else None
return buffer, warning
def _siteagent_status_payload():
"""Whether a prebuilt agent binary is staged for packaging.
@@ -458,6 +1051,8 @@ def _siteagent_status_payload():
'agentstaged': agent_staged,
'traystaged': tray_staged,
'certavailable': _server_cert_path() is not None,
'serverpin': _current_server_pin(),
'sharedkeyallowed': shared_key_allowed(),
'agentcurrent': agentstore.current_version('agent'),
'traycurrent': agentstore.current_version('tray'),
'agentversions': agentstore.list_versions('agent'),
@@ -480,7 +1075,12 @@ def package_siteagent():
"""Bundle the site agent as a downloadable ZIP.
Accepts either JSON or multipart/form-data with fields baseurl,
collectorkey, cadenceseconds, and bundlecert. A multipart request may also
collectorkey, cadenceseconds, bundlecert, includesharedkey, and
nextserverpin. includesharedkey defaults to the site setting
plugin.endpoints.sharedkeyallowed; when off, collectorkey is ignored and
nothing in the package carries it. nextserverpin (64 lowercase hex) is
shipped beside the current server pin for a planned certificate rotation.
A multipart request may also
carry a certfile upload: an operator-supplied .crt/.pem that is validated as
X.509 and bundled so the installer trusts it as a root on the endpoint. This
is the reverse-proxy path where the app cannot see its own TLS cert. When no
@@ -490,7 +1090,9 @@ def package_siteagent():
Returns an in-memory ZIP with the staged shopdbagent.exe (and tray when
present), a non-secret appsettings.json, install.ps1, and README.txt. The
collector key is embedded only in install.ps1 as its default parameter and
is never stored or logged.
is never stored or logged. When the pinned certificate is CA-flagged the
response carries an X-ShopDB-Build-Warning header explaining that only
pinning (agents 1.20 and newer) will trust the server.
"""
if request.form or request.files:
source = request.form
@@ -503,11 +1105,25 @@ def package_siteagent():
collectorkey = (source.get('collectorkey') or '').strip()
cadenceseconds = source.get('cadenceseconds', 900)
bundlecert = str(source.get('bundlecert', '')).lower() in ('1', 'true', 'yes', 'on')
rawinclude = source.get('includesharedkey')
if rawinclude is None or str(rawinclude).strip() == '':
includesharedkey = shared_key_allowed()
else:
includesharedkey = str(rawinclude).lower() in ('1', 'true', 'yes', 'on')
nextserverpin = (source.get('nextserverpin') or '').strip()
if not baseurl or not collectorkey:
if not includesharedkey:
collectorkey = ''
if not baseurl or (includesharedkey and not collectorkey):
return error_response(
ErrorCodes.VALIDATION_ERROR,
'baseurl and collectorkey are required',
'baseurl and collectorkey are required' if includesharedkey
else 'baseurl is required',
http_code=400)
if nextserverpin and not _PIN_PATTERN.match(nextserverpin):
return error_response(
ErrorCodes.VALIDATION_ERROR,
'nextserverpin must be 64 lowercase hex characters',
http_code=400)
try:
@@ -525,6 +1141,8 @@ def package_siteagent():
appsettings = dict(_BASE_APPSETTINGS)
appsettings['cadenceseconds'] = cadenceseconds
if not includesharedkey:
appsettings['apikey'] = ''
# An operator-uploaded cert wins over the auto-detected server cert: it is
# the explicit reverse-proxy answer where the app cannot see its own TLS
@@ -544,40 +1162,62 @@ def package_siteagent():
certfilename = 'server.crt' if (cert_pem or certpath) else None
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as archive:
archive.writestr('appsettings.json',
json.dumps(appsettings, indent=2) + '\n')
archive.writestr('install.ps1',
_install_script(baseurl.rstrip('/'), collectorkey,
cadenceseconds, certfilename))
archive.writestr('README.txt', _README)
if os.path.isfile(UNINSTALLER_PATH):
archive.write(UNINSTALLER_PATH, 'uninstall-shopdbagent.ps1')
agent = agentstore.current_path('agent')
if agent and os.path.isfile(agent):
archive.write(agent, 'shopdbagent.exe')
tray = agentstore.current_path('tray')
if tray and os.path.isfile(tray):
archive.write(tray, 'shopdb-endpoint-tray.exe')
# Pin the leaf the server presents: the bundled/uploaded leaf, else this
# server's own certificate. The SPKI pin ignores basicConstraints, so a
# CA-flagged self-signed cert is still pinned; the CA flag only gates the
# Root import.
trustcertinroot = True
currentpin = None
pinisca = False
if certfilename:
if cert_pem:
archive.writestr('server.crt', cert_pem)
elif certpath:
archive.write(certpath, 'server.crt')
bundledbytes = cert_pem.encode('ascii')
else:
with open(certpath, 'rb') as handle:
bundledbytes = handle.read()
currentpin, bundledisca = _leaf_certificate_facts(bundledbytes)
trustcertinroot = not bundledisca
pinisca = bool(bundledisca)
# An uploaded CA-flagged cert may be the server's own self-signed cert or an
# internal CA root sent by mistake. Ship the server's own pin beside it so
# the agent (which accepts any listed pin) works either way.
serverownpin = None
if cert_pem and pinisca:
serverownpin = _current_server_pin()
if serverownpin == currentpin:
serverownpin = None
if currentpin is None:
currentpin, pinisca = _current_server_facts()
warnings = [CA_CERT_BUILD_WARNING] if (currentpin and pinisca) else []
if serverownpin:
warnings.append(CA_CERT_BOTH_PINS_WARNING)
if nextserverpin and currentpin is None:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'nextserverpin needs a current server pin; bundle or upload the '
'server leaf certificate',
http_code=400)
serverpins = [pin for pin in (currentpin, serverownpin, nextserverpin) if pin]
serverpins = list(dict.fromkeys(serverpins))
appsettings['serverpins'] = serverpins
buffer = build_package_zip(
baseurl, collectorkey, cadenceseconds, appsettings,
certfilename=certfilename, cert_pem=cert_pem, certpath=certpath,
serverpins=serverpins, includesharedkey=includesharedkey,
trustcertinroot=trustcertinroot)
buffer.seek(0)
# Stamp the staged agent version into the download filename so a DT operator
# can see which agent build a package carries at a glance. This is cosmetic:
# install.ps1 finds the exe by its fixed name inside the ZIP, so the download
# name never affects the install. Falls back to the unversioned name when no
# managed version is current (only a legacy flat file is staged).
agentversion = agentstore.current_version('agent')
download_name = ('shopdb-endpoint-agent-{0}.zip'.format(agentversion)
if agentversion else 'shopdb-endpoint-agent.zip')
return send_file(
download_name = package_download_name()
response = send_file(
buffer,
mimetype='application/zip',
as_attachment=True,
download_name=download_name)
if warnings:
response.headers[CA_WARNING_HEADER] = ' '.join(warnings)
return response

View File

@@ -0,0 +1,658 @@
# upgrade-shopdbagent.ps1
# ShopDB Endpoint Agent SELF-UPGRADE template. Delivered to an endpoint by the
# run_script command and executed by the agent as SYSTEM.
#
# WHY THIS TAKES NO PARAMETERS
# run_script executes "powershell.exe -File <script>" with no argument list, so
# there is no channel to pass BaseUrl/CollectorKey in. That is not a problem
# here: an upgrade by definition targets an endpoint that is ALREADY installed
# and therefore already holds its own config. This script READS that config and
# never rewrites it - the values stay exactly as provisioned, so an upgrade can
# not silently repoint an endpoint at a different server or key. A fresh
# install is still install-shopdbagent.ps1's job.
#
# WHY IT RE-LAUNCHES ITSELF DETACHED
# The agent process is what runs this script, and the upgrade must stop that
# same agent to release the lock on shopdbagent.exe (single-file self-contained
# exes are held open while running). If the script stayed a child of the agent,
# stopping the service would kill the script mid-copy and leave the install
# directory half-written. The first pass therefore copies itself to a temp
# location, relaunches detached, and exits immediately so the agent sees a
# clean, fast result. All real work happens in the detached second pass.
#
# REPORTING
# Success is NOT reported through the command result - by the time the upgrade
# succeeds, the agent that would have reported it has been restarted. The new
# version is confirmed on the next check-in. This script's own transcript is
# the local record; see $LogFile below.
#
# AUTH AND PINS
# The download authenticates the way the agent does: the per-PC credential from
# the agent's SYSTEM-only credential.bin when this PC has enrolled, otherwise
# the shared CollectorKey. When ServerPins is set (registry, or serverpins in
# appsettings.json) the server's TLS key must match a pin or nothing is
# downloaded. A 1.19 agent upgrading to 1.20 has no credential yet and uses the
# shared key; the new agent enrolls itself after it starts.
[CmdletBinding()]
param(
# Set only when the script relaunches itself. Operators never pass this.
[switch] $Detached
)
$ErrorActionPreference = 'Stop'
$ServiceName = 'ShopDbEndpointAgent'
$InstallDir = 'C:\Program Files\ShopDB\EndpointAgent'
$TrayExeName = 'shopdb-endpoint-tray.exe'
$AgentExeName = 'shopdbagent.exe'
$StateDir = Join-Path $env:ProgramData 'ShopDB\EndpointAgent'
$WorkDir = Join-Path $StateDir 'upgrade'
$LogDir = Join-Path $StateDir 'logs'
$LogFile = Join-Path $LogDir ('upgrade-{0}.log' -f (Get-Date -Format 'yyyyMMdd-HHmmss'))
# Server-relative path of the managed agent bundle. This reuses the EXISTING
# agent-facing app-package route rather than adding a parallel download: it
# already authenticates with the collector key and already resolves an
# application's installpath, which is precisely what upload registration
# stamped for the agent bundle. The appid is substituted by the server when
# this template is rendered, because it is assigned when the row is created.
$PackagePath = '/api/endpoints/apppackage/app/{{AGENT_APPID}}'
function Write-Log {
param([string] $Message, [string] $Level = 'INFO')
$line = '{0} [{1}] {2}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Level, $Message
Write-Host $line
try { Add-Content -Path $LogFile -Value $line -ErrorAction Stop } catch { }
}
# The shared credential block below logs through Log, as the reporters do.
function Log([string] $msg) { Write-Log $msg }
# ---------------------------------------------------------------------------
# Per-PC credential and server pin. Kept in step with the same block in
# Report-AssetToShopDB.ps1, Report-PrintersToShopDB.ps1 and
# upgrade-shopdbagent.ps1: each runs standalone, so the text is duplicated.
#
# Credential: the endpoint agent (1.20+) enrolls and stores its own credential
# in credential.bin, DPAPI LocalMachine, readable only by SYSTEM and
# Administrators. When present it is sent as X-Endpoint-Credential and the
# shared key is not sent. Without it the shared CollectorKey goes as X-API-Key.
#
# Pins: HKLM\SOFTWARE\GE\ShopDB ServerPins (REG_MULTI_SZ), lowercase hex
# SHA-256 of the server leaf certificate's SubjectPublicKeyInfo. When any pin
# is set the TLS handshake is accepted only if the leaf key matches one.
# ---------------------------------------------------------------------------
$ShopDbCredentialFile = Join-Path $env:ProgramData 'ShopDB\EndpointAgent\credential\credential.bin'
$ShopDbPinSource = @'
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Security;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
public static class ShopDbServerPinValidator
{
private static readonly HashSet<string> Pins = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
public static string LastPresented;
public static void Install(string[] pins)
{
Pins.Clear();
foreach (string pin in pins)
{
if (!String.IsNullOrEmpty(pin)) { Pins.Add(pin); }
}
ServicePointManager.ServerCertificateValidationCallback = Validate;
}
public static bool Validate(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors errors)
{
if (Pins.Count == 0) { return errors == SslPolicyErrors.None; }
if (certificate == null) { return false; }
try
{
LastPresented = SpkiSha256Hex(certificate.GetRawCertData());
}
catch (Exception)
{
LastPresented = null;
return false;
}
return Pins.Contains(LastPresented);
}
public static bool IsPinned(string pin)
{
return !String.IsNullOrEmpty(pin) && Pins.Contains(pin);
}
// Walks the DER: Certificate > TBSCertificate > [0] version (optional),
// serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo.
public static string SpkiSha256Hex(byte[] der)
{
int length;
int position = EnterSequence(der, 0, out length);
position = EnterSequence(der, position, out length);
if (der[position] == 0xA0) { position = Skip(der, position); }
for (int field = 0; field < 5; field++) { position = Skip(der, position); }
if (der[position] != 0x30) { throw new FormatException("SubjectPublicKeyInfo not found"); }
int end = Skip(der, position);
using (SHA256 sha = SHA256.Create())
{
byte[] hash = sha.ComputeHash(der, position, end - position);
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
}
}
private static int ReadLength(byte[] der, int position, out int length)
{
int first = der[position];
if (first < 0x80) { length = first; return position + 1; }
int count = first & 0x7F;
if (count == 0 || count > 4) { throw new FormatException("Unsupported DER length"); }
length = 0;
for (int i = 1; i <= count; i++) { length = (length << 8) | der[position + i]; }
return position + 1 + count;
}
private static int EnterSequence(byte[] der, int position, out int length)
{
if (der[position] != 0x30) { throw new FormatException("Expected SEQUENCE"); }
return ReadLength(der, position + 1, out length);
}
private static int Skip(byte[] der, int position)
{
int length;
int content = ReadLength(der, position + 1, out length);
return content + length;
}
}
'@
function Get-ShopDbServerPins {
# Both registry views, like CollectorKey. Anything not 64 hex chars is dropped.
$pins = @()
foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64, [Microsoft.Win32.RegistryView]::Registry32)) {
try {
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $view)
try {
$key = $base.OpenSubKey('SOFTWARE\GE\ShopDB')
if ($key) {
try {
foreach ($value in @($key.GetValue('ServerPins'))) {
if ($null -eq $value) { continue }
$pin = ([string]$value -replace '[\s:]', '').ToLowerInvariant()
if ($pin -match '^[0-9a-f]{64}$' -and $pins -notcontains $pin) { $pins += $pin }
}
} finally { $key.Dispose() }
}
} finally { $base.Dispose() }
} catch {}
}
return ,$pins
}
function Test-ShopDbServerPinsValue {
# True when ServerPins exists in either registry view, even empty. The
# registry then owns the pin list and appsettings pins are ignored, the
# same rule the agent follows.
foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64, [Microsoft.Win32.RegistryView]::Registry32)) {
try {
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $view)
try {
$key = $base.OpenSubKey('SOFTWARE\GE\ShopDB')
if ($key) {
try {
if ($key.GetValueNames() -contains 'ServerPins') { return $true }
} finally { $key.Dispose() }
}
} finally { $base.Dispose() }
} catch {}
}
return $false
}
function Enable-ShopDbServerPins([string[]]$Pins) {
# True when pinning is active. False means pins are set but could not be
# enforced; the caller must not send anything then.
if ($PSVersionTable.PSEdition -eq 'Core') {
# PowerShell 7 web cmdlets ignore ServicePointManager, so the check
# would silently not happen.
Log 'ERROR ServerPins is set; pin checking needs Windows PowerShell 5.1 (powershell.exe), not pwsh'
return $false
}
try {
if (-not ('ShopDbServerPinValidator' -as [type])) {
Add-Type -TypeDefinition $ShopDbPinSource -ErrorAction Stop
}
[ShopDbServerPinValidator]::Install($Pins)
return $true
} catch {
Log "ERROR ServerPins is set but pin checking could not be enabled: $($_.Exception.Message)"
return $false
}
}
function Get-ShopDbEndpointCredential([string]$TargetUrl) {
# Empty when there is none or it cannot be used for this URL.
if (-not (Test-Path -LiteralPath $ShopDbCredentialFile)) { return '' }
try {
Add-Type -AssemblyName System.Security -ErrorAction Stop
$protected = [System.IO.File]::ReadAllBytes($ShopDbCredentialFile)
$plain = [System.Security.Cryptography.ProtectedData]::Unprotect(
$protected, $null, [System.Security.Cryptography.DataProtectionScope]::LocalMachine)
$record = [System.Text.Encoding]::UTF8.GetString($plain) | ConvertFrom-Json
} catch {
Log "WARN could not read the endpoint credential ($ShopDbCredentialFile): $($_.Exception.Message)"
return ''
}
$credential = [string]$record.credential
if (-not $credential.StartsWith('sdbe_')) {
Log 'WARN endpoint credential file has no usable credential; ignoring it'
return ''
}
if (-not $TargetUrl.StartsWith('https://', [System.StringComparison]::OrdinalIgnoreCase)) {
Log "WARN not sending the endpoint credential over a non-https URL ($TargetUrl)"
return ''
}
# Never hand a credential to a server other than the one that issued it.
$issuer = ([string]$record.baseurl).Trim().TrimEnd('/')
if ($issuer -and -not ($TargetUrl.TrimEnd('/') + '/').StartsWith($issuer + '/', [System.StringComparison]::OrdinalIgnoreCase)) {
Log "WARN endpoint credential was issued by $issuer, not the target $TargetUrl; not sending it"
return ''
}
return $credential
}
function Get-ShopDbHttpFailure($ErrorRecord) {
# Status and error envelope code (data.error.code) from a failed request.
$status = 0
$code = ''
$text = ''
try { $status = [int]$ErrorRecord.Exception.Response.StatusCode } catch {}
if ($ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) {
$text = $ErrorRecord.ErrorDetails.Message
} else {
try {
$reader = New-Object System.IO.StreamReader($ErrorRecord.Exception.Response.GetResponseStream())
$text = $reader.ReadToEnd()
$reader.Dispose()
} catch {}
}
if ($text) {
try { $code = [string](($text | ConvertFrom-Json).data.error.code) } catch {}
}
$presented = ''
$matched = $false
if ('ShopDbServerPinValidator' -as [type]) {
$presented = [string][ShopDbServerPinValidator]::LastPresented
$matched = [ShopDbServerPinValidator]::IsPinned($presented)
}
# Innermost exception text: the outer WebException hides the real cause
# (unexpected EOF, connection reset, trust failure).
$message = ''
$exception = $ErrorRecord.Exception
while ($exception) {
if ($exception.Message) { $message = $exception.Message }
$exception = $exception.InnerException
}
return @{ Status = $status; Code = $code; PresentedPin = $presented; PinMatched = $matched; Message = $message }
}
function Write-ShopDbAuthFailure($Failure) {
switch ($Failure.Code) {
'CREDENTIAL_INVALID' {
Log ('ERROR 401 CREDENTIAL_INVALID: the server does not accept this PC''s endpoint credential ' +
'(revoked or unknown). The endpoint agent deletes it and re-enrolls on its own.')
}
'SHARED_KEY_REFUSED' {
Log ('ERROR 401 SHARED_KEY_REFUSED: the server refuses the shared key for this PC, because ' +
'the PC is enrolled or the shared key is switched off. Reports need the endpoint agent ' +
"credential: check agent 1.20+ is installed, running and enrolled ($ShopDbCredentialFile).")
}
'ASSET_MISMATCH' {
Log ('ERROR 403 ASSET_MISMATCH: this PC''s credential belongs to a different asset than the ' +
'hostname in the report. Check the hostname and the enrollment in ShopDB.')
}
default {
if ($Failure.Status -eq 0 -and $Failure.PresentedPin) {
# A pin was seen, but only an absent pin is a pin failure. A
# matched pin means the handshake passed and something later
# failed (server dropped mid-transfer, timeout).
if ($Failure.PinMatched) {
Log "ERROR request failed after the server key pin matched ($($Failure.PresentedPin)): $($Failure.Message)"
} else {
Log "ERROR TLS refused: server key pin $($Failure.PresentedPin) is not in ServerPins"
}
}
}
}
}
# Read a value from the 64-bit HKLM view explicitly. If this runs under 32-bit
# PowerShell the HKLM: PSDrive silently redirects to WOW6432Node, where the
# 64-bit agent's config is not, and the upgrade would look unconfigured.
function Get-Hklm64Value {
param([string] $SubKey, [string] $Name)
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey(
[Microsoft.Win32.RegistryHive]::LocalMachine,
[Microsoft.Win32.RegistryView]::Registry64)
try {
$key = $base.OpenSubKey($SubKey)
if (-not $key) { return $null }
try { return $key.GetValue($Name) } finally { $key.Dispose() }
}
finally { $base.Dispose() }
}
# Config resolution mirrors the agent's own order of preference, so the upgrade
# reaches the same server the agent is actually talking to. Registry first;
# appsettings.json is the fallback for locked-down hosts (CyberArk/EPM) that
# gate the HKLM hive against reads.
function Get-AgentConfig {
$baseUrl = Get-Hklm64Value -SubKey 'SOFTWARE\GE\ShopDB' -Name 'BaseUrl'
$apiKey = Get-Hklm64Value -SubKey 'SOFTWARE\GE\ShopDB' -Name 'CollectorKey'
$pins = @()
# Read even when the registry is complete: serverpins lives only here.
$settings = Join-Path $InstallDir 'appsettings.json'
if (Test-Path $settings) {
try {
$json = Get-Content $settings -Raw | ConvertFrom-Json
if ([string]::IsNullOrWhiteSpace($baseUrl) -or [string]::IsNullOrWhiteSpace($apiKey)) {
if ([string]::IsNullOrWhiteSpace($baseUrl)) { $baseUrl = $json.baseurl }
if ([string]::IsNullOrWhiteSpace($apiKey)) { $apiKey = $json.apikey }
Write-Log 'read config from appsettings.json (registry unavailable or incomplete)'
}
foreach ($value in @($json.serverpins)) {
if ($null -eq $value) { continue }
$pin = ([string]$value -replace '[\s:]', '').ToLowerInvariant()
if ($pin -match '^[0-9a-f]{64}$') { $pins += $pin }
}
}
catch {
Write-Log "could not parse appsettings.json: $($_.Exception.Message)" 'WARN'
}
}
if ([string]::IsNullOrWhiteSpace($baseUrl)) {
throw 'No BaseUrl found in the registry or appsettings.json. This endpoint does not look installed; run install-shopdbagent.ps1 instead.'
}
return @{ BaseUrl = $baseUrl.TrimEnd('/'); ApiKey = [string]$apiKey; ServerPins = $pins }
}
# ---------------------------------------------------------------------------
# First pass: relaunch detached and get out of the way. See header.
# ---------------------------------------------------------------------------
if (-not $Detached) {
New-Item -ItemType Directory -Force -Path $WorkDir, $LogDir | Out-Null
$selfCopy = Join-Path $WorkDir 'upgrade-shopdbagent.ps1'
Copy-Item -Path $PSCommandPath -Destination $selfCopy -Force
# Copied out of the agent's own temp dir on purpose: the delivered script
# may be cleaned up by the agent once the command returns, which would
# delete this file out from under the detached run.
Start-Process -FilePath 'powershell.exe' `
-ArgumentList '-NoProfile', '-ExecutionPolicy', 'Bypass', `
'-File', "`"$selfCopy`"", '-Detached' `
-WindowStyle Hidden | Out-Null
Write-Host 'Upgrade handed off to a detached process; this agent will restart shortly.'
exit 0
}
# ---------------------------------------------------------------------------
# Second pass (detached): the actual upgrade.
# ---------------------------------------------------------------------------
New-Item -ItemType Directory -Force -Path $WorkDir, $LogDir | Out-Null
Write-Log '== ShopDB Endpoint Agent self-upgrade =='
try {
$config = Get-AgentConfig
Write-Log "server: $($config.BaseUrl)"
# 1. Download BEFORE stopping anything. A failed download must leave the
# running agent untouched rather than stranding the endpoint with a
# stopped service and no replacement binaries.
$zipPath = Join-Path $WorkDir 'agent-package.zip'
if (Test-Path $zipPath) { Remove-Item $zipPath -Force }
$url = $config.BaseUrl + $PackagePath
Write-Log "downloading package from $url ..."
# TLS 1.2 explicitly: Windows PowerShell 5.1 still defaults to older
# protocols the server will refuse.
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
# Get-ShopDbServerPins already returns an array; wrapping it in @() made
# several pins one joined string. Registry wins when the value exists,
# even empty; appsettings pins apply only when it is absent.
$serverPins = Get-ShopDbServerPins
if (-not (Test-ShopDbServerPinsValue)) {
foreach ($pin in $config.ServerPins) {
if ($serverPins -notcontains $pin) { $serverPins += $pin }
}
}
if ($serverPins.Count -gt 0) {
if (-not (Enable-ShopDbServerPins $serverPins)) {
throw 'ServerPins is set but the pin check could not be enabled; not downloading.'
}
Write-Log "server pin check on ($($serverPins.Count) pin(s))"
}
$credential = Get-ShopDbEndpointCredential $config.BaseUrl
$attempts = @()
if ($credential) { $attempts += ,@('credential', @{ 'X-Endpoint-Credential' = $credential }) }
if ($config.ApiKey) { $attempts += ,@('shared key', @{ 'X-API-Key' = $config.ApiKey }) }
if ($attempts.Count -eq 0) {
throw 'No endpoint credential and no CollectorKey on this endpoint; cannot authenticate the download.'
}
# Credential first. A refused credential (revoked) falls back to the shared
# key once, which the server admits again for an un-enrolled PC.
$downloaded = $false
foreach ($attempt in $attempts) {
try {
Invoke-WebRequest -Uri $url -Headers $attempt[1] `
-OutFile $zipPath -UseBasicParsing -TimeoutSec 600
Write-Log "authenticated with $($attempt[0])"
$downloaded = $true
break
}
catch {
$failure = Get-ShopDbHttpFailure $_
Write-Log "download with $($attempt[0]) failed: $($_.Exception.Message)" 'WARN'
Write-ShopDbAuthFailure $failure
if ($failure.Code -ne 'CREDENTIAL_INVALID') { throw }
}
}
if (-not $downloaded) { throw 'Package download was refused.' }
$size = (Get-Item $zipPath).Length
Write-Log "downloaded $size bytes"
if ($size -lt 1MB) {
throw "Package is only $size bytes; refusing to install a payload this small."
}
# 2. Unpack to a staging dir and validate the payload BEFORE touching the
# install. Expanding straight over Program Files would leave a partially
# replaced install if the archive turned out to be short a binary.
$stage = Join-Path $WorkDir 'stage'
if (Test-Path $stage) { Remove-Item $stage -Recurse -Force }
New-Item -ItemType Directory -Force -Path $stage | Out-Null
Expand-Archive -Path $zipPath -DestinationPath $stage -Force
$newAgent = Join-Path $stage $AgentExeName
if (-not (Test-Path $newAgent)) {
throw "$AgentExeName not found in the downloaded package; aborting before any change."
}
$newTray = Join-Path $stage $TrayExeName
$haveTray = Test-Path $newTray
Write-Log ("payload validated (agent{0})" -f $(if ($haveTray) { ' + tray' } else { ', no tray' }))
# 3. Keep the current binaries so a failed swap can be undone. The config
# files are deliberately NOT part of this - they are never modified.
$backup = Join-Path $WorkDir 'backup'
if (Test-Path $backup) { Remove-Item $backup -Recurse -Force }
New-Item -ItemType Directory -Force -Path $backup | Out-Null
foreach ($name in @($AgentExeName, $TrayExeName)) {
$existing = Join-Path $InstallDir $name
if (Test-Path $existing) { Copy-Item $existing -Destination $backup -Force }
}
Write-Log 'backed up current binaries'
# 4. Stop the service and kill lingering processes. Until this completes
# the exes are locked and the copy would fail with a file-in-use error.
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($svc -and $svc.Status -ne 'Stopped') {
Write-Log "stopping service $ServiceName ..."
try { Stop-Service -Name $ServiceName -Force -ErrorAction Stop }
catch { Write-Log "could not stop service: $($_.Exception.Message)" 'WARN' }
}
$trayBase = [System.IO.Path]::GetFileNameWithoutExtension($TrayExeName)
foreach ($name in @('shopdbagent', $trayBase)) {
foreach ($p in (Get-Process -Name $name -ErrorAction SilentlyContinue)) {
Write-Log "killing $($p.ProcessName) (pid $($p.Id))"
try { Stop-Process -Id $p.Id -Force -ErrorAction Stop } catch { }
}
}
Start-Sleep -Milliseconds 750
# 5. Swap the binaries. On failure, restore the backup and restart, so a
# broken upgrade leaves a RUNNING old agent rather than a dead endpoint
# that can no longer be reached to fix remotely.
try {
Copy-Item -Path $newAgent -Destination $InstallDir -Force
if ($haveTray) { Copy-Item -Path $newTray -Destination $InstallDir -Force }
Write-Log 'binaries replaced'
}
catch {
Write-Log "copy failed: $($_.Exception.Message); rolling back" 'ERROR'
Get-ChildItem $backup -File | ForEach-Object {
Copy-Item $_.FullName -Destination $InstallDir -Force
}
Start-Service -Name $ServiceName -ErrorAction SilentlyContinue
throw
}
# 6. Clear Mark-of-the-Web. Files extracted from a downloaded archive carry
# a Zone.Identifier stream; SmartScreen blocks the unsigned exe on first
# run, which would leave the service unable to start.
Get-ChildItem -Path $InstallDir -File | ForEach-Object {
try { Unblock-File -Path $_.FullName -ErrorAction Stop } catch { }
}
# 7. Restart and verify it stays up. A service that starts and immediately
# faults is a failed upgrade, so confirm rather than assume.
Write-Log "starting service $ServiceName ..."
Start-Service -Name $ServiceName
Start-Sleep -Seconds 5
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if (-not $svc -or $svc.Status -ne 'Running') {
Write-Log 'service did not stay running; rolling back' 'ERROR'
Get-ChildItem $backup -File | ForEach-Object {
Copy-Item $_.FullName -Destination $InstallDir -Force
}
Start-Service -Name $ServiceName -ErrorAction SilentlyContinue
throw 'Upgraded service failed to start; rolled back to the previous build.'
}
$installed = (Get-Item (Join-Path $InstallDir $AgentExeName)).VersionInfo.FileVersion
Write-Log "upgrade complete; agent version now $installed"
# 8. Relaunch the tray for the interactive user.
#
# This CANNOT use the installer's `explorer.exe <path>` trick. That works in
# install-shopdbagent.ps1 only because the installer runs AS THE USER - note
# its own guard, `if ($interactiveUser -ne "SYSTEM")`. This script always
# runs as SYSTEM in session 0, where `explorer.exe <path>` hands the request
# to a shell that is not reachable from here and then exits 0. Start-Process
# would report success and we would log a relaunch that never happened,
# which is exactly the bug this replaces.
#
# It also must NOT descend into the session with a token-duplicating launch.
# UserContextSpool documents that WTSQueryUserToken is a privileged operation
# that endpoint protection blocks on these hardened hosts - the service tried
# that direction before and it produced nothing on a live endpoint.
#
# So the tray is started the same way its own data path is pinned: against
# the known ShopFloor account. Task Scheduler performs the session/token
# transition, and an Interactive principal with no RunLevel starts the tray
# at the user's normal integrity - preserving the de-elevation the installer
# wanted, because an elevated unsigned tray trips the CyberArk/EPM broker.
#
# Best-effort by design: the agent is already upgraded and running, and the
# HKLM Run key the installer wrote still starts the tray at next logon. A
# late tray is not worth failing - or rolling back - a good agent upgrade.
if ($haveTray) {
$trayPath = Join-Path $InstallDir $TrayExeName
# Same pinned account the spool uses (FileLoggerProvider.ShopFloorUserName):
# every shop-floor endpoint signs in as this local account, and the tray
# must run as it or the mapped-drive probe reports the wrong account's
# (empty) drive table. Only relaunch if that profile is actually present.
$trayUser = 'ShopFloor'
$trayUserProfile = Join-Path $env:SystemDrive (Join-Path 'Users' $trayUser)
if (Test-Path $trayUserProfile) {
$taskName = 'ShopDB tray upgrade relaunch'
try {
# Clear a leftover from an interrupted earlier upgrade so we do
# not stack duplicates.
if (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
}
$action = New-ScheduledTaskAction -Execute $trayPath
$principal = New-ScheduledTaskPrincipal -UserId $trayUser -LogonType Interactive
$settings = New-ScheduledTaskSettingsSet `
-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
-ExecutionTimeLimit ([TimeSpan]::Zero)
Register-ScheduledTask -TaskName $taskName -Action $action `
-Principal $principal -Settings $settings -Force | Out-Null
Start-ScheduledTask -TaskName $taskName
# Verify rather than assume. The old code logged success off a
# Start-Process that could not fail, so a missing tray looked
# like a clean upgrade. Poll for the real process instead.
$trayBaseName = [System.IO.Path]::GetFileNameWithoutExtension($TrayExeName)
$running = $null
for ($i = 0; $i -lt 10; $i++) {
Start-Sleep -Milliseconds 500
$running = Get-Process -Name $trayBaseName -ErrorAction SilentlyContinue
if ($running) { break }
}
if ($running) { Write-Log "tray relaunched as $trayUser" }
else {
Write-Log ('tray did not appear after relaunch; it will start at next ' +
'logon via the HKLM Run key') 'WARN'
}
# One-shot hand-off, not a persistent artifact: leaving it would
# surface in the uninstaller's leftover sweep and in task audits.
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
}
catch {
Write-Log ("could not relaunch tray: $($_.Exception.Message); " +
'it will start at next logon') 'WARN'
}
}
else {
Write-Log ("no $trayUser profile at $trayUserProfile; " +
'leaving the tray to start at next logon') 'WARN'
}
}
Remove-Item $stage -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
exit 0
}
catch {
Write-Log "UPGRADE FAILED: $($_.Exception.Message)" 'ERROR'
exit 1
}

View File

@@ -0,0 +1,157 @@
#requires -Version 5.1
<#
.SYNOPSIS
Print the SPKI SHA-256 pin of a ShopDB server certificate, for building the
Set-ShopDBServerPins.ps1 entry.
.DESCRIPTION
The pin is the lowercase hex SHA-256 of the leaf certificate's
SubjectPublicKeyInfo: the same value the Server Certificate settings page
shows and the endpoint agent compares. It depends only on the key pair, so
a certificate renewed with the same key keeps its pin.
Give it either a URL (the certificate the server actually presents is read
from the TLS handshake; it is NOT trusted or validated, only hashed) or a
certificate file (.cer, .crt, .pem, DER or PEM). Use the file form to pin a
new certificate BEFORE it goes live on the server.
Reading from a URL trusts the network path at that moment. Compare the
printed pin with the one on the Server Certificate settings page before
rolling it out.
.PARAMETER Url
https://<server>[:port]. Only the host and port are used.
.PARAMETER Path
A certificate file.
.EXAMPLE
.\Get-ShopDBServerPin.ps1 -Url https://<server>
.EXAMPLE
.\Get-ShopDBServerPin.ps1 -Path .\server.crt
#>
[CmdletBinding(DefaultParameterSetName = 'Url')]
param(
[Parameter(Mandatory = $true, ParameterSetName = 'Url', Position = 0)]
[string]$Url,
[Parameter(Mandatory = $true, ParameterSetName = 'Path')]
[string]$Path,
[int]$TimeoutSec = 15
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$source = @'
using System;
using System.IO;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Authentication;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
public static class ShopDbServerPinReader
{
public static byte[] FetchLeafCertificate(string host, int port, int timeoutMilliseconds)
{
using (TcpClient client = new TcpClient())
{
IAsyncResult pending = client.BeginConnect(host, port, null, null);
if (!pending.AsyncWaitHandle.WaitOne(timeoutMilliseconds))
{
throw new TimeoutException("Timed out connecting to " + host + ":" + port);
}
client.EndConnect(pending);
client.ReceiveTimeout = timeoutMilliseconds;
client.SendTimeout = timeoutMilliseconds;
byte[] captured = null;
RemoteCertificateValidationCallback capture = delegate(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors errors)
{
if (certificate != null) { captured = certificate.GetRawCertData(); }
return true;
};
using (SslStream ssl = new SslStream(client.GetStream(), false, capture))
{
ssl.AuthenticateAsClient(host, null, SslProtocols.Tls12, false);
}
if (captured == null) { throw new InvalidOperationException("The server presented no certificate"); }
return captured;
}
}
// Walks the DER: Certificate > TBSCertificate > [0] version (optional),
// serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo.
public static string SpkiSha256Hex(byte[] der)
{
int length;
int position = EnterSequence(der, 0, out length);
position = EnterSequence(der, position, out length);
if (der[position] == 0xA0) { position = Skip(der, position); }
for (int field = 0; field < 5; field++) { position = Skip(der, position); }
if (der[position] != 0x30) { throw new FormatException("SubjectPublicKeyInfo not found"); }
int end = Skip(der, position);
using (SHA256 sha = SHA256.Create())
{
byte[] hash = sha.ComputeHash(der, position, end - position);
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
}
}
private static int ReadLength(byte[] der, int position, out int length)
{
int first = der[position];
if (first < 0x80) { length = first; return position + 1; }
int count = first & 0x7F;
if (count == 0 || count > 4) { throw new FormatException("Unsupported DER length"); }
length = 0;
for (int i = 1; i <= count; i++) { length = (length << 8) | der[position + i]; }
return position + 1 + count;
}
private static int EnterSequence(byte[] der, int position, out int length)
{
if (der[position] != 0x30) { throw new FormatException("Expected SEQUENCE"); }
return ReadLength(der, position + 1, out length);
}
private static int Skip(byte[] der, int position)
{
int length;
int content = ReadLength(der, position + 1, out length);
return content + length;
}
}
'@
if (-not ('ShopDbServerPinReader' -as [type])) {
Add-Type -TypeDefinition $source
}
try {
if ($PSCmdlet.ParameterSetName -eq 'Path') {
$resolved = (Resolve-Path -LiteralPath $Path).ProviderPath
$certificate = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 -ArgumentList $resolved
$der = $certificate.RawData
$label = "$resolved (subject $($certificate.Subject), expires $($certificate.NotAfter.ToString('yyyy-MM-dd')))"
} else {
$uri = [Uri]$Url
if ($uri.Scheme -ne 'https') { throw "The URL must be https ($Url)" }
$der = [ShopDbServerPinReader]::FetchLeafCertificate($uri.Host, $uri.Port, $TimeoutSec * 1000)
$certificate = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 -ArgumentList (,$der)
$label = "$($uri.Host):$($uri.Port) (subject $($certificate.Subject), expires $($certificate.NotAfter.ToString('yyyy-MM-dd')))"
}
$pin = [ShopDbServerPinReader]::SpkiSha256Hex($der)
} catch {
Write-Error "Could not read a certificate: $($_.Exception.Message)"
exit 1
}
Write-Host "Certificate: $label"
Write-Host "Pin (SPKI SHA-256):"
Write-Output $pin
exit 0

View File

@@ -0,0 +1,194 @@
#requires -Version 5.1
<#
.SYNOPSIS
Trust the ShopDB server's own certificate on this PC by adding it to
LocalMachine\Root, after checking it is the expected certificate and not a
certificate authority.
.DESCRIPTION
WHY: endpoint agents older than 1.20 check the normal Windows certificate
chain when they download the upgrade package. They know nothing about
ServerPins. A server with a self-signed certificate is therefore refused
by those agents, and the upgrade to 1.20 never lands. The certificate has
to be trusted on the PC BEFORE a store wave queues the upgrade.
PCs installed by the server's generated installer already trust it. PCs
installed from the share do not, and need this entry.
Adding a non-CA leaf certificate to Root trusts exactly that one server.
It cannot be used to vouch for any other site, so the blast radius is the
server itself. A CA certificate in Root would trust everything that CA
signs, which is why a CA is refused unless -AllowCa is passed.
Get the .cer from Settings > Endpoints > Server certificate (download), or
GET /api/endpoints/settings/server-cert/download. The expected thumbprint
is the sha1thumbprint field of GET /api/endpoints/settings/server-cert.
Meant to run as a GE-Enforce PS1 entry under the SYSTEM task, in the same
targeting as the Set-ShopDBServerPins.ps1 entry. Idempotent: the Root store
is checked first and nothing is written when the certificate is already
there, so it is safe every cycle. Nothing else is removed or changed.
.PARAMETER CertPath
Path to the server certificate file, DER or PEM (.cer / .crt / .pem).
.PARAMETER Thumbprint
Expected SHA-1 thumbprint of the certificate. Spaces, colons and case are
ignored. The file is refused when it does not match.
.PARAMETER AllowCa
Import even when the certificate is a CA (basicConstraints CA=true). Only
for a site that deliberately trusts its own private CA.
.PARAMETER LogPath
Append a transcript here as well as writing to the pipeline.
.EXAMPLE
.\Install-ShopDBServerCertificate.ps1 -CertPath <share>\shopdb-server.cer -Thumbprint <sha1thumbprint>
Trust the server certificate on this PC.
.EXAMPLE
.\Install-ShopDBServerCertificate.ps1 -CertPath .\shopdb-server.cer -Thumbprint <sha1thumbprint> -WhatIf
Show what would be imported without touching the store.
.NOTES
Exit codes:
0 imported, or already trusted
1 bad input, mismatch, refused CA, or an error stopped the import
#>
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[Parameter(Mandatory = $true)]
[string]$CertPath,
[Parameter(Mandatory = $true)]
[string]$Thumbprint,
[switch]$AllowCa,
[string]$LogPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Log {
param([string]$Message)
$line = "{0} [Install-ShopDBServerCertificate] {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message
Write-Output $line
if ($LogPath) {
try { Add-Content -LiteralPath $LogPath -Value $line -WhatIf:$false -ErrorAction Stop } catch { }
}
}
function Read-Certificate {
param([string]$Path)
$bytes = [System.IO.File]::ReadAllBytes($Path)
$text = [System.Text.Encoding]::ASCII.GetString($bytes)
$match = [regex]::Match($text, '-----BEGIN CERTIFICATE-----(.+?)-----END CERTIFICATE-----',
[System.Text.RegularExpressions.RegexOptions]::Singleline)
if ($match.Success) {
$bytes = [Convert]::FromBase64String(($match.Groups[1].Value -replace '\s', ''))
}
return New-Object System.Security.Cryptography.X509Certificates.X509Certificate2(,[byte[]]$bytes)
}
# --- 1. validate the request ------------------------------------------------
$expected = ($Thumbprint -replace '[\s:]', '').ToUpperInvariant()
if ($expected -notmatch '^[0-9A-F]{40}$') {
Log "ERROR '$Thumbprint' is not a SHA-1 thumbprint (40 hex characters); nothing imported"
exit 1
}
if (-not (Test-Path -LiteralPath $CertPath -PathType Leaf)) {
Log "ERROR certificate file not found: $CertPath"
exit 1
}
try {
$cert = Read-Certificate -Path $CertPath
} catch {
Log "ERROR could not read a certificate from $CertPath : $($_.Exception.Message)"
exit 1
}
$actual = $cert.Thumbprint.ToUpperInvariant()
Log "file: $CertPath"
Log "subject: $($cert.Subject); issuer: $($cert.Issuer); expires: $($cert.NotAfter.ToString('yyyy-MM-dd'))"
if ($actual -ne $expected) {
Log "ERROR thumbprint of the file is $actual, expected $expected; refusing to import"
exit 1
}
if ($cert.HasPrivateKey) {
Log 'ERROR the file carries a private key; export the public certificate only'
exit 1
}
# --- 2. refuse a CA ---------------------------------------------------------
$isCa = $false
foreach ($extension in $cert.Extensions) {
if ($extension.Oid.Value -eq '2.5.29.19') {
$constraints = [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]$extension
$isCa = $constraints.CertificateAuthority
}
}
if ($isCa) {
if (-not $AllowCa) {
Log ('ERROR the certificate is a CA (basicConstraints CA=true). In Root it would trust every ' +
'certificate it signs, not just the ShopDB server. Refusing; pass -AllowCa only if that is intended.')
exit 1
}
Log 'WARN the certificate is a CA and -AllowCa was passed; everything it signs will be trusted'
} else {
Log 'not a CA (basicConstraints CA absent or false); trusts only this server'
}
if ($cert.Subject -ne $cert.Issuer) {
Log 'WARN the certificate is not self-signed; Windows may still need its issuer to build the chain'
}
if ($cert.NotAfter -lt (Get-Date)) {
Log 'WARN the certificate has expired; the server needs a new one, importing anyway'
}
# --- 3. import into LocalMachine\Root ---------------------------------------
$store = New-Object System.Security.Cryptography.X509Certificates.X509Store(
[System.Security.Cryptography.X509Certificates.StoreName]::Root,
[System.Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine)
try {
$store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly)
try {
$present = $store.Certificates.Find(
[System.Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint, $actual, $false)
$alreadyTrusted = $present.Count -gt 0
} finally { $store.Close() }
if ($alreadyTrusted) {
Log "LocalMachine\Root already holds $actual; nothing to do"
exit 0
}
if ($PSCmdlet.ShouldProcess("Cert:\LocalMachine\Root", "Import $actual ($($cert.Subject))")) {
$store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite)
try { $store.Add($cert) } finally { $store.Close() }
# Read back rather than trust Add.
$store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly)
try {
$after = $store.Certificates.Find(
[System.Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint, $actual, $false)
} finally { $store.Close() }
if ($after.Count -eq 0) {
Log "ERROR $actual did not read back from LocalMachine\Root after import"
exit 1
}
Log "imported $actual into LocalMachine\Root"
}
} catch {
Log "ERROR $($_.Exception.Message)"
exit 1
} finally {
$store.Close()
}
exit 0

View File

@@ -0,0 +1,212 @@
#requires -Version 5.1
<#
.SYNOPSIS
Write the ShopDB server public key pins to HKLM:\SOFTWARE\GE\ShopDB
ServerPins, so the endpoint agent and the SYSTEM reporters trust only this
site's server key.
.DESCRIPTION
Each pin is the lowercase hex SHA-256 of the server leaf certificate's
SubjectPublicKeyInfo. The ShopDB Server Certificate settings page shows the
current one, and Get-ShopDBServerPin.ps1 computes one from a URL or a
certificate file.
Written as REG_MULTI_SZ in BOTH registry views (64-bit and WOW6432Node),
the same way CollectorKey is, so a 32-bit reader sees the same set.
Meant to run as a GE-Enforce PS1 entry under the SYSTEM task, before agent
1.20 reaches the PC. Idempotent: the set on the PC is compared with the
requested set first, and nothing is written when they already match, so it
is safe every cycle.
THE REGISTRY WINS. When ServerPins exists in either view, even empty, the
agent uses it alone and ignores serverpins / servercertthumbprint in
appsettings.json (the installer and store upgrade write those). Only when
the value is absent in both views do the appsettings pins apply.
ROTATING THE SERVER KEY: pass the old pin AND the new pin until every PC has
both, switch the server certificate, then write ONLY the new pin to retire
the old one. A PC holding only the old pin refuses the new certificate.
.PARAMETER Pins
One or more pins separated by commas, semicolons or spaces. Colons and case
are ignored. Every value must be 64 hex characters or nothing is written.
.PARAMETER Clear
Delete ServerPins from both views. The agent then falls back to the pins
in appsettings.json; with none there, normal Windows certificate checks.
.PARAMETER None
Write an EMPTY ServerPins in both views. The registry stays authoritative
with no pins, so appsettings.json pins are ignored and the normal Windows
certificate checks apply.
.PARAMETER LogPath
Append a transcript here as well as writing to the pipeline.
.EXAMPLE
.\Set-ShopDBServerPins.ps1 -Pins <pin>
Pin one server key.
.EXAMPLE
.\Set-ShopDBServerPins.ps1 -Pins "<oldpin>,<newpin>" -WhatIf
Show what a key rotation would write without touching the registry.
.EXAMPLE
.\Set-ShopDBServerPins.ps1 -Pins <newpin>
After the server switched certificates: retire the old pin.
.EXAMPLE
.\Set-ShopDBServerPins.ps1 -None
Turn pinning off on a PC whose appsettings.json still carries pins.
.NOTES
Exit codes:
0 written, or already correct
1 bad input or an error stopped the write
#>
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[string]$Pins,
[switch]$Clear,
[switch]$None,
[string]$LogPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$SubKey = 'SOFTWARE\GE\ShopDB'
$ValueName = 'ServerPins'
function Log {
param([string]$Message)
$line = "{0} [Set-ShopDBServerPins] {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message
Write-Output $line
if ($LogPath) {
try { Add-Content -LiteralPath $LogPath -Value $line -ErrorAction Stop } catch { }
}
}
# Returns @{ Present; Pins }. Present = the value exists, even empty.
function Get-CurrentPins {
param([Microsoft.Win32.RegistryView]$View)
$result = @{ Present = $false; Pins = @() }
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $View)
try {
$key = $base.OpenSubKey($SubKey)
if (-not $key) { return $result }
try {
$value = $key.GetValue($ValueName)
if ($null -eq $value) { return $result }
$result.Present = $true
$result.Pins = @(@($value) | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ })
return $result
} finally { $key.Dispose() }
} finally { $base.Dispose() }
}
function Test-SamePins {
param([string[]]$Left, [string[]]$Right)
$a = @($Left | Sort-Object -Unique)
$b = @($Right | Sort-Object -Unique)
if ($a.Count -ne $b.Count) { return $false }
for ($i = 0; $i -lt $a.Count; $i++) {
if ($a[$i] -ne $b[$i]) { return $false }
}
return $true
}
# --- 1. validate the request ------------------------------------------------
$requested = @()
if (@(@($Clear.IsPresent, $None.IsPresent, [bool]$Pins) | Where-Object { $_ }).Count -gt 1) {
Log 'ERROR use only one of -Pins, -Clear, -None'
exit 1
}
if ($None) {
Log 'requested an empty ServerPins (registry authoritative, no pins)'
} elseif (-not $Clear) {
if (-not $Pins -or -not $Pins.Trim()) {
Log 'ERROR no -Pins given (-Clear deletes the value, -None writes it empty)'
exit 1
}
foreach ($raw in ($Pins -split '[,;\s]+')) {
if (-not $raw) { continue }
$pin = ($raw -replace ':', '').ToLowerInvariant()
if ($pin -notmatch '^[0-9a-f]{64}$') {
Log "ERROR '$raw' is not a SHA-256 pin (64 hex characters); nothing written"
exit 1
}
if ($requested -notcontains $pin) { $requested += $pin }
}
if ($requested.Count -eq 0) {
Log 'ERROR -Pins held no values; nothing written'
exit 1
}
Log "requested $($requested.Count) pin(s): $($requested -join ', ')"
}
# --- 2. write each view -----------------------------------------------------
$views = @(
@{ Name = '64-bit'; View = [Microsoft.Win32.RegistryView]::Registry64 },
@{ Name = '32-bit'; View = [Microsoft.Win32.RegistryView]::Registry32 }
)
$failed = $false
foreach ($entry in $views) {
try {
$state = Get-CurrentPins -View $entry.View
$current = @($state.Pins)
if ($Clear) {
if (-not $state.Present) {
Log "$($entry.Name): no ServerPins; nothing to do"
continue
}
if ($PSCmdlet.ShouldProcess("HKLM\$SubKey ($($entry.Name))", "Remove $ValueName")) {
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $entry.View)
try {
$key = $base.OpenSubKey($SubKey, $true)
if ($key) {
try { $key.DeleteValue($ValueName, $false) } finally { $key.Dispose() }
}
} finally { $base.Dispose() }
Log "$($entry.Name): deleted ServerPins (appsettings.json pins apply)"
}
continue
}
if ($state.Present -and (Test-SamePins -Left $current -Right $requested)) {
Log "$($entry.Name): ServerPins already correct; nothing to do"
continue
}
$action = "Set $ValueName to $($requested.Count) pin(s) (was $($current.Count))"
if ($PSCmdlet.ShouldProcess("HKLM\$SubKey ($($entry.Name))", $action)) {
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $entry.View)
try {
$key = $base.CreateSubKey($SubKey)
try {
$key.SetValue($ValueName, [string[]]$requested, [Microsoft.Win32.RegistryValueKind]::MultiString)
} finally { $key.Dispose() }
} finally { $base.Dispose() }
# Read back rather than trust SetValue.
$after = Get-CurrentPins -View $entry.View
if (-not $after.Present -or -not (Test-SamePins -Left @($after.Pins) -Right $requested)) {
Log "ERROR $($entry.Name): ServerPins did not read back as written"
$failed = $true
continue
}
Log "$($entry.Name): wrote ServerPins ($($requested.Count) pin(s), was $($current.Count))"
}
} catch {
Log "ERROR $($entry.Name): $($_.Exception.Message)"
$failed = $true
}
}
if ($failed) { exit 1 }
exit 0

View File

@@ -77,14 +77,65 @@ COMMAND_ALLOWLIST = {
'label': 'Collect now',
'description': 'Force the endpoint agent to report fresh telemetry off-cadence.',
},
'network_connections': {
'label': 'Show network connections',
'description': 'Return the full TCP connection table with owning processes. '
'Read-only diagnostic; the standard report carries only '
'listening ports because established connections churn.',
},
'scheduledtask_run': {
'label': 'Run scheduled task',
'description': 'Start an already-registered Windows scheduled task on '
'the endpoint by its task path (for example '
'\\ReportIP). The agent does not create, modify, or '
'define tasks - it only starts one that already exists, '
'and only when the path clears the agent-side approved '
'task allowlist. The task runs in whatever context its '
'own registered definition specifies, not the agent\'s.',
'targetkind': 'scheduledtask',
},
'run_script': {
'label': 'Run script',
'description': 'Run a ShopDB-hosted script on the endpoint. The agent '
'fetches the script body by digest using its collector '
'credential and runs it in the script\'s configured '
'context (system or the logged-on user).',
'context (system or the logged-on user). CAUTION: a '
'script sent this way runs as SYSTEM unless its '
'descriptor pins the user context, so this is arbitrary '
'code execution on the endpoint by design. Grant '
'endpoints.runscript only to the most trusted '
'operators, '
'and gate the UI behind an explicit confirm the way '
'system_restart is gated (see confirmRestart in '
'PCDetail.vue).',
'targetkind': 'script',
},
'collect_log': {
'label': 'Collect log file',
'description': 'Read a plaintext log file from a known path on the '
'endpoint and return its contents to ShopDB for '
'viewing and download. Read-only: the agent opens the '
'file for reading with sharing permitted (so a log a '
'running application holds open can still be read), '
'never writes, and never deletes. Only the TAIL of a '
'file is returned, and only for plaintext extensions - '
'a binary or oversized file is refused rather than '
'mangled. Gated on endpoints.updateapp because the '
'reason to collect a log is almost always to diagnose '
'an application push, so it belongs with app lifecycle '
'rights rather than as a separate grant. Note this '
'still reads a caller-supplied path as SYSTEM: it is '
'bounded to text files, but it is not a sandbox.',
'targetkind': 'logpath',
# Introduced in agent 1.16.0. An older agent has no case for this action
# and falls through to its unknown-action default, which answers
# "Unknown or disallowed action" - a message that reads as an
# authorization refusal and sends the operator looking at permissions
# they already hold. Declaring the floor here lets the enqueue route
# refuse first, naming both versions, so the answer is the actionable
# one: this endpoint needs a newer agent.
'minagentversion': '1.16.0',
},
'update_app': {
'label': 'Install/update application',
'description': 'Push a ShopDB-hosted installer for an application (by app '
@@ -96,13 +147,89 @@ COMMAND_ALLOWLIST = {
}
# Dell Command | Update: TWO utilities, deliberately two separate actions.
# A scan reports what is available and changes nothing; an update installs and
# can reboot. Collapsing them into one parameterized action would make the
# destructive one reachable by editing a field, so they stay distinct and the
# UI can gate them differently.
#
# Both are driven through scheduledtask_run against ShopDB-owned task
# definitions (\ShopDB-DellCommandScan and \ShopDB-DellCommandUpdate, already in
# ScheduledTaskPolicy's shipped defaults) rather than a bespoke verb. The task
# definition - not the agent - pins the dcu-cli.exe path, its arguments, and its
# output path, which is why no new agent code is needed to run them.
#
# OUTPUT PATH CONVENTION. A ShopDB-initiated scan MUST write its report under
# %ProgramData%\ShopDB\EndpointAgent\ (the same SYSTEM-owned root as the agent
# logs), NOT to Dell's default location. Corporate-managed DCU tasks write to
# the default; keeping ShopDB's output separate is the only thing that makes
# "did ShopDB ask for this scan, or did corporate" answerable when the results
# are collected. The path lives in the task definition and is generated
# server-side, so it can change without touching the agent.
#
# STILL TO DO - the results collector. Firing DCU is the easy half. schtasks
# /run returns as soon as the task STARTS, so a successful scheduledtask_run
# means "scan started", never "scan finished" - the command result cannot carry
# findings. Reporting available updates back into ShopDB needs a collector that
# reads the report from the path above and folds it into the telemetry payload.
# That is a separate change from this one; do not infer from a green command
# result that the data path exists.
# Reserved seam - one-click mapped-drive remediation. NOT implemented; this is a
# placement note so the next person adds it here rather than inventing a new
# transport. When it lands it will re-establish a Disconnected mapping surfaced
# by the user-context card:
#
# 'remap_drive': {
# 'label': 'Reconnect mapped drive',
# 'description': 'Re-establish a disconnected network drive mapping.',
# 'targetkind': 'drive',
# },
#
# It MUST execute in the interactive USER session. A SYSTEM-context remap creates
# a mapping in SYSTEM's own drive table that the operator cannot see, so the card
# would keep reporting the drive as broken while the "fix" silently succeeded
# somewhere nobody is looking. An ELEVATED remap fails the same way, because UAC
# token separation gives the elevated context a different drive table - the same
# reason the probe itself runs unelevated from the tray.
#
# CommandExecutor.RunScriptInUserSession is the existing path for this.
# Actions that demand a permission stricter than the base endpoints.command.
# The enqueue route requires the caller to ALSO hold the mapped permission, so a
# destructive action (a remote reboot) is a heavier grant than a routine toggle.
#
# These four are gated by SCOPE, never by Configuration Policy. Policy shapes
# what a PC TYPE exposes; it is not the authority on whether a given person may
# reboot a machine or run arbitrary code. The check runs in the enqueue route
# BEFORE the policy gate, so it is also not break-glass-able: an admin may
# override what policy says about a machine, never what permissions say about
# the caller.
#
# Previously scheduledtask_run, run_script and update_app all mapped to a single
# endpoints.deploy grant. That bundle made app updates impossible to grant
# without also granting arbitrary SYSTEM code execution via run_script, so they
# are now four independent permissions. They are NOT equal risk, and the
# descriptions in plugin.py get_permissions() say so plainly:
# scheduledtask_run - starts something already registered and allowlisted
# agent-side
# update_app - runs a pinned installer
# run_script - arbitrary code as SYSTEM unless the descriptor pins a
# user context
#
# collect_log rides endpoints.updateapp rather than taking a grant of its own.
# The reason to read a log is nearly always to find out why an app push behaved
# the way it did, so the right to install an application and the right to read
# that application's log are the same job. It is read-only and strictly weaker
# than the installer push the same permission already allows.
ELEVATED_ACTION_PERMISSIONS = {
'system_restart': 'endpoints.restart',
'run_script': 'endpoints.deploy',
'update_app': 'endpoints.deploy',
'scheduledtask_run': 'endpoints.corptools',
'run_script': 'endpoints.runscript',
'update_app': 'endpoints.updateapp',
'collect_log': 'endpoints.updateapp',
}
@@ -111,6 +238,70 @@ def elevated_permission_for(action):
return ELEVATED_ACTION_PERMISSIONS.get(action)
def minimum_agent_version_for(action):
"""The lowest agent version that implements an action, or None if any will do."""
return COMMAND_ALLOWLIST.get(action, {}).get('minagentversion')
def action_minimum_versions():
"""Every action that has an agent version floor, as {action: version}.
Actions without a floor are omitted rather than mapped to None: a caller
asking "is there a minimum for this?" gets a plain absent key, and the
payload stays small as the allowlist grows.
"""
return {
action: spec['minagentversion']
for action, spec in COMMAND_ALLOWLIST.items()
if spec.get('minagentversion')
}
def parse_agent_version(value):
"""A reported agent version as a comparable tuple, or None if unusable.
Agents report four parts ('1.15.0.0') while the csproj and the binary store
carry three ('1.16.0'), so a plain string compare puts a NEWER agent below
the floor it satisfies. Both are normalised to a tuple and compared
numerically, with missing trailing parts treated as zero, so '1.16.0' and
'1.16.0.0' are the same version.
Anything non-numeric returns None rather than a guess. The fleet carries
'0.0.0-sample' on seeded rows, and there is no honest ordering between a
placeholder and a real build - the caller decides what to do about an
unknown version, which is a different question from being out of date.
"""
if not value:
return None
parts = []
for chunk in str(value).strip().split('.'):
if not chunk.isdigit():
return None
parts.append(int(chunk))
if not parts:
return None
while len(parts) < 4:
parts.append(0)
return tuple(parts[:4])
def agent_meets_minimum(reported, minimum):
"""Whether a reported agent version satisfies a minimum.
Returns False when either side is unparseable. That is the fail-closed
direction: an endpoint whose version we cannot read is one we cannot claim
supports the action, and letting it through only moves the failure to the
agent where the error message is worse.
"""
if not minimum:
return True
reportedparts = parse_agent_version(reported)
minimumparts = parse_agent_version(minimum)
if reportedparts is None or minimumparts is None:
return False
return reportedparts >= minimumparts
# Maps a monitored services/processes row to the start/stop/status actions that
# control it, so the services panel can render an inline toggle on exactly the
# rows that have a registered command action. The key is the agent-reported
@@ -215,6 +406,52 @@ def monitor_id_from_action(action):
return None
# Actions that are NOT a per-endpoint configuration concern.
#
# Membership here is a statement about the action's nature, not its risk. An
# action belongs in this set when Configuration Policy has nothing meaningful to
# say about it: it invokes an already-registered, agent-allowlisted task rather
# than describing how a machine should be shaped. Policy exists to shape what a
# given PC TYPE exposes - which monitors run, which service primitives are
# reachable - so a policy that never mentions such an action must not be read as
# removing it.
#
# This is deliberately narrow. Do not add an action here merely because a denial
# is inconvenient; the correct remedy for an over-narrow policy is to fix the
# policy, or to use the audited admin break-glass override at the enqueue route.
#
# Without this exemption the intersection at the end of ALLOWED_ACTIONS_FOR
# silently drops these actions on any endpoint whose effective policy is
# non-empty, producing "Action not permitted by policy for this endpoint" even
# for a caller who holds the matching grant. Exemption from POLICY is not
# exemption from PERMISSION: each of these actions still carries its own entry
# in ELEVATED_ACTION_PERMISSIONS, enforced per-action in the enqueue route
# before policy is ever consulted.
POLICY_EXEMPT_ACTIONS = frozenset({
'scheduledtask_run',
# Read-only telemetry refresh. Policy shapes WHAT a PC type exposes; asking
# an agent to report its existing state off-cadence changes nothing about
# the machine, so a policy that never mentions it must not be read as
# removing it. Without this, "Refresh Status" dies on every endpoint whose
# policy is non-empty - a denial no operator can act on, because the fix
# lives in a policy that was never about this action.
'collect_now',
# Read-only diagnostic retrieval. Policy describes how a machine should be
# shaped - which monitors run, which service primitives are reachable - and
# reading a log file shapes nothing. An existing policy was authored before
# this action existed and so can never mention it, which under the
# intersection means every endpoint carrying any policy at all refuses log
# collection, including for an admin. That denial is also unactionable in
# the way that matters: logs are what an operator reaches for when an
# endpoint is already misbehaving, so gating them behind editing that same
# endpoint's policy withholds the evidence exactly when it is needed.
# Exemption from POLICY is not exemption from PERMISSION - collect_log
# still requires endpoints.updateapp via ELEVATED_ACTION_PERMISSIONS, and
# the agent still refuses UNC, traversal, directories and non-text files.
'collect_log',
})
def ALLOWED_ACTIONS_FOR(pctype=None, assetid=None):
"""The action names an endpoint may run.
@@ -223,8 +460,15 @@ def ALLOWED_ACTIONS_FOR(pctype=None, assetid=None):
result is narrowed to the actions the endpoint's effective policy permits -
this is the allowlist-gating half of the policy design. With the feature off
(or no assetid), it returns the full base allowlist, so nothing changes until
a site opts in. Policy resolution is imported lazily to keep this module free
of a hard dependency on the computers plugin.
a site opts in. Actions in POLICY_EXEMPT_ACTIONS are never narrowed away,
because policy is not the authority on them; they remain gated by their own
entry in ELEVATED_ACTION_PERMISSIONS at the enqueue route. Policy resolution
is imported lazily to keep this module free of a hard dependency on the
computers plugin.
This answers allowlist questions only. It does not consult desired-state
holds, and it is not a permission check - callers must still enforce
ELEVATED_ACTION_PERMISSIONS themselves.
"""
base = set(COMMAND_ALLOWLIST.keys())
if assetid is None:
@@ -247,6 +491,10 @@ def ALLOWED_ACTIONS_FOR(pctype=None, assetid=None):
# "not permitted by policy". Expand each monitor:<id> to its kind's control
# actions before intersecting.
allowed = allowed | _monitor_control_actions(allowed)
# Exempt actions are added back unconditionally: policy narrows the
# per-endpoint surface, but it is not the authority on these actions. Their
# own elevated permission still gates them in the enqueue route.
allowed = allowed | POLICY_EXEMPT_ACTIONS
# Never widen past the code-level allowlist: policy can only gate within it.
return base & allowed
@@ -282,3 +530,256 @@ def _monitor_control_actions(action_names):
granted |= {'service_start', 'service_stop', 'service_status'}
return granted
def PLACE_POLICY_HOLD(assetid, action, reason, userid=None, username=None,
minutes=None):
"""Suspend desired-state reconciliation of one action on one endpoint.
Called after an admin break-glasses past a policy denial, or directly from
the admin hold dialog. Without this, an override on a desired-mode action is
undone by the next reconcile pass: the admin stops the service, the resolver
sees drift, and the command comes straight back. Gate-mode overrides need no
hold - nothing reverses them.
minutes=None takes DEFAULT_HOLD_MINUTES. minutes=0 places an INDEFINITE hold
(expiresat null) that never clears itself and stands until released by hand.
Nothing surfaces a stale indefinite hold, so it suppresses reconciliation of
that action silently and forever - deliberate, but the sharpest edge here.
Idempotent on an action already actively held: re-holding returns True
without stacking a second row. The multi-select picker makes "select all"
natural, and duplicate rows would mean release had to be pressed once per
hidden duplicate to actually resume policy.
Returns True when a hold is in place for the action afterwards. Resolved
lazily against the computers plugin, matching ALLOWED_ACTIONS_FOR, so this
module keeps no hard dependency on it: with the computers plugin absent
there is no reconciliation to suppress, and False is the honest answer.
"""
try:
from plugins.computers.models import (PolicyActionHold,
DEFAULT_HOLD_MINUTES)
from shopdb.api import db
except Exception:
return False
from datetime import datetime, timedelta
from sqlalchemy import or_
existing = (PolicyActionHold.query
.filter(PolicyActionHold.assetid == assetid,
PolicyActionHold.action == action,
PolicyActionHold.isactive.is_(True),
or_(PolicyActionHold.expiresat.is_(None),
PolicyActionHold.expiresat > datetime.utcnow()))
.first())
if existing is not None:
return True
if minutes is None:
expiresat = datetime.utcnow() + timedelta(minutes=DEFAULT_HOLD_MINUTES)
elif int(minutes) == 0:
expiresat = None
else:
expiresat = datetime.utcnow() + timedelta(minutes=int(minutes))
hold = PolicyActionHold(
assetid=assetid,
action=action,
expiresat=expiresat,
createdbyuserid=userid,
createdbyusername=username,
reason=reason,
)
db.session.add(hold)
db.session.commit()
return True
def ACTIVE_POLICY_HOLDS(assetid):
"""The unexpired break-glass holds on one endpoint, newest first.
The read counterpart to PLACE_POLICY_HOLD, resolved lazily against the
computers plugin for the same reason: with that plugin absent there is no
reconciliation to suppress, so there are no holds and an empty list is the
honest answer.
Expiry is filtered here rather than by a sweeper, matching the resolver, so
a stalled job can never silently extend a hold. Expired rows stay in the
table as the audit record; they are simply not reported as active.
"""
try:
from plugins.computers.models import PolicyActionHold
except Exception:
return []
from datetime import datetime
from sqlalchemy import or_
# Null expiresat is an indefinite hold and is always active, so it is
# matched explicitly - a bare comparison would report it as gone while the
# resolver still honours it, which is the worst kind of disagreement.
rows = (PolicyActionHold.query
.filter(PolicyActionHold.assetid == assetid,
PolicyActionHold.isactive.is_(True),
or_(PolicyActionHold.expiresat.is_(None),
PolicyActionHold.expiresat > datetime.utcnow()))
.all())
# Sorted here rather than in SQL: NULLS FIRST is not portable across the
# dialects this runs on. Indefinite holds sort ahead of every dated one,
# then latest expiry first. Keyed on datetime.max rather than a numeric
# fallback: datetime.min.timestamp() raises on Windows, and the placeholder
# is never actually compared against a real date anyway - the leading flag
# already separates the two groups.
rows.sort(key=lambda row: (row.expiresat is None,
row.expiresat or datetime.max),
reverse=True)
return [{
'action': row.action,
'expiresat': row.expiresat.isoformat() if row.expiresat else None,
'reason': row.reason,
'createdbyusername': row.createdbyusername,
} for row in rows]
def HOLDABLE_ACTIONS_FOR(assetid):
"""Desired-mode actions on this endpoint, with their current hold state.
Only desired-mode actions are offered: a hold suspends desired-state
reconciliation, so holding a gate-mode action would suspend nothing and the
control would silently do nothing.
Returns [] when the computers plugin is absent or policies are off - with no
reconciliation running there is nothing to hold, and an empty picker is the
honest answer rather than a list of actions the hold would not affect.
"""
try:
from plugins.computers.services.policyresolver import effective_policy_for
except Exception:
return []
effective = effective_policy_for(assetid)
held = effective.heldactions
return [{
'action': name,
'held': name in held,
} for name in sorted(effective.holdable_actions())]
def RELEASE_POLICY_HOLD(assetid, action):
"""End an active hold early, resuming reconciliation of one action.
Soft-deletes via isactive rather than dropping the row: the hold IS the
audit record of what was suppressed and by whom, and deleting it on release
would erase exactly the history worth reviewing. Both the resolver and
ACTIVE_POLICY_HOLDS already filter on isactive, so clearing it is enough to
resume reconciliation on the next resolve.
Returns the number of holds released, so a caller can tell "released" from
"there was nothing to release".
"""
try:
from plugins.computers.models import PolicyActionHold
from shopdb.api import db
except Exception:
return 0
from datetime import datetime
from sqlalchemy import or_
# Indefinite holds must be releasable too. Without the null branch the one
# kind of hold that never clears on its own would also be the one kind the
# release button could not clear.
rows = (PolicyActionHold.query
.filter(PolicyActionHold.assetid == assetid,
PolicyActionHold.action == action,
PolicyActionHold.isactive.is_(True),
or_(PolicyActionHold.expiresat.is_(None),
PolicyActionHold.expiresat > datetime.utcnow()))
.all())
for row in rows:
row.isactive = False
if rows:
db.session.commit()
return len(rows)
def ACTIVE_LOCK_FOR(assetid):
"""The endpoint lock standing on this asset, or None.
Unlike the hold helpers above there is no defensive cross-plugin import:
endpointlocks belongs to THIS plugin, so if the import could fail the
enqueue route calling it would not be running either.
No expiry filter, deliberately. A lock has no TTL - it is cleared by an
admin or it stands. Anything that looks like an expiry check here would be
a bug.
"""
from .models import EndpointLock
return (EndpointLock.query
.filter(EndpointLock.assetid == assetid,
EndpointLock.releasedat.is_(None))
.order_by(EndpointLock.lockedat.desc())
.first())
def PLACE_ENDPOINT_LOCK(assetid, reason, userid=None, username=None):
"""Quarantine one endpoint against non-admin commands.
Idempotent on the active lock: if one already stands this returns it
unchanged rather than stacking a second row. Two active locks on one
endpoint is not a state this table should represent, and the caller who
"locked" a locked machine got the outcome they asked for either way.
The invariant is enforced here rather than by a unique constraint because
released rows accumulate per asset as history, so uniqueness on assetid
would reject the second lock in an endpoint's life.
"""
from datetime import datetime
from shopdb.api import db
from .models import EndpointLock
existing = ACTIVE_LOCK_FOR(assetid)
if existing is not None:
return existing
lock = EndpointLock(
assetid=assetid,
reason=reason,
lockedbyuserid=userid,
lockedbyusername=username,
lockedat=datetime.utcnow(),
)
db.session.add(lock)
db.session.commit()
return lock
def RELEASE_ENDPOINT_LOCK(assetid, userid=None, username=None):
"""Lift the quarantine, recording who lifted it.
Stamps releasedat rather than deleting the row: the lock IS the record that
this machine was quarantined and why, and that record is most wanted after
the lock is gone and no longer able to explain itself. Every read filters on
releasedat, so stamping it is enough to restore normal command flow.
Returns the released lock, or None when there was nothing to release, so a
caller can tell those two cases apart.
"""
from datetime import datetime
from shopdb.api import db
lock = ACTIVE_LOCK_FOR(assetid)
if lock is None:
return None
lock.releasedat = datetime.utcnow()
lock.releasedbyuserid = userid
lock.releasedbyusername = username
db.session.commit()
return lock

View File

@@ -0,0 +1,477 @@
# Per-PC credentials rollout
Runbook for moving a site's endpoint fleet from the shared collector key to
per-PC credentials. The wire contract is `AGENT-CONTRACT.md` section 9.
Placeholders: `<server>` is the ShopDB base URL, `<share>` the GE-Enforce share
root, `<pin>` a server key pin, `<pilotbay>` / `<pilothost>` the pilot PC.
## What changes
- Every endpoint gets its own secret (`sdbe_...`), stored in
`%ProgramData%\ShopDB\EndpointAgent\credential\credential.bin`, DPAPI
LocalMachine, readable by SYSTEM and Administrators only.
- The server knows which asset is calling from the secret. A PC can no longer
report as another PC.
- The tray holds no secret. It talks to the SYSTEM agent over a local pipe.
- The agent, the upgrade script and the SYSTEM reporters
(`Report-AssetToShopDB.ps1`, `Report-PrintersToShopDB.ps1`) pin the server's
public key when `ServerPins` is set.
- The shared key keeps working for PCs that have NOT enrolled, until an admin
turns it off. An enrolled PC is always refused the shared key
(`401 SHARED_KEY_REFUSED`).
## Where to look in the app
- **Settings -> Endpoints -> Enrollment**: Migration tab (readiness, waves) and
Credentials tab (enrolled PCs, pending requests, un-enroll, allow
re-enrollment, the shared key and auto-enroll switches).
- **Settings -> Endpoints** (the agent key page): the shared key PCs use until
they enroll, with an enrolled-of-total summary and the pending request count.
Regenerate and revoke there say how many PCs are not enrolled and would be
affected.
- **Settings -> Endpoints -> Server Certificate**: the certificate file's pin
and thumbprint, and **What PCs receive**, a live check of the certificate the
site's base URL (`site_base_url`) actually presents. When the two differ, pin
the presented one.
- **Fleet** (sidebar, under Assets): the **Credential** column (Enrolled,
Pending, Re-enroll blocked, Shared key (not enrolled), Not reporting) and a
filter on it.
- **PC detail**: the Endpoint status card shows the PC's credential state (with
prefix, issued, last used, enrolled via and block reason for
`endpoints.fleet` holders). Admins get **Revoke credential** (with **Keep this
PC from enrolling again**) and **Allow re-enrollment** beside the status chip.
- **Dashboard**: "N PCs waiting for enrollment approval" (warning, links to
Enrollment) and "PCs blocked from re-enrolling over 7 days" (info, counted
from when the credential was revoked).
## Before you start
1. Server deployed with the endpoints migration `endpoints0023credentials`
applied (`flask plugin upgrade-all`).
2. Settings -> Endpoints -> Server Certificate shows a **Public key pin (SPKI
SHA-256)**. Cross-check it from a PC:
`Get-ShopDBServerPin.ps1 -Url https://<server>`. The two must match.
3. Agent 1.20 (and tray 1.7) uploaded as the current release under Settings ->
Endpoints -> Upload New/Legacy Agents. Keep the previous release staged as a
legacy build: it is the rollback.
4. Settings -> Endpoints -> Enrollment -> Credentials: **Shared key allowed**
on, **Auto-enroll** on (both are the defaults).
5. The GE-Enforce manifest scope that will carry the entries below is at
`Version` 2.2 or later, and the fleet's GE-Enforce client library
understands `TargetHostnames` / `TargetMachineNumbers`. An older manifest or
library ignores those lists and applies the entry to EVERY PC in scope. The
same happens with an empty list: never publish a targeted entry with a blank
target.
## Phase 1: pins on the PCs
Pins must be on a PC BEFORE agent 1.20 arrives there. A 1.20 agent with no pin
falls back to plain Windows validation, which a self-signed or internal-CA
server may fail; a pin set after the fact is not a rollout order you want to
debug per bay.
Stage `plugins/endpoints/client/Set-ShopDBServerPins.ps1` on the share and add
a PS1 entry. Pilot first:
```json
{
"_comment": "Server key pin for per-PC credentials. Pilot only.",
"Name": "ShopDB server pins",
"Type": "PS1",
"Script": "scripts/Set-ShopDBServerPins.ps1",
"Args": "-Pins <pin>",
"DetectionMethod": "Always",
"TargetMachineNumbers": ["<pilotbay>"]
}
```
Then widen by hostname list (or machine number list) in batches:
```json
{
"Name": "ShopDB server pins",
"Type": "PS1",
"Script": "scripts/Set-ShopDBServerPins.ps1",
"Args": "-Pins <pin>",
"DetectionMethod": "Always",
"TargetHostnames": ["<host1>", "<host2>", "<host3>"]
}
```
The script compares before it writes, so `Always` costs nothing per cycle.
A running agent picks up a changed `ServerPins` within about 60 seconds; no
service restart is needed.
**The registry wins.** The installer and the store upgrade also write
`serverpins` into appsettings.json. When `ServerPins` exists in the registry
(either view), even empty, the agent uses only the registry and ignores the
appsettings.json pins; only a PC with no `ServerPins` value falls back to them.
The agent log says which source is in effect (`Server certificate trust from
registry` / `from appsettings.json`, and `Server pins changed, source ...`).
- `-Pins <pin>[,<pin>]`: write exactly this set.
- `-None`: write an EMPTY value. No pins, normal Windows certificate checks,
appsettings.json pins ignored.
- `-Clear`: DELETE the value. The appsettings.json pins apply again (if any).
**Gate:** on the pilot, `HKLM\SOFTWARE\GE\ShopDB` and
`HKLM\SOFTWARE\WOW6432Node\GE\ShopDB` both hold `ServerPins` (REG_MULTI_SZ)
equal to the pin on the settings page, and the next `report-asset-*.log` under
`C:\Logs\Shopfloor` says `server pin check on (1 pin(s))` followed by a
RESPONSE line.
**Rotating the server key later:** any server-side change that gives the
certificate a new key (a regenerated or re-issued certificate) changes the pin.
Add the NEXT pin everywhere first: put the old AND the new pin in `-Pins`
(`"<oldpin>,<newpin>"`), wait until every PC has both, only then switch the
certificate on the server, then retire the old pin by writing ONLY the new pin
to the registry (`-Pins <newpin>`). Do not use `-Clear` for this: deleting the
value brings back whatever pins appsettings.json carries, which may be the old
one. Switching the server first
cuts off every pinned PC, including its ability to receive the fixed pin list
through anything that talks to ShopDB. A renewal that keeps the same key keeps
the same pin. PCs trusted through the Root store (below) also need the new
certificate imported before the switch.
### Trust the server certificate on PCs with agents older than 1.20
Required before ANY store wave. Agents older than 1.20 know nothing about
`ServerPins`: when they download the upgrade package they check the normal
Windows certificate chain. With a self-signed server certificate that check
fails and the upgrade never lands.
- PCs installed by the server's generated installer (Build site agent) already
trust the certificate.
- PCs installed from the share do NOT, and need this entry.
Get the certificate and its thumbprint from the server:
- Settings -> Endpoints -> Server Certificate -> download, or
`GET /api/endpoints/settings/server-cert/download` (a `.cer`).
- The thumbprint is `sha1thumbprint` on `GET /api/endpoints/settings/server-cert`.
Stage the `.cer` and `plugins/endpoints/client/Install-ShopDBServerCertificate.ps1`
on the share and add a PS1 entry with the SAME targeting as the pin entry:
```json
{
"_comment": "Trust the ShopDB server certificate for pre-1.20 agent upgrades.",
"Name": "ShopDB server certificate",
"Type": "PS1",
"Script": "scripts/Install-ShopDBServerCertificate.ps1",
"Args": "-CertPath <share>\\scripts\\shopdb-server.cer -Thumbprint <sha1thumbprint>",
"DetectionMethod": "Always",
"TargetMachineNumbers": ["<pilotbay>"]
}
```
The script refuses a file whose thumbprint differs, refuses a CA certificate
(one in Root would trust everything it signs), imports into
`LocalMachine\Root` only when the certificate is not already there, and removes
nothing. A non-CA leaf in Root trusts only this one server.
If a wave reaches a PC that does not trust the certificate, the download fails
and the Migration tab marks the PC **Blocked** with `server certificate not
trusted by this PC`. The server only knows about trust from that failure, so
the blocker stays until the PC is queued again. Fix trust with the entry above,
then in the Migration tab tick the PC (a PC whose only blocker is this one keeps
a checkbox and shows "Retry after fixing trust") and click **Upgrade selected**.
**Upgrade next 25** never picks it, and a PC with any other blocker as well
stays unselectable until that blocker is fixed.
## Phase 2: re-stamp the share's agent entries
PCs that got the agent from the share (collections manifest entries with a
`Hash` detection on `shopdbagent.exe`) are held to that hash by GE-Enforce. If
a store upgrade wave puts 1.20 on such a PC while the entry still carries the
1.19 hash, GE-Enforce reinstalls 1.19 on its next cycle and the PC flips back.
Before any wave reaches a share-installed PC, either:
- re-stamp those entries to the 1.20 payload and its `DetectionValue` SHA-256
(and keep the `ProductCode`/version guards the entries already have), or
- remove the entries for the PCs the wave will cover.
Agent entries stay targeted. Illustrative shape for the pilot (keep your
existing entry's type, installer and guards; what matters is the hash and a
non-empty target):
```json
{
"Name": "ShopDB endpoint agent 1.20",
"Type": "PS1",
"Script": "apps/shopdbagent/1.20.0/install-shopdbagent.ps1",
"DetectionMethod": "Hash",
"DetectionPath": "C:\\Program Files\\ShopDB\\EndpointAgent\\shopdbagent.exe",
"DetectionValue": "<sha256 of the 1.20 shopdbagent.exe>",
"TargetHostnames": ["<pilothost>"]
}
```
**Gate:** the manifest preview for the pilot shows the 1.20 hash, and no entry
for the same file with a blank target list exists in any scope that reaches it.
## Phase 3: pilot one PC
1. Enrollment -> Migration: find `<pilothost>`. It should be **Ready**. If it is
**Blocked**, the Blockers column lists every reason; fix those first.
2. Tick it, **Upgrade selected**.
3. Watch the row: **Upgrading**, then **Enrolled** within a few minutes of the
new agent starting. The agent enrolls on its own with the shared key it
already holds.
**Gate before any wave:**
- Credentials tab shows the PC enrolled, `enrolledvia` = `auto`, and a
`lastusedat` that keeps moving.
- The PC's Fleet row keeps reporting; commands (Collect now) still work.
- The tray shows alerts and Send Status works (it now goes through the pipe).
- `report-asset-*.log` and `report-printers-*.log` show `RESPONSE (credential)`.
- Nothing reinstalled the old agent after two GE-Enforce cycles.
## Phase 4: waves
Migration tab -> **Upgrade next 25**. The confirm dialog states how many PCs get
agent 1.20. The server re-checks readiness per PC and packages the agent once
per wave; PCs that stopped being ready are returned as skipped.
Between waves, in the Waves list:
- wait until the wave shows **Done** (every PC enrolled or failed),
- investigate every **failed** PC before the next wave,
- check nothing moved from Enrolled back to Upgrading or Ready (a share entry
reinstalling the old agent, see phase 2).
Repeat until **Ready** is 0.
## Phase 5: stragglers and new PCs
- **Blocked:** each blocker names its fix. `offline`: bring the PC back or wait.
`no serial on record` / `duplicate hostname`: fix the computer record.
`commands locked`: release the endpoint lock or policy hold. `no agent package
in store`: upload 1.20. `agent too old for remote upgrade`: move it via the
share (targeted agent entry as in phase 2). `server certificate not trusted
by this PC`: add the PC to the certificate entry (phase 1), wait a cycle,
then tick it and **Upgrade selected** (phase 1 explains the retry). `reenroll blocked`: the PC was rolled back on purpose; clear
it with allow-reenroll (see Rollback) only when moving it forward again.
- **Not reporting:** the PC has no working agent. It can only move via the
share channel: add its hostname to a targeted 1.20 agent entry. It shows as
Pending or Enrolled once the agent runs.
- **Pending:** the agent asked but auto-enroll could not decide (serial
mismatch, duplicate or unknown hostname, already enrolled, or auto-enroll
off). Credentials tab -> Requests -> **Approve**; the picker defaults to the
matched computer, search to choose another. The PC collects its credential on
its next retry - about a minute for a PC that started asking recently,
slowing to 15 minutes once it has been pending a while. **Reject** anything
you do not recognise. Approving also records the hostname the PC reports on
the computer you picked when that record has none; if the record carries a
DIFFERENT hostname the approval stops and asks, because the PC cannot
authenticate against a computer recorded under another name.
- **Newly imaged PCs:** imaging installs 1.20 with the shared key, and the agent
auto-enrolls on first start. A new PC can also be installed WITHOUT the
shared key at any time, before or after cutover (the generated installer's
"Include shared key" option off): the enroll call needs no key, so its request
lands in Pending and it waits there until an admin approves it in the UI. An
invalid key behaves the same (the server logs it). After the shared key is
turned off (phase 6) that is the only path, and the steady-state path for new
PCs.
## Installing a one-off PC
A PC nobody is going to author a GE-Enforce manifest entry for - a loaner, a
one-off office machine, a bay being rebuilt out of band - can be installed with
a single command instead of a downloaded package. **Managed bays keep using
GE-Enforce**; this does not replace the share channel, and the script itself
says so when it finds an agent already installed.
1. Settings -> Endpoints -> Build site agent -> **One-line install**. Set how
long the token lives (hours, 1 to 168) and how many installs it may serve
(1 to 500), then **Generate install command**.
2. Copy the command. The token is shown ONCE and cannot be recovered; generate
a new one if it is lost.
3. On the target PC, open PowerShell **as administrator** and paste it:
**Copy the command the UI gives you.** Do not retype it from here and do not
reuse one you saved from an older round - see the warning under this block. It
looks like this:
```
powershell -ExecutionPolicy Bypass -c "try { New-Item -ItemType Directory -Force -Path 'C:\Program Files\ShopDB\bootstrap' | Out-Null; icacls 'C:\Program Files\ShopDB\bootstrap' /inheritance:r /grant '*S-1-5-32-544:(OI)(CI)F' '*S-1-5-18:(OI)(CI)F' | Out-Null; if ((Get-Variable LASTEXITCODE -ValueOnly) -ne 0) { Write-Host 'Refusing to run: could not restrict C:\Program Files\ShopDB\bootstrap to administrators (icacls failed). Fix the directory or delete it, then run the command again.' -ForegroundColor Red; exit 6 }; Remove-Item 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1' -Force -ErrorAction SilentlyContinue; Invoke-WebRequest -UseBasicParsing 'https://<server>/api/endpoints/siteagent/bootstrap.ps1' -Headers @{'X-Endpoint-Bootstrap-Token'='<token>'} -OutFile 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1'; if ((Get-Acl 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1').GetOwner([Security.Principal.SecurityIdentifier]).Value -notin @('S-1-5-32-544','S-1-5-18',[Security.Principal.WindowsIdentity]::GetCurrent().User.Value)) { Write-Host 'Refusing to run: the staged bootstrap script is not owned by an administrator.' -ForegroundColor Red; exit 5 }; & 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1' } finally { Remove-Item 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1' -Force -ErrorAction SilentlyContinue }"
```
It creates an admin-only staging directory, downloads the script into it, runs
it and deletes it again. Do not shorten it back to `irm ... | iex`: Defender
scores that shape as `Trojan:Win32/Commando.A!ml` and kills the process before
anything runs, on a default install with no ASR rules. Paste it as one line
into an elevated **cmd** or **PowerShell** window; it means the same in both.
**Download .ps1** in the same block saves the script instead, for a PC where
pasting a long line is awkward - it carries the same token, so treat the file
the way you treat the command.
> **Do not use the old `?t=<token>` one-liner that staged into
> `C:\Windows\Temp`.** Any standard user on the bay can create that exact path
> first and wait for a tech to paste the command, which then runs the planted
> file elevated; the token also lands in the web server's access log. The
> command above stages under `C:\Program Files`, locks the directory to
> Administrators and SYSTEM and stops if that fails, refuses to run a staged
> file owned by anyone else, and sends the token in a header.
>
> The `?t=` form is still ACCEPTED, but only so a bay provisioned before this
> change keeps working until its command is re-issued. Every such fetch logs a
> server warning and writes an audit row against the token:
>
> ```
> bootstrap token presented in the query string by <caller> with no admin
> download marker, so this is an un-migrated bay running a command pasted
> before the header form; ... re-issue the one-line install command
> ```
>
> **`<caller>` is 127.0.0.1 on a stock Windows install.** IIS runs the app
> through HttpPlatformHandler over loopback and sets no `X-Forwarded-For` of
> its own; `deploy/windows/web.config` ships the rewrite rule that would add
> one commented out, so waitress has nothing to rewrite `REMOTE_ADDR` from.
> Check before you rely on the address: if these lines all say 127.0.0.1, the
> warning still tells you un-migrated bays EXIST and how many fetches they
> made, but not which bays. Get real addresses by enabling that rule (the
> Windows installer's `-ClientIpSource direct`, which installs URL Rewrite and
> allows `HTTP_X_FORWARDED_FOR` at server level); behind a further reverse
> proxy leave it off and set `TRUSTED_PROXY_COUNT` instead. Without that, the
> usable identifier is the token: each warning has a matching audit row on
> `EndpointBootstrapToken` carrying the 12-character `tokenprefix`, so you can
> at least tell which install round the stale command came from.
>
> Work that list down: mint a new token and hand out the new command. Clicking
> **Download .ps1** yourself does NOT add to it - that link is marked
> `&via=download` and logs at info, precisely so the warnings stay a list of
> bays. Once the list is empty, clear **Accept the token in the URL** in
> Settings -> Endpoints -> Build site agent -> One-line install
> (`plugin.endpoints.bootstrapquerytoken`) to refuse the query form outright.
> That also disables the Download .ps1 link and kills every command still in a
> technician's hands, so do it deliberately and re-issue first.
The token is short-lived and only fetches the installer. It carries no shared
key and provisions nothing by itself: the package it downloads is built with
"Include shared key" off, whatever the shared-key setting is, so the PC asks to
enroll and **waits for approval**. Approve it in Enrollment -> Credentials ->
Requests, the same as any other pending PC (phase 5).
A one-off PC is pending for `unknown hostname` by definition, so pick (or
create) the computer record it belongs to. The approval records the hostname
the PC reports on that record, which is what the PC authenticates against
afterwards; picking a record already named something else stops the approval
and asks before renaming it. Skipping that step is what used to leave a
bootstrapped PC enrolled and then refused (403) on every call.
The script checks the downloaded binaries against the SHA-256 values the server
stamped into it, refuses to run unelevated, and refuses to run on a PC that
already has the agent service (set `SHOPDB_BOOTSTRAP_FORCE=1` in the same
window to override). After a successful install it waits up to two minutes for
the credential to land and prints `approved and enrolled` if an admin approves
it in that window; still pending is a normal outcome, not a failure.
If it stops before any of that with a red **Refusing to run**, nothing was
installed and nothing was downloaded. Two cases, both meaning look at the bay
rather than retry:
| Exit | Message | What to do |
|---|---|---|
| 6 | could not restrict `C:\Program Files\ShopDB\bootstrap` to administrators | the ACL could not be set - usually the directory already exists with an owner or DACL you did not put there. Inspect it (`icacls`), delete it, run the command again. |
| 5 | the staged bootstrap script is not owned by an administrator | something else wrote the staged file. Treat the bay as suspect, do not force past it. |
Revoke a token from the same block when the install round is done. Tokens list
with their prefix, expiry and uses; they are also spent automatically
(`usecount` reaching `maxuses`) and expire on their own.
## Phase 6: cutover
**Gate:** Credentials tab **Not enrolled** is 0, or every remaining PC is one
you have decided may stop reporting. Remember that turning the shared key off
also refuses it on `/api/collector/computers` and `/api/collector/printers`, so
a PC with reporter scripts but no enrolled agent goes silent.
1. Turn **Shared key allowed** off. The confirm shows how many PCs are not
enrolled; type `OFF`.
2. Watch Fleet for an hour. A PC that went quiet is either unenrolled (turn the
shared key back on, fix it, try again) or failed pinning.
3. Remove the shared key from every place it lives (below).
4. Rotate the old key so any copy you missed is dead: Settings -> Endpoints ->
**Regenerate key** (and rotate the collector token the reporters used, if it
was a separate token). With the shared key off nothing on the floor needs
the new key: approved enroll requests are collected without it. Store it
the way the key page describes and keep it out of every file below. A
fleet rollback after this point (see Rollback) needs the new key pushed to
the PCs again.
### Every place the shared key lives
| Where | Put there by | How to clear at cutover |
|---|---|---|
| GE-Enforce manifest entry `Args` (`-ApiKey ...`) | whoever authored the entry | Remove the argument from the entry and publish. Search every scope for the key prefix. |
| `HKLM\SOFTWARE\GE\ShopDB` `CollectorKey`, both registry views | agent `install.ps1`; `Set-ShopdbCollectorKey.ps1` from `<share>\common\configs\shopdb-collector.txt` | First retire the `Set-ShopdbCollectorKey.ps1` entry (or it rewrites the value every cycle), then a targeted PS1 entry that deletes `CollectorKey` in both views. |
| `<share>\common\configs\shopdb-collector.txt` | share staging | Delete the file after the entry above is retired. |
| `appsettings.json` `apikey` in the agent install folder | agent package / hand edits | Targeted PS1 entry that sets `apikey` to an empty string. The agent never needs it once enrolled. |
| Generated `install.ps1` default `-CollectorKey` (Build site agent downloads) | Settings -> Endpoints -> Build site agent | Delete every kept copy (share, USB, work folders). Rebuild after rotation only if you still need the package. |
| Display kiosk `shopdb-key.txt` | display imaging | Confirm what the kiosk uses it for before clearing; a kiosk has no endpoint agent, so anything it reports with the shared key stops at cutover. Delete the file once that is settled. |
The agent never deletes the shared key itself: other SYSTEM scripts may still
read it until this table is done.
## Rollback
The order matters. A 1.20 agent that is plainly un-enrolled enrolls again
within about a second, before any downgrade can land, and the old agent it is
then replaced with only knows the shared key, which the server refuses for an
enrolled PC. Block re-enrollment FIRST.
**One PC:**
1. Make sure **Shared key allowed** is on.
2. Credentials tab -> **Un-enroll** the PC with **Keep this PC from enrolling
again** ticked. API equivalent:
`POST /api/endpoints/enrollment/assets/<assetid>/revoke`
`{"blockreenroll": true, "reason": "rollback"}`.
Its credential stops working immediately, the server admits the shared key
for it again, and its enroll attempts stay pending with `reenroll blocked`.
3. Make the previous agent current in the store (Upload New/Legacy Agents) and
queue the upgrade for that PC, or re-stamp its share entry to the old hash.
4. Verify: the Fleet row reports the old agent version, and the reporter logs
show `RESPONSE (shared key)`.
5. Remove the PC from the pin entry's targets only if pinning itself is the
problem. `Set-ShopDBServerPins.ps1 -None` turns pinning off outright;
`-Clear` deletes the value, which falls back to any appsettings.json pins.
6. Later, when moving the PC forward again, lift the block:
`POST /api/endpoints/enrollment/assets/<assetid>/allow-reenroll` (or the
same action in the UI). The PC enrolls on its next attempt once it runs
1.20. Until then the Migration tab shows it blocked with `reenroll blocked`.
**Whole fleet:** turn **Shared key allowed** back on first. Every PC that still
holds the shared key reports again immediately. Then revoke with the block and
downgrade PCs as above, in waves.
## Verified on Windows
A full pass on a Windows test PC against a test server proved:
- the agent enrolls on its own;
- the shared key is refused for the enrolled PC (`SHARED_KEY_REFUSED`);
- a request naming another asset is refused (`ASSET_MISMATCH`);
- server pins are enforced;
- the tray pipe works for a standard (non-admin) user;
- alert acknowledge works;
- both SYSTEM reporters send with the per-PC credential;
- revoke is followed by re-enrollment;
- rollback to the previous agent works;
- with the shared key switched off, enrolled PCs keep working.
## Quick reference
- Pin of the live server: Server Certificate settings page, or
`Get-ShopDBServerPin.ps1 -Url https://<server>`.
- Pin of a certificate file: `Get-ShopDBServerPin.ps1 -Path <file>`.
- Agent log of enrollment: `%ProgramData%\ShopDB\EndpointAgent\logs`.
- Reporter logs: `C:\Logs\Shopfloor\report-asset-*.log`,
`report-printers-*.log`. `SHARED_KEY_REFUSED` there means the PC is enrolled
or the shared key is off and the reporter could not read the credential;
`CREDENTIAL_INVALID` means the PC was un-enrolled and the agent is
re-enrolling.

View File

@@ -24,7 +24,21 @@ EVENT_CATALOG = {
55: ('File system corruption', 'critical', 'System'),
7011: ('Service timeout', 'critical', 'System'),
10016: ('DCOM permission error', 'warning', 'System'),
1001: ('Application crash', 'critical', 'Application'),
# Windows Error Reporting trio on the Application channel. 1000 is the
# faulting-application record (app name, version, faulting module, exception
# code) and is the one that says what actually broke; 1001 is the WER bucket
# record Windows writes alongside it; 1002 is a hang rather than a crash, so
# it is a warning - the app is unresponsive, not gone.
#
# 1001 is deliberately NOT critical. Windows writes it ALONGSIDE 1000 for the
# same crash, so counting both double-counted every fault: a box with 2200
# crashes reported "Application crash x4400". It also won the dominant-event
# tie often enough to displace 1000, which is the record that actually names
# the faulting module. Kept curated so the history panel still shows it, but
# it no longer drives the fleet health verdict - 1000 speaks for the crash.
1000: ('Application error', 'critical', 'Application'),
1001: ('Application crash', 'info', 'Application'),
1002: ('Application hang', 'warning', 'Application'),
}
# The IDs a tech should see surfaced fleet-wide on the dashboard stability card.

View File

@@ -24,15 +24,37 @@ export default [
component: () => import('./views/MonitorsSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'endpoints' }
},
// TEMPORARY / DEV ONLY. A manual bench to exercise the update_app and
// run_script delivery path end to end while Cameron builds the real UI.
// Gated on endpoints.deploy inside the view; remove once the production
// delivery UI lands.
{
path: 'endpoints/delivery-test',
name: 'endpoints-delivery-test',
component: () => import('./views/DeliveryTestBench.vue'),
meta: { requiresAuth: true, plugin: 'endpoints' }
path: 'settings/endpoints/server-cert',
name: 'endpoints-server-cert-settings',
component: () => import('./views/ServerCertSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'endpoints' }
},
// requiresPermission, not requiresAdmin: endpoints.runscript is grantable to
// non-admin operators, and the sibling pages' admin gate would lock the
// holders of the grant out of the library they are trusted to manage. The
// API enforces the same permission, so this only keeps the menu honest.
{
path: 'settings/endpoints/scripts',
name: 'endpoints-scripts-settings',
component: () => import('./views/SystemScriptsSettings.vue'),
meta: {
requiresAuth: true,
requiresPermission: 'endpoints.runscript',
plugin: 'endpoints'
}
},
// Readable by endpoints.fleet holders so they can watch a migration; every
// write on the page is admin-only and re-checked server-side.
{
path: 'settings/endpoints/enrollment',
name: 'endpoints-enrollment-settings',
component: () => import('./views/EnrollmentSettings.vue'),
meta: {
requiresAuth: true,
requiresPermission: 'endpoints.fleet',
plugin: 'endpoints'
}
}
]

View File

@@ -5,15 +5,17 @@
</div>
<p class="setting-description">
Manage how shop-floor endpoint agents authenticate to this server, build
the agent for this site, and set what it reports and how often.
Manage the shared key PCs use until they enroll, build the agent for this
site, and set what it reports and how often. Each enrolled PC
authenticates with its own credential instead; see Settings &gt; Endpoints
&gt; Enrollment.
</p>
<!-- ====================================================================
Key management
Shared key
================================================================= -->
<section class="settings-section">
<h3>Key management</h3>
<h3>Shared key (used until PCs enroll)</h3>
<p class="key-warning">
STORE YOUR KEY SECURELY. IT IS SHOWN ONLY ONCE AND CANNOT BE RECOVERED.
</p>
@@ -25,12 +27,40 @@
paste the raw key into a chat message or a plain file.
</p>
<p class="field-hint">
The endpoint agent reports with a single managed key scoped to endpoint
telemetry only. It is separate from every other collector, so rotating
or revoking it stops all endpoint-agent traffic immediately without
touching the PC or GE-Enforce collectors.
Every PC installed with the shared key holds this same key. It lets a
PC that has not enrolled yet report and ask for its own credential. An enrolled PC is refused it and
uses its credential instead, so rotating or revoking the key only
affects PCs that have not enrolled. It is scoped to endpoint telemetry
and separate from every other collector.
</p>
<div v-if="enrollment" class="card form-card enrollment-summary">
<div class="enrollment-counts">
<span class="enrollment-count">
<strong>{{ enrollment.enrolled }}</strong> of {{ enrollment.total }} PCs enrolled
</span>
<span class="enrollment-count">
<strong>{{ enrollment.notenrolled }}</strong> still on the shared key
</span>
<span class="enrollment-count">
<strong>{{ enrollment.requests }}</strong>
pending enrollment {{ enrollment.requests === 1 ? 'request' : 'requests' }}
</span>
</div>
<div class="progress-track">
<div class="progress-fill" :style="{ width: enrolledPercent + '%' }"></div>
</div>
<p class="field-hint">
<template v-if="!enrollment.sharedkeyallowed">
<strong>The shared key is switched off</strong> in Enrollment, so PCs
that have not enrolled already cannot report with it.
</template>
Approve requests, move PCs over and retire the shared key in
<router-link :to="{ name: 'endpoints-enrollment-settings' }">
Settings &gt; Endpoints &gt; Enrollment</router-link>.
</p>
</div>
<div class="card form-card">
<div v-if="message" class="settings-success">{{ message }}</div>
<div v-if="error" class="error-message">{{ error }}</div>
@@ -49,8 +79,8 @@
<template v-else>Not used yet.</template>
</p>
<p v-else class="field-hint">
No endpoint agent key is active. Generate one to let agents report
with a managed credential.
No shared key is active. PCs that have not enrolled cannot report
or auto-enroll until one is generated.
</p>
</div>
@@ -85,9 +115,10 @@
<div v-if="secret" class="card form-card secret-card">
<h4>Save this key now</h4>
<p class="field-hint">
Anyone with this key can report as an endpoint agent for this site.
Treat it like a password. If it is lost you must regenerate, which
revokes the old key and forces every agent to be reconfigured.
Anyone with this key can report as a not-yet-enrolled endpoint agent
for this site and ask to enroll. Treat it like a password. If it is
lost you must regenerate, which revokes the old key; PCs that have not
enrolled then need the new one.
</p>
<p class="field-hint">
<strong>Recommended:</strong> before you leave this page, open the
@@ -110,53 +141,57 @@
<summary><h4>How it works</h4></summary>
<ul class="field-hint info-list">
<li>
<strong>Generate</strong> mints a fresh key and, on the first
generation, turns off the bootstrap <code>.env</code> fallback so
the managed key becomes the only accepted credential. The full
secret is shown once - store it in the agent config as the
<code>X-API-Key</code> header.
<strong>Until a PC enrolls</strong> it reports with this shared key.
Agents from 1.20 use it once more to ask for their own credential,
and from then on send that credential instead.
</li>
<li>
<strong>Regenerate</strong> is a hard cutover: the previous key is
revoked in the same moment the new one is minted. Every deployed
agent must be reconfigured with the new key before it can report
again.
<strong>Enrolled PCs ignore it.</strong> The server refuses the shared
key for any PC that holds a credential, so regenerating or revoking
it does not touch them.
</li>
<li>
<strong>Revoke</strong> deactivates the key and leaves it off. All
endpoint-agent reporting stops until you generate a new key. This is
the kill switch.
<strong>Generate</strong> mints a key and, the first time, turns off
the bootstrap <code>.env</code> fallback. The full secret is shown
once; it goes into the agent config as the <code>X-API-Key</code>
header.
</li>
<li>
<strong>Regenerate</strong> revokes the previous key in the same
moment. PCs that have not enrolled must be given the new key before
they can report or enroll again.
</li>
<li>
<strong>Revoke</strong> leaves no shared key. PCs that have not
enrolled stop reporting and can no longer auto-enroll; enrolled PCs
carry on.
</li>
</ul>
</details>
<details class="collapsible">
<summary><h4>When to rotate</h4></summary>
<summary><h4>When to rotate or retire</h4></summary>
<ul class="field-hint info-list">
<li>
<strong>An installer or agent left the secure environment.</strong>
If a build carrying the key is copied off the shop floor - onto an
engineer's laptop, a USB stick, or a machine that leaves the site -
treat the key as compromised and regenerate immediately.
<strong>At cutover.</strong> Once every PC has enrolled, switch the
shared key off in Enrollment, clear it from every place it lives,
then regenerate so any copy you missed is dead. The steps are in the
per-PC credentials rollout runbook.
</li>
<li>
<strong>A device is lost, stolen, or decommissioned</strong> while
still holding the key, and you cannot confirm the key was wiped.
<strong>An installer or key left the secure environment.</strong> A
build or file carrying the key copied onto a laptop, a USB stick or a
machine that leaves the site. Regenerate; only PCs that have not
enrolled need the new key.
</li>
<li>
<strong>Staff offboarding.</strong> Someone who could read the key
from a config or build pipeline is leaving; rotate as part of the
offboarding checklist.
from a config or build pipeline is leaving.
</li>
<li>
<strong>Scheduled hygiene.</strong> Rotate on a routine cadence
(for example quarterly) so a silently leaked key has a bounded
lifetime.
</li>
<li>
<strong>Suspicious traffic.</strong> The Fleet view shows reports
from a host you do not recognize - revoke now, investigate, then
regenerate and redeploy to known-good endpoints.
<strong>Suspicious traffic.</strong> The Fleet view shows reports from
a host you do not recognize. Revoke now, investigate, then regenerate
for the PCs that still need it.
</li>
</ul>
</details>
@@ -222,10 +257,11 @@
<div class="card form-card">
<p class="field-hint">
Produce an instance-locked agent package stamped with this server's
URL, the endpoint agent key, and the reporting cadence, so it deploys
with no manual configuration on the client. The download includes the
agent, a non-secret <code>appsettings.json</code>, and an
<code>install.ps1</code> that writes the credentials to the registry
URL, server pin, optionally the endpoint agent key, and the reporting
cadence, so it deploys with no manual configuration on the client. The
download includes the agent, a non-secret
<code>appsettings.json</code>, and an <code>install.ps1</code> that
writes the settings to the registry
(<code>HKLM\SOFTWARE\GE\ShopDB</code>) - the key never lands in
appsettings.
</p>
@@ -245,14 +281,46 @@
placeholder="https://shopdb.example"
/>
<label class="field-label" for="agent-key">Endpoint agent key</label>
<label class="checkbox-row">
<input v-model="agentincludesharedkey" type="checkbox" />
<span>
Include shared key. When off, the package holds no key and each PC
requests enrollment for an admin to approve in Settings &gt;
Endpoints &gt; Enrollment.
</span>
</label>
<template v-if="agentincludesharedkey">
<label class="field-label" for="agent-key">Endpoint agent key</label>
<input
id="agent-key"
v-model="agentcollectorkey"
class="form-control mono"
type="text"
placeholder="Paste the endpoint agent key generated above"
/>
</template>
<label class="field-label" for="agent-nextpin">
Next server pin (optional)
</label>
<input
id="agent-key"
v-model="agentcollectorkey"
id="agent-nextpin"
v-model.trim="agentnextserverpin"
class="form-control mono"
type="text"
placeholder="Paste the endpoint agent key generated above"
maxlength="64"
placeholder="64 lowercase hex characters"
/>
<p class="field-hint">
Current server pin:
<code>{{ agentserverpin || 'not available (no server certificate found)' }}</code>.
Add the next certificate's pin only ahead of a planned certificate
rotation.
</p>
<p v-if="agentnextpininvalid()" class="field-error">
The next server pin must be exactly 64 lowercase hex characters.
</p>
<label class="field-label" for="agent-cadence">
Reporting cadence (seconds)
@@ -287,9 +355,11 @@
/>
<p class="field-hint">
For a reverse-proxy or internal-CA server this app cannot see, upload
the server's public certificate (or issuing CA) in PEM format. It is
validated, bundled with the package, and imported into the endpoint's
Trusted Root store by <code>install.ps1</code>. Leave empty when the
the server's public certificate in PEM format. It is validated,
bundled with the package, pinned, and imported into the endpoint's
Trusted Root store by <code>install.ps1</code> (a CA-flagged
certificate is bundled and pinned but never imported, so only agents
1.20 and newer will trust the server). Leave empty when the
server uses a publicly trusted certificate. An upload takes precedence
over the bundled server certificate above.
</p>
@@ -298,15 +368,183 @@
<button
class="btn btn-primary"
type="button"
:disabled="agentbusy || !agentbaseurl || !agentcollectorkey"
:disabled="agentbusy || !agentbaseurl
|| (agentincludesharedkey && !agentcollectorkey)
|| agentnextpininvalid()"
@click="buildSiteAgent"
>
{{ agentbusy ? 'Building...' : 'Build site agent' }}
</button>
</div>
<p v-if="agentmessage" class="field-hint">{{ agentmessage }}</p>
<p v-if="agentwarning" class="field-warning">{{ agentwarning }}</p>
<p v-if="agenterror" class="field-error">{{ agenterror }}</p>
<!-- ================================================================
One-line install. An admin mints a short-lived token and hands a
tech one command; the token only fetches the installer. The
package it serves never carries the shared key, so the PC waits
in Pending until an admin approves its enrollment. -->
<div class="card bootstrap-block">
<h4>One-line install</h4>
<p class="field-hint">
Generate a short-lived token and give a technician the command.
On a PC with no agent, run it from an <strong>elevated</strong>
PowerShell. The token only fetches the installer: it carries no
shared key and provisions nothing on its own. A managed bay should
keep getting the agent from GE-Enforce instead.
</p>
<div class="bootstrap-inputs">
<div>
<label class="field-label" for="bootstrap-hours">
Valid for (hours)
</label>
<input
id="bootstrap-hours"
v-model.number="bootstraphours"
class="form-control"
type="number"
min="1"
max="168"
/>
</div>
<div>
<label class="field-label" for="bootstrap-maxuses">
Maximum uses
</label>
<input
id="bootstrap-maxuses"
v-model.number="bootstrapmaxuses"
class="form-control"
type="number"
min="1"
max="500"
/>
</div>
</div>
<div class="button-row">
<button
class="btn btn-primary"
type="button"
:disabled="bootstrapbusy"
@click="generateBootstrapToken"
>
{{ bootstrapbusy ? 'Generating...' : 'Generate install command' }}
</button>
</div>
<template v-if="bootstrapcommand">
<p class="field-warning">
Copy this now. The token is shown once and cannot be recovered.
</p>
<label class="field-label" for="bootstrap-command">
Install command
</label>
<div class="bootstrap-command-row">
<input
id="bootstrap-command"
class="form-control mono"
type="text"
readonly
:value="bootstrapcommand"
/>
<button
class="btn btn-secondary"
type="button"
@click="copyBootstrapCommand"
>
{{ bootstrapcopied ? 'Copied' : 'Copy' }}
</button>
</div>
<p v-if="bootstrapscripturl" class="field-hint">
Or save the script and run it on the PC instead:
<a
class="bootstrap-download"
:href="bootstrapscripturl"
download="shopdb-bootstrap.ps1"
>
Download .ps1
</a>
(the same token; it carries the secret, so treat the file the
way you treat the command).
</p>
<p v-if="bootstraptoken" class="field-hint">
Expires {{ formatDate(bootstraptoken.expiresat) }}.
{{ bootstraptoken.remaining }} of
{{ bootstraptoken.maxuses }} uses remaining.
</p>
</template>
<p class="field-hint">
The PC installs with no shared key and then waits for approval in
Settings &gt; Endpoints &gt; Enrollment.
</p>
<!-- The deprecated query form of the bootstrap token. Rendered here
because this is the page that mints the command the setting
governs: turning it off invalidates every command already handed
out that carries ?t=. -->
<label class="checkbox-row">
<input
type="checkbox"
class="toggle-querytoken"
:checked="querytokenallowed"
:disabled="querytokenbusy"
@change="onQueryTokenToggle($event)"
/>
<span>
<strong>Accept the token in the URL.</strong> On by default:
the pasted one-line command and the Download .ps1 link above both
send the token as <code>?t=</code>, which is the only form a
browser download link can use. The token lands in the IIS and
proxy logs that way, so the header form
(<code>X-Endpoint-Bootstrap-Token</code>) replaces it. Turn this off
and every install command still in a technician's hands stops
working immediately - re-issue them first.
</span>
</label>
<p v-if="querytokenerror" class="field-error">{{ querytokenerror }}</p>
<table v-if="bootstraptokens.length" class="versions-table">
<thead>
<tr>
<th>Token</th>
<th>State</th>
<th>Expires</th>
<th>Uses</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="entry in bootstraptokens" :key="entry.tokenid">
<td class="mono">{{ entry.tokenprefix }}</td>
<td>{{ entry.state }}</td>
<td>{{ formatDate(entry.expiresat) }}</td>
<td>{{ entry.usecount }} / {{ entry.maxuses }}</td>
<td>
<button
v-if="entry.state === 'active'"
class="btn-link"
type="button"
:disabled="bootstrapbusy"
@click="revokeBootstrapToken(entry.tokenid)"
>
Revoke
</button>
</td>
</tr>
</tbody>
</table>
<p v-else class="field-hint">No install tokens yet.</p>
<p v-if="bootstrapmessage" class="field-hint">
{{ bootstrapmessage }}
</p>
<p v-if="bootstraperror" class="field-error">{{ bootstraperror }}</p>
</div>
<!-- ================================================================
Agent binary versions: upload a new build, keep the last few,
and roll the current pointer back to a retained version. Staging
@@ -319,9 +557,9 @@
<div
v-for="binary in binaries"
:key="binary.key"
class="binary-block"
class="card binary-block"
>
<h4>{{ binary.label }}</h4>
<h4 class="binary-title">{{ binary.label }}</h4>
<p class="field-hint">
Upload {{ binary.name }} updated EXE. Uploading here will update the
binaries available for your "Build Site Agent" install package.
@@ -355,7 +593,7 @@
<button
class="btn btn-secondary"
type="button"
:disabled="binary.busy || !binary.version || !binary.file"
:disabled="binary.busy || binary.detecting || !binary.file"
@click="uploadBinary(binary.key)"
>
{{ binary.busy ? 'Uploading...' : 'Upload current release' }}
@@ -370,8 +608,9 @@
Upload legacy build for rollback
</label>
<p class="field-warning">
WARNING: confirm agent version before uploading from the EXE
properties and enter the version number.
The version is read from the EXE itself. Leave this blank to use the
binary's own file version, or enter a dotted-numeric override only
when the build carries no readable version.
</p>
<div class="binary-upload">
<input
@@ -389,7 +628,7 @@
<button
class="btn btn-secondary"
type="button"
:disabled="binary.busy || !binary.legacyversion || !binary.legacyfile"
:disabled="binary.busy || !binary.legacyfile"
@click="uploadLegacy(binary.key)"
>
{{ binary.busy ? 'Uploading...' : 'Upload legacy build' }}
@@ -495,7 +734,7 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import api from '@/api'
@@ -508,6 +747,9 @@ const secret = ref('')
const copied = ref(false)
const message = ref('')
const error = ref('')
// Enrollment progress, from the same counts the Enrollment page shows. Null
// when the read fails; the key controls work without it.
const enrollment = ref(null)
// Adoption tool: build a one-shot, agentless enrollment download.
const adoptbaseurl = ref('')
@@ -528,6 +770,38 @@ const agentcertfile = ref(null)
const agentbusy = ref(false)
const agentmessage = ref('')
const agenterror = ref('')
const agentwarning = ref('')
const agentincludesharedkey = ref(true)
const agentnextserverpin = ref('')
const agentserverpin = ref('')
// One-line install tokens. bootstrapcommand holds the secret ONLY for the
// response that minted it: the list read never carries one, so a reload leaves
// the field empty rather than re-showing a token nobody can recover.
const bootstraphours = ref(24)
const bootstrapmaxuses = ref(25)
const bootstrapbusy = ref(false)
const bootstrapcommand = ref('')
const bootstrapscripturl = ref('')
const bootstraptoken = ref(null)
const bootstrapcopied = ref(false)
const bootstraptokens = ref([])
const bootstrapmessage = ref('')
const bootstraperror = ref('')
// plugin.endpoints.bootstrapquerytoken, read and written through the core
// settings API: the endpoints plugin has no settings route of its own for it,
// and the key is declared in get_settings_defaults so the upsert on PUT gets
// the boolean valuetype and the description from that declaration.
const QUERY_TOKEN_SETTING = 'plugin.endpoints.bootstrapquerytoken'
const querytokenallowed = ref(true)
const querytokenbusy = ref(false)
const querytokenerror = ref('')
function agentnextpininvalid() {
const pin = agentnextserverpin.value
return !!pin && !/^[0-9a-f]{64}$/.test(pin)
}
// Versioned agent/tray binaries. Each block owns its own upload form, retained
// version list, and busy/message state so the two rows act independently. The
@@ -568,6 +842,40 @@ function filenameFromResponse(response, fallback) {
return match ? decodeURIComponent(match[1]) : fallback
}
const enrolledPercent = computed(() => {
const total = enrollment.value?.total || 0
return total ? Math.round((enrollment.value.enrolled / total) * 100) : 0
})
async function loadEnrollment() {
try {
const response = await api.get('/endpoints/enrollment', { params: { perpage: 1 } })
const data = response.data.data || {}
const counts = data.counts || {}
const enrolled = counts.enrolled || 0
// pending PCs asked but hold no credential yet: still on the shared key.
const notenrolled = (counts.notenrolled || 0) + (counts.pendingcomputers || 0)
enrollment.value = {
enrolled,
notenrolled,
total: enrolled + notenrolled,
requests: (data.requests || []).filter(
(entry) => entry.status === 'pending' && entry.reason !== 'reenroll blocked').length,
sharedkeyallowed: data.settings?.sharedkeyallowed !== false
}
} catch (loadError) {
enrollment.value = null
console.error(loadError)
}
}
// The number of PCs a change to the shared key reaches, for the confirms.
function notEnrolledPhrase() {
if (!enrollment.value) return 'Every PC that has not enrolled'
const count = enrollment.value.notenrolled
return `${count} PC${count === 1 ? '' : 's'} not enrolled`
}
function applyStatus(data) {
key.value = data.key || null
envkeyfallback.value = !!data.envkeyfallback
@@ -587,12 +895,17 @@ onMounted(async () => {
} finally {
loading.value = false
}
loadEnrollment()
loadBootstrapTokens()
loadQueryTokenSetting()
try {
const response = await api.get('/endpoints/siteagent')
const status = response.data.data || {}
agentstaged.value = !!status.agentstaged
certavailable.value = !!status.certavailable
agentbundlecert.value = !!status.certavailable
agentincludesharedkey.value = status.sharedkeyallowed !== false
agentserverpin.value = status.serverpin || ''
applyBinaryStatus('agent', status.agentcurrent, status.agentversions)
applyBinaryStatus('tray', status.traycurrent, status.trayversions)
} catch (statusError) {
@@ -600,6 +913,117 @@ onMounted(async () => {
}
})
async function loadQueryTokenSetting() {
try {
const response = await api.get(`/settings/${QUERY_TOKEN_SETTING}`)
// The backend reads it as bool(Setting.get(key, True)), so only an
// explicit false is off. A row seeded as boolean comes back as a real
// false; an older row that was created as a string comes back as 'false'.
const value = response.data.data?.value
querytokenallowed.value = !(value === false || value === 'false')
} catch (loadError) {
// 404 = the row was never seeded (plugin installed before the key was
// declared). The backend default is on, so show on.
if (loadError.response?.status !== 404) console.error(loadError)
querytokenallowed.value = true
}
}
async function onQueryTokenToggle(event) {
const turningOn = event.target.checked
if (!turningOn) {
const answer = prompt(
'Stop accepting the token in the URL?\n\n'
+ 'Every install command already handed out uses ?t= and stops '
+ 'working at once, including the Download .ps1 links. Re-issue them '
+ 'before turning this off.\n\n'
+ 'Type OFF to confirm.')
if (!answer || answer.trim().toUpperCase() !== 'OFF') {
event.target.checked = true
return
}
}
querytokenbusy.value = true
querytokenerror.value = ''
try {
// PUT upserts, so this works on a site whose row was never seeded.
await api.put(`/settings/${QUERY_TOKEN_SETTING}`, { value: turningOn })
querytokenallowed.value = turningOn
} catch (saveError) {
querytokenerror.value = saveError.response?.status === 403
? 'Only an administrator can change this setting'
: 'Could not save the setting'
event.target.checked = querytokenallowed.value
console.error(saveError)
} finally {
querytokenbusy.value = false
}
}
async function loadBootstrapTokens() {
try {
const response = await api.get('/endpoints/siteagent/bootstrap/tokens')
bootstraptokens.value = response.data.data?.tokens || []
} catch (loadError) {
bootstraptokens.value = []
console.error(loadError)
}
}
async function generateBootstrapToken() {
bootstrapbusy.value = true
bootstrapmessage.value = ''
bootstraperror.value = ''
bootstrapcommand.value = ''
bootstrapscripturl.value = ''
bootstraptoken.value = null
bootstrapcopied.value = false
try {
const response = await api.post('/endpoints/siteagent/bootstrap/tokens', {
hours: bootstraphours.value,
maxuses: bootstrapmaxuses.value
})
const data = response.data.data || {}
bootstrapcommand.value = data.command || ''
bootstrapscripturl.value = data.scripturl || ''
bootstraptoken.value = data.token || null
bootstrapmessage.value = 'Install command generated.'
await loadBootstrapTokens()
} catch (generateError) {
bootstraperror.value = generateError.response?.data?.data?.error?.message
|| 'Could not generate an install token'
console.error(generateError)
} finally {
bootstrapbusy.value = false
}
}
async function copyBootstrapCommand() {
try {
await navigator.clipboard.writeText(bootstrapcommand.value)
bootstrapcopied.value = true
} catch (copyError) {
console.error(copyError)
}
}
async function revokeBootstrapToken(tokenid) {
bootstrapbusy.value = true
bootstrapmessage.value = ''
bootstraperror.value = ''
try {
await api.post(`/endpoints/siteagent/bootstrap/tokens/${tokenid}/revoke`)
bootstrapmessage.value = 'Install token revoked.'
await loadBootstrapTokens()
} catch (revokeError) {
bootstraperror.value = revokeError.response?.data?.data?.error?.message
|| 'Could not revoke the install token'
console.error(revokeError)
} finally {
bootstrapbusy.value = false
}
}
function applyBinaryStatus(binkey, current, versions) {
const binary = binaryFor(binkey)
if (!binary) return
@@ -609,8 +1033,9 @@ function applyBinaryStatus(binkey, current, versions) {
async function regenerate() {
if (key.value &&
!window.confirm('Regenerate the key? The current key stops working '
+ 'immediately and every agent must be reconfigured.')) {
!window.confirm('Regenerate the shared key? The current key stops working '
+ `immediately. ${notEnrolledPhrase()} will need the new key before they `
+ 'can report or enroll. Enrolled PCs are not affected.')) {
return
}
busy.value = true
@@ -637,8 +1062,9 @@ async function regenerate() {
}
async function revoke() {
if (!window.confirm('Revoke the key? All endpoint-agent reporting stops '
+ 'until a new key is generated.')) {
if (!window.confirm('Revoke the shared key? '
+ `${notEnrolledPhrase()} will lose access: they stop reporting and cannot `
+ 'enroll until a new key is generated. Enrolled PCs keep working.')) {
return
}
busy.value = true
@@ -713,10 +1139,17 @@ async function buildSiteAgent() {
agentbusy.value = true
agentmessage.value = ''
agenterror.value = ''
agentwarning.value = ''
try {
const form = new FormData()
form.append('baseurl', agentbaseurl.value)
form.append('collectorkey', agentcollectorkey.value)
if (agentincludesharedkey.value) {
form.append('collectorkey', agentcollectorkey.value)
}
form.append('includesharedkey', agentincludesharedkey.value ? 'true' : 'false')
if (agentnextserverpin.value) {
form.append('nextserverpin', agentnextserverpin.value)
}
form.append('cadenceseconds', agentcadenceseconds.value)
form.append('bundlecert', agentbundlecert.value ? 'true' : 'false')
if (agentcertfile.value) {
@@ -738,6 +1171,7 @@ async function buildSiteAgent() {
link.remove()
window.URL.revokeObjectURL(url)
agentmessage.value = 'Site agent built.'
agentwarning.value = response.headers?.['x-shopdb-build-warning'] || ''
} catch (buildError) {
if (buildError.response?.status === 400 && buildError.response?.data) {
agenterror.value = await readBlobError(buildError.response.data)
@@ -756,7 +1190,8 @@ async function detectVersion(binkey, file) {
const form = new FormData()
form.append('file', file)
const response = await api.post(
`/endpoints/siteagent/binaries/${binkey}/detect`, form)
`/endpoints/siteagent/binaries/${binkey}/detect`, form,
{ headers: { 'Content-Type': 'multipart/form-data' } })
return response.data.data?.version || ''
}
@@ -789,7 +1224,7 @@ function onLegacyFileChange(binkey, event) {
async function uploadBinary(binkey) {
const binary = binaryFor(binkey)
if (!binary || !binary.file || !binary.version) return
if (!binary || !binary.file) return
binary.busy = true
binary.message = ''
binary.error = ''
@@ -798,7 +1233,8 @@ async function uploadBinary(binkey) {
form.append('version', binary.version)
form.append('file', binary.file)
const response = await api.post(
`/endpoints/siteagent/binaries/${binkey}`, form)
`/endpoints/siteagent/binaries/${binkey}`, form,
{ headers: { 'Content-Type': 'multipart/form-data' } })
const data = response.data.data || {}
applyBinaryStatus(binkey, data.current, data.versions)
binary.version = ''
@@ -817,7 +1253,7 @@ async function uploadBinary(binkey) {
async function uploadLegacy(binkey) {
const binary = binaryFor(binkey)
if (!binary || !binary.legacyfile || !binary.legacyversion) return
if (!binary || !binary.legacyfile) return
binary.busy = true
binary.message = ''
binary.error = ''
@@ -827,7 +1263,8 @@ async function uploadLegacy(binkey) {
form.append('file', binary.legacyfile)
form.append('makecurrent', 'false')
const response = await api.post(
`/endpoints/siteagent/binaries/${binkey}`, form)
`/endpoints/siteagent/binaries/${binkey}`, form,
{ headers: { 'Content-Type': 'multipart/form-data' } })
const data = response.data.data || {}
applyBinaryStatus(binkey, data.current, data.versions)
binary.legacyversion = ''
@@ -903,13 +1340,39 @@ function editConfiguration() {
}
.secret-value { padding: 0.5rem; background: var(--color-surface-2, #1e1e1e); }
.settings-section { margin-bottom: 2rem; }
.enrollment-counts {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.5rem;
margin-bottom: 0.5rem;
}
.enrollment-count { font-size: 0.9rem; }
.progress-track {
height: 6px;
border-radius: 3px;
background: var(--color-border, #333);
overflow: hidden;
margin-bottom: 0.5rem;
}
.progress-fill {
height: 100%;
background: var(--color-success, #4caf50);
}
.settings-section > h3 { margin-bottom: 0.25rem; }
.info-list { margin: 0.25rem 0 0; padding-left: 1.25rem; }
.info-list li { margin-bottom: 0.5rem; }
/* Each agent is its own nested card inside the collapsible, so the two
binaries read as distinct units instead of one continuous wall of
controls separated by a hairline rule. */
.binary-block {
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border, #333);
margin-top: 1rem;
padding: 1rem;
}
.binary-block:first-of-type { margin-top: 0.75rem; }
.binary-title {
margin: 0 0 0.5rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-border, #333);
}
.binary-upload {
display: flex;
@@ -969,6 +1432,23 @@ function editConfiguration() {
padding: 0.3rem 0.5rem;
border-bottom: 1px solid var(--color-border, #333);
}
.bootstrap-block {
margin-top: 1rem;
padding: 1rem;
}
.bootstrap-inputs {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 0.5rem;
}
.bootstrap-inputs .form-control { max-width: 9rem; }
.bootstrap-command-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.bootstrap-command-row .form-control { flex: 1; }
.current-tag {
font-size: 0.8rem;
color: var(--color-success, #4caf50);

View File

@@ -1,327 +0,0 @@
<template>
<div>
<div class="page-header">
<h2>Delivery Test Bench</h2>
</div>
<!--
TEMPORARY / DEV ONLY. This view exists to exercise the update_app and
run_script delivery path end to end and to take notes for the real UI
(owned by Cameron). It is gated on endpoints.deploy. Remove it once the
production delivery UI lands. Nothing here is a supported operator surface.
-->
<div class="card dev-note">
<strong>Development test vehicle.</strong>
This bench pushes an allowlisted delivery command to one endpoint and reads
back the result. The agent only runs a script name it already holds in its
local allowlist, and only installs a ShopDB-hosted package pinned by digest.
Both actions require the <code>endpoints.deploy</code> permission.
</div>
<div v-if="!candeploy" class="card load-error">
Your account lacks the <code>endpoints.deploy</code> permission, so no
delivery command can be queued. Ask an administrator to grant it.
</div>
<template v-else>
<!-- 1. Pick the target endpoint -->
<div class="card bench-section">
<h3>1. Target endpoint</h3>
<div class="bench-row">
<input
v-model="endpointSearch"
type="text"
class="form-control"
placeholder="Search hostname or asset..."
@keyup.enter="loadEndpoints"
/>
<button class="btn btn-secondary btn-sm" :disabled="busy" @click="loadEndpoints">Search</button>
</div>
<select v-model="selectedAssetId" class="form-control" :disabled="busy">
<option value="">Select an endpoint...</option>
<option v-for="row in endpoints" :key="row.assetid" :value="row.assetid">
{{ row.hostname }} (asset {{ row.assetid }})
</option>
</select>
</div>
<!-- 2. Run a ShopDB-hosted script from the library -->
<div class="card bench-section">
<h3>2. Run script</h3>
<p class="bench-hint">
Pick a script from the ShopDB script library. The agent fetches the
script body by digest with its collector credential and runs it in the
script's configured run context (system or the interactive user).
</p>
<div class="bench-row">
<select v-model="selectedScriptId" class="form-control" :disabled="busy">
<option value="">Select script...</option>
<option v-for="script in scripts" :key="script.id" :value="script.id">
{{ script.scriptname }} ({{ script.runcontext }})
<template v-if="!script.sha256"> (no body - upload first)</template>
</option>
</select>
<button
class="btn btn-warning btn-sm"
:disabled="busy || !selectedAssetId || !selectedScriptId"
@click="pushScript"
>
Push run_script
</button>
</div>
</div>
<!-- 3. Install/update an application -->
<div class="card bench-section">
<h3>3. Install/update application</h3>
<p class="bench-hint">
Pick an application, then the app-level install source (the installer
uploaded on the application itself). The agent fetches the pinned
package by digest with its collector credential and runs it silently.
</p>
<div class="bench-row">
<select v-model="selectedAppId" class="form-control" :disabled="busy" @change="loadSources">
<option value="">Select application...</option>
<option v-for="app in applications" :key="app.appid" :value="app.appid">
{{ app.appname }}
</option>
</select>
<select v-model="selectedSource" class="form-control" :disabled="busy || !selectedAppId">
<option value="">Select install source...</option>
<option v-for="source in installSources" :key="source.value" :value="source.value">
{{ source.label }}
</option>
</select>
<button
class="btn btn-warning btn-sm"
:disabled="busy || !selectedAssetId || !selectedSource"
@click="pushUpdateApp"
>
Push update_app
</button>
</div>
</div>
<!-- 4. Command results (the Web UI log pane) -->
<div class="card bench-section">
<div class="bench-row bench-row-between">
<h3>4. Command log (Web UI side)</h3>
<button class="btn btn-secondary btn-sm" :disabled="busy || !selectedAssetId" @click="loadCommands">
Refresh
</button>
</div>
<p v-if="!selectedAssetId" class="bench-hint">Select an endpoint to see its recent commands.</p>
<table v-else-if="commands.length" class="table bench-table">
<thead>
<tr>
<th>When</th>
<th>Action</th>
<th>Target</th>
<th>Status</th>
<th>Result</th>
</tr>
</thead>
<tbody>
<tr v-for="command in commands" :key="command.commandid">
<td>{{ formatWhen(command.createdat) }}</td>
<td>{{ command.action }}</td>
<td>{{ command.target || '-' }}</td>
<td><span class="status-badge" :class="statusClass(command.status)">{{ command.status }}</span></td>
<td class="result-cell">{{ command.resultmessage || '-' }}</td>
</tr>
</tbody>
</table>
<p v-else class="bench-hint">No commands recorded for this endpoint yet.</p>
</div>
</template>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { endpointsApi, applicationsApi, scriptsApi } from '@/api'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
const authStore = useAuthStore()
const toast = useToast()
// Gated in the UI for convenience only; every enqueue is re-checked server-side.
const candeploy = computed(() => authStore.hasPermission('endpoints.deploy'))
const busy = ref(false)
const endpointSearch = ref('')
const endpoints = ref([])
const selectedAssetId = ref('')
const scripts = ref([])
const selectedScriptId = ref('')
const applications = ref([])
const selectedAppId = ref('')
const installSources = ref([])
const selectedSource = ref('')
const commands = ref([])
onMounted(() => {
if (!candeploy.value) return
loadEndpoints()
loadApplications()
loadScripts()
})
async function loadEndpoints() {
busy.value = true
try {
const params = { perpage: 100 }
if (endpointSearch.value) params.search = endpointSearch.value
const response = await endpointsApi.list(params)
endpoints.value = response.data.data || []
} catch (error) {
toast.error(apiError(error, 'Could not load endpoints'))
endpoints.value = []
} finally {
busy.value = false
}
}
async function loadApplications() {
try {
applications.value = await applicationsApi.listAll()
} catch (error) {
applications.value = []
}
}
async function loadScripts() {
try {
scripts.value = await scriptsApi.listAll()
} catch (error) {
scripts.value = []
}
}
// Build the install-source options for the chosen application: the app-level
// source (the installer uploaded on the application itself). The wire target is
// 'app:<id>', which the server resolves into a pinned delivery descriptor.
async function loadSources() {
selectedSource.value = ''
installSources.value = []
if (!selectedAppId.value) return
const sources = []
const app = applications.value.find(row => String(row.appid) === String(selectedAppId.value))
if (app && app.installpath) {
sources.push({
value: `app:${app.appid}`,
label: app.installersha256
? 'App-level installer'
: 'App-level installer (no digest - upload package first)',
})
}
installSources.value = sources
}
async function pushScript() {
await enqueue('run_script', `script:${selectedScriptId.value}`, 'run_script')
}
async function pushUpdateApp() {
await enqueue('update_app', selectedSource.value, 'update_app')
}
async function enqueue(action, target, label) {
if (!selectedAssetId.value) return
busy.value = true
try {
await endpointsApi.enqueueCommand(selectedAssetId.value, action, target)
toast.success(`${label} queued`)
await loadCommands()
} catch (error) {
// 409 = a command is already in flight for this endpoint (at-most-one queue).
const status = error.response?.status
if (status === 409) {
toast.info('A command is already in flight for this endpoint')
} else {
toast.error(apiError(error, `${label} failed`))
}
} finally {
busy.value = false
}
}
async function loadCommands() {
if (!selectedAssetId.value) return
try {
const response = await endpointsApi.listCommands(selectedAssetId.value)
commands.value = response.data.data || []
} catch (error) {
commands.value = []
}
}
function formatWhen(iso) {
if (!iso) return '-'
const date = new Date(iso)
return Number.isNaN(date.getTime()) ? iso : date.toLocaleString()
}
function statusClass(status) {
if (status === 'succeeded') return 'ok'
if (status === 'failed') return 'bad'
if (status === 'expired') return 'bad'
if (status === 'running') return 'warn'
return ''
}
</script>
<style scoped>
.dev-note {
border-left: 3px solid var(--warning, #d6a012);
}
.bench-section h3 {
margin-top: 0;
}
.bench-row {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
align-items: center;
}
.bench-row-between {
justify-content: space-between;
}
.bench-hint {
color: var(--text-light);
font-size: 0.9rem;
}
.bench-table {
width: 100%;
}
.result-cell {
max-width: 32rem;
white-space: pre-wrap;
word-break: break-word;
}
.status-badge {
padding: 0.1rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.85rem;
}
.status-badge.ok {
color: var(--success, #2e7d32);
}
.status-badge.bad {
color: var(--danger, #c62828);
}
.status-badge.warn {
color: var(--warning, #d6a012);
}
.load-error {
color: var(--danger, #c62828);
}
</style>

View File

@@ -41,6 +41,12 @@
<option value="quiet">Quiet</option>
<option value="never">Never reported</option>
</select>
<select v-model="credentialstate" class="form-control fleet-filter" @change="applyFilters">
<option value="">All credentials</option>
<option v-for="option in credentialOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<select v-model="computertypeid" class="form-control fleet-filter" @change="applyFilters">
<option value="">All PC types</option>
<option v-for="type in computertypes" :key="type.computertypeid" :value="type.computertypeid">
@@ -61,6 +67,16 @@
</select>
</div>
<!-- Placeholder for the staged agent rollout. Shown only to operators who
hold the grant it will require, so it reads as "not built yet" to the
people waiting on it rather than as a missing permission to everyone
else. This page is due a refactor; the utility lands with that work. -->
<div v-if="canagentupdate" class="rollout-note">
Bulk Agent Update utility coming soon. Until then, agents update through
the existing installer push, and an endpoint behind the published build is
flagged on its own detail page.
</div>
<!-- Actions live here rather than per row. A tenth column of buttons
pushed itself off the edge of a nowrap table, and selecting one row
costs a single click while giving one command path instead of two. -->
@@ -87,10 +103,13 @@
<input type="checkbox" :checked="allselected" @change="toggleAll" title="Select all on this page" />
</th>
<th>Status</th>
<th>Alias</th>
<th>Hostname</th>
<th>Machine</th>
<th>PC Type</th>
<th>Health</th>
<th>Agent Version</th>
<th>Credential</th>
<th>Logged-in User</th>
<th>Uptime</th>
<th>Last Reported</th>
@@ -110,6 +129,7 @@
<td>
<span class="status-dot" :class="item.status"></span>{{ statusLabel(item.status) }}
</td>
<td>{{ item.alias || '-' }}</td>
<td>{{ item.hostname || '-' }}</td>
<td>
<!-- What stops if this PC is dead. On a shop floor that is
@@ -123,12 +143,18 @@
class="health-pill" :class="item.health.level">{{ item.health.label }}</span>
<span v-else>-</span>
</td>
<td>{{ item.agentversion || '-' }}</td>
<td>
<span v-if="item.credentialstate" class="credential-chip"
:class="item.credentialstate">{{ credentialLabel(item.credentialstate) }}</span>
<span v-else>-</span>
</td>
<td>{{ item.loggedinuser || '-' }}</td>
<td>{{ item.uptimedisplay || '-' }}</td>
<td>{{ formatRelative(item.lastreportedat) }}</td>
</tr>
<tr v-if="endpoints.length === 0">
<td :colspan="cancommand ? 9 : 8" style="text-align: center; color: var(--text-light);">
<td :colspan="cancommand ? 12 : 11" style="text-align: center; color: var(--text-light);">
No endpoints found
</td>
</tr>
@@ -178,6 +204,17 @@ const sort = ref('status')
const summary = ref(null)
const criticalcount = ref(0)
const computertypeid = ref('')
// Which credential the PC reports with, computed server-side so the filter
// holds across pages. Read-only here: revoke and re-enrollment live on the PC
// detail page and the Enrollment settings page.
const credentialstate = ref('')
const credentialOptions = [
{ value: 'enrolled', label: 'Enrolled' },
{ value: 'pending', label: 'Pending' },
{ value: 'reenrollblocked', label: 'Re-enroll blocked' },
{ value: 'sharedkey', label: 'Shared key (not enrolled)' },
{ value: 'notreporting', label: 'Not reporting' }
]
const locationid = ref('')
const problemsonly = ref(false)
const computertypes = ref([])
@@ -190,6 +227,9 @@ const toast = useToast()
// Gated in the UI for convenience only; every enqueue is re-checked server-side.
const cancommand = computed(() => authStore.hasPermission('endpoints.command'))
const canrestart = computed(() => authStore.hasPermission('endpoints.restart'))
// Reserved for the staged rollout. Agent replacement is the control plane
// modifying itself, so it stays separate from ordinary app deployment.
const canagentupdate = computed(() => authStore.hasPermission('endpoints.agentupdate'))
const allselected = computed(() =>
endpoints.value.length > 0 && selected.value.length === endpoints.value.length)
@@ -274,23 +314,26 @@ onMounted(() => {
async function loadFilterOptions() {
// listAll, not list: perpage is clamped to 100 server-side, so a long type or
// location list would silently lose its tail in a dropdown.
try {
computertypes.value = await computersApi.types.listAll()
} catch (error) {
computertypes.value = []
}
try {
locations.value = await locationsApi.listAll()
} catch (error) {
locations.value = []
}
// Issued together: neither list feeds the other, so awaiting them in sequence
// only added a round trip to first paint. Each keeps its own fallback so one
// failing dropdown does not empty the other.
const [types, places] = await Promise.all([
computersApi.types.listAll().catch(() => []),
locationsApi.listAll().catch(() => [])
])
computertypes.value = types
locations.value = places
}
async function loadSummary() {
try {
const response = await endpointsApi.dashboardSummary()
// Issued together: the stability call does not depend on the summary, so
// sequencing them just stacked the slower request behind the faster one.
const [response, stability] = await Promise.all([
endpointsApi.dashboardSummary(),
endpointsApi.dashboardStability()
])
summary.value = response.data.data || null
const stability = await endpointsApi.dashboardStability()
criticalcount.value = (stability.data.data || []).length
} catch (error) {
// The strip is a convenience, not the page. If it fails the table still
@@ -309,6 +352,7 @@ async function loadEndpoints() {
if (search.value) params.search = search.value
if (status.value) params.status = status.value
if (sort.value) params.sort = sort.value
if (credentialstate.value) params.credentialstate = credentialstate.value
if (computertypeid.value) params.computertypeid = computertypeid.value
if (locationid.value) params.locationid = locationid.value
@@ -348,6 +392,11 @@ function openPc(item) {
}
}
function credentialLabel(value) {
const option = credentialOptions.find(entry => entry.value === value)
return option ? option.label : value
}
function statusLabel(value) {
return { reporting: 'Reporting', quiet: 'Quiet', never: 'Never' }[value] || '-'
}
@@ -374,6 +423,7 @@ function setStatus(value) {
function clearFilters() {
status.value = ''
credentialstate.value = ''
problemsonly.value = false
applyFilters()
}
@@ -495,6 +545,22 @@ function changePerPage(newPerPage) {
.fleet-filter { max-width: 200px; }
.credential-chip {
display: inline-block;
font-size: 0.78rem;
font-weight: 500;
padding: 0.1rem 0.5rem;
border-radius: 10px;
background: var(--bg);
color: var(--text-light);
white-space: nowrap;
}
.credential-chip.enrolled { color: var(--success-dark); }
.credential-chip.pending { color: var(--warning-dark); }
.credential-chip.reenrollblocked { color: var(--danger); }
[data-theme="dark"] .credential-chip.enrolled { color: var(--success); }
[data-theme="dark"] .credential-chip.pending { color: var(--warning); }
.bulk-bar {
display: flex;
align-items: center;
@@ -508,6 +574,18 @@ function changePerPage(newPerPage) {
.bulk-count { font-size: 0.9rem; margin-right: 0.25rem; }
/* Deliberately quieter than .bulk-bar: this announces something that does not
exist yet, so it must not compete with controls that actually do work. */
.rollout-note {
padding: 0.6rem 0.9rem;
margin-bottom: 0.75rem;
font-size: 0.9rem;
color: var(--text-light);
background: var(--bg-card);
border: 1px dashed var(--border);
border-radius: 0.25rem;
}
.select-cell { width: 1%; }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,640 @@
<template>
<div>
<div class="page-header">
<h2>Server Certificate</h2>
</div>
<p class="setting-description">
The TLS certificate PCs see when they connect to this site, whether it is
served by IIS or by the Docker stack. Its public key pin goes into the
Set-ShopDBServerPins.ps1 entry, and the certificate itself into the
trusted root entry for agents older than 1.20.
</p>
<!-- Certificate status -->
<section class="settings-section">
<h3>Current certificate</h3>
<div class="card form-card">
<div v-if="message" class="settings-success">{{ message }}</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div v-if="loading" class="field-hint">Loading certificate details...</div>
<template v-else-if="cert">
<div v-if="cert.expires_in_days < 30" class="warning-banner">
<strong>Warning:</strong> Certificate expires in {{ cert.expires_in_days }} days
({{ formatDate(cert.not_after) }}). Renew soon to avoid service disruption.
</div>
<div class="form-group">
<label>Common Name (CN)</label>
<p class="field-hint mono">{{ cert.subject_cn }}</p>
</div>
<div class="form-group">
<label>Subject Alternative Names</label>
<p class="field-hint mono">{{ cert.san_list.join(', ') }}</p>
</div>
<div class="form-group">
<label>Valid From</label>
<p class="field-hint">{{ formatDate(cert.not_before) }}</p>
</div>
<div class="form-group">
<label>Valid Until</label>
<p class="field-hint" :class="{ 'expiry-warning': cert.expires_in_days < 30 }">
{{ formatDate(cert.not_after) }}
<span v-if="cert.expires_in_days >= 0">
({{ cert.expires_in_days }} days remaining)
</span>
<span v-else class="expired-text">
(EXPIRED {{ Math.abs(cert.expires_in_days) }} days ago)
</span>
</p>
</div>
<div class="form-group">
<label>Thumbprint (SHA256)</label>
<p class="field-hint mono">{{ cert.thumbprint }}</p>
<button class="btn btn-secondary btn-sm" @click="copyThumbprint">
{{ thumbprintCopied ? 'Copied' : 'Copy thumbprint' }}
</button>
<p class="field-hint">
Use this value for ServerCertThumbprint in agent appsettings.json
to enable certificate pinning (prevents MITM attacks).
</p>
</div>
<div class="form-group spki-pin">
<label>Public key pin (SPKI SHA-256)</label>
<p v-if="cert.spkisha256" class="field-hint mono">{{ cert.spkisha256 }}</p>
<p v-else class="field-hint">Not reported by this server.</p>
<button
v-if="cert.spkisha256"
class="btn btn-secondary btn-sm"
@click="copyPin"
>
{{ pinCopied ? 'Copied' : 'Copy pin' }}
</button>
<p class="field-hint">
Paste this into the GE-Enforce Set-ShopDBServerPins.ps1 entry
(-Pins) so agents 1.20 and later trust only this server's key.
It survives a renewal that keeps the same private key.
</p>
</div>
<div class="form-group">
<label>Issuer</label>
<p class="field-hint mono">{{ cert.issuer }}</p>
<span v-if="cert.is_self_signed" class="badge">Self-signed</span>
</div>
</template>
<template v-else>
<p class="field-hint">No certificate file found at certs/server.crt</p>
</template>
</div>
</section>
<!-- Live check: what the public address really presents -->
<section class="settings-section">
<h3>What PCs receive</h3>
<p class="field-hint">
Connects to this site's base URL and reads the certificate it presents,
the way a PC does. An IIS binding or a proxy in front of the app can
present a different certificate than the file above.
</p>
<div class="card form-card">
<div class="form-group live-check">
<label>Presented public key pin (SPKI SHA-256)</label>
<p v-if="liveLoading" class="field-hint">Checking {{ live?.url || 'the site address' }}...</p>
<template v-else-if="live">
<p v-if="!live.configured" class="field-hint live-unconfigured">
No site base URL is set (site_base_url, under Settings, Email /
SMTP, or in the setup wizard), so there is no address to check.
</p>
<template v-else>
<p class="field-hint">Checked <span class="mono">{{ live.url }}</span></p>
<p v-if="live.error" class="live-error">{{ live.error }}</p>
<template v-else>
<p class="field-hint mono" :class="live.matchesfile ? 'live-match' : 'live-mismatch'">
{{ live.presentedspkisha256 }}
</p>
<p v-if="live.matchesfile" class="live-match live-status">
Matches the pin of the certificate file above.
</p>
<div v-else class="live-warning">
<strong>The site presents a different certificate than the file above.</strong>
PCs will be pinned to the certificate they actually receive; use the
presented pin.
<div class="live-pins">
<span>File pin:</span>
<span class="mono">{{ cert?.spkisha256 || 'no certificate file' }}</span>
<span>Presented pin:</span>
<span class="mono">{{ live.presentedspkisha256 }}</span>
<span>Presented thumbprint:</span>
<span class="mono">{{ live.presentedsha1thumbprint }}</span>
</div>
</div>
</template>
</template>
</template>
<button class="btn btn-secondary btn-sm live-recheck" :disabled="liveLoading" @click="loadLive">
{{ liveLoading ? 'Checking...' : 'Recheck' }}
</button>
</div>
</div>
</section>
<!-- Generate self-signed certificate -->
<section class="settings-section">
<h3>Generate self-signed certificate</h3>
<p class="field-hint">
Create a new self-signed certificate for development or air-gapped sites.
The certificate will be valid for 1 year (365 days).
</p>
<div class="card form-card">
<div class="form-group">
<label for="cn">Common Name (CN)</label>
<input
id="cn"
v-model="generateForm.cn"
type="text"
class="form-control"
placeholder="hostname or IP"
/>
<p class="field-hint">
Hostname or IP that agents use to connect, exactly as it appears in
the agent's base URL.
</p>
</div>
<div class="form-group">
<label for="san">Subject Alternative Names (SANs)</label>
<input
id="san"
v-model="generateForm.san"
type="text"
class="form-control"
placeholder="DNS:hostname,IP:address"
/>
<p class="field-hint">
Comma-separated list of DNS:hostname and IP:address entries. Include
every name and address a bay may use to reach this server; a name
that is missing here fails certificate validation on the endpoint.
</p>
</div>
<div class="form-group">
<label for="days">Validity (days)</label>
<input
id="days"
v-model.number="generateForm.days"
type="number"
class="form-control"
min="1"
max="3650"
/>
<p class="field-hint">
Certificate lifetime (recommended: 365 for self-signed)
</p>
</div>
<div class="warning-banner" v-if="cert">
<strong>Warning:</strong> Generating a new certificate will replace the current one.
Agents using ServerCertThumbprint pinning will reject connections until you update
their appsettings.json with the new thumbprint. A new certificate also has a new
key, so agents with ServerPins set will reject it until the pin entry carries the
new pin as well. Generating here gives no window to do that; to rotate without an
outage, compute the pin of a prepared certificate with Get-ShopDBServerPin.ps1, add
it to the pin entry alongside the old one, and upload that certificate below.
</div>
<div class="button-row">
<button
class="btn btn-primary"
@click="generateCertificate"
:disabled="busy || !generateForm.cn"
>
{{ busy ? 'Generating...' : 'Generate self-signed certificate' }}
</button>
</div>
</div>
</section>
<!-- Upload certificate -->
<section class="settings-section">
<h3>Upload certificate</h3>
<p class="field-hint">
Upload a certificate from a trusted CA or internal PKI. You must provide
both the certificate (.crt or .pem) and private key (.key).
</p>
<div class="card form-card">
<div class="form-group">
<label for="cert-file">Certificate file</label>
<input
id="cert-file"
type="file"
accept=".crt,.pem"
@change="onCertFileChange"
class="form-control"
/>
<p class="field-hint">
PEM-encoded certificate (.crt or .pem)
</p>
</div>
<div class="form-group">
<label for="key-file">Private key file</label>
<input
id="key-file"
type="file"
accept=".key,.pem"
@change="onKeyFileChange"
class="form-control"
/>
<p class="field-hint">
PEM-encoded private key (.key or .pem, unencrypted)
</p>
</div>
<div class="warning-banner" v-if="cert">
<strong>Warning:</strong> Uploading a new certificate will replace the current one.
Nginx will be restarted automatically.
</div>
<div class="button-row">
<button
class="btn btn-primary"
@click="uploadCertificate"
:disabled="busy || !uploadForm.certFile || !uploadForm.keyFile"
>
{{ busy ? 'Uploading...' : 'Upload certificate' }}
</button>
</div>
</div>
</section>
<!-- Documentation -->
<section class="settings-section">
<div class="card form-card">
<details class="collapsible">
<summary><h4>Certificate requirements</h4></summary>
<ul class="field-hint info-list">
<li>
<strong>Format:</strong> PEM-encoded X.509 certificate and RSA/ECDSA private key
</li>
<li>
<strong>Private key:</strong> Must be unencrypted (no passphrase)
</li>
<li>
<strong>Common Name:</strong> Must match the hostname agents use to connect
</li>
<li>
<strong>Subject Alternative Names:</strong> Include all hostnames and IPs agents might use
</li>
<li>
<strong>Trust:</strong> Self-signed certs require ServerCertThumbprint pinning on agents,
or agents must disable validation (development only)
</li>
</ul>
</details>
<details class="collapsible">
<summary><h4>Agent certificate pinning</h4></summary>
<p class="field-hint">
To prevent MITM attacks with self-signed certificates, configure agents to validate
the server certificate thumbprint:
</p>
<ol class="field-hint info-list">
<li>Copy the SHA256 thumbprint from the "Current certificate" section above</li>
<li>Update each agent's appsettings.json:
<pre class="code-block">{
"ServerCertThumbprint": "AA:BB:CC:..."
}</pre>
</li>
<li>Restart the ShopDbEndpointAgent service on each agent</li>
</ol>
<p class="field-hint">
<strong>Important:</strong> When you renew the server certificate, you must update
ServerCertThumbprint on all agents before the old certificate expires, or agents
will reject connections.
</p>
</details>
<details class="collapsible">
<summary><h4>Air-gapped deployment</h4></summary>
<p class="field-hint">
For air-gapped sites, generate the certificate on the work PC and include it in
the deployment bundle:
</p>
<ol class="field-hint info-list">
<li>Generate certificate using this page (use site-specific CN/SANs)</li>
<li>Certificate files are saved to certs/ directory (already in git)</li>
<li>Commit and bundle the code (certificate included automatically)</li>
<li>Deploy bundle to site - nginx picks up certificate on startup</li>
</ol>
</details>
</div>
</section>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import api from '@/api'
const loading = ref(false)
const busy = ref(false)
const message = ref('')
const error = ref('')
const cert = ref(null)
const live = ref(null)
const liveLoading = ref(false)
const thumbprintCopied = ref(false)
const pinCopied = ref(false)
// No baked host or address (ADR-015: no site literals in product code). One
// site's LAN address was shipped here as the default SAN, which every other
// site would have generated a certificate for. The operator types what this
// server is actually reached by; the form refuses to submit empty.
const generateForm = ref({
cn: '',
san: '',
days: 365
})
const uploadForm = ref({
certFile: null,
keyFile: null
})
onMounted(() => {
loadCertificate()
loadLive()
})
// What the site's public address actually presents. A proxy or IIS binding
// can serve a different certificate than the file this page manages, and a PC
// pins what it receives.
async function loadLive() {
liveLoading.value = true
try {
const response = await api.get('/endpoints/settings/server-cert/live')
live.value = response.data.data || null
} catch (err) {
live.value = {
configured: true,
url: live.value?.url || '',
error: `Could not run the check: ${err.message}`
}
} finally {
liveLoading.value = false
}
}
async function loadCertificate() {
loading.value = true
error.value = ''
try {
const response = await api.get('/endpoints/settings/server-cert')
if (response.data.status === 'success') {
cert.value = response.data.data
} else {
error.value = response.data.data?.error?.message || 'Failed to load certificate'
}
} catch (err) {
error.value = `Failed to load certificate: ${err.message}`
} finally {
loading.value = false
}
}
async function generateCertificate() {
if (!generateForm.value.cn) {
error.value = 'Common Name is required'
return
}
if (!confirm(
'Generate a new self-signed certificate? This will replace the current certificate. ' +
'Agents using thumbprint pinning will need to be reconfigured.'
)) {
return
}
busy.value = true
error.value = ''
message.value = ''
try {
const response = await api.post('/endpoints/settings/server-cert/generate', generateForm.value)
if (response.data.status === 'success') {
message.value = 'Certificate generated successfully. Nginx is restarting...'
await loadCertificate()
loadLive()
} else {
error.value = response.data.data?.error?.message || 'Failed to generate certificate'
}
} catch (err) {
error.value = `Failed to generate certificate: ${err.message}`
} finally {
busy.value = false
}
}
function onCertFileChange(event) {
uploadForm.value.certFile = event.target.files[0]
}
function onKeyFileChange(event) {
uploadForm.value.keyFile = event.target.files[0]
}
async function uploadCertificate() {
if (!uploadForm.value.certFile || !uploadForm.value.keyFile) {
error.value = 'Both certificate and private key files are required'
return
}
if (!confirm(
'Upload new certificate? This will replace the current certificate and restart nginx.'
)) {
return
}
busy.value = true
error.value = ''
message.value = ''
try {
const formData = new FormData()
formData.append('cert', uploadForm.value.certFile)
formData.append('key', uploadForm.value.keyFile)
const response = await api.post('/endpoints/settings/server-cert/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
if (response.data.status === 'success') {
message.value = 'Certificate uploaded successfully. Nginx is restarting...'
await loadCertificate()
loadLive()
// Clear file inputs
document.getElementById('cert-file').value = ''
document.getElementById('key-file').value = ''
uploadForm.value.certFile = null
uploadForm.value.keyFile = null
} else {
error.value = response.data.data?.error?.message || 'Failed to upload certificate'
}
} catch (err) {
error.value = `Failed to upload certificate: ${err.message}`
} finally {
busy.value = false
}
}
function copyThumbprint() {
navigator.clipboard.writeText(cert.value.thumbprint)
thumbprintCopied.value = true
setTimeout(() => {
thumbprintCopied.value = false
}, 2000)
}
function copyPin() {
navigator.clipboard.writeText(cert.value.spkisha256)
pinCopied.value = true
setTimeout(() => {
pinCopied.value = false
}, 2000)
}
function formatDate(dateString) {
if (!dateString) return 'N/A'
const date = new Date(dateString)
return date.toLocaleString()
}
</script>
<style scoped>
.warning-banner {
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: 4px;
padding: 12px;
margin-bottom: 16px;
color: #856404;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.warning-banner {
background: #664d03;
border-color: #ffca2c;
color: #ffecb5;
}
}
.expiry-warning {
color: #d63384;
font-weight: 600;
}
.expired-text {
color: #dc3545;
font-weight: bold;
}
.badge {
display: inline-block;
padding: 4px 8px;
background: #6c757d;
color: white;
border-radius: 4px;
font-size: 12px;
margin-left: 8px;
}
.mono {
font-family: 'Courier New', monospace;
font-size: 13px;
}
.btn-sm {
padding: 4px 12px;
font-size: 13px;
margin-top: 8px;
}
.code-block,
pre.code-block {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 12px;
margin: 8px 0;
font-family: 'Courier New', monospace;
font-size: 13px;
overflow-x: auto;
color: #212529;
white-space: pre;
}
/* Dark mode support for code blocks */
@media (prefers-color-scheme: dark) {
.code-block {
background: #2b2b2b !important;
border-color: #444 !important;
color: #e0e0e0 !important;
}
/* Ensure pre tags inside lists also get dark mode */
.info-list .code-block,
.field-hint .code-block,
pre.code-block {
background: #2b2b2b !important;
border-color: #444 !important;
color: #e0e0e0 !important;
}
}
.info-list {
margin-left: 20px;
}
.live-match { color: var(--success-dark); }
.live-mismatch { color: var(--danger); }
.live-status { font-size: 13px; margin: 4px 0; }
[data-theme="dark"] .live-match { color: var(--success); }
.live-error,
.live-warning {
margin: 8px 0;
padding: 10px 12px;
border-left: 3px solid var(--danger);
background: rgba(220, 53, 69, 0.1);
color: var(--danger);
font-size: 13px;
}
.live-pins {
display: grid;
grid-template-columns: max-content 1fr;
gap: 4px 12px;
margin-top: 8px;
word-break: break-all;
}
.info-list li {
margin-bottom: 12px;
}
</style>

View File

@@ -0,0 +1,443 @@
<template>
<div>
<div class="page-header">
<h2>System Scripts</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Script</button>
</div>
<p class="setting-description">
PowerShell the site agent can be told to run on an endpoint. A script is
stored here once, pinned by digest, and fetched by the agent at push time
so the bytes that run are the bytes that were reviewed.
</p>
<div class="warning-banner">
<strong>Every script here runs as SYSTEM.</strong>
There is no user context and no prompt: the agent is a service, so a
script cannot see a signed-in person's desktop, mapped drives, or
credentials. Scripts are intended to TRIGGER an already registered
scheduled task on the endpoint, which is what does the real work. Anything
written to act "as the logged-on user" will not behave the way it reads.
</div>
<details class="explainer">
<summary>How does this work?</summary>
<div class="explainer-body">
<h4>Delivery</h4>
<p>
The body is stored here and hashed. At push time the agent downloads it
with its own credential and checks the digest before running anything,
so the bytes that run are the bytes that were reviewed. The body never
travels on the command channel and is deleted from the endpoint after
the run.
</p>
<h4>Runs in</h4>
<p>
<strong>System</strong> is the agent's own service account: no desktop,
no mapped drives, no signed-in person's credentials.
<strong>User</strong> runs in the interactive session instead, and only
works when somebody is actually signed in.
</p>
<h4>Run in folder</h4>
<p>
The working directory for the run. Blank means the agent's staging
folder, which is right for a self-contained script. Set it when the
script calls a tool installed separately on the endpoint by relative
path. If the folder is missing the run fails outright rather than
running somewhere unintended.
</p>
<h4>When a line fails</h4>
<p>
This is the one worth understanding. PowerShell treats most errors as
<em>non-terminating</em>: it writes the error, carries on to the next
line, and still exits zero. Because the command's verdict comes from
that exit code, <strong>Continue</strong> means a script can report
success having accomplished nothing.
</p>
<p>
<strong>Stop</strong> makes the first error end the run, so the verdict
means what you would assume it means. It is not the default because it
changes how a script behaves: a body that tolerates a failing cleanup,
probes with a cmdlet that errors when something is absent, or loops
past bad items will start aborting partway through, leaving the
endpoint half-configured. Read the script first, then switch it on.
</p>
<h4>Where the output goes</h4>
<p>
Script output is always written to the agent log on the endpoint. It is
sent back to ShopDB only when the run fails, so routine output stays on
the machine that produced it. Long output is truncated and marked as
such.
</p>
<p class="hint">
In Stop mode the agent adds one line to the top of the script, so an
error reported on line N refers to line N-1 of the body shown here.
</p>
</div>
</details>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Runs in</th>
<th>On error</th>
<th>Body</th>
<th>Digest</th>
<th>Active</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="s in scripts" :key="s.scriptid">
<td>{{ s.scriptname }}</td>
<td class="cell-truncate" :title="s.scriptdescription">
{{ s.scriptdescription || '-' }}
</td>
<td class="cell-truncate mono" :title="s.workingdirectory">
{{ s.workingdirectory || 'Agent staging folder' }}
</td>
<td>
<span v-if="s.errormode === 'strict'" class="badge badge-secondary">
Stop on first error
</span>
<span v-else class="badge badge-warning" title="A failing line does not fail the command">
Continue
</span>
<span
v-if="s.captureoutput"
class="badge badge-secondary"
title="A successful run returns its output to ShopDB"
>
Returns output
</span>
</td>
<td>
<span v-if="s.hasbody" class="badge badge-secondary">{{ s.filename }}</span>
<span v-else class="badge badge-warning">No body - cannot run</span>
</td>
<td class="cell-truncate mono" :title="s.sha256">{{ shortdigest(s) }}</td>
<td>{{ s.isactive ? 'Yes' : 'No' }}</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(s)">Edit</button>
<button class="btn btn-danger btn-sm" @click="remove(s)">Delete</button>
</td>
</tr>
<tr v-if="scripts.length === 0">
<td colspan="8" style="text-align:center; color: var(--text-light);">
No scripts
</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div v-if="showModal" class="modal-overlay">
<div class="modal modal-lg">
<div class="modal-header">
<h3>{{ editing ? 'Edit Script' : 'Add Script' }}</h3>
</div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="warning-banner">
<strong>This script will run as SYSTEM on every endpoint it is
pushed to.</strong>
Write it to trigger a scheduled task that already exists on the
endpoint. It has no user session, so anything depending on a
person being signed in will not work.
</div>
<div class="form-group">
<label>Name *</label>
<input v-model="form.scriptname" type="text" class="form-control" required />
</div>
<div class="form-group">
<label>Description <span class="hint">(what it triggers, and why)</span></label>
<textarea v-model="form.scriptdescription" class="form-control" rows="3"></textarea>
</div>
<div class="form-group">
<label>
Script file <span class="hint">(.ps1, replaces the stored body)</span>
</label>
<input type="file" accept=".ps1" class="form-control" @change="pickfile" />
<p v-if="editing && editing.hasbody" class="hint">
Current: {{ editing.filename }}. Uploading replaces it and
restamps the digest.
</p>
<p v-else-if="editing" class="hint">
No body uploaded yet. A script without a body cannot be pushed.
</p>
</div>
<div class="form-group">
<label>
Run in folder
<span class="hint">(optional, endpoint-local absolute path)</span>
</label>
<input
v-model="form.workingdirectory"
type="text"
class="form-control mono"
placeholder="C:\Program Files\CorporateTools"
/>
<details class="field-help">
<summary>How this works</summary>
<p>
Leave blank to run from the agent's staging folder. Set this only
when the script calls a tool already installed on the endpoint by
relative path. The folder must exist on the endpoint or the push
fails rather than running somewhere else.
</p>
</details>
</div>
<div class="form-group">
<label>
When a line fails
<span class="hint">(how the run is judged)</span>
</label>
<select v-model="form.errormode" class="form-control">
<option value="default">Continue - report success if the script reaches the end</option>
<option value="strict">Stop on the first error and fail the command</option>
</select>
<details class="field-help">
<summary>How this works</summary>
<p>
PowerShell normally writes an error and carries on, and still
exits zero, so a script can report success having done none of
its work. Stop makes the first failure end the run and fail the
command. Leave this on Continue unless you have read the script
and know it does not rely on a step being allowed to fail.
</p>
</details>
</div>
<div class="form-group">
<label>
<input v-model="form.captureoutput" type="checkbox" />
Return output to ShopDB on success
</label>
<details class="field-help">
<summary>How this works</summary>
<p>
A failed run always sends its output back, so you can diagnose
it from the Run Scripts history without going to the machine.
A successful run does not, unless you tick this.
</p>
<p>
Leave it off for scripts whose output could contain anything
sensitive - account names, share paths, anything a Write-Host
left behind. Turn it on for scripts where seeing the output is
the only way to tell the run did real work. Either way the
full output is written to the agent log on the endpoint.
</p>
</details>
</div>
<div class="form-group">
<label>
<input v-model="form.isactive" type="checkbox" />
Active
</label>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { scriptsApi } from '@/api'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
const toast = useToast()
const scripts = ref([])
const loading = ref(true)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const pendingfile = ref(null)
const form = ref(emptyForm())
function emptyForm() {
return {
scriptname: '',
scriptdescription: '',
workingdirectory: '',
errormode: 'default',
captureoutput: false,
isactive: true
}
}
function shortdigest(s) {
return s.sha256 ? s.sha256.slice(0, 12) : '-'
}
async function loadData() {
loading.value = true
try {
const response = await scriptsApi.list({ active: 'false' })
scripts.value = response.data.data || []
} catch (err) {
toast.error(apiError(err, 'Failed to load scripts'))
} finally {
loading.value = false
}
}
function pickfile(event) {
pendingfile.value = event.target.files[0] || null
}
function openModal(script = null) {
error.value = ''
pendingfile.value = null
editing.value = script
form.value = script
? {
scriptname: script.scriptname,
scriptdescription: script.scriptdescription || '',
workingdirectory: script.workingdirectory || '',
errormode: script.errormode || 'default',
captureoutput: !!script.captureoutput,
isactive: script.isactive
}
: emptyForm()
showModal.value = true
}
function closeModal() {
showModal.value = false
editing.value = null
pendingfile.value = null
}
async function save() {
saving.value = true
error.value = ''
try {
const payload = {
scriptname: form.value.scriptname,
scriptdescription: form.value.scriptdescription,
workingdirectory: form.value.workingdirectory,
errormode: form.value.errormode,
captureoutput: form.value.captureoutput,
isactive: form.value.isactive
}
let scriptid
if (editing.value) {
await scriptsApi.update(editing.value.scriptid, payload)
scriptid = editing.value.scriptid
} else {
const created = await scriptsApi.create(payload)
scriptid = created.data.data.scriptid
}
// Second call on purpose: the body is multipart and the catalog row is
// JSON. A failure here leaves a bodyless row, which the list marks as
// unrunnable rather than pretending it is ready to push.
if (pendingfile.value) {
await scriptsApi.uploadBody(scriptid, pendingfile.value)
}
closeModal()
loadData()
} catch (err) {
error.value = apiError(err, 'Failed to save script')
} finally {
saving.value = false
}
}
async function remove(s) {
if (!confirm(`Delete script "${s.scriptname}"?`)) return
try {
await scriptsApi.delete(s.scriptid)
loadData()
} catch (err) {
toast.error(apiError(err, 'Failed to delete'))
}
}
onMounted(loadData)
</script>
<style scoped>
.cell-truncate {
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mono { font-family: monospace; }
.hint { color: var(--text-light); font-weight: normal; }
.warning-banner {
border: 1px solid var(--warning);
border-left-width: 4px;
border-radius: 4px;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
background: rgba(184, 134, 11, 0.08);
}
.explainer {
border: 1px solid var(--border);
border-radius: 4px;
margin-bottom: 1rem;
background: var(--bg-card);
color: var(--text);
}
.explainer summary {
cursor: pointer;
padding: 0.6rem 1rem;
font-weight: 600;
color: var(--text);
}
.explainer-body {
padding: 0 1rem 0.75rem;
}
.explainer-body h4 {
margin: 0.9rem 0 0.25rem;
font-size: 0.95rem;
color: var(--text);
}
.explainer-body p {
margin: 0 0 0.5rem;
color: var(--text-light);
line-height: 1.5;
}
.field-help {
margin-top: 0.4rem;
}
.field-help summary {
cursor: pointer;
color: var(--text-light);
font-size: 0.85rem;
}
.field-help p {
margin: 0.35rem 0 0;
color: var(--text-light);
font-size: 0.85rem;
line-height: 1.5;
}
</style>

View File

@@ -0,0 +1,210 @@
/**
* One-line install block on the endpoints agent key page.
*
* The token is shown exactly once, so the two things worth pinning are that the
* command rendered is the one the server returned, and that reloading the page
* does NOT put a token back on screen - the list read carries no secret, and a
* field that repopulated from it would be lying about what is recoverable.
*/
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 TOKENS_URL = '/endpoints/siteagent/bootstrap/tokens'
const SECRET = 'sdbb_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG'
const SCRIPT_URL =
`https://shopdb.test/shopdb/api/endpoints/siteagent/bootstrap.ps1?t=${SECRET}`
// Download then run: Defender kills the `irm ... | iex` shape before it runs.
const TEMP = 'C:\\Windows\\Temp\\shopdb-bootstrap.ps1'
const COMMAND =
'powershell -ExecutionPolicy Bypass -c "try { Invoke-WebRequest '
+ `-UseBasicParsing '${SCRIPT_URL}' -OutFile '${TEMP}'; & '${TEMP}' }`
+ ` finally { Remove-Item '${TEMP}' -Force -ErrorAction SilentlyContinue }"`
const MINTED_TOKEN = {
tokenid: 7,
tokenprefix: 'sdbb_012345',
createdat: '2026-09-15T10:00:00Z',
expiresat: '2026-09-16T10:00:00Z',
maxuses: 25,
usecount: 0,
remaining: 25,
revokedat: null,
state: 'active'
}
function listResponse(tokens) {
return { data: { data: { tokens } } }
}
async function render(tokens = []) {
api.get.mockImplementation((path) => {
if (path === TOKENS_URL) return Promise.resolve(listResponse(tokens))
if (path === '/endpoints/agentkey') {
return Promise.resolve({ data: { data: { key: null, envkeyfallback: false } } })
}
return Promise.resolve({ data: { data: {} } })
})
const wrapper = mount(AgentKeySettings, {
global: { stubs: { RouterLink: RouterLinkStub } }
})
await flushPromises()
return wrapper
}
function generateButton(wrapper) {
return wrapper.findAll('button')
.find((button) => button.text() === 'Generate install command')
}
describe('AgentKeySettings one-line install', () => {
beforeEach(() => {
api.get.mockReset()
api.post.mockReset()
api.delete.mockReset()
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('says the PC waits for approval and that managed bays use GE-Enforce', async () => {
const wrapper = await render()
const block = wrapper.find('.bootstrap-block').text()
expect(block).toContain('One-line install')
expect(block).toMatch(/waits for approval in\s+Settings > Endpoints > Enrollment/)
expect(block).toContain('GE-Enforce')
expect(block).toContain('elevated')
})
it('generate posts the hours and maximum uses', async () => {
api.post.mockResolvedValue({
data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } }
})
const wrapper = await render()
await wrapper.find('#bootstrap-hours').setValue(48)
await wrapper.find('#bootstrap-maxuses').setValue(5)
await generateButton(wrapper).trigger('click')
await flushPromises()
expect(api.post).toHaveBeenCalledWith(TOKENS_URL, { hours: 48, maxuses: 5 })
})
it('defaults to 24 hours and 25 uses', async () => {
api.post.mockResolvedValue({
data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } }
})
const wrapper = await render()
expect(wrapper.find('#bootstrap-hours').element.value).toBe('24')
expect(wrapper.find('#bootstrap-maxuses').element.value).toBe('25')
await generateButton(wrapper).trigger('click')
await flushPromises()
expect(api.post).toHaveBeenCalledWith(TOKENS_URL, { hours: 24, maxuses: 25 })
})
it('renders the returned command, the once-only warning and the expiry', async () => {
api.post.mockResolvedValue({
data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } }
})
const wrapper = await render()
await generateButton(wrapper).trigger('click')
await flushPromises()
const field = wrapper.find('#bootstrap-command')
expect(field.exists()).toBe(true)
expect(field.element.value).toBe(COMMAND)
expect(field.element.value).toContain(SECRET)
expect(field.attributes('readonly')).toBeDefined()
const block = wrapper.find('.bootstrap-block').text()
expect(block).toContain('The token is shown once and cannot be recovered')
expect(block).toContain('25 of')
expect(block).toContain('uses remaining')
})
it('offers the same token as a Download .ps1 link', async () => {
api.post.mockResolvedValue({
data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } }
})
const wrapper = await render()
expect(wrapper.find('.bootstrap-download').exists()).toBe(false)
await generateButton(wrapper).trigger('click')
await flushPromises()
const link = wrapper.find('.bootstrap-download')
expect(link.attributes('href')).toBe(SCRIPT_URL)
expect(link.attributes('download')).toBe('shopdb-bootstrap.ps1')
expect(wrapper.find('#bootstrap-command').element.value).not.toContain(' | iex')
})
it('copies the command to the clipboard', async () => {
const writeText = vi.fn(() => Promise.resolve())
vi.stubGlobal('navigator', { clipboard: { writeText } })
api.post.mockResolvedValue({
data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } }
})
const wrapper = await render()
await generateButton(wrapper).trigger('click')
await flushPromises()
const copy = wrapper.findAll('button').find((button) => button.text() === 'Copy')
await copy.trigger('click')
await flushPromises()
expect(writeText).toHaveBeenCalledWith(COMMAND)
expect(wrapper.find('.bootstrap-block').text()).toContain('Copied')
})
it('lists live tokens and revoke posts to that token', async () => {
api.post.mockResolvedValue({ data: { data: { token: MINTED_TOKEN } } })
const wrapper = await render([MINTED_TOKEN])
expect(api.get).toHaveBeenCalledWith(TOKENS_URL)
const rows = wrapper.findAll('.bootstrap-block tbody tr')
expect(rows).toHaveLength(1)
expect(rows[0].text()).toContain('sdbb_012345')
expect(rows[0].text()).toContain('0 / 25')
const revoke = wrapper.findAll('button').find((button) => button.text() === 'Revoke')
await revoke.trigger('click')
await flushPromises()
expect(api.post).toHaveBeenCalledWith(`${TOKENS_URL}/7/revoke`)
})
it('offers no Revoke on a token that is already spent', async () => {
const wrapper = await render([
{ ...MINTED_TOKEN, state: 'revoked', usecount: 2 }
])
expect(wrapper.findAll('button').find((button) => button.text() === 'Revoke'))
.toBeUndefined()
})
it('does not show the secret again after a reload', async () => {
api.post.mockResolvedValue({
data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } }
})
const first = await render()
await generateButton(first).trigger('click')
await flushPromises()
expect(first.find('#bootstrap-command').exists()).toBe(true)
// The reload sees the token in the list read, which carries no secret.
const reloaded = await render([MINTED_TOKEN])
expect(reloaded.find('#bootstrap-command').exists()).toBe(false)
expect(reloaded.text()).not.toContain(SECRET)
expect(reloaded.text()).not.toContain('The token is shown once')
// The row is still there, by prefix only.
expect(reloaded.find('.bootstrap-block tbody tr').text()).toContain('sdbb_012345')
})
})

View File

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

View File

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

View File

@@ -0,0 +1,345 @@
/**
* Enrollment settings page (per-PC credentials).
*
* Pins the wire shapes the page sends, because the server contract is exact:
* a wave is {"next": n} or {"assetids": [...]}, an approval carries the chosen
* assetid, and turning the shared key off must tell the admin how many PCs are
* not enrolled yet before anything is sent.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
vi.mock('@/api', () => ({
default: { get: vi.fn(), post: vi.fn(), put: vi.fn() },
computersApi: { list: 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('@/components/PaginationBar.vue', () => ({
default: { name: 'PaginationBar', template: '<div />' }
}))
import api, { computersApi } from '@/api'
import EnrollmentSettings from './EnrollmentSettings.vue'
function migrationPayload() {
return {
data: {
data: {
target: '1.20.0',
counts: { enrolled: 40, pending: 2, upgrading: 3, ready: 30, blocked: 5, notreporting: 20 },
computers: [
{ assetid: 11, hostname: 'PC-READY', agentversion: '1.19.2', lastreportedat: null, state: 'ready', blockers: [] },
{ assetid: 12, hostname: 'PC-BLOCKED', agentversion: '1.18.0', lastreportedat: null, state: 'blocked',
blockers: ['offline', 'no serial on record'] },
{ assetid: 13, hostname: 'PC-TRUST', agentversion: '1.19.2', lastreportedat: null, state: 'blocked',
blockers: ['server certificate not trusted by this PC'] },
{ assetid: 14, hostname: 'PC-TRUST-OFFLINE', agentversion: '1.19.2', lastreportedat: null, state: 'blocked',
blockers: ['offline', 'server certificate not trusted by this PC'] }
],
waves: [
{ waveid: 3, createdat: null, createdby: 'admin', size: 25, queued: 25, enrolled: 10, failed: 1, done: false }
]
},
meta: { pagination: { totalpages: 1 } }
}
}
}
function enrollmentPayload() {
return {
data: {
data: {
settings: { sharedkeyallowed: true, autoenroll: true,
maxpendingenrollments: 500 },
counts: { enrolled: 40, notenrolled: 58, pendingcomputers: 1, pending: 2 },
computers: [
{ assetid: 21, hostname: 'PC-ENROLLED', serialnumber: 'S1', state: 'enrolled',
credentialprefix: 'sdbe_abcdefg', issuedat: null, lastusedat: null, enrolledvia: 'auto',
reenrollblocked: false, reenrollblockedreason: null },
{ assetid: 22, hostname: 'PC-ROLLEDBACK', serialnumber: 'S2', state: 'revoked',
credentialprefix: 'sdbe_hijklmn', issuedat: null, lastusedat: null, enrolledvia: 'auto',
reenrollblocked: true, reenrollblockedreason: 'rollback' }
],
requests: [
{ requestid: 45, hostname: 'PC-NEW', serialnumber: 'S9', deviceid: null, reason: 'serial mismatch',
status: 'pending', matchedassetid: 77, requestedat: null, lastseenat: null, sourceip: null }
]
},
meta: { pagination: { totalpages: 1 } }
}
}
}
async function render() {
api.get.mockImplementation(path => Promise.resolve(
path === '/endpoints/enrollment/migration' ? migrationPayload() : enrollmentPayload()))
api.post.mockResolvedValue({ data: { data: { waveid: 4, queued: [11], skipped: [] } } })
api.put.mockResolvedValue({ data: { data: {} } })
const wrapper = mount(EnrollmentSettings)
await flushPromises()
return wrapper
}
async function openCredentials(wrapper) {
const tab = wrapper.findAll('.enroll-tab').find(button => button.text() === 'Credentials')
await tab.trigger('click')
await flushPromises()
}
describe('EnrollmentSettings', () => {
beforeEach(() => {
api.get.mockReset()
api.post.mockReset()
api.put.mockReset()
computersApi.list.mockReset()
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('renders the migration counts and enrolled progress', async () => {
const wrapper = await render()
expect(wrapper.find('[data-count="enrolled"]').text()).toBe('40')
expect(wrapper.find('[data-count="ready"]').text()).toBe('30')
expect(wrapper.find('[data-count="blocked"]').text()).toBe('5')
expect(wrapper.find('[data-count="notreporting"]').text()).toBe('20')
expect(wrapper.find('.progress-text').text()).toContain('40 of 100 enrolled')
})
it('shows every blocker for a blocked PC', async () => {
const wrapper = await render()
const row = wrapper.findAll('.migration-row').find(r => r.text().includes('PC-BLOCKED'))
expect(row.find('.blockers').text()).toBe('offline, no serial on record')
})
it('only offers a checkbox on ready rows', async () => {
const wrapper = await render()
const rows = wrapper.findAll('.migration-row')
expect(rows[0].find('input[type="checkbox"]').exists()).toBe(true)
expect(rows[1].find('input[type="checkbox"]').exists()).toBe(false)
})
it('offers a trust-only blocked PC for retry, not one with another blocker too', async () => {
const wrapper = await render()
const rows = wrapper.findAll('.migration-row')
const trust = rows.find(r => r.text().includes('PC-TRUST') && !r.text().includes('OFFLINE'))
const mixed = rows.find(r => r.text().includes('PC-TRUST-OFFLINE'))
expect(trust.find('input[type="checkbox"]').exists()).toBe(true)
expect(trust.find('.retry-hint').text()).toBe('Retry after fixing trust')
expect(mixed.find('input[type="checkbox"]').exists()).toBe(false)
expect(mixed.find('.retry-hint').exists()).toBe(false)
})
it('includes a trust-blocked PC in Upgrade selected', async () => {
vi.stubGlobal('confirm', vi.fn(() => true))
const wrapper = await render()
const rows = wrapper.findAll('.migration-row')
await rows[0].find('input[type="checkbox"]').setValue(true)
const trust = rows.find(r => r.text().includes('PC-TRUST') && !r.text().includes('OFFLINE'))
await trust.find('input[type="checkbox"]').setValue(true)
await wrapper.find('.upgrade-selected').trigger('click')
await flushPromises()
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/migration/wave', { assetids: [11, 13] })
})
it('posts {"next":25} for Upgrade next 25 after a confirm naming the count', async () => {
const confirmSpy = vi.fn(() => true)
vi.stubGlobal('confirm', confirmSpy)
const wrapper = await render()
await wrapper.find('.upgrade-next').trigger('click')
await flushPromises()
expect(confirmSpy).toHaveBeenCalledTimes(1)
expect(confirmSpy.mock.calls[0][0]).toMatch(/Upgrade 25 PC\(s\) to endpoint agent 1\.20\.0/)
expect(confirmSpy.mock.calls[0][0]).toMatch(/enrolls automatically/)
expect(confirmSpy.mock.calls[0][0]).toMatch(
/older than 1\.20 must trust the server certificate before they can download the upgrade/)
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/migration/wave', { next: 25 })
})
it('sends nothing when the wave confirm is declined', async () => {
vi.stubGlobal('confirm', vi.fn(() => false))
const wrapper = await render()
await wrapper.find('.upgrade-next').trigger('click')
await flushPromises()
expect(api.post).not.toHaveBeenCalled()
})
it('posts the selected assetids for Upgrade selected', async () => {
vi.stubGlobal('confirm', vi.fn(() => true))
const wrapper = await render()
await wrapper.findAll('.migration-row')[0].find('input[type="checkbox"]').setValue(true)
await wrapper.find('.upgrade-selected').trigger('click')
await flushPromises()
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/migration/wave', { assetids: [11] })
})
it('approves a request with the matched asset by default', async () => {
vi.stubGlobal('confirm', vi.fn(() => true))
const wrapper = await render()
await openCredentials(wrapper)
await wrapper.find('.approve-open').trigger('click')
expect(wrapper.find('.asset-picker').element.value).toBe('77')
await wrapper.find('.approve-confirm').trigger('click')
await flushPromises()
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/requests/45/approve', { assetid: 77 })
})
// Approving a one-off PC onto a computer recorded under another hostname is
// refused by the server, because the PC authenticates by hostname afterwards.
// The page has to show that message and let the admin answer it, not swallow
// it as "could not approve".
function hostnameConflict() {
return {
response: {
status: 409,
data: {
data: {
error: {
code: 'CONFLICT',
message: 'That computer is recorded as PC-OLD, but the request comes from PC-NEW.',
details: {
hostnameconflict: true,
computerhostname: 'PC-OLD',
requesthostname: 'PC-NEW'
}
}
}
}
}
}
}
it('offers to set the hostname when approve comes back 409, then re-posts', async () => {
const confirmSpy = vi.fn(() => true)
vi.stubGlobal('confirm', confirmSpy)
const wrapper = await render()
await openCredentials(wrapper)
api.post.mockReset()
api.post.mockRejectedValueOnce(hostnameConflict())
api.post.mockResolvedValue({ data: { data: { hostnameresult: 'overwritten' } } })
await wrapper.find('.approve-open').trigger('click')
await wrapper.find('.approve-confirm').trigger('click')
await flushPromises()
expect(api.post.mock.calls).toEqual([
['/endpoints/enrollment/requests/45/approve', { assetid: 77 }],
['/endpoints/enrollment/requests/45/approve', { assetid: 77, sethostname: true }]
])
const asked = confirmSpy.mock.calls.at(-1)[0]
expect(asked).toContain('recorded as PC-OLD')
expect(asked).toContain('Set the hostname on this PC?')
})
it('sends nothing more when the hostname question is declined', async () => {
vi.stubGlobal('confirm', vi.fn().mockReturnValueOnce(true).mockReturnValueOnce(false))
const wrapper = await render()
await openCredentials(wrapper)
api.post.mockReset()
api.post.mockRejectedValue(hostnameConflict())
await wrapper.find('.approve-open').trigger('click')
await wrapper.find('.approve-confirm').trigger('click')
await flushPromises()
expect(api.post).toHaveBeenCalledTimes(1)
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/requests/45/approve',
{ assetid: 77 })
})
it('rejects a request after confirm', async () => {
vi.stubGlobal('confirm', vi.fn(() => true))
const wrapper = await render()
await openCredentials(wrapper)
await wrapper.find('.reject').trigger('click')
await flushPromises()
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/requests/45/reject')
})
it('un-enrolls through the revoke route', async () => {
const wrapper = await render()
await openCredentials(wrapper)
await wrapper.find('.revoke').trigger('click')
expect(api.post).not.toHaveBeenCalled()
expect(wrapper.find('.revoke-row').text()).toContain('Keep this PC from enrolling again')
expect(wrapper.find('.revoke-block').element.checked).toBe(false)
await wrapper.find('.revoke-confirm').trigger('click')
await flushPromises()
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/assets/21/revoke')
})
it('sends blockreenroll with the reason when the block box is ticked', async () => {
const wrapper = await render()
await openCredentials(wrapper)
await wrapper.find('.revoke').trigger('click')
await wrapper.find('.revoke-block').setValue(true)
await wrapper.find('.revoke-reason').setValue(' rollback ')
await wrapper.find('.revoke-confirm').trigger('click')
await flushPromises()
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/assets/21/revoke',
{ blockreenroll: true, reason: 'rollback' })
})
it('shows the block chip and allows re-enrollment after confirm', async () => {
const confirmSpy = vi.fn(() => true)
vi.stubGlobal('confirm', confirmSpy)
const wrapper = await render()
await openCredentials(wrapper)
const rows = wrapper.findAll('.credential-row')
const blocked = rows.find(r => r.text().includes('PC-ROLLEDBACK'))
const enrolled = rows.find(r => r.text().includes('PC-ENROLLED'))
expect(blocked.find('.block-chip').text()).toBe('Re-enroll blocked')
expect(enrolled.find('.block-chip').exists()).toBe(false)
expect(enrolled.find('.allow-reenroll').exists()).toBe(false)
await blocked.find('.allow-reenroll').trigger('click')
await flushPromises()
expect(confirmSpy).toHaveBeenCalledTimes(1)
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/assets/22/allow-reenroll')
})
it('shows the not-enrolled count before turning the shared key off', async () => {
const promptSpy = vi.fn(() => 'OFF')
vi.stubGlobal('prompt', promptSpy)
const wrapper = await render()
await openCredentials(wrapper)
await wrapper.find('.toggle-sharedkey').setValue(false)
await flushPromises()
expect(promptSpy).toHaveBeenCalledTimes(1)
expect(promptSpy.mock.calls[0][0]).toMatch(/58 PC\(s\) are not enrolled yet/)
expect(api.put).toHaveBeenCalledWith('/endpoints/enrollment/settings',
{ sharedkeyallowed: false, autoenroll: true })
})
it('leaves the shared key on when the confirmation is not typed', async () => {
vi.stubGlobal('prompt', vi.fn(() => 'yes'))
const wrapper = await render()
await openCredentials(wrapper)
const toggle = wrapper.find('.toggle-sharedkey')
await toggle.setValue(false)
await flushPromises()
expect(api.put).not.toHaveBeenCalled()
expect(toggle.element.checked).toBe(true)
})
it('saves a new pending request limit', async () => {
const wrapper = await render()
await openCredentials(wrapper)
const field = wrapper.find('.input-maxpending')
expect(field.element.value).toBe('500')
await field.setValue(25)
await field.trigger('change')
await flushPromises()
expect(api.put).toHaveBeenCalledWith('/endpoints/enrollment/settings',
{ maxpendingenrollments: 25 })
})
// The negative case is proved server side (tests/test_plugins/
// test_endpoints_enroll_limits.py: PUT with -1 is a 400). Asserting it here
// would only be asserting what vue-test-utils does to a number input.
})

View File

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

View File

@@ -0,0 +1,124 @@
/**
* System Scripts settings page.
*
* Two things are worth pinning here and nothing else is:
*
* 1. The page must NOT offer a run context. Scripts run as SYSTEM because the
* agent is a service with no user session; exposing a picker would let
* someone author a script that reads as "run as the logged-on user" and
* silently does not.
* 2. The SYSTEM warning must be present on both the list and the add/edit
* form. Someone pasting a script into the modal may never have read the
* page copy above it.
*
* Asserted against the file text, matching configurationPolicyEditable.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, 'SystemScriptsSettings.vue'), 'utf8')
const routes = readFileSync(join(here, '..', 'routes.js'), 'utf8')
describe('SystemScriptsSettings', () => {
it('warns that scripts run as SYSTEM on the list page and in the form', () => {
const warnings = source.match(/runs? as SYSTEM/g) || []
expect(warnings.length).toBeGreaterThanOrEqual(2)
})
it('does not offer a run context picker', () => {
expect(source).not.toMatch(/runcontext/i)
})
it('does not send a runcontext in its save payload', () => {
const payload = source.slice(source.indexOf('const payload = {'))
expect(payload.slice(0, 200)).not.toMatch(/runcontext/i)
})
it('flags a script with no body as unrunnable', () => {
expect(source).toMatch(/hasbody/)
expect(source).toMatch(/cannot run/i)
})
it('is gated on the standalone script permission, not admin', () => {
const route = routes.slice(routes.indexOf("'settings/endpoints/scripts'"))
expect(route).toMatch(/requiresPermission: 'endpoints\.runscript'/)
expect(route.slice(0, 300)).not.toMatch(/requiresAdmin/)
})
it('offers an optional working directory and sends it on save', () => {
expect(source).toMatch(/form\.workingdirectory/)
const payload = source.slice(source.indexOf('const payload = {'))
expect(payload.slice(0, 300)).toMatch(/workingdirectory/)
})
it('shows the staging default rather than an empty cell', () => {
// A blank cell reads as "unknown". The folder is the difference between a
// corporate tool being found and the script failing further in, so the
// list has to state the default out loud.
expect(source).toMatch(/Agent staging folder/)
})
it('offers an error mode and sends it on save', () => {
expect(source).toMatch(/form\.errormode/)
const payload = source.slice(source.indexOf('const payload = {'))
expect(payload.slice(0, 300)).toMatch(/errormode/)
})
it('defaults a new script to the permissive mode', () => {
// Strict changes how a body behaves partway through. Defaulting to it
// would silently alter every script authored from here on, so a new
// script has to start where the old ones already are.
const empty = source.slice(source.indexOf('function emptyForm'))
expect(empty.slice(0, 300)).toMatch(/errormode: 'default'/)
})
it('names both error modes by their consequence, not by their flag', () => {
// "default" and "strict" mean nothing to the person choosing. What they
// need to know is which one can report success having done nothing.
expect(source).toMatch(/Continue - report success if the script reaches the end/)
expect(source).toMatch(/Stop on the first error and fail the command/)
})
it('explains the non-terminating error trap that justifies the choice', () => {
// The whole feature exists because PowerShell exits zero after an error.
// If the page never says so, the dropdown looks like pedantry and stays
// on the setting that produces false green.
expect(source).toMatch(/non-terminating/i)
expect(source).toMatch(/exits? zero/i)
})
it('offers the explainer collapsed so it does not bury the page', () => {
expect(source).toMatch(/<details class="explainer">/)
expect(source).toMatch(/How does this work\?/)
expect(source).not.toMatch(/<details class="explainer" open>/)
})
it('collapses the long per-field prose in the form', () => {
// The modal is already tall enough to scroll. Three paragraphs of standing
// explanation between the inputs pushes Save off screen and gets skimmed
// past, which is the opposite of what the copy is for.
const helps = source.match(/<details class="field-help">/g) || []
expect(helps.length).toBeGreaterThanOrEqual(2)
expect(source).toMatch(/<summary>How this works<\/summary>/)
})
it('styles itself from defined theme tokens only', () => {
// --surface and --text-primary are not defined by this app's theme, so a
// var() fallback silently rendered a white panel on the dark background.
// A fallback colour is the bug: it looks correct in light mode and only
// shows up as a bright block for dark-mode users.
const styles = source.slice(source.indexOf('<style scoped>'))
expect(styles).not.toMatch(/--surface|--text-primary|--text-secondary|--bg-secondary/)
expect(styles).not.toMatch(/var\(--[a-z-]+,\s*#/)
})
it('states that output is kept locally and only returned on failure', () => {
// Operators otherwise assume either that nothing was captured or that
// every run is shipped back to the server.
const explainer = source.slice(source.indexOf('Where the output goes'))
expect(explainer.slice(0, 400)).toMatch(/only when the run fails/i)
})
})

View File

@@ -4,7 +4,7 @@
"description": "Endpoint RMM telemetry: metrics, services, inventory, and curated Windows event records reported by the ShopDB agent",
"author": "ShopDB Team",
"dependencies": ["computers"],
"core_version": ">=0.20.0,<1.0.0",
"core_version": ">=0.34.0,<1.0.0",
"api_prefix": "/api/endpoints",
"provides": {
"features": [

View File

@@ -13,8 +13,9 @@ so the catalog matches what a deployed agent already watches:
process agent.exe -> MTConnect
service MTConnect UDC Agent -> MTConnect
Shipped rows are product-owned (source='shipped'): the CRUD API refuses to edit
or delete them. Launch paths mirror the agent's launchpaths block; collector and
Shipped rows (source='shipped') keep their kind and displayname - that pair is
the identity the monitor policies match on - but a site may edit their paths and
active flag. Launch paths mirror the agent's launchpaths block; collector and
log paths are left null until site paths are supplied.
Idempotent: creation checks the live schema first so a db.create_all() site

View File

@@ -1,12 +1,22 @@
"""Ship the shopfloor app monitors as a read-only Site Policy.
"""Ship the shopfloor app monitors as a Site Policy bundle.
SUPERSEDED BY 0019. This migration is kept because installs have stamped it, but
what it seeds no longer survives an upgrade: 0019 splits this single bundle into
one Configuration Policy per monitor and REMOVES the shipped Site Policy, so
sites compose their own. Read this for history; do not extend it.
Two claims below were true when written and are now false, corrected here rather
than left to mislead: the seeded rows are NOT read-only (0019 admits 'shipped'
at the policy CRUD guards), and no seeder reconciles them - the idempotency
check at the top of upgrade() returns early if the policy exists, which is what
keeps a site's edits safe across upgrades.
Wraps the four shipped endpointmonitors (eDNC, Universal Data Collector,
MTConnect process, MTConnect UDC Agent service) in a product-owned policy bundle
so the agent stops carrying them as hardcoded appsettings.json defaults and reads
its watch-list from the server instead.
MTConnect process, MTConnect UDC Agent service) in a policy bundle so the agent
stops carrying them as hardcoded appsettings.json defaults and reads its
watch-list from the server instead.
Seeds two computers-plugin rows, both source='shipped' (read-only in the UI, the
CRUD API refuses to edit or delete them):
Seeds two computers-plugin rows, both source='shipped':
ConfigurationPolicy "Shopfloor App Monitors"
scope {"type":"true"} (every endpoint), one action per shipped monitor,

View File

@@ -0,0 +1,80 @@
"""Dedup curated Windows events on the Windows event record id.
Adds endpointevents.eventrecordid and a unique index over
(assetid, eventchannel, eventrecordid).
Why: the agent used to re-read the newest window of every curated channel on
each cadence tick, and this ingest appended those rows without a dedup check, so
one real event was stored once per report for as long as it stayed in that
window - up to 96 identical rows a day. The agent now carries a per-channel
watermark keyed on the Windows record id, which is monotonic and unique within a
channel on a machine; this index is the server-side guarantee that survives an
agent reinstall, a restored image, or two overlapping reports, none of which
preserve the agent's watermark.
Nullable on purpose: an agent older than this field reports no record id, and
MySQL permits repeated NULLs inside a unique index, so those rows keep the
previous append-only behavior instead of failing ingest.
Existing rows are left alone. They all predate the column and so carry NULL,
which cannot collide with the new index. Purging the historical duplicates is a
separate, deliberate data cleanup - not something a schema migration should do
irreversibly.
Idempotent: both the column-add and the index-create check the live schema
first, so a db.create_all() site and a re-run both stay clean on strict MySQL 8.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'endpoints0012eventrecordid'
down_revision = 'endpoints0011trayversion'
branch_labels = None
depends_on = None
INDEX_NAME = 'idx_endpointevents_assetrecord'
def _has_column(table_name, column_name):
from sqlalchemy import inspect
inspector = inspect(op.get_bind())
if not inspector.has_table(table_name):
return False
return any(col['name'] == column_name
for col in inspector.get_columns(table_name))
def _has_index(table_name, index_name):
from sqlalchemy import inspect
inspector = inspect(op.get_bind())
if not inspector.has_table(table_name):
return False
return any(index['name'] == index_name
for index in inspector.get_indexes(table_name))
def upgrade():
if not _has_column('endpointevents', 'eventrecordid'):
op.add_column(
'endpointevents',
sa.Column('eventrecordid', sa.BigInteger(), nullable=True))
if not _has_index('endpointevents', INDEX_NAME):
op.create_index(
INDEX_NAME,
'endpointevents',
['assetid', 'eventchannel', 'eventrecordid'],
unique=True)
def downgrade():
if _has_index('endpointevents', INDEX_NAME):
op.drop_index(INDEX_NAME, table_name='endpointevents')
if _has_column('endpointevents', 'eventrecordid'):
op.drop_column('endpointevents', 'eventrecordid')

View File

@@ -0,0 +1,93 @@
"""Persist the hardware, OS, and session inventory the agent already reports.
Adds to endpointstates: disksjson, biosjson, memoryjson, processorjson,
osinfojson, usercontextjson, domain, and pendingreboot.
Why now: the agent has been sending disks, bios, memory, processor, osinfo and
domain on every report for several versions. The ingest logged them as unknown
payload keys and threw them away, which was the intended staging step - the
shapes are now proven against real endpoint traffic, so they get columns.
usercontext and pendingreboot are new in this same change.
Shape follows the existing servicesjson/portsjson convention: newest snapshot
per endpoint as MEDIUMTEXT, not one row per sample. The agent resends the full
structure every cycle and operators ask "what is in this PC now", not "what was
in it in March". History would be pure write amplification on a fleet-sized
table.
domain and pendingreboot are real columns rather than JSON because they are
single values the fleet view filters, groups, and alerts on; burying a scalar in
a blob makes it unqueryable.
All columns are nullable. Every existing row predates them, and there is no
correct backfill value - a machine whose reboot state has never been reported is
NOT the same as one confirmed to have no reboot pending, and inventing False
would silently create a fleet of "healthy" endpoints.
Idempotent: each column-add checks the live schema first, so a db.create_all()
site and a re-run both stay clean on strict MySQL 8.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'endpoints0013inventorycolumns'
down_revision = 'endpoints0012eventrecordid'
branch_labels = None
depends_on = None
TABLE_NAME = 'endpointstates'
# MEDIUMTEXT. An installed-application or disk list on a busy engineering
# workstation comfortably exceeds the 64KB TEXT ceiling, and a silent truncation
# there would corrupt the JSON rather than merely shorten it.
_SNAPSHOT_COLUMNS = (
'disksjson',
'biosjson',
'memoryjson',
'processorjson',
'osinfojson',
'usercontextjson',
)
def _has_column(table_name, column_name):
from sqlalchemy import inspect
inspector = inspect(op.get_bind())
if not inspector.has_table(table_name):
return False
return any(col['name'] == column_name
for col in inspector.get_columns(table_name))
def upgrade():
for column_name in _SNAPSHOT_COLUMNS:
if not _has_column(TABLE_NAME, column_name):
op.add_column(
TABLE_NAME,
sa.Column(column_name, sa.Text(length=16777215), nullable=True))
if not _has_column(TABLE_NAME, 'domain'):
op.add_column(
TABLE_NAME,
sa.Column('domain', sa.String(length=255), nullable=True))
if not _has_column(TABLE_NAME, 'pendingreboot'):
op.add_column(
TABLE_NAME,
sa.Column('pendingreboot', sa.Boolean(), nullable=True))
def downgrade():
if _has_column(TABLE_NAME, 'pendingreboot'):
op.drop_column(TABLE_NAME, 'pendingreboot')
if _has_column(TABLE_NAME, 'domain'):
op.drop_column(TABLE_NAME, 'domain')
for column_name in reversed(_SNAPSHOT_COLUMNS):
if _has_column(TABLE_NAME, column_name):
op.drop_column(TABLE_NAME, column_name)

View File

@@ -0,0 +1,56 @@
"""Add diskhealthjson to endpointstates.
Per-drive health, kept separate from disksjson. That column answers "how full
is this drive"; this one answers "is this drive dying". Merging them would mean
a capacity collection failure and a health collection failure could not be told
apart, and the two come from different WMI sources with different failure modes.
JSON rather than scalar columns because a machine has a variable number of
drives and each carries its own verdict, and because the agent reports the same
drive from two sources - MSFT_PhysicalDisk for NVMe and Win32_DiskDrive for
ATA/SATA - which the server keeps side by side rather than collapsing.
MEDIUMTEXT for consistency with the other snapshot columns on this table.
Nullable with no backfill. A drive whose health has never been reported is not
a healthy drive, and defaulting to an empty list would read as "no disks have
problems" on every endpoint that predates this column.
Idempotent: the column-add checks the live schema first, so a re-run and a
db.create_all() site both stay clean on strict MySQL 8.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'endpoints0014diskhealth'
down_revision = 'endpoints0013inventorycolumns'
branch_labels = None
depends_on = None
TABLE_NAME = 'endpointstates'
COLUMN_NAME = 'diskhealthjson'
def _has_column(table_name, column_name):
from sqlalchemy import inspect
inspector = inspect(op.get_bind())
if not inspector.has_table(table_name):
return False
return any(col['name'] == column_name
for col in inspector.get_columns(table_name))
def upgrade():
if not _has_column(TABLE_NAME, COLUMN_NAME):
op.add_column(
TABLE_NAME,
sa.Column(COLUMN_NAME, sa.Text(length=16777215), nullable=True))
def downgrade():
if _has_column(TABLE_NAME, COLUMN_NAME):
op.drop_column(TABLE_NAME, COLUMN_NAME)

View File

@@ -0,0 +1,78 @@
"""Add rebootreasonsjson, bootinfojson and defenderjson to endpointstates.
Phase 2 system-side telemetry. All three are collect-and-persist; none is
surfaced in the UI by this change.
rebootreasonsjson is NOT redundant with the existing pendingreboot column. That
column is a single boolean OR-ed from three registry signals, so it cannot say
which one fired, and the difference is operationally significant: a
PendingFileRenameOperations entry means files are staged for replacement on the
next boot, so the machine is mid-servicing, whereas a lone Component Based
Servicing or Windows Update key can sit set for weeks on a box nobody has
rebooted. Same boolean, very different urgency.
bootinfojson exists because uptime lies under fast startup - a machine reports
days of uptime after what the operator experienced as a shutdown, and a "reboot"
that was really a hybrid resume does not clear the driver or kernel state a
support call is usually trying to clear.
defenderjson is stored deliberately unsurfaced. Defender is known to produce
false positives in this environment, so wiring it to a card now would train
operators to ignore that card; persisting it starts the history accumulating for
whenever someone is ready to interpret it.
JSON rather than scalars: reboot reasons is a variable-length list, and the boot
and Defender payloads are small related groups that are read together and would
otherwise be six sparse columns.
MEDIUMTEXT for consistency with the other snapshot columns on this table.
Nullable with no backfill. Null means never reported, which is deliberately
distinct from an empty list (checked, nothing pending) - defaulting the reboot
reasons to [] would assert every pre-existing endpoint was confirmed clean.
Idempotent: each column-add checks the live schema first, so a re-run and a
db.create_all() site both stay clean on strict MySQL 8.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'endpoints0015systemtelemetry'
down_revision = 'endpoints0014diskhealth'
branch_labels = None
depends_on = None
TABLE_NAME = 'endpointstates'
_COLUMNS = (
'rebootreasonsjson',
'bootinfojson',
'defenderjson',
)
def _has_column(table_name, column_name):
from sqlalchemy import inspect
inspector = inspect(op.get_bind())
if not inspector.has_table(table_name):
return False
return any(col['name'] == column_name
for col in inspector.get_columns(table_name))
def upgrade():
for column_name in _COLUMNS:
if not _has_column(TABLE_NAME, column_name):
op.add_column(
TABLE_NAME,
sa.Column(column_name, sa.Text(length=16777215), nullable=True))
def downgrade():
for column_name in reversed(_COLUMNS):
if _has_column(TABLE_NAME, column_name):
op.drop_column(TABLE_NAME, column_name)

View File

@@ -0,0 +1,51 @@
"""Add dellupdatesjson to endpointstates.
Stores the pending Dell Command | Update summary reported by the
shopdb-dellupdates agent utility. MEDIUMTEXT for consistency with the other
snapshot columns on this table.
Nullable with no backfill. Null means the endpoint has never reported a Dell
scan result, which is deliberately distinct from a stored payload whose status
is 'ok' with an empty list (scanned, nothing pending). The status card renders
those two states differently, so defaulting this to an empty summary would
assert every pre-existing endpoint had been scanned and found clean.
Idempotent: the column-add checks the live schema first, so a re-run and a
db.create_all() site both stay clean on strict MySQL 8.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'endpoints0016dellupdates'
down_revision = 'endpoints0015systemtelemetry'
branch_labels = None
depends_on = None
TABLE_NAME = 'endpointstates'
COLUMN_NAME = 'dellupdatesjson'
def _has_column(table_name, column_name):
from sqlalchemy import inspect
inspector = inspect(op.get_bind())
if not inspector.has_table(table_name):
return False
return any(col['name'] == column_name
for col in inspector.get_columns(table_name))
def upgrade():
if not _has_column(TABLE_NAME, COLUMN_NAME):
op.add_column(
TABLE_NAME,
sa.Column(COLUMN_NAME, sa.Text(length=16777215), nullable=True))
def downgrade():
if _has_column(TABLE_NAME, COLUMN_NAME):
op.drop_column(TABLE_NAME, COLUMN_NAME)

View File

@@ -0,0 +1,133 @@
"""Add endpoint health rollup tables.
Three tables behind the Fleet Health column:
endpointhealthstates one cached verdict per endpoint, read directly by
the Fleet list instead of recomputing a 48h
fleet-wide event aggregate on every page load
endpointhealthtransitions capped history, written on CHANGE not on sample
endpointmonitorimpacts per-PC-type operational impact for a monitor
No backfill. An endpoint with no row has simply not checked in since this
shipped, and the first check-in writes one. Backfilling would have to invent a
firstseenat for every existing condition, and a fabricated "broken since" is
worse than an absent one - an operator reading it has no way to tell it apart
from an observed timestamp.
endpointmonitorimpacts ships EMPTY on purpose. Absence of a row means the
built-in default impact applies, so an endpoint with no policies behaves
exactly as it did before this migration. Seeding defaults here would freeze
today's guesses into rows nobody could distinguish from deliberate choices.
Idempotent: every create checks the live schema first, so a re-run and a
db.create_all() site both stay clean on strict MySQL 8.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'endpoints0017healthrollup'
down_revision = 'endpoints0016dellupdates'
branch_labels = None
depends_on = None
HEALTHSTATES = 'endpointhealthstates'
TRANSITIONS = 'endpointhealthtransitions'
IMPACTS = 'endpointmonitorimpacts'
def _has_table(table_name):
from sqlalchemy import inspect
return inspect(op.get_bind()).has_table(table_name)
def upgrade():
if not _has_table(HEALTHSTATES):
op.create_table(
HEALTHSTATES,
sa.Column('endpointhealthstateid', sa.Integer(), nullable=False),
sa.Column('assetid', sa.Integer(), nullable=False),
sa.Column('level', sa.String(length=16), nullable=False,
server_default='unknown'),
sa.Column('label', sa.String(length=255), nullable=True),
sa.Column('impactclass', sa.String(length=16), nullable=True),
sa.Column('sourcekind', sa.String(length=16), nullable=False,
server_default='none'),
sa.Column('sourceref', sa.String(length=64), nullable=True),
sa.Column('firstseenat', sa.DateTime(), nullable=True),
sa.Column('lastconfirmedat', sa.DateTime(), nullable=True),
sa.Column('lastevaluatedat', sa.DateTime(), nullable=False),
sa.Column('flapcount', sa.Integer(), nullable=False,
server_default='0'),
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'],
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('endpointhealthstateid'),
# One live verdict per endpoint. Unique rather than indexed: two
# rows for one asset is not a state this table should represent.
sa.UniqueConstraint('assetid',
name='uq_endpointhealthstates_assetid'),
)
op.create_index('idx_endpointhealthstates_asset', HEALTHSTATES,
['assetid'])
op.create_index('idx_endpointhealthstates_impactclass', HEALTHSTATES,
['impactclass'])
if not _has_table(TRANSITIONS):
op.create_table(
TRANSITIONS,
sa.Column('endpointhealthtransitionid', sa.Integer(),
nullable=False),
sa.Column('assetid', sa.Integer(), nullable=False),
sa.Column('level', sa.String(length=16), nullable=False),
sa.Column('label', sa.String(length=255), nullable=True),
sa.Column('impactclass', sa.String(length=16), nullable=True),
sa.Column('sourcekind', sa.String(length=16), nullable=False,
server_default='none'),
sa.Column('sourceref', sa.String(length=64), nullable=True),
sa.Column('openedat', sa.DateTime(), nullable=False),
sa.Column('resolvedat', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'],
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('endpointhealthtransitionid'),
)
# Retention reads newest-first per asset; the resolved index serves the
# "never evict an unresolved row" filter without scanning history.
op.create_index('idx_endpointhealthtransitions_assetopened',
TRANSITIONS, ['assetid', 'openedat'])
op.create_index('idx_endpointhealthtransitions_assetresolved',
TRANSITIONS, ['assetid', 'resolvedat'])
if not _has_table(IMPACTS):
op.create_table(
IMPACTS,
sa.Column('endpointmonitorimpactid', sa.Integer(), nullable=False),
sa.Column('endpointmonitorid', sa.Integer(), nullable=False),
# Plain integer, not a ForeignKey: computertypes belongs to the
# computers plugin and owns its own migration chain. A cross-plugin
# FK would couple the two chains' ordering for no benefit the
# unique constraint does not already provide.
sa.Column('computertypeid', sa.Integer(), nullable=False),
sa.Column('impactclass', sa.String(length=16), nullable=False,
server_default='degraded'),
sa.Column('createdat', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['endpointmonitorid'],
['endpointmonitors.endpointmonitorid'],
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('endpointmonitorimpactid'),
sa.UniqueConstraint('endpointmonitorid', 'computertypeid',
name='uq_endpointmonitorimpacts_monitortype'),
)
op.create_index('idx_endpointmonitorimpacts_computertypeid', IMPACTS,
['computertypeid'])
def downgrade():
# Dropped newest-first so the FK to endpointmonitors goes before anything
# that might reference it. All three are caches or mappings - nothing here
# is a system of record, so a downgrade loses no telemetry.
for table_name in (IMPACTS, TRANSITIONS, HEALTHSTATES):
if _has_table(table_name):
op.drop_table(table_name)

View File

@@ -0,0 +1,87 @@
"""Add endpointlocks (admin-only endpoint quarantine).
One table behind the endpoint lock: an admin-set refusal of operator commands
on a single endpoint, independent of policy and permission.
Deliberately separate from the computers plugin's policyactionholds. That table
suppresses ONE desired-state action on the policy-resolution axis and expires on
a timer; this one refuses NON-ADMIN CALLERS on the whole endpoint and does not
expire at all. Two concepts, two tables, two enforcement points - collapsing
them would recreate the axis-mixing the surrounding refactor exists to undo.
No expiresat column, and that is the design rather than an omission. A lock
stands for a physical-world condition (machine mid-repair, validated part
running, box that must not reboot during an audit) and those do not end on a
schedule. A TTL would silently unlock a machine someone still has their hands
inside.
Ships EMPTY. No endpoint has ever been locked before this migration, so there is
nothing to backfill and every endpoint keeps behaving exactly as it did.
Idempotent: the create checks the live schema first, so a re-run and a
db.create_all() site both stay clean on strict MySQL 8.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'endpoints0018endpointlocks'
down_revision = 'endpoints0017healthrollup'
branch_labels = None
depends_on = None
LOCKS = 'endpointlocks'
def _has_table(table_name):
from sqlalchemy import inspect
return inspect(op.get_bind()).has_table(table_name)
def upgrade():
if not _has_table(LOCKS):
op.create_table(
LOCKS,
sa.Column('endpointlockid', sa.Integer(), nullable=False),
sa.Column('assetid', sa.Integer(), nullable=False),
sa.Column('reason', sa.Text(), nullable=False),
sa.Column('lockedbyuserid', sa.Integer(), nullable=True),
sa.Column('lockedbyusername', sa.String(length=100), nullable=True),
sa.Column('lockedat', sa.DateTime(), nullable=False),
# Null while the lock stands. Setting it is what releases the lock,
# so there is no separate active flag able to drift out of step.
sa.Column('releasedat', sa.DateTime(), nullable=True),
sa.Column('releasedbyuserid', sa.Integer(), nullable=True),
sa.Column('releasedbyusername', sa.String(length=100),
nullable=True),
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'],
ondelete='CASCADE'),
# SET NULL, not CASCADE: deleting the admin who locked a machine
# must not delete the record that it was locked. The username
# snapshot columns survive to say who it was.
sa.ForeignKeyConstraint(['lockedbyuserid'], ['users.userid'],
ondelete='SET NULL'),
sa.ForeignKeyConstraint(['releasedbyuserid'], ['users.userid'],
ondelete='SET NULL'),
sa.PrimaryKeyConstraint('endpointlockid'),
)
# Serves the hot path: "is there an active lock on this asset", asked on
# every enqueue and every endpoint detail load. NOT unique on assetid -
# released rows accumulate per endpoint as history, and a unique
# constraint would make the second lock in a machine's life fail. The
# one-active-lock invariant is enforced on write instead.
op.create_index('idx_endpointlocks_assetactive', LOCKS,
['assetid', 'releasedat'])
def downgrade():
# Safe to drop: the table is a system of record for quarantine state, but a
# downgrade past this revision removes the enforcement that reads it, so
# leaving orphaned rows behind would be worse - they would silently reapply
# if the revision were ever re-applied.
if _has_table(LOCKS):
op.drop_index('idx_endpointlocks_assetactive', table_name=LOCKS)
op.drop_table(LOCKS)

View File

@@ -0,0 +1,320 @@
"""Split the shipped monitor bundle into one policy per monitor.
0007 seeded a single Configuration Policy "Shopfloor App Monitors" carrying all
four shipped monitor actions, wrapped in a shipped Site (business) Policy of the
same name. That bundle was all-or-nothing: a site that runs eDNC but no
MTConnect could not detach the half it does not have, because the whole row was
product-owned and read-only.
This migration replaces it with one Configuration Policy per shipped monitor:
Monitor: eDNC (process)
Monitor: Universal Data Collector (process)
Monitor: MTConnect (process)
Monitor: MTConnect (service)
Each carries exactly one 'monitor:<endpointmonitorid>' action, mode='gate',
scope {"type":"true"} - no PC type attribution. The name carries the kind
because displayname alone is not unique: MTConnect ships as both a process and
a service, and configurationpolicies.name is UNIQUE.
source stays 'shipped'. Provenance is still true - the product did ship these -
and the UI keeps showing where they came from. What changes is that the CRUD
guards now admit 'shipped' for edit/delete, so a site can retune or drop one
without losing the record of its origin.
The shipped SITE policy is REMOVED and not replaced. Composition is a site
decision: which monitors apply to which endpoints, and at what priority, is
exactly the judgement a plant makes about its own floor. Shipping one meant
every install inherited "apply all four to every endpoint" as a starting point
that could not be edited. Sites now build their own from the per-monitor pieces.
Removing it is the behaviour change to be aware of on upgrade: monitors that
were attached through that bundle stop being attached until a site policy
composes them. It is deliberate and it is the point of the split - see the
"off by default" direction in the 2026-09-08 plan - but it is not silent, since
the per-monitor policies are listed and ready to compose.
Idempotent on the policy names, so a re-run neither duplicates the split rows
nor resurrects the bundle. Guards on the policy tables being present and no-ops
if they are not, matching 0007.
Revision ID: endpoints0019splitpolicies
Revises: endpoints0018endpointlocks
"""
from alembic import op
import sqlalchemy as sa
from datetime import datetime
# Alembic stores this in alembic_version.version_num, a VARCHAR(32). A longer
# id passes on SQLite and fails on strict MySQL with "Data too long".
revision = 'endpoints0019splitpolicies'
down_revision = 'endpoints0018endpointlocks'
branch_labels = None
depends_on = None
BUNDLE_NAME = 'Shopfloor App Monitors'
# The shipped monitors, by identity (kind, displayname) - never by hardcoded id,
# so this survives id drift across installs exactly as 0007 does.
_SHIPPED_MONITORS = (
('process', 'eDNC'),
('process', 'Universal Data Collector'),
('process', 'MTConnect'),
('service', 'MTConnect'),
)
_REQUIRED = {'endpointmonitors', 'configurationpolicies',
'configurationpolicyactions', 'businesspolicies',
'businesspolicyitems'}
def _tables(bind):
from sqlalchemy import inspect
return set(inspect(bind).get_table_names())
def _policy_name(kind, displayname):
return f'Monitor: {displayname} ({kind})'
def _policy_description(kind, displayname):
noun = 'process' if kind == 'process' else 'service'
return (f'Product-shipped watch-list entry for the {displayname} {noun}. '
f'Attach it to a Site Policy to apply it to endpoints.')
def _config_policy_id(bind, name):
return bind.execute(
sa.text('SELECT configurationpolicyid FROM configurationpolicies '
'WHERE name = :name'),
{'name': name}).scalar()
def _drop_config_policy(bind, name):
"""Delete a configuration policy, its actions, and any items citing it."""
policyid = _config_policy_id(bind, name)
if policyid is None:
return
# Clear children explicitly rather than lean on ON DELETE CASCADE, which
# sqlite does not enforce by default.
bind.execute(
sa.text('DELETE FROM businesspolicyitems '
'WHERE configurationpolicyid = :id'), {'id': policyid})
bind.execute(
sa.text('DELETE FROM configurationpolicyactions '
'WHERE configurationpolicyid = :id'), {'id': policyid})
bind.execute(
sa.text('DELETE FROM configurationpolicies '
'WHERE configurationpolicyid = :id'), {'id': policyid})
def _drop_business_policy(bind, name):
policyid = bind.execute(
sa.text('SELECT businesspolicyid FROM businesspolicies '
'WHERE name = :name AND source = :source'),
{'name': name, 'source': 'shipped'}).scalar()
if policyid is None:
return
bind.execute(
sa.text('DELETE FROM businesspolicyitems WHERE businesspolicyid = :id'),
{'id': policyid})
bind.execute(
sa.text('DELETE FROM businesspolicies WHERE businesspolicyid = :id'),
{'id': policyid})
def _monitor_id(bind, kind, displayname):
return bind.execute(
sa.text('SELECT endpointmonitorid FROM endpointmonitors '
'WHERE kind = :kind AND displayname = :displayname'),
{'kind': kind, 'displayname': displayname}).scalar()
def upgrade():
bind = op.get_bind()
if not _REQUIRED.issubset(_tables(bind)):
return
now = datetime.utcnow()
config_policies = sa.table(
'configurationpolicies',
sa.column('name', sa.String),
sa.column('description', sa.Text),
sa.column('scopejson', sa.Text),
sa.column('source', sa.String),
sa.column('createddate', sa.DateTime),
sa.column('modifieddate', sa.DateTime),
sa.column('isactive', sa.Boolean),
)
actions = sa.table(
'configurationpolicyactions',
sa.column('configurationpolicyid', sa.Integer),
sa.column('action', sa.String),
sa.column('mode', sa.String),
sa.column('desiredstate', sa.Text),
sa.column('params', sa.Text),
sa.column('intervalminutes', sa.Integer),
sa.column('sortorder', sa.Integer),
sa.column('createddate', sa.DateTime),
sa.column('modifieddate', sa.DateTime),
sa.column('isactive', sa.Boolean),
)
for kind, displayname in _SHIPPED_MONITORS:
name = _policy_name(kind, displayname)
if _config_policy_id(bind, name) is not None:
continue
monitorid = _monitor_id(bind, kind, displayname)
if monitorid is None:
# A site that never had this monitor gets no policy for it.
continue
op.bulk_insert(config_policies, [{
'name': name,
'description': _policy_description(kind, displayname),
'scopejson': '{"type": "true"}',
'source': 'shipped',
'createddate': now,
'modifieddate': now,
'isactive': True,
}])
op.bulk_insert(actions, [{
'configurationpolicyid': _config_policy_id(bind, name),
'action': f'monitor:{monitorid}',
'mode': 'gate',
'desiredstate': None,
'params': None,
'intervalminutes': None,
'sortorder': 0,
'createddate': now,
'modifieddate': now,
'isactive': True,
}])
# Retire the monolith. The business policy goes first so its item rows are
# gone before the configuration policy it cited is removed.
_drop_business_policy(bind, BUNDLE_NAME)
_drop_config_policy(bind, BUNDLE_NAME)
def downgrade():
"""Reinstate the single shipped bundle and drop the per-monitor policies.
Rebuilt from the same monitor identities rather than from anything the
upgrade stashed, so it lands in the state 0007 produces regardless of what
a site did to the split rows in between.
"""
bind = op.get_bind()
if not _REQUIRED.issubset(_tables(bind)):
return
now = datetime.utcnow()
monitor_ids = []
for kind, displayname in _SHIPPED_MONITORS:
monitorid = _monitor_id(bind, kind, displayname)
if monitorid is not None:
monitor_ids.append(monitorid)
for kind, displayname in _SHIPPED_MONITORS:
_drop_config_policy(bind, _policy_name(kind, displayname))
if not monitor_ids or _config_policy_id(bind, BUNDLE_NAME) is not None:
return
config_policies = sa.table(
'configurationpolicies',
sa.column('name', sa.String),
sa.column('description', sa.Text),
sa.column('scopejson', sa.Text),
sa.column('source', sa.String),
sa.column('createddate', sa.DateTime),
sa.column('modifieddate', sa.DateTime),
sa.column('isactive', sa.Boolean),
)
op.bulk_insert(config_policies, [{
'name': BUNDLE_NAME,
'description': 'Product-shipped watch-list for the shopfloor apps '
'(eDNC, Universal Data Collector, MTConnect). Read-only.',
'scopejson': '{"type": "true"}',
'source': 'shipped',
'createddate': now,
'modifieddate': now,
'isactive': True,
}])
config_policy_id = _config_policy_id(bind, BUNDLE_NAME)
actions = sa.table(
'configurationpolicyactions',
sa.column('configurationpolicyid', sa.Integer),
sa.column('action', sa.String),
sa.column('mode', sa.String),
sa.column('desiredstate', sa.Text),
sa.column('params', sa.Text),
sa.column('intervalminutes', sa.Integer),
sa.column('sortorder', sa.Integer),
sa.column('createddate', sa.DateTime),
sa.column('modifieddate', sa.DateTime),
sa.column('isactive', sa.Boolean),
)
op.bulk_insert(actions, [{
'configurationpolicyid': config_policy_id,
'action': f'monitor:{monitorid}',
'mode': 'gate',
'desiredstate': None,
'params': None,
'intervalminutes': None,
'sortorder': index,
'createddate': now,
'modifieddate': now,
'isactive': True,
} for index, monitorid in enumerate(monitor_ids)])
business_policies = sa.table(
'businesspolicies',
sa.column('name', sa.String),
sa.column('description', sa.Text),
sa.column('priority', sa.Integer),
sa.column('source', sa.String),
sa.column('createddate', sa.DateTime),
sa.column('modifieddate', sa.DateTime),
sa.column('isactive', sa.Boolean),
)
op.bulk_insert(business_policies, [{
'name': BUNDLE_NAME,
'description': 'Product-shipped Site Policy that applies the shopfloor '
'app monitors to every endpoint. Read-only.',
'priority': 100,
'source': 'shipped',
'createddate': now,
'modifieddate': now,
'isactive': True,
}])
business_policy_id = bind.execute(
sa.text('SELECT businesspolicyid FROM businesspolicies '
'WHERE name = :name'), {'name': BUNDLE_NAME}).scalar()
items = sa.table(
'businesspolicyitems',
sa.column('businesspolicyid', sa.Integer),
sa.column('configurationpolicyid', sa.Integer),
sa.column('sortorder', sa.Integer),
sa.column('createddate', sa.DateTime),
sa.column('modifieddate', sa.DateTime),
sa.column('isactive', sa.Boolean),
)
op.bulk_insert(items, [{
'businesspolicyid': business_policy_id,
'configurationpolicyid': config_policy_id,
'sortorder': 0,
'createddate': now,
'modifieddate': now,
'isactive': True,
}])

View File

@@ -0,0 +1,90 @@
"""Add fingerprint column for delta-based application updates
Revision ID: endpoints0020appfingerprints
Revises: endpoints0019splitpolicies
Create Date: 2026-09-14
Performance optimization: Add appfingerprint column to endpointapplications
table to enable delta-based updates instead of delete-all-insert-all pattern.
Impact:
- Before: 200 endpoints x 50 apps x 4 reports/hour = 40,000 DELETEs + 40,000 INSERTs/hour
- After: Only changed apps are touched (~2% per day) = 40 DELETEs + 40 INSERTs/hour
- Reduction: 99.9% fewer writes (1.92M writes/day -> 1,920 writes/day)
The fingerprint is SHA256(displayname|displayversion|publisher) computed by
the agent and sent in the collector payload. Nullable during rollout - legacy
agents without fingerprint support fall back to delete-all-insert-all behavior.
DEPLOYMENT SEQUENCE (CRITICAL):
1. Deploy this migration: flask db upgrade
2. Run backfill script: python scripts/backfill_app_fingerprints.py
3. Deploy updated plugin.py (delta logic code)
4. Deploy updated C# agent (fingerprint computation)
ROLLBACK PROCEDURE:
1. Deploy old plugin.py FIRST (remove delta logic)
2. THEN run: flask db downgrade -1
3. Verify: SELECT COUNT(*) FROM endpointapplications (should be unchanged)
WARNING: Do NOT downgrade migration before reverting code - will cause AttributeError!
SAFETY FEATURES:
- Row-level locking prevents concurrent update races
- Fingerprint format validation (64 hex chars)
- Delete count verification detects silent failures
- Memory limits prevent pathological cases (>5000 apps)
- Metrics logged for monitoring
Idempotent: a database that already has the column (create_all built it from
the model, or an earlier build of this revision added it as app_fingerprint)
is left alone. 0023 renames the old spelling.
"""
from alembic import op
import sqlalchemy as sa
revision = 'endpoints0020appfingerprints'
down_revision = 'endpoints0019splitpolicies'
branch_labels = None
depends_on = None
plugin_name = 'endpoints'
TABLE = 'endpointapplications'
INDEX = 'idx_endpointapplications_fingerprint'
def _columns():
from sqlalchemy import inspect
return {c['name'] for c in inspect(op.get_bind()).get_columns(TABLE)}
def _indexes():
from sqlalchemy import inspect
return {ix['name'] for ix in inspect(op.get_bind()).get_indexes(TABLE)}
def upgrade():
columns = _columns()
# Old spelling present: leave it for 0023 to rename.
if 'appfingerprint' in columns or 'app_fingerprint' in columns:
return
op.add_column(TABLE, sa.Column('appfingerprint', sa.String(64),
nullable=True))
if INDEX not in _indexes():
# Unique per asset: the delta logic keys rows on it.
op.create_index(INDEX, TABLE, ['assetid', 'appfingerprint'],
unique=True)
def downgrade():
if INDEX in _indexes():
op.drop_index(INDEX, table_name=TABLE)
columns = _columns()
for name in ('appfingerprint', 'app_fingerprint'):
if name in columns:
with op.batch_alter_table(TABLE) as batch:
batch.drop_column(name)

View File

@@ -0,0 +1,26 @@
"""Retired: client certificate columns on assets.
Revision ID: endpoints0021clientcerts
Revises: endpoints0020appfingerprints
Create Date: 2026-09-14
This revision once added client certificate columns to the core assets table.
Client certificate authentication was replaced by per-PC endpoint credentials
(0023), so the revision is kept only so databases already stamped with it
still resolve. It does nothing; 0023 drops the columns wherever an earlier
build of this revision created them.
"""
revision = 'endpoints0021clientcerts'
down_revision = 'endpoints0020appfingerprints'
branch_labels = None
depends_on = None
plugin_name = 'endpoints'
def upgrade():
pass
def downgrade():
pass

View File

@@ -0,0 +1,24 @@
"""Retired: client certificate revocation columns on assets.
Revision ID: endpoints0022certrevocation
Revises: endpoints0021clientcerts
Create Date: 2026-09-14
Kept only so databases already stamped with this revision still resolve. It
does nothing; 0023 drops the columns wherever an earlier build of this
revision created them.
"""
revision = 'endpoints0022certrevocation'
down_revision = 'endpoints0021clientcerts'
branch_labels = None
depends_on = None
plugin_name = 'endpoints'
def upgrade():
pass
def downgrade():
pass

View File

@@ -0,0 +1,181 @@
"""Per-PC endpoint credentials; remove the client certificate leftovers.
Revision ID: endpoints0023credentials
Revises: endpoints0022certrevocation
Create Date: 2026-09-15
Three things, each guarded against the live schema so the revision is safe on
a database built by create_all, on one that ran the earlier client certificate
builds of 0021/0022, and on a re-run:
1. Drop the client certificate columns (and their index) from the core assets
table where an earlier build of 0021/0022 created them.
2. Rename endpointapplications.app_fingerprint to appfingerprint where an
earlier build of 0020 used the underscore spelling.
3. Create endpointcredentials (with the reenrollblocked flag and its reason),
endpointenrollrequests and endpointmigrationwaves.
MySQL 5.6 safe: no JSON columns, and the enroll request lookup index uses
prefix lengths so the key stays under 767 bytes on utf8mb4.
Downgrade drops the three tables. The certificate columns are not restored:
the code that read them is gone.
"""
from alembic import op
import sqlalchemy as sa
revision = 'endpoints0023credentials'
down_revision = 'endpoints0022certrevocation'
branch_labels = None
depends_on = None
plugin_name = 'endpoints'
CREDENTIALS = 'endpointcredentials'
REQUESTS = 'endpointenrollrequests'
WAVES = 'endpointmigrationwaves'
_CERT_COLUMNS = ('client_cert_fingerprint', 'cert_issued_at', 'cert_expires_at',
'cert_revoked_at', 'cert_revocation_reason')
_APPS = 'endpointapplications'
_FINGERPRINT_INDEX = 'idx_endpointapplications_fingerprint'
def _inspector():
from sqlalchemy import inspect
return inspect(op.get_bind())
def _has_table(name):
return _inspector().has_table(name)
def _columns(table):
return {c['name'] for c in _inspector().get_columns(table)}
def _indexes(table):
return _inspector().get_indexes(table)
def _drop_certificate_columns():
if not _has_table('assets'):
return
present = _columns('assets') & set(_CERT_COLUMNS)
if not present:
return
# Any index over a certificate column goes first: 0021 named its own,
# create_all names by convention, and SQLite refuses to drop an indexed column.
for index in _indexes('assets'):
if set(index.get('column_names') or ()) & present:
op.drop_index(index['name'], table_name='assets')
for column in _CERT_COLUMNS:
if column in present:
op.drop_column('assets', column)
def _rename_fingerprint():
if not _has_table(_APPS):
return
columns = _columns(_APPS)
if 'app_fingerprint' not in columns:
return
if 'appfingerprint' in columns:
return
for index in _indexes(_APPS):
if 'app_fingerprint' in (index.get('column_names') or ()):
op.drop_index(index['name'], table_name=_APPS)
op.alter_column(_APPS, 'app_fingerprint', new_column_name='appfingerprint',
existing_type=sa.String(64), existing_nullable=True)
op.create_index(_FINGERPRINT_INDEX, _APPS, ['assetid', 'appfingerprint'],
unique=True)
def upgrade():
_drop_certificate_columns()
_rename_fingerprint()
if not _has_table(CREDENTIALS):
op.create_table(
CREDENTIALS,
sa.Column('credentialid', sa.Integer(), nullable=False),
sa.Column('assetid', sa.Integer(), nullable=False),
sa.Column('credentialhash', sa.CHAR(length=64), nullable=False),
sa.Column('credentialprefix', sa.String(length=12), nullable=False),
sa.Column('enrolledvia', sa.String(length=16), nullable=False),
sa.Column('issuedat', sa.DateTime(), nullable=False),
sa.Column('lastusedat', sa.DateTime(), nullable=True),
sa.Column('revokedat', sa.DateTime(), nullable=True),
sa.Column('revokedby', sa.String(length=64), nullable=True),
sa.Column('reenrollblocked', sa.Boolean(), nullable=False,
server_default=sa.false()),
sa.Column('reenrollblockedreason', sa.String(length=64),
nullable=True),
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'],
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('credentialid'),
sa.UniqueConstraint('assetid'),
sa.UniqueConstraint('credentialhash'),
)
else:
# A test database that ran an earlier build of this revision.
columns = _columns(CREDENTIALS)
if 'reenrollblocked' not in columns:
op.add_column(CREDENTIALS, sa.Column(
'reenrollblocked', sa.Boolean(), nullable=False,
server_default=sa.false()))
if 'reenrollblockedreason' not in columns:
op.add_column(CREDENTIALS, sa.Column(
'reenrollblockedreason', sa.String(length=64), nullable=True))
if not _has_table(REQUESTS):
op.create_table(
REQUESTS,
sa.Column('requestid', sa.Integer(), nullable=False),
sa.Column('hostname', sa.String(length=255), nullable=False),
sa.Column('serialnumber', sa.String(length=128), nullable=False),
sa.Column('deviceid', sa.String(length=64), nullable=True),
sa.Column('enrollnonce', sa.String(length=128), nullable=False),
sa.Column('agentversion', sa.String(length=32), nullable=True),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('reason', sa.String(length=64), nullable=True),
sa.Column('matchedassetid', sa.Integer(), nullable=True),
sa.Column('sourceip', sa.String(length=45), nullable=True),
sa.Column('requestedat', sa.DateTime(), nullable=False),
sa.Column('lastseenat', sa.DateTime(), nullable=False),
sa.Column('decidedat', sa.DateTime(), nullable=True),
sa.Column('decidedby', sa.String(length=64), nullable=True),
sa.ForeignKeyConstraint(['matchedassetid'], ['assets.assetid'],
ondelete='SET NULL'),
sa.PrimaryKeyConstraint('requestid'),
)
existing = {tuple(ix.get('column_names') or ()) for ix in _indexes(REQUESTS)}
if ('hostname', 'serialnumber') not in existing:
op.create_index('idx_endpointenrollrequests_hostserial', REQUESTS,
['hostname', 'serialnumber'],
mysql_length={'hostname': 128, 'serialnumber': 63})
if not _has_table(WAVES):
op.create_table(
WAVES,
sa.Column('waveid', sa.Integer(), nullable=False),
sa.Column('createdat', sa.DateTime(), nullable=False),
sa.Column('createdby', sa.String(length=64), nullable=True),
sa.Column('assetids', sa.Text(), nullable=False),
sa.Column('notes', sa.String(length=255), nullable=True),
sa.PrimaryKeyConstraint('waveid'),
)
def downgrade():
if _has_table(WAVES):
op.drop_table(WAVES)
if _has_table(REQUESTS):
for index in _indexes(REQUESTS):
if index['name'] == 'idx_endpointenrollrequests_hostserial':
op.drop_index(index['name'], table_name=REQUESTS)
op.drop_table(REQUESTS)
if _has_table(CREDENTIALS):
op.drop_table(CREDENTIALS)

View File

@@ -0,0 +1,65 @@
"""One-line bootstrap install tokens.
Revision ID: endpoints0024bootstraptokens
Revises: endpoints0023credentials
Create Date: 2026-09-15
Creates endpointbootstraptokens: the short-lived, admin-minted authorizations
that let a tech fetch the bootstrap script and the keyless agent package with a
single command. Only the sha256 of the secret is stored.
Guarded so the revision is safe on a database built by create_all and on a
re-run. MySQL 5.6 safe: no JSON columns, and the only index is the unique key
on a fixed-length CHAR(64), well under the 767-byte limit on utf8mb4.
Downgrade drops the table.
"""
from alembic import op
import sqlalchemy as sa
revision = 'endpoints0024bootstraptokens'
down_revision = 'endpoints0023credentials'
branch_labels = None
depends_on = None
plugin_name = 'endpoints'
TOKENS = 'endpointbootstraptokens'
def _inspector():
from sqlalchemy import inspect
return inspect(op.get_bind())
def _has_table(name):
return _inspector().has_table(name)
def upgrade():
if _has_table(TOKENS):
return
op.create_table(
TOKENS,
sa.Column('tokenid', sa.Integer(), nullable=False),
sa.Column('tokenhash', sa.CHAR(length=64), nullable=False),
sa.Column('tokenprefix', sa.String(length=12), nullable=False),
sa.Column('createdat', sa.DateTime(), nullable=False),
sa.Column('createdby', sa.String(length=64), nullable=True),
sa.Column('expiresat', sa.DateTime(), nullable=False),
sa.Column('maxuses', sa.Integer(), nullable=False),
sa.Column('usecount', sa.Integer(), nullable=False,
server_default='0'),
sa.Column('revokedat', sa.DateTime(), nullable=True),
sa.Column('lastusedat', sa.DateTime(), nullable=True),
sa.Column('lastusedip', sa.String(length=45), nullable=True),
sa.PrimaryKeyConstraint('tokenid'),
sa.UniqueConstraint('tokenhash'),
)
def downgrade():
if _has_table(TOKENS):
op.drop_table(TOKENS)

View File

@@ -2,11 +2,23 @@
from .endpoint import (
EndpointState, EndpointEvent, EndpointCommand, EndpointApplication,
EndpointLock, EndpointLogCollection,
)
from .monitor import EndpointMonitor
from .operatorreport import OperatorReport
from .credential import (
EndpointCredential, EndpointEnrollRequest, EndpointMigrationWave,
)
from .bootstrap import EndpointBootstrapToken
from .health import (
EndpointHealthState, EndpointHealthTransition, EndpointMonitorImpact,
)
__all__ = [
'EndpointState', 'EndpointEvent', 'EndpointCommand', 'EndpointApplication',
'EndpointLock', 'EndpointLogCollection',
'EndpointMonitor', 'OperatorReport',
'EndpointCredential', 'EndpointEnrollRequest', 'EndpointMigrationWave',
'EndpointBootstrapToken',
'EndpointHealthState', 'EndpointHealthTransition', 'EndpointMonitorImpact',
]

View File

@@ -0,0 +1,78 @@
"""One-line bootstrap install tokens.
An admin mints a short-lived token in the UI and hands a tech one command. The
token is only ever an authorization to FETCH the bootstrap script and the
keyless agent package: it carries no fleet key, provisions no credential, and
the PC it installs still has to be approved in Settings > Endpoints >
Enrollment before it can report anything.
Only the sha256 of the secret is stored, plus a short display prefix, the same
shape as EndpointCredential. A token is spent per package download (usecount
against maxuses) and dies at expiresat or when an admin revokes it.
"""
from datetime import datetime
from shopdb.api import db
def _utciso(value):
return value.isoformat() + 'Z' if value else None
class EndpointBootstrapToken(db.Model):
"""A short-lived authorization to fetch the bootstrap installer."""
__tablename__ = 'endpointbootstraptokens'
tokenid = db.Column(db.Integer, primary_key=True)
tokenhash = db.Column(db.CHAR(64), nullable=False, unique=True)
tokenprefix = db.Column(db.String(12), nullable=False)
createdat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
createdby = db.Column(db.String(64), nullable=True)
expiresat = db.Column(db.DateTime, nullable=False)
maxuses = db.Column(db.Integer, nullable=False, default=1)
usecount = db.Column(db.Integer, nullable=False, default=0,
server_default='0')
revokedat = db.Column(db.DateTime, nullable=True)
lastusedat = db.Column(db.DateTime, nullable=True)
lastusedip = db.Column(db.String(45), nullable=True)
def isusable(self, now):
"""Whether this token may still serve a download at `now`."""
if self.revokedat is not None:
return False
if self.expiresat is None or self.expiresat <= now:
return False
return (self.usecount or 0) < (self.maxuses or 0)
def state(self, now):
if self.revokedat is not None:
return 'revoked'
if self.expiresat is None or self.expiresat <= now:
return 'expired'
if (self.usecount or 0) >= (self.maxuses or 0):
return 'exhausted'
return 'active'
def to_dict(self, now=None):
now = now or datetime.utcnow()
used = self.usecount or 0
maximum = self.maxuses or 0
return {
'tokenid': self.tokenid,
'tokenprefix': self.tokenprefix,
'createdat': _utciso(self.createdat),
'createdby': self.createdby,
'expiresat': _utciso(self.expiresat),
'maxuses': maximum,
'usecount': used,
'remaining': max(maximum - used, 0),
'revokedat': _utciso(self.revokedat),
'lastusedat': _utciso(self.lastusedat),
'lastusedip': self.lastusedip,
'state': self.state(now),
}
def __repr__(self):
return f'<EndpointBootstrapToken {self.tokenprefix}>'

View File

@@ -0,0 +1,165 @@
"""Per-PC endpoint credentials, enrollment requests and migration waves.
EndpointCredential
One row per asset. Holds only the sha256 of the secret the
agent was issued, plus a short display prefix. Revoking sets
revokedat; re-issuing after a revoke UPDATES the same row, so
an asset never carries two credentials. The row therefore also
says that this PC has enrolled at least once, which is how
agentauth tells a revoked PC from one that never enrolled:
plugin.endpoints.revokeddeniessharedkey refuses the shared key
to the first and never to the second. reenrollblocked keeps
a revoked PC from enrolling again (rolling it back to an agent
that only knows the shared key, or a compromised PC) until an
admin allows it.
EndpointEnrollRequest
One row per (hostname, serial number) that asked to enroll -
the endpoint's identity, and the reason every contact from one
PC lands on one row. The agent's enrollment nonce is recorded
on first sight and is what an admin approval is bound to, so a
second machine claiming the same hostname and serial cannot
collect the approved credential. A REJECTED row binds to the
identity instead: any nonce from that hostname and serial stays
rejected, so a reinstall cannot argue its way back into the
queue. Only an admin approving the request clears it.
EndpointMigrationWave
An admin action that pushed the agent upgrade to a set of PCs.
Progress is computed live from credentials and commands; only
the membership is stored.
"""
from datetime import datetime
from shopdb.api import db
def _utciso(value):
return value.isoformat() + 'Z' if value else None
class EndpointCredential(db.Model):
"""The credential issued to one endpoint (hash only)."""
__tablename__ = 'endpointcredentials'
ENROLLED_AUTO = 'auto'
ENROLLED_APPROVED = 'approved'
credentialid = db.Column(db.Integer, primary_key=True)
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
nullable=False,
unique=True,
)
credentialhash = db.Column(db.CHAR(64), nullable=False, unique=True)
credentialprefix = db.Column(db.String(12), nullable=False)
enrolledvia = db.Column(db.String(16), nullable=False)
issuedat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
lastusedat = db.Column(db.DateTime, nullable=True)
revokedat = db.Column(db.DateTime, nullable=True)
revokedby = db.Column(db.String(64), nullable=True)
reenrollblocked = db.Column(db.Boolean, nullable=False, default=False,
server_default=db.false())
reenrollblockedreason = db.Column(db.String(64), nullable=True)
@property
def isactive(self):
return self.revokedat is None
def to_dict(self):
return {
'credentialid': self.credentialid,
'assetid': self.assetid,
'credentialprefix': self.credentialprefix,
'enrolledvia': self.enrolledvia,
'issuedat': _utciso(self.issuedat),
'lastusedat': _utciso(self.lastusedat),
'revokedat': _utciso(self.revokedat),
'revokedby': self.revokedby,
'reenrollblocked': bool(self.reenrollblocked),
'reenrollblockedreason': self.reenrollblockedreason,
}
def __repr__(self):
return f'<EndpointCredential asset={self.assetid} {self.credentialprefix}>'
class EndpointEnrollRequest(db.Model):
"""A PC asking for a credential."""
__tablename__ = 'endpointenrollrequests'
STATUS_PENDING = 'pending'
STATUS_APPROVED = 'approved'
STATUS_REJECTED = 'rejected'
STATUS_ISSUED = 'issued'
requestid = db.Column(db.Integer, primary_key=True)
hostname = db.Column(db.String(255), nullable=False)
serialnumber = db.Column(db.String(128), nullable=False)
deviceid = db.Column(db.String(64), nullable=True)
enrollnonce = db.Column(db.String(128), nullable=False)
agentversion = db.Column(db.String(32), nullable=True)
status = db.Column(db.String(16), nullable=False, default=STATUS_PENDING)
reason = db.Column(db.String(64), nullable=True)
matchedassetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='SET NULL'),
nullable=True,
)
sourceip = db.Column(db.String(45), nullable=True)
requestedat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
lastseenat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
decidedat = db.Column(db.DateTime, nullable=True)
decidedby = db.Column(db.String(64), nullable=True)
# Prefix lengths keep the key under 767 bytes on utf8mb4 (4 bytes a char).
__table_args__ = (
db.Index('idx_endpointenrollrequests_hostserial', 'hostname',
'serialnumber',
mysql_length={'hostname': 128, 'serialnumber': 63}),
)
def to_dict(self):
return {
'requestid': self.requestid,
'hostname': self.hostname,
'serialnumber': self.serialnumber,
'deviceid': self.deviceid,
'agentversion': self.agentversion,
'reason': self.reason,
'status': self.status,
'matchedassetid': self.matchedassetid,
'requestedat': _utciso(self.requestedat),
'lastseenat': _utciso(self.lastseenat),
'decidedat': _utciso(self.decidedat),
'decidedby': self.decidedby,
'sourceip': self.sourceip,
}
def __repr__(self):
return f'<EndpointEnrollRequest {self.hostname!r} {self.status}>'
class EndpointMigrationWave(db.Model):
"""One batch of agent upgrades pushed to move PCs onto credentials."""
__tablename__ = 'endpointmigrationwaves'
waveid = db.Column(db.Integer, primary_key=True)
createdat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
createdby = db.Column(db.String(64), nullable=True)
# Comma list of asset ids. TEXT, not JSON: MySQL 5.6.
assetids = db.Column(db.Text, nullable=False, default='')
notes = db.Column(db.String(255), nullable=True)
@property
def assetidlist(self):
return [int(part) for part in (self.assetids or '').split(',')
if part.strip().isdigit()]
def __repr__(self):
return f'<EndpointMigrationWave {self.waveid}>'

View File

@@ -16,13 +16,23 @@ Two shapes, one per concern:
crash) so this stays a readable security/stability history
rather than a raw event firehose.
EndpointLogCollection
A plaintext log file collected from an endpoint on operator
demand. Kept out of EndpointCommand.resultjson because it is
bulky, needs its own per-asset retention cap, and has to be
addressable by id for the download route.
Both link to the core Asset via assetid (FK, ON DELETE CASCADE), so an endpoint
is the same physical thing as its Computer/Asset - no duplicate identity. The
agent reports a hostname; the plugin resolves it to the existing Computer.
"""
import json
import re
from datetime import datetime
import sqlalchemy as sa
from shopdb.api import db
@@ -80,6 +90,65 @@ class EndpointState(db.Model):
interfacesjson = db.Column(db.Text(length=16777215), nullable=True)
scheduledtasksjson = db.Column(db.Text(length=16777215), nullable=True)
# Hardware and OS inventory, same newest-snapshot rule as above. These
# arrived on the wire well before this and were logged as unknown keys, so
# the shapes are already proven against real endpoint traffic.
disksjson = db.Column(db.Text(length=16777215), nullable=True)
biosjson = db.Column(db.Text(length=16777215), nullable=True)
memoryjson = db.Column(db.Text(length=16777215), nullable=True)
processorjson = db.Column(db.Text(length=16777215), nullable=True)
osinfojson = db.Column(db.Text(length=16777215), nullable=True)
# Session-scoped data collected by a probe running as the signed-in user
# (mapped drives, user-owned listeners). Null when nobody is signed in,
# which is a normal unattended state rather than a fault.
usercontextjson = db.Column(db.Text(length=16777215), nullable=True)
# Windows domain or workgroup membership. A scalar, not JSON: it is a single
# value the fleet view filters and groups on.
domain = db.Column(db.String(255), nullable=True)
# True when the endpoint is holding a reboot. Null = never reported, which
# is deliberately distinct from a confident False.
pendingreboot = db.Column(db.Boolean, nullable=True)
# Per-drive health from the agent's two disk sources. JSON because a machine
# has a variable number of drives and each carries its own verdict. Kept
# separate from disksjson: that is capacity, this is whether the hardware is
# dying. Null = never reported.
diskhealthjson = db.Column(db.Text(length=16777215), nullable=True)
# Which signal(s) drove pendingreboot. That column is a single bool OR-ed
# from three sources and so cannot say WHY, and the difference matters: a
# pendingfilerename means files are staged for replacement on next boot, so
# the box is mid-servicing, while a lone servicing key can sit set for weeks
# on a machine nobody rebooted. Empty list = checked, nothing pending.
# Null = never reported or the check failed.
rebootreasonsjson = db.Column(db.Text(length=16777215), nullable=True)
# Fast startup configuration and what the last boot actually did. Kept
# because uptime is not trustworthy under fast startup: a box can report
# days of uptime after what the operator experienced as a shutdown, and a
# "reboot" that was a hybrid resume does not clear the state a support call
# is usually trying to clear.
bootinfojson = db.Column(db.Text(length=16777215), nullable=True)
# Defender health. Persisted but deliberately NOT surfaced anywhere yet:
# this environment produces known false positives, and wiring it to a card
# now would train operators to ignore that card. Stored so the history is
# already accumulating when someone is ready to interpret it.
defenderjson = db.Column(db.Text(length=16777215), nullable=True)
# Pending Dell Command | Update items, produced by the shopdb-dellupdates
# agent utility from the log the \ShopDB-DellCommandScan task writes. Only
# the count drives the status card; the short list is stored so the panel
# can show what is actually pending without a second round trip.
#
# A null column and a stored payload with status 'unknown' both mean "never
# scanned", which stays distinct from a scan that found nothing pending. The
# status card relies on that distinction - see _dell_updates_field.
dellupdatesjson = db.Column(db.Text(length=16777215), nullable=True)
# Warn-only reconciler output, refreshed on every agent check-in in the same
# commit as servicesjson. Each entry names a policy/shipped-attached monitor
# the endpoint is expected to be running but the latest snapshot does not
@@ -116,6 +185,8 @@ class EndpointState(db.Model):
'idleseconds': self.idleseconds,
'cpupercent': self.cpupercent,
'memorypercent': self.memorypercent,
'domain': self.domain,
'pendingreboot': self.pendingreboot,
'mtconnectrunning': self.mtconnectrunning,
'mtconnectcheckedat': _utciso(self.mtconnectcheckedat),
'lastreportedat': _utciso(self.lastreportedat),
@@ -214,8 +285,16 @@ class EndpointCommand(db.Model):
def __repr__(self):
return f"<EndpointCommand {self.action} asset={self.assetid} {self.status}>"
def to_dict(self):
return {
def to_dict(self, includedetail=False):
"""Serialise a command for the operator views.
resultjson is opt-in. It is agent-reported structured detail, and for a
script it carries the evidence that a green badge alone does not: the
exit code, the context it ran as and the directory it really ran in. On
failure the agent also puts the captured output in there, so it is not
served to every list that only needs a status.
"""
data = {
'endpointcommandid': self.endpointcommandid,
'assetid': self.assetid,
'action': self.action,
@@ -228,6 +307,24 @@ class EndpointCommand(db.Model):
'claimedat': _utciso(self.claimedat),
'completedat': _utciso(self.completedat),
}
if includedetail:
data['resultdetail'] = self._resultdetail()
return data
def _resultdetail(self):
"""resultjson as a dict, or None.
Stored as text, so a malformed or truncated payload is possible. A
detail panel is diagnostics: failing to parse it must not break the
command list that surrounds it.
"""
if not self.resultjson:
return None
try:
parsed = json.loads(self.resultjson)
except (TypeError, ValueError):
return None
return parsed if isinstance(parsed, dict) else None
class EndpointEvent(db.Model):
@@ -255,6 +352,16 @@ class EndpointEvent(db.Model):
eventchannel = db.Column(db.String(50), nullable=True)
eventsource = db.Column(db.String(255), nullable=True)
# The Windows event record id: monotonic and unique within its channel on a
# given machine. This is the dedup key. The agent also uses it as its
# harvest watermark, so in normal operation a record is sent once; the
# unique index below is what guarantees it even when an agent is reinstalled
# (losing its watermark) or two reports overlap. Nullable because an agent
# older than this field still reports, and MySQL permits repeated NULLs in a
# unique index, so those degrade to the previous append-only behavior rather
# than failing.
eventrecordid = db.Column(db.BigInteger, nullable=True)
# When the event happened on the endpoint (from the record), not when it
# reached ShopDB.
eventtime = db.Column(db.DateTime, nullable=True, index=True)
@@ -269,6 +376,8 @@ class EndpointEvent(db.Model):
__table_args__ = (
db.Index('idx_endpointevents_assetevent', 'assetid', 'eventtime'),
db.Index('idx_endpointevents_assetrecord',
'assetid', 'eventchannel', 'eventrecordid', unique=True),
)
def __repr__(self):
@@ -279,6 +388,7 @@ class EndpointEvent(db.Model):
'endpointeventid': self.endpointeventid,
'assetid': self.assetid,
'eventid': self.eventid,
'eventrecordid': self.eventrecordid,
'eventchannel': self.eventchannel,
'eventsource': self.eventsource,
'eventtime': _utciso(self.eventtime),
@@ -354,8 +464,15 @@ class EndpointApplication(db.Model):
# When this snapshot row was written (the report that produced it).
recordedat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
# SHA256 fingerprint for delta-based updates: hash of displayname, displayversion,
# publisher. Allows efficient comparison to detect changes without comparing all
# columns. Computed by agent and sent in payload. Nullable for backward compatibility
# during migration - backfill script will populate existing rows.
appfingerprint = db.Column(db.String(64), nullable=True)
__table_args__ = (
db.Index('idx_endpointapplications_assetname', 'assetid', 'displayname'),
db.Index('idx_endpointapplications_fingerprint', 'assetid', 'appfingerprint', unique=True),
)
def __repr__(self):
@@ -371,3 +488,216 @@ class EndpointApplication(db.Model):
'appid': self.appid,
'recordedat': _utciso(self.recordedat),
}
class EndpointLock(db.Model):
"""An admin-only quarantine refusing operator commands on one endpoint.
Deliberately NOT the same thing as a PolicyActionHold, despite the similar
name. The two sit on different axes and collapsing them would recreate the
axis-mixing this refactor exists to undo:
PolicyActionHold policy-resolution axis. Suppresses ONE desired-state
action so reconciliation stops fighting an admin.
Per-action, several may stand at once, and it EXPIRES.
EndpointLock caller-authority axis. Refuses commands from non-admins
on this endpoint regardless of policy or permission.
Per-endpoint, one at a time, and INDEFINITE.
Indefinite is the point. A lock represents a physical-world condition - a
machine mid-repair, a validated part running, a box that must not reboot
during a customer audit - and those do not end on a timer. A TTL here would
quietly unlock a machine someone is still working inside, which is the exact
accident the lock exists to prevent. Stale locks are surfaced in the UI
instead of being auto-cleared.
Released rows are kept and marked rather than deleted: the record of who
quarantined a machine, why, and who lifted it is the audit trail, and it is
most wanted precisely when the lock is no longer there to explain itself.
"""
__tablename__ = 'endpointlocks'
endpointlockid = db.Column(db.Integer, primary_key=True)
# Not unique at the schema level: released rows accumulate per asset and a
# unique constraint would make the second lock in an endpoint's life fail.
# The one-active-lock invariant is enforced on write in PLACE_ENDPOINT_LOCK.
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
nullable=False,
index=True,
)
reason = db.Column(db.Text, nullable=False)
# Who set it. The id goes null if the account is deleted, but the username
# snapshot survives: a quarantine that forgets who imposed it is unusable at
# review time, which is the only time anyone reads it.
lockedbyuserid = db.Column(
db.Integer,
db.ForeignKey('users.userid', ondelete='SET NULL'),
nullable=True,
)
lockedbyusername = db.Column(db.String(100), nullable=True)
lockedat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
# Null while the lock stands. Set on release, which is what makes this row
# inactive - there is no isactive flag to drift out of step with them.
releasedat = db.Column(db.DateTime, nullable=True)
releasedbyuserid = db.Column(
db.Integer,
db.ForeignKey('users.userid', ondelete='SET NULL'),
nullable=True,
)
releasedbyusername = db.Column(db.String(100), nullable=True)
__table_args__ = (
# Serves the hot path: "is there an active lock on this asset", asked on
# every enqueue and every detail page load.
db.Index('idx_endpointlocks_assetactive', 'assetid', 'releasedat'),
)
def __repr__(self):
state = 'released' if self.releasedat else 'active'
return f"<EndpointLock asset={self.assetid} {state}>"
def to_dict(self):
return {
'endpointlockid': self.endpointlockid,
'assetid': self.assetid,
'reason': self.reason,
'lockedbyuserid': self.lockedbyuserid,
'lockedbyusername': self.lockedbyusername,
'lockedat': _utciso(self.lockedat),
'releasedat': _utciso(self.releasedat),
'releasedbyusername': self.releasedbyusername,
}
class EndpointLogCollection(db.Model):
"""One plaintext log file collected from an endpoint by an operator.
Collected content lives here rather than in EndpointCommand.resultjson for
three reasons. It is bulky and would be dragged along by every command
history list that only wants a status; it needs its own retention rule
(RETENTION_PER_ASSET), which a command row cannot express without deleting
the audit record of the command itself; and the download route needs to
serve bytes, which means it needs a row it can address by id rather than a
JSON field it has to re-parse.
Retention is deliberately PER ASSET. A global cap would let one busy machine
evict another machine's history, and the history is read per machine on the
PC detail page, so a global cap would make the panel unreliable exactly
where it is used. Pruning happens on insert in the result route.
The content stored here is a TAIL, not necessarily the whole file - see
istruncated. A log that has run for months is not something to load into a
browser pane, and the agent bounds what it sends.
"""
__tablename__ = 'endpointlogcollections'
# How many collections to keep per asset. The operator asked for 20: enough
# to compare a handful of runs against each other, few enough that stored
# log text does not grow without bound across a fleet.
RETENTION_PER_ASSET = 20
endpointlogcollectionid = db.Column(db.Integer, primary_key=True)
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
nullable=False,
index=True,
)
# The command that produced this, kept for audit: it ties the collected
# bytes back to who queued the collection and when it ran. SET NULL rather
# than CASCADE so pruning or expiring a command never silently destroys a
# log an operator is still looking at.
endpointcommandid = db.Column(
db.Integer,
db.ForeignKey('endpointcommands.endpointcommandid', ondelete='SET NULL'),
nullable=True,
)
# The path as the AGENT resolved it, which is not always the path the
# operator typed (environment variables get expanded endpoint-side). Storing
# the resolved one is what makes the history honest about what was read.
sourcepath = db.Column(db.String(512), nullable=False)
content = db.Column(db.Text(length=16777215), nullable=True)
# Size of the file ON DISK, not the size of content. When the tail is
# truncated these differ, and that difference is the whole point: it tells
# the operator they are looking at the end of something larger.
filesizebytes = db.Column(db.BigInteger, nullable=True)
contentbytes = db.Column(db.Integer, nullable=True)
linecount = db.Column(db.Integer, nullable=True)
istruncated = db.Column(db.Boolean, nullable=False,
default=False, server_default=sa.false())
# Last-write time reported by the endpoint. A log that has not changed since
# the previous collection is the fastest way to see an app is not logging.
filemodifiedat = db.Column(db.DateTime, nullable=True)
# Username snapshot alongside the id: the id alone becomes unreadable if the
# user is later removed, and this history is read long after collection.
collectedbyuserid = db.Column(
db.Integer,
db.ForeignKey('users.userid', ondelete='SET NULL'),
nullable=True,
)
collectedbyusername = db.Column(db.String(100), nullable=True)
collectedat = db.Column(db.DateTime, nullable=False,
default=datetime.utcnow, index=True)
__table_args__ = (
# Serves both hot paths: listing an asset's history newest-first, and
# finding the oldest rows to prune on insert.
db.Index('idx_endpointlogcollections_assetcollected',
'assetid', 'collectedat'),
)
def __repr__(self):
return (f"<EndpointLogCollection asset={self.assetid} "
f"{self.sourcepath}>")
@property
def filename(self):
"""A download filename derived from the collected path."""
raw = (self.sourcepath or '').replace('/', '\\')
base = raw.rsplit('\\', 1)[-1].strip() or 'collected.log'
# Keep it to characters that survive a Content-Disposition header and a
# Windows save dialog without quoting games.
safe = re.sub(r'[^A-Za-z0-9._-]', '_', base)[:100]
stamp = (self.collectedat or datetime.utcnow()).strftime('%Y%m%d-%H%M%S')
return f'{stamp}_{safe}'
def to_dict(self, includecontent=False):
"""Serialise a collection.
Content is opt-in for the same reason resultjson is on EndpointCommand:
the history list renders 20 rows and none of them need the log body,
which may be hundreds of kilobytes each.
"""
data = {
'endpointlogcollectionid': self.endpointlogcollectionid,
'assetid': self.assetid,
'endpointcommandid': self.endpointcommandid,
'sourcepath': self.sourcepath,
'filesizebytes': self.filesizebytes,
'contentbytes': self.contentbytes,
'linecount': self.linecount,
'istruncated': bool(self.istruncated),
'filemodifiedat': _utciso(self.filemodifiedat),
'collectedbyuserid': self.collectedbyuserid,
'collectedbyusername': self.collectedbyusername,
'collectedat': _utciso(self.collectedat),
'filename': self.filename,
}
if includecontent:
data['content'] = self.content or ''
return data

View File

@@ -0,0 +1,267 @@
"""Endpoint health rollup: cached verdict, transition history, impact mapping.
Three tables, one job: decide what the Fleet Health column says about an
endpoint, decide it ONCE when the data changes, and keep enough history to
answer "when did this break".
Why this exists at all. The verdict used to be computed on every Fleet page
load by aggregating 48h of endpointevents fleet-wide, which cost more the
longer the fleet ran, and it ranked a raw Windows event count above every
other signal. A box with a crash loop reported "Application crash x5861" while
its dead eDNC service - the thing that actually stops work on the floor - was
structurally unreachable, because the event branch returned first.
The model here separates two things the old rollup conflated:
events Point-in-time facts. They happened, they are counted, they age
out of the window on their own.
conditions A stopped service or a disconnected drive is not an event, it is
a STATE with a duration. It opens, it persists, and it is only
over when something observes it to be over.
Ranking a condition against an event count is comparing incompatible things.
Conditions are pinned until explicitly resolved; events are ranked beneath
them. That is the whole reason the Health column can be trusted now.
"""
from datetime import datetime
from shopdb.api import db
def _utciso(value):
"""Naive-UTC datetime -> ISO string marked UTC, or None."""
return value.isoformat() + 'Z' if value else None
# Operational impact, in the site's terms rather than the OS's. Windows calls a
# crash severe; the floor calls a disconnected drive a stoppage. These disagree,
# and the floor wins - which is why this vocabulary is not the event catalog's
# 'critical'/'warning'/'info'.
IMPACT_BLOCKER = 'blocker'
IMPACT_DEGRADED = 'degraded'
IMPACT_INFORMATIONAL = 'informational'
IMPACT_CLASSES = (IMPACT_BLOCKER, IMPACT_DEGRADED, IMPACT_INFORMATIONAL)
# Lower sorts worse, matching _DRIVE_STATUS_RANK's existing convention so the
# two ladders in this plugin do not read in opposite directions.
IMPACT_RANK = {
IMPACT_BLOCKER: 0,
IMPACT_DEGRADED: 1,
IMPACT_INFORMATIONAL: 2,
}
# Where a verdict came from. Kept alongside sourceref so the UI can deep-link
# back to the monitor or the event that produced the label.
SOURCE_DRIVE = 'drive'
SOURCE_SERVICE = 'service'
SOURCE_PROCESS = 'process'
SOURCE_EVENT = 'event'
SOURCE_NONE = 'none'
class EndpointHealthState(db.Model):
"""The current health verdict for one endpoint - the Fleet page's only read.
Cached deliberately. The Fleet list joins this and reads it directly; it
does not recompute anything per row. Written in the SAME commit as the
check-in that produced it, so the cached verdict and the telemetry it
reflects can never disagree (the ledger pattern this codebase already uses
for quantityonhand).
"""
__tablename__ = 'endpointhealthstates'
endpointhealthstateid = db.Column(db.Integer, primary_key=True)
# One row per asset. Unique rather than a plain index: two live verdicts
# for one endpoint is not a state this table should be able to represent.
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
nullable=False,
unique=True,
)
# Display vocabulary, matching what the Fleet renderer already understands:
# 'ok', 'warning', 'critical', 'unknown'.
level = db.Column(db.String(16), nullable=False, default='unknown')
# The phrase an operator reads ("eDNC stopped", "S: disconnected"). A
# PHRASE, not a score - it has to say what to do.
label = db.Column(db.String(255), nullable=True)
# Null for an ok/unknown verdict: nothing is wrong, so nothing has impact.
impactclass = db.Column(db.String(16), nullable=True)
sourcekind = db.Column(db.String(16), nullable=False, default=SOURCE_NONE)
# Monitor id, drive letter or Windows event id, depending on sourcekind.
# Free text because those identifier spaces have nothing in common.
sourceref = db.Column(db.String(64), nullable=True)
# When the CURRENT condition opened. Survives re-evaluation: a service that
# has been down for three days keeps its original firstseenat, so the UI can
# say how long rather than only that it is down now.
firstseenat = db.Column(db.DateTime, nullable=True)
# Last time an agent explicitly observed this endpoint. Drives staleness:
# a pinned blocker on an endpoint that stopped reporting is neither
# resolvable nor trustworthy, and must not render as present tense.
lastconfirmedat = db.Column(db.DateTime, nullable=True)
lastevaluatedat = db.Column(db.DateTime, nullable=False,
default=datetime.utcnow)
# Transitions inside the trailing window. A service flapping ten times an
# hour is a different fault from one cleanly down, and dispatching someone
# to a service that has already restarted twice wastes a trip.
flapcount = db.Column(db.Integer, nullable=False, default=0)
__table_args__ = (
db.Index('idx_endpointhealthstates_asset', 'assetid'),
db.Index('idx_endpointhealthstates_impactclass', 'impactclass'),
)
def __repr__(self):
return f"<EndpointHealthState asset={self.assetid} {self.level}>"
def to_dict(self):
return {
'assetid': self.assetid,
'level': self.level,
'label': self.label,
'impactclass': self.impactclass,
'sourcekind': self.sourcekind,
'sourceref': self.sourceref,
'firstseenat': _utciso(self.firstseenat),
'lastconfirmedat': _utciso(self.lastconfirmedat),
'lastevaluatedat': _utciso(self.lastevaluatedat),
'flapcount': self.flapcount,
}
class EndpointHealthTransition(db.Model):
"""One verdict CHANGE for an endpoint. Written on change, never on sample.
Storing a row per check-in would make the last 50 rows cover a few minutes
and say nothing. Storing a row per change makes 50 rows cover months on a
stable endpoint, compresses "nothing happened" to zero cost, and answers
the question operators actually ask - when did this break - directly rather
than by inferring it from a gap between samples.
It also makes flapping measurable: 50 transitions in an hour and 50 over a
year are the same row count and obviously different faults.
"""
__tablename__ = 'endpointhealthtransitions'
endpointhealthtransitionid = db.Column(db.Integer, primary_key=True)
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
nullable=False,
)
level = db.Column(db.String(16), nullable=False)
label = db.Column(db.String(255), nullable=True)
impactclass = db.Column(db.String(16), nullable=True)
sourcekind = db.Column(db.String(16), nullable=False, default=SOURCE_NONE)
sourceref = db.Column(db.String(64), nullable=True)
openedat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
# Null means STILL OPEN. Retention never evicts an unresolved row: at 50
# deep a long-running blocker's opening transition would otherwise age out
# behind a burst of unrelated churn, deleting the record of why the thing
# currently on screen is on screen.
resolvedat = db.Column(db.DateTime, nullable=True)
__table_args__ = (
db.Index('idx_endpointhealthtransitions_assetopened',
'assetid', 'openedat'),
db.Index('idx_endpointhealthtransitions_assetresolved',
'assetid', 'resolvedat'),
)
def __repr__(self):
return (f"<EndpointHealthTransition asset={self.assetid} "
f"{self.level} opened={self.openedat}>")
def to_dict(self):
return {
'endpointhealthtransitionid': self.endpointhealthtransitionid,
'assetid': self.assetid,
'level': self.level,
'label': self.label,
'impactclass': self.impactclass,
'sourcekind': self.sourcekind,
'sourceref': self.sourceref,
'openedat': _utciso(self.openedat),
'resolvedat': _utciso(self.resolvedat),
}
class EndpointMonitorImpact(db.Model):
"""How much a given monitor matters ON A GIVEN PC TYPE.
eDNC stopped on a shopfloor PC halts work; the same service stopped on a
lobby display is noise. Impact is therefore a property of the monitor IN
CONTEXT, not of the monitor and not of the event type - which is why it
cannot live on endpointmonitors and could never have lived in the event
catalog.
Why a plugin-owned side table rather than a column on the policy action:
monitors bind to PC types through configurationpolicyactions, which is a
CORE table shared by every plugin. Adding an endpoints-specific impact
column there would be a contract-surface change imposed on unrelated
plugins. This keeps the concept inside the plugin that owns it.
ABSENCE OF A ROW IS THE DEFAULT, and that is load-bearing. An endpoint with
no policies, no PC type, or no monitors at all resolves to the built-in
default impact for its source kind - the same verdict the old rollup gave.
The no-policy path is the NORMAL path here, not a special case.
"""
__tablename__ = 'endpointmonitorimpacts'
endpointmonitorimpactid = db.Column(db.Integer, primary_key=True)
endpointmonitorid = db.Column(
db.Integer,
db.ForeignKey('endpointmonitors.endpointmonitorid',
ondelete='CASCADE'),
nullable=False,
)
# Plain integer, not a ForeignKey: computertypes belongs to the computers
# plugin and owns its own migration chain. A cross-plugin FK would couple
# the two chains' ordering for no benefit the unique constraint below does
# not already provide.
computertypeid = db.Column(db.Integer, nullable=False)
impactclass = db.Column(db.String(16), nullable=False,
default=IMPACT_DEGRADED)
createdat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
__table_args__ = (
db.UniqueConstraint('endpointmonitorid', 'computertypeid',
name='uq_endpointmonitorimpacts_monitortype'),
db.Index('idx_endpointmonitorimpacts_computertypeid', 'computertypeid'),
)
def __repr__(self):
return (f"<EndpointMonitorImpact monitor={self.endpointmonitorid} "
f"pctype={self.computertypeid} {self.impactclass}>")
def to_dict(self):
return {
'endpointmonitorimpactid': self.endpointmonitorimpactid,
'endpointmonitorid': self.endpointmonitorid,
'computertypeid': self.computertypeid,
'impactclass': self.impactclass,
'createdat': _utciso(self.createdat),
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,416 @@
"""Evaluate one endpoint's health verdict and record it when it changes.
Scoped to ONE assetid on purpose. The old rollup aggregated 48h of events
fleet-wide on every Fleet page load; this runs when the data actually changes
(a check-in, or an explicit refresh from PC detail) and narrows to a single
asset, which the existing idx_endpointevents_assetevent (assetid, eventtime)
composite already serves. The Fleet page then reads a cached row per endpoint
and recomputes nothing.
THE ORDERING RULE, which is the whole point of this module:
1. Unresolved BLOCKER conditions (drive down, blocking service stopped)
2. DEGRADED conditions (stalled service, non-blocking stop)
3. Events, ranked by catalog severity
4. ok / unknown
A condition outranks an event count no matter how large the count. That is the
inversion of the old behaviour, where the event branch returned first and a
crash loop permanently hid a dead service.
THE NO-POLICY RULE, which is what keeps it safe:
A policy binding may only RAISE impact. It may never gate PRESENCE.
An endpoint with no policies, no PC type, or no monitors still gets a verdict.
Drives and events are not policy-gated at all, so they work with zero
configuration; services fall back to a built-in default impact. Absence of an
EndpointMonitorImpact row is the normal path, not an error path.
"""
import json
import logging
from datetime import datetime, timedelta
from shopdb.api import db
from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title
from ..models.endpoint import EndpointEvent
from ..models.health import (
EndpointHealthState, EndpointHealthTransition, EndpointMonitorImpact,
IMPACT_BLOCKER, IMPACT_DEGRADED, IMPACT_INFORMATIONAL, IMPACT_RANK,
SOURCE_DRIVE, SOURCE_EVENT, SOURCE_NONE, SOURCE_PROCESS, SOURCE_SERVICE,
)
logger = logging.getLogger(__name__)
# Keep the last 50 transitions per asset, but do not pay for a DELETE on every
# write. Let history grow to the high-water mark and then trim back, which
# amortises the delete to roughly once per ten transitions and leaves the write
# path a plain insert almost every time.
TRANSITION_RETENTION = 50
TRANSITION_HIGHWATER = 60
# Window for the flap counter and the event rollup. Matches the 48h the
# stability card used, so the number on screen does not change meaning.
HEALTH_WINDOW_HOURS = 48
# A drive that is unavailable stops work on the floor. This is the one signal
# that is a blocker with NO configuration at all - it never consults policy,
# so it survives an endpoint that has none. What counts as unavailable is
# _mapped_drive_rows' decision, not a second list maintained here.
# Default impact when no EndpointMonitorImpact row applies. This is the
# no-policy path, and it reproduces the old rollup's verdict rather than
# inventing a new one: a stopped watched service was a warning before and is
# 'degraded' now.
_DEFAULT_IMPACT_BY_SOURCE = {
SOURCE_DRIVE: IMPACT_BLOCKER,
SOURCE_SERVICE: IMPACT_DEGRADED,
SOURCE_PROCESS: IMPACT_DEGRADED,
SOURCE_EVENT: IMPACT_INFORMATIONAL,
}
_LEVEL_BY_IMPACT = {
IMPACT_BLOCKER: 'critical',
IMPACT_DEGRADED: 'warning',
IMPACT_INFORMATIONAL: 'warning',
}
def _naive_utcnow():
return datetime.utcnow()
def impact_for_monitor(endpointmonitorid, computertypeid):
"""Impact class for a monitor on a PC type, or None if unmapped.
Returns None rather than raising or defaulting when there is no mapping,
no PC type, or no monitor id. The caller supplies the default; this
function only reports what policy explicitly said, so "policy said
degraded" and "policy said nothing" stay distinguishable.
"""
if endpointmonitorid is None or computertypeid is None:
return None
row = (EndpointMonitorImpact.query
.filter_by(endpointmonitorid=endpointmonitorid,
computertypeid=computertypeid)
.first())
return row.impactclass if row is not None else None
def _drive_conditions(state):
"""Open drive conditions from the last user-context snapshot.
Not policy-gated: mapped drives arrive with the user-context payload and
are evaluated for every endpoint regardless of configuration. On a shop
floor a dead share is a stoppage, so this is a blocker by default and does
not wait for anyone to declare it one.
"""
from ..api.routes import _mapped_drive_rows
raw = getattr(state, 'usercontextjson', None)
if not raw:
return []
try:
context = json.loads(raw)
except (TypeError, ValueError):
# A malformed payload is not a health verdict. Say nothing rather than
# claiming the endpoint is either broken or fine.
logger.warning('malformed usercontextjson for asset %s',
getattr(state, 'assetid', None))
return []
if not isinstance(context, dict):
return []
out = []
for row in _mapped_drive_rows(context):
# Reuse the card's own verdict rather than re-deriving one from the
# raw status string. _mapped_drive_rows already resolves the two
# conflicting sources, treats unknown as unhealthy, and is the thing
# the PC detail card renders - so the Health column and the drive card
# cannot disagree about whether a drive is down.
if row['statecolor'] == 'green':
continue
out.append({
'impactclass': IMPACT_BLOCKER,
'sourcekind': SOURCE_DRIVE,
'sourceref': row['drive'],
'label': '{} {}'.format(row['drive'], row['statuslabel']),
})
return out
def _service_conditions(state, computertypeid):
"""Open service conditions from the last services snapshot.
Distinguishes STALLED from STOPPED, which the old rollup could not: it
read a running/not-running boolean, so a service sitting in stop-pending
was reported identically to one cleanly down. _service_transitional() has
existed for a while; nothing was calling it.
Policy-gated only in the sense that an endpoint with no monitors reports no
services and therefore has no service conditions - which is the existing
contract, not a regression. Where a service IS reported, an unmapped
monitor falls back to the built-in default impact.
"""
from ..api.routes import (_service_name, _service_running,
_service_transitional)
raw = getattr(state, 'servicesjson', None)
if not raw:
# Nothing watched, or nothing reported. Distinct from malformed: this
# is "no condition", and it must read as ok rather than unknown or the
# Fleet page would light up every unpolicied endpoint.
return []
try:
entries = json.loads(raw) or []
except (TypeError, ValueError):
# Malformed IS unknown - we genuinely cannot tell. Signalled to the
# caller with a sentinel so it does not get mistaken for "healthy".
return None
out = []
for entry in entries:
if not isinstance(entry, dict):
continue
if _service_running(entry):
continue
name = _service_name(entry)
monitorid = entry.get('monitorid') or entry.get('endpointmonitorid')
raw_state = entry.get('state') or entry.get('status') or ''
stalled = _service_transitional(raw_state)
mapped = impact_for_monitor(monitorid, computertypeid)
if mapped is not None:
impactclass = mapped
elif stalled:
# A stalled service MAY be a blocker but we cannot know without
# someone saying so. Degraded until policy says otherwise - it is
# visible, but it does not cry wolf.
impactclass = IMPACT_DEGRADED
else:
impactclass = _DEFAULT_IMPACT_BY_SOURCE[SOURCE_SERVICE]
kind = (entry.get('kind') or SOURCE_SERVICE).strip().lower()
sourcekind = SOURCE_PROCESS if kind == 'process' else SOURCE_SERVICE
out.append({
'impactclass': impactclass,
'sourcekind': sourcekind,
'sourceref': str(monitorid) if monitorid is not None else name,
'label': '{} {}'.format(name, 'stalled' if stalled else 'stopped'),
})
return out
def _event_condition(assetid, cutoff):
"""Worst recent event for one asset, as a pseudo-condition, or None.
Narrowed to a single assetid so the (assetid, eventtime) composite serves
it. Counts are still shown, but a count can no longer outrank a real
condition - it is ranked beneath every one of them.
"""
if not CRITICAL_EVENT_IDS:
return None
rows = (db.session.query(EndpointEvent.eventid,
db.func.count(EndpointEvent.endpointeventid))
.filter(EndpointEvent.assetid == assetid,
EndpointEvent.eventid.in_(CRITICAL_EVENT_IDS),
EndpointEvent.eventtime.isnot(None),
EndpointEvent.eventtime >= cutoff)
.group_by(EndpointEvent.eventid)
.all())
if not rows:
return None
total = sum(count for _eventid, count in rows)
dominant = max(rows, key=lambda row: row[1])[0]
title = event_title(dominant)
label = title if total == 1 else '{} x{}'.format(title, total)
severity = event_severity(dominant)
impactclass = (IMPACT_DEGRADED if severity == 'critical'
else IMPACT_INFORMATIONAL)
return {
'impactclass': impactclass,
'sourcekind': SOURCE_EVENT,
'sourceref': str(dominant),
'label': label,
}
def _verdict(state, computertypeid, assetid, now):
"""Resolve the single worst signal into a verdict dict.
Order is impact class first, source second. A flat "conditions always beat
events" rule would eventually put a stopped print spooler above filesystem
corruption; ranking by impact avoids that while still guaranteeing a
blocker outranks any event.
"""
cutoff = now - timedelta(hours=HEALTH_WINDOW_HOURS)
services = _service_conditions(state, computertypeid)
if services is None:
return {
'level': 'unknown',
'label': None,
'impactclass': None,
'sourcekind': SOURCE_NONE,
'sourceref': None,
}
candidates = list(_drive_conditions(state)) + list(services)
event = _event_condition(assetid, cutoff)
if event is not None:
candidates.append(event)
if not candidates:
if getattr(state, 'lastreportedat', None) is None:
return {
'level': 'unknown',
'label': 'no agent',
'impactclass': None,
'sourcekind': SOURCE_NONE,
'sourceref': None,
}
return {
'level': 'ok',
'label': 'ok',
'impactclass': None,
'sourcekind': SOURCE_NONE,
'sourceref': None,
}
# Conditions before events at equal impact: a live condition is actionable
# now, an event is a report of something that already finished happening.
def sort_key(candidate):
return (
IMPACT_RANK.get(candidate['impactclass'], 99),
1 if candidate['sourcekind'] == SOURCE_EVENT else 0,
)
worst = sorted(candidates, key=sort_key)[0]
return {
'level': _LEVEL_BY_IMPACT.get(worst['impactclass'], 'warning'),
'label': worst['label'],
'impactclass': worst['impactclass'],
'sourcekind': worst['sourcekind'],
'sourceref': worst['sourceref'],
}
def _same_condition(existing, verdict):
"""Is this the same ongoing condition, or a new one?
Compared on identity (what and where), NOT on the label: a crash count
ticking from x12 to x13 is the same condition and must not open a new
transition, or the history would fill with noise and the flap counter
would be meaningless.
"""
if existing is None:
return False
return (existing.level == verdict['level']
and existing.impactclass == verdict['impactclass']
and existing.sourcekind == verdict['sourcekind']
and existing.sourceref == verdict['sourceref'])
def _trim_transitions(assetid):
"""Enforce retention, keeping every unresolved row regardless of age."""
total = (db.session.query(db.func.count(
EndpointHealthTransition.endpointhealthtransitionid))
.filter(EndpointHealthTransition.assetid == assetid)
.scalar()) or 0
if total <= TRANSITION_HIGHWATER:
return
keep = [row.endpointhealthtransitionid for row in
(db.session.query(
EndpointHealthTransition.endpointhealthtransitionid)
.filter(EndpointHealthTransition.assetid == assetid)
.order_by(EndpointHealthTransition.openedat.desc())
.limit(TRANSITION_RETENTION)
.all())]
query = (EndpointHealthTransition.query
.filter(EndpointHealthTransition.assetid == assetid,
EndpointHealthTransition.resolvedat.isnot(None)))
if keep:
query = query.filter(
~EndpointHealthTransition.endpointhealthtransitionid.in_(keep))
query.delete(synchronize_session=False)
def evaluate_endpoint_health(assetid, state, computertypeid=None, now=None):
"""Recompute one endpoint's verdict and record a transition if it changed.
Does NOT commit. The caller owns the transaction so the verdict lands in
the same commit as the telemetry it was derived from - the cached row and
the data behind it can never disagree.
Fail-open is the caller's job too: a health error must never fail an
otherwise-good check-in.
"""
now = now or _naive_utcnow()
verdict = _verdict(state, computertypeid, assetid, now)
existing = (EndpointHealthState.query
.filter_by(assetid=assetid)
.first())
unchanged = _same_condition(existing, verdict)
if existing is None:
existing = EndpointHealthState(assetid=assetid)
db.session.add(existing)
if not unchanged:
# Close the outgoing condition before opening the new one, so the two
# never overlap in history.
(EndpointHealthTransition.query
.filter(EndpointHealthTransition.assetid == assetid,
EndpointHealthTransition.resolvedat.is_(None))
.update({'resolvedat': now}, synchronize_session=False))
db.session.add(EndpointHealthTransition(
assetid=assetid,
level=verdict['level'],
label=verdict['label'],
impactclass=verdict['impactclass'],
sourcekind=verdict['sourcekind'],
sourceref=verdict['sourceref'],
openedat=now,
))
existing.firstseenat = now
window = now - timedelta(hours=HEALTH_WINDOW_HOURS)
existing.flapcount = (db.session.query(db.func.count(
EndpointHealthTransition.endpointhealthtransitionid))
.filter(EndpointHealthTransition.assetid == assetid,
EndpointHealthTransition.openedat >= window)
.scalar()) or 0
_trim_transitions(assetid)
existing.level = verdict['level']
existing.label = verdict['label']
existing.impactclass = verdict['impactclass']
existing.sourcekind = verdict['sourcekind']
existing.sourceref = verdict['sourceref']
existing.lastevaluatedat = now
# Only an explicit observation confirms an endpoint. A verdict recomputed
# from a stale snapshot must NOT refresh this, or a pinned blocker on a
# silent endpoint would look freshly verified forever.
if getattr(state, 'lastreportedat', None) is not None:
existing.lastconfirmedat = state.lastreportedat
return existing

Some files were not shown because too many files have changed in this diff Show More