Pulse/internal/api/unified_agent.go
2026-04-14 19:22:12 +01:00

417 lines
14 KiB
Go

package api
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/updates"
"github.com/rs/zerolog/log"
)
const v5MaintenanceScriptRef = "release/5.1"
func releaseAssetTagFromServerVersion(serverVersion string) string {
version := strings.TrimSpace(serverVersion)
if version == "" || strings.EqualFold(version, "dev") {
return ""
}
parsed, err := updates.ParseVersion(version)
if err != nil {
return ""
}
if parsed.Major == 0 && parsed.Minor == 0 && parsed.Patch == 0 {
return ""
}
tag := fmt.Sprintf("v%d.%d.%d", parsed.Major, parsed.Minor, parsed.Patch)
if parsed.Prerelease != "" {
tag += "-" + parsed.Prerelease
}
return tag
}
func releaseAssetGitHubURL(assetName, serverVersion string) string {
tag := releaseAssetTagFromServerVersion(serverVersion)
if tag == "" {
return ""
}
return "https://github.com/rcourtman/Pulse/releases/download/" + tag + "/" + assetName
}
// 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 ""
}
}
func installScriptGitHubURL(scriptName, serverVersion string) string {
if releaseURL := releaseAssetGitHubURL(scriptName, serverVersion); releaseURL != "" {
return releaseURL
}
return "https://raw.githubusercontent.com/rcourtman/Pulse/" + v5MaintenanceScriptRef + "/scripts/" + scriptName
}
// 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 := releaseAssetGitHubURL(binaryName, r.serverVersion)
if githubURL == "" {
log.Error().Str("serverVersion", r.serverVersion).Msg("Cannot proxy agent binary without a released server version")
http.Error(w, "Agent binary unavailable for non-release server builds", http.StatusServiceUnavailable)
return
}
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)
}
// proxyHostAgentBinaryFromGitHub downloads a host-agent binary from GitHub releases and
// serves either the binary (with checksum header) or just the checksum body for .sha256 requests.
func (r *Router) proxyHostAgentBinaryFromGitHub(w http.ResponseWriter, req *http.Request, platform, arch string, checksumOnly bool) {
binaryName := "pulse-host-agent-" + platform + "-" + arch
if platform == "windows" {
binaryName += ".exe"
}
githubURL := releaseAssetGitHubURL(binaryName, r.serverVersion)
if githubURL == "" {
log.Error().Str("serverVersion", r.serverVersion).Msg("Cannot proxy host agent binary without a released server version")
http.Error(w, "Host agent binary unavailable for non-release server builds", http.StatusServiceUnavailable)
return
}
log.Info().
Str("platform", platform).
Str("arch", arch).
Str("url", githubURL).
Msg("Local host 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 host agent binary from GitHub")
http.Error(w, "Failed to fetch host 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 host agent binary")
http.Error(w, "Host agent binary not found on GitHub", http.StatusNotFound)
return
}
const maxHostAgentBinarySize = 100 * 1024 * 1024
limitedReader := io.LimitReader(resp.Body, maxHostAgentBinarySize+1)
hasher := sha256.New()
content, err := io.ReadAll(io.TeeReader(limitedReader, hasher))
if err != nil {
log.Error().Err(err).Msg("Failed to read host agent binary from GitHub")
http.Error(w, "Failed to read host agent binary", http.StatusInternalServerError)
return
}
if int64(len(content)) > maxHostAgentBinarySize {
log.Error().Int64("size", int64(len(content))).Msg("Host agent binary from GitHub exceeds size limit")
http.Error(w, "Host agent binary too large", http.StatusInternalServerError)
return
}
checksum := hex.EncodeToString(hasher.Sum(nil))
if checksumOnly {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("X-Served-From", "github-proxy")
_, _ = w.Write([]byte(checksum + "\n"))
return
}
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) {
githubURL := installScriptGitHubURL(scriptName, r.serverVersion)
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)
}