Files
pulse/internal/api/host_agents.go
T
rcourtman a38c071644 feat(#903): Remote agent configuration for AI command execution
This implements full remote configuration for the AI command execution setting:

Backend:
- Add CommandsEnabled field to HostMetadata for persistent storage
- Add GetHostAgentConfig/UpdateHostAgentConfig methods to Monitor
- Add /api/agents/host/{id}/config endpoint (GET for agents, PATCH for UI)
- Server includes config in report response for immediate agent application
- Agent parses response and dynamically enables/disables command client

Frontend:
- Add 'AI Commands' toggle column in Managed Agents table
- Toggle immediately updates server config; agent applies on next heartbeat
- Add 'Enable AI command execution' checkbox in agent installer wizard
- Checkbox adds --enable-commands flag to generated install commands

This allows users to:
1. Enable at install time via checkbox in the wizard
2. Toggle remotely via the Managed Agents UI for existing agents
3. Agents apply changes automatically on their next report cycle
2025-12-25 08:07:28 +00:00

301 lines
8.9 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())
// Include any server-side config overrides in the response
serverConfig := h.monitor.GetHostAgentConfig(host.ID)
resp := map[string]any{
"success": true,
"hostId": 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 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")
}
}
// HandleConfig handles GET (fetch config) and PATCH (update config) for host agents.
// GET /api/agents/host/{hostId}/config - Agent fetches its server-side config
// PATCH /api/agents/host/{hostId}/config - UI updates host config (e.g., commandsEnabled)
func (h *HostAgentHandlers) HandleConfig(w http.ResponseWriter, r *http.Request) {
// Extract host ID from URL path
// Expected format: /api/agents/host/{hostId}/config
trimmedPath := strings.TrimPrefix(r.URL.Path, "/api/agents/host/")
trimmedPath = strings.TrimSuffix(trimmedPath, "/config")
hostID := strings.TrimSpace(trimmedPath)
if hostID == "" {
writeErrorResponse(w, http.StatusBadRequest, "missing_host_id", "Host ID is required", nil)
return
}
switch r.Method {
case http.MethodGet:
h.handleGetConfig(w, r, hostID)
case http.MethodPatch:
h.handlePatchConfig(w, r, hostID)
default:
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET and PATCH are allowed", nil)
}
}
// handleGetConfig returns the server-side config for an agent to apply.
func (h *HostAgentHandlers) handleGetConfig(w http.ResponseWriter, r *http.Request, hostID string) {
config := h.monitor.GetHostAgentConfig(hostID)
resp := map[string]any{
"success": true,
"hostId": hostID,
"config": config,
}
if err := utils.WriteJSONResponse(w, resp); err != nil {
log.Error().Err(err).Msg("Failed to serialize host config response")
}
}
// handlePatchConfig updates the server-side config for a host agent.
func (h *HostAgentHandlers) handlePatchConfig(w http.ResponseWriter, r *http.Request, hostID string) {
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.monitor.UpdateHostAgentConfig(hostID, req.CommandsEnabled); err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "update_failed", err.Error(), nil)
return
}
go h.wsHub.BroadcastState(h.monitor.GetState().ToFrontend())
log.Info().
Str("hostId", hostID).
Interface("commandsEnabled", req.CommandsEnabled).
Msg("Host agent config updated")
resp := map[string]any{
"success": true,
"hostId": hostID,
"config": map[string]any{
"commandsEnabled": req.CommandsEnabled,
},
}
if err := utils.WriteJSONResponse(w, resp); err != nil {
log.Error().Err(err).Msg("Failed to serialize host config update response")
}
}