mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
Align agent command config gate with channel admission
v6.1.2 (c41edb65a) left the agent config gate and the command-channel
admission evaluating exec-token bindings under different policies: the
config gate admitted on bound-hostname OR bound-agent-ID while channel
admission required both to match, compared hostnames with plain case
folding instead of the system-wide short-vs-FQDN equivalence rule, and
had no recovery path for hosts whose immutable agent ID still matched
but whose hostname had drifted since binding. Affected agents kept
reporting CommandsEnabled=true while every channel registration was
rejected, so fleets showed a permanent "Remote control blocked" chip
with reinstall as the only recourse (reported by a customer with a
large Docker fleet after upgrading to v6.1.2).
- Single-source the binding decision in evaluateAgentExecBinding; both
admitAgentExecToken and commandConfigAllowedForToken now consume it,
so the config payload can never advertise command execution that
admission would reject.
- Treat the immutable machine-derived agent ID as the primary binding
identity: an exact ID match re-binds a drifted (renamed) hostname in
place instead of stranding the host; hostname match alone still fails
closed for version-2 bindings.
- Compare hostnames with unifiedresources.HostnamesEquivalent (plus
case-insensitive exact match for IP literals) across admission,
session validation, and legacy migration, so docker01 vs docker01.lan
no longer splits the decision.
- Stop treating a miss on the token-scoped connectivity lookup as
authoritative in the connections ledger: host.TokenID is sticky
across token rotation/revocation, and a shared token fronting more
than one live session fails closed in the token lookup, so fall
through to the agent-ID and hostname lookups before reporting an
enabled host as blocked.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2201,6 +2201,25 @@ Agent` secondary handoff against the live setup wizard instead of relying
|
||||
|
||||
## Current State
|
||||
|
||||
### Command-channel token binding is one decision consumed by every surface
|
||||
|
||||
Agent exec token binding is evaluated by a single decision function
|
||||
(`evaluateAgentExecBinding` in `internal/api/agent_exec_token_binding.go`)
|
||||
that both command-channel admission (`admitAgentExecToken`) and the agent
|
||||
config gate (`commandConfigAllowedForToken`) consume. The two surfaces may
|
||||
never diverge: an agent is told commands are enabled only when its channel
|
||||
registration would be admitted, because a divergent gate leaves the host
|
||||
reporting `CommandsEnabled=true` against a channel that is always rejected — the
|
||||
permanent "Remote control blocked" state shipped in v6.1.2. The immutable
|
||||
machine-derived agent ID is the primary binding identity: an exact ID match
|
||||
re-binds a drifted (renamed) `bound_hostname` in place rather than stranding
|
||||
the host, while a hostname match alone never satisfies a version-2 identity
|
||||
binding. Hostname comparison uses the system-wide equivalence rule
|
||||
(`unifiedresources.HostnamesEquivalent`, plus case-insensitive exact match
|
||||
for IP literals), so short-name vs fully-qualified drift does not break
|
||||
admission. Legacy pre-v6.1.1 hostname-bound records still migrate exactly
|
||||
once on hostname match.
|
||||
|
||||
### PBS connection health does not create agent lifecycle evidence
|
||||
|
||||
The shared API connections ledger and diagnostics now project PBS health from
|
||||
|
||||
@@ -3668,6 +3668,22 @@ auto-register mutation boundary.
|
||||
|
||||
## Current State
|
||||
|
||||
### Connections command-channel liveness tolerates stale host token IDs
|
||||
|
||||
The `/api/connections` ledger's `RemoteControl` and `CommandPolicy` signals
|
||||
derive command-channel liveness through a three-stage lookup: the host's
|
||||
recorded enrollment token ID first, then the agent ID, then the hostname
|
||||
(`Router.agentCommandSessionConnected`). A miss on the token-scoped lookup is
|
||||
never authoritative because `host.TokenID` is sticky across token revocation
|
||||
and rotation — monitoring retains the last-seen token on the host record — and
|
||||
because a single token may front more than one live session, which the
|
||||
token-scoped lookup deliberately fails closed on. Only after all three lookups
|
||||
miss may the ledger report an enabled host as `blocked`. The agent config
|
||||
payload gate (`commandConfigAllowedForToken`) mirrors the command-channel
|
||||
admission decision exactly via the shared binding evaluation in
|
||||
`internal/api/agent_exec_token_binding.go`, so the config payload never
|
||||
advertises command execution that admission would reject.
|
||||
|
||||
### Agent update target responses are reconciliation-safe
|
||||
|
||||
`GET` and `HEAD /api/agent/version` project
|
||||
|
||||
@@ -877,6 +877,16 @@ still remove an authoritatively deleted guest. These rules preserve sort,
|
||||
selection, drawer, and virtualized viewport state without adding another
|
||||
resource scan, websocket subscription, or browser-local source of truth.
|
||||
|
||||
### Command-session liveness lookup stays bounded and in-memory
|
||||
|
||||
The connections ledger's command-channel liveness check
|
||||
(`Router.agentCommandSessionConnected`) is at most three in-memory scans of
|
||||
the live agent registry per agent row — token ID, then agent ID, then
|
||||
hostname — each under a read lock with no network I/O, persistence reads, or
|
||||
background fan-out. The fallback stages exist for correctness against stale
|
||||
recorded token IDs and must not grow into per-request enumeration of token
|
||||
stores or durable state.
|
||||
|
||||
### Canonical mutation-plane dependency
|
||||
|
||||
Router wiring now exposes only typed action planning for model-originated
|
||||
|
||||
@@ -864,8 +864,17 @@ Proxmox install-command tokens. `internal/api/agent_exec_token_binding.go` may
|
||||
persist `bound_agent_id`, `bound_hostname`, and `bound_at` only for
|
||||
Pulse-minted PVE/PBS install-command tokens when the command agent first
|
||||
registers. Generic unbound `agent:exec` tokens, or tokens already bound to a
|
||||
different hostname or agent ID, must fail closed so command execution cannot
|
||||
cross hosts through reusable bearer credentials.
|
||||
different agent, must fail closed so command execution cannot cross hosts
|
||||
through reusable bearer credentials. Within that boundary the immutable
|
||||
machine-derived agent ID is the binding identity: a version-2 binding whose
|
||||
agent ID does not match fails closed even when the hostname matches, while a
|
||||
token whose agent ID matches exactly may re-bind a drifted `bound_hostname`
|
||||
in place, because a hostname is an operator-renamable label, not a second
|
||||
credential. Hostname comparison follows the system-wide short-name vs
|
||||
fully-qualified equivalence rule rather than ad-hoc case folding. The
|
||||
accept/reject decision is single-sourced (`evaluateAgentExecBinding`) and the
|
||||
agent config gate must consume the same decision, so no surface can advertise
|
||||
command execution that channel admission would refuse.
|
||||
Telemetry/privacy disclosures now also route through the shipped frontend docs
|
||||
boundary: `frontend-modern/src/utils/docsLinks.ts` is the canonical frontend
|
||||
owner for privacy-document URLs, while `frontend-modern/public/docs/PRIVACY.md`
|
||||
|
||||
@@ -1985,6 +1985,17 @@ that Safe auto-fix or Autopilot remediation is verified.
|
||||
|
||||
## Current State
|
||||
|
||||
### Agent exec token binding repairs are fail-closed durable writes
|
||||
|
||||
Command-token binding metadata (`bound_agent_id`, `bound_hostname`,
|
||||
`bound_at`, binding version) is repaired only through one-shot migrations
|
||||
inside admission — first-use bind, legacy identity migration, backfill, and
|
||||
hostname re-bind after a host rename. Each repair persists through
|
||||
`SaveAPITokens` before admission succeeds; a failed persistence write
|
||||
restores the prior in-memory metadata and denies the registration rather
|
||||
than admitting a session whose binding would not survive a restart. Steady
|
||||
state registrations perform no token-store writes.
|
||||
|
||||
### Proxmox runtime continuity is not protection evidence
|
||||
|
||||
The additive `ProxmoxData.RuntimeStatus` field preserves VM/LXC power-state
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
@@ -45,6 +46,77 @@ func (r *Router) validateAgentExecToken(token string, agentID string, hostname s
|
||||
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 strings.EqualFold(bound, requested) || unifiedresources.HostnamesEquivalent(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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if record == nil {
|
||||
return agentExecBindingDecision{}
|
||||
}
|
||||
requestedID = strings.TrimSpace(requestedID)
|
||||
requestedHost = strings.TrimSpace(requestedHost)
|
||||
boundID := strings.TrimSpace(record.Metadata["bound_agent_id"])
|
||||
boundHost := strings.TrimSpace(record.Metadata["bound_hostname"])
|
||||
|
||||
if boundID == "" && boundHost == "" {
|
||||
if canBindAgentInstallExecToken(record, requestedID, requestedHost) {
|
||||
return agentExecBindingDecision{admit: true, firstBind: true}
|
||||
}
|
||||
return agentExecBindingDecision{}
|
||||
}
|
||||
|
||||
// Pre-v6.1.1 deploy tokens could carry a server-synthesized agent ID even
|
||||
// though the runtime derives its ID from machine-id. Migrate that
|
||||
// hostname-bound legacy record exactly once, then enforce identity.
|
||||
if strings.TrimSpace(record.Metadata[agentExecBindingVersionKey]) != agentExecBindingVersion &&
|
||||
boundHost != "" && agentExecHostnamesMatch(boundHost, requestedHost) {
|
||||
return agentExecBindingDecision{admit: true, legacyMigrate: true}
|
||||
}
|
||||
|
||||
idMatches := boundID == "" || boundID == requestedID
|
||||
hostMatches := boundHost == "" || agentExecHostnamesMatch(boundHost, requestedHost)
|
||||
// The runtime agent ID is immutable machine identity while hostnames can
|
||||
// be renamed after enrollment, so an exact ID match re-binds a drifted
|
||||
// hostname rather than stranding the host: v6.1.1 admitted these agents
|
||||
// under an ID-or-hostname rule, and rejecting them afterwards leaves no
|
||||
// operator recourse short of reinstalling the agent.
|
||||
rebindHostname := boundID != "" && boundID == requestedID && !hostMatches && requestedHost != ""
|
||||
if !idMatches || (!hostMatches && !rebindHostname) {
|
||||
return agentExecBindingDecision{}
|
||||
}
|
||||
return agentExecBindingDecision{
|
||||
admit: true,
|
||||
rebindHostname: rebindHostname,
|
||||
backfillID: boundID == "" && boundHost != "",
|
||||
backfillHost: boundHost == "" && boundID != "",
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) admitAgentExecToken(token string, agentID string, hostname string) (agentexec.AgentAdmission, bool) {
|
||||
if r == nil || r.config == nil {
|
||||
return agentexec.AgentAdmission{}, false
|
||||
@@ -92,7 +164,10 @@ func (r *Router) admitAgentExecToken(token string, agentID string, hostname stri
|
||||
|
||||
boundID := strings.TrimSpace(record.Metadata["bound_agent_id"])
|
||||
boundHost := strings.TrimSpace(record.Metadata["bound_hostname"])
|
||||
if boundID == "" && boundHost == "" && canBindAgentInstallExecToken(record, requestedID, requestedHost) {
|
||||
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 {
|
||||
@@ -135,21 +210,8 @@ func (r *Router) admitAgentExecToken(token string, agentID string, hostname stri
|
||||
AgentID: requestedID,
|
||||
Hostname: requestedHost,
|
||||
}, true
|
||||
}
|
||||
|
||||
if boundID == "" && boundHost == "" {
|
||||
config.Mu.Unlock()
|
||||
log.Warn().
|
||||
Str("token_id", tokenID).
|
||||
Msg("Agent exec token missing binding metadata")
|
||||
return agentexec.AgentAdmission{}, false
|
||||
}
|
||||
|
||||
// Pre-v6.1.1 deploy tokens could carry a server-synthesized agent ID even
|
||||
// though the runtime derives its ID from machine-id. Migrate that
|
||||
// hostname-bound legacy record exactly once, then enforce both fields.
|
||||
if strings.TrimSpace(record.Metadata[agentExecBindingVersionKey]) != agentExecBindingVersion &&
|
||||
boundHost != "" && strings.EqualFold(boundHost, requestedHost) {
|
||||
case decision.legacyMigrate:
|
||||
previousID := boundID
|
||||
previousMetadata := snapshotAgentExecMetadata(
|
||||
record.Metadata,
|
||||
@@ -184,11 +246,9 @@ func (r *Router) admitAgentExecToken(token string, agentID string, hostname stri
|
||||
AgentID: requestedID,
|
||||
Hostname: requestedHost,
|
||||
}, true
|
||||
}
|
||||
|
||||
idMatches := boundID == "" || boundID == requestedID
|
||||
hostMatches := boundHost == "" || strings.EqualFold(boundHost, requestedHost)
|
||||
if idMatches && hostMatches {
|
||||
case decision.admit:
|
||||
previousHost := boundHost
|
||||
previousMetadata := snapshotAgentExecMetadata(
|
||||
record.Metadata,
|
||||
"bound_agent_id",
|
||||
@@ -197,12 +257,12 @@ func (r *Router) admitAgentExecToken(token string, agentID string, hostname stri
|
||||
agentExecBindingVersionKey,
|
||||
)
|
||||
metadataChanged := false
|
||||
if boundID == "" && boundHost != "" {
|
||||
if decision.backfillID {
|
||||
record.Metadata["bound_agent_id"] = requestedID
|
||||
boundID = requestedID
|
||||
metadataChanged = true
|
||||
}
|
||||
if boundHost == "" && boundID != "" {
|
||||
if decision.backfillHost || decision.rebindHostname {
|
||||
record.Metadata["bound_hostname"] = requestedHost
|
||||
boundHost = requestedHost
|
||||
metadataChanged = true
|
||||
@@ -223,6 +283,14 @@ func (r *Router) admitAgentExecToken(token string, agentID string, hostname stri
|
||||
}
|
||||
}
|
||||
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,
|
||||
@@ -232,6 +300,12 @@ func (r *Router) admitAgentExecToken(token string, agentID string, hostname stri
|
||||
}
|
||||
|
||||
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).
|
||||
@@ -270,11 +344,34 @@ func (r *Router) validateAgentExecSession(admission agentexec.AgentAdmission) bo
|
||||
}
|
||||
return organizationID == strings.TrimSpace(admission.OrganizationID) &&
|
||||
strings.TrimSpace(record.Metadata["bound_agent_id"]) == requestedID &&
|
||||
strings.EqualFold(strings.TrimSpace(record.Metadata["bound_hostname"]), requestedHost)
|
||||
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 {
|
||||
if record == nil || strings.TrimSpace(agentID) == "" || strings.TrimSpace(hostname) == "" {
|
||||
return false
|
||||
|
||||
@@ -539,19 +539,11 @@ func commandConfigAllowedForToken(record *config.APITokenRecord, host models.Hos
|
||||
return false
|
||||
}
|
||||
|
||||
requestedID := strings.TrimSpace(host.ID)
|
||||
requestedHost := strings.TrimSpace(host.Hostname)
|
||||
boundID := strings.TrimSpace(record.Metadata["bound_agent_id"])
|
||||
boundHost := strings.TrimSpace(record.Metadata["bound_hostname"])
|
||||
|
||||
if boundHost != "" && requestedHost != "" && strings.EqualFold(boundHost, requestedHost) {
|
||||
return true
|
||||
}
|
||||
if boundID != "" && requestedID != "" && boundID == requestedID {
|
||||
return true
|
||||
}
|
||||
|
||||
return boundID == "" && boundHost == "" && canBindAgentInstallExecToken(record, requestedID, requestedHost)
|
||||
// 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).
|
||||
return evaluateAgentExecBinding(record, host.ID, host.Hostname).admit
|
||||
}
|
||||
|
||||
func (h *UnifiedAgentHandlers) ensureAgentTokenMatch(w http.ResponseWriter, r *http.Request, agentID string) bool {
|
||||
|
||||
@@ -21763,3 +21763,175 @@ func TestAdminRecoverySurvivesFailedLegacyRBACMigration(t *testing.T) {
|
||||
t.Fatalf("legacy source was not preserved for a retry: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContract_AgentCommandConfigGateMirrorsChannelAdmission pins the v6.1.2
|
||||
// regression where the agent config gate (commandConfigAllowedForToken) and
|
||||
// command-channel admission (admitAgentExecToken) evaluated token bindings
|
||||
// under different policies: the gate admitted on bound-hostname OR bound-ID
|
||||
// while the channel required both, so an agent could be told commands were
|
||||
// enabled while every channel registration was rejected — the host surfaced
|
||||
// as "Remote control blocked" permanently, with reinstall as the only exit.
|
||||
// Both surfaces must return the same verdict for the same agent identity.
|
||||
func TestContract_AgentCommandConfigGateMirrorsChannelAdmission(t *testing.T) {
|
||||
const rawToken = "gate-parity-token-123.12345678"
|
||||
cases := []struct {
|
||||
name string
|
||||
metadata map[string]string
|
||||
agentID string
|
||||
hostname string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "exact identity match admits",
|
||||
metadata: map[string]string{
|
||||
"bound_agent_id": "agent-1",
|
||||
"bound_hostname": "docker01",
|
||||
agentExecBindingVersionKey: agentExecBindingVersion,
|
||||
},
|
||||
agentID: "agent-1",
|
||||
hostname: "docker01",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "immutable id match re-binds a renamed hostname",
|
||||
metadata: map[string]string{
|
||||
"bound_agent_id": "agent-1",
|
||||
"bound_hostname": "docker01",
|
||||
agentExecBindingVersionKey: agentExecBindingVersion,
|
||||
},
|
||||
agentID: "agent-1",
|
||||
hostname: "docker01-renamed",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "short bound hostname matches fully-qualified variant",
|
||||
metadata: map[string]string{
|
||||
"bound_agent_id": "agent-1",
|
||||
"bound_hostname": "docker01",
|
||||
agentExecBindingVersionKey: agentExecBindingVersion,
|
||||
},
|
||||
agentID: "agent-1",
|
||||
hostname: "docker01.lan",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "hostname match alone does not satisfy an immutable identity binding",
|
||||
metadata: map[string]string{
|
||||
"bound_agent_id": "agent-1",
|
||||
"bound_hostname": "docker01",
|
||||
agentExecBindingVersionKey: agentExecBindingVersion,
|
||||
},
|
||||
agentID: "agent-2",
|
||||
hostname: "docker01",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "legacy hostname-bound record migrates on hostname match",
|
||||
metadata: map[string]string{
|
||||
"bound_agent_id": "agent-docker01",
|
||||
"bound_hostname": "docker01",
|
||||
},
|
||||
agentID: "f0c1b2a3e4d5f60718293a4b5c6d7e8f",
|
||||
hostname: "docker01",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "unbound install token binds on first use",
|
||||
metadata: map[string]string{
|
||||
"install_type": agentInstallTypeHost,
|
||||
"issued_via": agentInstallIssuedViaConfig,
|
||||
},
|
||||
agentID: "agent-1",
|
||||
hostname: "docker01",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "unbound token without install provenance stays rejected",
|
||||
metadata: map[string]string{},
|
||||
agentID: "agent-1",
|
||||
hostname: "docker01",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
record := newTokenRecord(t, rawToken, []string{config.ScopeAgentExec}, tc.metadata)
|
||||
cfg := newTestConfigWithTokens(t, record)
|
||||
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
||||
|
||||
gate := commandConfigAllowedForToken(&cfg.APITokens[0], models.Host{
|
||||
ID: tc.agentID,
|
||||
Hostname: tc.hostname,
|
||||
})
|
||||
if gate != tc.want {
|
||||
t.Fatalf("commandConfigAllowedForToken = %v, want %v", gate, tc.want)
|
||||
}
|
||||
|
||||
_, admitted := router.admitAgentExecToken(rawToken, tc.agentID, tc.hostname)
|
||||
if admitted != tc.want {
|
||||
t.Fatalf("admitAgentExecToken = %v, want %v", admitted, tc.want)
|
||||
}
|
||||
if gate != admitted {
|
||||
t.Fatalf("config gate (%v) diverged from channel admission (%v)", gate, admitted)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestContract_AgentCommandSessionLookupSurvivesStaleHostTokenID pins the
|
||||
// connectivity-lookup contract behind the connections ledger's RemoteControl
|
||||
// and CommandPolicy signals. host.TokenID is sticky across token revocation
|
||||
// and rotation, so a miss on the token-scoped lookup must fall through to the
|
||||
// agent-ID and hostname lookups instead of reporting a live, admitted command
|
||||
// channel as blocked.
|
||||
func TestContract_AgentCommandSessionLookupSurvivesStaleHostTokenID(t *testing.T) {
|
||||
rawToken := "stale-lookup-agent-token-123.12345678"
|
||||
record := newTokenRecord(t, rawToken, []string{config.ScopeAgentExec}, map[string]string{
|
||||
"bound_agent_id": "agent-1",
|
||||
"bound_hostname": "host-1",
|
||||
agentExecBindingVersionKey: agentExecBindingVersion,
|
||||
})
|
||||
cfg := newTestConfigWithTokens(t, record)
|
||||
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
||||
|
||||
ts := newIPv4HTTPServer(t, router.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
wsURL := wsURLForHTTP(ts.URL) + "/api/agent/ws"
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL))
|
||||
if err != nil {
|
||||
t.Fatalf("Dial: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
regMsg, err := agentexec.NewMessage(agentexec.MsgTypeAgentRegister, "", agentexec.AgentRegisterPayload{
|
||||
AgentID: "agent-1",
|
||||
Hostname: "host-1",
|
||||
Version: "1.0.0",
|
||||
Platform: "linux",
|
||||
Token: rawToken,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewMessage: %v", err)
|
||||
}
|
||||
if err := conn.WriteJSON(regMsg); err != nil {
|
||||
t.Fatalf("WriteJSON: %v", err)
|
||||
}
|
||||
if reg := readRegisteredPayload(t, conn); !reg.Success {
|
||||
t.Fatalf("expected registration to succeed, got %q", reg.Message)
|
||||
}
|
||||
|
||||
tokenID := cfg.APITokens[0].ID
|
||||
if !router.agentCommandSessionConnected("default", tokenID, "agent-1", "host-1") {
|
||||
t.Fatal("live session not found via its canonical token ID")
|
||||
}
|
||||
if !router.agentCommandSessionConnected("default", "rotated-away-token-id", "agent-1", "host-1") {
|
||||
t.Fatal("stale host token ID hid a live command session from the agent-ID fallback")
|
||||
}
|
||||
if !router.agentCommandSessionConnected("default", "rotated-away-token-id", "", "host-1") {
|
||||
t.Fatal("stale host token ID hid a live command session from the hostname fallback")
|
||||
}
|
||||
if router.agentCommandSessionConnected("default", "rotated-away-token-id", "agent-x", "host-x") {
|
||||
t.Fatal("unknown agent reported as connected")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
|
||||
agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
|
||||
)
|
||||
@@ -319,3 +320,49 @@ func TestHostAgentRemovalLifecycleThroughAuthenticatedRouterAndRestart(t *testin
|
||||
t.Fatalf("shared token resolved config for %q, want active keeper %q", oldConfigBody.AgentID, keeperID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHostAgentRenameKeepsCommandGateAlignedWithChannelAdmission covers the
|
||||
// host-rename leg of the agent lifecycle: a host that renames itself (or
|
||||
// starts reporting a fully-qualified hostname) after its exec token was bound
|
||||
// must keep its command channel. v6.1.2 rejected the drifted hostname at
|
||||
// channel admission while the config gate kept telling the agent commands
|
||||
// were enabled, so renamed hosts were stranded on "Remote control blocked"
|
||||
// until the agent was reinstalled.
|
||||
func TestHostAgentRenameKeepsCommandGateAlignedWithChannelAdmission(t *testing.T) {
|
||||
const rawToken = "rename-lifecycle-token-123.12345678"
|
||||
record := newTokenRecord(t, rawToken, []string{config.ScopeAgentExec}, map[string]string{
|
||||
"bound_agent_id": "agent-1",
|
||||
"bound_hostname": "docker01",
|
||||
agentExecBindingVersionKey: agentExecBindingVersion,
|
||||
})
|
||||
cfg := newTestConfigWithTokens(t, record)
|
||||
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
||||
tokenRecord := &cfg.APITokens[0]
|
||||
|
||||
// The same machine starts reporting its fully-qualified name.
|
||||
if !commandConfigAllowedForToken(tokenRecord, models.Host{ID: "agent-1", Hostname: "docker01.lan"}) {
|
||||
t.Fatal("config gate rejected the fully-qualified variant of the bound hostname")
|
||||
}
|
||||
if _, ok := router.admitAgentExecToken(rawToken, "agent-1", "docker01.lan"); !ok {
|
||||
t.Fatal("channel admission rejected the fully-qualified variant of the bound hostname")
|
||||
}
|
||||
|
||||
// The host is renamed outright; the immutable agent ID still matches.
|
||||
if !commandConfigAllowedForToken(tokenRecord, models.Host{ID: "agent-1", Hostname: "web01"}) {
|
||||
t.Fatal("config gate rejected a renamed host with a matching immutable agent ID")
|
||||
}
|
||||
if _, ok := router.admitAgentExecToken(rawToken, "agent-1", "web01"); !ok {
|
||||
t.Fatal("channel admission rejected a renamed host with a matching immutable agent ID")
|
||||
}
|
||||
if got := tokenRecord.Metadata["bound_hostname"]; got != "web01" {
|
||||
t.Fatalf("admission did not re-bind the drifted hostname: bound_hostname = %q", got)
|
||||
}
|
||||
|
||||
// A different machine presenting the original hostname stays rejected.
|
||||
if commandConfigAllowedForToken(tokenRecord, models.Host{ID: "agent-2", Hostname: "web01"}) {
|
||||
t.Fatal("config gate admitted a different agent ID on a matching hostname")
|
||||
}
|
||||
if _, ok := router.admitAgentExecToken(rawToken, "agent-2", "web01"); ok {
|
||||
t.Fatal("channel admission admitted a different agent ID on a matching hostname")
|
||||
}
|
||||
}
|
||||
|
||||
+1
-11
@@ -689,17 +689,7 @@ func (r *Router) setupRoutes() {
|
||||
r.agentExecServer = agentexec.NewServerWithAdmissionValidator(r.admitAgentExecToken, r.validateAgentExecSession)
|
||||
r.agentExecServer.SetCommandAuthorizationVerifier(verifyAndConsumeCommandAuthorization)
|
||||
if r.connectionsHandlers != nil {
|
||||
r.connectionsHandlers.SetAgentCommandSessionProvider(func(organizationID, tokenID, agentID, hostname string) bool {
|
||||
if strings.TrimSpace(tokenID) != "" {
|
||||
_, connected := r.agentExecServer.GetAgentForTokenForOrganization(organizationID, tokenID)
|
||||
return connected
|
||||
}
|
||||
if strings.TrimSpace(agentID) != "" && r.agentExecServer.IsAgentConnectedForOrganization(organizationID, agentID) {
|
||||
return true
|
||||
}
|
||||
_, connected := r.agentExecServer.GetAgentForHostForOrganization(organizationID, hostname)
|
||||
return connected
|
||||
})
|
||||
r.connectionsHandlers.SetAgentCommandSessionProvider(r.agentCommandSessionConnected)
|
||||
}
|
||||
if r.resourceHandlers != nil {
|
||||
r.resourceHandlers.SetActionExecutor(newRoutedActionExecutor(
|
||||
|
||||
Reference in New Issue
Block a user