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:
rcourtman 2026-07-08 23:23:20 +01:00
parent 8222f132a8
commit 313552debc
3 changed files with 708 additions and 1 deletions

View 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)
}
})
}

View 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
}

View file

@ -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`,