diff --git a/internal/monitoring/monitor_polling_node_helpers.go b/internal/monitoring/monitor_polling_node_helpers.go index d6984f45f..03156560d 100644 --- a/internal/monitoring/monitor_polling_node_helpers.go +++ b/internal/monitoring/monitor_polling_node_helpers.go @@ -378,7 +378,7 @@ func (m *Monitor) applyNodePendingUpdates(ctx context.Context, instanceName stri Err(err). Str("node", node.Node). Str("instance", instanceName). - Msg("Could not check pending apt updates (may require Sys.Audit permission)") + Msg("Could not check pending apt updates with configured Proxmox credentials") modelNode.PendingUpdatesReason = pendingUpdatesFailureReason(err) if hasCached { modelNode.PendingUpdates = cached.count diff --git a/pkg/proxmox/client.go b/pkg/proxmox/client.go index 34367a0ba..5dc74c8d6 100644 --- a/pkg/proxmox/client.go +++ b/pkg/proxmox/client.go @@ -2944,7 +2944,9 @@ type AptPackage struct { } // GetNodePendingUpdates returns the list of pending apt updates for a node -// Requires Sys.Audit permission on /nodes/{node} +// Access is evaluated by Proxmox for the configured credential on /nodes/{node}. +// Do not infer access from general node-audit permission; endpoint requirements +// can differ from other monitoring reads. func (c *Client) GetNodePendingUpdates(ctx context.Context, node string) ([]AptPackage, error) { resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/apt/update", node)) if err != nil { diff --git a/pkg/proxmox/pending_updates_auth_routing_test.go b/pkg/proxmox/pending_updates_auth_routing_test.go new file mode 100644 index 000000000..0854e21eb --- /dev/null +++ b/pkg/proxmox/pending_updates_auth_routing_test.go @@ -0,0 +1,64 @@ +package proxmox + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" +) + +// A cluster entry endpoint may proxy a different target node. Neither the +// selected endpoint nor its successful inventory read grants apt access. +func TestClusterClient_PendingUpdatesConfiguredCredentialAndTarget(t *testing.T) { + for _, status := range []int{http.StatusOK, http.StatusForbidden} { + t.Run(fmt.Sprint(status), func(t *testing.T) { + var reads atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("unexpected method %s", r.Method) + } + if r.Header.Get("Authorization") != "PVEAPIToken=monitor@pve!readonly=synthetic" { + t.Error("configured token was not preserved") + } + if r.Header.Get("Cookie") != "" { + t.Error("unexpected browser/session credential") + } + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api2/json/nodes": + fmt.Fprint(w, `{"data":[{"node":"entry","status":"online"},{"node":"target","status":"online"}]}`) + case "/api2/json/nodes/target/apt/update": + reads.Add(1) + w.WriteHeader(status) + if status == http.StatusOK { + fmt.Fprint(w, `{"data":[{"Package":"example","Version":"2"}]}`) + } else { + fmt.Fprint(w, `{"message":"Permission check failed"}`) + } + default: + t.Errorf("unexpected request path %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + cfg := ClientConfig{Host: server.URL, TokenName: "monitor@pve!readonly", TokenValue: "synthetic"} + cc := NewClusterClient("routing-test", cfg, []string{server.URL}, nil) + updates, err := cc.GetNodePendingUpdates(context.Background(), "target") + if status == http.StatusOK { + if err != nil || len(updates) != 1 || updates[0].Package != "example" { + t.Fatalf("unexpected successful result: %v, %v", updates, err) + } + } else if err == nil || extractStatusCode(err.Error()) != http.StatusForbidden || len(updates) != 0 { + t.Fatalf("denial not preserved: %v, %v", updates, err) + } + if reads.Load() != 1 { + t.Fatalf("apt reads = %d; expected one without auth retry", reads.Load()) + } + if !cc.GetHealthStatus()[server.URL] { + t.Error("apt permission failure must not poison cluster endpoint health") + } + }) + } +}