Files
pulse/internal/api/unified_agent.go
T
rcourtman a3e36d787f 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.
2026-05-29 14:51:18 +01:00

668 lines
23 KiB
Go

package api
import (
"archive/tar"
"archive/zip"
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/updates"
"github.com/rs/zerolog/log"
)
const (
canonicalUnifiedAgentReportPath = "/api/agents/agent/report"
legacyUnifiedAgentReportPath = "/api/agents/host/report"
defaultInstallScriptReleaseRepo = "rcourtman/Pulse"
checksumHeaderName = "X-Checksum-Sha256"
signatureHeaderName = "X-Signature-Ed25519"
sshSignatureHeaderName = "X-Signature-SSHSIG"
)
func installScriptReleaseRepo() string {
repo := strings.TrimSpace(os.Getenv("PULSE_GITHUB_REPO"))
if repo == "" {
return defaultInstallScriptReleaseRepo
}
return repo
}
func githubReleaseAssetURL(tag, assetName string) string {
return fmt.Sprintf("https://github.com/%s/releases/download/%s/%s", installScriptReleaseRepo(), strings.TrimSpace(tag), assetName)
}
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")
}
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")
}
// 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
}
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
scriptPath := prodPath
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
scriptPath = fallbackPath
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
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
}
}
signature, sigErr := readReleaseAssetSignature(scriptPath)
sshSignature, sshSigErr := readReleaseAssetSSHSignature(scriptPath)
if (sigErr != nil || sshSigErr != nil) && isPublishedReleaseAssetVersion(serverVersion) {
// The local agent installer is present but its detached signatures are not
// (e.g. an install from before the installer deployed the sidecars). Serve
// the local AGENT installer anyway; do NOT proxy the GitHub install.sh
// release asset, which is the SERVER installer (rejects the wizard's
// --url/--token-file). The served /install.sh endpoint must only ever hand
// out the agent installer. Nothing on the agent install path verifies these
// headers (the wizard is `curl ... | bash`), so omitting them is safe; new
// installs ship the sidecars and are served signed. See issue #1470.
log.Warn().Err(errors.Join(sigErr, sshSigErr)).Str("path", scriptPath).Msg("Serving local install script without release signatures; sidecars not deployed")
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", "inline; filename=\""+scriptName+"\"")
if signature != "" {
w.Header().Set(signatureHeaderName, signature)
}
if sshSignature != "" {
w.Header().Set(sshSignatureHeaderName, sshSignature)
}
http.ServeFile(w, req, scriptPath)
}
// normalizeUnifiedAgentArch normalizes architecture strings for the unified agent.
func normalizeUnifiedAgentArch(arch string) string {
arch = strings.ToLower(strings.TrimSpace(arch))
switch arch {
case "linux-amd64", "amd64", "x86_64":
return "linux-amd64"
case "linux-arm64", "arm64", "aarch64":
return "linux-arm64"
case "linux-armv7", "armv7", "armv7l", "armhf":
return "linux-armv7"
case "linux-armv6", "armv6":
return "linux-armv6"
case "linux-386", "386", "i386", "i686":
return "linux-386"
case "darwin-amd64", "macos-amd64":
return "darwin-amd64"
case "darwin-arm64", "macos-arm64":
return "darwin-arm64"
case "freebsd-amd64":
return "freebsd-amd64"
case "freebsd-arm64":
return "freebsd-arm64"
case "windows-amd64":
return "windows-amd64"
case "windows-arm64":
return "windows-arm64"
case "windows-386":
return "windows-386"
default:
return ""
}
}
func unifiedAgentLocalBuildCommand(normalized string) string {
goos, goarch, ok := strings.Cut(strings.TrimSpace(normalized), "-")
if !ok || goos == "" || goarch == "" {
goos = "linux"
goarch = "amd64"
normalized = "linux-amd64"
}
env := []string{"CGO_ENABLED=0", "GOOS=" + goos}
switch goarch {
case "armv7":
env = append(env, "GOARCH=arm", "GOARM=7")
case "armv6":
env = append(env, "GOARCH=arm", "GOARM=6")
default:
env = append(env, "GOARCH="+goarch)
}
return fmt.Sprintf("%s go build -o bin/pulse-agent-%s ./cmd/pulse-agent", strings.Join(env, " "), normalized)
}
// handleDownloadUnifiedAgent serves the pulse-agent binary
func (r *Router) handleDownloadUnifiedAgent(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet && req.Method != http.MethodHead {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Prevent caching - always serve the latest version
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
archParam := strings.TrimSpace(req.URL.Query().Get("arch"))
// Validate architecture if provided
if archParam != "" && normalizeUnifiedAgentArch(archParam) == "" {
http.Error(w, "Invalid architecture specified", http.StatusBadRequest)
return
}
searchPaths := make([]string, 0, 6)
// If a specific architecture is requested, only look for that architecture
// Do NOT fall back to generic binary - that could serve the wrong architecture
normalized := normalizeUnifiedAgentArch(archParam)
if normalized != "" {
searchPaths = append(searchPaths,
filepath.Join(pulseBinDir(), "pulse-agent-"+normalized),
filepath.Join("/opt/pulse", "pulse-agent-"+normalized),
filepath.Join("/app", "pulse-agent-"+normalized),
filepath.Join(r.projectRoot, "bin", "pulse-agent-"+normalized),
)
} else {
// No specific architecture requested - allow fallback to generic binary
searchPaths = append(searchPaths,
filepath.Join(pulseBinDir(), "pulse-agent"),
"/opt/pulse/pulse-agent",
filepath.Join("/app", "pulse-agent"),
filepath.Join(r.projectRoot, "bin", "pulse-agent"),
)
}
invalidCandidates := make([]string, 0, len(searchPaths))
for _, candidate := range searchPaths {
if candidate == "" {
continue
}
info, err := os.Stat(candidate)
if err != nil || info.IsDir() {
continue
}
if err := validateUnifiedAgentBinary(candidate); err != nil {
log.Warn().Err(err).Str("path", candidate).Msg("Skipping incompatible local unified agent binary")
invalidCandidates = append(invalidCandidates, fmt.Sprintf("%s (%v)", candidate, err))
continue
}
checksum, err := r.cachedSHA256(candidate, info)
if err != nil {
log.Error().Err(err).Str("path", candidate).Msg("Failed to compute unified agent checksum")
continue
}
file, err := os.Open(candidate)
if err != nil {
log.Error().Err(err).Str("path", candidate).Msg("Failed to open unified agent binary for download")
continue
}
defer file.Close()
signature, sigErr := readReleaseAssetSignature(candidate)
sshSignature, sshSigErr := readReleaseAssetSSHSignature(candidate)
if (sigErr != nil || sshSigErr != nil) && isPublishedReleaseAssetVersion(r.serverVersion) {
log.Warn().Err(errors.Join(sigErr, sshSigErr)).Str("path", candidate).Msg("Skipping unsigned local unified agent binary")
invalidCandidates = append(invalidCandidates, fmt.Sprintf("%s (%v)", candidate, errors.Join(sigErr, sshSigErr)))
continue
}
w.Header().Set(checksumHeaderName, checksum)
if signature != "" {
w.Header().Set(signatureHeaderName, signature)
}
if sshSignature != "" {
w.Header().Set(sshSignatureHeaderName, sshSignature)
}
http.ServeContent(w, req, filepath.Base(candidate), info.ModTime(), file)
return
}
if len(invalidCandidates) > 0 {
log.Warn().Strs("paths", invalidCandidates).Msg("Ignoring stale local unified agent binaries")
}
// 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 "+unifiedAgentLocalBuildCommand(normalized), http.StatusNotFound)
return
}
r.proxyAgentBinaryFromGitHub(w, req, normalized)
return
}
// No architecture specified and no local binary - can't redirect without knowing arch
if len(invalidCandidates) > 0 {
http.Error(w, "Local agent binary is stale or incompatible. Specify ?arch=linux-amd64 (or your architecture) after rebuilding the local agent artifact.", http.StatusNotFound)
return
}
http.Error(w, "Agent binary not found. Specify ?arch=linux-amd64 (or your architecture)", http.StatusNotFound)
}
func validateUnifiedAgentBinary(path string) error {
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("open binary: %w", err)
}
defer file.Close()
hasCanonical, hasLegacy, err := scanUnifiedAgentBinaryContract(file)
if err != nil {
return fmt.Errorf("scan binary contract: %w", err)
}
if hasLegacy {
return fmt.Errorf("references deprecated host endpoint %s", legacyUnifiedAgentReportPath)
}
if !hasCanonical {
return fmt.Errorf("missing canonical host endpoint %s", canonicalUnifiedAgentReportPath)
}
return nil
}
func scanUnifiedAgentBinaryContract(r io.Reader) (hasCanonical bool, hasLegacy bool, err error) {
canonicalNeedle := []byte(canonicalUnifiedAgentReportPath)
legacyNeedle := []byte(legacyUnifiedAgentReportPath)
maxNeedleLen := len(canonicalNeedle)
if len(legacyNeedle) > maxNeedleLen {
maxNeedleLen = len(legacyNeedle)
}
overlap := maxNeedleLen - 1
if overlap < 0 {
overlap = 0
}
buf := make([]byte, 64*1024)
window := make([]byte, 0, len(buf)+overlap)
for {
n, readErr := r.Read(buf)
if n > 0 {
window = append(window, buf[:n]...)
if bytes.Contains(window, canonicalNeedle) {
hasCanonical = true
}
if bytes.Contains(window, legacyNeedle) {
hasLegacy = true
}
if hasCanonical && hasLegacy {
return true, true, nil
}
if len(window) > overlap {
window = append(window[:0], window[len(window)-overlap:]...)
}
}
if readErr == io.EOF {
break
}
if readErr != nil {
return false, false, readErr
}
}
return hasCanonical, hasLegacy, nil
}
// proxyAgentBinaryFromGitHub downloads an agent binary from GitHub releases and serves
// it to the requesting agent with the X-Checksum-Sha256 header. This is used when the
// binary isn't available locally (e.g., LXC/bare-metal installations updated via web UI).
// 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) {
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
}
signatureURL := githubURL + ".sig"
sshSignatureURL := githubURL + ".sshsig"
log.Info().Str("arch", normalized).Str("url", githubURL).Msg("Local agent binary not found, proxying from GitHub releases")
client := r.installScriptClient
if client == nil {
client = &http.Client{
Timeout: 5 * time.Minute,
}
}
resp, err := client.Get(githubURL)
if err != nil {
log.Error().Err(err).Str("url", githubURL).Msg("Failed to fetch agent binary from GitHub")
http.Error(w, "Failed to fetch agent binary", http.StatusServiceUnavailable)
return
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
content, checksum, readErr := readBinaryWithChecksum(resp.Body)
if readErr != nil {
log.Error().Err(readErr).Msg("Failed to read agent binary from GitHub")
http.Error(w, "Failed to read agent binary", http.StatusInternalServerError)
return
}
signature, sigErr := fetchReleaseAssetContent(req.Context(), client, signatureURL, 16*1024)
if sigErr != nil {
log.Error().Err(sigErr).Str("url", signatureURL).Msg("Failed to fetch agent binary signature from GitHub")
http.Error(w, "Failed to fetch agent binary signature", http.StatusServiceUnavailable)
return
}
sshSignature, sshSigErr := fetchReleaseAssetContent(req.Context(), client, sshSignatureURL, 64*1024)
if sshSigErr != nil {
log.Error().Err(sshSigErr).Str("url", sshSignatureURL).Msg("Failed to fetch agent binary SSH signature from GitHub")
http.Error(w, "Failed to fetch agent binary SSH signature", http.StatusServiceUnavailable)
return
}
serveProxiedAgentBinaryWithSignatures(w, content, checksum, strings.TrimSpace(string(signature)), encodeSSHSignatureForHeader(sshSignature), "github-proxy")
return
}
if resp.StatusCode != http.StatusNotFound {
log.Error().Int("status", resp.StatusCode).Str("url", githubURL).Msg("GitHub returned non-200 status for agent binary")
http.Error(w, "Agent binary not found on GitHub", http.StatusNotFound)
return
}
archiveContent, checksum, archiveErr := r.fetchAgentBinaryFromReleaseArchive(client, normalized)
if archiveErr != nil {
log.Error().Err(archiveErr).Str("arch", normalized).Msg("Failed archive fallback for agent binary")
http.Error(w, "Agent binary not found on GitHub", http.StatusNotFound)
return
}
signature, sigErr := fetchReleaseAssetContent(req.Context(), client, signatureURL, 16*1024)
if sigErr != nil {
log.Error().Err(sigErr).Str("url", signatureURL).Msg("Failed to fetch agent binary signature from GitHub")
http.Error(w, "Failed to fetch agent binary signature", http.StatusServiceUnavailable)
return
}
sshSignature, sshSigErr := fetchReleaseAssetContent(req.Context(), client, sshSignatureURL, 64*1024)
if sshSigErr != nil {
log.Error().Err(sshSigErr).Str("url", sshSignatureURL).Msg("Failed to fetch agent binary SSH signature from GitHub")
http.Error(w, "Failed to fetch agent binary SSH signature", http.StatusServiceUnavailable)
return
}
serveProxiedAgentBinaryWithSignatures(w, archiveContent, checksum, strings.TrimSpace(string(signature)), encodeSSHSignatureForHeader(sshSignature), "github-proxy-archive")
}
const maxAgentBinarySize = 100 * 1024 * 1024
func readBinaryWithChecksum(body io.Reader) ([]byte, string, error) {
limitedReader := io.LimitReader(body, maxAgentBinarySize+1)
hasher := sha256.New()
content, err := io.ReadAll(io.TeeReader(limitedReader, hasher))
if err != nil {
return nil, "", err
}
if int64(len(content)) > maxAgentBinarySize {
return nil, "", fmt.Errorf("binary exceeds size limit")
}
return content, hex.EncodeToString(hasher.Sum(nil)), nil
}
func serveProxiedAgentBinary(w http.ResponseWriter, content []byte, checksum, servedFrom string) {
serveProxiedAgentBinaryWithSignatures(w, content, checksum, "", "", servedFrom)
}
func serveProxiedAgentBinaryWithSignatures(w http.ResponseWriter, content []byte, checksum, signature, sshSignature, servedFrom string) {
w.Header().Set(checksumHeaderName, checksum)
if strings.TrimSpace(signature) != "" {
w.Header().Set(signatureHeaderName, strings.TrimSpace(signature))
}
if strings.TrimSpace(sshSignature) != "" {
w.Header().Set(sshSignatureHeaderName, strings.TrimSpace(sshSignature))
}
w.Header().Set("X-Served-From", servedFrom)
w.Header().Set("Content-Type", "application/octet-stream")
w.Write(content)
}
func (r *Router) fetchAgentBinaryFromReleaseArchive(client *http.Client, normalized string) ([]byte, string, error) {
tag, err := r.releaseAssetTag()
if err != nil {
return nil, "", err
}
version := strings.TrimPrefix(tag, "v")
archiveName := fmt.Sprintf("pulse-agent-v%s-%s.tar.gz", version, normalized)
entryName := "pulse-agent-" + normalized
isWindows := strings.HasPrefix(normalized, "windows-")
if isWindows {
archiveName = fmt.Sprintf("pulse-agent-v%s-%s.zip", version, normalized)
entryName += ".exe"
}
archiveURL := githubReleaseAssetURL(tag, archiveName)
resp, err := client.Get(archiveURL)
if err != nil {
return nil, "", fmt.Errorf("failed to fetch release archive: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, "", fmt.Errorf("release archive returned status %d", resp.StatusCode)
}
archiveReader := io.LimitReader(resp.Body, maxAgentBinarySize+1)
archiveBytes, err := io.ReadAll(archiveReader)
if err != nil {
return nil, "", fmt.Errorf("failed reading release archive: %w", err)
}
if int64(len(archiveBytes)) > maxAgentBinarySize {
return nil, "", fmt.Errorf("release archive exceeded size limit")
}
var binary []byte
if isWindows {
binary, err = extractFromZip(archiveBytes, entryName)
} else {
binary, err = extractFromTarGz(archiveBytes, entryName)
}
if err != nil {
return nil, "", err
}
if int64(len(binary)) > maxAgentBinarySize {
return nil, "", fmt.Errorf("extracted binary exceeded size limit")
}
sum := sha256.Sum256(binary)
return binary, hex.EncodeToString(sum[:]), nil
}
func isPublishedReleaseAssetVersion(rawVersion string) bool {
rawVersion = strings.TrimSpace(rawVersion)
if rawVersion == "" || strings.EqualFold(rawVersion, "dev") {
return false
}
version, err := updates.ParseVersion(rawVersion)
if err != nil {
return false
}
return version.IsPublishedReleaseAssetVersion()
}
func readReleaseAssetSignature(path string) (string, error) {
signaturePath := path + ".sig"
data, err := os.ReadFile(signaturePath)
if err != nil {
return "", fmt.Errorf("read release signature %s: %w", signaturePath, err)
}
signature := strings.TrimSpace(string(data))
if signature == "" {
return "", fmt.Errorf("release signature %s is empty", signaturePath)
}
return signature, nil
}
func readReleaseAssetSSHSignature(path string) (string, error) {
signaturePath := path + ".sshsig"
data, err := os.ReadFile(signaturePath)
if err != nil {
return "", fmt.Errorf("read release ssh signature %s: %w", signaturePath, err)
}
if len(bytes.TrimSpace(data)) == 0 {
return "", fmt.Errorf("release ssh signature %s is empty", signaturePath)
}
return encodeSSHSignatureForHeader(data), nil
}
func encodeSSHSignatureForHeader(data []byte) string {
return base64.StdEncoding.EncodeToString(data)
}
func fetchReleaseAssetContent(ctx context.Context, client *http.Client, url string, limit int64) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("create release asset request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("release asset returned status %d", resp.StatusCode)
}
content, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
if err != nil {
return nil, err
}
if int64(len(content)) > limit {
return nil, fmt.Errorf("release asset exceeded size limit")
}
return content, nil
}
func extractFromTarGz(archive []byte, entryName string) ([]byte, error) {
gzReader, err := gzip.NewReader(bytes.NewReader(archive))
if err != nil {
return nil, fmt.Errorf("failed to open tar.gz: %w", err)
}
defer gzReader.Close()
tr := tar.NewReader(gzReader)
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("failed reading tar entry: %w", err)
}
if filepath.Base(header.Name) != entryName {
continue
}
content, err := io.ReadAll(io.LimitReader(tr, maxAgentBinarySize+1))
if err != nil {
return nil, fmt.Errorf("failed reading binary from tar.gz: %w", err)
}
if int64(len(content)) > maxAgentBinarySize {
return nil, fmt.Errorf("binary from tar.gz exceeded size limit")
}
return content, nil
}
return nil, fmt.Errorf("binary %q not found in tar.gz", entryName)
}
func extractFromZip(archive []byte, entryName string) ([]byte, error) {
zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive)))
if err != nil {
return nil, fmt.Errorf("failed to open zip: %w", err)
}
for _, file := range zr.File {
if filepath.Base(file.Name) != entryName {
continue
}
rc, err := file.Open()
if err != nil {
return nil, fmt.Errorf("failed opening binary in zip: %w", err)
}
content, readErr := io.ReadAll(io.LimitReader(rc, maxAgentBinarySize+1))
rc.Close()
if readErr != nil {
return nil, fmt.Errorf("failed reading binary from zip: %w", readErr)
}
if int64(len(content)) > maxAgentBinarySize {
return nil, fmt.Errorf("binary from zip exceeded size limit")
}
return content, nil
}
return nil, fmt.Errorf("binary %q not found in zip", entryName)
}
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 assets")
}
version, err := updates.ParseVersion(rawVersion)
if err != nil {
return "", fmt.Errorf("server version %q is not a published release version", rawVersion)
}
if !version.IsPublishedReleaseAssetVersion() {
return "", fmt.Errorf("server version %q is not a published release asset version", rawVersion)
}
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) agentBinaryReleaseAssetURL(normalized string) (string, error) {
binaryName := "pulse-agent-" + normalized
if strings.HasPrefix(normalized, "windows-") {
binaryName += ".exe"
}
return r.releaseAssetURL(binaryName)
}