The gate that mirrors command-channel admission
(commandConfigAllowedForToken) runs on every agent report, before the
agent ever attempts channel registration. When it refuses, the server
silently serves commandsEnabled=false, the agent never tries to
register, so none of the channel rejection warnings fire, and an
operator who enabled lifecycle management and granted agent:exec sees
"Remote commands: Not enabled" with nothing in any log. Emit the
refusal and the binding metadata it was judged against so the mismatch
is diagnosable from journalctl.
Refs #1728
Contract-Neutral: diagnostic logging only in agent command config gate, no behavioral or public contract change
A reverse-proxy deployment that set PROXY_AUTH_ROLE_HEADER without also
setting PROXY_AUTH_ADMIN_ROLE granted every proxy-authenticated user full
administrator access. CheckProxyAuth only evaluated roles when both values
were non-empty, so the half-configuration skipped role gating entirely and
returned isAdmin=true. docs/PROXY_AUTH.md has always documented an `admin`
default for that variable, but the Config struct's envconfig `default` tags
are legacy and never applied (config.go), so nothing ever populated it.
CheckProxyAuth is the single admin verdict all 20+ proxy-auth gates consume,
so the fail-open reached every one of them. Verified on a scratch instance
with PROXY_AUTH_ROLE_HEADER set and no admin role: a request carrying only
`X-Proxy-Roles: user` received HTTP 200 and the full admin payload from
GET /api/system/settings, HTTP 200 from POST /api/system/settings/update,
and proxyAuthIsAdmin=true from /api/security/status. All three now return
403 / false, while `X-Proxy-Roles: admin` still passes.
Resolve the documented default in both layers that can produce the verdict:
config load populates ProxyAuthAdminRole when proxy auth is configured, and
CheckProxyAuth now keys role gating on the role header alone, resolving an
empty admin role through config.DefaultProxyAuthAdminRole. Configuring a
role header is the operator's signal that admin access is role-gated;
leaving the admin role unset must not switch that off.
Deployments that intentionally treat every proxied user as an admin are
unaffected: that is still expressed by leaving the role header unset.
Non-admin sessions cannot read GET /api/system/settings, so the Settings
General Monitoring Cadence card fell back to the Realtime (10s) preset
regardless of the configured interval; an issue #1601 reporter read that
as the server polling faster for non-admins. Publish the effective
pvePollingInterval on the authenticated runtime-display projection
(runtime config first, persisted value only as fallback, matching the
admin route's precedence), consume it in the viewer fallback of the
settings state, and run that initialization for sessions without
infrastructureRead too, whose ungated General panel previously never
initialized presentation state at all.
Adaptive polling deliberately stretches an instance's cadence toward its
max interval (5 minutes by default) while data is fresh, but the
connections aggregator judged staleness against the configured cadence
with a 2-minute floor. Any adaptive-enabled install therefore cycled
healthy PVE/PBS/PMG connections into stale for the back half of every
stretched poll gap: the Infrastructure page dropped the source badge
from API + Agent to Agent and connection-degraded alerts fired against
a schedule the poller was honoring.
The aggregator now scales the active-to-stale cutoff by the scheduler's
currently planned interval when that exceeds the configured cadence,
via Monitor.PlannedPollInterval and per-instance planned intervals in
the aggregator inputs. A plan tighter than the configured cadence never
tightens the cutoff, so genuine poll outages still trip the floor on
time. Connection-degraded alerts and the runtime inventory gate consume
the same derived state and inherit the fix.
Refs #1437
Contract-Neutral: behavioral fix: stale cutoff follows adaptive planned interval (#1437), no public contract delta
The Plans & Billing navigation entry is suppressed for sessions without
commercial context, which hid the only activation-form entry point from
exactly the customers who need it: a fresh Pro install has no license yet,
so hideUpgrade stayed true until the key the customer could not find a
place to paste was pasted. The compiled Pro binary is only distributed
through the paid broker flow, so a Pro-edition session is commercial
context by construction — edition.IsPro() now sets commercialContext in
securityStatusPresentationPolicy.
Ordinary free self-hosted sessions run the community binary and keep the
opt-in default (the edition marker keys off the compiled binary, never
license state). Demo mode and white-label keep full suppression via
hideCommercial, which wins over edition-derived context. Complements
1b9bc9482 (setup-completion activation pointer); together a fresh Pro
install gets both the first-run pointer and a permanently visible
Plans & Billing entry. api-contracts, storage-recovery, and
agent-lifecycle contracts record the policy delta.
Clears the red main streak that began at 63a0adf9a:
- newTestConfigHandlers never stopped its alerts.Manager, leaking a
periodicSaveAlerts goroutine that logs through the global zerolog
logger and races with the authorization-refusal contract test's
logger swap under -race
- InfrastructureAgentDoctorPage used a raw select, tripping the
settings native-select guardrail; converted to the shared labelled
FormSelect primitive
- the metric-title residual pin still expected the removed -1 sentinel
copy; aligned with the Off-control wording from 16179dd0a
Contract-Neutral: test-only CI red fix: stops alerts.Manager goroutine leak in api test helper, converts agent doctor raw select to the canonical FormSelect primitive the frontend-primitives contract already mandates, updates a stale test pin; no public contract delta
Route config-owned install commands and setup-script artifacts through the canonical trusted request-origin resolver. Preserve configured URL precedence and add endpoint-level adversarial coverage for token-bearing commands.
Agent install and update commands advertised http://<LAN-IP>:7655 on
installs where the operator browses Pulse over an https FQDN, because
boot-time detection pre-fills config.PublicURL with an IP guess and
resolvePublicURL treated that guess as configuration. Track
auto-detected values (boot probe or first-request capture) separately:
explicit config from the env var or the settings UI still wins, but an
auto-detected guess now yields to the origin of the admin request that
is asking, so copied commands match how the instance is actually
reached.
Also stop boot detection from clobbering a settings-persisted public
URL on restart, which silently reverted the operator's saved value
whenever PULSE_PUBLIC_URL was unset.
Refs #1692
Contract-Neutral: behavioral fix to advertised-URL derivation: no API shape, field, or endpoint changes; PublicURLAutoDetected is internal (json:-)
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.
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.
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.
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.
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.
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.
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).