Merge origin/main into align-wilm-main (align wilm with Cameron's latest)

Bring main's endpoint hardening, operator-report validation, launch-path
policy, and theme-variable CSS into wilm. Channel model reconciled to this
repo's shipping (stable=release tag, edge=main trunk) while keeping wilm's
remote-BuildKit publish step and GHCR pull-based launcher.
This commit is contained in:
benjamin.baker
2026-09-02 17:41:03 -04:00
33 changed files with 1539 additions and 117 deletions

View File

@@ -310,9 +310,9 @@ async function remove(p) {
margin: 2px 0 0;
padding: 6px 8px;
font-size: 0.85em;
color: var(--warning-text, #8a6d3b);
background: var(--warning-bg, rgba(240, 173, 78, 0.12));
border: 1px solid var(--warning-border, rgba(240, 173, 78, 0.4));
color: var(--warning-dark);
background: var(--bg);
border: 1px solid var(--warning);
border-radius: 4px;
}
</style>

View File

@@ -230,7 +230,36 @@
v-model="form.azuredeviceid"
type="text"
class="form-control"
placeholder="Entra/Azure deviceId GUID (set by Azure imports)"
placeholder="Entra deviceId GUID (set by the Azure import)"
/>
<small class="muted">Opens the device in Intune from the PC page.</small>
</div>
<!-- The OBJECT id, not the device id. Entra gives a device both and
they address different places: deviceId opens Intune, objectId
opens the Entra device record. The import populates both, so a
page showing only one left the other uneditable when it was
wrong. -->
<div class="form-group">
<label for="azureobjectid">Azure Object ID</label>
<input
id="azureobjectid"
v-model="form.azureobjectid"
type="text"
class="form-control"
placeholder="Entra objectId GUID (set by the Azure import)"
/>
<small class="muted">Opens the device in Entra from the PC page.</small>
</div>
<div class="form-group">
<label for="azuredevicecategory">Azure Device Category</label>
<input
id="azuredevicecategory"
v-model="form.azuredevicecategory"
type="text"
class="form-control"
placeholder="Entra device category or group label"
/>
</div>
@@ -395,6 +424,8 @@ const form = ref({
osid: '',
loggedinuser: '',
azuredeviceid: '',
azureobjectid: '',
azuredevicecategory: '',
accessmethods: [],
notes: '',
mapx: null,
@@ -529,6 +560,8 @@ onMounted(async () => {
osid: ext.osid || '',
loggedinuser: ext.loggedinuser || '',
azuredeviceid: ext.azuredeviceid || '',
azureobjectid: ext.azureobjectid || '',
azuredevicecategory: ext.azuredevicecategory || '',
accessmethods: (pc.accessmethods || []).map(a => ({
protocolid: a.protocolid,
portoverride: a.portoverride ?? null
@@ -605,6 +638,8 @@ async function savePC() {
osid: form.value.osid || null,
loggedinuser: form.value.loggedinuser || null,
azuredeviceid: form.value.azuredeviceid || null,
azureobjectid: form.value.azureobjectid || null,
azuredevicecategory: form.value.azuredevicecategory || null,
accessmethods: form.value.accessmethods,
notes: form.value.notes || null,
ipaddress: form.value.ipaddress || null,

View File

@@ -21,16 +21,30 @@ from flask_jwt_extended import current_user
from shopdb.api import (
db, success_response, error_response, ErrorCodes, require_permission,
require_role,
require_role, Setting,
)
from ..models import EndpointMonitor
from .routes import endpoints_bp
# Shipped monitors default to this PC type until they are authored as policies.
# Which PC type the shipped monitors attach to, as a SETTING rather than a
# literal (ADR-015). PC-type vocabulary is per-site seed data: a plant whose
# type is called "Shop Floor Workstation" got no shipped monitors at all when
# this was hardcoded, and a plant that happens to use the name for something
# else got all of them. The default matches the seeded type so nothing changes
# for a site that uses it; a blank setting attaches the shipped bundle to
# nothing, which is the right neutral behaviour for a site that does not want
# it rather than a silent mismatch.
SHIPPED_PCTYPE_SETTING = 'plugin.endpoints.shippedmonitorpctype'
SHIPPED_DEFAULT_PCTYPE = 'Shopfloor PC'
def shipped_pctype():
"""The PC type the shipped monitor bundle attaches to at this site."""
configured = Setting.get(SHIPPED_PCTYPE_SETTING, default=SHIPPED_DEFAULT_PCTYPE)
return (configured or '').strip()
_VALID_KINDS = ('process', 'service')
@@ -93,9 +107,6 @@ def update_monitor(monitor_id):
if monitor is None:
return error_response(ErrorCodes.NOT_FOUND, 'Monitor not found',
http_code=404)
if monitor.source != 'local':
return error_response(ErrorCodes.FORBIDDEN,
'Shipped monitors cannot be edited', http_code=403)
body = request.get_json(silent=True) or {}
kind = (body.get('kind') or monitor.kind or '').strip().lower()
@@ -110,8 +121,27 @@ def update_monitor(monitor_id):
if problem:
return error_response(ErrorCodes.VALIDATION_ERROR, problem, http_code=400)
monitor.kind = kind
monitor.displayname = (body.get('displayname', monitor.displayname) or '').strip()
# A shipped row is product-owned IDENTITY, not a product-owned opinion about
# one site's disk layout. It used to refuse every edit, which meant a plant
# whose eDNC lives on D:, or that runs no UDC at all, could neither correct
# the launch path nor switch the monitor off - and then got permanent false
# drift warnings for it. ADR-015 allows seed data precisely because a site
# can edit it afterwards.
#
# So: kind and displayname are the identity 0007's policy bundle matches on
# and stay fixed on a shipped row; the paths and the active flag, which are
# what actually differ between plants, are editable.
if monitor.source != 'local':
for field in ('kind', 'displayname'):
if field in body and (body.get(field) or '').strip() != (
getattr(monitor, field) or ''):
return error_response(
ErrorCodes.FORBIDDEN,
'A shipped monitor keeps its kind and display name; its '
'paths and active flag can be changed', http_code=403)
else:
monitor.kind = kind
monitor.displayname = (body.get('displayname', monitor.displayname) or '').strip()
monitor.processname = (body.get('processname', monitor.processname) or '').strip() or None
monitor.servicename = (body.get('servicename', monitor.servicename) or '').strip() or None
monitor.launchpath = (body.get('launchpath', monitor.launchpath) or '').strip() or None
@@ -142,13 +172,14 @@ def delete_monitor(monitor_id):
def _resolve_pctype_monitors(pctype):
"""Monitor definitions a PC type should watch: shipped defaults + policy.
Shipped monitors are attached to SHIPPED_DEFAULT_PCTYPE as a code rule;
Shipped monitors attach to the PC type named by the shipped-pctype setting;
policy-attached monitors come from the computers policy resolver by PC type.
Returns a list of EndpointMonitor rows (active only), de-duped by id.
"""
selected = {}
if pctype == SHIPPED_DEFAULT_PCTYPE:
shipped_for = shipped_pctype()
if shipped_for and pctype == shipped_for:
for monitor in EndpointMonitor.query.filter_by(
source='shipped', isactive=True).all():
selected[monitor.endpointmonitorid] = monitor

View File

@@ -39,11 +39,12 @@ def _naive_utcnow():
return datetime.now(timezone.utc).replace(tzinfo=None)
# A watched service or process reports a STATE STRING, not a boolean, and the
# vocabulary varies by source. Both the services panel and the fleet health
# rollup have to agree on what counts as running, so the rule lives here once -
# reading it wrong makes every service look stopped.
_RUNNING_STATES = ('running', 'started', 'up', 'ok', 'active')
# What counts as RUNNING is defined once, by the reconciler, and imported here.
# The panel and the reconciler read the same agent snapshot, so a disagreement
# would paint a service green and flag it as drift on the same page. Importing
# api -> services is the direction the plugin layout already runs in; the
# reconciler's own import of this module is deferred, so there is no cycle.
from ..services.reconciler import RUNNING_STATES as _RUNNING_STATES
def _service_running(entry):
@@ -1781,6 +1782,13 @@ OPERATOR_REPORT_RETENTION_DEFAULT_DAYS = 7
# Screenshots are small PNGs from a virtual-screen grab. Cap the upload so a
# malformed or hostile agent cannot fill the disk with one request.
OPERATOR_REPORT_MAX_IMAGE_BYTES = 10 * 1024 * 1024
# The multipart envelope carries the other form fields and the part boundaries
# too, so content_length is legitimately larger than the image. Allow for that
# rather than rejecting a screenshot that is just under the cap.
_OPERATOR_REPORT_FORM_SLACK = 64 * 1024
# PNG signature. The route stores and serves the file as image/png, so this is
# the one thing it accepts.
_PNG_MAGIC = b'\x89PNG\r\n\x1a\n'
def _operator_report_dir():
@@ -1837,13 +1845,26 @@ def upload_operator_report():
holds only metadata plus the stored filename. One retention sweep runs
opportunistically so the feed stays bounded.
"""
from werkzeug.utils import secure_filename
import uuid
error = _collector_auth_or_error()
if error is not None:
return error
# Refuse an oversized body BEFORE anything parses it. content_length is a
# header, so it is readable without touching the body; request.form is not
# - the first access to it makes werkzeug parse the whole multipart and
# spool the file part to a temp file. An earlier version of this check sat
# below that line and so ran after the thing it exists to prevent. The
# value is caller-supplied, which is why this is an early out and not the
# enforcement; the bounded read below is.
declared = request.content_length or 0
if declared > OPERATOR_REPORT_MAX_IMAGE_BYTES + _OPERATOR_REPORT_FORM_SLACK:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'screenshot exceeds the {} byte limit'.format(
OPERATOR_REPORT_MAX_IMAGE_BYTES))
hostname = (request.form.get('hostname') or '').strip()
if not hostname:
return error_response(ErrorCodes.VALIDATION_ERROR,
@@ -1864,17 +1885,24 @@ def upload_operator_report():
upload = request.files.get('screenshot')
if upload is not None and upload.filename:
data = upload.read()
# Read one byte past the cap: enough to know it was exceeded, without
# taking the whole of a body that lied about its length.
data = upload.read(OPERATOR_REPORT_MAX_IMAGE_BYTES + 1)
if len(data) > OPERATOR_REPORT_MAX_IMAGE_BYTES:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'screenshot exceeds the {} byte limit'.format(
OPERATOR_REPORT_MAX_IMAGE_BYTES))
if data and not data.startswith(_PNG_MAGIC):
# The stored file is served back as image/png. Storing whatever
# arrived under that name meant the extension and the bytes could
# disagree; the upload headers make the document case inert, but
# nothing is gained by keeping a file we already know is not what it
# claims.
return error_response(ErrorCodes.VALIDATION_ERROR,
'screenshot must be a PNG')
if data:
ext = os.path.splitext(secure_filename(upload.filename))[1].lower()
if ext != '.png':
ext = '.png'
imagefilename = 'report-{}{}'.format(uuid.uuid4().hex, ext)
imagefilename = 'report-{}.png'.format(uuid.uuid4().hex)
imagemimetype = 'image/png'
imagebytesize = len(data)
report_dir = _operator_report_dir()

View File

@@ -20,17 +20,27 @@ CRUD API refuses to edit or delete them):
trying to START the process when a launch path is mapped - never something
the product ships switched on for every endpoint.
BusinessPolicy "RPCA Shopfloor App Monitors"
BusinessPolicy "Shopfloor App Monitors"
the site-level wrapper, carrying the Configuration Policy as its one item.
It was named for the originating plant when this arrived. A shipped row is
product-owned and every adopting site sees it, so the name cannot be one
site's designator (ADR-015). Migration 0010 renames it on installs that
already stamped this one.
Monitors are looked up by identity (kind, displayname), never by a hardcoded id,
so the bundle survives id drift across installs.
Lives in the endpoints chain, after 0006, because the monitor rows it references
are seeded there. The computers policy tables are created by the core baseline
before per-plugin chains run; this migration still guards on their presence and
no-ops cleanly if they are absent, and is idempotent on the policy/business-policy
names so re-running never duplicates the bundle.
are seeded there. The policy tables belong to the COMPUTERS plugin and are
created by its own post-cutover chain, not by the core baseline; endpoints
declares a hard dependency on computers, which is what orders the two.
It guards on those tables being present and no-ops if they are not, and it is
idempotent on the policy names so re-running never duplicates the bundle. Be
clear about what the guard costs, though: Alembic stamps the revision either
way, so if it ever no-ops the bundle is simply never seeded and nothing comes
back to finish the job. That is acceptable only because the dependency makes
the absent case unreachable in a supported install.
"""
from alembic import op
@@ -47,7 +57,7 @@ depends_on = None
CONFIG_POLICY_NAME = 'Shopfloor App Monitors'
BUSINESS_POLICY_NAME = 'RPCA Shopfloor App Monitors'
BUSINESS_POLICY_NAME = 'Shopfloor App Monitors'
# The shipped monitors this bundle attaches, by identity (kind, displayname).
_SHIPPED_MONITORS = (

View File

@@ -0,0 +1,60 @@
"""Rename the shipped business policy off one site's designator.
0007 seeded it as "RPCA Shopfloor App Monitors". RPCA is the originating
plant's facility designator, and the row is product-owned: source='shipped',
seeded into every install, and the CRUD API refuses to rename it. So every
adopting site would carry another plant's label at the top of its policy list
with no way to change it, which is exactly what ADR-015 exists to prevent.
0007 now seeds the neutral name, which covers a fresh install. This migration
covers the installs that already stamped 0007 with the old one - the
originating site, and anyone who upgraded before this landed.
Renames only when the old name is present AND the new one is not, so it is
idempotent and cannot collide with a site that already has a policy of its own
under the new name. Downgrade is deliberately one-way: putting another site's
designator back into a shipped row is not a state worth being able to reach.
Revision ID: endpoints0010renamepolicy
Revises: endpoints0009opreports
"""
from alembic import op
import sqlalchemy as sa
revision = 'endpoints0010renamepolicy'
down_revision = 'endpoints0009opreports'
branch_labels = None
depends_on = None
OLD_NAME = 'RPCA Shopfloor App Monitors'
NEW_NAME = 'Shopfloor App Monitors'
def _has_table(bind, name):
return sa.inspect(bind).has_table(name)
def upgrade():
bind = op.get_bind()
# The policy tables belong to the computers plugin. Same guard as 0007: if
# they are not there, there is nothing seeded to rename.
if not _has_table(bind, 'businesspolicies'):
return
exists = bind.execute(
sa.text('SELECT COUNT(*) FROM businesspolicies WHERE name = :name'),
{'name': NEW_NAME}).scalar()
if exists:
return
bind.execute(
sa.text('UPDATE businesspolicies SET name = :new WHERE name = :old'),
{'new': NEW_NAME, 'old': OLD_NAME})
def downgrade():
# One-way on purpose. See the module docstring.
pass

View File

@@ -27,11 +27,16 @@ Two seams are kept deliberately narrow so this stays easy to change:
can call it directly.
"""
# The running-state vocabulary a watched process/service can report. Kept in
# lockstep with plugins/endpoints/api/routes.py::_RUNNING_STATES so a service the
# panel paints green is never flagged as drift here. Both read from the same
# agent snapshot; disagreeing would show a running app as out-of-desired-state.
_RUNNING_STATES = ('running', 'started', 'up', 'ok', 'active')
# The running-state vocabulary a watched process/service can report. A watched
# service reports a STATE STRING, not a boolean, and the vocabulary varies by
# source, so what counts as running is a single rule rather than two.
#
# It lives here, and api/routes.py imports it, because the panel and this
# reconciler MUST agree: a service the panel paints green that this flags as
# drift is a contradiction shown to the same person on the same page. It was
# two copies with a comment asking a human to keep them in lockstep, which is
# not a mechanism.
RUNNING_STATES = ('running', 'started', 'up', 'ok', 'active')
def monitor_expects_running(monitor):
@@ -82,7 +87,7 @@ def _reported_running_keys(reported_services):
else:
name, raw, kind = str(entry), '', None
name = name.strip().lower()
if not name or raw not in _RUNNING_STATES:
if not name or raw not in RUNNING_STATES:
continue
running.add((kind, name))
return running

View File

@@ -336,6 +336,44 @@
<!-- Notes Section -->
<h3 class="form-section-title">Notes</h3>
<!-- Maintenance. Shown on the detail page since it was added but never
editable, so a wrong date needed database access to correct. These
are the columns a measuring instrument's calibration dates map onto
when metrology moves to machines, which is the other reason they
have to be enterable. -->
<div class="form-group">
<label for="lastmaintenancedate">Last Maintenance</label>
<input
id="lastmaintenancedate"
v-model="form.lastmaintenancedate"
type="date"
class="form-control"
/>
</div>
<div class="form-group">
<label for="nextmaintenancedate">Next Maintenance</label>
<input
id="nextmaintenancedate"
v-model="form.nextmaintenancedate"
type="date"
class="form-control"
/>
<small class="muted">Set directly; it is not derived from the interval.</small>
</div>
<div class="form-group">
<label for="maintenanceintervaldays">Maintenance Interval (days)</label>
<input
id="maintenanceintervaldays"
v-model.number="form.maintenanceintervaldays"
type="number"
min="1"
class="form-control"
placeholder="e.g. 180"
/>
</div>
<div class="form-group">
<textarea
id="notes"
@@ -420,6 +458,9 @@ const form = ref({
businessunitid: '',
requiresmanualconfig: false,
islocationonly: false,
lastmaintenancedate: '',
nextmaintenancedate: '',
maintenanceintervaldays: null,
notes: '',
mapx: null,
mapy: null,
@@ -613,6 +654,9 @@ onMounted(async () => {
businessunitid: data.businessunitid || '',
requiresmanualconfig: data.machine?.requiresmanualconfig || false,
islocationonly: data.machine?.islocationonly || false,
lastmaintenancedate: (data.machine?.lastmaintenancedate || '').slice(0, 10),
nextmaintenancedate: (data.machine?.nextmaintenancedate || '').slice(0, 10),
maintenanceintervaldays: data.machine?.maintenanceintervaldays ?? null,
notes: data.notes || '',
mapx: data.mapx ?? null,
mapy: data.mapy ?? null,
@@ -714,6 +758,9 @@ async function saveMachine() {
businessunitid: form.value.businessunitid || null,
requiresmanualconfig: form.value.requiresmanualconfig,
islocationonly: form.value.islocationonly,
lastmaintenancedate: form.value.lastmaintenancedate || null,
nextmaintenancedate: form.value.nextmaintenancedate || null,
maintenanceintervaldays: form.value.maintenanceintervaldays || null,
notes: form.value.notes || null,
mapx: form.value.mapx,
mapy: form.value.mapy

View File

@@ -3,7 +3,7 @@
import logging
import re
from flask import Blueprint, request, Response
from flask import Blueprint, request, Response, current_app
from flask_jwt_extended import jwt_required
from shopdb.api import db, cache, Setting, Asset, AssetType, Vendor, Model, Communication, CommunicationType, AssetRelationship, RelationshipType, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
@@ -380,6 +380,24 @@ def printer_install_list():
UNIVERSAL_INSTALL_VENDORS = frozenset({'HP', 'Xerox'})
# A hostname and nothing else: letters, digits, dot, hyphen, optional :port.
# Anything outside this cannot reach the generated script, so the quote that
# would end the PowerShell literal never gets the chance.
_HOST_RE = re.compile(r'^[A-Za-z0-9.-]+(?::[0-9]{1,5})?$')
def _safe_host(value):
"""A request-supplied host, or None when it is not one.
The value reaches a single-quoted PowerShell -Uri literal in the generated
.bat, so it is validated rather than escaped: an allowlist of what a host
may contain is a smaller thing to get right than enumerating what a shell
does with the rest.
"""
host = (value or '').strip()
return host if host and _HOST_RE.match(host) else None
def _batch_base_url():
"""Base URL the generated .bat downloads installers from. Prefer the
configured site_base_url (it already includes scheme + the /shopdb mount).
@@ -387,12 +405,34 @@ def _batch_base_url():
Fallback matters: behind IIS the app sees http on a loopback port and its
url_root drops the mount, so a naive request.url_root yields a broken
http://127.0.0.1/installers/... URL. Rebuild from the forwarded Host + the
mount (script_root) and force https instead."""
mount (script_root) and force https instead.
THE FALLBACK IS THE DEFAULT PATH, not an edge case: site_base_url ships
blank. So the host it uses is caller-supplied on every fresh install, and
it lands unescaped inside a single-quoted PowerShell literal. X-Forwarded-Host
is only believed when a proxy is actually declared (TRUSTED_PROXY_COUNT) -
the same rule shopdb/utils/clientip.py applies to X-Forwarded-For, which was
centralised for this exact reason and did not cover the Host variant. Either
way the value is validated as a hostname before it is interpolated, and a
request that supplies neither a usable forwarded host nor a usable Host gets
a relative base rather than a poisoned absolute one.
"""
base = (Setting.get('site_base_url') or '').strip().rstrip('/')
if base:
return base
host = request.headers.get('X-Forwarded-Host') or request.host
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:
# Same-origin relative URLs still download correctly from the browser
# that fetched the script. Better than emitting a host we cannot vouch
# for into a file that runs commands.
return root
return 'https://%s%s' % (host, root)

View File

@@ -223,6 +223,28 @@
</div>
</div>
<!-- Capabilities. On the detail page since they were added but never
editable, so a wrong one needed database access to correct.
Nothing writes these automatically - the collector reports queues
and supplies, not capabilities - so a person is the only source. -->
<div class="form-group">
<label>Capabilities</label>
<div class="capability-list">
<label class="capability-item">
<input type="checkbox" v-model="form.iscolor" />
<span>Colour</span>
</label>
<label class="capability-item">
<input type="checkbox" v-model="form.isduplex" />
<span>Duplex</span>
</label>
<label class="capability-item">
<input type="checkbox" v-model="form.isnetwork" />
<span>Network</span>
</label>
</div>
</div>
<div class="form-group">
<label for="installpath">Driver Install Path</label>
<input
@@ -361,7 +383,12 @@ const form = ref({
ipaddress: '',
csfname: '',
installpath: '',
pin: ''
pin: '',
iscolor: false,
isduplex: false,
// true to match the create default in the API; most printers here are
// networked and a new record that claimed otherwise would be wrong.
isnetwork: true
})
const printerTypes = ref([])
@@ -559,6 +586,9 @@ onMounted(async () => {
modelnumberid: ext.modelnumberid || '',
locationid: printer.locationid || '',
notes: printer.notes || '',
iscolor: !!printer.printer?.iscolor,
isduplex: !!printer.printer?.isduplex,
isnetwork: printer.printer?.isnetwork !== false,
mapx: printer.mapx ?? null,
mapy: printer.mapy ?? null,
levelid: printer.levelid ?? null,
@@ -652,6 +682,9 @@ async function savePrinter() {
installpath: form.value.installpath || null,
pin: form.value.pin || null,
ipaddress: form.value.ipaddress || null,
iscolor: !!form.value.iscolor,
isduplex: !!form.value.isduplex,
isnetwork: !!form.value.isnetwork,
mapx: form.value.mapx,
mapy: form.value.mapy
}
@@ -688,6 +721,9 @@ async function savePrinter() {
</script>
<style scoped>
.capability-list { display: flex; flex-wrap: wrap; gap: 1rem; }
.capability-item { display: inline-flex; align-items: center; gap: 0.4rem; font-weight: normal; }
.map-location-control {
display: flex;
align-items: center;