diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index caac9ed8f..17a6fc9f8 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -7865,6 +7865,28 @@ func TestContract_InstallScriptReleaseAssetURLRejectsDevPrereleaseBuild(t *testi } } +func TestContract_AgentBinaryReleaseAssetURL(t *testing.T) { + router := &Router{serverVersion: "v6.0.0-rc.1"} + + got, err := router.agentBinaryReleaseAssetURL("linux-arm64") + if err != nil { + t.Fatalf("agent binary release asset URL: %v", err) + } + + const want = "https://github.com/rcourtman/Pulse/releases/download/v6.0.0-rc.1/pulse-agent-linux-arm64" + if got != want { + t.Fatalf("agent binary release asset URL = %q, want %q", got, want) + } +} + +func TestContract_AgentBinaryReleaseAssetURLRejectsDevPrereleaseBuild(t *testing.T) { + router := &Router{serverVersion: "v6.0.0-dev"} + + if _, err := router.agentBinaryReleaseAssetURL("linux-arm64"); err == nil { + t.Fatalf("expected dev prerelease build to reject release asset lookup") + } +} + func TestContract_ProxmoxInstallCommandIncludesInsecureForPlainHTTP(t *testing.T) { got := buildProxmoxAgentInstallCommand(agentInstallCommandOptions{ BaseURL: "http://pulse.example.com:7655/", diff --git a/internal/api/unified_agent.go b/internal/api/unified_agent.go index 64065e181..1b8f7d527 100644 --- a/internal/api/unified_agent.go +++ b/internal/api/unified_agent.go @@ -9,7 +9,6 @@ import ( "crypto/sha256" "encoding/base64" "encoding/hex" - "encoding/json" "errors" "fmt" "io" @@ -40,18 +39,10 @@ func installScriptReleaseRepo() string { return repo } -func githubReleaseDownloadURL(assetName string) string { - return fmt.Sprintf("https://github.com/%s/releases/latest/download/%s", installScriptReleaseRepo(), assetName) -} - func githubReleaseAssetURL(tag, assetName string) string { return fmt.Sprintf("https://github.com/%s/releases/download/%s/%s", installScriptReleaseRepo(), strings.TrimSpace(tag), assetName) } -func githubLatestReleaseAPIURL() string { - return fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", installScriptReleaseRepo()) -} - func (r *Router) handleDownloadUnifiedInstallScript(w http.ResponseWriter, req *http.Request) { handleDownloadInstallScriptCommon(w, req, r.serverVersion, "/opt/pulse/scripts/install.sh", filepath.Join(r.projectRoot, "scripts", "install.sh"), "install.sh", "text/x-shellscript", r.proxyInstallScriptFromGitHub) } @@ -230,25 +221,25 @@ func (r *Router) handleDownloadUnifiedAgent(w http.ResponseWriter, req *http.Req log.Warn().Strs("paths", invalidCandidates).Msg("Ignoring stale local unified agent binaries") } - // In dev mode, never fall through to GitHub releases — the released binary - // would lack current fixes. Return a clear 404 with build instructions. - if r.serverVersion == "dev" { - reason := fmt.Sprintf("Agent binary not found for %q in dev mode.", normalized) - if len(invalidCandidates) > 0 { - reason = fmt.Sprintf("Local agent binary for %q is stale or incompatible in dev mode:\n %s", - normalized, - strings.Join(invalidCandidates, "\n "), - ) - } - http.Error(w, reason+"\nBuild with:\n GOOS=linux GOARCH=amd64 go build -o bin/pulse-agent-linux-amd64 ./cmd/pulse-agent", http.StatusNotFound) - return - } - // Fallback: proxy from GitHub releases for the binary // This handles LXC/barebone installations that don't have agent binaries locally. // We proxy instead of redirecting because agents require the X-Checksum-Sha256 header, // which GitHub doesn't provide. if normalized != "" { + // Outside published release builds, never fall through to GitHub releases — + // that would silently fetch the wrong channel. Return a clear 404 with build + // instructions instead. + if !isPublishedReleaseAssetVersion(r.serverVersion) { + reason := fmt.Sprintf("Agent binary not found for %q in dev mode.", normalized) + if len(invalidCandidates) > 0 { + reason = fmt.Sprintf("Local agent binary for %q is stale or incompatible in dev mode:\n %s", + normalized, + strings.Join(invalidCandidates, "\n "), + ) + } + http.Error(w, reason+"\nBuild with:\n GOOS=linux GOARCH=amd64 go build -o bin/pulse-agent-linux-amd64 ./cmd/pulse-agent", http.StatusNotFound) + return + } r.proxyAgentBinaryFromGitHub(w, req, normalized) return } @@ -281,13 +272,14 @@ func validateUnifiedAgentBinary(path string) error { // We must proxy instead of redirecting because the agent requires the checksum header // for security verification, and GitHub doesn't provide it. func (r *Router) proxyAgentBinaryFromGitHub(w http.ResponseWriter, req *http.Request, normalized string) { - binaryName := "pulse-agent-" + normalized - if strings.HasPrefix(normalized, "windows-") { - binaryName += ".exe" + githubURL, err := r.agentBinaryReleaseAssetURL(normalized) + if err != nil { + log.Error().Err(err).Str("server_version", strings.TrimSpace(r.serverVersion)).Str("arch", normalized).Msg("Agent binary fallback unavailable for current server build") + http.Error(w, "Agent binary unavailable for current server build", http.StatusServiceUnavailable) + return } - githubURL := githubReleaseDownloadURL(binaryName) - signatureURL := githubReleaseDownloadURL(binaryName + ".sig") - sshSignatureURL := githubReleaseDownloadURL(binaryName + ".sshsig") + signatureURL := githubURL + ".sig" + sshSignatureURL := githubURL + ".sshsig" log.Info().Str("arch", normalized).Str("url", githubURL).Msg("Local agent binary not found, proxying from GitHub releases") @@ -389,7 +381,7 @@ func serveProxiedAgentBinaryWithSignatures(w http.ResponseWriter, content []byte } func (r *Router) fetchAgentBinaryFromReleaseArchive(client *http.Client, normalized string) ([]byte, string, error) { - tag, err := fetchLatestReleaseTag(client) + tag, err := r.releaseAssetTag() if err != nil { return nil, "", err } @@ -438,36 +430,6 @@ func (r *Router) fetchAgentBinaryFromReleaseArchive(client *http.Client, normali return binary, hex.EncodeToString(sum[:]), nil } -func fetchLatestReleaseTag(client *http.Client) (string, error) { - req, err := http.NewRequest(http.MethodGet, githubLatestReleaseAPIURL(), nil) - if err != nil { - return "", err - } - req.Header.Set("Accept", "application/vnd.github+json") - req.Header.Set("User-Agent", "pulse-agent-download-proxy") - - resp, err := client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("latest release lookup returned status %d", resp.StatusCode) - } - - var payload struct { - TagName string `json:"tag_name"` - } - if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&payload); err != nil { - return "", fmt.Errorf("failed decoding latest release payload: %w", err) - } - tag := strings.TrimSpace(payload.TagName) - if tag == "" { - return "", fmt.Errorf("latest release payload missing tag_name") - } - return tag, nil -} - func isPublishedReleaseAssetVersion(rawVersion string) bool { rawVersion = strings.TrimSpace(rawVersion) if rawVersion == "" || strings.EqualFold(rawVersion, "dev") { @@ -589,13 +551,13 @@ func extractFromZip(archive []byte, entryName string) ([]byte, error) { return nil, fmt.Errorf("binary %q not found in zip", entryName) } -func (r *Router) installScriptReleaseAssetURL(scriptName string) (string, error) { +func (r *Router) releaseAssetTag() (string, error) { rawVersion := strings.TrimSpace(r.serverVersion) if rawVersion == "" { return "", fmt.Errorf("server version is unavailable") } if strings.EqualFold(rawVersion, "dev") { - return "", fmt.Errorf("development builds must serve local install scripts") + return "", fmt.Errorf("development builds must serve local assets") } version, err := updates.ParseVersion(rawVersion) @@ -606,12 +568,27 @@ func (r *Router) installScriptReleaseAssetURL(scriptName string) (string, error) return "", fmt.Errorf("server version %q is not a published release asset version", rawVersion) } - return fmt.Sprintf( - "https://github.com/%s/releases/download/v%s/%s", - installScriptReleaseRepo(), - version.String(), - scriptName, - ), nil + return "v" + version.String(), nil +} + +func (r *Router) releaseAssetURL(assetName string) (string, error) { + tag, err := r.releaseAssetTag() + if err != nil { + return "", err + } + return githubReleaseAssetURL(tag, assetName), nil +} + +func (r *Router) installScriptReleaseAssetURL(scriptName string) (string, error) { + return r.releaseAssetURL(scriptName) +} + +func (r *Router) agentBinaryReleaseAssetURL(normalized string) (string, error) { + binaryName := "pulse-agent-" + normalized + if strings.HasPrefix(normalized, "windows-") { + binaryName += ".exe" + } + return r.releaseAssetURL(binaryName) } // proxyInstallScriptFromGitHub fetches an install script from the exact GitHub diff --git a/internal/api/unified_agent_test.go b/internal/api/unified_agent_test.go index b69dacaaa..bc931d730 100644 --- a/internal/api/unified_agent_test.go +++ b/internal/api/unified_agent_test.go @@ -166,12 +166,13 @@ func TestDownloadUnifiedAgent_LocalReleaseBinaryIncludesSignatureHeader(t *testi func TestDownloadUnifiedAgent_SkipsStaleLocalBinaryAndProxies(t *testing.T) { router, tempDir := setupUnifiedAgentRouter(t) + router.serverVersion = "v6.0.0-rc.1" stalePath := filepath.Join(tempDir, "bin", "pulse-agent-linux-amd64") require.NoError(t, os.WriteFile(stalePath, staleTestUnifiedAgentBinary("linux-amd64"), 0755)) binaryContent := "fresh github binary" - expectedURL := "https://github.com/rcourtman/Pulse/releases/latest/download/pulse-agent-linux-amd64" + expectedURL := "https://github.com/rcourtman/Pulse/releases/download/v6.0.0-rc.1/pulse-agent-linux-amd64" router.installScriptClient = newTestInstallScriptClientSequence(t, []expectedHTTPExchange{ {Method: http.MethodGet, URL: expectedURL, Status: http.StatusOK, Body: binaryContent}, {Method: http.MethodGet, URL: expectedURL + ".sig", Status: http.StatusOK, Body: "signed-agent"}, @@ -210,11 +211,12 @@ func TestDownloadUnifiedAgent_DevModeRejectsStaleLocalBinary(t *testing.T) { func TestDownloadUnifiedAgent_ProxyFromGitHub(t *testing.T) { router, _ := setupUnifiedAgentRouter(t) + router.serverVersion = "v6.0.0-rc.1" // Ensure NO local files exist (temp dir is empty of binaries) // Set up a mock HTTP client to simulate GitHub response binaryContent := "fake binary content for proxy test" - expectedURL := "https://github.com/rcourtman/Pulse/releases/latest/download/pulse-agent-linux-amd64" + expectedURL := "https://github.com/rcourtman/Pulse/releases/download/v6.0.0-rc.1/pulse-agent-linux-amd64" router.installScriptClient = newTestInstallScriptClientSequence(t, []expectedHTTPExchange{ {Method: http.MethodGet, URL: expectedURL, Status: http.StatusOK, Body: binaryContent}, {Method: http.MethodGet, URL: expectedURL + ".sig", Status: http.StatusOK, Body: "signed-agent"}, @@ -243,9 +245,10 @@ func TestDownloadUnifiedAgent_ProxyFromGitHub_UsesConfiguredRepo(t *testing.T) { t.Setenv("PULSE_GITHUB_REPO", "example/pulse-fork") router, _ := setupUnifiedAgentRouter(t) + router.serverVersion = "v6.0.0-rc.1" binaryContent := "fake binary content for proxy test" - expectedURL := "https://github.com/example/pulse-fork/releases/latest/download/pulse-agent-linux-amd64" + expectedURL := "https://github.com/example/pulse-fork/releases/download/v6.0.0-rc.1/pulse-agent-linux-amd64" router.installScriptClient = newTestInstallScriptClientSequence(t, []expectedHTTPExchange{ {Method: http.MethodGet, URL: expectedURL, Status: http.StatusOK, Body: binaryContent}, {Method: http.MethodGet, URL: expectedURL + ".sig", Status: http.StatusOK, Body: "signed-agent"}, @@ -263,9 +266,10 @@ func TestDownloadUnifiedAgent_ProxyFromGitHub_UsesConfiguredRepo(t *testing.T) { func TestDownloadUnifiedAgent_ProxyFromGitHub_Windows(t *testing.T) { router, _ := setupUnifiedAgentRouter(t) + router.serverVersion = "v6.0.0-rc.1" binaryContent := "MZ fake windows binary" - expectedURL := "https://github.com/rcourtman/Pulse/releases/latest/download/pulse-agent-windows-amd64.exe" + expectedURL := "https://github.com/rcourtman/Pulse/releases/download/v6.0.0-rc.1/pulse-agent-windows-amd64.exe" router.installScriptClient = newTestInstallScriptClientSequence(t, []expectedHTTPExchange{ {Method: http.MethodGet, URL: expectedURL, Status: http.StatusOK, Body: binaryContent}, {Method: http.MethodGet, URL: expectedURL + ".sig", Status: http.StatusOK, Body: "signed-agent"}, @@ -286,13 +290,13 @@ func TestDownloadUnifiedAgent_ProxyFromGitHub_Windows(t *testing.T) { func TestDownloadUnifiedAgent_ProxyFromGitHub_ArchiveFallback_Darwin(t *testing.T) { router, _ := setupUnifiedAgentRouter(t) + router.serverVersion = "v9.9.9" binaryContent := []byte("darwin arm64 binary payload") archivePayload := buildTestTarGz(t, "pulse-agent-darwin-arm64", binaryContent) - binaryURL := "https://github.com/rcourtman/Pulse/releases/latest/download/pulse-agent-darwin-arm64" + binaryURL := "https://github.com/rcourtman/Pulse/releases/download/v9.9.9/pulse-agent-darwin-arm64" signatureURL := binaryURL + ".sig" sshSignatureURL := binaryURL + ".sshsig" - latestURL := "https://api.github.com/repos/rcourtman/Pulse/releases/latest" archiveURL := "https://github.com/rcourtman/Pulse/releases/download/v9.9.9/pulse-agent-v9.9.9-darwin-arm64.tar.gz" router.installScriptClient = &http.Client{ @@ -305,13 +309,6 @@ func TestDownloadUnifiedAgent_ProxyFromGitHub_ArchiveFallback_Darwin(t *testing. Header: make(http.Header), Body: io.NopCloser(strings.NewReader("not found")), }, nil - case latestURL: - return &http.Response{ - StatusCode: http.StatusOK, - Status: "200 OK", - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader(`{"tag_name":"v9.9.9"}`)), - }, nil case archiveURL: return &http.Response{ StatusCode: http.StatusOK, @@ -360,13 +357,13 @@ func TestDownloadUnifiedAgent_ProxyFromGitHub_ArchiveFallback_UsesConfiguredRepo t.Setenv("PULSE_GITHUB_REPO", "example/pulse-fork") router, _ := setupUnifiedAgentRouter(t) + router.serverVersion = "v9.9.9" binaryContent := []byte("darwin arm64 binary payload") archivePayload := buildTestTarGz(t, "pulse-agent-darwin-arm64", binaryContent) - binaryURL := "https://github.com/example/pulse-fork/releases/latest/download/pulse-agent-darwin-arm64" + binaryURL := "https://github.com/example/pulse-fork/releases/download/v9.9.9/pulse-agent-darwin-arm64" signatureURL := binaryURL + ".sig" sshSignatureURL := binaryURL + ".sshsig" - latestURL := "https://api.github.com/repos/example/pulse-fork/releases/latest" archiveURL := "https://github.com/example/pulse-fork/releases/download/v9.9.9/pulse-agent-v9.9.9-darwin-arm64.tar.gz" router.installScriptClient = &http.Client{ @@ -379,13 +376,6 @@ func TestDownloadUnifiedAgent_ProxyFromGitHub_ArchiveFallback_UsesConfiguredRepo Header: make(http.Header), Body: io.NopCloser(strings.NewReader("not found")), }, nil - case latestURL: - return &http.Response{ - StatusCode: http.StatusOK, - Status: "200 OK", - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader(`{"tag_name":"v9.9.9"}`)), - }, nil case archiveURL: return &http.Response{ StatusCode: http.StatusOK, @@ -428,10 +418,11 @@ func TestDownloadUnifiedAgent_ProxyFromGitHub_ArchiveFallback_UsesConfiguredRepo func TestDownloadUnifiedAgent_ProxyFromGitHub_NotFound(t *testing.T) { router, _ := setupUnifiedAgentRouter(t) + router.serverVersion = "v6.0.0-rc.1" router.installScriptClient = newTestInstallScriptClientSequence(t, []expectedHTTPExchange{ - {Method: http.MethodGet, URL: "https://github.com/rcourtman/Pulse/releases/latest/download/pulse-agent-linux-amd64", Status: http.StatusNotFound, Body: ""}, - {Method: http.MethodGet, URL: "https://api.github.com/repos/rcourtman/Pulse/releases/latest", Status: http.StatusNotFound, Body: ""}, + {Method: http.MethodGet, URL: "https://github.com/rcourtman/Pulse/releases/download/v6.0.0-rc.1/pulse-agent-linux-amd64", Status: http.StatusNotFound, Body: ""}, + {Method: http.MethodGet, URL: "https://github.com/rcourtman/Pulse/releases/download/v6.0.0-rc.1/pulse-agent-v6.0.0-rc.1-linux-amd64.tar.gz", Status: http.StatusNotFound, Body: ""}, }) req := httptest.NewRequest(http.MethodGet, "/api/install/agent?arch=linux-amd64", nil) @@ -444,9 +435,10 @@ func TestDownloadUnifiedAgent_ProxyFromGitHub_NotFound(t *testing.T) { func TestDownloadUnifiedAgent_ProxyFromGitHub_Error(t *testing.T) { router, _ := setupUnifiedAgentRouter(t) + router.serverVersion = "v6.0.0-rc.1" // GitHub is unreachable - router.installScriptClient = newTestInstallScriptClient(t, http.MethodGet, "https://github.com/rcourtman/Pulse/releases/latest/download/pulse-agent-linux-amd64", 0, "", errors.New("connection refused")) + router.installScriptClient = newTestInstallScriptClient(t, http.MethodGet, "https://github.com/rcourtman/Pulse/releases/download/v6.0.0-rc.1/pulse-agent-linux-amd64", 0, "", errors.New("connection refused")) req := httptest.NewRequest(http.MethodGet, "/api/install/agent?arch=linux-amd64", nil) w := httptest.NewRecorder() @@ -456,6 +448,20 @@ func TestDownloadUnifiedAgent_ProxyFromGitHub_Error(t *testing.T) { require.Equal(t, http.StatusServiceUnavailable, w.Code) } +func TestDownloadUnifiedAgent_DevPrereleaseRejectsGitHubFallback(t *testing.T) { + router, _ := setupUnifiedAgentRouter(t) + router.serverVersion = "v6.0.0-dev" + + req := httptest.NewRequest(http.MethodGet, "/api/install/agent?arch=linux-amd64", nil) + w := httptest.NewRecorder() + + router.handleDownloadUnifiedAgent(w, req) + + require.Equal(t, http.StatusNotFound, w.Code) + assert.Contains(t, w.Body.String(), "dev mode") + assert.Contains(t, w.Body.String(), "go build -o bin/pulse-agent-linux-amd64") +} + func TestNormalizeUnifiedAgentArch(t *testing.T) { tests := []struct { input string