39aaa5d0cb1af3dfed2951ffef4098511df222ca
312 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
be78e45320 |
Rework the first-run setup wizard around what a site cannot run without
The wizard configured five of the product's 129 settings. A site that finished
it still had no timezone, no authentication, no mail server, no backup
destination and no endpoint agent policy, and nothing told the operator so.
Six steps now: Administrator, Site, Authentication, Features, Mail and Backups,
Review. Everything else moves to a standing setup checklist at
/settings/setupchecklist, because a wizard that asked for all 129 settings would
be worse than one that asked for none.
Two new endpoints back it. /setup/checklist reports twelve areas as
ok/partial/todo/na with a link to the page that fixes each, and /setup/health
reports account counts plus whether a plugin toggle has reached every worker.
Both take care not to lie:
- Several settings are SEEDED non-empty (the shipped logo, a placeholder floor
plan, every identifier field), so "is it set" compares against the shipped
default rather than emptiness.
- plugin.endpoints.sharedkeyallowed DEFAULTS TO ALLOWED when its row is absent,
so an absent row is reported as the fleet-wide key being accepted. Reporting
it as refused told an admin the opposite of the truth about the credential
they are migrating away from.
- A plugin is held at "starting" until several DISTINCT workers report it live.
Counting replies instead let one recycled worker answer four times and declare
a change complete while other workers still served the old routes.
Recommended plugins are declared in each plugin's manifest rather than listed in
the wizard, so adding a plugin cannot leave a stale list in another file.
Also fixed: the logo preview was invisible, because the image borrowed a class
that hardcodes a white background while the shipped logo is white; and signing
in did not re-read setup state, so an admin already browsing was never steered
to the wizard and a hard refresh was the only cure.
(cherry picked from commit
|
||
|
|
0eed80f3b9 |
Create endpointlogcollections in the chain that owns it
A fresh install could not build its schema. Core revision 7d51_endpoint_logcollections creates endpointlogcollections with a foreign key onto endpointcommands, but PLUGIN_TABLE_OWNERS lists both tables as owned by the endpoints plugin, and only the plugin's 0002 revision creates endpointcommands. Core migrations run before any plugin chain, and a plugin chain only runs once that plugin is enabled, which cannot happen before the schema exists. So there is no ordering in which the core revision can succeed on an empty database. MySQL rejects it with 1824, 'Failed to open the referenced table endpointcommands'. That is the first command the README gives an adopting site, on the branch that exists for sites to adopt. Nothing caught it because the suite runs on sqlite:///:memory: and SQLite accepts a foreign key onto a table that does not exist. The failure is MySQL-only and therefore invisible to every test. Fix: - endpoints revision 0025 creates the table in the plugin chain, after 0002 has created its foreign key target. Guarded, so it is a no-op on sites that already applied the core revision. - 7d51 skips when endpointcommands is absent, which is exactly the fresh install case 0025 then covers. Its downgrade is guarded to match. - EXPECTED_HEAD_REVISION records the new endpoints head. Verified against real MySQL 8.4, not SQLite: on an empty database the README sequence now completes, 7d51 stamps without creating anything, and enabling the endpoints plugin builds endpointlogcollections with endpointlogcollections_ibfk_2 pointing at endpointcommands. |
||
|
|
2f98188feb |
Let a site close the legacy collector routes, once it can afford to
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. |
||
|
|
7ac1ee3077 |
Refuse a kiosk caller wearing an address that belongs to the path
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. |
||
|
|
987f5c0d0f |
Stop the one-line install handing a standard user a way in
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. |
||
|
|
3f80425f30 |
Make an admin's refusal stick, and let a revoke actually deny
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. |
||
|
|
fe4f44f855 |
Lock the shared fleet key away from the people standing at the bay
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. |
||
|
|
35d4fcd41c |
Report whether a returned drive was wiped and scanned
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. |
||
|
|
0f56653345 |
Say when a kiosk screen is not set up, and hand out the setup link
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. |
||
|
|
da91a29155 |
Admit kiosk callers by network position, not only by device token
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. |
||
|
|
066a19052b |
Act on the wj architectural review: races, limits, pool and contract gaps
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. |
||
|
|
2557d1967d |
Make the one-line install survive Defender, and approval leave a PC that works
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. |
||
|
|
ca57b7f728 |
Install an agent on a one-off PC from a single command
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.
|
||
|
|
408cf08628 |
Stop the USB settings page from replacing the kiosk token with "true"
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. |
||
|
|
3d9fe793b2 |
Keep every server pin in the upgrade script, and stop promising enrollment to an enrolled PC
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. |
||
|
|
8f10c09a76 |
Always pin the server, let the registry own pins, and survive a revoke mid-upgrade
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. |
||
|
|
1c2ba677c3 |
Let key-less PCs ask to enroll, and let an admin retry a trust-blocked PC
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. |
||
|
|
22d06abb70 |
Show per-PC credentials across the admin interface
- Endpoints (shared key) page: the key is presented as what PCs use until they enroll, with enrollment progress, a link to Enrollment, rewritten guidance, and rotate/revoke confirmations that say how many PCs are not enrolled yet. - Settings cards describe the server certificate as what PCs actually receive, on IIS or Docker; Enrollment has its own icon (the icon set lacked the one the Server Certificate card already asked for). - Server Certificate page checks the certificate the site presents at its public address and compares its pin with the certificate file, warning when they differ. The host comes from site settings only; the check is a TLS handshake, never an HTTP request. - Fleet list: Credential column and filter (enrolled, pending, re-enroll blocked, shared key, not reporting), computed in the list query. - PC detail: credential state, prefix and dates for fleet readers, state only for others, with admin Revoke (optionally blocking re-enrollment) and Allow re-enrollment. - Dashboard: pending enrollment requests, and PCs blocked from re-enrolling for more than seven days. - Runbook: where to find each of these in the app. |
||
|
|
70d5531b4d |
Make rollback, first upgrades and pin changes work as tested on Windows
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.
|
||
|
|
1164cfb287 |
Add the enrollment page, move scripts to per-PC credentials, and document the rollout
- 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. |
||
|
|
7480e4fe3a |
Give each endpoint its own credential and fix the review findings on the server
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. |
||
|
|
1ed0c4d215 |
HOTFIX: Remove hot-path logging and unnecessary flush from collector
Removed performance bottlenecks in _replace_applications(): - 5 logger.info/warning calls executing on every collector check-in - Explicit db.session.flush() in legacy path forcing immediate SQL - With 800 check-ins/hour, this was 800 log writes + 800 premature flushes Changes: - Removed all hot-path logging (moved critical info to warnings list) - Removed flush() in legacy fallback path (let transaction commit batch SQL) - Kept conditional flush in delta mode (only when changes detected) Impact: Eliminates 800+ disk I/O operations per hour in collector path Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> |
||
|
|
affe455c55 |
HOTFIX: Remove row-level locks from collector check-in path
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> |
||
|
|
5cd5332397 |
Add agent fingerprinting, client certificate auth, and security audit documentation
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> |
||
|
|
1a7bb9bd34 |
Fix code block still showing white text in dark mode (!important)
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> |
||
|
|
812d1ee4d1 |
Fix dark mode color contrast on server cert settings page
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> |
||
|
|
b7e454b5ef |
Fix blank screen on server cert settings page (missing authentication)
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>
|
||
|
|
87582d0851 |
Fix endpoints plugin load failure (audit_log decorator incorrect usage)
Critical Bug Fix: Plugin load failure
Problem:
- Endpoints plugin failed to load after adding server cert management
- Error: audit_log() missing 1 required positional argument: 'entitytype'
- Plugin completely broken - no endpoints routes available
Root Cause:
- Used @audit_log('action') as decorator on routes.py:4394, 4521
- audit_log() is NOT a decorator - it's a function requiring (action, entitytype)
- Decorator pattern doesn't exist in ShopDB API
Solution:
- Removed @audit_log decorators from both endpoints:
- /settings/server-cert/generate
- /settings/server-cert/upload
- Added manual AuditLog.log() calls inside functions after success
- Wrapped in try/except to prevent audit logging from breaking endpoint
Implementation:
try:
AuditLog.log(action='endpoints.server_cert.generated',
userid=current_user.userid if current_user else None,
details='Generated self-signed certificate: CN=...')
except Exception:
pass
Result:
- Endpoints plugin loads successfully
- Blueprint registered: /api/endpoints
- All endpoints routes working (tested: /api/endpoints/alerts)
- Settings menu now visible in sidebar
- Audit logging still captures cert operations
Verification:
- docker-compose logs: 'Endpoints plugin initialized (v0.1.0)'
- GET /api/endpoints/alerts returns 200
- GET /api/dashboard/health returns healthy
Impact:
- Entire endpoints plugin was broken since commit
|
||
|
|
ca90969eb4 |
Fix fleet alerts sent immediately instead of respecting scheduled starttime
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>
|
||
|
|
d11ba5c5d6 |
Add server certificate management UI (Settings > Endpoints > Server Certificate)
Operational Feature: Web UI for TLS certificate management
Problem:
- Server certificate (nginx HTTPS) managed manually via certs/ directory
- No visibility into cert expiry from UI
- Regeneration required command-line OpenSSL knowledge
- No warning when cert approaching expiration
- Agents using thumbprint pinning need new thumbprint after renewal
Solution:
- New admin-only settings page: Settings > Endpoints > Server Certificate
- View current certificate details (CN, SANs, expiry, thumbprint)
- Warning banner when cert expires within 30 days
- Generate self-signed certificate (configure CN, SANs, validity)
- Upload CA-signed certificate (PEM format)
- Automatic nginx restart after certificate changes
- Copy-to-clipboard for agent thumbprint pinning
Frontend:
- plugins/endpoints/frontend/views/ServerCertSettings.vue
- Certificate status display with expiry countdown
- Self-signed generation form (CN, SANs, days)
- CA certificate upload (cert + key files)
- Documentation collapsibles (requirements, pinning, air-gapped)
- plugins/endpoints/frontend/routes.js
- Route: /settings/endpoints/server-cert (admin-only)
Backend API:
- plugins/endpoints/api/routes.py
- GET /api/endpoints/settings/server-cert (view cert details)
- Uses openssl to extract CN, issuer, dates, thumbprint, SANs
- Calculates days until expiry (warns if < 30)
- Returns self-signed status
- POST /api/endpoints/settings/server-cert/generate
- Generates self-signed cert with custom CN/SANs/validity
- Backs up old certificate with timestamp
- Writes new cert/key to certs/ directory
- Restarts nginx (docker-compose restart nginx)
- Audit logged: endpoints.server_cert.generated
- POST /api/endpoints/settings/server-cert/upload
- Validates PEM format (cert + unencrypted key)
- Backs up old certificate
- Writes uploaded files to certs/
- Restarts nginx automatically
- Audit logged: endpoints.server_cert.uploaded
- All endpoints admin-only (@require_role('admin'))
Navigation:
- plugins/endpoints/plugin.py
- Added menu item: Settings > Endpoints > Server Certificate
- Icon: shield-check, Position: 29
Security:
- Admin-only access (certificate management is privileged operation)
- Private key validation (rejects encrypted keys)
- Certificate format validation (PEM-encoded)
- Audit logging for all cert operations
- Automatic backup before replacement (timestamped .backup files)
Operational Notes:
- Certificate changes restart nginx (brief HTTPS interruption)
- Agents using ServerCertThumbprint pinning must be updated after renewal
- Self-signed certs valid 1 year by default (configurable)
- Air-gapped sites: cert files in certs/ directory bundled automatically
Related:
- Complements certs/README.md and regeneration scripts (commit
|
||
|
|
8d87921dee |
Fix syntax error in certificate renewal endpoint
Remove duplicate 'if not asset:' line that caused IndentationError Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> |
||
|
|
702eeb3e52 |
Fix CRITICAL and HIGH security vulnerabilities in certificate renewal
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> |
||
|
|
65f26b1576 |
Implement automatic certificate renewal (Option 2 - agent self-renewal)
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> |
||
|
|
18e5fdc000 |
Fix critical security vulnerabilities in client certificate authentication
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> |
||
|
|
64ab1c5299 |
Add Code 39 barcode format to barcode generator tool
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> |
||
|
|
2ac2e6d33d |
Fix plugin contract violation and installer defaults mismatch
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> |
||
|
|
fe09d2745e |
Implement plugin dynamic settings routes architecture
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
|
||
|
|
c5b60024ed |
Add timezone localization support to endpoints plugin
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. |
||
|
|
f9f1d39f08 |
Consolidate settings into 8 hubs and fix security vulnerabilities
Settings Consolidation (37+ pages to 8 hubs): - Asset Type Hub: Unified type management with plugin descriptors - Model Catalog Hub: Vendors, types, models, operating systems - Asset Configuration Hub: Statuses, relationships, fields, identifiers - Security Hub: Authentication, tokens, users, audit - Site Configuration Hub: Site settings, floor map, branding, imports - Integrations Hub: ServiceNow, Zabbix, Dell warranty - Locations Hub: Locations, business units, employees - Network Infrastructure Hub: VLANs, subnets Plugin Descriptor Pattern: - Add get_asset_type_descriptor hook to BasePlugin - Implement descriptors in 6 plugins (printers, computers, machines, network, measuringtools, locations) - Add /api/pluginui/asset-type-descriptors and core-type-descriptors endpoints - Generic SubtypesTab component renders CRUD UI from descriptor schema Security Fixes: - Fix stored XSS via javascript: URIs in Models and Support Teams - Add URL scheme validation (allow only http/https) in create/update endpoints - Fix plugin contract violations in endpoints plugin (use shopdb.api imports) - Add missing requiresAdmin guard to printer model supplies route - Add user-facing error messages to API token load failures Integration Improvements: - Add persistent tab navigation to ServiceNow, Zabbix, and Dell Warranty settings - Standardize integration settings UI across all three pages - Fix tab visibility when navigating between integrations Tested with 94-agent orchestrated review for ADR compliance and security. |
||
|
|
9f78d0bfc7 |
Merge origin/wilm into wj
Brings in agent upgrade and log collection features: - One-click agent self-upgrade from endpoint detail page - Auto-register staged agent builds as pushable applications - Log collection and script output capture - Endpoint log collections plugin integration Resolved conflict in ShopDbEndpointAgent.csproj by taking wilm version 1.16.0 |
||
|
|
8be1c2379f |
Add one-click agent self-upgrade from the endpoint detail page
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. |
||
|
|
d32293ccb2 |
Auto-register staged agent builds as a pushable application
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.
|
||
|
|
b49fb60c0c |
Add log collection, script output capture, and timestamp fixes
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. |
||
|
|
e8b45bf15d |
Merge wilm fleet alert scope fixes into wj
Brings in wilm commits: - Make alertscope actually target fleet alerts - Fix fleet alert server surface inherited from wj Key changes merged: - Added _alert_targets_endpoint() and _endpoint_scope_facts() for proper scope filtering (hostname, assettype, computertype, location, businessunit) - Alertscope now properly filters which endpoints receive fleet alerts - Added comprehensive test coverage in test_endpoints_fleet_alerts.py - Preserved wj's operator report email enhancements (FQDN, machine relationship) Resolved conflicts: - plugins/endpoints/api/routes.py: merged scope filtering with operator report changes - agent csproj: bumped to 1.14.2 combining both branches' changes |
||
|
|
494b837bae |
Make alertscope actually target fleet alerts
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.
|
||
|
|
a9a785fd50 |
Merge wilm into wj
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 |
||
|
|
e0a7a51d46 |
Fix fleet alert server surface inherited from wj
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. |
||
|
|
c635457e7f |
Merge wj: fleet alert notifications and operator report emails
Brings in the fleet alert notification concept, the operator report email alerts, and the tray work that goes with them (tray 1.6.5). Two files needed resolution: - ShopDbEndpointAgent.csproj: wj carried 1.13.1 through 1.13.4, all tray-coordination bumps with no agent code behind them. Ours carried 1.14.0, which is real code. Kept every changelog entry and took 1.14.0 as the version, since it already supersedes the 1.13.x line. - plugins/endpoints/api/routes.py auto-merged; verified the errormode descriptor survived intact. KNOWN RED, INHERITED NOT CAUSED: five backend tests fail on this merge - authz on endpoints.acknowledge_fleet_alert, the public GET surface scan, two plugin-contract import checks, and the notifications plugin migration/model agreement. All five were confirmed failing on a clean worktree of origin/wj before merging, so they arrive with the branch rather than from the merge. They reflect the fleet alert work being half-landed: the push path was a delivery proof of concept and the server-side surface was still being stood up. These are not fixed here on purpose. The next change replaces that delivery path with the endpoint agent, so the notification persists on the endpoint until the operator acknowledges it or the server closes it, and the affected surfaces get their gating and migration settled as part of that work rather than being patched twice. |
||
|
|
561988ac8f |
Render the script surfaces correctly in dark mode
The explainer panel used var(--surface, #fff) and PCDetail's Run Scripts and Corporate Tools styles used --text-primary, --text-secondary and --bg-secondary. None of those four tokens exist in this app's theme, so var() fell through: the explainer rendered a solid white block over the dark background and the surrounding copy lost its intended contrast. The real tokens are --bg-card, --text and --text-light. A fallback colour is what made this hard to catch - it looks correct in light mode and only misrenders on the theme the shop floor actually runs, so it would not have shown up in authoring or in review. Both files now carry a test asserting their styles reference only defined tokens and contain no var(--x, #colour) fallbacks. That test immediately caught a further dead --warning fallback in the banner. The two long per-field explanations in the Add Script form are now collapsed behind "How this works". The modal already scrolled, and three paragraphs of standing prose between the inputs pushed Save off screen, so the copy was being skimmed past rather than read. |
||
|
|
d6163f54a4 |
Stop scripts reporting success after doing nothing
A run_script command was judged solely on PowerShell's exit code, and PowerShell treats most errors as non-terminating: it writes the error, runs the next line, and still exits zero. The account-provisioning script reported succeeded having provisioned nothing, and because no output was captured there was no evidence to contradict the verdict. Scripts now carry an errormode. Strict prepends $ErrorActionPreference = 'Stop' so the first error ends the run and the exit code means what it says. It is opt-in per script rather than global because bodies authored against the permissive semantics rely on them - tolerating a failing cleanup, probing with a cmdlet that errors when a thing is absent, looping past a bad item - and would start aborting midway, leaving endpoints half-configured. The default is unchanged, so every existing script keeps running exactly as before. The prologue is applied after digest verification, so the SHA-256 still validates the bytes the server delivered. stdout/stderr is captured on both paths: redirected streams for SYSTEM, a transcript file under the ShopFloor profile for user-session runs, since CreateProcessAsUser cannot inherit the service's pipes across the session boundary. Output is always logged locally and returned to the server only on failure, so routine output stays on the machine that produced it instead of filling command history. Also adds the optional per-script working directory, which the same surfaces needed, and a collapsible explainer on the scripts page - the dropdown is not self-explanatory, and an operator who does not know about non-terminating errors has no reason to ever leave the default. Agent 1.14.0. An older agent ignoring errormode keeps today's behaviour. |