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

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