mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-17 11:52:40 +00:00
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
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 inside313552debvia a parallel session committing a shared staged index; this commit completes the wiring they belong to.
This commit is contained in:
parent
313552debc
commit
93bccaff18
18 changed files with 467 additions and 95 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ const (
|
|||
subscriptionStateCanceledValue = pkglicensing.SubStateCanceled
|
||||
subscriptionStateTrialValue = pkglicensing.SubStateTrial
|
||||
activationKeyPrefixValue = pkglicensing.ActivationKeyPrefix
|
||||
defaultLicenseServerURLValue = pkglicensing.DefaultLicenseServerURL
|
||||
)
|
||||
|
||||
func newLicenseService() *licenseService {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue