Files
Pulse/internal/api/host_agents.go
rcourtman cf26ed7f12 security: Add request body size limits to remaining API handlers
Add http.MaxBytesReader to 8 additional handlers to complete API
hardening against memory exhaustion attacks:

- guest_metadata.go: HandleUpdateMetadata (16KB)
- notification_queue.go: RetryDLQItem, DeleteDLQItem (8KB each)
- temperature_proxy.go: HandleRegister (8KB)
- host_agents.go: HandleReport (256KB)
- updates.go: HandleApplyUpdate (8KB)
- docker_metadata.go: HandleUpdateMetadata (16KB)
- system_settings.go: UpdateSystemSettings (64KB)

All API handlers that decode JSON request bodies now have size limits.
2025-12-02 16:47:13 +00:00

213 lines
6.0 KiB
Go

package api
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
"github.com/rcourtman/pulse-go-rewrite/internal/websocket"
agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
"github.com/rs/zerolog/log"
)
// HostAgentHandlers manages ingest from the pulse-host-agent.
type HostAgentHandlers struct {
monitor *monitoring.Monitor
wsHub *websocket.Hub
}
// NewHostAgentHandlers constructs a new handler set for host agents.
func NewHostAgentHandlers(m *monitoring.Monitor, hub *websocket.Hub) *HostAgentHandlers {
return &HostAgentHandlers{monitor: m, wsHub: hub}
}
// SetMonitor updates the monitor reference for host agent handlers.
func (h *HostAgentHandlers) SetMonitor(m *monitoring.Monitor) {
h.monitor = m
}
// HandleReport ingests host agent reports.
func (h *HostAgentHandlers) HandleReport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only POST is allowed", nil)
return
}
// Limit request body to 256KB to prevent memory exhaustion
r.Body = http.MaxBytesReader(w, r.Body, 256*1024)
defer r.Body.Close()
var report agentshost.Report
if err := json.NewDecoder(r.Body).Decode(&report); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_json", "Failed to decode request body", map[string]string{"error": err.Error()})
return
}
if report.Timestamp.IsZero() {
report.Timestamp = time.Now().UTC()
}
tokenRecord := getAPITokenRecordFromRequest(r)
host, err := h.monitor.ApplyHostReport(report, tokenRecord)
if err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_report", err.Error(), nil)
return
}
log.Debug().
Str("hostId", host.ID).
Str("hostname", host.Hostname).
Str("platform", host.Platform).
Msg("Host agent report processed")
go h.wsHub.BroadcastState(h.monitor.GetState().ToFrontend())
resp := map[string]any{
"success": true,
"hostId": host.ID,
"lastSeen": host.LastSeen,
"platform": host.Platform,
"osName": host.OSName,
"osVersion": host.OSVersion,
}
if err := utils.WriteJSONResponse(w, resp); err != nil {
log.Error().Err(err).Msg("Failed to serialize host agent response")
}
}
// HandleLookup returns host registration details for installer validation.
func (h *HostAgentHandlers) HandleLookup(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET is allowed", nil)
return
}
id := strings.TrimSpace(r.URL.Query().Get("id"))
hostname := strings.TrimSpace(r.URL.Query().Get("hostname"))
if id == "" && hostname == "" {
writeErrorResponse(w, http.StatusBadRequest, "missing_lookup_param", "Provide either id or hostname to look up a host", nil)
return
}
state := h.monitor.GetState()
var (
host models.Host
found bool
)
if id != "" {
for _, candidate := range state.Hosts {
if candidate.ID == id {
host = candidate
found = true
break
}
}
}
if !found && hostname != "" {
// First pass: exact match (case-insensitive)
for _, candidate := range state.Hosts {
if strings.EqualFold(candidate.Hostname, hostname) || strings.EqualFold(candidate.DisplayName, hostname) {
host = candidate
found = true
break
}
}
// Second pass: short hostname match (if exact match failed)
if !found {
// Helper to get short hostname (before first dot)
getShortName := func(h string) string {
if idx := strings.Index(h, "."); idx != -1 {
return h[:idx]
}
return h
}
shortLookup := getShortName(hostname)
for _, candidate := range state.Hosts {
if strings.EqualFold(getShortName(candidate.Hostname), shortLookup) {
host = candidate
found = true
break
}
}
}
}
if !found {
writeErrorResponse(w, http.StatusNotFound, "host_not_found", "Host has not registered with Pulse yet", nil)
return
}
// Ensure the querying token matches the host (when applicable).
if record := getAPITokenRecordFromRequest(r); record != nil && host.TokenID != "" && host.TokenID != record.ID {
writeErrorResponse(w, http.StatusForbidden, "host_lookup_forbidden", "Host does not belong to this API token", nil)
return
}
connected := strings.EqualFold(host.Status, "online") ||
strings.EqualFold(host.Status, "running") ||
strings.EqualFold(host.Status, "healthy")
resp := map[string]any{
"success": true,
"host": map[string]any{
"id": host.ID,
"hostname": host.Hostname,
"displayName": host.DisplayName,
"status": host.Status,
"connected": connected,
"lastSeen": host.LastSeen,
"agentVersion": host.AgentVersion,
},
}
if err := utils.WriteJSONResponse(w, resp); err != nil {
log.Error().Err(err).Msg("Failed to serialize host lookup response")
}
}
// HandleDeleteHost removes a host from the shared state.
func (h *HostAgentHandlers) HandleDeleteHost(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only DELETE is allowed", nil)
return
}
// Extract host ID from URL path
// Expected format: /api/agents/host/{hostId}
trimmedPath := strings.TrimPrefix(r.URL.Path, "/api/agents/host/")
hostID := strings.TrimSpace(trimmedPath)
if hostID == "" {
writeErrorResponse(w, http.StatusBadRequest, "missing_host_id", "Host ID is required", nil)
return
}
// Remove the host from state
host, err := h.monitor.RemoveHostAgent(hostID)
if err != nil {
writeErrorResponse(w, http.StatusNotFound, "host_not_found", err.Error(), nil)
return
}
go h.wsHub.BroadcastState(h.monitor.GetState().ToFrontend())
if err := utils.WriteJSONResponse(w, map[string]any{
"success": true,
"hostId": host.ID,
"message": "Host removed",
}); err != nil {
log.Error().Err(err).Msg("Failed to serialize host removal response")
}
}