Files
pulse/internal/api/unified_agent.go
T
rcourtman 883e951023 fix(agent): refuse to serve an agent binary older than the server
/download/pulse-agent served whatever agent artifact sat on disk with no
relation to the running build. Local agent binaries are build outputs that
nothing refreshes on their own, so they go stale silently: a dev backend was
found serving a four-week-old v6.0.5 agent while reporting 6.2.0-rc.8.

Staleness is not cosmetic. The installer renders its service wrapper from the
server's current template, so an agent predating a flag that template now
passes exits immediately with "flag provided but not defined" and crash-loops
under its watchdog. That is how a real host lost its agent: the version
mismatch was reported only as an installer warning, after download, easy to
read as noise.

Validation already scanned the binary for its report-contract endpoints, so
the version check joins that same single pass and rejects a binary that does
not carry this server's agent version. Refusal is loud where the old warning
was quiet: a dev server answers 404 naming the stale path and the build
command, and a published release falls through to the existing release-asset
proxy and fetches the matching version, which makes production self-healing
rather than silently downgraded.

The expected version resolves through updates.GetCurrentVersion rather than
the compiled-in serverVersion. The first cut of this guard used serverVersion
and was inert on exactly the builds that need it: the enterprise binary
compiles in "dev-pro", no version parser accepts it, and the check disabled
itself. It passed its unit tests and still served the stale binary; only
replaying the real v6.0.5 artifact through the running server exposed it.
"dev-pro" is now pinned in the version table with that reasoning attached.

The shared download-test fixture built a binary carrying the report endpoint
but no version string, which a real agent always has, so it now stamps the
expected version. Verified non-vacuous in both directions: those tests fail
with the guard active and the thin fixture, and pass with a faithful one.

Contract-Neutral: storage-recovery is pulled in only by the broad internal/api/ Extension Points prefix and this change does not move that boundary: it constrains which agent binary /download/pulse-agent serves, touching no storage provider, backup target, recovery repository or protected-workload evidence. The agent-lifecycle, deployment-installability and api-contracts deltas staged here cover every boundary the change actually moves.
2026-08-06 15:27:39 +01:00

732 lines
26 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"),
)
}
expectedAgentVersion := expectedAgentBinaryVersion(r.resolveAgentBinaryVersionSource())
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, expectedAgentVersion); 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)
}
// validateUnifiedAgentBinary rejects a local agent binary that this server must
// not hand out. The endpoint checks catch a binary built against a superseded
// report contract; the version check catches one that is merely old.
//
// Staleness is not cosmetic. The installer generates its wrapper from this
// server's current template, so a binary predating a flag that template now
// passes fails to start and crash-loops under the watchdog. `bin/` is a
// gitignored build output that nothing refreshes on its own, so it goes stale
// silently. Refusing here turns that into a loud failure at the point of
// download: a dev server answers 404 with the build command, and a published
// release falls through to the GitHub proxy and fetches the correct version.
func validateUnifiedAgentBinary(path string, expectedVersion string) error {
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("open binary: %w", err)
}
defer file.Close()
hasCanonical, hasLegacy, hasVersion, err := scanUnifiedAgentBinaryContract(file, expectedVersion)
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)
}
if !hasVersion {
return fmt.Errorf("stale build: does not carry this server's agent version %s", expectedVersion)
}
return nil
}
// resolveAgentBinaryVersionSource picks the version this server should expect
// its agent binaries to carry.
//
// It deliberately prefers updates.GetCurrentVersion over the compiled-in
// serverVersion. A development server is exactly where agent binaries go stale,
// and there the compiled-in value is a placeholder ("dev", "dev-pro") that no
// version parser accepts, which would silently disable the freshness check on
// the only builds that need it. GetCurrentVersion resolves the VERSION file,
// the same source `make build-agents` stamps into the agent, so dev and release
// builds compare against one identity. IsDevelopment is intentionally not
// consulted: a dev build still knows which agent it expects.
func (r *Router) resolveAgentBinaryVersionSource() string {
if versionInfo, err := updates.GetCurrentVersion(); err == nil && versionInfo != nil {
if resolved := strings.TrimSpace(versionInfo.Version); resolved != "" {
return resolved
}
}
return r.serverVersion
}
// expectedAgentBinaryVersion reduces a server version to the release-identity
// string that `make build-agents` stamps into the agent through
// -X main.Version, i.e. "v6.2.0-rc.8" for a server reporting
// "6.2.0-rc.8+git.44.gdd72bd149.dirty". Build metadata is dropped because it
// records how the server itself was built and never appears in the agent.
// Returns "" when the version cannot be parsed, which disables the freshness
// check rather than rejecting every candidate.
func expectedAgentBinaryVersion(rawVersion string) string {
rawVersion = strings.TrimSpace(rawVersion)
if rawVersion == "" || strings.EqualFold(rawVersion, "dev") {
return ""
}
version, err := updates.ParseVersion(rawVersion)
if err != nil {
return ""
}
identity := fmt.Sprintf("v%d.%d.%d", version.Major, version.Minor, version.Patch)
if prerelease := strings.TrimSpace(version.Prerelease); prerelease != "" {
identity += "-" + prerelease
}
return identity
}
func scanUnifiedAgentBinaryContract(r io.Reader, versionNeedle string) (hasCanonical bool, hasLegacy bool, hasVersion bool, err error) {
canonicalNeedle := []byte(canonicalUnifiedAgentReportPath)
legacyNeedle := []byte(legacyUnifiedAgentReportPath)
versionBytes := []byte(versionNeedle)
// An empty needle would match everything, so treat "no expected version"
// as "already satisfied" and skip the comparison entirely.
hasVersion = len(versionBytes) == 0
maxNeedleLen := len(canonicalNeedle)
if len(legacyNeedle) > maxNeedleLen {
maxNeedleLen = len(legacyNeedle)
}
if len(versionBytes) > maxNeedleLen {
maxNeedleLen = len(versionBytes)
}
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 !hasVersion && bytes.Contains(window, versionBytes) {
hasVersion = true
}
if hasCanonical && hasLegacy && hasVersion {
return true, 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, false, readErr
}
}
return hasCanonical, hasLegacy, hasVersion, 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 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)
}