diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 4b681e6b2..a3e6a9abd 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -1361,6 +1361,17 @@ surface and no new `internal/api/` lifecycle handler. into a short-name collapse that would make two different FQDNs appear to be the same lifecycle target. 6. Keep Proxmox registration continuity self-healing: stale local registration markers must be verified against Pulse before the host agent skips setup, and a missing matching node on the Pulse side must drive canonical re-registration instead of asking operators to delete marker files manually. + Runtime-side Proxmox setup must keep Pulse-managed Proxmox API tokens + scoped to the concrete node before falling back to the Pulse URL. PVE API + tokens are cluster-wide, so two cluster members that report to the same + Pulse server must not share one token name or let a later node install + rotate the token stored by an earlier node. + Reusable Infrastructure install tokens minted by the browser setup handoff + are reporting and config-read credentials, with Docker and Kubernetes + reporting scope when needed. They must not include `agent:exec`; command + execution is granted only by backend-issued host-bound runtime tokens or by + first-use Proxmox install-command tokens carrying the governed install + metadata that the command websocket can bind to one agent identity. 7. Keep first-session lifecycle handoff explicit: the live setup completion surface in `frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx` must route the primary CTA into `/settings/infrastructure?add=pick`, frame diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 499200cde..117e4322a 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -2585,6 +2585,22 @@ a new API state machine, queue contract, or verification-accounting field. 2. Update frontend API types in the same slice 3. Route runtime changes through the explicit API-contract proof policies in `registry.json`; default fallback proof routing is not allowed 4. Update this contract when canonical payload ownership changes + Reusable install-token generation in + `frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx` + must stay outside the command-execution scope. The browser may request + reporting/config plus Docker and Kubernetes reporting scopes for reusable + installer transport, but it must not add `agent:exec`; command-capable + install commands must come from the backend command-generation path with + host-bound metadata or first-use Proxmox install metadata. + Signed Unified Agent config responses must honor the command-registration + token boundary before returning `commandsEnabled:true`. When a real API + token record is present on an agent config/report request, the backend may + sign or echo command enablement as true only if that token has `agent:exec` + and is already bound to the requested agent by hostname or agent id, or is + an unbound Pulse-minted Proxmox install-command token carrying the governed + first-use metadata. Generic or unbound command-scope tokens must receive + `commandsEnabled:false` in the response while the stored desired setting + remains unchanged for a later correctly bound token. 5. Keep `/api/resources` policy metadata aligned across backend payload tests and canonical frontend resource consumers whenever sensitivity or routing fields change 6. Keep Patrol status payloads explicit enough that the frontend can present blocked runtime state without treating a previously healthy summary snapshot as current runtime truth, and keep Patrol recency semantics explicit in transport by reserving `last_patrol_at` for completed full patrols while exposing any Patrol activity separately through `last_activity_at` and the scoped-trigger status payload on that same Patrol status surface, so queued scoped work, busy-mode state, and per-source enablement (`alert` versus `anomaly`) stay transport-backed instead of being inferred by page-local heuristics diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index f6dac90ca..de41e49c6 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -82,6 +82,11 @@ diagnostics flows may observe the API-owned `bound_agent_id`, `bound_hostname`, and `bound_at` metadata, but they must not rebind generic `agent:exec` tokens or treat a Proxmox install-command token as recoverable for another host after the first command registration identity has been persisted. +Remote-config suppression of command enablement for generic or unbound +command-scope tokens is part of that same adjacent API boundary. Storage and +recovery surfaces may observe disconnected command readiness, but they must not +re-enable command config, reinterpret the suppression as backup or restore +state, or introduce a recovery-local command-token binding path. Hosted tenant agent install commands in `internal/api/cloud_agent_install_command.go` are adjacent API/lifecycle transport only. A provider-hosted MSP PVE/PBS install diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx index 9fa7d8a5a..59559b134 100644 --- a/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx @@ -333,6 +333,14 @@ describe('infrastructure operations model', () => { ); }); + it('keeps reusable install tokens out of the command-exec scope', () => { + expect(useInfrastructureInstallStateSource).toContain('AGENT_REPORT_SCOPE'); + expect(useInfrastructureInstallStateSource).toContain('AGENT_CONFIG_READ_SCOPE'); + expect(useInfrastructureInstallStateSource).toContain('DOCKER_REPORT_SCOPE'); + expect(useInfrastructureInstallStateSource).toContain('KUBERNETES_REPORT_SCOPE'); + expect(useInfrastructureInstallStateSource).not.toContain('AGENT_EXEC_SCOPE'); + }); + it('keeps infrastructure install and operations surfaces free of retired commercial telemetry wrappers', () => { for (const source of [ infrastructureOperationsModelSource, diff --git a/frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx b/frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx index c644071c1..23b74a116 100644 --- a/frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx +++ b/frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx @@ -7,7 +7,6 @@ import type { AgentLookupResponse, ConnectedInfrastructureItem } from '@/types/a import type { SecurityStatus } from '@/types/config'; import { AGENT_CONFIG_READ_SCOPE, - AGENT_EXEC_SCOPE, AGENT_REPORT_SCOPE, DOCKER_REPORT_SCOPE, KUBERNETES_REPORT_SCOPE, @@ -263,7 +262,6 @@ Pulse prepares the first-host install token from setup so you can move straight AGENT_CONFIG_READ_SCOPE, DOCKER_REPORT_SCOPE, KUBERNETES_REPORT_SCOPE, - AGENT_EXEC_SCOPE, ]; const { token, record } = await SecurityAPI.createToken(desiredName, scopes); @@ -278,7 +276,7 @@ Pulse prepares the first-host install token from setup so you can move straight setSetupHandoffAutoTokenFailed(false); if (options.notifySuccess) { notificationStore.success( - 'Token generated with Agent config + reporting, Docker, and Kubernetes permissions.', + 'Token generated with Agent config, reporting, Docker, and Kubernetes permissions.', 4000, ); } diff --git a/internal/api/agent_ingest.go b/internal/api/agent_ingest.go index 362b1ad7f..25f44623d 100644 --- a/internal/api/agent_ingest.go +++ b/internal/api/agent_ingest.go @@ -99,6 +99,7 @@ func (h *UnifiedAgentHandlers) HandleReport(w http.ResponseWriter, r *http.Reque // Include any server-side config overrides in the response serverConfig := h.getMonitor(r.Context()).GetHostAgentConfig(host.ID) + serverConfig = sanitizeHostAgentConfigForToken(serverConfig, tokenRecord, host) resp := map[string]any{ "success": true, @@ -482,6 +483,7 @@ func (h *UnifiedAgentHandlers) handleGetConfig(w http.ResponseWriter, r *http.Re agentID = host.ID config := h.getMonitor(r.Context()).GetHostAgentConfig(agentID) + config = sanitizeHostAgentConfigForToken(config, record, host) signedConfig, err := h.signAgentConfig(agentID, config) if err != nil { log.Error().Err(err).Msg("Failed to sign agent config payload") @@ -515,6 +517,39 @@ func tokenID(record *config.APITokenRecord) string { return record.ID } +func sanitizeHostAgentConfigForToken(cfg monitoring.HostAgentConfig, record *config.APITokenRecord, host models.Host) monitoring.HostAgentConfig { + if cfg.CommandsEnabled == nil || !*cfg.CommandsEnabled || commandConfigAllowedForToken(record, host) { + return cfg + } + + disabled := false + cfg.CommandsEnabled = &disabled + return cfg +} + +func commandConfigAllowedForToken(record *config.APITokenRecord, host models.Host) bool { + if record == nil { + return true + } + if !record.HasScope(config.ScopeAgentExec) { + 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 == "" && canBindProxmoxAgentInstallExecToken(record, requestedID, requestedHost) +} + func (h *UnifiedAgentHandlers) ensureAgentTokenMatch(w http.ResponseWriter, r *http.Request, agentID string) bool { record := getAPITokenRecordFromRequest(r) if record == nil { diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 5eff70191..984874abd 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -12706,6 +12706,86 @@ func TestContract_AgentExecWebSocketBindsProxmoxInstallTokenOnFirstUse(t *testin } } +func TestContract_AgentConfigSuppressesCommandsForUnboundExecToken(t *testing.T) { + handler, monitor := newUnifiedAgentHandlers(t, nil) + hostID := seedUnifiedAgentHost(t, monitor) + state := monitorState(t, monitor) + state.UpsertHost(models.Host{ + ID: hostID, + Hostname: "node-1", + TokenID: "runtime-token", + }) + + commandsEnabled := true + if err := monitor.UpdateHostAgentConfig(hostID, &commandsEnabled); err != nil { + t.Fatalf("UpdateHostAgentConfig: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/agents/agent/"+hostID+"/config", nil) + attachAPITokenRecord(req, &config.APITokenRecord{ + ID: "runtime-token", + Scopes: []string{config.ScopeAgentConfigRead, config.ScopeAgentReport, config.ScopeAgentExec}, + }) + rec := httptest.NewRecorder() + handler.HandleConfig(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) + } + + var resp struct { + Config monitoring.HostAgentConfig `json:"config"` + } + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode config response: %v", err) + } + if resp.Config.CommandsEnabled == nil || *resp.Config.CommandsEnabled { + t.Fatalf("commandsEnabled = %+v, want false for an unbound command token", resp.Config.CommandsEnabled) + } +} + +func TestContract_AgentConfigAllowsFirstUseProxmoxInstallCommandToken(t *testing.T) { + handler, monitor := newUnifiedAgentHandlers(t, nil) + hostID := seedUnifiedAgentHost(t, monitor) + state := monitorState(t, monitor) + state.UpsertHost(models.Host{ + ID: hostID, + Hostname: "pve-node-1", + TokenID: "proxmox-runtime-token", + }) + + commandsEnabled := true + if err := monitor.UpdateHostAgentConfig(hostID, &commandsEnabled); err != nil { + t.Fatalf("UpdateHostAgentConfig: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/agents/agent/"+hostID+"/config", nil) + attachAPITokenRecord(req, &config.APITokenRecord{ + ID: "proxmox-runtime-token", + Scopes: []string{config.ScopeAgentConfigRead, config.ScopeAgentReport, config.ScopeAgentExec}, + Metadata: map[string]string{ + "install_type": proxmoxInstallTypePVE, + "issued_via": agentInstallIssuedViaConfig, + }, + }) + rec := httptest.NewRecorder() + handler.HandleConfig(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) + } + + var resp struct { + Config monitoring.HostAgentConfig `json:"config"` + } + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode config response: %v", err) + } + if resp.Config.CommandsEnabled == nil || !*resp.Config.CommandsEnabled { + t.Fatalf("commandsEnabled = %+v, want true for a first-use Proxmox install command token", resp.Config.CommandsEnabled) + } +} + func TestContract_AdminBypassFailsClosedOutsideDevMode(t *testing.T) { t.Setenv("ALLOW_ADMIN_BYPASS", "1") t.Setenv("PULSE_DEV", "") diff --git a/internal/api/unified_agent_handlers_additional_test.go b/internal/api/unified_agent_handlers_additional_test.go index 7d2154691..22f72a67a 100644 --- a/internal/api/unified_agent_handlers_additional_test.go +++ b/internal/api/unified_agent_handlers_additional_test.go @@ -150,6 +150,51 @@ func TestUnifiedAgentHandlers_HandleReportIncludesConfigOverride(t *testing.T) { } } +func TestUnifiedAgentHandlers_HandleReportSuppressesConfigOverrideForUnboundExecToken(t *testing.T) { + handler, monitor := newUnifiedAgentHandlers(t, nil) + + hostID := "machine-report-unbound" + enabled := true + if err := monitor.UpdateHostAgentConfig(hostID, &enabled); err != nil { + t.Fatalf("UpdateHostAgentConfig: %v", err) + } + + report := agentshost.Report{ + Agent: agentshost.AgentInfo{ID: "agent-report-unbound"}, + Host: agentshost.HostInfo{ + ID: hostID, + Hostname: "host-report-unbound.local", + Platform: "linux", + }, + } + body, _ := json.Marshal(report) + + req := httptest.NewRequest(http.MethodPost, "/api/agents/agent/report", bytes.NewReader(body)) + attachAPITokenRecord(req, &config.APITokenRecord{ + ID: "install-token", + Scopes: []string{config.ScopeAgentReport, config.ScopeAgentConfigRead, config.ScopeAgentExec}, + }) + rec := httptest.NewRecorder() + + handler.HandleReport(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d: %s", http.StatusOK, rec.Code, rec.Body.String()) + } + + var resp map[string]any + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + cfg, ok := resp["config"].(map[string]any) + if !ok { + t.Fatalf("expected config override in response") + } + if val, ok := cfg["commandsEnabled"].(bool); !ok || val { + t.Fatalf("expected commandsEnabled=false for unbound exec token, got %#v", cfg["commandsEnabled"]) + } +} + func TestUnifiedAgentHandlers_HandleDeleteHostErrors(t *testing.T) { handler := newUnifiedAgentHandlerForTests(t) @@ -400,6 +445,124 @@ func TestUnifiedAgentHandlers_HandleConfigSignsDesiredMetadata(t *testing.T) { } } +func TestUnifiedAgentHandlers_HandleConfigSuppressesCommandsForUnboundExecToken(t *testing.T) { + handler, monitor := newUnifiedAgentHandlers(t, nil) + hostID := seedUnifiedAgentHost(t, monitor) + state := monitorState(t, monitor) + state.UpsertHost(models.Host{ + ID: hostID, + Hostname: "host-1.local", + TokenID: "install-token", + }) + + commandsEnabled := true + if err := monitor.UpdateHostAgentConfig(hostID, &commandsEnabled); err != nil { + t.Fatalf("UpdateHostAgentConfig: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/agents/agent/"+hostID+"/config", nil) + attachAPITokenRecord(req, &config.APITokenRecord{ + ID: "install-token", + Scopes: []string{config.ScopeAgentConfigRead, config.ScopeAgentReport, config.ScopeAgentExec}, + }) + rec := httptest.NewRecorder() + handler.HandleConfig(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d: %s", http.StatusOK, rec.Code, rec.Body.String()) + } + + var resp struct { + Config monitoring.HostAgentConfig `json:"config"` + } + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.Config.CommandsEnabled == nil || *resp.Config.CommandsEnabled { + t.Fatalf("commandsEnabled = %+v, want false for unbound exec token", resp.Config.CommandsEnabled) + } +} + +func TestUnifiedAgentHandlers_HandleConfigPreservesCommandsForBoundExecToken(t *testing.T) { + handler, monitor := newUnifiedAgentHandlers(t, nil) + hostID := seedUnifiedAgentHost(t, monitor) + state := monitorState(t, monitor) + state.UpsertHost(models.Host{ + ID: hostID, + Hostname: "host-1.local", + TokenID: "runtime-token", + }) + + commandsEnabled := true + if err := monitor.UpdateHostAgentConfig(hostID, &commandsEnabled); err != nil { + t.Fatalf("UpdateHostAgentConfig: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/agents/agent/"+hostID+"/config", nil) + attachAPITokenRecord(req, &config.APITokenRecord{ + ID: "runtime-token", + Scopes: []string{config.ScopeAgentConfigRead, config.ScopeAgentReport, config.ScopeAgentExec}, + Metadata: map[string]string{ + "bound_hostname": "host-1.local", + }, + }) + rec := httptest.NewRecorder() + handler.HandleConfig(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d: %s", http.StatusOK, rec.Code, rec.Body.String()) + } + + var resp struct { + Config monitoring.HostAgentConfig `json:"config"` + } + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.Config.CommandsEnabled == nil || !*resp.Config.CommandsEnabled { + t.Fatalf("commandsEnabled = %+v, want true for bound exec token", resp.Config.CommandsEnabled) + } +} + +func TestUnifiedAgentHandlers_HandleConfigPreservesCommandsForFirstUseProxmoxInstallToken(t *testing.T) { + handler, monitor := newUnifiedAgentHandlers(t, nil) + hostID := seedUnifiedAgentHost(t, monitor) + state := monitorState(t, monitor) + state.UpsertHost(models.Host{ + ID: hostID, + Hostname: "pve-1", + TokenID: "proxmox-install-token", + }) + + commandsEnabled := true + if err := monitor.UpdateHostAgentConfig(hostID, &commandsEnabled); err != nil { + t.Fatalf("UpdateHostAgentConfig: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/agents/agent/"+hostID+"/config", nil) + attachAPITokenRecord(req, &config.APITokenRecord{ + ID: "proxmox-install-token", + Scopes: []string{config.ScopeAgentConfigRead, config.ScopeAgentReport, config.ScopeAgentExec}, + Metadata: map[string]string{ + "install_type": proxmoxInstallTypePVE, + "issued_via": agentInstallIssuedViaConfig, + }, + }) + rec := httptest.NewRecorder() + handler.HandleConfig(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d: %s", http.StatusOK, rec.Code, rec.Body.String()) + } + + var resp struct { + Config monitoring.HostAgentConfig `json:"config"` + } + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.Config.CommandsEnabled == nil || !*resp.Config.CommandsEnabled { + t.Fatalf("commandsEnabled = %+v, want true for first-use Proxmox install token", resp.Config.CommandsEnabled) + } +} + func TestUnifiedAgentHandlers_HandleConfigInvalidKeyAllowed(t *testing.T) { handler := newUnifiedAgentHandlerForTests(t, models.Host{ID: "host-1"}) diff --git a/internal/hostagent/proxmox_setup.go b/internal/hostagent/proxmox_setup.go index ab84d3f79..e05b60428 100644 --- a/internal/hostagent/proxmox_setup.go +++ b/internal/hostagent/proxmox_setup.go @@ -200,7 +200,7 @@ func proxmoxTokenScope(candidates ...string) string { } func (p *ProxmoxSetup) monitorTokenName() string { - return "pulse-" + proxmoxTokenScope(p.pulseURL, p.hostname) + return "pulse-" + proxmoxTokenScope(p.hostname, p.pulseURL) } func tokenAlreadyExists(err error, output string) bool { diff --git a/internal/hostagent/proxmox_setup_test.go b/internal/hostagent/proxmox_setup_test.go index b732e1188..dd71235c7 100644 --- a/internal/hostagent/proxmox_setup_test.go +++ b/internal/hostagent/proxmox_setup_test.go @@ -614,6 +614,33 @@ func TestProxmoxSetup_RunForType(t *testing.T) { }) } +func TestProxmoxSetup_MonitorTokenNameIsNodeScoped(t *testing.T) { + first := &ProxmoxSetup{ + pulseURL: "https://pulse.example.com", + hostname: "pve01", + } + second := &ProxmoxSetup{ + pulseURL: "https://pulse.example.com", + hostname: "pve02", + } + fallback := &ProxmoxSetup{ + pulseURL: "https://pulse.example.com", + } + + if got := first.monitorTokenName(); got != "pulse-pve01" { + t.Fatalf("first node token name = %q, want pulse-pve01", got) + } + if got := second.monitorTokenName(); got != "pulse-pve02" { + t.Fatalf("second node token name = %q, want pulse-pve02", got) + } + if first.monitorTokenName() == second.monitorTokenName() { + t.Fatal("PVE cluster nodes pointed at the same Pulse URL must not share one Proxmox API token name") + } + if got := fallback.monitorTokenName(); got != "pulse-pulse-example-com" { + t.Fatalf("fallback token name = %q, want pulse-pulse-example-com", got) + } +} + func TestProxmoxSetup_SetupPVETokenUsesPrivilegeSeparatedTokenACLs(t *testing.T) { mc := &mockCollector{} calls := make([]commandCall, 0)