package api import ( "context" "crypto/ed25519" "encoding/json" "errors" "fmt" "net/http" "strings" "sync" "time" "github.com/rcourtman/pulse-go-rewrite/internal/api/agentbinding" "github.com/rcourtman/pulse-go-rewrite/internal/api/agenttokens" "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 // serverVersion is echoed on report acknowledgements so agents can spot a // server upgrade on their next report instead of their next hourly update // check. Empty (the default) omits it from acks. serverVersion string // recordReportedIdentity records a report's server-resolved identity on // its hostname-bound token. Nil (the default) records nothing. recordReportedIdentity func(organizationID, tokenID, presentedID, hostID, hostname string) configFetchAudits *agentConfigFetchAuditTracker } // SetServerVersion supplies the running server version to include on report // acknowledgements. Call once at wiring time; an empty version keeps acks // version-free. func (h *UnifiedAgentHandlers) SetServerVersion(version string) { h.serverVersion = strings.TrimSpace(version) } // SetReportedIdentityRecorder supplies the canonical token-store binding that // records the agent identity a report resolves to. Call once at wiring time. func (h *UnifiedAgentHandlers) SetReportedIdentityRecorder(record func(organizationID, tokenID, presentedID, hostID, hostname string)) { h.recordReportedIdentity = record } 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), configFetchAudits: newAgentConfigFetchAuditTracker(), } } // 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 } var report agentshost.Report // Support gzip-compressed reports from agents (backward compatible with // uncompressed), with independent encoded and decoded size limits. if !decodeCompressedAgentReport(w, r, 256*1024, 1536*1024, &report) { return } if report.Timestamp.IsZero() { report.Timestamp = time.Now().UTC() } tokenRecord := getAPITokenRecordFromRequest(r) host, err := h.getMonitor(r.Context()).ApplyHostReport(report, tokenRecord) if err != nil { writeErrorResponse(w, http.StatusBadRequest, "invalid_report", err.Error(), nil) return } // Name the agent on a token bound only to its hostname, so the token // carries an identity even if this agent never registers a command channel. // Only a host the monitor resolved and holds under this token carries its // token ID; mock mode's discard acknowledgement echoes the presented ID // unresolved and carries none, whenever mock mode flips. The hostname is // the report's own, which is what the agent's command registration // presents: a report the ordering watermark rejects returns the stored // host, whose hostname may come from a different report. The request's // token snapshot screens out tokens that are already bound or do not // qualify without taking the exclusive token lock on every report; the // recorder decides again on the live record. reportedHostname := strings.TrimSpace(report.Host.Hostname) if h.recordReportedIdentity != nil && tokenRecord != nil && host.TokenID == tokenRecord.ID && agentbinding.EvaluateReportedIdentity(tokenRecord, report.Agent.ID, host.ID, reportedHostname) { h.recordReportedIdentity(GetOrgID(r.Context()), tokenRecord.ID, report.Agent.ID, host.ID, reportedHostname) } // A freshly generated install token is the server's durable record of the // operator's command-policy choice. Project that choice onto the stable host // identity before returning remote config, otherwise an old false override // survives remove/reinstall and immediately shuts down an agent launched // with --enable-commands (#1728). if err := reconcileInstallTokenCommandPolicy(h.getMonitor(r.Context()), tokenRecord, host); err != nil { log.Error().Err(err). Str("agent_id", host.ID). Str("token_id", tokenID(tokenRecord)). Msg("Failed to reconcile install-token command policy") } 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) serverConfig = sanitizeHostAgentConfigForToken(serverConfig, tokenRecord, host) resp := map[string]any{ "success": true, "agentId": host.ID, "lastSeen": host.LastSeen, "platform": host.Platform, "osName": host.OSName, "osVersion": host.OSVersion, } if h.serverVersion != "" { resp["serverVersion"] = h.serverVersion } // Name the identity this agent's command channel must register under, for // a host the monitor resolved and holds under this token, judged for the // hostname this report presents, as its registration will. Mock mode's // discard acknowledgement carries no token ID, so it names nothing. if tokenRecord != nil && host.TokenID == tokenRecord.ID { commandHost := host commandHost.Hostname = reportedHostname if commandAgentID := commandIdentityForToken(tokenRecord, commandHost); commandAgentID != "" { resp["commandAgentId"] = commandAgentID } } // 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") } } func reconcileInstallTokenCommandPolicy(monitor *monitoring.Monitor, requestRecord *config.APITokenRecord, host models.Host) error { if monitor == nil || requestRecord == nil || strings.TrimSpace(requestRecord.ID) == "" || strings.TrimSpace(host.ID) == "" { return nil } cfg := monitor.GetConfig() if cfg == nil { return nil } // Authentication stores a clone in the request context. Re-read the live // record so concurrent scope changes and an already-consumed intent marker // are authoritative. var record *config.APITokenRecord config.Mu.RLock() for index := range cfg.APITokens { if cfg.APITokens[index].ID == requestRecord.ID { clone := cfg.APITokens[index].Clone() record = &clone break } } config.Mu.RUnlock() if record == nil { return nil } requestedEnabled, hasIntent := agenttokens.ParseCommandPolicyIntent(record) if !hasIntent || strings.TrimSpace(record.Metadata[agenttokens.CommandPolicyAppliedAgentIDMetadataKey]) != "" { return nil } if !evaluateAgentExecBinding(record, host.ID, host.Hostname).admit { // Intent metadata is useful only on a Pulse-minted install token whose // binding policy admits this identity. Hand-created API tokens remain // unable to turn themselves into command credentials. return nil } // Scope removal always wins over the original enabled intent. Conversely, // adding agent:exec later does not rewrite a deliberately disabled install // intent; the explicit token-scope editor and desired policy remain separate // controls. effectiveEnabled := requestedEnabled && record.HasScope(config.ScopeAgentExec) if err := monitor.UpdateHostAgentConfig(host.ID, &effectiveEnabled); err != nil { return fmt.Errorf("persist desired command policy: %w", err) } persistence := monitor.GetConfigPersistence() config.Mu.Lock() defer config.Mu.Unlock() for index := range cfg.APITokens { live := &cfg.APITokens[index] if live.ID != requestRecord.ID { continue } if strings.TrimSpace(live.Metadata[agenttokens.CommandPolicyAppliedAgentIDMetadataKey]) != "" { return nil } if live.Metadata == nil { live.Metadata = make(map[string]string) } live.Metadata[agenttokens.CommandPolicyAppliedAgentIDMetadataKey] = host.ID if persistence != nil { if err := persistence.SaveAPITokens(cfg.APITokens); err != nil { delete(live.Metadata, agenttokens.CommandPolicyAppliedAgentIDMetadataKey) return fmt.Errorf("persist install-token command-policy marker: %w", err) } } log.Info(). Str("agent_id", host.ID). Str("hostname", host.Hostname). Str("token_id", live.ID). Bool("commands_enabled", effectiveEnabled). Msg("Applied install-token command policy to host") return nil } return nil } // 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.StatusInternalServerError, "agent_removal_failed", 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) || // Older host/docker-agent tokens may only carry agent:report (legacy // host-agent:report). Allow them to keep fetching their own config via // token binding — resolveConfigAgent below restricts a report-only token // to the host it is bound to. record.HasScope(config.ScopeAgentReport) || 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. monitor := h.getMonitor(ctx) hosts := monitor.GetLiveHostsSnapshot() if record == nil || record.HasScope(config.ScopeSettingsWrite) || record.HasScope(config.ScopeAgentManage) { for _, candidate := range hosts { if candidate.ID == agentID { return candidate, true } } // Live state loses agent-reported hosts across monitor reloads and // restarts until the next report lands; fall back to the persisted // continuity store so config fetches in that window do not 404 (#1570). return monitor.MatchHostConfigContinuity(agentID, "") } for _, candidate := range hosts { if candidate.TokenID != "" && candidate.TokenID == record.ID { return candidate, true } } return monitor.MatchHostConfigContinuity(agentID, record.ID) } func (h *UnifiedAgentHandlers) signAgentConfig(agentID string, cfg monitoring.HostAgentConfig) (monitoring.HostAgentConfig, error) { var err error cfg, err = ensureDesiredAgentConfigMetadata(cfg) if err != nil { return cfg, err } 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 ensureDesiredAgentConfigMetadata(cfg monitoring.HostAgentConfig) (monitoring.HostAgentConfig, error) { metadata, err := remoteconfig.BuildDesiredConfigMetadata(cfg.CommandsEnabled, cfg.Settings) if err != nil { return cfg, fmt.Errorf("failed to build desired config metadata: %w", err) } cfg.DesiredConfig = &metadata 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) config = sanitizeHostAgentConfigForToken(config, record, host) 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 } configHash := "" if signedConfig.DesiredConfig != nil { configHash = signedConfig.DesiredConfig.Hash } orgID := GetOrgID(r.Context()) if reason, audit := h.configFetchAudits.observe(orgID, agentID, tokenID(record), configHash, time.Now()); audit { details := fmt.Sprintf("agent_id=%s token_id=%s config=%s", agentID, tokenID(record), configHash) if reason != "" { details += " reason=" + reason } LogAuditEventForTenant(orgID, "agent_config_fetch", auth.GetUser(r.Context()), GetClientIP(r), r.URL.Path, true, details) } } func tokenID(record *config.APITokenRecord) string { if record == nil { return "" } return record.ID } func sanitizeHostAgentConfigForToken(cfg monitoring.HostAgentConfig, record *config.APITokenRecord, host models.Host) monitoring.HostAgentConfig { if cfg.CommandsEnabled == nil || !*cfg.CommandsEnabled || commandConfigAllowedForToken(record, host) { return cfg } disabled := false cfg.CommandsEnabled = &disabled return cfg } func commandConfigAllowedForToken(record *config.APITokenRecord, host models.Host) bool { if record == nil { return true } if !record.HasScope(config.ScopeAgentExec) { log.Warn(). Str("token_id", record.ID). Str("agent_id", host.ID). Str("hostname", host.Hostname). Msg("Suppressing enabled remote commands for agent: token missing required scope agent:exec") return false } // Mirror the command-channel admission decision exactly. Telling an agent // commands are enabled when its channel registration would be rejected // strands the host on "Remote control blocked" (the agent reports // CommandsEnabled=true forever while no channel can be admitted). if commandIdentityForToken(record, host) == "" { // This gate runs before the agent ever attempts command-channel // registration, so without a log here a refused binding leaves no // trace anywhere: the agent never learns commands were requested and // the channel rejection warnings never fire (#1728). log.Warn(). Str("token_id", record.ID). Str("agent_id", host.ID). Str("hostname", host.Hostname). Str("bound_agent_id", strings.TrimSpace(record.Metadata["bound_agent_id"])). Str("bound_hostname", strings.TrimSpace(record.Metadata["bound_hostname"])). Str("install_type", strings.TrimSpace(record.Metadata["install_type"])). Str("issued_via", strings.TrimSpace(record.Metadata["issued_via"])). Msg("Suppressing enabled remote commands for agent: exec binding would reject this token and agent identity") return false } return true } // commandIdentityForToken names the identity a host agent's command channel // must register under for its token to admit it: the host ID Pulse resolved // for the agent's report, which the ack returns as agentId and the agent // persists and presents after a restart. A token binds the first ID its // channel presents, so an agent that registered a configured --agent-id, or // an ID Pulse forked or kept from an earlier enrollment, was refused on every // restart while the config gate judged the resolved ID. // // The ID is named only for a credential that command admission would take on // its legacy path (agent:exec, at most one organization, no runner, // collector, or unsupported runtime role), and only when the shared binding // decision admits it: the token already holds it, or a registration // presenting it would take the first bind, backfill, legacy migration, // hostname rebind, or one-time deploy repair it would get anyway. Naming it // grants nothing beyond that registration. An established binding to any // other ID is never moved by report contents, which can be steered onto // another host's continuity record; the result is empty and the agent keeps // registering under the ID it presents. The config gate consumes the same // decision, so commands are never reported enabled for an identity the // channel would refuse. func commandIdentityForToken(record *config.APITokenRecord, host models.Host) string { hostID := strings.TrimSpace(host.ID) if record == nil || hostID == "" || !record.HasScope(config.ScopeAgentExec) || len(record.GetBoundOrgs()) > 1 { return "" } switch strings.TrimSpace(record.Metadata[agenttokens.RuntimeRoleMetadataKey]) { case "", agenttokens.CredentialKindLegacyFullTrust: default: return "" } if !evaluateAgentExecBinding(record, hostID, host.Hostname).admit { return "" } return hostID } 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") monitor := h.getMonitor(r.Context()) record := getAPITokenRecordFromRequest(r) var err error if record != nil { _, err = monitor.UninstallHostAgent(agentID, record.ID) } else { // Preserve the existing administrative/session removal surface. Collector // self-uninstall always takes the exact-token durable path above. _, err = monitor.RemoveHostAgent(agentID) } if err != nil { switch { case errors.Is(err, monitoring.ErrHostAgentTokenMismatch): writeErrorResponse(w, http.StatusForbidden, "agent_lookup_forbidden", "Agent does not belong to this API token", nil) case errors.Is(err, monitoring.ErrHostAgentNotFound): writeErrorResponse(w, http.StatusNotFound, "agent_not_found", "Agent has not registered with Pulse yet", nil) default: log.Error().Err(err).Str("agentId", agentID).Msg("Collector uninstall transaction failed") writeErrorResponse(w, http.StatusInternalServerError, "agent_uninstall_failed", "Pulse could not durably remove the agent", nil) } return } 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") } }