Pulse/internal/api/agent_ingest.go
2026-03-19 04:46:02 +00:00

714 lines
22 KiB
Go

package api
import (
"context"
"crypto/ed25519"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
"github.com/rcourtman/pulse-go-rewrite/internal/remoteconfig"
"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/rcourtman/pulse-go-rewrite/pkg/auth"
"github.com/rs/zerolog/log"
)
const (
configSignatureTTL = 15 * time.Minute
agentRoutePrefix = "/api/agents/agent/"
legacyHostRoutePrefix = "/api/agents/host/"
)
var configSigningState struct {
once sync.Once
key ed25519.PrivateKey
err error
}
// UnifiedAgentHandlers manages ingest from the runtime-side Unified Agent module of pulse-agent.
type UnifiedAgentHandlers struct {
baseAgentHandlers
}
func trimUnifiedAgentRoutePath(path string) string {
for _, prefix := range []string{agentRoutePrefix, legacyHostRoutePrefix} {
if strings.HasPrefix(path, prefix) {
return strings.TrimPrefix(path, prefix)
}
}
return path
}
// NewUnifiedAgentHandlers constructs a new handler set for Pulse Unified Agent ingest.
func NewUnifiedAgentHandlers(mtm *monitoring.MultiTenantMonitor, m *monitoring.Monitor, hub *websocket.Hub) *UnifiedAgentHandlers {
return &UnifiedAgentHandlers{baseAgentHandlers: newBaseAgentHandlers(mtm, m, hub)}
}
// HandleReport ingests Pulse Unified Agent reports.
func (h *UnifiedAgentHandlers) 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()
// Support gzip-compressed reports from agents (backward compatible with uncompressed).
// Cap decompressed size at 1.5MB (6x compressed limit — generous for legitimate payloads).
body, err := utils.DecompressBodyIfGzipped(r, 1536*1024)
if err != nil {
writeErrorResponse(w, http.StatusUnsupportedMediaType, "unsupported_encoding", err.Error(), nil)
return
}
defer body.Close()
var report agentshost.Report
if err := json.NewDecoder(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)
if enforceMonitoredSystemLimitForHostReport(
w,
r.Context(),
h.getMonitor(r.Context()),
report,
tokenRecord,
) {
return
}
host, err := h.getMonitor(r.Context()).ApplyHostReport(report, tokenRecord)
if err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_report", err.Error(), nil)
return
}
log.Debug().
Str("agentId", host.ID).
Str("hostname", host.Hostname).
Str("platform", host.Platform).
Msg("Agent report processed")
h.broadcastState(r.Context())
// Include any server-side config overrides in the response
serverConfig := h.getMonitor(r.Context()).GetHostAgentConfig(host.ID)
resp := map[string]any{
"success": true,
"agentId": host.ID,
"lastSeen": host.LastSeen,
"platform": host.Platform,
"osName": host.OSName,
"osVersion": host.OSVersion,
}
// Only include config if there are actual overrides
if serverConfig.CommandsEnabled != nil {
resp["config"] = map[string]any{
"commandsEnabled": serverConfig.CommandsEnabled,
}
}
if err := utils.WriteJSONResponse(w, resp); err != nil {
log.Error().Err(err).Msg("Failed to serialize agent response")
}
}
// HandleLookup returns agent registration details for installer validation.
func (h *UnifiedAgentHandlers) 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
}
lookupID := strings.TrimSpace(r.URL.Query().Get("id"))
hostname := strings.TrimSpace(r.URL.Query().Get("hostname"))
if lookupID == "" && hostname == "" {
writeErrorResponse(w, http.StatusBadRequest, "missing_lookup_param", "Provide either id or hostname to look up an agent", nil)
return
}
// Use the live host snapshot (not the global mock snapshot) so agent
// registrations can still be validated while Pulse is in mock/demo mode.
hosts := h.getMonitor(r.Context()).GetLiveHostsSnapshot()
var (
host models.Host
found bool
)
if lookupID != "" {
for _, candidate := range hosts {
if candidate.ID == lookupID {
host = candidate
found = true
break
}
}
}
if !found && hostname != "" {
// First pass: exact hostname match (case-insensitive) when unique.
var exactMatch *models.Host
for i := range hosts {
candidate := &hosts[i]
if strings.EqualFold(candidate.Hostname, hostname) {
if exactMatch != nil {
exactMatch = nil
break
}
exactMatch = candidate
}
}
if exactMatch != nil {
host = *exactMatch
found = true
}
// Second pass: display-name match, but only when unambiguous.
if !found {
var displayMatch *models.Host
for i := range hosts {
candidate := &hosts[i]
if strings.EqualFold(candidate.DisplayName, hostname) {
if displayMatch != nil {
displayMatch = nil
break
}
displayMatch = candidate
}
}
if displayMatch != nil {
host = *displayMatch
found = true
}
}
// Third pass: short hostname match, but only when unique.
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)
var shortMatch *models.Host
for i := range hosts {
candidate := &hosts[i]
if strings.EqualFold(getShortName(candidate.Hostname), shortLookup) {
if shortMatch != nil {
shortMatch = nil
break
}
shortMatch = candidate
}
}
if shortMatch != nil {
host = *shortMatch
found = true
}
}
}
if !found {
writeErrorResponse(w, http.StatusNotFound, "agent_not_found", "Agent has not registered with Pulse yet", nil)
return
}
// Ensure the querying token matches the agent (when applicable).
if record := getAPITokenRecordFromRequest(r); record != nil && host.TokenID != "" && host.TokenID != record.ID {
writeErrorResponse(w, http.StatusForbidden, "agent_lookup_forbidden", "Agent 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")
agentInfo := map[string]any{
"id": host.ID,
"hostname": host.Hostname,
"displayName": host.DisplayName,
"status": host.Status,
"connected": connected,
"lastSeen": host.LastSeen,
"agentVersion": host.AgentVersion,
}
resp := map[string]any{
"success": true,
"agent": agentInfo,
}
if err := utils.WriteJSONResponse(w, resp); err != nil {
log.Error().Err(err).Msg("Failed to serialize agent lookup response")
}
}
// HandleDeleteHost removes an agent from the shared state.
func (h *UnifiedAgentHandlers) 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 agent ID from URL path.
// Expected format: /api/agents/agent/{agentId} or legacy /api/agents/host/{agentId}
trimmedPath := trimUnifiedAgentRoutePath(r.URL.Path)
agentID := strings.TrimSpace(trimmedPath)
if agentID == "" {
writeErrorResponse(w, http.StatusBadRequest, "missing_agent_id", "agentId is required", nil)
return
}
// Remove the agent from state.
host, err := h.getMonitor(r.Context()).RemoveHostAgent(agentID)
if err != nil {
writeErrorResponse(w, http.StatusNotFound, "agent_not_found", err.Error(), nil)
return
}
h.broadcastState(r.Context())
if err := utils.WriteJSONResponse(w, map[string]any{
"success": true,
"agentId": host.ID,
"message": "Agent removed",
}); err != nil {
log.Error().Err(err).Msg("Failed to serialize agent removal response")
}
}
// HandleAllowReenroll clears the removal block for a host agent to permit future reports.
func (h *UnifiedAgentHandlers) HandleAllowReenroll(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only POST is allowed", nil)
return
}
trimmedPath := trimUnifiedAgentRoutePath(r.URL.Path)
trimmedPath = strings.TrimSuffix(trimmedPath, "/allow-reenroll")
agentID := strings.TrimSpace(trimmedPath)
if agentID == "" {
writeErrorResponse(w, http.StatusBadRequest, "missing_agent_id", "agentId is required", nil)
return
}
if err := h.getMonitor(r.Context()).AllowHostAgentReenroll(agentID); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "agent_reenroll_failed", err.Error(), nil)
return
}
h.broadcastState(r.Context())
if err := utils.WriteJSONResponse(w, map[string]any{
"success": true,
"agentId": agentID,
}); err != nil {
log.Error().Err(err).Msg("Failed to serialize host-agent allow reenroll response")
}
}
// HandleConfig handles GET (fetch config) and PATCH (update config) for agents.
// GET /api/agents/agent/{agentId}/config - Agent fetches its server-side config.
// PATCH /api/agents/agent/{agentId}/config - UI updates agent config (e.g., commandsEnabled).
// Legacy clients may also use /api/agents/host/{agentId}/config.
func (h *UnifiedAgentHandlers) HandleConfig(w http.ResponseWriter, r *http.Request) {
// Extract agent ID from URL path.
// Expected format: /api/agents/agent/{agentId}/config or legacy /api/agents/host/{agentId}/config
trimmedPath := trimUnifiedAgentRoutePath(r.URL.Path)
trimmedPath = strings.TrimSuffix(trimmedPath, "/config")
agentID := strings.TrimSpace(trimmedPath)
if agentID == "" {
writeErrorResponse(w, http.StatusBadRequest, "missing_agent_id", "agentId is required", nil)
return
}
switch r.Method {
case http.MethodGet:
h.handleGetConfig(w, r, agentID)
case http.MethodPatch:
h.handlePatchConfig(w, r, agentID)
default:
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET and PATCH are allowed", nil)
}
}
func (h *UnifiedAgentHandlers) canReadConfig(record *config.APITokenRecord) bool {
if record == nil {
return true
}
return record.HasScope(config.ScopeAgentConfigRead) ||
record.HasScope(config.ScopeAgentManage) ||
record.HasScope(config.ScopeSettingsWrite)
}
func (h *UnifiedAgentHandlers) resolveConfigAgent(ctx context.Context, agentID string, record *config.APITokenRecord) (models.Host, bool) {
// Use the live host snapshot so agents can still fetch config while
// Pulse is running in mock/demo mode.
hosts := h.getMonitor(ctx).GetLiveHostsSnapshot()
if record == nil || record.HasScope(config.ScopeSettingsWrite) || record.HasScope(config.ScopeAgentManage) {
for _, candidate := range hosts {
if candidate.ID == agentID {
return candidate, true
}
}
return models.Host{}, false
}
for _, candidate := range hosts {
if candidate.TokenID != "" && candidate.TokenID == record.ID {
return candidate, true
}
}
return models.Host{}, false
}
func (h *UnifiedAgentHandlers) signAgentConfig(agentID string, cfg monitoring.HostAgentConfig) (monitoring.HostAgentConfig, error) {
signatureRequired := isConfigSignatureRequired()
key, err := getConfigSigningKey()
if err != nil {
if signatureRequired {
return cfg, fmt.Errorf("failed to load config signing key: %w", err)
}
log.Warn().Err(err).Msg("Failed to load config signing key")
return cfg, nil
}
if len(key) == 0 {
if signatureRequired {
return cfg, fmt.Errorf("config signing required but PULSE_AGENT_CONFIG_SIGNING_KEY is not set")
}
return cfg, nil
}
issuedAt := time.Now().UTC()
expiresAt := issuedAt.Add(configSignatureTTL)
payload := remoteconfig.SignedConfigPayload{
AgentID: agentID,
IssuedAt: issuedAt,
ExpiresAt: expiresAt,
CommandsEnabled: cfg.CommandsEnabled,
Settings: cfg.Settings,
}
signature, err := remoteconfig.SignConfigPayload(payload, key)
if err != nil {
if signatureRequired {
return cfg, fmt.Errorf("failed to sign agent config payload: %w", err)
}
log.Warn().Err(err).Msg("Failed to sign agent config payload")
return cfg, nil
}
cfg.IssuedAt = &issuedAt
cfg.ExpiresAt = &expiresAt
cfg.Signature = signature
return cfg, nil
}
func getConfigSigningKey() (ed25519.PrivateKey, error) {
configSigningState.once.Do(func() {
raw := utils.GetenvTrim("PULSE_AGENT_CONFIG_SIGNING_KEY")
if raw == "" {
return
}
key, err := remoteconfig.DecodeEd25519PrivateKey(raw)
if err != nil {
configSigningState.err = err
return
}
configSigningState.key = key
})
return configSigningState.key, configSigningState.err
}
func isConfigSignatureRequired() bool {
return utils.ParseBool(utils.GetenvTrim("PULSE_AGENT_CONFIG_SIGNATURE_REQUIRED"))
}
// handleGetConfig returns the server-side config for an agent to apply.
func (h *UnifiedAgentHandlers) handleGetConfig(w http.ResponseWriter, r *http.Request, agentID string) {
record := getAPITokenRecordFromRequest(r)
if !h.canReadConfig(record) {
respondMissingScope(w, config.ScopeAgentConfigRead)
LogAuditEventForTenant(GetOrgID(r.Context()), "agent_config_fetch", auth.GetUser(r.Context()), GetClientIP(r), r.URL.Path, false,
fmt.Sprintf("agent_id=%s token_id=%s", agentID, tokenID(record)))
return
}
host, ok := h.resolveConfigAgent(r.Context(), agentID, record)
if !ok {
writeErrorResponse(w, http.StatusNotFound, "agent_not_found", "Agent has not registered with Pulse yet", nil)
LogAuditEventForTenant(GetOrgID(r.Context()), "agent_config_fetch", auth.GetUser(r.Context()), GetClientIP(r), r.URL.Path, false,
fmt.Sprintf("agent_id=%s token_id=%s", agentID, tokenID(record)))
return
}
agentID = host.ID
config := h.getMonitor(r.Context()).GetHostAgentConfig(agentID)
signedConfig, err := h.signAgentConfig(agentID, config)
if err != nil {
log.Error().Err(err).Msg("Failed to sign agent config payload")
writeErrorResponse(w, http.StatusInternalServerError, "config_signing_failed", "Failed to sign agent config", nil)
LogAuditEventForTenant(GetOrgID(r.Context()), "agent_config_fetch", auth.GetUser(r.Context()), GetClientIP(r), r.URL.Path, false,
fmt.Sprintf("agent_id=%s token_id=%s", agentID, tokenID(record)))
return
}
resp := map[string]any{
"success": true,
"agentId": agentID,
"config": signedConfig,
}
if err := utils.WriteJSONResponse(w, resp); err != nil {
log.Error().Err(err).Msg("Failed to serialize agent config response")
LogAuditEventForTenant(GetOrgID(r.Context()), "agent_config_fetch", auth.GetUser(r.Context()), GetClientIP(r), r.URL.Path, false,
fmt.Sprintf("agent_id=%s token_id=%s", agentID, tokenID(record)))
return
}
LogAuditEventForTenant(GetOrgID(r.Context()), "agent_config_fetch", auth.GetUser(r.Context()), GetClientIP(r), r.URL.Path, true,
fmt.Sprintf("agent_id=%s token_id=%s", agentID, tokenID(record)))
}
func tokenID(record *config.APITokenRecord) string {
if record == nil {
return ""
}
return record.ID
}
func (h *UnifiedAgentHandlers) ensureAgentTokenMatch(w http.ResponseWriter, r *http.Request, agentID string) bool {
record := getAPITokenRecordFromRequest(r)
if record == nil {
return true
}
if record.HasScope(config.ScopeSettingsWrite) || record.HasScope(config.ScopeWildcard) {
return true
}
// Use the live host snapshot so mock/demo mode doesn't block agent auth checks.
hosts := h.getMonitor(r.Context()).GetLiveHostsSnapshot()
for _, host := range hosts {
if host.ID != agentID {
continue
}
if host.TokenID == record.ID {
return true
}
writeErrorResponse(w, http.StatusForbidden, "agent_lookup_forbidden", "Agent does not belong to this API token", nil)
return false
}
writeErrorResponse(w, http.StatusNotFound, "agent_not_found", "Agent has not registered with Pulse yet", nil)
return false
}
// handlePatchConfig updates the server-side config for an agent.
func (h *UnifiedAgentHandlers) handlePatchConfig(w http.ResponseWriter, r *http.Request, agentID string) {
if !h.ensureAgentTokenMatch(w, r, agentID) {
return
}
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
defer r.Body.Close()
var req struct {
CommandsEnabled *bool `json:"commandsEnabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_json", "Failed to decode request body", map[string]string{"error": err.Error()})
return
}
if err := h.getMonitor(r.Context()).UpdateHostAgentConfig(agentID, req.CommandsEnabled); err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "update_failed", err.Error(), nil)
return
}
h.broadcastState(r.Context())
log.Info().
Str("agentId", agentID).
Interface("commandsEnabled", req.CommandsEnabled).
Msg("Agent config updated")
resp := map[string]any{
"success": true,
"agentId": agentID,
"config": map[string]any{
"commandsEnabled": req.CommandsEnabled,
},
}
if err := utils.WriteJSONResponse(w, resp); err != nil {
log.Error().Err(err).Msg("Failed to serialize agent config update response")
}
}
// HandleUninstall allows an agent to unregister itself during uninstallation.
// Requires ScopeAgentReport and a valid agentId in the request body.
func (h *UnifiedAgentHandlers) HandleUninstall(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only POST is allowed", nil)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
defer r.Body.Close()
var req struct {
AgentID string `json:"agentId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_json", "Failed to decode request body", map[string]string{"error": err.Error()})
return
}
agentID := strings.TrimSpace(req.AgentID)
if agentID == "" {
writeErrorResponse(w, http.StatusBadRequest, "missing_agent_id", "agentId is required", nil)
return
}
log.Info().Str("agentId", agentID).Msg("Received unregistration request from agent uninstaller")
// Ensure the token can manage this specific agent.
if !h.ensureAgentTokenMatch(w, r, agentID) {
return
}
// Remove the agent from state.
_, err := h.getMonitor(r.Context()).RemoveHostAgent(agentID)
if err != nil {
// If the agent is not found, we still return success because the goal is reached.
log.Warn().Err(err).Str("agentId", agentID).Msg("Agent not found during unregistration request")
}
h.broadcastState(r.Context())
if err := utils.WriteJSONResponse(w, map[string]any{
"success": true,
"agentId": agentID,
"message": "Agent unregistered successfully",
}); err != nil {
log.Error().Err(err).Msg("Failed to serialize agent unregistration response")
}
}
// HandleLink manually links an agent to a specific PVE node.
// This is used when auto-linking can't disambiguate (e.g., multiple nodes with hostname "pve").
func (h *UnifiedAgentHandlers) HandleLink(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only POST is allowed", nil)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
defer r.Body.Close()
var req struct {
AgentID string `json:"agentId"`
NodeID string `json:"nodeId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_json", "Failed to decode request body", map[string]string{"error": err.Error()})
return
}
agentID := strings.TrimSpace(req.AgentID)
if agentID == "" {
writeErrorResponse(w, http.StatusBadRequest, "missing_agent_id", "agentId is required", nil)
return
}
nodeID := strings.TrimSpace(req.NodeID)
if nodeID == "" {
writeErrorResponse(w, http.StatusBadRequest, "missing_node_id", "Node ID is required", nil)
return
}
if err := h.getMonitor(r.Context()).LinkHostAgent(agentID, nodeID); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "link_failed", err.Error(), nil)
return
}
h.broadcastState(r.Context())
if err := utils.WriteJSONResponse(w, map[string]any{
"success": true,
"agentId": agentID,
"nodeId": nodeID,
"message": "Agent linked to PVE node",
}); err != nil {
log.Error().Err(err).Msg("Failed to serialize agent link response")
}
}
// HandleUnlink removes the link between an agent and its PVE node.
// The agent continues to report but appears in the Managed Agents table.
func (h *UnifiedAgentHandlers) HandleUnlink(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only POST is allowed", nil)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
defer r.Body.Close()
var req struct {
AgentID string `json:"agentId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_json", "Failed to decode request body", map[string]string{"error": err.Error()})
return
}
agentID := strings.TrimSpace(req.AgentID)
if agentID == "" {
writeErrorResponse(w, http.StatusBadRequest, "missing_agent_id", "agentId is required", nil)
return
}
if err := h.getMonitor(r.Context()).UnlinkHostAgent(agentID); err != nil {
writeErrorResponse(w, http.StatusNotFound, "unlink_failed", err.Error(), nil)
return
}
h.broadcastState(r.Context())
if err := utils.WriteJSONResponse(w, map[string]any{
"success": true,
"agentId": agentID,
"message": "Agent unlinked from PVE node",
}); err != nil {
log.Error().Err(err).Msg("Failed to serialize agent unlink response")
}
}