Fix Proxmox agent install token isolation

Keep Proxmox setup tokens node-scoped so cluster installs do not rotate one shared Pulse API token.

Suppress command-enable config for tokens that cannot register command channels and keep reusable installer tokens out of agent:exec.
This commit is contained in:
rcourtman 2026-07-05 17:21:34 +01:00
parent cf6161e035
commit 04a9e6ad3e
10 changed files with 347 additions and 4 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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,
);
}

View file

@ -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 {

View file

@ -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", "")

View file

@ -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"})

View file

@ -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 {

View file

@ -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)