The list mixed both kinds of configuration policy into one table, so the only
way to tell a shipped default from something the site had written was to read
the source column. Split them into two tables under their own headings.
The split is presentational only. Both tables render the same Edit and Delete
buttons gated on the same iseditable(p) rule, so a shipped policy is not
stranded behind a dead button the way it was when the buttons gated on
source === 'local'.
The spec counted exactly two disabled bindings, which the second table would
have broken while the behaviour it was guarding stayed correct. It now counts
Edit and Delete in pairs and asserts the two always agree, so adding a group
does not fail a test about button gating.
The sync route asked Dell about every pending service tag in one request.
bulk_lookup sends 100 tags per HTTP call at a 15 second timeout, so a
freshly-onboarded site was dozens of sequential calls inside a single web
request. A gunicorn worker timeout or a proxy would plausibly kill it first,
leaving the user with a failure after minutes and no way to tell what landed.
F3 released the pooled connection before the vendor call, which stopped one
sync from taking the app down, but did not make the request itself survivable.
Extract the sync into a service taking a per-run tag limit. The route now caps
at 200 tags - two Dell calls - and returns how many are still pending so the
caller can run it again. A flask warranty sync-dell command does the same work
from the command line for a large first-run backfill.
The chunk is sampled at random rather than taken off a stable order. A serial
Dell does not recognize never gains a warranty and so stays pending forever;
under a stable order those dead tags would occupy every chunk and the sync
would never reach the rest of the fleet.
Nothing runs this on a schedule. The operation is used a few times a year, so
a cron would idle-poll the warranty tables tens of thousands of times a year
for nothing and log a configuration failure on every run at sites without Dell
credentials.
The two existing sync tests patched get_provider on the route module; the
provider is now resolved in the service, so they patch it there.
Both Dell routes held a pooled connection open for the duration of the
vendor HTTP call. sync_dell was the severe case: bulk_lookup asks Dell
about 100 service tags per request and calls them sequentially, so a
fleet-sized sync pinned one connection for minutes. On gevent workers
that exhausts the pool and fails every request in the application, not
just warranty.
Copy the values the lookup needs into locals, commit to return the
connection, then reacquire for the writes. The locals are load-bearing:
a commit expires loaded attributes, so reading them afterwards would
quietly check a connection back out and restore the defect.
Preload the existing warranty links in one query. The write loop ran a
query per asset plus one per link, which would have traded the long
network hold for a long write transaction.
Transaction boundaries do not show up in behavioural tests, so the new
tests assert session state at the moment the provider is called. Both
were confirmed to fail against the unfixed code, including the
post-commit attribute-access variant that leaves all other tests green.
Single commit and the existing error handling are unchanged, so the
sync stays all-or-nothing.
Report IP is an icon on every Public Desktop in the company, so a help desk
caller asking for it to be re-run is asking for something they could already
do themselves at the keyboard. Getting there through ShopDB meant holding
endpoints.runscript, which is the permission that lets someone push ANY script
in the library - the agent self-upgrade included. Handing that over to run a
desktop shortcut is the bundling the permission split was created to take
apart, so the work was simply not being done through the tool.
The allowance is on one named script, never on the run_script action. The
script id rides in the caller-controlled request body, so relaxing the action
would grant the whole library and quietly rebuild the endpoints.deploy bundle.
The enqueue route resolves the target to a real Script row first and matches
the STORED name, because matching what the requester typed would let anyone
name their way past the grant.
Granting on a name is only defensible while the name cannot be made to mean
something else. The body is source-controlled at
plugins/endpoints/api/templates/report-ip-shortcut.ps1, seeded from there,
pinned by SHA-256 on delivery, and the managed fields are re-asserted on every
seed - so an operator cannot flip the row to runcontext=system, deactivate it,
or edit the body and leave the name still pushable.
The template lives under the plugin rather than agent/ because the image copies
plugins/ but not agent/: reading it from agent/ works on a developer checkout
and fails in the deployed container.
The script runs in the signed-in user's session. This is not a preference.
Launched as SYSTEM the tool re-enters the interactive session and the launcher
exits 0 immediately, reporting success having observed nothing - the same
session-0 boundary the collector already documents for mapped drives. User
context fails closed when nobody is logged on, which is the honest answer.
The seed runs in the migrate service alongside the other seeders, so an
existing site picks the script up on the upgrade it already performs.
Turning "Shared key allowed" off refused the key on the endpoints plugin and
left four core collector routes open to it. Anything still holding the old key
could keep rewriting logged-in user, last boot time, serial number and installed
app inventory for ANY hostname, while the admin believed the shared key was off.
Data integrity only - no command execution, no credential issuance - but it is a
hole in a boundary the product advertises as closed.
It cannot simply be closed. Both GE-Enforce reporters still authenticate with
that key, so refusing it fleet-wide would stop asset and printer reporting
everywhere, and on an air-gapped site mid-migration that is the wrong order of
operations. collector.legacysharedkeyrefused therefore DEFAULTS TO TODAY'S
BEHAVIOUR, and its description names exactly what turning it on breaks and when
it should be turned on: after the reporters move to per-PC credentials, not
before.
A legacy route used with the shared key now logs a warning, so a site can find
out who still depends on the key before flipping anything. On a stock Windows
deployment that warning may name the proxy rather than the caller; the token
prefix in the same line is the identifier to go by.
The switch is seeded with the core defaults and has a control on the
authentication settings page. Undeclared, it was a row that did not exist, and
this UI has no generic key/value editor, so seeding alone would have rendered
nothing for an operator to click.
Two contract declarations corrected while in here:
notifications.alertscope was declared db.JSON in the model while the migration
that creates it ships TEXT, and those migrations state the constraint in their
own docstrings: no JSON columns. The model now matches the shipped column rather
than the schema being bent to the model.
usb and printedparts both import a name that arrived in contract 0.35.0 while
declaring core_version floors of 0.1.0 and 0.16.0, so the loader would have
accepted them on a core that cannot satisfy them - an ImportError at blueprint
registration instead of a refusal with a reason. Both floors now say what the
plugin actually needs.
Both kiosks admit a check-in or check-out with no login when the caller sits in
a configured network. The subtraction that makes that safe was missing.
A reverse proxy normally sits ON the plant network a site enters in the
allowlist. When the proxy forwards nothing - misconfigured, or something
bypassed it - the request arrives wearing the proxy's own address, which matches
the allowlist, while carrying no evidence whatever about who sent it. That is
the one case that has to refuse, and it was being admitted. The previous
fail-closed test only looked closed because the proxy in it happened to sit
outside the range.
Addresses that belong to the path are now subtracted before the allowlist is
consulted: loopback always, since no site's kiosk is the server itself, plus
whatever the site declares as its proxies. USB and printedparts get the same
treatment - they had diverged for no reason anybody chose.
Three more, same surface:
A range that admits everybody can no longer be saved by accident. 0.0.0.0/0 and
::/0 are refused outright, anything wider than a site plausibly needs is refused
unless an explicit override is set, and the refusal says why.
The kiosk token no longer travels in a URL. The provisioning link carries a
single-use setup code redeemed on first open, so the token stays out of browser
history, referrers and proxy logs. The route is declared public deliberately:
the secret is the code, the way a password-reset link is the secret.
The badge-to-name lookup is bounded. It answers without a login and is now
reachable from a whole network rather than one screen, so a source that keeps
missing gets refused rather than being allowed to walk the directory. On by
default, budget and kill switch are settings.
All four new settings carry controls on their settings pages. A setting that can
stop the kiosk, or widen it, must not be reachable only by hand-editing rows.
The installer's client-IP advice is corrected: it described the opposite of what
the shipped waitress flags do, and named only GE-Enforce as the thing that
breaks when the rule is absent, when the two kiosk allowlists depend on it too.
The bootstrap command staged its script into C:\Windows\Temp and then ran it
elevated. That directory grants Users create-file with CREATOR OWNER full
control, so somebody without admin rights can pre-plant the exact path the
elevated run is about to execute. It now stages under Program Files, creates the
directory with an explicit DACL when it is missing, and refuses to run a staged
file that is not owned by an admin principal. A failed hardening is loud rather
than silent: the run stops instead of carrying on with a file it could not
protect.
The token moved out of the query string into a header. In a query string it
lands verbatim in the reverse proxy and IIS logs, which is a credential written
to a file nobody thinks of as a secret store. The query form is still accepted,
because bays provisioned before this change are holding install commands that
use it, and it logs that it was used - without logging the token - so a site can
see who still needs a re-issued command before turning it off with
plugin.endpoints.bootstrapquerytoken.
Two smaller things this also closes:
A request with no token at all answered 400 naming the header, which told an
anonymous caller the state of that switch and contradicted the module's own
promise that anything unusable answers 404. No token is now 404. The 400 is kept
for a token genuinely presented in the query form while the query form is off,
which is a caller who needs to know their command is stale.
The rollout documentation was republishing the vulnerable one-liner verbatim, so
a tech following the runbook reinstated exactly what this fixes. Both documents
now carry the current command.
Also corrects the operator-facing copy for the token, which named a query
argument and a header that do not exist: it is ?t= and X-Endpoint-Bootstrap-Token,
per bootstrap.py's own constants. Anybody following the old text got a 404 with
no reason given.
Both switches added in this range are now declared with the other plugin
settings and reachable from their settings page. Read through Setting.get but
never declared, they were rows that did not exist until someone wrote one by
hand, which on an air-gapped box means the feature was unreachable.
Three holes in the enrollment path, all of the same shape: a decision the admin
made did not survive contact with a PC that simply asked again.
REJECTION WAS UNDONE BY A NEW NONCE. A rejected request re-armed itself as a
fresh cycle when the PC came back with a different nonce, and with the shared
key and auto-enroll on it was then issued the credential the admin had just
refused. Rejection now binds to the endpoint identity - hostname plus BIOS
serial - not to the nonce, so the PC stays refused however often it reinstalls.
An admin clears it deliberately, through the approve path that already exists; a
genuinely different PC is untouched.
THE PENDING QUEUE COULD BE FILLED UNTIL IT REFUSED EVERY NEW PC. /enroll takes
no auth by design - a bay that has never spoken to the server has nothing to
authenticate with - so anything that can reach the server could sit at the cap
and lock out real enrollments fleet-wide. Rows nobody has touched for seven days
now age out before the cap refuses anyone, and the count of what went is
audited. Per-identity dedup and the cap itself were already there and are left
alone.
REVOKE DID NOT STOP A LIVE AGENT. It fell back to the shared key and the server
admitted it again, so revoking a compromised PC changed nothing while that key
still worked. plugin.endpoints.revokeddeniessharedkey makes a revoked host
refused on the shared key too.
That switch DEFAULTS OFF, and that is the important part. Turning it on would
silence any bay revoked for a key rotation, and would break the documented
revoke-then-rollback path to an older agent - on an air-gapped site, mid
migration, that is a fleet outage. A never-enrolled bay is never affected either
way: with no credential row at all the shared key still works, which is the
whole un-migrated fleet.
The checkbox sits next to the other two enrollment switches and says what
turning it on costs, because a setting that can stop the floor should not be
reachable only through the API.
The generated install.ps1 wrote the collector key to HKLM\SOFTWARE\GE\ShopDB and
left it at the DACL the key inherits, which grants BUILTIN\Users read. Any
operator logged on to any bay could read the fleet key, and that key still
authenticates an un-enrolled PC: it claims queued commands and downloads script
bodies and installer packages. The per-PC credential work exists to retire that
key, but it stays until every consumer is off it, so meanwhile what matters is
who can read it.
Protect-HklmShopdbKey breaks inheritance, DROPS the inherited entries rather
than copying them - copying is what silently preserves the Users read - and
states SYSTEM and Administrators by SID in both registry views. By SID, so a
localised Windows still matches.
Everything that legitimately reads this key is SYSTEM (the service, the
GE-Enforce reporters) or an administrator (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.
It runs for the key-less package too, because ServerPins live in the same key
and the registry is authoritative for pinning: a standard user must not be able
to rewrite the pins either.
A bay that cannot take the ACL still installs. It says so, loudly, rather than
failing the install or staying quiet, because an agent that is not installed is
a worse outcome than a key that is still readable on that one machine.
Note what this does NOT fix: bays that already carry the key keep their
inherited DACL until they are reinstalled or upgraded through a package built
after this change.
The USB audit answered who had which drive and when, but not whether it came
back wiped or virus-scanned. That is the question the record is kept to answer:
removable-media custody is CMMC evidence, and custody without the state of the
media on return is half the story. Both stores already held the values - the
external checkinoutlog in sanitized and scanned_viruses, the app's own
usbcheckouts in waswiped and scannedviruses - and neither mode selected them.
Both now report them, in the JSON, the CSV and the column list, so the two
directory modes still answer in one voice.
Two things the rendering has to get right:
- Only a return carries them. The wipe and the scan happen when the drive comes
back, so a check-out row shows blanks rather than implying a fact about an
event that had not happened yet.
- Unknown is not "no". Both stores keep these nullable deliberately: a null
means nobody wrote it down, which is a different claim from a drive that came
back unwiped. Rendering an unrecorded return as "No" would put an assertion
in an audit record that no one ever made, so unknown stays blank and reads as
the gap it is. A test holds that, because "no" is the tempting default.
auditflag lives in selfhosted.py rather than routes.py: routes already imports
selfhosted, so the helper had to sit on that side or the import would be
circular.
Two failures from the same root: an administrator cannot see the kiosk token,
and a screen that was provisioned wrongly does not say so.
The settings API masks every key whose name contains 'token', so the settings
page shows '********'. The real value is visible for one moment - after
Generate, before Save - and an administrator who copies the field at any other
time provisions a screen with the literal mask. The kiosk then answers 404,
exactly as it answers a caller it has never heard of, because the route
deliberately does not advertise itself. Nothing anywhere says what went wrong.
- Each kiosk gains a permission-gated provisioning-link route that returns the
real setup URL, token included, to somebody who already holds settings.edit.
The mask stays: a token has no business in an ordinary settings list. The
copy step just stops depending on catching the one moment it is on screen.
Revealing it writes an audit row, since that is when the value left the
database. Both settings pages get a "Copy setup link" button, which falls
back to showing the link when the clipboard is refused - it is, over plain
http and in locked-down browsers.
- The USB kiosk asks the server on load whether this screen may be used, and
says plainly when it may not. It rendered a keypad and only failed once
somebody had typed their SSO, which reads as broken rather than as not set
up. It cannot guess: the enabled flag is public and only says the site has a
kiosk, and a caller admitted by network holds no token to look for, so the
parts kiosk's localStorage check would warn exactly the callers the allowlist
exists to serve. The probe is a badge-less session request - the gate runs
before any badge handling, so refused is 404 and admitted is the validation
error, with no lookup and nothing recorded. A test pins that ordering,
because reordering it would start telling admitted screens they are not set
up.
Verified in a browser, not only in tests: provisioning with the mask lands on
the refused screen, a correct token gets the drive list and survives a plain
revisit, and a tokenless caller inside the allowlist gets straight in.
The device token assumes a handful of fixed screens an administrator
provisions once. That is not how these kiosks are used: most people open the
page on their own PC, and there is no login, so per-browser provisioning would
mean touching every PC on the floor and losing it again on the next reimage or
cleared profile. A screen in that state did not look refused either - the USB
kiosk rendered a working page whose every call answered 404.
Both kiosks now admit a caller on either ground, the same shape the GE-Enforce
client endpoints already use: the per-device token, or a source address inside
an allowlist. Wall screens keep the token and are not confined to the
allowlist; everyone else is admitted by where they are on the network. Still
gated on the site's enable flag, so nothing opens by itself, and both
allowlists ship empty - token-only until a site enters its ranges.
- shopdb.api gains ip_in_cidrs and request_ip_allowlisted (contract 0.35.0).
On the surface because both plugins need the identical gate, and a CIDR
parser copied into two of them is where a fail-open creeps in later. They
fail closed on an empty setting, an unparseable caller address and a
malformed entry, and they read remote_addr rather than raw X-Forwarded-For -
so the IIS rewrite rule is what makes them meaningful, which the module says
at length.
- Separate settings per kiosk, usb_kiosk_allowed_cidrs and
printedparts_kiosk_allowed_cidrs. One must not grant the other, for the same
reason the tokens are separate.
- Both settings pages get a picker listing the network plugin's subnets with
their VLANs. It stores resolved CIDR strings, not subnet ids: the setting has
to work at a site that does not run that plugin and must not break when an
inventory row is deleted. Entries matching no subnet are shown rather than
hidden, since a stale one left by an inventory edit looks exactly like a
hand-typed range.
- USBKiosk says it was not admitted instead of pretending to work, which
PartsKiosk already did.
- The USB settings page keyed text-vs-boolean off the token alone. Adding a
second text setting to the boolean path would have saved it as the word
'true' - the bug that replaced the kiosk token and 404'd every provisioned
screen. Both load and save now key off a list, with a spec on it.
The trade, stated plainly in the admin UI: anyone on an allowed network can
check a drive in or out against a badge number they guess, and the record
carries that person's name. Badges are attribution, not authorization. Keeping
the ranges narrow is what bounds it; login is the alternative and was ruled out
because these users do not sign in.
Findings F2, F4, F5, F6, F7, F8, F9, F10, F11 and F13 from
wj-architectural-review.md. F1/F1a (the non-sargable LOWER pattern) and F3
(the warranty sync holding a pooled connection) are being handled separately
and are untouched here.
- A collector check-in that raced another for the same PC lost everything:
the unique index on the application fingerprint raised, nothing caught it,
and the whole check-in rolled back, discarding state, events and hardware,
answering the agent with form copy meant for a person. The insert now runs
in a savepoint, reconciles what the other transaction already wrote, one row
per savepoint so a second collision skips a row rather than the batch, and
the check-in commits with a warning. This is the shape the delete side of
the same function already had.
- Enrollment is deliberately unauthenticated, so it now carries a per-address
rate limit and a cap on pending requests, and the cap is a setting an admin
can see and change. A flood can no longer bury the approval queue or grow
the table without bound.
- The fleet alert poll no longer formats debug strings on every request.
- BusinessUnit is on the contract surface (0.34.0), so the endpoints plugin
stops reaching into a core table with raw SQL.
- A test walks every asset type descriptor and proves the endpoint it names
really refuses a caller without the declared permission.
- The plugin settings routes now need a permission; they were telling anyone
which modules a site runs and where its components live.
- The connection pool is sized explicitly with a short timeout, and the
arithmetic against the server's connection ceiling is written next to the
worker count instead of being left to defaults nobody chose.
- Fleet alerts carry both the correct lowercase keys and the old camelCase
pair. Trays older than 1.7.0 match keys case-sensitively and would have gone
quiet on every PC not yet upgraded; the old pair goes when they are.
- The dev server proxy points at localhost again, with environment variables
for the remote-box workflow, and one developer's address is out of the
server certificate page.
All three found testing the bootstrap installer on Windows.
- The published command used the irm-pipe-iex form, which Microsoft Defender
detects as Trojan:Win32/Commando.A!ml and kills before it runs, every time,
on a default install. The command now downloads the script to a file, runs
it, and removes it in a finally block, which Defender does not object to.
The UI also offers the script as a plain download for a tech who would
rather save it.
- Approving a request only linked the asset, so a PC whose pending reason was
"unknown hostname" - which is every PC installed this way - kept getting 403
on every call afterwards, because its computer record had no hostname to
match the credential against. Approval now stamps the hostname when the
record has none, refuses with a 409 naming both when the record has a
different one, and overwrites only when the admin confirms. The Credentials
tab offers that confirmation.
- A hostname that resolves to no record now says so instead of reading as an
impostor, while still refusing the request.
- While waiting for approval the agent retried on the 15-minute telemetry
cadence, so a PC approved immediately enrolled a quarter of an hour later
and the installer always ended on "still pending". It now retries about
every minute for the first ten minutes, then every five to thirty minutes,
then every fifteen, with jitter. A rate limit or server error still uses the
slower backoff.
An admin generates a short-lived token in Settings > Endpoints > Endpoints and
copies the command it produces. A tech runs that one line in an elevated
PowerShell on a PC with no agent:
powershell -ExecutionPolicy Bypass -c "irm https://<server>/shopdb/api/
endpoints/siteagent/bootstrap.ps1?t=<token> | iex"
The script refuses to run unelevated, refuses a PC that already has the agent
(a managed bay gets it from GE-Enforce), downloads the package the existing
builder produces, checks both binaries against the hashes the server baked in,
installs with NO shared key, and reports what happened. The PC then asks to
enroll and waits for an admin to approve it, which the script watches for
locally for two minutes before exiting.
- New endpointbootstraptokens table and migration
endpoints0024bootstraptokens: hash only, expiry, use count, revoke.
- Admin routes to generate, list and revoke tokens; the secret is shown once.
- Two token-only routes: the script, and the package (which spends a use).
Anything wrong with the token answers 404 rather than confirming it exists,
both are rate limited per address and both are audited with the caller.
- The package builder is now shared rather than duplicated, and a bootstrap
package can never carry a collector key.
The page saved every field as 'true'/'false', the kiosk device token
included, and loaded the token as a boolean. One save of Settings > USB
Devices therefore stored the literal word "true" (or "false") as the token,
and every kiosk provisioned with the real token got 404 on each request -
reported as the kiosk no longer accepting an SSO. Generate + Save never
stored the generated value either.
The token now loads and saves as text. The server already keeps the stored
token when it receives the mask back unchanged. The two switches still save
as booleans. The parts kiosk page was not affected.
Found in the Windows VM check.
- The agent upgrade script wrapped Get-ShopDbServerPins in @(), which turned
several registry pins into one joined string. During a certificate rotation
the upgrade download would have refused the rotated server. It also merged
appsettings pins with the registry; it now follows the agent's rule: when the
ServerPins value exists, even empty, the registry owns the list. The shared
pin block in the upgrade script and both reporters gains
Test-ShopDbServerPinsValue.
- A key-less package installed over an enrolled PC printed both "existing
enrolled credential found; kept as is" and "This PC will request
enrollment". The enrollment notices now print only when no credential exists.
Found in the Windows VM re-test.
- A CA-flagged server certificate (common with a plain openssl self-signed
cert) produced an installer package with no pin at all, so agent 1.20
refused the server. The package now always carries the SPKI pin of the
certificate PCs receive; the CA flag only skips the trusted-root import.
When an uploaded CA-flagged certificate differs from the server's own, both
pins are included. The build response carries X-ShopDB-Build-Warning and the
installer logs what older agents need.
- Pins written into appsettings.json by the installer were merged with the
registry, so removing a pin from HKLM never took effect. When the ServerPins
value exists in the registry it is now authoritative, even when empty;
appsettings pins apply only when it is absent. Set-ShopDBServerPins.ps1
-Clear deletes the value; new -None writes an empty value (no pins).
- A script or package download sent with a credential revoked moments earlier
got 401 CREDENTIAL_INVALID and failed the command. The agent now retries that
download once with the shared key and records the fallback in the result.
Found in the Windows VM re-test.
- A PC installed without the shared key got 401 from POST
/api/endpoints/enroll while the shared key was allowed, so no request was
recorded and no admin could approve it. Enroll now never refuses: a valid
shared key only decides auto-issue on a clean match; no key or an invalid
key creates or updates the one pending request for that hostname and
serial. An approved request still issues only to its own nonce.
- A PC blocked with "server certificate not trusted by this PC" could never be
queued again after trust was fixed, because the blocker comes from its last
failed upgrade. Naming the PC in a wave now queues it when trust is its only
blocker, and the response lists it under "retried"; "next n" still skips
it. The Migration table lets such a PC be selected.
- counts.pending on GET /enrollment counts pending requests, including ones
for unknown hostnames; counts.pendingcomputers keeps the per-computer figure.
- Endpoints (shared key) page: the key is presented as what PCs use until
they enroll, with enrollment progress, a link to Enrollment, rewritten
guidance, and rotate/revoke confirmations that say how many PCs are not
enrolled yet.
- Settings cards describe the server certificate as what PCs actually
receive, on IIS or Docker; Enrollment has its own icon (the icon set lacked
the one the Server Certificate card already asked for).
- Server Certificate page checks the certificate the site presents at its
public address and compares its pin with the certificate file, warning when
they differ. The host comes from site settings only; the check is a TLS
handshake, never an HTTP request.
- Fleet list: Credential column and filter (enrolled, pending, re-enroll
blocked, shared key, not reporting), computed in the list query.
- PC detail: credential state, prefix and dates for fleet readers, state only
for others, with admin Revoke (optionally blocking re-enrollment) and Allow
re-enrollment.
- Dashboard: pending enrollment requests, and PCs blocked from re-enrolling
for more than seven days.
- Runbook: where to find each of these in the app.
Found in an end-to-end test on a Windows 11 VM against a self-signed server.
- Rollback: a 1.20 agent re-enrolls within a second of an un-enroll, so an
older agent pushed afterwards arrived on an enrolled PC and was refused.
Revoke now takes {"blockreenroll": true, "reason": ...}; a blocked PC is
never issued a credential and keeps working on the shared key until
POST .../assets/<assetid>/allow-reenroll. The Enrollment page offers the
block when un-enrolling and shows blocked PCs with an allow button.
- First upgrade of old agents: agents before 1.20 check the server
certificate when downloading the upgrade, so a store wave failed on PCs that
do not trust a self-signed server while the Migration tab said ready. A
failed download now marks the PC blocked with "server certificate not
trusted by this PC". New Install-ShopDBServerCertificate.ps1 (GE-Enforce,
SYSTEM) adds a non-CA server certificate to the trusted roots after checking
its thumbprint; the server certificate page offers a .cer download and the
SHA-1 thumbprint.
- The generated installer writes ServerPins (with an optional next pin for a
planned rotation), can leave the shared key out so the PC waits for
approval, never touches an existing credential, and skips the trusted-root
import for a CA certificate.
- The agent re-reads server pins about every 60 seconds and recycles pooled
connections within 2 minutes, so a pin change needs no restart.
- An alert can only be acknowledged by a PC it targets.
- A wave no longer counts a PC as failed after a later wave upgraded it.
- The scripts report a pin mismatch only when the pin really is missing, and
otherwise log the real error.
- Runbook: trust step before the first wave, rollback order, pin re-read,
verifying the pin against the live URL; agent contract updated.
- Settings > Endpoints > Enrollment. Migration tab: readiness counts and
progress, each PC's state with the reasons it is blocked, "Upgrade
selected" and "Upgrade next 25", and live wave progress. Credentials tab:
enrolled PCs with revoke, pending requests with approve and reject, and the
shared key and auto-enroll switches. Turning the shared key off asks for
confirmation and shows how many PCs are not enrolled.
- The server certificate page shows the SPKI pin to put in the GE-Enforce
pin entry.
- The agent upgrade script and the asset and printer reporters send the PC's
credential when it has one (over https, only to the issuing server), fall
back to the shared key, check server pins, and log refusals clearly.
- New Set-ShopDBServerPins.ps1 (GE-Enforce entry) and Get-ShopDBServerPin.ps1.
- AGENT-CONTRACT.md documents per-PC credentials; the rollout runbook covers
phases, gates, rollback, pin and wave entries, and every place the shared
key lives and how to clear it at cutover.
- Removed the 09-14 certificate design notes and dated session reports, and
two VM notes that carried a live collector key and advised plain http.
- Corrected the GE-Enforce collector note, which claimed a plain registry
token is protected by ACL.
Per-PC credentials (replaces the 09-14 client-certificate attempt):
- POST /api/endpoints/enroll issues a credential to a PC that matches exactly
one computer by hostname and BIOS serial and has none yet. Anything else
(unknown or duplicate hostname, serial mismatch, already enrolled) waits as
a request an admin approves or rejects. The agent's persisted nonce binds an
approved request to the PC that asked.
- One auth helper serves every agent route. With X-Endpoint-Credential the
asset comes from the credential and a request naming another PC gets 403
ASSET_MISMATCH. The shared key still works for PCs that have not enrolled,
is refused (401 SHARED_KEY_REFUSED) for any PC that has, and can be switched
off. A revoked credential gets 401 CREDENTIAL_INVALID so the agent
re-enrolls.
- The core collector accepts the credential for the computers, printers and
endpoints collectors through two new plugin hooks exposed on shopdb.api
(contract 0.33.0), and refuses the shared key for enrolled PCs.
- Admin API: enrollment list, approve, reject, revoke (un-enroll), settings.
Migration API: per-PC readiness with blockers, and upgrade waves that
package the agent once and queue the existing agent upgrade per PC.
- Migration 0023 creates endpointcredentials, endpointenrollrequests and
endpointmigrationwaves, drops the certificate columns from assets where
test databases have them, and renames app_fingerprint to appfingerprint.
0021 and 0022 keep their ids but no longer alter the core assets table.
- Removed: client_cert.py, certificate renewal, the unauthenticated
/test/client-cert route, nginx client certificate forwarding and the agent
certificate script. The client-cert header could be forged on IIS, the key
was readable by users, and the shared key remained a fallback.
- GET /settings/server-cert returns the SPKI SHA-256 pin for GE-Enforce.
Review fixes:
- report_command_result used g without importing it; every result was a 500.
- collect_log refuses setup answer files, appsettings, agent directories and
key files at enqueue.
- Fleet alerts: alerttimeout and alertfleet agree with the migration, the
scope is validated and a malformed scope no longer reaches the whole fleet,
and the ack race no longer raises NameError.
- A refused break-glass override leaves no hold and no audit row.
- Agent upgrade checks locks and permissions before repackaging, and writes
the package atomically.
- Server certificate upload validates the pair before replacing files;
generation no longer builds an openssl config from user input; audit rows
are actually written.
- Health evaluation runs in a savepoint so a caught error cannot lose the
check-in.
- Required-app policies are found by appid, survive renames, and deleting an
app deactivates policies that still name it. The UI and API now say such a
policy only takes effect inside a business policy.
- Backfill handles duplicate fingerprints and rolls back a failed batch.
- Plugin settings routes skip a plugin whose hook raises.
- The setup token is no longer printed to the log.
- The naming gate checks top-level files again and handles odd file names.
- Removed root scratch scripts, one of which carried a token.
- Rewrote the fleet alert scheduling and app delta tests on real fixtures.
Removed .with_for_update() from _replace_applications() which was
acquiring row-level locks on every collector check-in (800/hour).
These locks were holding during fingerprint comparison, blocking
concurrent reads and causing severe performance degradation.
The UNIQUE constraint on (assetid, app_fingerprint) already provides
sufficient concurrency protection without explicit row locks.
Impact: Eliminates 800 lock acquisitions per hour, unblocks reads
during check-in processing.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit adds:
- Database migrations for app_delta fingerprints and client certificates
- Scripts for certificate generation and fingerprint backfilling
- Comprehensive security audit reports and findings tracking
- Implementation documentation for certificate authentication
- Test utilities for certificate renewal and fingerprinting
- GE-ENFORCE collector fix documentation
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
UX Fix: Code block contrast in dark mode
Problem:
- Code block with JSON example still white-on-white in dark mode
- User reported: 'still white text on white bg'
- Previous dark mode fix didn't override parent styles
Root Cause:
- Parent .field-hint and .info-list classes override .code-block
- Need !important to force dark mode colors
- <pre> tag selector needed more specificity
Solution:
- Added !important to dark mode color rules
- Added explicit selectors for pre.code-block
- Added nested selectors: .info-list .code-block, .field-hint .code-block
- Ensures dark mode applies regardless of parent context
Dark Mode Colors (enforced):
- background: #2b2b2b (dark gray)
- color: #e0e0e0 (light gray)
- border: #444 (medium gray)
Testing:
- Run: node scripts/stage-frontend.mjs
- Check: JSON code block in 'Agent certificate pinning' section
- Verify: Dark background, light text, readable in dark mode
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
UX Fix: Dark mode readability
Problem:
- Warning banner: light yellow background with dark text unreadable in dark mode
- Code blocks: light gray background with dark text unreadable in dark mode
- User reported: 'warning on darkmode not visible, light yellow background on white text'
Solution:
- Added @media (prefers-color-scheme: dark) styles
- Warning banner: dark background (#664d03), light text (#ffecb5)
- Code blocks: dark background (#2b2b2b), light text (#e0e0e0)
- Maintains good contrast in both light and dark themes
Colors:
- Light mode warning: #fff3cd background, #856404 text (WCAG AA compliant)
- Dark mode warning: #664d03 background, #ffecb5 text (high contrast)
- Light mode code: #f8f9fa background, #212529 text
- Dark mode code: #2b2b2b background, #e0e0e0 text
Deployment Note:
After editing plugins/*/frontend/ files, run:
node scripts/stage-frontend.mjs
This copies plugin frontends to frontend/src/.plugins-staged/
where Vite reads them (per ADR-013 plugin pruning).
Context:
- Missed running staging script initially (my error)
- Manual copy to .plugins-staged worked but wrong workflow
- .plugins-staged/ is .gitignored (generated, not committed)
- Proper workflow: edit source -> stage -> Vite hot-reloads
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Critical Bug Fix: Server cert settings page blank screen
Problem:
- Navigating to /settings/endpoints/server-cert showed blank page
- Both localhost:5173 and localhost:5001 affected
- Browser console showed authentication errors
Root Cause:
- ServerCertSettings.vue used raw fetch() without authentication
- API endpoint requires admin role (@require_role('admin'))
- fetch() didn't include JWT bearer token from cookies
- API returned 401 Unauthorized, component failed silently
Solution:
- Converted from Options API to Composition API (Vue 3 standard)
- Changed from raw fetch() to authenticated api client
- Used 'import api from @/api' (same as AgentKeySettings.vue)
Changes:
Before (broken):
export default {
methods: {
async loadCertificate() {
const response = await fetch('/api/endpoints/settings/server-cert')
// No auth headers = 401 error
}
}
}
After (working):
import { ref, onMounted } from 'vue'
import api from '@/api'
async function loadCertificate() {
const response = await api.get('/endpoints/settings/server-cert')
// api.get() includes JWT from cookie automatically
}
API Client Features:
- Automatically includes Authorization header with JWT
- Handles response parsing
- Consistent error handling
- Used by all other admin settings pages
Result:
- Page loads successfully
- Certificate details display
- Generate/upload forms work
- All admin-only endpoints authenticated
Tested:
- Hard refresh clears browser cache
- Page renders certificate details
- Generate/upload buttons functional
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Bug Fix: Fleet alert scheduling regression
Problem:
- User sets notification to run at 3:30 PM (future time)
- Fleet alert sent to agents immediately at 3:10 PM instead of waiting
- Agents see notification 20 minutes early
Root Cause:
- GET /api/endpoints/alerts query filtered for:
- alertfleet = True
- isactive = True
- endtime is NULL OR endtime > now (not expired)
- Not acknowledged
- MISSING CHECK: starttime is NULL OR starttime <= now (has started)
- Result: Any notification with future starttime appeared immediately
Solution:
- Added starttime filter to fleet alert query (routes.py:4151-4153)
- Query now checks:
db.or_(
Notification.starttime.is_(None),
Notification.starttime <= now
)
- Notifications with future starttime excluded until that time
Behavior After Fix:
- Notification with no starttime: appears immediately (existing behavior)
- Notification with past starttime: appears immediately (existing behavior)
- Notification with future starttime: waits until that time (FIXED)
- Notification with past endtime: never appears (existing behavior)
Testing:
- tests/test_plugins/test_endpoints_fleet_alert_scheduling.py
- test_fleet_alert_respects_future_starttime (regression test)
- test_fleet_alert_with_no_starttime_appears_immediately
- test_fleet_alert_past_starttime_appears
- test_fleet_alert_expired_does_not_appear
Timeline Example:
- 3:10 PM: User creates notification with starttime=3:30 PM, alertfleet=True
- 3:15 PM: Agent polls /api/endpoints/alerts -> empty (starttime not reached)
- 3:35 PM: Agent polls again -> alert appears (starttime has passed)
Impact:
- Agents poll every 60 seconds, so scheduled alerts appear within 1 minute of starttime
- No database migration needed (starttime column already exists)
- Backwards compatible (NULL starttime still means immediate)
Related:
- Notification.is_current property already checks starttime (models/notification.py:162-169)
- Shopfloor board respects starttime correctly
- Only fleet alert endpoint had this regression
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Addresses all critical and high-severity findings from Fable security review.
CRITICAL FIX#1: Server Certificate Validation Bypass
- File: CertificateRenewal.cs
- Issue: Agent accepted ANY server certificate when ServerCertThumbprint not configured
- Fix: Now validates against Windows trusted roots when pinning not configured
- Impact: Prevents MITM attacks during renewal requests
CRITICAL FIX#2: Certificate Revocation Check Incomplete
- Files: asset.py, client_cert.py, migration 0022
- Issue: Revocation check used hasattr() and silently skipped when columns missing
- Fix: Applied migration 0022, uncommented revocation columns, removed hasattr()
- Impact: Revoked certificates can no longer authenticate or renew
CRITICAL FIX#3: Expired Certificates Cannot Renew
- Files: routes.py (renewal endpoint), CertificateRenewal.cs
- Issue: Expired certs fail at TLS handshake before reaching renewal endpoint
- Fix: Added API key + hostname fallback for renewal when cert expired
- Agent now sends hostname in request body for API key authentication
- Impact: Agents can renew expired certificates, 30-day window becomes effective
HIGH FIX#4: Hostname Injection Vulnerability
- File: routes.py
- Issue: No validation before passing hostname to certificate generation
- Fix: Added regex validation - 1-64 chars, alphanumeric plus dot/hyphen/underscore
- Impact: Prevents injection attacks via malicious hostnames
HIGH FIX#5: Rate Limit Bypass via Clock Manipulation
- File: routes.py
- Issue: Naive UTC datetime arithmetic vulnerable to timezone changes
- Fix: Use timezone-aware datetime with explicit UTC conversion
- Impact: Rate limit cannot be bypassed via clock manipulation
HIGH FIX#6: Hard Process Termination Loses In-Flight Work
- File: Program.cs
- Issue: Environment.Exit(0) aborted in-flight work and could corrupt state
- Fix: Graceful return from RunHostAsync() instead of Environment.Exit()
- Impact: Service exits cleanly, no lost telemetry or corrupted local state
HIGH FIX#7: Missing Database Transaction Isolation
- File: routes.py
- Issue: Race condition in concurrent renewal requests
- Fix: Added with_for_update() row-level locking for both auth paths
- Impact: Prevents concurrent renewals from bypassing rate limit
MEDIUM FIX#8: Missing Audit Logging for Failures
- File: routes.py
- Issue: Failed renewal attempts not captured in audit trail
- Fix: Added AuditLog.log() for cert generation and database update failures
- Impact: Security monitoring can detect repeated renewal failures
SECURITY HARDENING:
- Server cert validation: Never accepts certificates blindly
- Revocation enforcement: Fail closed if columns missing
- Expired cert handling: Dual-auth allows renewal while maintaining security
- Input validation: Hostname must match safe character set
- Transaction safety: Row locks prevent race conditions
- Audit trail: All failures logged for security monitoring
OPERATIONAL IMPROVEMENTS:
- Agents can now renew expired certificates (no more manual intervention)
- Graceful shutdown prevents data loss during renewal
- Rate limiting immune to timezone issues
- Comprehensive audit trail for troubleshooting
ADR COMPLIANCE:
- Maintains ADR-001 (Asset as platform contract)
- Maintains ADR-006 (Collector contract - backward compatible)
- Database naming (snake_case columns)
- No unicode/emojis
- Security best practices: ALL CRITICAL/HIGH issues resolved
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
AGENT SELF-RENEWAL:
- Agent checks certificate expiry on startup
- If < 30 days remaining, requests renewal from server
- Server generates new certificate and updates database
- Agent installs new cert, updates config, and restarts
SERVER-SIDE (/api/endpoints/certificate/renew):
- Requires authentication via existing (expiring) client certificate
- Rate limited: 1 renewal per asset per 24 hours
- Generates 2-year self-signed certificate with cryptography library
- Computes SHA1 fingerprint (matches nginx $ssl_client_fingerprint)
- Updates assets table with new fingerprint and expiration
- Returns PFX/P12 certificate with random password to agent
- Audit logs all renewal operations
AGENT-SIDE (CertificateRenewal.cs):
- CheckAndRenewIfNeeded() called on service startup before host starts
- Finds current certificate by thumbprint in LocalMachine\My store
- Checks expiry: if < 30 days, POST to /api/endpoints/certificate/renew
- Authenticates renewal request with expiring certificate (mTLS)
- Receives PFX + password in JSON response
- Imports new certificate to LocalMachine\My store
- Grants BUILTIN\Users read access to private key (for tray app)
- Updates appsettings.json with new thumbprint
- Exits with code 0 so service manager restarts agent with new cert
CERTIFICATE GENERATION (certificate_renewal.py):
- Uses cryptography library to generate RSA 2048-bit key pairs
- Self-signed certificates with CN=hostname, 2-year validity
- Extended key usage: CLIENT_AUTH
- Subject Alternative Name: hostname as DNS name
- Exports as password-protected PFX/P12 for Windows import
- SHA1 fingerprint computed from DER encoding (nginx compatible)
STARTUP FLOW:
1. Agent service starts (Program.cs RunHostAsync)
2. CertificateRenewal.CheckAndRenewIfNeeded() runs BEFORE host starts
3. If cert expires in < 30 days, requests renewal from server
4. If renewal succeeds, agent exits (Environment.Exit(0))
5. Windows Service Manager automatically restarts the service
6. New process picks up updated thumbprint from appsettings.json
7. Agent continues with renewed certificate
SECURITY:
- Renewal requires valid (not expired) existing certificate
- Rate limit prevents renewal loop abuse
- Server validates cert not revoked before renewal
- Audit logging tracks all renewal operations
- PFX password is random, used once during import, never stored
PERFORMANCE:
- Renewal check adds ~100-200ms to service startup
- Only triggers when expiry < 30 days (rare operation)
- No impact on steady-state agent performance
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
CRITICAL FIXES:
- Enforce cert authentication on collector telemetry endpoint
- Add authorization check in report_command_result (prefer cert over hostname)
- Implement server certificate pinning in C# agents (optional)
- Add constant-time fingerprint comparison to prevent timing attacks
- Validate fingerprint format (40 or 64 hex chars only)
AUTHENTICATION:
- Created shopdb/core/auth/client_cert.py with authenticate_by_client_cert()
- Extracts fingerprint from nginx header (SHA1), IIS environ, or ARR header
- Validates against assets.client_cert_fingerprint in database
- Checks certificate expiration (cert_expires_at column)
- Falls back to API key when cert not present (dual-auth during rollout)
AUTHORIZATION:
- shopdb/core/api/collector.py: Added cert auth to generic_collect() before
API key check. Sets g.cert_authenticated_asset_id when cert validates.
- plugins/endpoints/plugin.py: apply_collector_payload() compares
g.cert_authenticated_asset_id with payload's resolved assetid. Raises
PermissionError if mismatch (prevents agent A from submitting data for
asset B by changing hostname field).
- plugins/endpoints/api/routes.py: report_command_result() prefers
g.cert_authenticated_asset_id over hostname parameter. Requires either
cert or hostname for authorization.
AGENT CHANGES:
- AgentConfig.cs: Added ServerCertThumbprint config for certificate pinning
- CollectorClient.cs: Validates server cert thumbprint when configured
- CommandClient.cs: Validates server cert on WebSocket TLS
- FleetAlertClient.cs: Validates server cert on WebSocket TLS
- When ServerCertThumbprint is empty, accepts all certs (rollout compat)
REVOCATION SUPPORT:
- Added cert_revoked_at and cert_revocation_reason columns to assets table
- Migration 0022_client_cert_revocation.py adds columns
- Revocation check uses hasattr() for backward compatibility
- Revocation requires manual database UPDATE (instant kill switch)
SECURITY HARDENING:
- Constant-time fingerprint comparison via hmac.compare_digest()
- Format validation: must be 40 (SHA1) or 64 (SHA256) hex chars
- SHA1 documented as nginx limitation, acceptable for air-gapped self-signed
- Audit logging for failed authentication attempts (AuditLog.log)
DEPLOYMENT:
- Dual-auth during rollout: cert OR API key both work
- Agents without certs continue using API key (backward compatible)
- No impact on existing fleet until certs provisioned
- Rollout: server first, pilot certs, fleet rollout, retire API key
Addresses Fable security review findings: 1 critical + 5 high vulnerabilities
related to authentication bypass, authorization flaws, and timing attacks.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Feature: Support Code 39 symbology alongside existing QR and CODE128
- Added CODE39 option to code type dropdown (front and back sides)
- Updated renderOne() to handle code39 type with format parameter
- Code 39 shares CSS styling with CODE128 (linear barcode layout)
- Updated error message to be format-agnostic
Use case: Legacy systems and equipment that require Code 39 format
for compatibility (common in manufacturing and inventory systems)
Technical notes:
- JsBarcode library already supports CODE39 format
- Passed format parameter to barcodeSvgDataUri() function
- Both CODE128 and CODE39 use .type-barcode CSS class
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Fix 1: Remove duplicate Setting import violating plugin contract
- plugins/endpoints/api/routes.py:3551 imported shopdb.core.models.Setting
- Setting already imported from shopdb.api at line 21
- Removed local import to comply with plugin contract (ADR-001)
- Test: test_plugins_only_import_contract_surface
Fix 2: Align installer defaults with measuringtools manifest
- measuringtools manifest declares default_enabled: true
- Installer (ShopDBFlask.iss) excluded it from PluginDefault()
- Removed measuringtools from exclusion list (now 4 excluded, not 5)
- Exclusions now match manifests: usb, employees, geenforce, printedparts
- Test: test_wizard_defaults_match_the_manifests
Historical note: measuringtools was promoted from default_enabled false to
true but the installer config was never updated to reflect this change.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Plugins can now register their settings pages dynamically via get_settings_routes()
hook, eliminating hardcoded imports that break lean builds.
Backend:
- Add get_settings_routes() hook to BasePlugin (shopdb/plugins/base.py)
- Implement in ComputersPlugin for Operating Systems management
- Add /api/plugins/settings-routes endpoint to expose plugin routes
Frontend:
- ModelCatalogHub fetches plugin routes and dynamically adds them via router.addRoute()
- Tabs render dynamically from pluginTabs ref
- Components lazy-load when clicked
Benefits:
- Lean builds work without manual route exclusions
- Plugins self-contain their UI extensions
- No core code changes needed when adding plugin settings pages
- Follows plugin architecture best practices
Fixes TODO from commit a03fd3de4f
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Endpoints plugin now respects the site_timezone setting from /settings.
All timestamps in endpoint details, script runs, and log collections are
now converted from UTC to the configured site timezone for display.
Changes:
- Added _get_site_timezone() helper to read site_timezone setting
- Added _localize_timestamp() to convert UTC datetimes to site timezone
- Applied localization to GET /api/endpoints/<id> (last_seen, created_at)
- Applied localization to GET /api/endpoints/<id>/scriptruns (execution times)
- Applied localization to GET /api/endpoints/<id>/logcollections (collection times)
This aligns endpoints plugin with notifications and backups plugins which
already use site_timezone for their time-sensitive operations.
An operator could see that an endpoint was behind the published build but
had no way to act on it. update_app resolves against Application rows, and
the agent lives in the version store, so there was nothing to push.
Bridge the two: the staged build is packaged and registered as a reserved
Application row, and a managed PowerShell script is seeded to install it.
The endpoint downloads that package over the existing collector-authenticated
app-package route, so no parallel delivery path is introduced.
The new route prepares the delivery and then DELEGATES to
enqueue_endpoint_command as a digest-pinned run_script. It deliberately does
not implement its own enqueue: a second path would duplicate, and eventually
drift from, the endpoint lock, the Configuration Policy gate, stale-command
reaping and the at-most-one-in-flight rule. A fleet-wide agent swap is the
last action that should skip a quarantine check. Tests pin that delegation
rather than only the happy path.
Because it lands as run_script, the caller needs endpoints.runscript on top
of endpoints.agentupdate. That is intended: this delivers arbitrary SYSTEM
PowerShell by the same mechanism as any other script push. The audit event
is written only on an actual 201, so a refused push does not record an
upgrade that never happened.
Replacing the agent is self-modifying the control plane - the process
receiving the command is the one being swapped. The script therefore
re-launches itself detached and exits immediately, because the agent runs
run_script as a direct child and holds its stdout/stderr pipes while
waiting on it. Expect the command to report queued rather than success:
the process that would report completion is stopped mid-run. Judge the
result by the version the endpoint reports afterward.
Tray relaunch does not 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 `-ne "SYSTEM"` guard. This script always runs as
SYSTEM in session 0, where the hand-off reaches no shell and exits 0, so
Start-Process reported success for a relaunch that never happened. It also
cannot descend into the session with a token-duplicating launch:
UserContextSpool documents that WTSQueryUserToken is blocked by endpoint
protection on these hosts. Instead a one-shot scheduled task targets the
pinned ShopFloor account, matching how the user-context spool already
resolves that user, and the tray must run as that account or the
mapped-drive probe reports the wrong (empty) drive table. The relaunch is
verified by polling for the process rather than assumed, and stays
best-effort: the Run key still starts the tray at next logon, and a late
tray is not worth rolling back a good agent upgrade.
panel_status now carries agentbehind/agentversion/agentcurrent so the UI
gates the action on a flag rather than parsing "(behind X)" out of a label.
The button is confirmed by typing UPDATE, naming both versions and the
service interruption, matching how run_script and system_restart are gated.
Verified on live endpoints: upgraded 1.13.0.0 and 1.15.0.0 machines to
1.16.0.0. run_script delivery, digest fetch and system context all predate
1.13.0.0, so no minimum agent version is declared for this path.
Gate collect_log on a minimum agent version and bridge the agent version
store to the applications table so a staged build can actually be pushed.
update_app resolves an install source from the applications table alone; it
cannot see agentstore. A build could therefore be staged and current while no
application row existed at all, which is what the fleet was in: the agent was
unpushable. Staging is now the single source of truth - it packages the
current build and upserts the row in the same request, so the row cannot
describe a build that is no longer staged.
PARTIAL BUILD ADVISORY
This is steps 1-4 of a longer rollout and is deliberately incomplete:
- There is no push trigger yet. The registered package is resolvable and
verifiable, but nothing enqueues an upgrade; the "behind current build"
indicator is still display-only.
- The upgrade template script, its script row, and the enqueue route
(endpoints.agentupdate) are not written.
- Blocking constraint for the next slice: run_script executes
powershell.exe -File <script> with NO argument channel, so a delivered
upgrade script cannot be passed BaseUrl/CollectorKey and must either
source them itself or run_script must be extended first.
Nothing here touches a live endpoint. Staging only changes what a future
push would contain.
Details:
- Package members carry a fixed timestamp. zipfile stamps the current mtime
by default, so identical input produced a different archive digest on
every call. update_app pins a download by SHA-256, so a drifting hash
would rewrite installersha256 out from under an endpoint mid-download and
fail its verification.
- All three routes that can change 'current' re-register (upload,
adopt-legacy, rollback). Covering only upload would leave the row pinned
to the build an operator just rolled away from, and the push would ship
it anyway - silent drift.
- Registration cannot fail an upload. The binary is staged and audited by
the time it runs, so a packaging problem is reported in the response
payload rather than raised; returning an error would tell the operator a
successful upload was rejected.
- The package is written to a temp name and os.replace'd, so an interrupted
write cannot leave a truncated archive at the path the row advertises.
- The row is isinstallable but hidden: machine-managed, and it must not
appear in the software catalogue beside real licensed applications.
- Added application_package_location() to the shopdb.api contract surface
(lazy, matching collector_authorized) returning the package directory and
URL prefix together. agentregistry reached into shopdb.core internals,
violating the plugin import contract; sourcing both from one place also
stops the write location and installpath from drifting apart.
collect_log is refused at enqueue below its minimum agent version, with
numeric comparison - string ordering misplaces 1.9.0.0 against 1.16.0. The
refusal is mirrored in the UI so a command slot is not spent on a call that
is guaranteed to fail.
Test isolation: create_app('testing') resolves instance_path to the real repo
instance/ directory, so agent upload tests write into the developer's actual
store. The new tests redirect both the store and package output into tmp_path
and include a guard test that fails if that isolation regresses. NOTE: the
pre-existing test_endpoints_agentupload.py still has this leak and deposits
1.5.0.0 and 1.6.3 into instance/siteagent/ on a full run - not fixed here.
Suite: 2834 passed, 11 skipped. The two failures (test_docs_publishable, and
a symlink scan needing Windows privileges) were confirmed pre-existing by
stashing this work and reproducing them.
Log Collector: a new collect_log action reads a plaintext log file from a
known path and returns its tail for viewing and download. Rides the existing
endpoints.updateapp grant rather than inventing one, since collecting a log is
the same trust level as pushing an application. Collected logs land in their
own table with per-asset retention of 20 instead of overloading the command
audit row, and the agent refuses UNC, traversal, wildcards, directories and
non-text content independently of the server check.
collect_log is POLICY_EXEMPT. Every existing Configuration Policy predates the
action and so cannot name it, and the intersection in ALLOWED_ACTIONS_FOR drops
anything unnamed - so every policied endpoint refused log collection outright,
including for admins, who saw a permission-shaped error for a grant they held.
Exemption from policy is not exemption from permission: the elevated grant
still applies, and a regression test asserts both halves so a future edit
cannot quietly loosen the grant while "fixing" the policy gate.
Script output capture: successful runs can return output, gated per script by
captureoutput. Failures always returned it; success never did, which left a
green badge as the only evidence a run produced.
Timestamps: Activity and Operator Reports rendered date-only because the shared
formatter's 'date' branch drops the clock time. Run Scripts additionally
labels its timestamp, since a bare time meant "completed" on a terminal row but
"queued" on an in-flight one - indistinguishable, and the wrong reading.
Agent 1.16.0. Minor: collect_log is a new action name, so an older agent asked
for it fails closed on the unknown-action default.
alertscope was stored, serialised and editable in the authoring form but
never read when alerts were handed to an endpoint, so every fleet alert
went to every machine regardless of what was entered. The control implied
a capability nothing enforced.
get_fleet_alerts now resolves the polling endpoint's facts and filters
each alert through its scope. Supported keys: hostname, assettype,
computertype, location, businessunit. A key may hold one value or a list
(any match), matching is case-insensitive since these are operator-typed
names, and multiple keys narrow together.
Unrecognised keys are ignored rather than treated as match-nothing. A
typo like {"hostnam": "BAY-01"} would otherwise mute the alert fleet-wide
and look identical to a delivery failure; sending wider than intended is
the safe direction for something whose job is to warn people. A garbled
non-dict scope falls back to the whole fleet for the same reason. The
real fix for typos belongs at authoring time, not delivery.
BusinessUnit is not exported from shopdb.api, so its name is resolved by
table rather than importing the model and breaking the plugin import
contract.
Ten tests cover the narrowing cases and the fail-open behaviour.
Brings in wilm commits:
- Render script surfaces correctly in dark mode
- Stop scripts reporting success after doing nothing
- Correct two gate commands that pass without running
- Gate the script library on the grant that pushes scripts
- Let the policy list edit the rows the API accepts
- Cap plugin revision ids at the 32 chars MySQL stores
- Split shipped monitor bundle into per-monitor policies
Resolved conflict in agent ShopDbEndpointAgent.csproj by merging:
- wilm 1.14.0 script verdict and output capture features
- wj 1.13.1-1.13.5 tray coordination releases
- Bumped to 1.14.1 for coordinated agent+tray release
The wj merge brought in a half-built fleet alert feature: server-side UI
behaviour still being stood up over a delivery proof of concept. It left
five red tests. This clears them and fixes a real hole underneath.
The cross-machine guard never ran. acknowledge_fleet_alert read .assetid
off _resolve_asset_for_hostname, which returns the id itself, so the
check raised AttributeError on every call and answered 500. That guard is
what stops one endpoint acknowledging another's alert, and the collector
key is shared across the fleet, so until now any bay could clear an alert
on another machine's behalf. Compare the id directly.
Gate both alert routes the way the rest of the agent channel is gated.
They are collector-key authenticated, not JWT+role, because a shop floor
bay has no user session - the same shape as claim_next_command and
upload_operator_report. Declared in the authz allowlists with the reason,
rather than inventing a permission no agent could hold.
Settle the delivery contract ahead of the durable agent work. The poll
response is authoritative for a host: every alert that should be live on
it right now. An alert absent from the feed is the agent's instruction to
retire the pending toast, which is how a server-side close or cancel
reaches a machine already displaying one. Acknowledgement is terminal per
product decision, so a close after an ack is a no-op and the agent can
retry a queued ack safely. A toast that merely timed out is not an
acknowledgement.
Stop reaching past the plugin contract surface for the mailer. One import
was a plain redirect; the other pulled a private helper to pre-check
recipients, duplicating what send_alert already does. It also returned
early when no email recipients existed, which silently suppressed the
independent webhook leg - misconfigured SMTP was dropping Teams alerts.
Register NotificationAcknowledgement in get_models() and give the
notifications plugin ownership of notificationacknowledgements, then move
the expected head to notifications0006fleetalerts.
Add the coverage whose absence let the .assetid bug ship. Thirteen tests
over gating, authoritative-feed withdrawal, terminal and idempotent
acknowledgement, per-asset scoping, and the cross-machine guard. Five of
them fail against the pre-fix handler.
Remaining suite failures are pre-existing and local: the symlink scan
needs elevation, and the docs check wants files absent from this checkout.