diff --git a/internal/updates/manager_pro_update_test.go b/internal/updates/manager_pro_update_test.go new file mode 100644 index 000000000..95425ecd0 --- /dev/null +++ b/internal/updates/manager_pro_update_test.go @@ -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) + } + }) +} diff --git a/internal/updates/pro_update.go b/internal/updates/pro_update.go new file mode 100644 index 000000000..eceb9892b --- /dev/null +++ b/internal/updates/pro_update.go @@ -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 .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 +} diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 45d804ede..b4414892d 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -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`,