Files
Pulse/internal/api/unified_agent.go
rcourtman 502766b9b7 fix: proxy agent binary from GitHub instead of redirecting
When the server doesn't have agent binaries locally (common for
LXC/bare-metal installations), it was redirecting to GitHub releases.
The agent's HTTP client followed the redirect, but GitHub doesn't
provide the X-Checksum-Sha256 header that agents require for security
verification, causing every update attempt to fail silently.

Proxy the download through the server instead, computing and attaching
the checksum header so agents can verify and install the update.
2026-02-04 19:31:33 +00:00

298 lines
10 KiB
Go

package api
import (
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/rs/zerolog/log"
)
// handleDownloadUnifiedInstallScript serves the universal install.sh script
func (r *Router) handleDownloadUnifiedInstallScript(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")
scriptPath := "/opt/pulse/scripts/install.sh"
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
// Fallback to project root (dev environment)
scriptPath = filepath.Join(r.projectRoot, "scripts", "install.sh")
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
// Final fallback: proxy from GitHub releases
// This handles LXC/barebone installations updated via web UI where
// only the binary is updated, not the scripts directory
log.Info().Msg("Local install.sh not found, proxying from GitHub releases")
r.proxyInstallScriptFromGitHub(w, req, "install.sh")
return
}
}
w.Header().Set("Content-Type", "text/x-shellscript")
w.Header().Set("Content-Disposition", "inline; filename=\"install.sh\"")
http.ServeFile(w, req, scriptPath)
}
// handleDownloadUnifiedInstallScriptPS serves the universal install.ps1 script
func (r *Router) handleDownloadUnifiedInstallScriptPS(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")
scriptPath := "/opt/pulse/scripts/install.ps1"
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
// Fallback to project root (dev environment)
scriptPath = filepath.Join(r.projectRoot, "scripts", "install.ps1")
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
// Final fallback: proxy from GitHub releases
log.Info().Msg("Local install.ps1 not found, proxying from GitHub releases")
r.proxyInstallScriptFromGitHub(w, req, "install.ps1")
return
}
}
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("Content-Disposition", "inline; filename=\"install.ps1\"")
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 ""
}
}
// 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"),
)
}
for _, candidate := range searchPaths {
if candidate == "" {
continue
}
info, err := os.Stat(candidate)
if err != nil || info.IsDir() {
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
}
w.Header().Set("X-Checksum-Sha256", checksum)
http.ServeContent(w, req, filepath.Base(candidate), info.ModTime(), file)
file.Close()
return
}
// Fallback: proxy from GitHub releases for the binary
// This handles LXC/barebone installations that don't have agent binaries locally.
// We proxy instead of redirecting because agents require the X-Checksum-Sha256 header,
// which GitHub doesn't provide.
if normalized != "" {
r.proxyAgentBinaryFromGitHub(w, req, normalized)
return
}
// No architecture specified and no local binary - can't redirect without knowing arch
http.Error(w, "Agent binary not found. Specify ?arch=linux-amd64 (or your architecture)", http.StatusNotFound)
}
// 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) {
binaryName := "pulse-agent-" + normalized
if strings.HasPrefix(normalized, "windows-") {
binaryName += ".exe"
}
githubURL := "https://github.com/rcourtman/Pulse/releases/latest/download/" + binaryName
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 {
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
}
// Read the binary with size limit (100 MB)
const maxAgentBinarySize = 100 * 1024 * 1024
limitedReader := io.LimitReader(resp.Body, maxAgentBinarySize+1)
hasher := sha256.New()
content, err := io.ReadAll(io.TeeReader(limitedReader, hasher))
if err != nil {
log.Error().Err(err).Msg("Failed to read agent binary from GitHub")
http.Error(w, "Failed to read agent binary", http.StatusInternalServerError)
return
}
if int64(len(content)) > maxAgentBinarySize {
log.Error().Int64("size", int64(len(content))).Msg("Agent binary from GitHub exceeds size limit")
http.Error(w, "Agent binary too large", http.StatusInternalServerError)
return
}
checksum := hex.EncodeToString(hasher.Sum(nil))
w.Header().Set("X-Checksum-Sha256", checksum)
w.Header().Set("X-Served-From", "github-proxy")
w.Header().Set("Content-Type", "application/octet-stream")
w.Write(content)
}
// proxyInstallScriptFromGitHub fetches an install script from GitHub releases
// This is used as a fallback when scripts aren't available locally (e.g., LXC updates)
func (r *Router) proxyInstallScriptFromGitHub(w http.ResponseWriter, req *http.Request, scriptName string) {
// Use raw.githubusercontent.com to fetch from main branch
githubURL := "https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/" + scriptName
client := r.installScriptClient
if client == nil {
client = &http.Client{
Timeout: 30 * time.Second,
}
}
resp, err := client.Get(githubURL)
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
}
// Read the script content
content, err := io.ReadAll(resp.Body)
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
}
// 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.Write(content)
}