mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-10 00:14:38 +00:00
An agent enrolled for metrics but whose token the server doesn't recognise (or that lacks the agent:exec scope, or is bound to a different agent) was rejected on the command-exec WebSocket with a bare 'Invalid token' and — for the token-not-found case — no server log at all. The agent then retried forever, logging only 'Invalid token', so the operator had no signal that discovery deep-scan was failing or why. (Confirmed live: delly/minipc agents pointed at a backend that didn't recognise their token retried thousands of times; discovery abstained for every guest as a result.) - agentexec/server.go: the registration-rejection message the agent logs verbatim now says 'agent token not authorized for command execution — re-run the agent installer to enroll an agent:exec-scoped token'. - api/agent_exec_token_binding.go: the previously-silent token-not-recognised branch now logs the specific reason with the agent hostname. Contract-neutral: same rejection behaviour, just legible. Regression test: TestHandleWebSocket_RejectionMessageIsActionable. Verified live end-to-end.
132 lines
3.8 KiB
Go
132 lines
3.8 KiB
Go
package api
|
|
|
|
import (
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
const (
|
|
agentInstallIssuedViaConfig = "config_agent_install_command"
|
|
agentInstallIssuedViaHosted = "hosted_agent_install_command"
|
|
)
|
|
|
|
func (r *Router) validateAgentExecToken(token string, agentID string, hostname string) bool {
|
|
if r == nil || r.config == nil {
|
|
return false
|
|
}
|
|
|
|
requestedID := strings.TrimSpace(agentID)
|
|
requestedHost := strings.TrimSpace(hostname)
|
|
|
|
config.Mu.Lock()
|
|
record, ok := r.config.ValidateAPIToken(token)
|
|
if !ok {
|
|
config.Mu.Unlock()
|
|
// This is the branch a stale-enrollment agent hits: it holds a token
|
|
// from a prior install that this server no longer recognises. It was
|
|
// previously the only rejection path with no log, which made a looping
|
|
// "Invalid token" agent impossible to diagnose without reading source.
|
|
log.Warn().
|
|
Str("agent_id", requestedID).
|
|
Str("hostname", requestedHost).
|
|
Msg("Agent exec token not recognized by this server — re-run the agent installer to re-enroll this agent")
|
|
return false
|
|
}
|
|
|
|
tokenID := record.ID
|
|
if !record.HasScope(config.ScopeAgentExec) {
|
|
config.Mu.Unlock()
|
|
log.Warn().
|
|
Str("token_id", tokenID).
|
|
Msg("Agent exec token missing required scope: agent:exec")
|
|
return false
|
|
}
|
|
|
|
boundID := strings.TrimSpace(record.Metadata["bound_agent_id"])
|
|
boundHost := strings.TrimSpace(record.Metadata["bound_hostname"])
|
|
if boundID == "" && boundHost == "" && canBindProxmoxAgentInstallExecToken(record, requestedID, requestedHost) {
|
|
issuedVia := strings.TrimSpace(record.Metadata["issued_via"])
|
|
installType := strings.TrimSpace(record.Metadata["install_type"])
|
|
if record.Metadata == nil {
|
|
record.Metadata = make(map[string]string)
|
|
}
|
|
record.Metadata["bound_agent_id"] = requestedID
|
|
record.Metadata["bound_hostname"] = requestedHost
|
|
record.Metadata["bound_at"] = time.Now().UTC().Format(time.RFC3339)
|
|
tokens := make([]config.APITokenRecord, len(r.config.APITokens))
|
|
copy(tokens, r.config.APITokens)
|
|
config.Mu.Unlock()
|
|
|
|
if r.persistence != nil {
|
|
if err := r.persistence.SaveAPITokens(tokens); err != nil {
|
|
log.Warn().
|
|
Err(err).
|
|
Str("token_id", tokenID).
|
|
Str("hostname", requestedHost).
|
|
Msg("Failed to persist first-use Proxmox agent exec token binding")
|
|
}
|
|
}
|
|
|
|
log.Info().
|
|
Str("token_id", tokenID).
|
|
Str("hostname", requestedHost).
|
|
Str("issued_via", issuedVia).
|
|
Str("install_type", installType).
|
|
Msg("Bound Proxmox agent install token to first command agent registration")
|
|
return true
|
|
}
|
|
|
|
if boundID == "" && boundHost == "" {
|
|
config.Mu.Unlock()
|
|
log.Warn().
|
|
Str("token_id", tokenID).
|
|
Msg("Agent exec token missing binding metadata")
|
|
return false
|
|
}
|
|
|
|
if boundHost != "" && strings.EqualFold(boundHost, requestedHost) {
|
|
config.Mu.Unlock()
|
|
return true
|
|
}
|
|
|
|
if boundID != "" && boundID == requestedID {
|
|
config.Mu.Unlock()
|
|
return true
|
|
}
|
|
|
|
config.Mu.Unlock()
|
|
log.Warn().
|
|
Str("token_id", tokenID).
|
|
Str("bound_id", boundID).
|
|
Str("bound_hostname", boundHost).
|
|
Str("requested_id", requestedID).
|
|
Str("requested_hostname", requestedHost).
|
|
Msg("Agent token mismatch: token is not bound to the registering agent")
|
|
return false
|
|
}
|
|
|
|
func canBindProxmoxAgentInstallExecToken(record *config.APITokenRecord, agentID string, hostname string) bool {
|
|
if record == nil || strings.TrimSpace(agentID) == "" || strings.TrimSpace(hostname) == "" {
|
|
return false
|
|
}
|
|
if strings.TrimSpace(record.Metadata["bound_agent_id"]) != "" ||
|
|
strings.TrimSpace(record.Metadata["bound_hostname"]) != "" {
|
|
return false
|
|
}
|
|
|
|
switch strings.TrimSpace(record.Metadata["install_type"]) {
|
|
case proxmoxInstallTypePVE, proxmoxInstallTypePBS:
|
|
default:
|
|
return false
|
|
}
|
|
|
|
switch strings.TrimSpace(record.Metadata["issued_via"]) {
|
|
case agentInstallIssuedViaConfig, agentInstallIssuedViaHosted:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|