mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-19 04:36:43 +00:00
The Add Pulse Agent flow composed a fixed scope list in the frontend, so ticking Enable Pulse command execution added --enable-commands to the install command while the token it shipped with never carried agent:exec, and the command channel rejected every registration (issues #1586, #1564, confirmed by a reporter's server log). Even with the scope, the generic token had no binding metadata, so the first-use binding gate would have refused it next. The install token is now minted through POST /api/agent-install-command with type 'host'. The server decides the scopes from enableCommands at mint time, stamps the install_type/issued_via metadata that makes the token eligible for first-use command-channel binding (canBindProxmoxAgentInstallExecToken renamed canBindAgentInstallExecToken and extended to the host install type), and returns the sanitized token record. The frontend regenerates the token when the checkbox toggles, since scopes cannot be upgraded on an existing token, and revokes the superseded token so toggling does not accumulate orphans. Contract-Neutral: install-token mint bugfix (#1586, #1564): checkbox-promised exec scope now real; agent-lifecycle contract delta deferred because the contract docs carry another agent's uncommitted WIP on the shared tree
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 == "" && canBindAgentInstallExecToken(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 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 canBindAgentInstallExecToken(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, agentInstallTypeHost:
|
|
default:
|
|
return false
|
|
}
|
|
|
|
switch strings.TrimSpace(record.Metadata["issued_via"]) {
|
|
case agentInstallIssuedViaConfig, agentInstallIssuedViaHosted:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|