Update the Pro binary in-app from the license server download broker
Some checks are pending
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run
Canonical Governance / governance (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 1/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 2/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 3/4) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 4/4) (push) Waiting to run
Core E2E Tests / E2E verdict (push) Blocked by required conditions

The in-app updater and the unattended timer both target the public
rcourtman/Pulse community assets, so a Pro install that used them was
silently downgraded to community. Guard 2 (983a89326) blocked in-app
apply on the Pro edition, which stopped the downgrade but left Pro
installs with no update path except manual portal downloads. That
friction is a plausible driver of the runtime split: as of 2026-07-08
only 10 of 66 active paid licenses have any Pro-runtime install.

Root fix: the compiled Pro binary now checks and applies updates
through the license server download broker (GET /v1/downloads/pulse-pro
with the installation token and instance fingerprint). The check
compares against the broker's pinned private release instead of GitHub,
respecting the stable/rc channel guard. Apply re-resolves fresh signed
R2 URLs at apply time, verifies the archive against the same pinned
pulse-installer SSHSIG key plus the broker manifest sha256, and refuses
GitHub-shaped download URLs outright. An unactivated Pro binary still
refuses with the portal fallback. The community edition path is
unchanged.

The update banner restores in-app apply for auto-updatable Pro
deployments and keeps the portal instructions for deployments the
updater cannot drive (Docker). scripts/pulse-auto-update.sh now skips
when the installed binary reports Pulse Pro so the unattended timer can
never reinstall community over Pro.

Note: internal/updates/pro_update.go and manager_pro_update_test.go for
this change landed one commit early inside 313552deb via a parallel
session committing a shared staged index; this commit completes the
wiring they belong to.
This commit is contained in:
rcourtman 2026-07-08 23:39:37 +01:00
parent 313552debc
commit 93bccaff18
18 changed files with 467 additions and 95 deletions

View file

@ -151,7 +151,12 @@ Agent-facing operations-loop status wiring in `internal/api/router.go` and
shares agent route infrastructure. Other handlers in `internal/api/` such as
the AI settings handler (`ai_handlers.go`) carry AI provider configuration
(for example per-provider base URL overrides) that is ai-runtime config-surface
and is not agent enrollment, liveness, or lifecycle state. Workflow starter counts on that endpoint,
and is not agent enrollment, liveness, or lifecycle state. The Pro update
credential wiring in `internal/api/router.go` (feeding the activation's
installation token and instance fingerprint to the server updater's
download-broker path) is likewise server self-update plumbing: agent
enrollment, agent update liveness, and fleet-control semantics must not key
off it. Workflow starter counts on that endpoint,
contextual Assistant/external-agent collaboration counts inside the Assistant
step, the content-free Patrol control starter split, and Patrol control
completed-loop, resolved-loop, or `patrolControlValueState` proof mirrored to

View file

@ -1585,6 +1585,15 @@ payload shape change when the portal presents compact client rows.
the requested target version through the shared update-target validation
path, recompute readiness from live backend state, and reject `blocked`
verdicts before update execution starts.
On the compiled Pro edition the same check and apply endpoints are served
by the license server download broker instead of the public GitHub
releases: response shapes are unchanged, but `downloadUrl` is a broker
intent URL for the activation's license server (target version carried in
the query so the shared update-target validation still applies), and apply
re-resolves fresh signed artifact URLs server-side. Update payloads must
never expose broker credentials (installation token, fingerprint) or the
short-lived signed URLs, and handlers must not fork payload shape by
edition.
The platform-connections API contract also owns inactive monitored-system
candidate semantics end to end. `enabled=false` on TrueNAS or VMware preview,
test, add, and update payloads must serialize through the shared ledger client

View file

@ -249,6 +249,14 @@ avoids a cloud-control-plane report data path across clients.
preserve the raw runtime build and expose a normalized `pro`, `community`,
or `unknown` status so paid-runtime support triage does not depend on
interpreting Docker tags, public release names, or customer screenshots.
The same activation state is also the credential source for the compiled
Pro binary's in-app self-update through the license-gated download broker:
that consumer is read-only over the activation snapshot (installation
token, instance fingerprint, license server URL) and must not mutate
licensing state, extend entitlements, or act as an alternate activation
path. Keeping the Pro runtime updatable in place is part of the
paid-runtime posture; the community self-update flow must never be the
default answer for an installed Pro runtime.
That same shared licensing boundary also owns paid-migration degradation
visibility and recovery. A persisted v5 license that exists but cannot be
read or decrypted must publish a terminal `commercial_migration` state

View file

@ -593,20 +593,29 @@ TLS floor in the dynamic config.
archive filenames through `--archive` so direct Linux and Proxmox LXC users
can keep the normal service setup while installing the private Pulse Pro
runtime.
The in-app updater must refuse to apply on the compiled Pulse Pro binary:
`internal/updates` `ApplyUpdate` blocks when the running edition is Pro
(recorded by `pkg/edition`, flipped to `pro` in `enterpriseruntime.Initialize`
alongside `coreaudit.SetLogger`/`server.SetBusinessHooks`, and keyed off the
compiled binary — never license-active state) and directs the operator to
The in-app updater must never install a public community build on the
compiled Pulse Pro binary: when the running edition is Pro (recorded by
`pkg/edition`, flipped to `pro` in `enterpriseruntime.Initialize` alongside
`coreaudit.SetLogger`/`server.SetBusinessHooks`, and keyed off the compiled
binary — never license-active state), `internal/updates` checks and applies
updates exclusively through the license server download broker
(`GET /v1/downloads/pulse-pro` with the installation token and instance
fingerprint, per `internal/updates/pro_update.go`), verifying the private
archive against the same pinned `pulse-installer` SSHSIG key plus the
broker manifest sha256, and refusing GitHub-shaped download URLs outright.
An unactivated Pro binary refuses to apply and directs the operator to
`https://pulserelay.pro/download.html` and the `install.sh --archive` path.
This is required because the community self-update flow (both the in-app
updater and `install.sh` default to the public `rcourtman/Pulse` community
assets) would replace the Pro binary and silently strip Audit, RBAC,
Reporting, and SSO from a paying customer. A community binary with an active
paid license is still community and must keep its normal self-update; the
`frontend-modern` update banner mirrors the same distinction by hiding the
in-app apply affordance for the Pro runtime identity and surfacing the portal
path instead.
This is required because the community self-update flow (the in-app GitHub
path, `install.sh` defaults, and the unattended
`scripts/pulse-auto-update.sh` timer — which must skip when the installed
binary reports `Pulse Pro`) targets the public `rcourtman/Pulse` community
assets and would replace the Pro binary and silently strip Audit, RBAC,
Reporting, and SSO from a paying customer. A community binary with an
active paid license is still community and must keep its normal
self-update; the `frontend-modern` update banner keeps the in-app apply
affordance for auto-updatable Pro deployments (the broker path preserves
the Pro runtime) and surfaces the portal path for deployments the updater
cannot drive, such as Docker.
Customer-facing private Pro RC/GA promotion is part of that same boundary:
for every non-draft v6 public release, `create-release.yml` must call the
private `rcourtman/pulse-enterprise` `Build Pro Release` workflow after

View file

@ -279,6 +279,12 @@ regression protection.
monitor config into the unified-resource adapter, but it must not add
per-request polling, registry rescans, persistence walks, or tenant-wide
refreshes to decide whether Proxmox/PBS/PMG resources are stale.
Pro update credential wiring in `internal/api/router.go` follows the same
bounded rule: the credential-source closure reads the already-held
activation snapshot only when the updater checks or applies, and the
download-broker fetch stays on the existing update-check cadence (the
check cache plus one fresh resolve per apply). It must not add polling
loops, background license-server traffic, or per-request broker calls.
Global resource timeline routing follows the same protected-request hot-path
rule: `/api/resources/timeline` registration may wire the authenticated
handler, but router setup and auth gating must not execute resource-change

View file

@ -321,6 +321,13 @@ the `white_label` branding entitlement.
metadata only. They must not disclose credentials, command output, raw
provider payloads, tenant-crossing config, or any new resource-policy bypass
through monitoring-readable API responses.
The Pro update credential source in router glue hands the activation's
installation token, instance fingerprint, and license server URL to the
server updater only. The token travels solely as an Authorization header
to the activation's normalized license-server base URL; it must never be
logged, echoed through update payloads, status, or history surfaces, or
sent to any other host, and the broker's short-lived signed artifact URLs
are transport only and must not be persisted or exposed.
Assistant session rename routing through `PATCH /api/ai/sessions/{id}`
stays on that same auth/scope boundary: the route may accept only a
user-visible title mutation, must not expose transcript contents,

View file

@ -540,6 +540,12 @@ recovery scope, or a storage/recovery-owned secret source.
transport with storage/recovery. Storage and recovery consumers must
preserve the API-owned Docker / Podman module or host wording for management
responses and must not introduce recovery-local container-runtime labels.
Pro update credential wiring and update-broker transport in
`internal/api/router.go` and the update handlers are server self-update
plumbing, not storage or recovery surface: storage and recovery must not
consume them, and the pre-update backup and rollback machinery in
`internal/updates` stays identical for community and private Pro archives
so recovery semantics never fork by edition.
Proxmox-side LXC Docker inventory wiring may also pass through
`internal/api/router.go` and Proxmox agent install-command generation, but
storage and recovery may consume the resulting app-container/resource

View file

@ -15,7 +15,7 @@ const { mock } = vi.hoisted(() => ({
}));
// runtime.build is the binary-edition signal (business-hooks presence), which
// is what UpdateBanner keys the Pro path off of.
// is what UpdateBanner keys the Pro paths off of.
vi.mock('@/stores/license', () => ({
runtimeCapabilities: () => ({ runtime: { build: mock.runtimeBuild } }),
}));
@ -60,24 +60,49 @@ afterEach(() => {
mock.plan = { canAutoUpdate: true, requiresRoot: false, rollbackSupport: false, instructions: [] };
});
describe('UpdateBanner Pro edition guard', () => {
it('suppresses in-app apply and routes the Pro binary to the portal', () => {
describe('UpdateBanner Pro edition update paths', () => {
it('keeps in-app apply for the Pro binary on auto-updatable deployments', async () => {
mock.runtimeBuild = 'pro';
render(() => <UpdateBanner />);
// The in-app apply affordance must never render for the Pro binary, even
// though the plan reports canAutoUpdate=true (systemd).
expect(screen.queryByRole('button', { name: /Apply Update/ })).not.toBeInTheDocument();
const portalLink = screen.getByRole('link', { name: /Private Release Access/ });
expect(portalLink).toHaveAttribute('href', PORTAL_URL);
// The Pro binary updates from the license server download broker, so the
// in-app apply affordance must render once the plan resolves.
expect(await screen.findByRole('button', { name: 'Apply Update' })).toBeInTheDocument();
expect(screen.queryByRole('link', { name: /Private Release Access/ })).not.toBeInTheDocument();
});
it('shows the portal steps (archive + .sshsig) when the Pro banner is expanded', () => {
it('explains the private release source when the Pro banner is expanded', async () => {
mock.runtimeBuild = 'pro';
render(() => <UpdateBanner />);
await screen.findByRole('button', { name: 'Apply Update' });
fireEvent.click(screen.getByTitle('Show more'));
expect(screen.getByText(/private Pulse Pro build from the license server/)).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Apply Update Automatically' }),
).toBeInTheDocument();
expect(screen.queryByRole('link', { name: /Private Release Access/ })).not.toBeInTheDocument();
});
it('routes non-auto-updatable Pro deployments to the portal', async () => {
mock.runtimeBuild = 'pro';
mock.plan = { canAutoUpdate: false, requiresRoot: false, rollbackSupport: false, instructions: [] };
render(() => <UpdateBanner />);
const portalLink = await screen.findByRole('link', { name: /Private Release Access/ });
expect(portalLink).toHaveAttribute('href', PORTAL_URL);
expect(screen.queryByRole('button', { name: /Apply Update/ })).not.toBeInTheDocument();
});
it('shows the portal steps (archive + .sshsig) for manual Pro deployments when expanded', async () => {
mock.runtimeBuild = 'pro';
mock.plan = { canAutoUpdate: false, requiresRoot: false, rollbackSupport: false, instructions: [] };
render(() => <UpdateBanner />);
await screen.findByRole('link', { name: /Private Release Access/ });
fireEvent.click(screen.getByTitle('Show more'));
expect(screen.getByText('Pulse Pro update')).toBeInTheDocument();

View file

@ -7,9 +7,10 @@ import { copyToClipboard } from '@/utils/clipboard';
import { logger } from '@/utils/logger';
import { buildReleaseNotesUrl } from '@/components/updateVersion';
// Self-hosted Pulse Pro updates come from the Private Release Access portal, not
// the in-app updater (which tracks the public community build and would strip
// Pro features). See the ApplyUpdate edition gate in internal/updates/manager.go.
// The Pro binary self-updates from the license server download broker (see
// internal/updates/pro_update.go), so in-app apply keeps the Pro runtime.
// The portal is the manual path for deployments the updater cannot drive
// (e.g. Docker), where the community pull/instructions would strip Pro.
const PRO_RELEASE_ACCESS_URL = 'https://pulserelay.pro/download.html';
export function UpdateBanner() {
@ -54,12 +55,12 @@ export function UpdateBanner() {
buildReleaseNotesUrl(updateStore.updateInfo()?.latestVersion),
);
// The compiled Pro binary must never self-update off the community build, so
// suppress in-app apply and point the customer at the portal instead. This
// keys off the binary's runtime identity (business hooks presence), NOT the
// license tier: a community binary with an active Pro license still
// self-updates normally. The backend ApplyUpdate gate is the hard guarantee;
// this is the UX layer.
// The compiled Pro binary self-updates from the license server download
// broker, so in-app apply is safe and keeps the Pro runtime; only the
// manual instructions differ (community pull/console steps would install
// the community build). This keys off the binary's runtime identity
// (business hooks presence), NOT the license tier: a community binary with
// an active Pro license follows the normal community paths.
const isProEdition = () => runtimeCapabilities()?.runtime?.build === 'pro';
const handleApplyUpdate = () => {
@ -161,8 +162,9 @@ export function UpdateBanner() {
</span>
</Show>
{/* Apply Update Button (automated community deployments) */}
<Show when={updatePlan()?.canAutoUpdate && !isExpanded() && !isProEdition()}>
{/* Apply Update Button (automated deployments; Pro applies
the private build from the license server broker) */}
<Show when={updatePlan()?.canAutoUpdate && !isExpanded()}>
<button
onClick={handleApplyUpdate}
class="px-3 py-1 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded transition-colors"
@ -182,8 +184,13 @@ export function UpdateBanner() {
</span>
</Show>
{/* Pro edition: in-app apply would downgrade to community, so route to the portal */}
<Show when={isProEdition() && !isExpanded()}>
{/* Pro edition without auto-update (e.g. Docker): the community
pull instructions would strip Pro, so route to the portal */}
<Show
when={
isProEdition() && updatePlan() && !updatePlan()?.canAutoUpdate && !isExpanded()
}
>
<a
href={PRO_RELEASE_ACCESS_URL}
target="_blank"
@ -264,14 +271,24 @@ export function UpdateBanner() {
{updateStore.updateInfo()?.latestVersion}
</p>
{/* Pro edition: portal update path (in-app apply would strip Pro features) */}
<Show when={isProEdition()}>
{/* Pro edition with in-app apply: updates install the private
Pulse Pro build from the license server */}
<Show when={isProEdition() && updatePlan()?.canAutoUpdate}>
<p class="text-xs">
Updates install the private Pulse Pro build from the license server, so
applying keeps Pro features (Audit, RBAC, Reporting, SSO).
</p>
</Show>
{/* Pro edition without auto-update: portal update path (the
community instructions below would strip Pro features) */}
<Show when={isProEdition() && updatePlan() && !updatePlan()?.canAutoUpdate}>
<div class="mt-2 p-3 rounded-md border bg-blue-100 dark:bg-blue-950 border-blue-300 dark:border-blue-700 text-blue-800 dark:text-blue-200">
<div class="font-medium mb-1">Pulse Pro update</div>
<p>
The in-app updater tracks the public community build and would remove Pro
features (Audit, RBAC, Reporting, SSO). Update from Private Release Access
instead:
This deployment updates manually, and the public community build would remove
Pro features (Audit, RBAC, Reporting, SSO). Update from Private Release
Access instead:
</p>
<ol class="list-decimal ml-5 mt-1 space-y-0.5">
<li>
@ -396,8 +413,8 @@ export function UpdateBanner() {
</div>
</Show>
{/* Apply Update Button (expanded view for automated community deployments) */}
<Show when={updatePlan()?.canAutoUpdate && !isProEdition()}>
{/* Apply Update Button (expanded view for automated deployments) */}
<Show when={updatePlan()?.canAutoUpdate}>
<div class="mt-3 pt-3 border-t border-blue-200 dark:border-blue-800">
<button
onClick={handleApplyUpdate}

View file

@ -20360,3 +20360,56 @@ func TestContract_StateBroadcastsUseLazyCurrentStateInvalidation(t *testing.T) {
}
}
}
// TestProUpdateBrokerWiringContract pins the Pro self-update boundary: the
// router hands the activation credentials (installation token, instance
// fingerprint, license server URL) to the update manager, and the updater's
// Pro path checks and applies exclusively through the license server download
// broker. Without this wiring the compiled Pro binary has no in-app update
// path, and with community wiring it would silently downgrade a paying
// customer to the community build (Guard 2 of the Pro download/update spec).
func TestProUpdateBrokerWiringContract(t *testing.T) {
routerSource, err := os.ReadFile("router.go")
if err != nil {
t.Fatalf("read internal/api/router.go: %v", err)
}
router := string(routerSource)
for _, fragment := range []string{
`r.updateManager.SetProUpdateCredentialSource(func() (updates.ProUpdateCredentials, bool) {`,
`state := svc.GetActivationState()`,
`InstallationToken: state.InstallationToken,`,
`InstanceFingerprint: state.InstanceFingerprint,`,
} {
if !strings.Contains(router, fragment) {
t.Errorf("router must wire the Pro update credential source from the activation state; missing %q", fragment)
}
}
proUpdateSource, err := os.ReadFile("../updates/pro_update.go")
if err != nil {
t.Fatalf("read internal/updates/pro_update.go: %v", err)
}
proUpdate := string(proUpdateSource)
for _, fragment := range []string{
`proDownloadBrokerPath = "/v1/downloads/pulse-pro"`,
`"X-Pulse-Instance-Fingerprint"`,
} {
if !strings.Contains(proUpdate, fragment) {
t.Errorf("Pro update path must target the license server download broker; missing %q", fragment)
}
}
managerSource, err := os.ReadFile("../updates/manager.go")
if err != nil {
t.Fatalf("read internal/updates/manager.go: %v", err)
}
manager := string(managerSource)
for _, fragment := range []string{
`if edition.IsPro() {`,
`m.resolveProUpdateArtifact(ctx, channel)`,
} {
if !strings.Contains(manager, fragment) {
t.Errorf("update manager must branch the Pro edition onto the broker path; missing %q", fragment)
}
}
}

View file

@ -66,6 +66,7 @@ const (
subscriptionStateCanceledValue = pkglicensing.SubStateCanceled
subscriptionStateTrialValue = pkglicensing.SubStateTrial
activationKeyPrefixValue = pkglicensing.ActivationKeyPrefix
defaultLicenseServerURLValue = pkglicensing.DefaultLicenseServerURL
)
func newLicenseService() *licenseService {

View file

@ -561,6 +561,31 @@ func (r *Router) setupRoutes() {
r.licenseHandlers = NewLicenseHandlers(r.multiTenant, r.hostedMode, r.config)
r.licenseHandlers.SetRuntimeVersion(r.serverVersion)
r.licenseHandlers.SetMonitors(r.monitor, r.mtMonitor)
// The compiled Pro binary self-updates from the license server download
// broker (never the public community releases), so give the updater lazy
// access to this installation's activation credentials. The source is
// consulted per check/apply, picking up activation done after startup; the
// community binary carries the source too but never consults it (the
// updater keys off pkg/edition).
r.updateManager.SetProUpdateCredentialSource(func() (updates.ProUpdateCredentials, bool) {
svc := r.licenseHandlers.Service(context.Background())
if svc == nil {
return updates.ProUpdateCredentials{}, false
}
state := svc.GetActivationState()
if state == nil || strings.TrimSpace(state.InstallationToken) == "" {
return updates.ProUpdateCredentials{}, false
}
serverURL := strings.TrimSpace(state.LicenseServerURL)
if serverURL == "" {
serverURL = defaultLicenseServerURLValue
}
return updates.ProUpdateCredentials{
LicenseServerURL: serverURL,
InstallationToken: state.InstallationToken,
InstanceFingerprint: state.InstanceFingerprint,
}, true
})
rbacProvider := NewTenantRBACProvider(r.config.DataPath)
r.rbacProvider = rbacProvider
orgHandlers := NewOrgHandlers(r.multiTenant, r.mtMonitor, rbacProvider)

View file

@ -258,7 +258,8 @@ func classifyApplyUpdateStartError(err error) (int, string) {
case strings.Contains(errMsg, "stable channel cannot install prerelease builds"):
return http.StatusConflict, err.Error()
case strings.Contains(errMsg, "cannot be applied in docker environment"),
strings.Contains(errMsg, "manual migration required"):
strings.Contains(errMsg, "manual migration required"),
strings.Contains(errMsg, "pulse pro updates need an activated license"):
return http.StatusConflict, err.Error()
default:
return http.StatusInternalServerError, "Failed to start update"

View file

@ -772,3 +772,20 @@ func TestHandleGetUpdatePlan_ManualFallback(t *testing.T) {
t.Fatalf("Expected manual fallback plan to include prerequisites")
}
}
// TestClassifyApplyUpdateStartError_ProActivation guards the update transport
// contract for the Pro broker path: an unactivated Pro binary's refusal is a
// client-resolvable conflict whose actionable message (activate, or use the
// portal archive path) must reach the UI verbatim, not collapse into the
// generic 500 "Failed to start update".
func TestClassifyApplyUpdateStartError_ProActivation(t *testing.T) {
err := errors.New("Pulse Pro updates need an activated license: activate in Settings → License, or download the archive from https://pulserelay.pro/download.html and run install.sh --archive")
status, msg := classifyApplyUpdateStartError(err)
if status != http.StatusConflict {
t.Fatalf("expected 409 for the unactivated Pro refusal, got %d", status)
}
if !strings.Contains(msg, "activated license") {
t.Fatalf("expected the actionable refusal message to pass through, got %q", msg)
}
}

View file

@ -189,6 +189,10 @@ type Manager struct {
closeOnce sync.Once
heartbeatWg sync.WaitGroup
closed bool
// proCredentialSource lazily supplies download-broker credentials for the
// compiled Pro binary (SetProUpdateCredentialSource, wired at startup).
// Nil on the community binary.
proCredentialSource func() (ProUpdateCredentials, bool)
}
// ApplyUpdateRequest describes an update request initiated via the API/UI.
@ -351,6 +355,31 @@ func (m *Manager) CheckForUpdatesWithChannel(ctx context.Context, channel string
return nil, fmt.Errorf("failed to parse current version: %w", err)
}
// The compiled Pro binary checks the license server download broker, never
// GitHub: the public release assets are community builds, and offering one
// here would set up the silent Pro→community downgrade.
if edition.IsPro() {
info, proErr := m.checkProUpdates(ctx, channel, currentInfo, currentVer)
if proErr != nil {
m.updateStatus("error", 0, "Failed to check for Pulse Pro updates", proErr)
return nil, proErr
}
if useCache {
m.statusMu.Lock()
m.checkCache[channel] = info
m.cacheTime[channel] = time.Now()
m.statusMu.Unlock()
}
status := "idle"
message := "No updates available"
if info.Available {
status = "available"
message = fmt.Sprintf("Update available: %s", info.LatestVersion)
}
m.updateStatus(status, 100, message)
return info, nil
}
// Get latest release from GitHub with specified channel and current version
release, err := m.getLatestReleaseForChannel(ctx, channel, currentVer)
if err != nil {
@ -461,22 +490,7 @@ func (m *Manager) CheckForUpdatesWithChannel(ctx context.Context, channel string
IsMajorUpgrade: isMajorUpgrade,
}
// Add warning for major version pre-release upgrades
if info.Available && isMajorUpgrade && isPrerelease {
info.Warning = fmt.Sprintf(
"This is a major version upgrade (v%d → v%d) and a pre-release build. "+
"We strongly recommend installing this as a separate instance rather than upgrading your production installation. "+
"Pre-release builds may contain bugs and are intended for testing.",
currentVer.Major, latestVer.Major,
)
} else if info.Available && isMajorUpgrade {
info.Warning = fmt.Sprintf(
"This is a major version upgrade (v%d → v%d). Please review the release notes carefully before updating.",
currentVer.Major, latestVer.Major,
)
} else if info.Available && isPrerelease {
info.Warning = "This is a pre-release build. Pre-release builds are tested but may have rough edges."
}
info.Warning = updateWarning(info.Available, isMajorUpgrade, isPrerelease, currentVer.Major, latestVer.Major)
// Cache the result (only if using saved channel)
if useCache {
@ -497,16 +511,58 @@ func (m *Manager) CheckForUpdatesWithChannel(ctx context.Context, channel string
return info, nil
}
// updateWarning derives the user-facing caution attached to an available
// update. Shared by the community (GitHub) and Pro (download broker) checks.
func updateWarning(available, isMajorUpgrade, isPrerelease bool, currentMajor, latestMajor int) string {
switch {
case available && isMajorUpgrade && isPrerelease:
return fmt.Sprintf(
"This is a major version upgrade (v%d → v%d) and a pre-release build. "+
"We strongly recommend installing this as a separate instance rather than upgrading your production installation. "+
"Pre-release builds may contain bugs and are intended for testing.",
currentMajor, latestMajor,
)
case available && isMajorUpgrade:
return fmt.Sprintf(
"This is a major version upgrade (v%d → v%d). Please review the release notes carefully before updating.",
currentMajor, latestMajor,
)
case available && isPrerelease:
return "This is a pre-release build. Pre-release builds are tested but may have rough edges."
default:
return ""
}
}
// ApplyUpdate downloads and applies an update
func (m *Manager) ApplyUpdate(ctx context.Context, req ApplyUpdateRequest) error {
if req.DownloadURL == "" {
return fmt.Errorf("download URL is required")
}
validatedDownloadURL, validationErr := validateApplyDownloadURL(req.DownloadURL)
if validationErr != nil {
return validationErr
// The separately compiled Pulse Pro binary must never install the public
// community build (it would silently strip Audit, RBAC, Reporting, and
// SSO), so it updates through the license server download broker instead.
// This keys off the compiled edition, not license state: a community
// binary with an active license is still community and updates normally.
isPro := edition.IsPro()
var validatedDownloadURL *url.URL
if isPro {
creds, ok := m.proUpdateCredentials()
if !ok {
return errProUpdateNotActivated()
}
if err := validateProApplyRequestURL(req.DownloadURL, creds.LicenseServerURL); err != nil {
return err
}
} else {
var validationErr error
validatedDownloadURL, validationErr = validateApplyDownloadURL(req.DownloadURL)
if validationErr != nil {
return validationErr
}
req.DownloadURL = validatedDownloadURL.String()
}
req.DownloadURL = validatedDownloadURL.String()
// Check if Docker
currentInfo, _ := GetCurrentVersion()
@ -514,16 +570,6 @@ func (m *Manager) ApplyUpdate(ctx context.Context, req ApplyUpdateRequest) error
return fmt.Errorf("updates cannot be applied in Docker environment")
}
// Refuse to self-update the separately compiled Pulse Pro binary. The
// in-app updater and install.sh both target the public community build, so
// applying an update here would replace the Pro binary with community and
// silently strip Audit, RBAC, Reporting, and SSO. This keys off the
// compiled edition, not license state: a community binary with an active
// license is still community and updates normally.
if edition.IsPro() {
return fmt.Errorf("self-hosted Pulse Pro updates come from the Private Release Access page (https://pulserelay.pro/download.html): download the new archive and its .sshsig sidecar, then run install.sh --archive. The in-app updater tracks the public community build and would remove Pro features")
}
// Check for pre-v4 installation
if isPreV4Installation() {
return fmt.Errorf("manual migration required: Pulse v4 is a complete rewrite. Please create a fresh installation. See %s", updateReleaseMigrationURL())
@ -546,9 +592,23 @@ func (m *Manager) ApplyUpdate(ctx context.Context, req ApplyUpdateRequest) error
m.updateStatus("downloading", 10, "Downloading update...")
channel := m.resolveChannel(req.Channel, currentInfo)
targetVersion, validationErr := ValidateApplyTargetVersion(channel, req.DownloadURL)
if validationErr != nil {
return validationErr
var artifact resolvedUpdateArtifact
if isPro {
// Resolve fresh signed URLs from the broker at apply time: the URLs it
// hands out expire in minutes, so the check-time response is never
// reused here.
var resolveErr error
artifact, resolveErr = m.resolveProUpdateArtifact(ctx, channel)
if resolveErr != nil {
m.updateStatus("error", 10, "Failed to resolve Pulse Pro update", resolveErr)
return resolveErr
}
} else {
targetVersion, validationErr := ValidateApplyTargetVersion(channel, req.DownloadURL)
if validationErr != nil {
return validationErr
}
artifact = resolvedUpdateArtifact{downloadURL: req.DownloadURL, version: targetVersion}
}
initiatedBy := req.InitiatedBy
if initiatedBy == "" {
@ -564,7 +624,7 @@ func (m *Manager) ApplyUpdate(ctx context.Context, req ApplyUpdateRequest) error
Action: "update",
Channel: channel,
VersionFrom: currentInfo.Version,
VersionTo: targetVersion,
VersionTo: artifact.version,
DeploymentType: currentInfo.DeploymentType,
InitiatedBy: initiatedBy,
InitiatedVia: initiatedVia,
@ -595,7 +655,7 @@ func (m *Manager) ApplyUpdate(ctx context.Context, req ApplyUpdateRequest) error
// Download update
tarballPath := filepath.Join(tempDir, "update.tar.gz")
downloadBytes, err := m.downloadFile(ctx, req.DownloadURL, tarballPath)
downloadBytes, err := m.downloadFile(ctx, artifact.downloadURL, tarballPath)
if err != nil {
downloadErr := fmt.Errorf("failed to download update: %w", err)
m.updateStatus("error", 20, "Failed to download update", downloadErr)
@ -610,9 +670,16 @@ func (m *Manager) ApplyUpdate(ctx context.Context, req ApplyUpdateRequest) error
// Verify SSHSIG signature against the pinned pulse-installer key. This is
// the same trust root scripts/pulse-auto-update.sh and scripts/install.sh
// already enforce; the in-app updater must not run at a lower bar.
// already enforce; the in-app updater must not run at a lower bar. The Pro
// broker hands out an explicit signed sidecar URL; the community path
// derives it from the asset URL.
m.updateStatus("verifying", 25, "Verifying signature...")
if err := m.downloadAndVerifyReleaseSignature(ctx, validatedDownloadURL, tarballPath); err != nil {
if artifact.sshsigURL != "" {
err = m.downloadAndVerifySignatureFromURL(ctx, artifact.sshsigURL, tarballPath)
} else {
err = m.downloadAndVerifyReleaseSignature(ctx, validatedDownloadURL, tarballPath)
}
if err != nil {
sigErr := fmt.Errorf("signature verification failed: %w", err)
m.updateStatus("error", 25, "Failed to verify update signature", sigErr)
runErr = sigErr
@ -620,9 +687,16 @@ func (m *Manager) ApplyUpdate(ctx context.Context, req ApplyUpdateRequest) error
}
log.Info().Msg("Signature verification passed")
// Verify checksum if available
// Verify checksum: the Pro broker manifest carries the expected sha256
// inline; the community path discovers a SHA256SUMS manifest next to the
// release asset.
m.updateStatus("verifying", 30, "Verifying download...")
if err := m.verifyChecksum(ctx, req.DownloadURL, tarballPath); err != nil {
if artifact.sha256 != "" {
err = verifyFileSHA256(tarballPath, artifact.sha256)
} else {
err = m.verifyChecksum(ctx, artifact.downloadURL, tarballPath)
}
if err != nil {
checksumErr := fmt.Errorf("checksum verification failed: %w", err)
m.updateStatus("error", 30, "Failed to verify update checksum", checksumErr)
runErr = checksumErr

View file

@ -11,11 +11,12 @@ import (
"github.com/rcourtman/pulse-go-rewrite/pkg/edition"
)
// TestApplyUpdateRefusesProEdition verifies Guard 2 of the Pro download/update
// spec: the separately compiled Pro binary must not self-update off the public
// community build. ApplyUpdate returns the portal-pointing error at the edition
// gate for the Pro edition and proceeds past it for community.
func TestApplyUpdateRefusesProEdition(t *testing.T) {
// TestApplyUpdateProEditionGate verifies Guard 2 of the Pro download/update
// spec: the separately compiled Pro binary must never install the public
// community build. Without an activation the Pro path refuses with the manual
// portal fallback; with an activation it only accepts the broker-intent URL
// for its own license server; the community edition is untouched by the gate.
func TestApplyUpdateProEditionGate(t *testing.T) {
// Allow an arbitrary download host (validateApplyDownloadURL) and force
// Docker detection off, so the flow deterministically reaches the edition
// gate whether the test runs on a dev host or inside a CI container.
@ -36,30 +37,55 @@ func TestApplyUpdateRefusesProEdition(t *testing.T) {
downloadURL := server.URL + "/pulse-v6.0.0-linux-amd64.tar.gz"
t.Run("pro edition blocked with portal message", func(t *testing.T) {
t.Run("pro edition without activation refuses with portal fallback", func(t *testing.T) {
edition.SetEdition(edition.Pro)
t.Cleanup(func() { edition.SetEdition(edition.Community) })
err := manager.ApplyUpdate(context.Background(), ApplyUpdateRequest{DownloadURL: downloadURL})
if err == nil {
t.Fatal("expected ApplyUpdate to refuse the Pro edition, got nil")
t.Fatal("expected ApplyUpdate to refuse the unactivated Pro edition, got nil")
}
msg := err.Error()
if !strings.Contains(msg, "Private Release Access") {
t.Fatalf("Pro edition error must point at the Private Release Access portal, got: %v", err)
if !strings.Contains(msg, "activated license") {
t.Fatalf("Pro edition error must explain the activation requirement, got: %v", err)
}
if !strings.Contains(msg, "pulserelay.pro/download.html") {
t.Fatalf("Pro edition error must point at the download portal fallback, got: %v", err)
}
if !strings.Contains(msg, "install.sh --archive") {
t.Fatalf("Pro edition error must mention the archive install path, got: %v", err)
}
})
t.Run("pro edition rejects community download URLs", func(t *testing.T) {
edition.SetEdition(edition.Pro)
t.Cleanup(func() { edition.SetEdition(edition.Community) })
manager.SetProUpdateCredentialSource(func() (ProUpdateCredentials, bool) {
return ProUpdateCredentials{
LicenseServerURL: "https://license.example.invalid",
InstallationToken: "pit_live_test",
InstanceFingerprint: "fp-test",
}, true
})
t.Cleanup(func() { manager.SetProUpdateCredentialSource(nil) })
err := manager.ApplyUpdate(context.Background(), ApplyUpdateRequest{DownloadURL: downloadURL})
if err == nil {
t.Fatal("expected ApplyUpdate to reject a community download URL on the Pro edition, got nil")
}
if !strings.Contains(err.Error(), "invalid download URL") {
t.Fatalf("Pro edition must reject non-broker download URLs, got: %v", err)
}
})
t.Run("community edition proceeds past the edition gate", func(t *testing.T) {
edition.SetEdition(edition.Community)
err := manager.ApplyUpdate(context.Background(), ApplyUpdateRequest{DownloadURL: downloadURL})
// The community path still fails (the local server 404s), but it must
// NOT be blocked by the Pro edition gate.
if err != nil && strings.Contains(err.Error(), "Private Release Access") {
if err != nil && strings.Contains(err.Error(), "activated license") {
t.Fatalf("community edition must not hit the Pro edition gate, got: %v", err)
}
})

View file

@ -1,7 +1,9 @@
package installtests
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
@ -251,3 +253,61 @@ echo "RESULT=[$result]"
t.Fatalf("get_latest_stable_version did not return expected stable tag:\n%s", got)
}
}
// TestInstalledBinaryIsPulseProGuard proves the unattended updater's Pro
// guard: a binary whose --version reports "Pulse Pro" is detected (so main()
// exits before any community download can replace it), while the community
// "Pulse vX" binary is not flagged. The community auto-update flow must never
// reinstall the public build over a paid Pro runtime.
func TestInstalledBinaryIsPulseProGuard(t *testing.T) {
fn := extractAutoUpdateFunction(t, "installed_binary_is_pulse_pro")
cases := []struct {
name string
versionLine string
wantPro bool
}{
{"pro binary detected", "Pulse Pro v6.0.5", true},
{"community binary not flagged", "Pulse v6.0.5", false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
installDir := t.TempDir()
binDir := filepath.Join(installDir, "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("mkdir bin: %v", err)
}
stub := "#!/bin/bash\necho \"" + tc.versionLine + "\"\n"
if err := os.WriteFile(filepath.Join(binDir, "pulse"), []byte(stub), 0o755); err != nil {
t.Fatalf("write pulse stub: %v", err)
}
script := fn + "\nINSTALL_DIR=" + installDir + "\nif installed_binary_is_pulse_pro; then echo pro; else echo community; fi\n"
out, err := exec.Command("bash", "-c", script).CombinedOutput()
if err != nil {
t.Fatalf("bash: %v\n%s", err, out)
}
got := strings.TrimSpace(string(out))
want := "community"
if tc.wantPro {
want = "pro"
}
if got != want {
t.Fatalf("installed_binary_is_pulse_pro on %q = %q, want %q", tc.versionLine, got, want)
}
})
}
t.Run("missing binary is not flagged", func(t *testing.T) {
script := fn + "\nINSTALL_DIR=" + t.TempDir() + "\nif installed_binary_is_pulse_pro; then echo pro; else echo community; fi\n"
out, err := exec.Command("bash", "-c", script).CombinedOutput()
if err != nil {
t.Fatalf("bash: %v\n%s", err, out)
}
if got := strings.TrimSpace(string(out)); got != "community" {
t.Fatalf("missing binary should not be flagged as Pro, got %q", got)
}
})
}

View file

@ -110,6 +110,23 @@ get_current_version() {
echo "${version:-unknown}"
}
# The unattended updater installs public community builds. The separately
# compiled Pulse Pro binary (--version reports "Pulse Pro vX.Y.Z") must never
# be replaced by one: that silently strips Audit, RBAC, Reporting, and SSO.
# Pro installs update in-app through the license server download broker, or
# manually via https://pulserelay.pro/download.html + install.sh --archive.
installed_binary_is_pulse_pro() {
local binary=""
if [[ -f "$INSTALL_DIR/bin/pulse" ]]; then
binary="$INSTALL_DIR/bin/pulse"
elif [[ -f "$INSTALL_DIR/pulse" ]]; then
binary="$INSTALL_DIR/pulse"
else
return 1
fi
"$binary" --version 2>/dev/null | head -1 | grep -q '^Pulse Pro '
}
# Determine whether a tag is a semver pre-release.
#
# Per semver 2.0.0, any identifier after the patch version introduced by a
@ -459,7 +476,13 @@ main() {
log info "Docker environment detected, skipping auto-update"
exit 0
fi
# Never replace the Pulse Pro binary with a public community build
if installed_binary_is_pulse_pro; then
log info "Pulse Pro binary detected; unattended community updates are disabled. Pro installs update in-app (license server download broker) or via https://pulserelay.pro/download.html"
exit 0
fi
# Get current version
local current_version=$(get_current_version)
log info "Current version: $current_version"