Remove the /install.sh GitHub proxy fallback; serve bundled agent installer or fail closed (#1470)

Root fix for the recurring class behind issue #1470. The served /install.sh and
/install.ps1 endpoints existed to hand out the unified AGENT installer, but their
GitHub fallback fetched the top-level install.sh release asset, which since
49412357a is the SERVER installer. Prior commits made that fallback unreachable
in normal deployments (deploy the sidecars; serve the local script even when
unsigned), but the endpoint was still structurally capable of serving the wrong
script in the no-local-bundle case.

The agent installer is a per-build artifact bundled into every release tarball and
Docker image, not a release asset, so the endpoint has no business proxying a
release asset at all. Remove proxyInstallScriptFromGitHub and its
installScriptReleaseAssetURL wrapper. handleDownloadInstallScriptCommon now serves
the locally bundled agent installer (signed when sidecars are present, unsigned
otherwise) or fails closed with 503 when no bundled script exists. Serving the
SERVER installer at this endpoint is now structurally impossible, not merely
unreachable.

The shared version-pinning (releaseAssetTag/releaseAssetURL) and installScriptClient
remain for the agent-BINARY download proxy, which legitimately fetches published
release assets; its version-pinning stays covered by the agentBinaryReleaseAssetURL
contract tests.

Replace the obsolete install-script proxy tests with fail-closed assertions
(including a guard that the endpoint makes no outbound call), drop the four
installScriptReleaseAssetURL contract tests, and revise the four subsystem
contracts that pinned the install-script fallback transport (api-contracts items
8 and 27, agent-lifecycle item 14, storage-recovery item 14, plus the
deployment-installability note) to state that install scripts are served local or
fail closed with no GitHub fallback.
This commit is contained in:
rcourtman 2026-05-29 14:51:18 +01:00
parent b3bdc701ef
commit a3e36d787f
7 changed files with 68 additions and 316 deletions

View file

@ -720,17 +720,17 @@ profile and assignment columns, but embedded table framing must route through
the omitted `usage` or `total` series as missing lifecycle telemetry or
enrollment-state evidence.
Dashboard storage trend consumers on that shared router boundary must now reuse the single `/api/storage-charts` summary response instead of fanning out per-pool `/api/metrics-store/history` reads, and lifecycle surfaces still must treat that batched storage summary transport as presentation context only rather than install, enrollment, or freshness truth.
14. Keep lifecycle installer fallback pinned to published release lineage only,
and reserved for a genuinely-absent local script. The GitHub proxy in
`internal/api/unified_agent.go` for `/install.sh` / `/install.ps1` runs only
when no local installer is bundled at all; a present-but-unsigned local agent
installer is served locally so first-host install, repair, and fleet
continuity never receive the top-level GitHub `install.sh` SERVER installer in
place of the bundled AGENT installer (issue #1470). When the proxy does run,
the shared lifecycle path may only treat stable tags and explicit RC
prerelease tags as release assets. Working-line dev prereleases and
build-metadata versions must fail closed so install/repair do not depend on
unpublished or branch-local installer URLs.
14. Keep install-script serving fallback-free, and keep the lifecycle
agent-download fallback pinned to published release lineage. The served
install-script endpoints (/install.sh, /install.ps1) have no GitHub fallback:
`internal/api/unified_agent.go` serves the locally bundled AGENT installer
(present in every release tarball and Docker image) or fails closed, so
first-host install, repair, and fleet continuity never receive the top-level
GitHub install.sh SERVER installer in place of the agent installer (issue
#1470). The agent-BINARY download fallback that remains may only treat stable
tags and explicit RC prerelease tags as release assets; working-line dev
prereleases and build-metadata versions must fail closed so install/repair do
not depend on unpublished or branch-local URLs.
15. Keep self-hosted purchase handoff state on the adjacent commercial/auth
boundary. When shared `internal/api/router.go`,
`internal/api/router_routes_cloud.go`, `internal/api/licensing_handlers.go`,

View file

@ -672,7 +672,7 @@ the canonical monitored-system blocked payload.
host-agent row for the same machine must leave the API boundary as one
hybrid `agent` resource with merged source facets, not as duplicate rows
that disappear only after REST reconciliation.
8. Route unified-agent installer and binary download headers through `internal/api/unified_agent.go` and `internal/api/contract_test.go` together. Unified-agent BINARY downloads must keep the canonical `X-Checksum-Sha256` plus `X-Signature-Ed25519` contract for updater clients whether the binary is served locally or proxied from the matching GitHub release, instead of leaving callers to infer trust from source location alone. The served install-script endpoints (GET /install.sh and /install.ps1) are governed differently: they must always serve the locally bundled AGENT installer and must never substitute the top-level GitHub install.sh release asset, which is the SERVER installer and rejects the agent wizard's --url / --token-file (issue #1470). They attach the base64-encoded `X-Signature-SSHSIG` header when the local detached signatures are present (or when a genuinely-absent local script is proxied from the matching release), but a present-but-unsigned local agent installer is served WITHOUT the header rather than proxying a signed-but-wrong server installer; the agent install path (curl piped into bash) does not verify these headers, so correctness of the served script outranks signature presence on that endpoint.
8. Route unified-agent installer and binary download headers through `internal/api/unified_agent.go` and `internal/api/contract_test.go` together. Unified-agent BINARY downloads must keep the canonical `X-Checksum-Sha256` plus `X-Signature-Ed25519` contract for updater clients whether the binary is served locally or proxied from the matching GitHub release, instead of leaving callers to infer trust from source location alone. The served install-script endpoints (GET /install.sh and /install.ps1) are governed differently and have NO GitHub fallback at all: they serve the locally bundled AGENT installer or fail closed with 503. The agent installer is a per-build artifact bundled into every release tarball and Docker image, not a release asset, so the endpoint must never fetch the top-level GitHub install.sh release asset (the SERVER installer, which rejects the agent wizard's --url / --token-file, issue #1470). It attaches the base64-encoded `X-Signature-SSHSIG` header when the local detached signatures are present and omits it when they are not; a present-but-unsigned local agent installer is still served, because the agent install path (curl piped into bash) does not verify these headers, so correctness of the served script outranks signature presence.
9. Route canonical AI intelligence summary and resource-intelligence reads through `frontend-modern/src/api/ai.ts`, `frontend-modern/src/stores/aiIntelligence.ts`, `frontend-modern/src/stores/aiIntelligenceSummaryModel.ts`, `frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts`, `frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx`, the Patrol-owned section files under `frontend-modern/src/features/patrol/`, `frontend-modern/src/pages/AIIntelligence.tsx`, `internal/api/ai_handlers.go`, and `internal/api/contract_test.go` together so the summary card, store normalization owner, runtime hook, feature shell, section owners, route shell, and backend payload stay aligned on one governed surface, including the canonical recent-changes slice
while keeping the learning counters backend-only coverage, so the summary page keeps Patrol health and findings primary and renders timeline, correlation, and policy-posture data as secondary investigation context rather than as a separate headline product metric
and the Patrol findings empty-state behavior, so `0 active findings` only renders as a healthy frontend conclusion when the same governed AI summary contract still reports healthy overall health; degraded or not-fully-verified health predictions must flow through to the Patrol findings surface instead of being replaced by page-local "looks healthy" copy
@ -1573,14 +1573,16 @@ the canonical monitored-system blocked payload.
normalize the browser back to `/settings/infrastructure`, but first-run
callers must not fall back to the retired `/settings/infrastructure/install`
or `/settings/infrastructure/platforms` deep links.
27. Keep shared install-script fallback transport pinned to published release
lineage. `internal/api/unified_agent.go` and
27. Keep the shared agent-download fallback transport pinned to published
release lineage. The served install-script endpoints (/install.sh,
/install.ps1) have no GitHub fallback at all (see item 8); they serve the
locally bundled agent installer or fail closed. The remaining GitHub fallback
is the agent-BINARY download proxy: `internal/api/unified_agent.go` and
`internal/api/contract_test.go` must only map stable tags or explicit RC
prerelease tags without build metadata to GitHub install-script release
assets; dev prereleases such as `v6.0.0-dev`, git-described
`+git...` builds, and other unpublished prerelease identifiers must fail
closed on that API boundary instead of generating fake release URLs from
a local runtime version string.
prerelease tags without build metadata to GitHub release assets; dev
prereleases such as `v6.0.0-dev`, git-described `+git...` builds, and other
unpublished prerelease identifiers must fail closed on that API boundary
instead of generating fake release URLs from a local runtime version string.
28. Keep local trial-start transport retired from self-hosted v6 GA runtime
paths. `POST /api/license/trial/start` must not be registered as an ordinary
in-app acquisition endpoint; browser API clients, route inventory, demo

View file

@ -174,11 +174,12 @@ server-side update execution surfaces.
above, is the SERVER installer and rejects the "Install on Linux" wizard's
`--url` / `--token-file` with "Unknown option"). Two layers enforce this and
both must hold: (a) `internal/api/unified_agent.go::handleDownloadInstallScriptCommon`
serves the locally bundled agent installer and must not fall back to proxying
the GitHub `install.sh` asset when the local copy is present but its detached
signatures are missing (nothing on the `curl ... | bash` agent path verifies
those headers, so an unsigned-but-correct local script beats a signed-but-wrong
proxied one); and (b) a server install should still deploy the script's
serves the locally bundled agent installer and has no GitHub fallback at all —
it must never proxy the GitHub `install.sh` asset; a present-but-unsigned local
agent installer is served as-is (nothing on the `curl ... | bash` agent path
verifies the signature headers, so an unsigned-but-correct local script beats a
signed-but-wrong proxied one) and a genuinely-missing local script fails closed
with 503; and (b) a server install should still deploy the script's
`.sig` / `.sshsig` sidecars next to it (`/opt/pulse/scripts/install.sh.sig`,
`.sshsig`) so the served script carries signatures. The Docker image deploys
these sidecars (`Dockerfile`); `install.sh`'s `deploy_agent_scripts` must

View file

@ -932,11 +932,11 @@ recovery scope, or a storage/recovery-owned secret source.
should also preserve their detached `.sig` sidecars so recovery- and
storage-adjacent flows do not silently downgrade installer or agent
download trust back to unsigned local files during upgrade or repair.
When the served install-script local copy is nonetheless present without its
sidecars, that shared boundary serves the local AGENT installer rather than
proxying the top-level GitHub `install.sh` SERVER installer: a correct but
unsigned local script beats a signed-but-wrong proxied one on the unverified
`curl ... | bash` agent path (issue #1470).
The served install-script endpoints themselves have no GitHub fallback: they
serve the locally bundled AGENT installer or fail closed, never proxying the
top-level GitHub install.sh SERVER installer, so a correct local script
(signed or not) is always preferred over a wrong-identity proxied one on the
unverified curl-piped-into-bash agent path (issue #1470).
15. Keep storage summary chart identity and sticky-shell behavior on the
shared storage path. Pool rows, disk rows, storage summary cards, and
storage detail charts must all address history through the canonical

View file

@ -8476,52 +8476,6 @@ func TestContract_HostedSubscriptionRequiredErrorJSONSnapshot(t *testing.T) {
assertJSONSnapshot(t, rec.Body.Bytes(), want)
}
func TestContract_InstallScriptReleaseAssetURL(t *testing.T) {
router := &Router{serverVersion: "v6.0.0-rc.1"}
got, err := router.installScriptReleaseAssetURL("install.sh")
if err != nil {
t.Fatalf("install script release asset URL: %v", err)
}
const want = "https://github.com/rcourtman/Pulse/releases/download/v6.0.0-rc.1/install.sh"
if got != want {
t.Fatalf("install script release asset URL = %q, want %q", got, want)
}
}
func TestContract_InstallScriptReleaseAssetURLUsesConfiguredRepo(t *testing.T) {
t.Setenv("PULSE_GITHUB_REPO", "example/pulse-fork")
router := &Router{serverVersion: "v6.0.0-rc.1"}
got, err := router.installScriptReleaseAssetURL("install.sh")
if err != nil {
t.Fatalf("install script release asset URL: %v", err)
}
const want = "https://github.com/example/pulse-fork/releases/download/v6.0.0-rc.1/install.sh"
if got != want {
t.Fatalf("install script release asset URL = %q, want %q", got, want)
}
}
func TestContract_InstallScriptReleaseAssetURLRejectsUnreleasedBuild(t *testing.T) {
router := &Router{serverVersion: "dev"}
if _, err := router.installScriptReleaseAssetURL("install.sh"); err == nil {
t.Fatalf("expected development build to reject release asset lookup")
}
}
func TestContract_InstallScriptReleaseAssetURLRejectsDevPrereleaseBuild(t *testing.T) {
router := &Router{serverVersion: "v6.0.0-dev"}
if _, err := router.installScriptReleaseAssetURL("install.sh"); err == nil {
t.Fatalf("expected dev prerelease build to reject release asset lookup")
}
}
func TestContract_AgentBinaryReleaseAssetURL(t *testing.T) {
router := &Router{serverVersion: "v6.0.0-rc.1"}

View file

@ -44,16 +44,21 @@ func githubReleaseAssetURL(tag, assetName string) string {
}
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)
handleDownloadInstallScriptCommon(w, req, r.serverVersion, "/opt/pulse/scripts/install.sh", filepath.Join(r.projectRoot, "scripts", "install.sh"), "install.sh", "text/x-shellscript")
}
func (r *Router) handleDownloadUnifiedInstallScriptPS(w http.ResponseWriter, req *http.Request) {
handleDownloadInstallScriptCommon(w, req, r.serverVersion, "/opt/pulse/scripts/install.ps1", filepath.Join(r.projectRoot, "scripts", "install.ps1"), "install.ps1", "text/plain", r.proxyInstallScriptFromGitHub)
handleDownloadInstallScriptCommon(w, req, r.serverVersion, "/opt/pulse/scripts/install.ps1", filepath.Join(r.projectRoot, "scripts", "install.ps1"), "install.ps1", "text/plain")
}
type proxyFunc func(http.ResponseWriter, *http.Request, string)
func handleDownloadInstallScriptCommon(w http.ResponseWriter, req *http.Request, serverVersion, prodPath, fallbackPath, scriptName, contentType string, fallbackProxy proxyFunc) {
// handleDownloadInstallScriptCommon serves the locally bundled AGENT installer
// (install.sh / install.ps1). It deliberately has no GitHub fallback: the agent
// installer is a per-build artifact bundled into every release tarball and Docker
// image, NOT a release asset. The top-level GitHub install.sh asset is the SERVER
// installer, so proxying it here would hand the agent wizard a script that rejects
// --url/--token-file (issue #1470). If the local script is genuinely missing the
// install is broken; fail closed rather than serving a wrong-identity script.
func handleDownloadInstallScriptCommon(w http.ResponseWriter, req *http.Request, serverVersion, prodPath, fallbackPath, scriptName, contentType string) {
if req.Method != http.MethodGet && req.Method != http.MethodHead {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
@ -67,8 +72,8 @@ func handleDownloadInstallScriptCommon(w http.ResponseWriter, req *http.Request,
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
scriptPath = fallbackPath
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
log.Info().Msgf("Local %s not found, proxying from GitHub releases", scriptName)
fallbackProxy(w, req, scriptName)
log.Error().Str("script", scriptName).Msg("Bundled install script not found; the Pulse install is incomplete")
http.Error(w, "Install script unavailable: the bundled agent installer is missing from this Pulse install", http.StatusServiceUnavailable)
return
}
}
@ -423,7 +428,6 @@ func (r *Router) proxyAgentBinaryFromGitHub(w http.ResponseWriter, req *http.Req
}
const maxAgentBinarySize = 100 * 1024 * 1024
const maxInstallScriptSize = 512 * 1024
func readBinaryWithChecksum(body io.Reader) ([]byte, string, error) {
limitedReader := io.LimitReader(body, maxAgentBinarySize+1)
@ -654,10 +658,6 @@ func (r *Router) releaseAssetURL(assetName string) (string, error) {
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-") {
@ -665,87 +665,3 @@ func (r *Router) agentBinaryReleaseAssetURL(normalized string) (string, error) {
}
return r.releaseAssetURL(binaryName)
}
// proxyInstallScriptFromGitHub fetches an install script from the exact GitHub
// release asset that matches the running server version. This is used as a
// fallback when scripts aren't available locally (for example in LXC updates).
func (r *Router) proxyInstallScriptFromGitHub(w http.ResponseWriter, req *http.Request, scriptName string) {
githubURL, err := r.installScriptReleaseAssetURL(scriptName)
if err != nil {
log.Error().Err(err).Str("server_version", strings.TrimSpace(r.serverVersion)).Str("script", scriptName).Msg("Install script fallback unavailable for current server build")
http.Error(w, "Install script unavailable for current server build", http.StatusServiceUnavailable)
return
}
client := r.installScriptClient
if client == nil {
client = &http.Client{
Timeout: 30 * time.Second,
}
}
proxyReq, err := http.NewRequestWithContext(req.Context(), req.Method, githubURL, nil)
if err != nil {
log.Error().Err(err).Str("url", githubURL).Msg("Failed to create install script fallback request")
http.Error(w, "Failed to fetch install script", http.StatusInternalServerError)
return
}
resp, err := client.Do(proxyReq)
if err != nil {
log.Error().Err(err).Str("url", githubURL).Msg("Failed to fetch install script from GitHub")
http.Error(w, "Failed to fetch install script", http.StatusServiceUnavailable)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Error().Int("status", resp.StatusCode).Str("url", githubURL).Msg("GitHub returned non-200 status for install script")
http.Error(w, "Install script not found", http.StatusNotFound)
return
}
signatureContent, sigErr := fetchReleaseAssetContent(req.Context(), client, githubURL+".sig", 16*1024)
if sigErr != nil {
log.Error().Err(sigErr).Str("url", githubURL+".sig").Msg("Failed to fetch install script signature from GitHub")
http.Error(w, "Failed to fetch install script signature", http.StatusServiceUnavailable)
return
}
sshSignatureContent, sshSigErr := fetchReleaseAssetContent(req.Context(), client, githubURL+".sshsig", 64*1024)
if sshSigErr != nil {
log.Error().Err(sshSigErr).Str("url", githubURL+".sshsig").Msg("Failed to fetch install script SSH signature from GitHub")
http.Error(w, "Failed to fetch install script SSH signature", http.StatusServiceUnavailable)
return
}
// Read the script content with a hard cap so a bad upstream response cannot
// exhaust memory while the server is proxying release assets.
content, err := io.ReadAll(io.LimitReader(resp.Body, maxInstallScriptSize+1))
if err != nil {
log.Error().Err(err).Msg("Failed to read install script from GitHub")
http.Error(w, "Failed to read install script", http.StatusInternalServerError)
return
}
if len(content) > maxInstallScriptSize {
log.Error().Int("size", len(content)).Str("url", githubURL).Msg("Install script from GitHub exceeded size limit")
http.Error(w, "Install script exceeds size limit", http.StatusServiceUnavailable)
return
}
// Determine content type based on script extension
contentType := "text/x-shellscript"
if strings.HasSuffix(scriptName, ".ps1") {
contentType = "text/plain"
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", "inline; filename=\""+scriptName+"\"")
w.Header().Set("X-Served-From", "github-fallback")
w.Header().Set(signatureHeaderName, strings.TrimSpace(string(signatureContent)))
w.Header().Set(sshSignatureHeaderName, encodeSSHSignatureForHeader(sshSignatureContent))
if req.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
w.Write(content)
}

View file

@ -1,7 +1,6 @@
package api
import (
"errors"
"fmt"
"io"
"net/http"
@ -104,134 +103,49 @@ func TestDownloadUnifiedInstallScriptPS_MethodNotAllowed(t *testing.T) {
}
}
func TestDownloadUnifiedInstallScript_ProxyFallback(t *testing.T) {
func TestDownloadUnifiedInstallScript_NoLocalScriptFailsClosed(t *testing.T) {
router, _ := setupUnifiedAgentRouter(t)
router.serverVersion = "v6.0.0-rc.1"
expectedURL := "https://github.com/rcourtman/Pulse/releases/download/v6.0.0-rc.1/install.sh"
payload := "#!/bin/bash\necho hi"
router.installScriptClient = newTestInstallScriptClientSequence(t, []expectedHTTPExchange{
{Method: http.MethodGet, URL: expectedURL, Status: http.StatusOK, Body: payload},
{Method: http.MethodGet, URL: expectedURL + ".sig", Status: http.StatusOK, Body: "signed-install-script"},
{Method: http.MethodGet, URL: expectedURL + ".sshsig", Status: http.StatusOK, Body: "signed-install-script-ssh"},
})
router.serverVersion = "v6.0.0-rc.6"
// No agent installer is bundled. The endpoint must fail closed and must never
// proxy the GitHub install.sh release asset (the SERVER installer). Any outbound
// HTTP call from this endpoint is a regression of the issue #1470 root fix.
router.installScriptClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
t.Errorf("install-script endpoint must not make outbound calls; got %s %s", req.Method, req.URL)
return nil, fmt.Errorf("unexpected outbound call")
})}
req := httptest.NewRequest(http.MethodGet, "/install.sh", nil)
w := httptest.NewRecorder()
router.handleDownloadUnifiedInstallScript(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503, got %d", w.Code)
}
if got := w.Header().Get("X-Served-From"); got != "github-fallback" {
t.Fatalf("unexpected X-Served-From header: %q", got)
}
if got := w.Header().Get("Content-Type"); got != "text/x-shellscript" {
t.Fatalf("unexpected Content-Type: %q", got)
}
if got := w.Header().Get(signatureHeaderName); got != "signed-install-script" {
t.Fatalf("unexpected signature header: %q", got)
}
if got := w.Header().Get(sshSignatureHeaderName); got != encodedTestSSHSignature("signed-install-script-ssh") {
t.Fatalf("unexpected SSH signature header: %q", got)
}
if !strings.Contains(w.Header().Get("Content-Disposition"), "install.sh") {
t.Fatalf("missing Content-Disposition filename")
}
if strings.TrimSpace(w.Body.String()) != payload {
t.Fatalf("unexpected response body")
if got := w.Header().Get("X-Served-From"); got != "" {
t.Fatalf("endpoint must not proxy; X-Served-From = %q", got)
}
}
func TestDownloadUnifiedInstallScript_ProxyFallbackUsesConfiguredRepo(t *testing.T) {
t.Setenv("PULSE_GITHUB_REPO", "example/pulse-fork")
func TestDownloadUnifiedInstallScriptPS_NoLocalScriptFailsClosed(t *testing.T) {
router, _ := setupUnifiedAgentRouter(t)
router.serverVersion = "v6.0.0-rc.1"
expectedURL := "https://github.com/example/pulse-fork/releases/download/v6.0.0-rc.1/install.sh"
payload := "#!/bin/bash\necho hi"
router.installScriptClient = newTestInstallScriptClientSequence(t, []expectedHTTPExchange{
{Method: http.MethodGet, URL: expectedURL, Status: http.StatusOK, Body: payload},
{Method: http.MethodGet, URL: expectedURL + ".sig", Status: http.StatusOK, Body: "signed-install-script"},
{Method: http.MethodGet, URL: expectedURL + ".sshsig", Status: http.StatusOK, Body: "signed-install-script-ssh"},
})
req := httptest.NewRequest(http.MethodGet, "/install.sh", nil)
w := httptest.NewRecorder()
router.handleDownloadUnifiedInstallScript(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
}
func TestDownloadUnifiedInstallScriptPS_ProxyFallback(t *testing.T) {
router, _ := setupUnifiedAgentRouter(t)
router.serverVersion = "6.0.0"
expectedURL := "https://github.com/rcourtman/Pulse/releases/download/v6.0.0/install.ps1"
payload := "Write-Host 'hi'"
router.installScriptClient = newTestInstallScriptClientSequence(t, []expectedHTTPExchange{
{Method: http.MethodGet, URL: expectedURL, Status: http.StatusOK, Body: payload},
{Method: http.MethodGet, URL: expectedURL + ".sig", Status: http.StatusOK, Body: "signed-install-script"},
{Method: http.MethodGet, URL: expectedURL + ".sshsig", Status: http.StatusOK, Body: "signed-install-script-ssh"},
})
router.serverVersion = "v6.0.0"
router.installScriptClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
t.Errorf("install-script endpoint must not make outbound calls; got %s %s", req.Method, req.URL)
return nil, fmt.Errorf("unexpected outbound call")
})}
req := httptest.NewRequest(http.MethodGet, "/install.ps1", nil)
w := httptest.NewRecorder()
router.handleDownloadUnifiedInstallScriptPS(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if got := w.Header().Get("Content-Type"); got != "text/plain" {
t.Fatalf("unexpected Content-Type: %q", got)
}
if !strings.Contains(w.Header().Get("Content-Disposition"), "install.ps1") {
t.Fatalf("missing Content-Disposition filename")
}
}
func TestProxyInstallScriptFromGitHub_NonOK(t *testing.T) {
router := &Router{
serverVersion: "v6.0.0-rc.1",
installScriptClient: newTestInstallScriptClient(t, http.MethodGet, "", http.StatusNotFound, "", nil),
}
req := httptest.NewRequest(http.MethodGet, "/install.sh", nil)
w := httptest.NewRecorder()
router.proxyInstallScriptFromGitHub(w, req, "install.sh")
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
if !strings.Contains(w.Body.String(), "Install script not found") {
t.Fatalf("expected not found message")
}
}
func TestProxyInstallScriptFromGitHub_Error(t *testing.T) {
router := &Router{
serverVersion: "v6.0.0-rc.1",
installScriptClient: newTestInstallScriptClient(t, http.MethodGet, "", 0, "", errors.New("boom")),
}
req := httptest.NewRequest(http.MethodGet, "/install.sh", nil)
w := httptest.NewRecorder()
router.proxyInstallScriptFromGitHub(w, req, "install.sh")
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503, got %d", w.Code)
}
if !strings.Contains(w.Body.String(), "Failed to fetch install script") {
t.Fatalf("expected fetch failure message")
}
}
func TestDownloadUnifiedInstallScript_ProxyFallbackRejectsDevelopmentBuild(t *testing.T) {
func TestDownloadUnifiedInstallScript_NoLocalScriptFailsClosedForDevBuild(t *testing.T) {
router, _ := setupUnifiedAgentRouter(t)
router.serverVersion = "dev"
@ -243,54 +157,19 @@ func TestDownloadUnifiedInstallScript_ProxyFallbackRejectsDevelopmentBuild(t *te
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503, got %d", w.Code)
}
if !strings.Contains(w.Body.String(), "Install script unavailable for current server build") {
t.Fatalf("unexpected response body: %q", w.Body.String())
}
}
func TestDownloadUnifiedInstallScript_ProxyFallbackRejectsDevPrereleaseBuild(t *testing.T) {
func TestDownloadUnifiedInstallScript_NoLocalScriptFailsClosedHEAD(t *testing.T) {
router, _ := setupUnifiedAgentRouter(t)
router.serverVersion = "v6.0.0-dev"
req := httptest.NewRequest(http.MethodGet, "/install.sh", nil)
w := httptest.NewRecorder()
router.handleDownloadUnifiedInstallScript(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503, got %d", w.Code)
}
if !strings.Contains(w.Body.String(), "Install script unavailable for current server build") {
t.Fatalf("unexpected response body: %q", w.Body.String())
}
}
func TestDownloadUnifiedInstallScript_ProxyFallbackPreservesHEAD(t *testing.T) {
router, _ := setupUnifiedAgentRouter(t)
router.serverVersion = "v6.0.0-rc.1"
expectedURL := "https://github.com/rcourtman/Pulse/releases/download/v6.0.0-rc.1/install.sh"
router.installScriptClient = newTestInstallScriptClientSequence(t, []expectedHTTPExchange{
{Method: http.MethodHead, URL: expectedURL, Status: http.StatusOK, Body: ""},
{Method: http.MethodGet, URL: expectedURL + ".sig", Status: http.StatusOK, Body: "signed-install-script"},
{Method: http.MethodGet, URL: expectedURL + ".sshsig", Status: http.StatusOK, Body: "signed-install-script-ssh"},
})
router.serverVersion = "v6.0.0-rc.6"
req := httptest.NewRequest(http.MethodHead, "/install.sh", nil)
w := httptest.NewRecorder()
router.handleDownloadUnifiedInstallScript(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if w.Body.Len() != 0 {
t.Fatalf("expected empty HEAD response body")
}
if got := w.Header().Get(signatureHeaderName); got != "signed-install-script" {
t.Fatalf("unexpected signature header: %q", got)
}
if got := w.Header().Get(sshSignatureHeaderName); got != encodedTestSSHSignature("signed-install-script-ssh") {
t.Fatalf("unexpected SSH signature header: %q", got)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503, got %d", w.Code)
}
}