From bb73ec6cbdb4b4a726690f9de8b1d4a755708acd Mon Sep 17 00:00:00 2001 From: "courtmanr@gmail.com" Date: Mon, 27 Jul 2026 17:45:21 +0100 Subject: [PATCH] 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 --- .../v6/internal/subsystems/agent-lifecycle.md | 19 ++ .../v6/internal/subsystems/api-contracts.md | 16 ++ .../subsystems/performance-and-scalability.md | 10 + .../internal/subsystems/security-privacy.md | 13 +- .../internal/subsystems/storage-recovery.md | 11 ++ internal/api/agent_exec_token_binding.go | 141 +++++++++++--- internal/api/agent_ingest.go | 18 +- internal/api/contract_test.go | 172 ++++++++++++++++++ ...gent_removal_lifecycle_integration_test.go | 47 +++++ internal/api/router.go | 12 +- 10 files changed, 411 insertions(+), 48 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index a04142ebe..a756eca96 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 797bcdf1b..22ff8d4df 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index dec87ef46..732dd8284 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index a72ec6c5b..5fc76fceb 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -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` diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index eff1f8a67..ab1b8990e 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.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 diff --git a/internal/api/agent_exec_token_binding.go b/internal/api/agent_exec_token_binding.go index 235144cdb..36d401f0d 100644 --- a/internal/api/agent_exec_token_binding.go +++ b/internal/api/agent_exec_token_binding.go @@ -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 diff --git a/internal/api/agent_ingest.go b/internal/api/agent_ingest.go index 8882cdfec..86a196541 100644 --- a/internal/api/agent_ingest.go +++ b/internal/api/agent_ingest.go @@ -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 { diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 187718eed..082fadf4c 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -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") + } +} diff --git a/internal/api/host_agent_removal_lifecycle_integration_test.go b/internal/api/host_agent_removal_lifecycle_integration_test.go index 00c3e6f1c..0e02b0ad2 100644 --- a/internal/api/host_agent_removal_lifecycle_integration_test.go +++ b/internal/api/host_agent_removal_lifecycle_integration_test.go @@ -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") + } +} diff --git a/internal/api/router.go b/internal/api/router.go index aead8f12d..dd3cde4ec 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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(