mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Pin the OAuth Tailscale path in the update-demo workflow test
TestUpdateDemoWorkflowUsesGovernedNetworkPath still asserted the retired
authkey: TS_AUTHKEY line; 0a9a29d63 moved update-demo-server.yml to the
OAuth client (TS_OAUTH_CLIENT_ID / TS_OAUTH_SECRET, tag:infra), so the
test now pins those lines instead.
This commit is contained in:
parent
8222f132a8
commit
313552debc
3 changed files with 708 additions and 1 deletions
310
internal/updates/manager_pro_update_test.go
Normal file
310
internal/updates/manager_pro_update_test.go
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
package updates
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/edition"
|
||||
)
|
||||
|
||||
// proBrokerFixture stands in for the license server download broker plus the
|
||||
// signed R2 URLs it hands out: /v1/downloads/pulse-pro returns the manifest,
|
||||
// and the artifact/.sshsig endpoints simulate the presigned object URLs.
|
||||
type proBrokerFixture struct {
|
||||
t *testing.T
|
||||
server *httptest.Server
|
||||
version string
|
||||
prerelease bool
|
||||
tarball []byte
|
||||
sha256Hex string // manifest hash; may deliberately mismatch the tarball
|
||||
omitSSHSig bool // drop the sshsig_url from the manifest
|
||||
|
||||
brokerCalls int
|
||||
tarballCalls int
|
||||
sshsigCalls int
|
||||
}
|
||||
|
||||
func newProBrokerFixture(t *testing.T, version string, prerelease bool) *proBrokerFixture {
|
||||
t.Helper()
|
||||
|
||||
f := &proBrokerFixture{t: t, version: version, prerelease: prerelease}
|
||||
f.tarball = buildDummyTarball(t)
|
||||
digest := sha256.Sum256(f.tarball)
|
||||
f.sha256Hex = hex.EncodeToString(digest[:])
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v1/downloads/pulse-pro", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.brokerCalls++
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer pit_live_test" {
|
||||
t.Errorf("broker got Authorization %q, want installation token bearer", got)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if got := r.Header.Get("X-Pulse-Instance-Fingerprint"); got != "fp-test" {
|
||||
t.Errorf("broker got fingerprint %q, want fp-test", got)
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if got := r.URL.Query().Get("target"); got != proUpdateTarget() {
|
||||
t.Errorf("broker got target %q, want %q", got, proUpdateTarget())
|
||||
}
|
||||
|
||||
artifact := map[string]any{
|
||||
"target": proUpdateTarget(),
|
||||
"filename": fmt.Sprintf("pulse-pro-v%s-%s.tar.gz", f.version, proUpdateTarget()),
|
||||
"sha256": f.sha256Hex,
|
||||
"download_url": f.server.URL + "/r2/pulse-pro.tar.gz?X-Amz-Signature=signed",
|
||||
}
|
||||
if !f.omitSSHSig {
|
||||
artifact["sshsig_url"] = f.server.URL + "/r2/pulse-pro.tar.gz.sshsig?X-Amz-Signature=signed"
|
||||
}
|
||||
resp := map[string]any{
|
||||
"release": map[string]any{
|
||||
"version": f.version,
|
||||
"prerelease": f.prerelease,
|
||||
},
|
||||
"artifacts": []any{artifact},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
t.Errorf("encode broker response: %v", err)
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("/r2/pulse-pro.tar.gz", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.tarballCalls++
|
||||
if r.URL.Query().Get("X-Amz-Signature") == "" {
|
||||
t.Error("artifact download must use the presigned URL from the broker manifest")
|
||||
}
|
||||
if _, err := w.Write(f.tarball); err != nil {
|
||||
t.Errorf("write tarball: %v", err)
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("/r2/pulse-pro.tar.gz.sshsig", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.sshsigCalls++
|
||||
if r.URL.Query().Get("X-Amz-Signature") == "" {
|
||||
t.Error("signature download must use the presigned URL from the broker manifest")
|
||||
}
|
||||
if _, err := w.Write([]byte("dummy-sshsig")); err != nil {
|
||||
t.Errorf("write sshsig: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
f.server = httptest.NewServer(mux)
|
||||
t.Cleanup(f.server.Close)
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *proBrokerFixture) credentialSource() func() (ProUpdateCredentials, bool) {
|
||||
return func() (ProUpdateCredentials, bool) {
|
||||
return ProUpdateCredentials{
|
||||
LicenseServerURL: f.server.URL,
|
||||
InstallationToken: "pit_live_test",
|
||||
InstanceFingerprint: "fp-test",
|
||||
}, true
|
||||
}
|
||||
}
|
||||
|
||||
func setupProUpdateTest(t *testing.T, currentVersion string) {
|
||||
t.Helper()
|
||||
t.Setenv("PULSE_ALLOW_DOCKER_UPDATES", "true")
|
||||
t.Setenv("PULSE_DATA_DIR", t.TempDir())
|
||||
t.Setenv("PULSE_INSTALL_DIR", t.TempDir())
|
||||
|
||||
oldBuildVersion := BuildVersion
|
||||
BuildVersion = currentVersion
|
||||
t.Cleanup(func() { BuildVersion = oldBuildVersion })
|
||||
|
||||
edition.SetEdition(edition.Pro)
|
||||
t.Cleanup(func() { edition.SetEdition(edition.Community) })
|
||||
}
|
||||
|
||||
// TestCheckForUpdatesProUsesBroker proves the Pro binary's update check reads
|
||||
// the license server download broker, never GitHub: the offered version is the
|
||||
// broker's pinned private release and the download URL is the broker intent
|
||||
// URL, not a public release asset.
|
||||
func TestCheckForUpdatesProUsesBroker(t *testing.T) {
|
||||
setupProUpdateTest(t, "6.0.0")
|
||||
|
||||
t.Run("offers the broker-pinned release", func(t *testing.T) {
|
||||
fixture := newProBrokerFixture(t, "6.0.5", false)
|
||||
manager := NewManager(&config.Config{DataPath: t.TempDir()})
|
||||
manager.SetProUpdateCredentialSource(fixture.credentialSource())
|
||||
|
||||
info, err := manager.CheckForUpdatesWithChannel(context.Background(), "stable")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckForUpdatesWithChannel: %v", err)
|
||||
}
|
||||
if fixture.brokerCalls == 0 {
|
||||
t.Fatal("expected the update check to query the download broker")
|
||||
}
|
||||
if !info.Available {
|
||||
t.Fatalf("expected update 6.0.0 → 6.0.5 to be available, got %+v", info)
|
||||
}
|
||||
if info.LatestVersion != "6.0.5" {
|
||||
t.Fatalf("LatestVersion = %q, want 6.0.5", info.LatestVersion)
|
||||
}
|
||||
if !strings.Contains(info.DownloadURL, proDownloadBrokerPath) {
|
||||
t.Fatalf("DownloadURL %q must be the broker intent URL", info.DownloadURL)
|
||||
}
|
||||
if !strings.Contains(info.DownloadURL, "version=v6.0.5") {
|
||||
t.Fatalf("DownloadURL %q must carry the target version for the apply channel guard", info.DownloadURL)
|
||||
}
|
||||
// The API handler runs the shared channel guard on this URL before
|
||||
// readiness checks; it must be able to infer the version from it.
|
||||
target, err := ValidateApplyTargetVersion("stable", info.DownloadURL)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateApplyTargetVersion on the broker intent URL: %v", err)
|
||||
}
|
||||
if target != "v6.0.5" {
|
||||
t.Fatalf("inferred target version = %q, want v6.0.5", target)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stable channel skips a prerelease broker pin", func(t *testing.T) {
|
||||
fixture := newProBrokerFixture(t, "6.1.0-rc.1", true)
|
||||
manager := NewManager(&config.Config{DataPath: t.TempDir()})
|
||||
manager.SetProUpdateCredentialSource(fixture.credentialSource())
|
||||
|
||||
info, err := manager.CheckForUpdatesWithChannel(context.Background(), "stable")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckForUpdatesWithChannel: %v", err)
|
||||
}
|
||||
if info.Available {
|
||||
t.Fatalf("stable channel must not offer prerelease %q, got %+v", "6.1.0-rc.1", info)
|
||||
}
|
||||
if !strings.Contains(info.Warning, "prerelease") {
|
||||
t.Fatalf("expected a prerelease-pin warning, got %q", info.Warning)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rc channel offers a prerelease broker pin", func(t *testing.T) {
|
||||
fixture := newProBrokerFixture(t, "6.1.0-rc.1", true)
|
||||
manager := NewManager(&config.Config{DataPath: t.TempDir()})
|
||||
manager.SetProUpdateCredentialSource(fixture.credentialSource())
|
||||
|
||||
info, err := manager.CheckForUpdatesWithChannel(context.Background(), "rc")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckForUpdatesWithChannel: %v", err)
|
||||
}
|
||||
if !info.Available || !info.IsPrerelease {
|
||||
t.Fatalf("rc channel should offer prerelease 6.1.0-rc.1, got %+v", info)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("without activation reports unavailable with guidance", func(t *testing.T) {
|
||||
manager := NewManager(&config.Config{DataPath: t.TempDir()})
|
||||
|
||||
info, err := manager.CheckForUpdatesWithChannel(context.Background(), "stable")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckForUpdatesWithChannel: %v", err)
|
||||
}
|
||||
if info.Available {
|
||||
t.Fatalf("unactivated Pro must not offer updates, got %+v", info)
|
||||
}
|
||||
if !strings.Contains(info.Warning, "activated license") {
|
||||
t.Fatalf("expected activation guidance in warning, got %q", info.Warning)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestApplyUpdateProDownloadsFromBroker proves the Pro apply path resolves
|
||||
// fresh signed URLs from the broker, downloads the private archive, verifies
|
||||
// its .sshsig against the pinned key path and its sha256 against the manifest,
|
||||
// and never fetches a public community asset. The dummy tarball deliberately
|
||||
// contains no pulse binary, so a "pulse binary not found" failure at the apply
|
||||
// stage is the proof that every Pro-specific stage before it succeeded.
|
||||
func TestApplyUpdateProDownloadsFromBroker(t *testing.T) {
|
||||
setupProUpdateTest(t, "6.0.0")
|
||||
|
||||
origVerify := verifyReleaseSignatureFunc
|
||||
verifyReleaseSignatureFunc = func(ctx context.Context, targetPath, signaturePath string) error {
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { verifyReleaseSignatureFunc = origVerify })
|
||||
|
||||
t.Run("full flow through verification and extraction", func(t *testing.T) {
|
||||
fixture := newProBrokerFixture(t, "6.0.5", false)
|
||||
manager := NewManager(&config.Config{DataPath: t.TempDir()})
|
||||
manager.SetProUpdateCredentialSource(fixture.credentialSource())
|
||||
|
||||
applyURL, err := proUpdateApplyURL(fixture.server.URL, "6.0.5")
|
||||
if err != nil {
|
||||
t.Fatalf("proUpdateApplyURL: %v", err)
|
||||
}
|
||||
|
||||
err = manager.ApplyUpdate(context.Background(), ApplyUpdateRequest{DownloadURL: applyURL, Channel: "stable"})
|
||||
if err == nil {
|
||||
t.Fatal("expected apply to fail at the binary-install stage (dummy tarball has no pulse binary)")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "pulse binary not found") {
|
||||
t.Fatalf("expected failure at the apply stage after all Pro verification stages passed, got: %v", err)
|
||||
}
|
||||
if fixture.brokerCalls == 0 {
|
||||
t.Fatal("apply must re-resolve signed URLs from the broker")
|
||||
}
|
||||
if fixture.tarballCalls != 1 {
|
||||
t.Fatalf("expected exactly one artifact download, got %d", fixture.tarballCalls)
|
||||
}
|
||||
if fixture.sshsigCalls != 1 {
|
||||
t.Fatalf("expected exactly one signature download, got %d", fixture.sshsigCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("manifest hash mismatch fails closed", func(t *testing.T) {
|
||||
fixture := newProBrokerFixture(t, "6.0.5", false)
|
||||
fixture.sha256Hex = strings.Repeat("0", 64)
|
||||
manager := NewManager(&config.Config{DataPath: t.TempDir()})
|
||||
manager.SetProUpdateCredentialSource(fixture.credentialSource())
|
||||
|
||||
applyURL, err := proUpdateApplyURL(fixture.server.URL, "6.0.5")
|
||||
if err != nil {
|
||||
t.Fatalf("proUpdateApplyURL: %v", err)
|
||||
}
|
||||
|
||||
err = manager.ApplyUpdate(context.Background(), ApplyUpdateRequest{DownloadURL: applyURL, Channel: "stable"})
|
||||
if err == nil || !strings.Contains(err.Error(), "checksum verification failed") {
|
||||
t.Fatalf("expected checksum failure, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing signature URL fails closed", func(t *testing.T) {
|
||||
fixture := newProBrokerFixture(t, "6.0.5", false)
|
||||
fixture.omitSSHSig = true
|
||||
manager := NewManager(&config.Config{DataPath: t.TempDir()})
|
||||
manager.SetProUpdateCredentialSource(fixture.credentialSource())
|
||||
|
||||
applyURL, err := proUpdateApplyURL(fixture.server.URL, "6.0.5")
|
||||
if err != nil {
|
||||
t.Fatalf("proUpdateApplyURL: %v", err)
|
||||
}
|
||||
|
||||
err = manager.ApplyUpdate(context.Background(), ApplyUpdateRequest{DownloadURL: applyURL, Channel: "stable"})
|
||||
if err == nil || !strings.Contains(err.Error(), ".sshsig") {
|
||||
t.Fatalf("expected missing-signature refusal, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stable channel refuses a prerelease broker pin at apply time", func(t *testing.T) {
|
||||
fixture := newProBrokerFixture(t, "6.1.0-rc.1", true)
|
||||
manager := NewManager(&config.Config{DataPath: t.TempDir()})
|
||||
manager.SetProUpdateCredentialSource(fixture.credentialSource())
|
||||
|
||||
applyURL, err := proUpdateApplyURL(fixture.server.URL, "6.1.0-rc.1")
|
||||
if err != nil {
|
||||
t.Fatalf("proUpdateApplyURL: %v", err)
|
||||
}
|
||||
|
||||
err = manager.ApplyUpdate(context.Background(), ApplyUpdateRequest{DownloadURL: applyURL, Channel: "stable"})
|
||||
if err == nil || !strings.Contains(err.Error(), "stable channel cannot install prerelease builds") {
|
||||
t.Fatalf("expected the stable-channel guard, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
395
internal/updates/pro_update.go
Normal file
395
internal/updates/pro_update.go
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
package updates
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
|
||||
)
|
||||
|
||||
// The compiled Pulse Pro binary must never update off the public community
|
||||
// releases: the GitHub assets are community builds, so applying one would
|
||||
// replace the Pro binary and silently strip Audit, RBAC, Reporting, and SSO.
|
||||
// Instead the Pro binary checks and applies updates through the license
|
||||
// server's authenticated download broker (GET /v1/downloads/pulse-pro), which
|
||||
// returns the pinned private release version plus short-lived signed URLs for
|
||||
// the archive, its sha256, and its .sshsig sidecar. The archive is signed with
|
||||
// the same pinned pulse-installer key the community release path already
|
||||
// verifies, so the Pro path runs at the same trust bar.
|
||||
const (
|
||||
proDownloadBrokerPath = "/v1/downloads/pulse-pro"
|
||||
|
||||
// proApplyVersionParam carries the target version in the broker apply URL
|
||||
// so the shared handler-side channel guard (ValidateApplyTargetVersion)
|
||||
// can classify it without a network call. ApplyUpdate re-resolves fresh
|
||||
// signed URLs from the broker at apply time; the URL the frontend echoes
|
||||
// back is only an intent marker, never fetched directly.
|
||||
proApplyVersionParam = "version"
|
||||
|
||||
proBrokerFetchTimeout = 30 * time.Second
|
||||
maxProBrokerResponseBytes = 1 << 20 // 1 MiB
|
||||
proUpdatePortalInstructions = "you can still download the archive and its .sshsig sidecar from https://pulserelay.pro/download.html and run install.sh --archive"
|
||||
)
|
||||
|
||||
// ProUpdateCredentials carries what the license-gated download broker needs
|
||||
// for a bound installation: the activation's license server, the installation
|
||||
// token, and the instance fingerprint header value.
|
||||
type ProUpdateCredentials struct {
|
||||
LicenseServerURL string
|
||||
InstallationToken string
|
||||
InstanceFingerprint string
|
||||
}
|
||||
|
||||
// SetProUpdateCredentialSource wires a lazy credential source consulted on
|
||||
// each Pro update check/apply, so activation performed after startup is picked
|
||||
// up without restarting. Call during startup wiring, before requests are
|
||||
// served. The source returns false when no usable activation exists.
|
||||
func (m *Manager) SetProUpdateCredentialSource(source func() (ProUpdateCredentials, bool)) {
|
||||
m.proCredentialSource = source
|
||||
}
|
||||
|
||||
func (m *Manager) proUpdateCredentials() (ProUpdateCredentials, bool) {
|
||||
if m.proCredentialSource == nil {
|
||||
return ProUpdateCredentials{}, false
|
||||
}
|
||||
creds, ok := m.proCredentialSource()
|
||||
if !ok {
|
||||
return ProUpdateCredentials{}, false
|
||||
}
|
||||
if strings.TrimSpace(creds.LicenseServerURL) == "" || strings.TrimSpace(creds.InstallationToken) == "" {
|
||||
return ProUpdateCredentials{}, false
|
||||
}
|
||||
return creds, true
|
||||
}
|
||||
|
||||
func errProUpdateNotActivated() error {
|
||||
return fmt.Errorf("Pulse Pro updates need an activated license: the updater downloads private Pulse Pro builds from the license server, which requires this installation's activation credentials; activate in Settings → License, or %s", proUpdatePortalInstructions)
|
||||
}
|
||||
|
||||
// proBrokerResponse is the subset of the broker's response the updater needs.
|
||||
type proBrokerResponse struct {
|
||||
Release struct {
|
||||
Version string `json:"version"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
Channel string `json:"channel"`
|
||||
} `json:"release"`
|
||||
Artifacts []proBrokerArtifact `json:"artifacts"`
|
||||
}
|
||||
|
||||
type proBrokerArtifact struct {
|
||||
Target string `json:"target"`
|
||||
Filename string `json:"filename"`
|
||||
SHA256 string `json:"sha256"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
SSHSignatureURL string `json:"sshsig_url"`
|
||||
}
|
||||
|
||||
type proBrokerErrorResponse struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
// proUpdateTarget maps the running architecture to the broker's artifact
|
||||
// target naming (same mapping the community release assets use).
|
||||
func proUpdateTarget() string {
|
||||
arch := runtime.GOARCH
|
||||
if arch == "arm" {
|
||||
arch = "armv7"
|
||||
}
|
||||
return "linux-" + arch
|
||||
}
|
||||
|
||||
func proBrokerBaseURL(licenseServerURL string) (*url.URL, error) {
|
||||
baseURL, err := securityutil.NormalizeHTTPBaseURL(licenseServerURL, "https")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid license server URL: %w", err)
|
||||
}
|
||||
return baseURL, nil
|
||||
}
|
||||
|
||||
// proUpdateApplyURL builds the stable broker-intent URL returned as
|
||||
// UpdateInfo.DownloadURL for Pro binaries. It embeds the version tag so the
|
||||
// shared apply-target validation can infer it; it is never fetched directly.
|
||||
func proUpdateApplyURL(licenseServerURL, version string) (string, error) {
|
||||
baseURL, err := proBrokerBaseURL(licenseServerURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
target, err := securityutil.ResolveRelativeURL(baseURL, proDownloadBrokerPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("build Pulse Pro download URL: %w", err)
|
||||
}
|
||||
query := target.Query()
|
||||
query.Set(proApplyVersionParam, "v"+strings.TrimPrefix(strings.TrimSpace(version), "v"))
|
||||
target.RawQuery = query.Encode()
|
||||
return target.String(), nil
|
||||
}
|
||||
|
||||
// validateProApplyRequestURL checks that an apply request on the Pro binary
|
||||
// carries the broker-intent URL for this installation's license server, so a
|
||||
// stale or hand-crafted community download URL can never reach the download
|
||||
// stage on a Pro binary.
|
||||
func validateProApplyRequestURL(rawURL, licenseServerURL string) error {
|
||||
requested, err := securityutil.NormalizeAbsoluteHTTPURL(rawURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid download URL: %w", err)
|
||||
}
|
||||
baseURL, err := proBrokerBaseURL(licenseServerURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expected, err := securityutil.ResolveRelativeURL(baseURL, proDownloadBrokerPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid download URL")
|
||||
}
|
||||
if requested.Scheme != expected.Scheme || !strings.EqualFold(requested.Host, expected.Host) || requested.Path != expected.Path {
|
||||
return fmt.Errorf("invalid download URL: Pulse Pro updates install from the license server download broker (%s)", expected.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchProDownloadManifest queries the license server download broker for the
|
||||
// current private Pulse Pro release and signed artifact URLs for this
|
||||
// architecture.
|
||||
func (m *Manager) fetchProDownloadManifest(ctx context.Context, creds ProUpdateCredentials) (*proBrokerResponse, error) {
|
||||
baseURL, err := proBrokerBaseURL(creds.LicenseServerURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target, err := securityutil.ResolveRelativeURL(baseURL, proDownloadBrokerPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build Pulse Pro download broker URL: %w", err)
|
||||
}
|
||||
query := target.Query()
|
||||
query.Set("target", proUpdateTarget())
|
||||
target.RawQuery = query.Encode()
|
||||
|
||||
headers := map[string]string{
|
||||
"Accept": "application/json",
|
||||
"Authorization": "Bearer " + creds.InstallationToken,
|
||||
"User-Agent": "Pulse-Update-Checker",
|
||||
}
|
||||
if fingerprint := strings.TrimSpace(creds.InstanceFingerprint); fingerprint != "" {
|
||||
headers["X-Pulse-Instance-Fingerprint"] = fingerprint
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: proBrokerFetchTimeout}
|
||||
resp, err := m.getWithRetry(ctx, client, target, headers, "fetch Pulse Pro download manifest")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch Pulse Pro download manifest: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxProBrokerResponseBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read Pulse Pro download manifest: %w", err)
|
||||
}
|
||||
if int64(len(body)) > maxProBrokerResponseBytes {
|
||||
return nil, fmt.Errorf("Pulse Pro download manifest exceeds %d bytes", maxProBrokerResponseBytes)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
detail := ""
|
||||
var brokerErr proBrokerErrorResponse
|
||||
if json.Unmarshal(body, &brokerErr) == nil && brokerErr.Error.Message != "" {
|
||||
detail = brokerErr.Error.Message
|
||||
if brokerErr.Error.Code != "" {
|
||||
detail = brokerErr.Error.Code + ": " + detail
|
||||
}
|
||||
}
|
||||
if detail == "" {
|
||||
detail = strings.TrimSpace(string(body))
|
||||
}
|
||||
if detail == "" {
|
||||
detail = resp.Status
|
||||
}
|
||||
return nil, fmt.Errorf("Pulse Pro download broker returned status %d: %s", resp.StatusCode, detail)
|
||||
}
|
||||
|
||||
var manifest proBrokerResponse
|
||||
if err := json.Unmarshal(body, &manifest); err != nil {
|
||||
return nil, fmt.Errorf("decode Pulse Pro download manifest: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(manifest.Release.Version) == "" {
|
||||
return nil, fmt.Errorf("Pulse Pro download manifest is missing a release version")
|
||||
}
|
||||
return &manifest, nil
|
||||
}
|
||||
|
||||
// checkProUpdates is the Pro-binary counterpart to the GitHub release check:
|
||||
// it compares the current version against the private release pinned on the
|
||||
// download broker. Never touches GitHub.
|
||||
func (m *Manager) checkProUpdates(ctx context.Context, channel string, currentInfo *VersionInfo, currentVer *Version) (*UpdateInfo, error) {
|
||||
creds, ok := m.proUpdateCredentials()
|
||||
if !ok {
|
||||
return &UpdateInfo{
|
||||
Available: false,
|
||||
CurrentVersion: currentInfo.Version,
|
||||
LatestVersion: currentInfo.Version,
|
||||
Warning: "Update checks are unavailable: " + errProUpdateNotActivated().Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
manifest, err := m.fetchProDownloadManifest(ctx, creds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
latestVer, err := ParseVersion(manifest.Release.Version)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse Pulse Pro release version %q: %w", manifest.Release.Version, err)
|
||||
}
|
||||
isPrerelease := manifest.Release.Prerelease || latestVer.IsPrerelease()
|
||||
|
||||
// The broker pins a single release. A stable-channel install must not be
|
||||
// offered a prerelease pin; report "no update" with an explanatory warning
|
||||
// instead of dangling an apply that the channel guard would refuse.
|
||||
if channel == "stable" && isPrerelease && latestVer.IsNewerThan(currentVer) {
|
||||
return &UpdateInfo{
|
||||
Available: false,
|
||||
CurrentVersion: currentInfo.Version,
|
||||
LatestVersion: currentInfo.Version,
|
||||
Warning: fmt.Sprintf("The private Pulse Pro release channel currently serves prerelease %s; stable-channel installs skip prereleases.", manifest.Release.Version),
|
||||
}, nil
|
||||
}
|
||||
|
||||
downloadURL, err := proUpdateApplyURL(creds.LicenseServerURL, manifest.Release.Version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
isMajorUpgrade := latestVer.Major > currentVer.Major
|
||||
info := &UpdateInfo{
|
||||
Available: latestVer.IsNewerThan(currentVer),
|
||||
CurrentVersion: currentInfo.Version,
|
||||
LatestVersion: strings.TrimPrefix(manifest.Release.Version, "v"),
|
||||
ReleaseNotes: "",
|
||||
DownloadURL: downloadURL,
|
||||
IsPrerelease: isPrerelease,
|
||||
IsMajorUpgrade: isMajorUpgrade,
|
||||
}
|
||||
info.Warning = updateWarning(info.Available, isMajorUpgrade, isPrerelease, currentVer.Major, latestVer.Major)
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// resolvedUpdateArtifact carries where ApplyUpdate downloads the release from
|
||||
// and how it verifies it. The community path derives the .sshsig sidecar and
|
||||
// SHA256SUMS location from the download URL; the Pro path carries explicit
|
||||
// signed URLs and an inline hash from the broker manifest.
|
||||
type resolvedUpdateArtifact struct {
|
||||
downloadURL string
|
||||
sshsigURL string // "" → derive <downloadURL>.sshsig
|
||||
sha256 string // "" → discover a SHA256SUMS manifest next to downloadURL
|
||||
version string // target version tag, e.g. v6.0.5
|
||||
}
|
||||
|
||||
// resolveProUpdateArtifact fetches fresh signed URLs from the broker at apply
|
||||
// time (the signed URLs expire in minutes, so anything captured at check time
|
||||
// is stale by design) and enforces the channel guard against the broker's
|
||||
// current pin.
|
||||
func (m *Manager) resolveProUpdateArtifact(ctx context.Context, channel string) (resolvedUpdateArtifact, error) {
|
||||
creds, ok := m.proUpdateCredentials()
|
||||
if !ok {
|
||||
return resolvedUpdateArtifact{}, errProUpdateNotActivated()
|
||||
}
|
||||
|
||||
manifest, err := m.fetchProDownloadManifest(ctx, creds)
|
||||
if err != nil {
|
||||
return resolvedUpdateArtifact{}, err
|
||||
}
|
||||
|
||||
latestVer, err := ParseVersion(manifest.Release.Version)
|
||||
if err != nil {
|
||||
return resolvedUpdateArtifact{}, fmt.Errorf("parse Pulse Pro release version %q: %w", manifest.Release.Version, err)
|
||||
}
|
||||
if channel == "stable" && (manifest.Release.Prerelease || latestVer.IsPrerelease()) {
|
||||
return resolvedUpdateArtifact{}, fmt.Errorf("stable channel cannot install prerelease builds (the private Pulse Pro channel currently serves %s)", manifest.Release.Version)
|
||||
}
|
||||
|
||||
wantTarget := proUpdateTarget()
|
||||
var artifact *proBrokerArtifact
|
||||
for i := range manifest.Artifacts {
|
||||
if manifest.Artifacts[i].Target == wantTarget {
|
||||
artifact = &manifest.Artifacts[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if artifact == nil {
|
||||
return resolvedUpdateArtifact{}, fmt.Errorf("Pulse Pro download broker has no artifact for target %q", wantTarget)
|
||||
}
|
||||
if strings.TrimSpace(artifact.DownloadURL) == "" {
|
||||
return resolvedUpdateArtifact{}, fmt.Errorf("Pulse Pro artifact %q is missing a download URL", wantTarget)
|
||||
}
|
||||
// Fail closed: the Pro path never installs an archive it cannot verify
|
||||
// against both the pinned signing key and the manifest hash.
|
||||
if strings.TrimSpace(artifact.SSHSignatureURL) == "" {
|
||||
return resolvedUpdateArtifact{}, fmt.Errorf("Pulse Pro artifact %q is missing its .sshsig signature URL", wantTarget)
|
||||
}
|
||||
if strings.TrimSpace(artifact.SHA256) == "" {
|
||||
return resolvedUpdateArtifact{}, fmt.Errorf("Pulse Pro artifact %q is missing its sha256", wantTarget)
|
||||
}
|
||||
|
||||
return resolvedUpdateArtifact{
|
||||
downloadURL: artifact.DownloadURL,
|
||||
sshsigURL: artifact.SSHSignatureURL,
|
||||
sha256: strings.ToLower(strings.TrimSpace(artifact.SHA256)),
|
||||
version: "v" + strings.TrimPrefix(strings.TrimSpace(manifest.Release.Version), "v"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// downloadAndVerifySignatureFromURL fetches an explicit .sshsig URL (the
|
||||
// broker hands out signed sidecar URLs that cannot be derived by suffixing
|
||||
// the artifact URL) and verifies it against assetPath with the same pinned
|
||||
// pulse-installer trust root as the community path.
|
||||
func (m *Manager) downloadAndVerifySignatureFromURL(ctx context.Context, sigURLRaw, assetPath string) error {
|
||||
sigURL, err := securityutil.NormalizeAbsoluteHTTPURL(sigURLRaw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid signature URL: %w", err)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: signatureFetchTimeout}
|
||||
resp, err := m.getWithRetry(ctx, client, sigURL, nil, "download release signature")
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch %s: %w", sigURL.String(), err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return readSignatureAndVerify(ctx, resp, sigURL.String(), assetPath)
|
||||
}
|
||||
|
||||
// verifyFileSHA256 checks a downloaded file against an expected hex digest
|
||||
// carried inline by the broker manifest.
|
||||
func verifyFileSHA256(path, expectedHex string) error {
|
||||
expected := strings.ToLower(strings.TrimSpace(expectedHex))
|
||||
if len(expected) != sha256.Size*2 {
|
||||
return fmt.Errorf("invalid expected sha256 %q", expectedHex)
|
||||
}
|
||||
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %q for checksum: %w", path, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, file); err != nil {
|
||||
return fmt.Errorf("compute checksum for %q: %w", path, err)
|
||||
}
|
||||
actual := hex.EncodeToString(hash.Sum(nil))
|
||||
if actual != expected {
|
||||
return fmt.Errorf("checksum mismatch: expected %s, got %s", expected, actual)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -969,7 +969,9 @@ func TestUpdateDemoWorkflowUsesGovernedNetworkPath(t *testing.T) {
|
|||
required := []string{
|
||||
`- name: Tailscale`,
|
||||
`uses: tailscale/github-action@4e4c49acaa9818630ce0bd7a564372c17e33fb4d # v2`,
|
||||
`authkey: ${{ secrets.TS_AUTHKEY }}`,
|
||||
`oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}`,
|
||||
`oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}`,
|
||||
`tags: tag:infra`,
|
||||
`uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0`,
|
||||
`go run ./scripts/release_update_key.go public-key-ssh`,
|
||||
`sed -i "s|^PINNED_RELEASE_SSH_PUBLIC_KEY=.*|PINNED_RELEASE_SSH_PUBLIC_KEY=\"${TRUSTED_SSH_PUBLIC_KEY}\"|" /tmp/pulse-install.sh`,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue