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:
courtmanr@gmail.com
2026-07-27 17:45:21 +01:00
parent 4ea99183fd
commit bb73ec6cbd
10 changed files with 411 additions and 48 deletions
@@ -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
+119 -22
View File
@@ -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
+5 -13
View File
@@ -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 {
+172
View File
@@ -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
View File
@@ -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(