mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
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:
parent
cf6161e035
commit
04a9e6ad3e
10 changed files with 347 additions and 4 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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", "")
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue