Commit Graph

4001 Commits

Author SHA1 Message Date
rcourtman 60c1c51eb9 fix(workloads): serve viewer-safe inventory health 2026-08-08 03:36:55 +01:00
rcourtman d1f687c0ea fix(governance): enforce frontend dependency audits 2026-08-08 03:26:36 +01:00
rcourtman 633d3117f7 fix(governance): fail closed on unsupported commercial evidence 2026-08-08 03:11:12 +01:00
rcourtman d130d00867 fix(release): make customer promotion convergent 2026-08-08 02:36:28 +01:00
rcourtman 541c9be7fd fix(websocket): keep oversized recovery baseline-free 2026-08-08 01:35:49 +01:00
rcourtman 98ecfb3f10 fix(updates): gate update-status polling by route authority 2026-08-08 01:21:44 +01:00
rcourtman 7a0f410508 fix(settings): gate all admin-only panels 2026-08-08 01:19:44 +01:00
rcourtman 6d8a509376 fix(settings): serve runtime display settings to viewers 2026-08-08 01:10:40 +01:00
rcourtman fbee92614a fix(release): restore verifiable MSP evaluation delivery 2026-08-08 01:04:07 +01:00
rcourtman b0759d20d7 fix(release): activate before mutable customer promotion 2026-08-08 00:58:18 +01:00
rcourtman 27b4d98bc0 fix(workloads): stop offering viewers an infrastructure page they cannot open
The "No workload inventory available" empty state told every session to
"Review source credentials, permissions, and collection status in Settings
→ Infrastructure" and rendered a button to /settings/infrastructure. Since
755a88878 gated that nav item on the infrastructureRead capability, a
non-admin viewer cannot open the page — and once inventory source health is
served at monitoring:read, a viewer with a broken source lands on this exact
state and is pointed at a door that is locked for them.

Gate the call to action on the destination's own capability. Reusing
infrastructureRead rather than a second predicate is what keeps the link and
the nav gate from drifting apart. Without it the copy now names the action a
viewer can actually take: contact an administrator.

The signal did not exist outside Settings — infrastructureRead only reached
useSettingsAccess's local fetch, which runs when Settings mounts and so is no
help to a page deciding whether to link there. Publish it from the
/api/security/status resolve that useAppRuntimeState already performs on
mount, alongside the sessionCapabilities sync that hangs off the same call.
No new request is added. Unresolved sessions keep the link, matching how
settingsNavVisibility treats an unresolved capability set, so an admin never
flickers through the restricted copy.

The banner itself, the admin path, and the monitoring:read inventory-sources
call are all unchanged. The surface's inline fallback now defers to the
shared presentation helper instead of duplicating the copy, so the gate
cannot be bypassed by whichever path renders.

Verified on a scratch instance against real proxy-auth sessions: the viewer
(detailLevel authenticated, infrastructureRead false) gets the reworded copy
and no link at 1280x800 and 375x812, while the admin (privileged) is
byte-identical to before. Because the empty state is only reachable by a
viewer once inventory health is served at monitoring:read, the live exercise
ran with that branch's route present.
2026-08-07 22:57:16 +01:00
rcourtman 6a958761e8 fix(settings): hide the admin-only System tabs from non-admin sessions
755a88878 gated Settings -> Infrastructure on infrastructureRead but left the
System group untouched. Network, Pulse server updates, and Recovery are the
same shape: pure instance administration - the public URL and CORS boundaries,
the server update channel, backup polling and configuration export/import -
behind routes that are all RequireAdmin + settings:read. A session without it
was still offered all three, could deep-link straight at them, and landed on
panels it can never populate.

Serve systemSettingsRead alongside infrastructureRead and gate the three tabs
on it. requiredCapability blocks the route as well as the nav item, so a deep
link now falls through to the capability-aware fallback that 755a88878 added,
landing on the first tab the session can actually reach.

The new field is a sibling of infrastructureRead, not a reuse of it. They
share the canAccessAdminSurface(config.ScopeSettingsRead) derivation today, but
infrastructureRead is named and documented for the data sources behind one
page. Borrowing it to gate a different page is the same defect as re-deriving
a capability: the two would drift the first time one surface's gate changed,
and the api-contracts rule on published capabilities exists to prevent exactly
that. Each field names the surface it gates and is pinned separately.

These hide rather than render an inline gate, matching Infrastructure rather
than the paid-feature items in the same catalog. A free install seeing a paid
tab can act on it by upgrading, so hiding it would cost discoverability. A
non-admin cannot grant themselves admin, so the tab is only noise.

system-general stays ungated on purpose: theme, language, and unit preferences
there are user-scoped, so gating it would take personal settings away from
every non-admin, and the panel is not empty for them.

Verified against real admin and non-admin sessions rather than a stubbed
capability - one binary built from this worktree behind two header-injecting
proxies differing only in the role header. The viewer sees none of the three
tabs and a deep link to /settings/system-network lands on
/settings/monitoring/availability; the admin keeps all three and the same deep
link resolves to the Network panel. Confirmed at 1280x720 and 375x812.
2026-08-07 22:18:17 +01:00
rcourtman 14a82e7684 fix(api): log routine authorization refusals at debug, warn on the rate
A refusal is the access control working, not a fault, but every one of them
warned twice: once in RequireAuth/RequireAdmin/RequirePermission, and again in
the middleware, which warned on every 4xx unconditionally. A correctly
configured instance therefore could not produce a quiet log, and #1601's rc.9
reporter read that stream as an RBAC regression.

Gating clients one at a time does not fix this. The previous commit stopped six
endpoints being polled by a non-admin UI, and /api/updates/status,
/api/updates/plan and /api/availability-targets still warned, as would every
caller nobody has audited yet.

Refusals now route through logAuthDenial, which records them at debug and counts
them per caller. Attribution prefers the authenticated username so a principal
stays tracked across rotating addresses, falling back to the client IP. Crossing
20 refusals in a minute emits exactly one warn for that window, which is the
shape that separates probing from a UI mounting a surface its session cannot
read; a closed window re-arms it. The tracked set is bounded with oldest-window
eviction so spoofed forwarded-for values cannot grow it. The middleware now
warns only on 5xx.

Enforcement is untouched: every route returns the same status to the same
callers, and the contract test pins that pairing so a future attempt to quiet
the log by relaxing enforcement fails rather than passes. Verified live on a
proxy-auth instance - /api/connections, /api/updates/status and
/api/system/settings still 403 for a viewer and 200 for an admin; 19 refusals
produce no warn, the 20th produces one, and 30 more produce none; an idle
non-admin browser session logged zero warn lines across 90 seconds.
2026-08-07 21:38:47 +01:00
rcourtman 755a888787 fix(settings): stop non-admin sessions polling admin infrastructure endpoints
Settings -> Infrastructure reads /api/connections, /api/config/nodes,
/api/system/settings, /api/truenas/connections and /api/vmware/connections on
mount and then polls /api/connections every 15s and /api/discover every 30s.
Every one of those is RequireAdmin, so an authenticated non-admin rendered a
page where nothing loaded while each poll reprinted "Non-admin user attempted
to access admin endpoint" at warn level. Measured on a proxy-auth viewer
session: 6 denials/minute from this page alone on an idle tab.

Serve infrastructureRead alongside the other settings capabilities, derived
from the same canAccessAdminSurface(settings:read) expression the routes
enforce, and gate the nav item on it. Two follow-on fixes were needed because
the page is not the only mount point:

- DEFAULT_SETTINGS_TAB is infrastructure-systems, so the blocked-route
  fallback pointed straight back at the tab it had just refused. It now falls
  back to the first tab the session can actually reach.
- Settings.tsx constructs useInfrastructureSettingsState for every settings
  tab, so the discovery poller and the TrueNAS/VMware mount fetches ran no
  matter which tab was open. They now wait on the same capability. The
  TrueNAS/VMware loads moved from onMount to an effect so admins still load
  once the capability resolves, rather than sampling it before it exists.

Verified against a local instance behind a header-injecting proxy-auth shim:
viewer goes from 6 infrastructure denials/minute to 0, admin keeps both
pollers armed (/api/discover x4 and /api/connections x3 over 152s) with 0
denials. Re-checked at 1280x800 and 375x812 for both roles.
2026-08-07 20:22:04 +01:00
rcourtman 06d8bfe65e docs(commercial): stage the owed release-note copy for the surfaces revision
The 2026-08-07 commercial-surfaces revision owed a release-note entry, and
its instrumentation (schema-v8 business_estate, checkout source attribution)
has now landed too. v6.2.0-rc.9 was already tagged before any of it merged,
so the copy is staged in the record rather than written into a
version-numbered packet: picking the next version and generating its packet
is the governed release-preparation step, not this one.

The copy is written for someone who will notice paid-feature pages appearing
in a free install and want to know why, so it leads with what changed on
screen, states that every button on the business-estate card dismisses it
permanently, and confirms demo, kiosk, white-label, and MSP tenant runtimes
stay clean. It also declares the telemetry field and the checkout-surface
attribution plainly rather than leaving them for someone to find in a diff.

Every claim was checked against source: the thresholds against
internal/monitoring/business_estate.go, the dismissal behaviour against
BusinessEstateCard.tsx, and the opt-out and schema number against
internal/telemetry/telemetry.go.
2026-08-07 20:10:23 +01:00
rcourtman d5bc3e3862 feat(commercial): attribute in-app checkouts to the surface that started them
Stripe checkouts could not be told apart by origin: every session was
stamped checkout_origin pulserelay_landing, so an upgrade started from an
RBAC gate looked identical to one started from the website. Gate CTAs now
stamp a closed-vocabulary source token (gate-<feature>, estate-card, or the
plans-page default) onto the owned billing plan route; the plan page threads
it into the purchase-start handoff, and the license server persists it on the
checkout intent and stamps Stripe metadata checkout_source.

Attribution is authenticated-session-only by construction. The public
/pricing route and the public pricing URL never carry it, and
getSelfHostedPurchaseStartUrl scrubs the parameter from forwarded query
strings so a crafted website link cannot claim in-app origin. Server-side,
source is validated against the same kebab vocabulary, skip-listed from the
Pulse Account portal redirect exactly as feature already is, and travels
only inside the handoff body. It is request-scoped and persists nothing on
the install; the cancel return echoes it so a retry keeps its origin.

The handoff field is omitempty because the license server decodes strictly,
so source-less installs stay compatible with a server either side of the
field being added. That ordering is recorded in pulse-pro OPERATIONS.md.
2026-08-07 19:45:13 +01:00
rcourtman 5b07bdc3d8 feat(telemetry): add business_estate to the ping at schema v8
The 2026-08-07 telemetry read showed installs at or above 5 PVE nodes, 10
Docker hosts, or 3 VMware hosts convert to paid at ~8x the rate of smaller
estates. The ping now carries that classification as a server-derived
boolean so receiver-side cohort queries keep a stable column even if the
thresholds move later; it is derived in the pkg/server snapshot closure
from the same AggregateInstallSnapshotCounts values the payload already
sends, so no new information leaves the install.

The thresholds move to internal/monitoring/business_estate.go as the
single definition; the session-capability surface behind the in-product
business-estate card delegates to it, and dropping the now-unneeded direct
pkg/licensing import there restores TestPkgLicensingImportBoundary, which
f0e2243b4 had left red. All three payload surfaces (Ping struct, private
receiver, TelemetryPingPreview) move together per
check_telemetry_schema_parity.py, and both PRIVACY.md copies document the
field.

Verified live on an isolated worktree backend with 6 mock PVE nodes: the
Settings telemetry preview renders schema_version 8 with business_estate
true, and /api/security/status still reports
sessionCapabilities.businessEstate true through the delegated thresholds.
2026-08-07 19:44:52 +01:00
rcourtman 0928071b9a fix(actions): gate approvals on live readiness 2026-08-07 17:03:10 +01:00
rcourtman 8fd43b307b fix(release): simplify in-app highlights 2026-08-07 15:50:39 +01:00
rcourtman f0e2243b44 feat(commercial): revise self-hosted commercial surfaces (supersede RA5 opt-in posture)
Free self-hosted sessions now see reactive commercial surfaces: paid-feature
nav items stay visible with panel-owned inline gates (Relay precedent extended
to reporting, roles, users, audit log, audit webhooks), gate CTAs render, and
Plans & Billing is discoverable and carries an MSP/provider section with the
free 60-day evaluation. Adds a one-shot business-estate card for authenticated
free sessions whose monitored estate crosses business-scale thresholds
(>=5 PVE nodes, >=10 Docker hosts, or >=3 VMware hosts); the flag rides
sessionCapabilities only, never the pre-auth presentation policy, so estate
size cannot leak to anonymous visitors. Demo mode and white-label runtimes
(including MSP tenant containers) keep full commercial suppression; the
multi_tenant organization nav items deliberately stay hidden.

Decision record supersedes the 2026-04-25 opt-in record:
docs/release-control/v6/internal/records/self-hosted-commercial-surfaces-revision-2026-08-07.md
RA5 summary, contract pins, nav integration pins, and the RA5 browser spec
are updated to the revised invariant in the same commit.
2026-08-07 13:47:46 +01:00
Richard Courtman 5e9db16927 fix(release): stage artifacts before publication 2026-08-07 12:27:36 +01:00
rcourtman d699dc66a0 feat(agent): converge agent self-update within one report cycle
The server now echoes its version on unified-agent report acks, and the
agent nudges its auto-updater the moment an ack carries a newer version.
After a server upgrade, agents converge within one report interval
instead of waiting out the hourly update check, so the "older Pulse
agent" notice self-resolves in seconds once upgraded agents report in.

The hourly loop stays as the retry and backstop path. Nudges dedupe per
server version, refuse downgrades, skip disabled and development-mode
updaters, and never fire from observer destination acks — only the
authoritative server may steer an agent's updater, and a nudged check
re-validates against the server and runs the existing checksum and
self-test pipeline before swapping binaries. Agents deployed before
this change still converge on their old hourly cadence once; every
upgrade after that lands within a report cycle.

Contract deltas recorded in agent-lifecycle and api-contracts, with
boundary notes in security-privacy (no update authority in the echo),
performance-and-scalability (no steady-state work), and
storage-recovery (nothing persisted).
2026-08-07 11:40:46 +01:00
rcourtman 4885c378aa Keep the operator .env out of the Traefik container and unhardcode the DNS-01 provider
Traefik took the whole operator .env via env_file, so the internet-facing
container's environment carried CP_ADMIN_KEY and the entitlement signing
private key, neither of which it needs. It now receives only ACME/DNS
material: the CF token by explicit passthrough plus an optional
dns-credentials.env that setup.sh creates 0600.

The same wiring removes the silent Cloudflare-only narrowing: the resolver
provider is now ACME_DNS_PROVIDER (default cloudflare) via Traefik's env
override, the CF token is required only for the default provider, and any
other Traefik dnsChallenge provider supplies its credential variables through
dns-credentials.env. Pinned by
TestProviderMSPTraefikEnvIsMinimalAndDNSProviderOverridable; contract delta
in deployment-installability.
2026-08-07 10:47:13 +01:00
rcourtman 6b76443455 Give the MSP evaluation docs their missing download step
The Start free button on the MSP page lands on this section, which described
the self-issued licence but never said how to obtain the bundle. The natural
guess, the latest stable tarball, ships a setup.sh that predates the
evaluation flow and dies asking for a licence. Point evaluators at main and
state the host prerequisites, which were documented nowhere in this guide.
2026-08-07 10:10:51 +01:00
Richard Courtman 8a616b4b56 Prepare v6.2.0-rc.9 release 2026-08-07 10:03:16 +01:00
Richard Courtman 99407ee74c Fix QNAP RAID role bitmap parsing
Addresses #1688.
2026-08-06 23:39:15 +01:00
Richard Courtman 844d5f3a86 Fix platform-scoped alert threshold hosts
Addresses #1593.
2026-08-06 23:24:25 +01:00
rcourtman aae8aef4ab fix(security): bound provider MSP restore writes
restoreProviderMSPArchiveFile copied each tar entry with an unbounded
io.Copy, so the only limit on what a restore wrote to disk was the size
the archive declared for itself. A gzip bomb, a PAX sparse entry claiming
a huge logical size, or a corrupt stream could fill the target volume.

Bound extraction the way readProviderMSPBackupManifestBytes already
bounds the manifest: a per-entry cap and a cumulative cap across the
whole restore, both enforced against the bytes actually copied rather
than the declared header size. That size comes from the archive, so it
is only good for an early reject, never as the bound. An entry that
overruns fails the restore and its partial file is removed, rather than
being silently truncated into a file that looks complete.

A restore that failed partway had already deleted whatever it replaced,
leaving a half-populated control plane that looks bootable and forcing
the retry to use replace. Roll the partial restore back to an empty
target instead, and say so in the error.

Follows the zip-slip fix in the same function (CodeQL alert 314).
2026-08-06 22:39:20 +01:00
rcourtman 69b5ec22fd fix(alerts): key docker container overrides by name so toggles survive updates
The #1601 follow-on: per-container alert overrides were keyed by Docker
container ID (docker:{host}/{containerID}), which changes on every
recreate, so each image update silently re-armed alerts the user had
switched off and left a dead entry behind in alerts.json — the unbounded
growth that pushed the reporter's config past the old 64KB body cap
(raised in 38434a513). The v6 thresholds UI additionally wrote keys from
the unified hash id (docker:{host}/app-container-{16hex}), which the
evaluator never read at all.

Overrides now key on stable identity, docker:{host}/{containerName}:

- The evaluator resolves the name key first and falls back to the legacy
  container-ID key so pre-migration entries keep working
  (evaluateDockerContainer, checkDockerContainerState, the
  container-update resolver, and reevaluateActiveAlertsLocked).
- MigrateDockerContainerOverrideKeys runs in the monitor sync next to
  MigrateCanonicalOverrideKeys, driven by the unified resource snapshot:
  it re-homes live legacy-ID and unified-hash keys onto the name key and
  prunes orphaned ID-shaped entries, ending the per-update orphan
  accumulation. Name-keyed entries for absent containers are kept so a
  recreate under the same name still honours them.
- The UI candidate chain (single implementation in alertOverridesModel)
  now leads with docker:{host}/{name} and trails the container-ID, short
  ID, unified-hash and slash-tail forms, so rows bind pre-existing
  overrides of every historical shape and the next save re-homes them.
  Rows carry overrideStorageId/overrideIdCandidates so toggle,
  connectivity, offline-state, edit and remove all write the stable key.
- The ignored-containers card copy now documents the wildcard forms
  (runner-*, *-dev, *staging*) shipped in b5fa6a9af, under the title
  "Ignored container patterns".

Contract deltas: alerts, frontend-primitives, monitoring, and
unified-resources now pin the name-keyed override identity, the single
frontend candidate-chain owner, the sync-cadence migration, and the
resource-facet-backed table identity respectively.

Verified live against a mock instance: a UI toggle persists
docker:{host}/loki and binds back after reload, and seeded
legacy/hash/orphan keys converge to name keys on disk within two sync
ticks. go test ./internal/alerts/... ./internal/monitoring/... green;
recreate survival pinned in
TestDockerContainerOverrideSurvivesContainerRecreate.

Refs #1601
2026-08-06 22:04:04 +01:00
rcourtman b35eec9773 fix(ui): format dates and times in the viewer's locale
Fixes #1685.

The "Last refresh" clock in the app shell was built with a hardcoded en-US
locale and hour12 true, so every reader saw a US 12 hour clock regardless of
their system settings. #1279 already fixed this once in App.tsx; the v6
rewrite that moved the logic into useAppRuntimeState.ts reinstated the
hardcoded form, and it has shipped that way since GA. The reporter spotted
the regression and named the original PR.

Swept the rest of the frontend for the same defect. One other site remained,
the alert history day group full-date label, which rendered "Thursday,
August 6, 2026" to a reader whose own rows are ordered day-month. Both now
pass undefined so the runtime resolves the viewer's locale and clock
convention.

Because this is a regression that already survived one fix, add a
canonical-shared/no-hardcoded-format-locale audit rule covering
toLocaleString, toLocaleDateString, toLocaleTimeString and the Intl
constructors, with an allowFiles escape for any call that genuinely needs a
fixed locale. Confirmed the rule fires on the exact reintroduced regression
and passes once reverted.

Verified in an en-GB browser: the footer now reads 20:52:55 rather than
8:52:55 PM, and the history day header title reads "Thursday, 6 August 2026".

Contract-Neutral: Contract deltas staged where the change actually lands: frontend-primitives.md gains the shared date/number formatting-locale rule and names the audit that enforces it, and alerts.md extends the existing timestamp paragraph to the day group label. Residual demands are inapplicable. cloud-paid.md names useAppRuntimeState.ts for hosted org-context bootstrap and licence boundaries and performance-and-scalability.md names it as an app-shell performance boundary; grepped both and neither documents date formatting, and swapping a locale argument moves neither boundary. The alerts frontend surface proof is the same fixed policy list as a0113b964 and does not name alertHistoryModel.branchcov.test.ts, which is the file that actually covers this model; editing an unrelated listed proof would be fabrication.
2026-08-06 20:56:44 +01:00
rcourtman eac9ffcabf feat(vmware): ingest real vCenter tags instead of provenance placeholders
The vSphere adapter filled `Resource.Tags` with six fixed strings on every
resource — `vmware`, `vsphere`, `<kind>`, `source:vcenter`,
`connection:<name>`, `power:<state>` — and never read vCenter's own tag and
category system. Every VM in an estate returned a byte-identical set, so the
workload Tags column rendered the same dots on every row and filtering on any
of them selected everything. Commit 6b78feba8 default-hid the column and said
in as many words that the hide was a stopgap awaiting this fix.

`internal/vmware/client_tags.go` reads the CIS tagging service. That is a
different endpoint family from the `/api/vcenter/...` inventory reads, but the
same vSphere Automation API, so it reuses the caller's `/api/session` token
rather than opening and managing a second session. Associations come from one
batched `list-attached-tags-on-objects` POST per bounded object batch, never a
per-object request; tag and category names resolve through a client-scoped
catalog with a 10-minute TTL, so a steady-state refresh of a tagged estate
costs only the association reads while a rename still converges without a
restart. A vCenter without the tagging service, or an account without the tag
read privilege, degrades into a `tags` stage enrichment issue and leaves the
inventory untagged; it never fails the refresh.

The provenance strings stay. `Resource.Tags` is the only keyword set
`resourceSearchMatch.ts`, the `?tags=` resources filter, and saved
report-schedule tag filters read — `collectSearchCandidates` gathers no
`technology`, `type`, or `platformScopes` candidate — so dropping "vmware" or
"vsphere" would silently stop matching searches and saved filters that depend
on them. Real vCenter labels are appended to that set, never substituted for
it.

Because that flat set is deliberately mixed, it is the wrong source for a
per-row Tags cell. Real tags therefore also land on a canonical `VMware.Tags`
facet that carries vCenter's category alongside each name, and
`useWorkloads.ts` maps `WorkloadGuest.tags` from that facet for any resource
carrying VMware metadata — including the empty case, so a vSphere VM nobody
tagged renders an empty cell instead of falling back to the provenance dots.
vCenter tag names are unique only inside their category, so the flat label is
`category:name`: two categories may each hold a "Production".

With the column carrying per-row meaning again, `tags` leaves
VMWARE_WORKLOAD_DEFAULT_HIDDEN_COLUMN_IDS and the `defaultHiddenMigrationIds`
retirement list, and the state-model test that pinned the stopgap now pins its
absence. No un-hide migration ships alongside it: 6b78feba8 is on main but no tag
contains it, so the stopgap never shipped and no install carries the
auto-hidden preference. That holds only while the two stay together — the
migration writes the hide into each user's saved preference on first load,
so an rc cut from main carrying the stopgap without this commit would make
an explicit un-hide path necessary.

Mock fixtures carry uneven tag coverage — several categories on some objects,
one on others, none on the rest — because a uniform fixture set would hide
exactly the defect this data exists to catch.

Verified against a mock estate built from this branch: `/api/resources`
returns provenance plus real labels on the flat set and only real labels on
`vmware.tags`; the Tags column renders 2-4 dots per tagged VM and none for
untagged ones; a dot's tooltip reads `Backup:Nightly`, and clicking it
searches `tags:Backup:Nightly` and narrows 18 VMs to the 3 that carry it.

Contract deltas: performance-and-scalability.md Extension Point 17 replaces
the stopgap paragraph with the two-surface tag contract and the bounded
tag-read budget; unified-resources.md states the keyword-union vs facet split
and that a present-but-empty facet means "no operator tags" rather than a
fallback; storage-recovery.md extends its VMware descriptive-only boundary to
`vmware.tags`, because vCenter tag vocabularies read like protection policy
(`Backup:Nightly`) and a label the operator wrote must never satisfy a
coverage or compliance verdict that recovery-owned evidence should decide.
2026-08-06 20:50:46 +01:00
rcourtman 9615b5f2b0 fix(security): close the four open CodeQL findings
Resolves every open code scanning alert on the repository. Dependabot and
secret scanning were already clear.

SMART temperature truncation (alerts 312, 313). parseRawValue returns a
64-bit raw attribute value, but DiskSMART.Temperature is an int, which is
32 bits wide on the 386 and arm release builds Pulse ships. The range check
ran after the narrowing conversion, so a raw value of 4294967316 truncated
to 20 and was published as a plausible 20 degree reading.
validSMARTTemperature64 now gates the conversion.

Provider MSP restore archive names (alert 314). cleanProviderMSPArchiveName
rejected a leading "../" but not a bare "..", which path.Clean produces from
entries such as ".." and "a/../..". pathIsInside caught the escape
downstream, so this was not exploitable, but the sanitizer now rejects it
outright instead of depending on a second gate.

TrueNAS device paths (alert 315). vdev.Device is supplied by the appliance,
concatenated into a path and published verbatim on ZFSDevice.Path, so values
like "//evil.example.com/share" and "/\evil.example.com" passed straight
through. devicePath now drops traversal segments and backslashes and
collapses a leading double slash. The alert's open-redirect framing does not
apply here, there is no redirect sink on this path, but the value is
untrusted input rendered as a path and is worth normalising.

Patrol readiness cache key (alert 311). The key is persisted to
ai_patrol_model_readiness.json and embedded an unkeyed SHA-256 of the Ollama
Basic Auth username and password. That password is chosen by a human, so
anyone holding the evidence file could recover it offline at two SHA-256
operations per guess. The fingerprint is now HMAC-SHA256 keyed with a
32-byte per-install salt stored beside the evidence at mode 600. Credential
rotation still invalidates the cache and the key still survives a restart.

Each fix carries a regression test confirmed to fail against the previous
implementation.

monitoring.md carries the one warranted contract refinement. It already
required SMART temperature selection to accept only plausible readings, and
that rule now states the width at which plausibility is decided.

Contract-Neutral: CodeQL security fixes with no public-contract delta and no payload change. monitoring.md carries the one warranted refinement (SMART plausibility decided at 64-bit width). Residual demands are inapplicable: ai-runtime readiness prose documents interruption semantics, not cache-key derivation, and the credential-invalidation contract is unchanged; cloud-paid and deployment-installability contracts never name archive-entry sanitisation; agent-lifecycle owns smartctl.go but its SMART temperature prose lives in the staged monitoring.md.
2026-08-06 18:11:51 +01:00
rcourtman a0113b9648 fix(alerts): open resource incidents at the row that asked for them
Fixes #1687. Also addresses the locale half of #1685.

The per-row Resource button in Alerts > History rendered its panel as a
page-level sibling in HistoryTab, between the filters card and the table.
Reproduced at 1280px: scrolled 3200px into the history, clicking the button
opened the panel 2595px above the top of the viewport. The only visible
effect was the row list shifting down as the panel was inserted above it, so
the button read as dead and the reporter could not reach the incident detail
or the absolute timestamps it carries.

Render the panel inline under the row instead, matching the neighbouring
Timeline button, in both the desktop table and the phone card list. Because
several alerts can share one resource, the panel state now carries the
originating rowKey and each row renders it only on a match, which also keeps
exactly one panel open at a time. Re-triggering the same row closes it.

The resource resolver moves from a HistoryTab prop onto the history state.
useAlertHistoryState already receives getResource; re-exposing it avoids
threading the lookup through the table section, group row, alert row and
mobile list now that the panel mounts in four places rather than one.

Row timestamps were built with a hardcoded 'en-US' locale while the rest of
this feature already formats through the viewer's locale, so a European
reader saw "05:19 AM" for 05:19. They also showed clock time only, with the
date available just in the day group header, which scrolls out of sight in a
long history. Both surfaces now format through the viewer's locale and carry
the full absolute date and time as a title, with the formatters owned by the
history state so table and mobile cannot drift.

Verified against mock data at 1280px and 390px: the panel opens in view
under the clicked row and card, a non-owning row renders nothing, timestamps
render 23:50 with a "Thursday, 6 August 2026 at 23:50:18" title across all
98 rendered rows, and there is no horizontal overflow at phone width.

Contract-Neutral: Contract deltas staged in alerts.md (inline resource-incident panel placement, rowKey targeting, locale-aware row timestamps) and frontend-primitives.md rule 35 plus its prose (resource resolver moves from the tab prop chain to the history state). Residual demand is an 'alerts frontend surface proof' from a fixed policy list that does not name the three test files this change actually exercises: __tests__/useAlertHistoryState.test.tsx, __tests__/AlertHistoryMobileList.test.tsx and __tests__/HistoryTab.test.tsx, all staged with new assertions covering the panel placement, the toggle, and the timestamp title. Editing an unrelated listed proof file would be fabrication.
2026-08-06 18:07:15 +01:00
Richard Courtman 25ba5fd63e docs: publish SignPath policy links 2026-08-06 17:42:08 +01:00
rcourtman 6b78feba8a fix(ui): stop rendering two uninformative workload columns
Follow-up audit after the vSphere Backup fix, sweeping every platform page
and tab for columns that say the same thing on every row.

Avail (both Proxmox and vSphere, default-visible): the cell renders nothing
at all until an availability check is linked to that workload. Availability
checks are opt-in per resource, so any install without one showed an empty
column under an "Avail" header on every row. Gate it on live data via a new
`hasAvailabilityData` accessor rather than on a stored preference: writing a
hidden preference would overwrite the user's own choice the first time a
probe appeared. The column now returns by itself when data arrives, and the
accessor reads the unfiltered guest set so narrowing the table by search or
status never makes it vanish.

Tags (vSphere, default-visible): every VM rendered six dots carrying the
identical tag set, because `internal/vmware/provider.go` fills `Resource.Tags`
with fixed provenance strings (`vmware`, `vsphere`, `vm`, `source:vcenter`,
`connection:<name>`, `power:<state>`) rather than reading vCenter's tag API.
Five are constant across an estate and the sixth restates the power state the
status filter already owns, so filtering on any of them selects everything.
Default-hide it on the vSphere scope, with the same one-time migration used
for `backup` so existing preferences are retired too. Proxmox keeps the column
since its tags are genuine per-guest labels.

The Tags hide is a stopgap, not the canonical fix. vCenter does expose a real
tag/category system; the adapter simply does not read it. The tags stay in the
payload because search and facet counts consume them, and the hide should be
removed once the adapter ingests real vCenter tags.

Also checked and left alone: AI Context on vSphere reports the backend's own
`unsupported` discovery state as "N/A", which is honest and already
default-hidden. Docker, Kubernetes, TrueNAS, Machines and every Proxmox and
vSphere sub-tab had no single-value columns.

Contract-Neutral: Contract delta staged in performance-and-scalability.md Extension Point 17 (vSphere tags stopgap + the hasAvailabilityData gate). Residual demand is a Workloads hot-path perf proof; inapplicable because this only changes which columns are offered at render time and adds no per-row or per-frame work, so no hot-path proof file legitimately changes.
2026-08-06 17:32:20 +01:00
Richard Courtman dbd353427b docs(governance): establish localized product lane 2026-08-06 17:26:12 +01:00
Richard Courtman d1e44101b6 docs(governance): promote monitor-first Patrol lane 2026-08-06 17:08:27 +01:00
rcourtman ed9446fdaf fix(ui): retire stale vSphere Backup column preference
The workload table's Backup column reads only `resource.proxmox.lastBackup`
(useWorkloads.ts), so on vSphere it renders "None" on every row forever.
d929ac647 added `backup` to VMWARE_WORKLOAD_DEFAULT_HIDDEN_COLUMN_IDS, but a
default only applies to users with no saved preference for that storage scope.
Anyone who had touched the Columns control on the vSphere page before that
commit kept the column and still sees a full column of "None".

useColumnVisibility already carries the mechanism for exactly this case: a
one-time default-hidden migration that hides a newly-defaulted column once for
users with an existing preference, and records a marker so a deliberate
re-show sticks. useWorkloadsControlsState was passing only `aiContext`, so
`backup` never migrated. Add it.

The migration is guarded on the id already being in that scope's
effectiveDefaultHidden set, so Proxmox, the only platform whose adapter
populates lastBackup, keeps the column visible with real backup ages.

Contract-Neutral: Contract delta staged in performance-and-scalability.md Extension Point 17 (defaultHiddenMigrationIds ownership). Residual demand is a Workloads hot-path perf proof; inapplicable because this adds one id to a startup-time default-hidden migration array and no per-row or per-frame work, so no hot-path proof file legitimately changes.
2026-08-06 16:41:19 +01:00
rcourtman bbd6910f49 docs(signing): record test-signing proof 2026-08-06 16:15:32 +01:00
rcourtman 9d4f6ae923 fix(install): stop the agent version warning firing on correct installs
The installer compares the agent binary it downloaded against the server that
served it, stripping a leading "v" so "v6.0.4" and "6.0.4" match. It did not
strip semver build metadata, so a server built from a working tree reporting
"6.2.0-rc.8+git.46.g98a638e00.dirty" never matched the "v6.2.0-rc.8" agent it
had just served, and the mismatch warning fired on every correct development
install.

This is the warning's whole job, so a false positive is expensive. It is the
only client-side signal that a stale agent was downloaded, and because it
always fired it read as background noise. That is exactly how a genuinely
stale v6.0.5 agent was installed on a live host earlier today: the warning
was there, above the install output, and looked like the one that always
appears.

Strip build metadata from both sides before comparing, keeping the prerelease
suffix because 6.2.0-rc.8 and 6.2.0 are genuinely different releases. This is
the same release-identity reduction the server applies when deciding whether a
local agent artifact is fresh enough to serve; the contracts now state that
one definition governs both ends rather than leaving each side to invent its
own.

Guarded by a test that pins both normalisation steps and exercises the
comparison across the cases that matter: the dev-server shape that used to
warn wrongly, the stale-download shape that must still warn, and a prerelease
against its release. Verified to fail when either strip is removed.
2026-08-06 15:50:47 +01:00
rcourtman 883e951023 fix(agent): refuse to serve an agent binary older than the server
/download/pulse-agent served whatever agent artifact sat on disk with no
relation to the running build. Local agent binaries are build outputs that
nothing refreshes on their own, so they go stale silently: a dev backend was
found serving a four-week-old v6.0.5 agent while reporting 6.2.0-rc.8.

Staleness is not cosmetic. The installer renders its service wrapper from the
server's current template, so an agent predating a flag that template now
passes exits immediately with "flag provided but not defined" and crash-loops
under its watchdog. That is how a real host lost its agent: the version
mismatch was reported only as an installer warning, after download, easy to
read as noise.

Validation already scanned the binary for its report-contract endpoints, so
the version check joins that same single pass and rejects a binary that does
not carry this server's agent version. Refusal is loud where the old warning
was quiet: a dev server answers 404 naming the stale path and the build
command, and a published release falls through to the existing release-asset
proxy and fetches the matching version, which makes production self-healing
rather than silently downgraded.

The expected version resolves through updates.GetCurrentVersion rather than
the compiled-in serverVersion. The first cut of this guard used serverVersion
and was inert on exactly the builds that need it: the enterprise binary
compiles in "dev-pro", no version parser accepts it, and the check disabled
itself. It passed its unit tests and still served the stale binary; only
replaying the real v6.0.5 artifact through the running server exposed it.
"dev-pro" is now pinned in the version table with that reasoning attached.

The shared download-test fixture built a binary carrying the report endpoint
but no version string, which a real agent always has, so it now stamps the
expected version. Verified non-vacuous in both directions: those tests fail
with the guard active and the thin fixture, and pass with a faithful one.

Contract-Neutral: storage-recovery is pulled in only by the broad internal/api/ Extension Points prefix and this change does not move that boundary: it constrains which agent binary /download/pulse-agent serves, touching no storage provider, backup target, recovery repository or protected-workload evidence. The agent-lifecycle, deployment-installability and api-contracts deltas staged here cover every boundary the change actually moves.
2026-08-06 15:27:39 +01:00
rcourtman 8a9dedc99d docs(signing): record SignPath onboarding status 2026-08-06 15:12:25 +01:00
rcourtman daf2ad7ff9 fix(install): bound every wrapper kill and stop supervisors first
Completes the wrapper-teardown rule across the remaining branches. The QNAP
install and both uninstall paths still used a bare pkill -f
"start-pulse-agent.sh" and still stopped the agent before its wrapper.

The bare pattern is narrower than it looks and wider than it should be. It
does NOT match a co-installed agent's supervisor, so the sibling case was
already safe; what it does match is anything where the unescaped dot stands in
for another character and the unbounded tail keeps going, including a .bak
copy of the wrapper and an editor session holding it open. Escaping the dot
and bounding the far end removes both without narrowing the intended match.

Ordering is the more consequential half. A wrapper is a watchdog, so stopping
the agent while its wrapper still loops only races the respawn. QNAP and the
uninstall paths now stop the supervisor first, which is what the contracts
already required of every branch that writes and launches a wrapper.

Uninstall keeps a deliberately broader match than install, with no leading
path separator, so it still reaches a wrapper invoked by a relative path or
stranded at a superseded location. Both contracts now carry that distinction
and the teardown ordering rule, which each had stated only for install.

Guarded by two tests that pin every wrapper kill in the file rather than one
branch: one requires the escaped dot and the bounded tail everywhere, the
other walks each stop block and fails if an agent kill precedes its wrapper
kill. Both were confirmed to fail against the pre-fix QNAP block.
2026-08-06 15:03:09 +01:00
rcourtman d7d6753d05 fix(install): stop the previous watchdog before starting a new one on Unraid
The Unraid install path killed the running agent but never the wrapper
supervising it, then appended a second wrapper at the end of the install. The
survivor and the newcomer both loop trying to own the same agent id, and
because the old wrapper is a watchdog it respawns the agent mid-install with
the previous binary and arguments. Observed on a live Unraid host: a
supervisor from a July install was still running beside the one the reinstall
had just started.

Nothing reports this as a failure. It presents later as an agent that
restarts on its own or reverts to superseded arguments.

Stop the wrapper first, then the agent: killing a supervised agent while its
wrapper still loops only races the respawn. The pattern matches the trailing
path segment so a wrapper left at an older storage location is caught too,
with the dot escaped and the far end bounded so a co-installed agent's
supervisor (start-pulse-agent-prod.sh) is not.

The QNAP branch already stopped its wrapper, which is what made the Unraid
omission visible; the contracts now require every wrapper-writing branch to
own the same teardown.

Guarded by two tests: one pins that the Unraid branch stops the wrapper and
does so before the agent, the other pins that the wrapper pattern spares a
sibling supervisor, with a premise check that the loose pattern really does
match so neither assertion can pass vacuously.
2026-08-06 14:41:20 +01:00
rcourtman dd72bd1490 fix(install): stop agent kills matching a co-installed sibling agent
pkill -f matches the whole command line and "^" only anchors the start, so
"^/usr/local/bin/pulse-agent" also matches "/usr/local/bin/pulse-agent-prod".
On a host running a second agent whose binary name shares the prefix, every
install, every upgrade, and every restart of the generated Unraid wrapper
silently killed the other agent too. Confirmed on a live dual-agent Unraid
box: the old pattern matched both the dev agent and the production dogfood
agent, the bounded pattern matches only its own.

The wrapper is the worst of the three because restarting through it is the
documented runbook step, so the collateral kill repeats every time an
operator follows it.

Bound the far end of each binary-anchored pattern with ([[:space:]]|$), and
swap the bare pkill -9 -f "pulse-agent" for -x on the exact process name,
which keeps that site's deliberate path-agnostic intent while excluding the
sibling. The pkill -x sites were already safe and are unchanged.

Guarded by two tests: one pins that no binary-anchored pkill in the installer
is left unbounded, the other exercises POSIX ERE semantics against the two
command lines a dual-agent host presents, including a premise check that the
unbounded pattern really does match the sibling so the assertion cannot pass
vacuously.
2026-08-06 14:07:32 +01:00
rcourtman 59773ce94e fix(mock): stop real infrastructure reaching mock-mode surfaces
Mock mode suspends pull-based collection outright, but push-based agent
reports were never given the same treatment, so a real machine still landed
in monitor state while the unified read path substituted the mock snapshot
over the top. The hosts were hidden, everything downstream was not: a real
Unraid box raised a live storage-topology alert next to fixture data, and
its identity persisted through host continuity.

Three vectors, each closed at its source.

Agent ingest now drops real reports while mock mode is on. ApplyHostReport,
ApplyDockerReport and ApplyKubernetesReport acknowledge the report with the
reporting agent's own identity and touch no state, so nothing raises alerts,
persists continuity, records metrics or feeds the online/offline sweep. The
acknowledgement stays a success so a real agent does not read a demo server
as an outage and retry-storm it.

recentStandaloneHostContinuityEntries returns nothing in mock mode. Those
entries are written to disk from real reports and outlive the toggle, and
every consumer injects them after the read path has already substituted the
mock snapshot, so a machine that reported before mock mode was enabled came
back by its real hostname. There is no real-polling exception here: agent
ingest is not gated on PULSE_MOCK_KEEP_REAL_POLLING and the read state is
mock either way.

Active-alert restore is now opt-out, and mock mode opts out. SetMockMode
already clears active alerts when the toggle flips, but a process booting
with mock mode already enabled never ran that path and restored real alerts
from active-alerts.json.

TestHostedTenantAgentInstallTokenCannotReportToOtherTenant used mock mode as
scaffolding. Under the ingest guard both tenants would be empty and its
isolation assertion would pass without exercising the boundary, so it now
runs in real mode. Every new test pairs the mock assertion with a real-mode
one for the same reason.
2026-08-06 12:13:57 +01:00
rcourtman 5787cea4eb fix(install): use absolute binary path in pct exec instructions
pct exec runs with PATH=/sbin:/bin:/usr/sbin:/usr/bin, which does not
include /usr/local/bin where the pulse binary is linked. Every first-run
instruction that invoked a bare `pulse` through pct exec therefore failed
with exit 127, including the command the installer itself prints at the
end of an LXC install.

Reproduced on Proxmox VE 9.1.9 and 8.3.0:

    pct exec <ctid> -- pulse --version
      -> exit 127, Failed to exec "pulse"
    pct exec <ctid> -- /usr/local/bin/pulse --version
      -> Pulse v6.2.0-rc.8

The installer now prints $BINARY_LINK_PATH and $UPDATE_HELPER_PATH rather
than a bare name, so both stay correct when a custom service name moves
them. The update helper was only affected for custom service names, since
the default /bin/update already sits on the pct exec PATH.

Docs updated in INSTALL.md, FAQ.md and TROUBLESHOOTING.md plus their
frontend-modern/public/docs mirrors. The setup wizard prints the same
broken command and is fixed separately.
2026-08-06 12:08:13 +01:00
rcourtman 98a87864a0 docs(brand): crop the animated Pulse mark to its artwork bounds
Icon collections require the artwork to fill the canvas with no empty
space, so a padded viewBox renders the mark a few percent smaller than
every neighbouring icon on a dashboard. Crop the viewBox to the bounding
box of the disc so the file can be submitted to those collections
directly rather than needing a separate cropped variant. The mark itself
is unchanged, and the scale origin still resolves to the true centre.

Also corrects the provenance claim in c41338d10. That message said the
dashboard-icons file was "drawn by hand from a screenshot". It was not.
The file submitted in homarr-labs/dashboard-icons#1526 carries our exact
palette, including the dark-mode #dbeafe, and the same
prefers-color-scheme structure as docs/images/pulse-logo.svg, so it was
adapted from our published static logo. The ring and centre dot were
merged into a single circle in the process. The gap that made that
adaptation necessary was ours, since no animated mark had been published
for anyone to use.
2026-08-06 11:22:26 +01:00
rcourtman b6cf0109e5 fix(mock): keep real configured sources out of the mock connections ledger
Mock mode never initialises real PVE/PBS/PMG clients and does not run the
platform pollers, so every configured real source sat in the connections
ledger at "awaiting first poll" forever. That published real connection
names and addresses through /api/connections while the rest of the payload
was authored fixtures, and surfaced them on monitoring copy: the Proxmox
workloads empty state rendered "Collection pending: minipc" next to three
mock nodes.

/api/config/nodes already substitutes mock entries wholesale in mock mode
and rejects node mutations outright, so the ledger was the one surface that
had not been brought in line. Move the mock-mode input shaping into
applyMockLedgerInputs in platform_mock_connections.go, which already owns
the mock vSphere, TrueNAS, and availability ledger fixtures, and drop the
config and persistence derived sources there. PULSE_MOCK_KEEP_REAL_POLLING
keeps the previous behaviour, since those sources do collect under it.

Proof is at the payload level rather than the aggregator inputs: the new
handler test asserts the served /api/connections body contains no real
source by name or address, and was verified red before the fix.

Contract-Neutral: agent-lifecycle is named only by the broad internal/api/ Extension Points prefix and this change does not move that boundary: agent rows come from the monitor hosts snapshot, which is untouched. The api-contracts and storage-recovery deltas cover the boundaries actually moved. No payload field was added or changed, so the backend-API-payload proof list does not apply; the handler-level payload proof is TestConnectionsHandleListDropsRealSourcesInMockMode in internal/api/connections_handlers_mock_test.go, verified red without the fix.
2026-08-06 11:09:52 +01:00
rcourtman c41338d106 docs(brand): add the animated Pulse mark as a canonical asset
The Pulse mark is three concentric shapes, a blue disc, a white stroked
ring and a white centre dot. Only the static form was ever published, so
the dashboard-icons entry for Pulse (homarr-labs/dashboard-icons#1526)
was drawn by hand from a screenshot and shipped two circles instead of
three, with the ring flattened into a solid disc and the centre dot
missing entirely. Every Homepage dashboard rendering `icon: pulse.svg`
shows that approximation rather than the real mark.

Publish the animated mark alongside the static one so downstream icon
sets have a canonical file to mirror instead of an eyeballed redraw.
Geometry matches docs/images/pulse-logo.svg exactly, the ring pulses on
a 2s cycle from the true centre of the viewBox, and the animation holds
still under prefers-reduced-motion.
2026-08-06 10:59:23 +01:00