mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Block in-app self-update on the Pro binary to prevent silent downgrade
The in-app updater and install.sh both fetch the public community build from github.com/rcourtman/Pulse. On non-Docker Pro installs (systemd, proxmoxve, source) the "Apply Update" button was live, so applying an update replaced the separately compiled Pro binary with community and silently stripped Audit, RBAC, Reporting, and SSO from a paying customer. Docker was already blocked; nothing else was. Add a dependency-free pkg/edition marker (defaults to community) that the Pro binary flips via enterpriseruntime.Initialize, mirroring the existing coreaudit.SetLogger / server.SetBusinessHooks registration seam. ApplyUpdate now refuses when the edition is Pro, pointing at the Private Release Access portal (https://pulserelay.pro/download.html) and the install.sh --archive path. The gate keys off the compiled binary, not license state: a community binary with an active license still self-updates as before. The update banner hides the in-app apply affordance and shows portal instructions for the Pro runtime, keyed off the existing runtime-identity signal (runtime.build) rather than a new payload field, so nothing extra is plumbed through the version API and the frontend reuses the canonical "which binary am I" contract. The backend gate is the hard guarantee; the banner is the UX layer. Guard 2 of the Pro download/update experience spec.
This commit is contained in:
parent
05883ba4b2
commit
983a89326f
6 changed files with 274 additions and 10 deletions
|
|
@ -593,6 +593,20 @@ 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
|
||||
`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.
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
import { Show, createSignal, createEffect, createMemo, For, onCleanup } from 'solid-js';
|
||||
import { updateStore } from '@/stores/updates';
|
||||
import { runtimeCapabilities } from '@/stores/license';
|
||||
import { UpdatesAPI, type UpdatePlan } from '@/api/updates';
|
||||
import { UpdateConfirmationModal } from './UpdateConfirmationModal';
|
||||
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.
|
||||
const PRO_RELEASE_ACCESS_URL = 'https://pulserelay.pro/download.html';
|
||||
|
||||
export function UpdateBanner() {
|
||||
const [isExpanded, setIsExpanded] = createSignal(false);
|
||||
const [updatePlan, setUpdatePlan] = createSignal<UpdatePlan | null>(null);
|
||||
|
|
@ -48,6 +54,14 @@ 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.
|
||||
const isProEdition = () => runtimeCapabilities()?.runtime?.build === 'pro';
|
||||
|
||||
const handleApplyUpdate = () => {
|
||||
setShowConfirmModal(true);
|
||||
};
|
||||
|
|
@ -147,8 +161,8 @@ export function UpdateBanner() {
|
|||
</span>
|
||||
</Show>
|
||||
|
||||
{/* Apply Update Button (automated deployments) */}
|
||||
<Show when={updatePlan()?.canAutoUpdate && !isExpanded()}>
|
||||
{/* Apply Update Button (automated community deployments) */}
|
||||
<Show when={updatePlan()?.canAutoUpdate && !isExpanded() && !isProEdition()}>
|
||||
<button
|
||||
onClick={handleApplyUpdate}
|
||||
class="px-3 py-1 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded transition-colors"
|
||||
|
|
@ -157,14 +171,30 @@ export function UpdateBanner() {
|
|||
</button>
|
||||
</Show>
|
||||
|
||||
{/* Manual Steps Badge (non-automated deployments) */}
|
||||
<Show when={updatePlan() && !updatePlan()?.canAutoUpdate && !isExpanded()}>
|
||||
{/* Manual Steps Badge (non-automated community deployments) */}
|
||||
<Show
|
||||
when={
|
||||
updatePlan() && !updatePlan()?.canAutoUpdate && !isExpanded() && !isProEdition()
|
||||
}
|
||||
>
|
||||
<span class="px-2 py-0.5 text-xs font-medium bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200 rounded">
|
||||
Manual steps required
|
||||
</span>
|
||||
</Show>
|
||||
|
||||
{!isExpanded() && getUpdateInstructions() && (
|
||||
{/* Pro edition: in-app apply would downgrade to community, so route to the portal */}
|
||||
<Show when={isProEdition() && !isExpanded()}>
|
||||
<a
|
||||
href={PRO_RELEASE_ACCESS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="px-3 py-1 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded transition-colors"
|
||||
>
|
||||
Update via Private Release Access →
|
||||
</a>
|
||||
</Show>
|
||||
|
||||
{!isExpanded() && !isProEdition() && getUpdateInstructions() && (
|
||||
<>
|
||||
<span class="text-blue-600 dark:text-blue-400 text-sm hidden sm:inline">•</span>
|
||||
<span class="text-blue-600 dark:text-blue-400 text-sm hidden sm:inline">
|
||||
|
|
@ -233,7 +263,41 @@ export function UpdateBanner() {
|
|||
<span class="font-medium ml-1">Latest:</span>{' '}
|
||||
{updateStore.updateInfo()?.latestVersion}
|
||||
</p>
|
||||
{getUpdateInstructions() && (
|
||||
|
||||
{/* Pro edition: portal update path (in-app apply would strip Pro features) */}
|
||||
<Show when={isProEdition()}>
|
||||
<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:
|
||||
</p>
|
||||
<ol class="list-decimal ml-5 mt-1 space-y-0.5">
|
||||
<li>
|
||||
Download the new Pro archive and its{' '}
|
||||
<code class="font-mono text-xs">.sshsig</code> sidecar from the portal.
|
||||
</li>
|
||||
<li>
|
||||
Run{' '}
|
||||
<code class="font-mono text-xs">
|
||||
sudo bash install.sh --archive ./pulse-pro-…tar.gz
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
</ol>
|
||||
<a
|
||||
href={PRO_RELEASE_ACCESS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-block mt-2 underline hover:text-blue-900 dark:hover:text-blue-100"
|
||||
>
|
||||
Open Private Release Access →
|
||||
</a>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{!isProEdition() && getUpdateInstructions() && (
|
||||
<p>
|
||||
<span class="font-medium">Quick upgrade:</span> {getUpdateInstructions()}
|
||||
</p>
|
||||
|
|
@ -268,9 +332,13 @@ export function UpdateBanner() {
|
|||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Manual Update Instructions */}
|
||||
{/* Manual Update Instructions (community deployments; Pro uses the portal block above) */}
|
||||
<Show
|
||||
when={updatePlan()?.instructions && (updatePlan()?.instructions?.length ?? 0) > 0}
|
||||
when={
|
||||
!isProEdition() &&
|
||||
updatePlan()?.instructions &&
|
||||
(updatePlan()?.instructions?.length ?? 0) > 0
|
||||
}
|
||||
>
|
||||
<div class="mt-3 pt-3 border-t border-blue-200 dark:border-blue-800">
|
||||
<div class="font-medium mb-2">Update Instructions:</div>
|
||||
|
|
@ -328,8 +396,8 @@ export function UpdateBanner() {
|
|||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Apply Update Button (expanded view for automated deployments) */}
|
||||
<Show when={updatePlan()?.canAutoUpdate}>
|
||||
{/* Apply Update Button (expanded view for automated community deployments) */}
|
||||
<Show when={updatePlan()?.canAutoUpdate && !isProEdition()}>
|
||||
<div class="mt-3 pt-3 border-t border-blue-200 dark:border-blue-800">
|
||||
<button
|
||||
onClick={handleApplyUpdate}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import (
|
|||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/edition"
|
||||
"github.com/rs/zerolog/log"
|
||||
godisk "github.com/shirou/gopsutil/v4/disk"
|
||||
)
|
||||
|
|
@ -513,6 +514,16 @@ 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())
|
||||
|
|
|
|||
66
internal/updates/manager_edition_test.go
Normal file
66
internal/updates/manager_edition_test.go
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
package updates
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"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) {
|
||||
// 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.
|
||||
t.Setenv("PULSE_UPDATE_SERVER", "http://example.invalid")
|
||||
t.Setenv("PULSE_ALLOW_DOCKER_UPDATES", "true")
|
||||
t.Setenv("PULSE_DATA_DIR", t.TempDir())
|
||||
|
||||
// A local server that always 404s keeps the community path hermetic: it
|
||||
// gets past the edition gate and then fails fast on download without
|
||||
// touching the real network.
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.NotFound(w, nil)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
manager := NewManager(cfg)
|
||||
|
||||
downloadURL := server.URL + "/pulse-v6.0.0-linux-amd64.tar.gz"
|
||||
|
||||
t.Run("pro edition blocked with portal message", 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")
|
||||
}
|
||||
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, "install.sh --archive") {
|
||||
t.Fatalf("Pro edition error must mention the archive install path, 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") {
|
||||
t.Fatalf("community edition must not hit the Pro edition gate, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
63
pkg/edition/edition.go
Normal file
63
pkg/edition/edition.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
// Package edition records which compiled Pulse binary is running: the public
|
||||
// community build or the separately compiled Pulse Pro build (Audit, RBAC,
|
||||
// Reporting, and SSO compiled in).
|
||||
//
|
||||
// It is a small public registration seam that mirrors the existing
|
||||
// coreaudit.SetLogger / server.SetBusinessHooks pattern: the community binary
|
||||
// leaves the default (Community) in place, and only the Pro binary flips it to
|
||||
// Pro during enterpriseruntime.Initialize. The marker keys off the compiled
|
||||
// binary, never off license-active state — a community binary with an active
|
||||
// license is still community and must keep its normal self-update behaviour.
|
||||
//
|
||||
// This lives in pkg/ rather than internal/ because pulse-enterprise is a
|
||||
// separate Go module and cannot import internal/ packages. Keeping it
|
||||
// dependency-free also lets internal/updates consult it without any import
|
||||
// cycle against pkg/server (which transitively imports internal/updates).
|
||||
package edition
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
const (
|
||||
// Community is the public, open-source Pulse build.
|
||||
Community = "community"
|
||||
// Pro is the separately compiled Pulse Pro build.
|
||||
Pro = "pro"
|
||||
)
|
||||
|
||||
// current holds the running binary's edition. It always stores a string, so
|
||||
// atomic.Value is safe here and lets read paths (HTTP handlers) run lock-free.
|
||||
var current atomic.Value
|
||||
|
||||
func init() {
|
||||
current.Store(Community)
|
||||
}
|
||||
|
||||
// SetEdition records the running binary's edition. Any value other than Pro
|
||||
// normalizes to Community so a mis-wired caller can never silently claim Pro.
|
||||
// Call once during startup registration.
|
||||
func SetEdition(name string) {
|
||||
current.Store(normalize(name))
|
||||
}
|
||||
|
||||
// Current returns the running binary's edition (Community or Pro).
|
||||
func Current() string {
|
||||
if v, ok := current.Load().(string); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return Community
|
||||
}
|
||||
|
||||
// IsPro reports whether the running binary is the compiled Pulse Pro edition.
|
||||
func IsPro() bool {
|
||||
return Current() == Pro
|
||||
}
|
||||
|
||||
func normalize(name string) string {
|
||||
if strings.EqualFold(strings.TrimSpace(name), Pro) {
|
||||
return Pro
|
||||
}
|
||||
return Community
|
||||
}
|
||||
42
pkg/edition/edition_test.go
Normal file
42
pkg/edition/edition_test.go
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
package edition
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDefaultsToCommunity(t *testing.T) {
|
||||
// A fresh process (no SetEdition call) must read as community so the
|
||||
// public community binary is never treated as Pro.
|
||||
if got := Current(); got != Community {
|
||||
t.Fatalf("Current() default = %q, want %q", got, Community)
|
||||
}
|
||||
if IsPro() {
|
||||
t.Fatal("IsPro() default = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetEditionPro(t *testing.T) {
|
||||
t.Cleanup(func() { SetEdition(Community) })
|
||||
|
||||
SetEdition(Pro)
|
||||
if got := Current(); got != Pro {
|
||||
t.Fatalf("Current() after SetEdition(Pro) = %q, want %q", got, Pro)
|
||||
}
|
||||
if !IsPro() {
|
||||
t.Fatal("IsPro() after SetEdition(Pro) = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetEditionNormalizesUnknownToCommunity(t *testing.T) {
|
||||
t.Cleanup(func() { SetEdition(Community) })
|
||||
|
||||
for _, name := range []string{"", "enterprise", "PRO ", "Community", "bogus"} {
|
||||
SetEdition(name)
|
||||
got := Current()
|
||||
want := Community
|
||||
if name == "PRO " {
|
||||
want = Pro // trimmed + case-insensitive match
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("Current() after SetEdition(%q) = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue