Files
pulse/internal/api/unified_agent.go
pulse-triage[bot] 78023e0e42 Serve signed Windows agents from canonical assets
Change-source: pulse-maintainer
2026-09-01 16:16:56 +01:00

996 lines
35 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/%s ./cmd/pulse-agent", strings.Join(env, " "), unifiedAgentLocalFilename(normalized))
}
func unifiedAgentLocalFilename(normalized string) string {
filename := "pulse-agent-" + normalized
if strings.HasPrefix(normalized, "windows-") {
return filename + ".exe"
}
return filename
}
func normalizeAgentHelperArch(arch string) string {
normalized := normalizeUnifiedAgentArch(arch)
if !strings.HasPrefix(normalized, "linux-") {
return ""
}
return normalized
}
func agentHelperLocalBuildCommand(normalized string) string {
goos, goarch, ok := strings.Cut(strings.TrimSpace(normalized), "-")
if !ok || goos != "linux" || 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-helper-%s ./cmd/pulse-agent-helper", strings.Join(env, " "), normalized)
}
func agentRunnerLocalBuildCommand(normalized string) string {
goos, goarch, ok := strings.Cut(strings.TrimSpace(normalized), "-")
if !ok || goos != "linux" || 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-runner-%s ./cmd/pulse-agent-runner", 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 != "" {
filename := unifiedAgentLocalFilename(normalized)
searchPaths = append(searchPaths,
filepath.Join(pulseBinDir(), filename),
filepath.Join("/opt/pulse", filename),
filepath.Join("/app", filename),
filepath.Join(r.projectRoot, "bin", filename),
)
} 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)
}
// handleDownloadAgentHelper serves the separately signed Linux privilege
// helper. The helper is never substituted with the networked collector binary
// and unsupported operating systems fail closed.
func (r *Router) handleDownloadAgentHelper(w http.ResponseWriter, req *http.Request) {
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")
archParam := strings.TrimSpace(req.URL.Query().Get("arch"))
normalized := normalizeAgentHelperArch(archParam)
if normalized == "" {
http.Error(w, "A supported Linux architecture is required", http.StatusBadRequest)
return
}
binaryName := "pulse-agent-helper-" + normalized
searchPaths := []string{
filepath.Join(pulseBinDir(), binaryName),
filepath.Join("/opt/pulse", binaryName),
filepath.Join("/app", binaryName),
filepath.Join(r.projectRoot, "bin", binaryName),
}
for _, candidate := range searchPaths {
info, err := os.Stat(candidate)
if err != nil || info.IsDir() {
continue
}
checksum, err := r.cachedSHA256(candidate, info)
if err != nil {
continue
}
signature, sigErr := readReleaseAssetSignature(candidate)
sshSignature, sshSigErr := readReleaseAssetSSHSignature(candidate)
if (sigErr != nil || sshSigErr != nil) && isPublishedReleaseAssetVersion(r.serverVersion) {
continue
}
file, err := os.Open(candidate)
if err != nil {
continue
}
defer file.Close()
w.Header().Set(checksumHeaderName, checksum)
if signature != "" {
w.Header().Set(signatureHeaderName, signature)
}
if sshSignature != "" {
w.Header().Set(sshSignatureHeaderName, sshSignature)
}
http.ServeContent(w, req, binaryName, info.ModTime(), file)
return
}
if !isPublishedReleaseAssetVersion(r.serverVersion) {
http.Error(w, "Agent helper binary not found for "+normalized+" in dev mode.\nBuild with:\n "+agentHelperLocalBuildCommand(normalized), http.StatusNotFound)
return
}
r.proxyAgentHelperFromGitHub(w, req, normalized)
}
func (r *Router) proxyAgentHelperFromGitHub(w http.ResponseWriter, req *http.Request, normalized string) {
assetName := "pulse-agent-helper-" + normalized
assetURL, err := r.releaseAssetURL(assetName)
if err != nil {
http.Error(w, "Agent helper binary unavailable for current server build", http.StatusServiceUnavailable)
return
}
client := r.installScriptClient
if client == nil {
client = &http.Client{Timeout: 5 * time.Minute}
}
response, err := client.Get(assetURL)
if err != nil {
http.Error(w, "Failed to fetch agent helper binary", http.StatusServiceUnavailable)
return
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
http.Error(w, "Agent helper binary not found on GitHub", http.StatusNotFound)
return
}
content, checksum, err := readBinaryWithChecksum(response.Body)
if err != nil {
http.Error(w, "Failed to read agent helper binary", http.StatusInternalServerError)
return
}
signature, err := fetchReleaseAssetContent(req.Context(), client, assetURL+".sig", 16*1024)
if err != nil {
http.Error(w, "Failed to fetch agent helper binary signature", http.StatusServiceUnavailable)
return
}
sshSignature, err := fetchReleaseAssetContent(req.Context(), client, assetURL+".sshsig", 64*1024)
if err != nil {
http.Error(w, "Failed to fetch agent helper binary SSH signature", http.StatusServiceUnavailable)
return
}
serveProxiedAgentBinaryWithSignatures(
w,
content,
checksum,
strings.TrimSpace(string(signature)),
encodeSSHSignatureForHeader(sshSignature),
"github-proxy",
)
}
// handleDownloadAgentRunner serves the separately signed Linux action runner.
// It is a distinct asset from both the monitoring collector and the local
// privilege helper so installer profiles cannot substitute one authority for
// another.
func (r *Router) handleDownloadAgentRunner(w http.ResponseWriter, req *http.Request) {
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")
normalized := normalizeAgentHelperArch(strings.TrimSpace(req.URL.Query().Get("arch")))
if normalized == "" {
http.Error(w, "A supported Linux architecture is required", http.StatusBadRequest)
return
}
binaryName := "pulse-agent-runner-" + normalized
searchPaths := []string{
filepath.Join(pulseBinDir(), binaryName),
filepath.Join("/opt/pulse", binaryName),
filepath.Join("/app", binaryName),
filepath.Join(r.projectRoot, "bin", binaryName),
}
for _, candidate := range searchPaths {
info, err := os.Stat(candidate)
if err != nil || info.IsDir() {
continue
}
checksum, err := r.cachedSHA256(candidate, info)
if err != nil {
continue
}
signature, sigErr := readReleaseAssetSignature(candidate)
sshSignature, sshSigErr := readReleaseAssetSSHSignature(candidate)
if (sigErr != nil || sshSigErr != nil) && isPublishedReleaseAssetVersion(r.serverVersion) {
continue
}
file, err := os.Open(candidate)
if err != nil {
continue
}
defer file.Close()
w.Header().Set(checksumHeaderName, checksum)
if signature != "" {
w.Header().Set(signatureHeaderName, signature)
}
if sshSignature != "" {
w.Header().Set(sshSignatureHeaderName, sshSignature)
}
http.ServeContent(w, req, binaryName, info.ModTime(), file)
return
}
if !isPublishedReleaseAssetVersion(r.serverVersion) {
http.Error(w, "Agent runner binary not found for "+normalized+" in dev mode.\nBuild with:\n "+agentRunnerLocalBuildCommand(normalized), http.StatusNotFound)
return
}
r.proxyAgentRunnerFromGitHub(w, req, normalized)
}
func (r *Router) proxyAgentRunnerFromGitHub(w http.ResponseWriter, req *http.Request, normalized string) {
assetName := "pulse-agent-runner-" + normalized
assetURL, err := r.releaseAssetURL(assetName)
if err != nil {
http.Error(w, "Agent runner binary unavailable for current server build", http.StatusServiceUnavailable)
return
}
client := r.installScriptClient
if client == nil {
client = &http.Client{Timeout: 5 * time.Minute}
}
response, err := client.Get(assetURL)
if err != nil {
http.Error(w, "Failed to fetch agent runner binary", http.StatusServiceUnavailable)
return
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
http.Error(w, "Agent runner binary not found on GitHub", http.StatusNotFound)
return
}
content, checksum, err := readBinaryWithChecksum(response.Body)
if err != nil {
http.Error(w, "Failed to read agent runner binary", http.StatusInternalServerError)
return
}
signature, err := fetchReleaseAssetContent(req.Context(), client, assetURL+".sig", 16*1024)
if err != nil {
http.Error(w, "Failed to fetch agent runner binary signature", http.StatusServiceUnavailable)
return
}
sshSignature, err := fetchReleaseAssetContent(req.Context(), client, assetURL+".sshsig", 64*1024)
if err != nil {
http.Error(w, "Failed to fetch agent runner binary SSH signature", http.StatusServiceUnavailable)
return
}
serveProxiedAgentBinaryWithSignatures(w, content, checksum, strings.TrimSpace(string(signature)), encodeSSHSignatureForHeader(sshSignature), "github-proxy")
}
// 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)
}