mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-25 04:59:43 +00:00
f77c8b2f3d
A token binds the first agent ID its command channel presents and never moves. The agent registered that channel before its first report, under its configured --agent-id, or an ID Pulse had forked for a cloned machine ID or kept from an earlier enrollment. Pulse meanwhile resolved its reports to another host ID and acknowledged it, and the agent persisted that ID. The config gate, which judges the resolved ID, then turned the operator's commands off, and the next restart under the persisted ID was refused for good. Report acknowledgements now name commandAgentId: the resolved host ID, only when the token's binding already holds it or a registration presenting it would take the first bind, backfill, legacy migration, hostname rebind or deploy repair it would get anyway. The credential must also be one command admission takes, and the host must be held under the reporting token. Naming grants nothing that registration would not. An established binding to another ID is never moved from report contents, because resolution can map a report onto another host's continuity record by machine ID, so a forged report and a stale one look the same. The config gate takes the same decision. The agent keeps its channel staged until a report is acknowledged and registers under the named identity, or under its own ID when none is named, which is what an older Pulse or an already-bound token sees. An unreadable ack keeps it waiting, and a 403 releases it under its own ID because Pulse can never answer that token. A running client is never rebuilt, since that would reopen its receipt store under in-flight operations. A superseded client can no longer take the run slot. Tokens already bound to a diverged ID stay as they are; re-running the install command issues a fresh token that binds the resolved identity.
975 lines
33 KiB
Go
975 lines
33 KiB
Go
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")
|
|
}
|
|
}
|