Files
pulse/internal/api/agent_exec_token_binding.go
T
rcourtman d01ad47524 Keep deployed agents' command channel across restarts
Deploy enrollment bound the runtime token to an invented agent-<hostname>
and handed that ID to the agent. Once Pulse acknowledged the agent's reports
with its machine-derived ID, the next restart registered under that ID and
the command channel refused it for good.

Enrollment now binds the token to the node's hostname only and lets the first
command registration bind the agent's own ID. Tokens already issued with the
placeholder move once, on the exact same hostname, to the registering agent's
ID, and record that they were repaired so they never move again.
2026-10-05 22:23:09 +01:00

433 lines
16 KiB
Go

package api
import (
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
"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/rs/zerolog/log"
)
const (
agentInstallIssuedViaConfig = "config_agent_install_command"
agentInstallIssuedViaHosted = "hosted_agent_install_command"
agentExecBindingVersionKey = "agent_exec_binding_version"
agentExecBindingVersion = "2"
)
type agentExecMetadataValue struct {
value string
present bool
}
func snapshotAgentExecMetadata(metadata map[string]string, keys ...string) map[string]agentExecMetadataValue {
snapshot := make(map[string]agentExecMetadataValue, len(keys))
for _, key := range keys {
value, present := metadata[key]
snapshot[key] = agentExecMetadataValue{value: value, present: present}
}
return snapshot
}
func restoreAgentExecMetadata(metadata map[string]string, snapshot map[string]agentExecMetadataValue) {
for key, previous := range snapshot {
if previous.present {
metadata[key] = previous.value
} else {
delete(metadata, key)
}
}
}
func (r *Router) validateAgentExecToken(token string, agentID string, hostname string) bool {
_, ok := r.admitAgentExecToken(token, agentID, hostname)
return ok
}
// agentExecHostnamesMatch compares a bound hostname against the hostname an
// agent reports. Short-name vs fully-qualified variants of the same host must
// compare equal here the same way they do everywhere else in the system
// (unifiedresources.HostnamesEquivalent); the case-insensitive exact branch
// keeps IP-literal hostnames comparable, which HostnamesEquivalent rejects.
func agentExecHostnamesMatch(bound, requested string) bool {
return agentbinding.HostnamesMatch(bound, requested)
}
// agentExecBindingDecision is the single source of truth for whether an
// already-issued agent exec token accepts a registering agent identity, and
// which metadata repair the admission path must persist when it does.
type agentExecBindingDecision struct {
admit bool
firstBind bool
legacyMigrate bool
rebindHostname bool
backfillID bool
backfillHost bool
repairDeployIdentity bool
}
// evaluateAgentExecBinding computes the admission decision for a token record
// and a requesting agent identity without mutating the record. Both the
// command-channel admission (admitAgentExecToken) and the agent config gate
// (commandConfigAllowedForToken) consume this decision: v6.1.2 shipped them as
// two divergent policies, so an agent could be told commands were enabled
// while its command channel was rejected, leaving the host permanently on
// "Remote control blocked" with reinstall as the only recourse.
func evaluateAgentExecBinding(record *config.APITokenRecord, requestedID, requestedHost string) agentExecBindingDecision {
decision := agentbinding.Evaluate(record, requestedID, requestedHost)
return agentExecBindingDecision{
admit: decision.Admit,
firstBind: decision.FirstBind,
legacyMigrate: decision.LegacyMigrate,
rebindHostname: decision.RebindHostname,
backfillID: decision.BackfillID,
backfillHost: decision.BackfillHost,
repairDeployIdentity: decision.RepairDeployIdentity,
}
}
func (r *Router) admitAgentExecToken(token string, agentID string, hostname string) (agentexec.AgentAdmission, bool) {
if r == nil || r.config == nil {
return agentexec.AgentAdmission{}, 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 agentexec.AgentAdmission{}, 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 agentexec.AgentAdmission{}, false
}
orgs := record.GetBoundOrgs()
if len(orgs) > 1 {
config.Mu.Unlock()
log.Warn().
Str("token_id", tokenID).
Strs("organization_ids", orgs).
Msg("Agent exec token rejected because command sessions require one organization binding")
return agentexec.AgentAdmission{}, false
}
organizationID := "default"
if len(orgs) == 1 && strings.TrimSpace(orgs[0]) != "" {
organizationID = strings.TrimSpace(orgs[0])
}
runtimeRole := strings.TrimSpace(record.Metadata[agenttokens.RuntimeRoleMetadataKey])
if runtimeRole == agenttokens.CredentialKindMonitoringCollector {
config.Mu.Unlock()
log.Warn().Str("token_id", tokenID).Msg("Monitoring collector credential rejected by action listener")
return agentexec.AgentAdmission{}, false
}
if runtimeRole == agenttokens.CredentialKindActionRunner {
if len(orgs) != 1 {
config.Mu.Unlock()
log.Warn().Str("token_id", tokenID).Msg("Action runner credential requires one explicit organization binding")
return agentexec.AgentAdmission{}, false
}
decision := agentbinding.EvaluateActionRunner(record, requestedID, requestedHost)
if !decision.Admit {
config.Mu.Unlock()
log.Warn().Str("token_id", tokenID).Msg("Action runner credential binding or capability mismatch")
return agentexec.AgentAdmission{}, false
}
capability := strings.TrimSpace(record.Metadata[agenttokens.ActionCapabilityMetadataKey])
canonicalHostname := strings.TrimSpace(record.Metadata["bound_hostname"])
activationPending := strings.TrimSpace(record.Metadata[agenttokens.ActionRunnerActivationPendingMetadataKey]) == "true"
config.Mu.Unlock()
return agentexec.AgentAdmission{
OrganizationID: organizationID,
TokenID: tokenID,
AgentID: requestedID,
Hostname: canonicalHostname,
RuntimeRole: agentexec.RuntimeRoleActionRunner,
ActionCapability: capability,
ActivationPending: activationPending,
}, true
}
if runtimeRole != "" && runtimeRole != agenttokens.CredentialKindLegacyFullTrust {
config.Mu.Unlock()
log.Warn().Str("token_id", tokenID).Str("runtime_role", runtimeRole).Msg("Agent exec token has unsupported runtime role")
return agentexec.AgentAdmission{}, false
}
boundID := strings.TrimSpace(record.Metadata["bound_agent_id"])
boundHost := strings.TrimSpace(record.Metadata["bound_hostname"])
decision := evaluateAgentExecBinding(record, requestedID, requestedHost)
switch {
case decision.firstBind:
issuedVia := strings.TrimSpace(record.Metadata["issued_via"])
installType := strings.TrimSpace(record.Metadata["install_type"])
if record.Metadata == nil {
record.Metadata = make(map[string]string)
}
previousMetadata := snapshotAgentExecMetadata(
record.Metadata,
"bound_agent_id",
"bound_hostname",
"bound_at",
agentExecBindingVersionKey,
agenttokens.RuntimeRoleMetadataKey,
)
record.Metadata["bound_agent_id"] = requestedID
// A bound_hostname already written by the Proxmox auto-register
// bootstrap is authoritative: it is what the still-unconsumed install
// grant compares against, so an equivalent-but-different spelling
// reported by the agent must not overwrite it.
if strings.TrimSpace(record.Metadata["bound_hostname"]) == "" {
record.Metadata["bound_hostname"] = requestedHost
}
record.Metadata["bound_at"] = time.Now().UTC().Format(time.RFC3339)
record.Metadata[agentExecBindingVersionKey] = agentExecBindingVersion
record.Metadata[agenttokens.RuntimeRoleMetadataKey] = agenttokens.CredentialKindLegacyFullTrust
if r.persistence != nil {
if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil {
restoreAgentExecMetadata(record.Metadata, previousMetadata)
config.Mu.Unlock()
log.Error().
Err(err).
Str("token_id", tokenID).
Str("hostname", requestedHost).
Msg("Failed to persist first-use agent exec token binding; command registration denied")
return agentexec.AgentAdmission{}, false
}
}
config.Mu.Unlock()
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 agentexec.AgentAdmission{
OrganizationID: organizationID,
TokenID: tokenID,
AgentID: requestedID,
Hostname: requestedHost,
RuntimeRole: agentexec.RuntimeRoleLegacyFullTrust,
}, true
case decision.legacyMigrate:
previousID := boundID
previousMetadata := snapshotAgentExecMetadata(
record.Metadata,
"bound_agent_id",
"bound_at",
agentExecBindingVersionKey,
agenttokens.RuntimeRoleMetadataKey,
agentbinding.DeployIdentityKey,
)
record.Metadata["bound_agent_id"] = requestedID
record.Metadata["bound_at"] = time.Now().UTC().Format(time.RFC3339)
record.Metadata[agentExecBindingVersionKey] = agentExecBindingVersion
record.Metadata[agenttokens.RuntimeRoleMetadataKey] = agenttokens.CredentialKindLegacyFullTrust
if decision.repairDeployIdentity {
record.Metadata[agentbinding.DeployIdentityKey] = agentbinding.DeployIdentityRepaired
}
if r.persistence != nil {
if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil {
restoreAgentExecMetadata(record.Metadata, previousMetadata)
config.Mu.Unlock()
log.Error().
Err(err).
Str("token_id", tokenID).
Msg("Failed to persist legacy agent exec identity migration; command registration denied")
return agentexec.AgentAdmission{}, false
}
}
config.Mu.Unlock()
log.Info().
Str("token_id", tokenID).
Str("previous_agent_id", previousID).
Str("agent_id", requestedID).
Str("hostname", requestedHost).
Msg("Migrated legacy hostname-bound agent exec token to immutable runtime identity")
return agentexec.AgentAdmission{
OrganizationID: organizationID,
TokenID: tokenID,
AgentID: requestedID,
Hostname: requestedHost,
RuntimeRole: agentexec.RuntimeRoleLegacyFullTrust,
}, true
case decision.admit:
previousHost := boundHost
previousMetadata := snapshotAgentExecMetadata(
record.Metadata,
"bound_agent_id",
"bound_hostname",
"bound_at",
agentExecBindingVersionKey,
agenttokens.RuntimeRoleMetadataKey,
)
metadataChanged := runtimeRole == ""
if runtimeRole == "" {
record.Metadata[agenttokens.RuntimeRoleMetadataKey] = agenttokens.CredentialKindLegacyFullTrust
}
if decision.backfillID {
record.Metadata["bound_agent_id"] = requestedID
boundID = requestedID
metadataChanged = true
}
if decision.backfillHost || decision.rebindHostname {
record.Metadata["bound_hostname"] = requestedHost
boundHost = requestedHost
metadataChanged = true
}
if metadataChanged {
record.Metadata["bound_at"] = time.Now().UTC().Format(time.RFC3339)
record.Metadata[agentExecBindingVersionKey] = agentExecBindingVersion
}
if metadataChanged && r.persistence != nil {
if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil {
restoreAgentExecMetadata(record.Metadata, previousMetadata)
config.Mu.Unlock()
log.Error().
Err(err).
Str("token_id", tokenID).
Msg("Failed to persist migrated agent exec token binding; command registration denied")
return agentexec.AgentAdmission{}, false
}
}
config.Mu.Unlock()
if decision.rebindHostname {
log.Info().
Str("token_id", tokenID).
Str("agent_id", requestedID).
Str("previous_hostname", previousHost).
Str("hostname", requestedHost).
Msg("Re-bound agent exec token hostname for matching immutable agent identity")
}
return agentexec.AgentAdmission{
OrganizationID: organizationID,
TokenID: tokenID,
AgentID: requestedID,
Hostname: requestedHost,
RuntimeRole: agentexec.RuntimeRoleLegacyFullTrust,
}, true
}
config.Mu.Unlock()
if boundID == "" && boundHost == "" {
log.Warn().
Str("token_id", tokenID).
Msg("Agent exec token missing binding metadata")
return agentexec.AgentAdmission{}, false
}
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 agentexec.AgentAdmission{}, false
}
func (r *Router) validateAgentExecSession(admission agentexec.AgentAdmission) bool {
if r == nil || r.config == nil {
return false
}
tokenID := strings.TrimSpace(admission.TokenID)
requestedID := strings.TrimSpace(admission.AgentID)
requestedHost := strings.TrimSpace(admission.Hostname)
if tokenID == "" || requestedID == "" || requestedHost == "" {
return false
}
config.Mu.Lock()
defer config.Mu.Unlock()
for index := range r.config.APITokens {
record := &r.config.APITokens[index]
if record.ID != tokenID || record.IsExpired() || !record.HasScope(config.ScopeAgentExec) {
continue
}
orgs := record.GetBoundOrgs()
organizationID := "default"
if len(orgs) > 1 {
return false
}
if len(orgs) == 1 && strings.TrimSpace(orgs[0]) != "" {
organizationID = strings.TrimSpace(orgs[0])
}
runtimeRole := strings.TrimSpace(record.Metadata[agenttokens.RuntimeRoleMetadataKey])
if runtimeRole == agenttokens.CredentialKindActionRunner {
return len(orgs) == 1 &&
strings.TrimSpace(admission.RuntimeRole) == agentexec.RuntimeRoleActionRunner &&
strings.TrimSpace(admission.ActionCapability) == agentexec.ActionCapabilityTypedV1 &&
organizationID == strings.TrimSpace(admission.OrganizationID) &&
agentbinding.EvaluateActionRunner(record, requestedID, requestedHost).Admit
}
if runtimeRole != agenttokens.CredentialKindLegacyFullTrust {
return false
}
return strings.TrimSpace(admission.RuntimeRole) == agentexec.RuntimeRoleLegacyFullTrust &&
organizationID == strings.TrimSpace(admission.OrganizationID) &&
strings.TrimSpace(record.Metadata["bound_agent_id"]) == requestedID &&
agentExecHostnamesMatch(strings.TrimSpace(record.Metadata["bound_hostname"]), requestedHost)
}
return false
}
// agentCommandSessionConnected reports whether a live command channel exists
// for a telemetry host. host.TokenID is sticky across token revocation and
// rotation (monitoring keeps the last-seen token on the host record), so a
// token-scoped miss must not be authoritative: fall through to the agent-ID
// and hostname lookups before declaring the channel disconnected. The
// token-first order still lets the canonical enrollment token win when its
// session is live.
func (r *Router) agentCommandSessionConnected(organizationID, tokenID, agentID, hostname string) bool {
if r == nil || r.agentExecServer == nil {
return false
}
if strings.TrimSpace(tokenID) != "" {
if _, connected := r.agentExecServer.GetAgentForTokenForOrganization(organizationID, tokenID); connected {
return true
}
}
if strings.TrimSpace(agentID) != "" && r.agentExecServer.IsAgentConnectedForOrganization(organizationID, agentID) {
return true
}
_, connected := r.agentExecServer.GetAgentForHostForOrganization(organizationID, hostname)
return connected
}
func canBindAgentInstallExecToken(record *config.APITokenRecord, agentID string, hostname string) bool {
return agentbinding.CanBindInstallToken(record, agentID, hostname)
}
// canBindAutoRegisteredAgentInstallExecToken reports whether an install token
// whose bound_hostname was populated by the Proxmox auto-register bootstrap may
// take the clean first-use exec bind. The registration path writes
// bound_hostname without bound_agent_id or a binding version, so the record
// looks identical to a pre-v6.1.1 deploy token; without this the first command
// enrollment of a freshly installed Proxmox host is admitted by the
// legacy-migration branch, which exists to repair old records and is not the
// contract this flow should depend on. Hostname equivalence is still required.
func canBindAutoRegisteredAgentInstallExecToken(record *config.APITokenRecord, agentID string, hostname string) bool {
return agentbinding.CanBindAutoRegisteredInstallToken(record, agentID, hostname)
}