From b5f00509eec16dbe6d77361428e0170cc73aae5b Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Fri, 3 Jul 2026 11:08:44 +0200 Subject: [PATCH] =?UTF-8?q?hub=20v0.31.0:=20accept=20'critical'=20severity?= =?UTF-8?q?=20at=20event=20ingest=20+=20UI=20badges/CSS;=20event=5Ftest.go?= =?UTF-8?q?=20(red-proofed);=20REUSE.md=20=C2=A71/=C2=A73=20updated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6 --- REUSE.md | 4 +- hub/CHANGELOG.md | 23 +++++ hub/internal/api/event_test.go | 92 +++++++++++++++++++ hub/internal/api/handler.go | 4 +- hub/internal/notify/templates.go | 7 +- hub/internal/web/server.go | 10 +- .../web/templates/customer_unified.html | 1 + hub/internal/web/templates/dashboard.html | 2 +- hub/internal/web/templates/style.css | 10 ++ 9 files changed, 141 insertions(+), 12 deletions(-) create mode 100644 hub/internal/api/event_test.go diff --git a/REUSE.md b/REUSE.md index 5aff1cd..60e47f9 100644 --- a/REUSE.md +++ b/REUSE.md @@ -12,7 +12,7 @@ |---|---|---|---|---| | `(*Handler).checkAuthCustomer` | hub/internal/api/handler.go (~L94) | `(r) (customerID string, isGlobal, ok bool)` | Bearer auth for controller-facing endpoints (global key OR per-customer key) | Global key → `("", true, true)`: caller must then trust body `customer_id`. Constant-time compare on global key. | | `(*Handler).checkAuthHost` | hub/internal/api/handler.go (~L119) | `(r) (hostID, customerID string, isGlobal, ok bool)` | Bearer auth for agent-facing endpoints (global OR per-host key) | Sibling of checkAuthCustomer — do NOT mix the two token namespaces. Global key requires the host row to already exist (see handleHostReport). | -| `(*Handler).handleEvent` + `allowedEventTypes` | hub/internal/api/handler.go (~L1115 / ~L1063) | `POST /api/v1/event` | The ONLY controller→hub structured-event ingest | Unknown `event_type` → 400 (add to the map FIRST). Severity coerced to info/warning/error — `"critical"` silently becomes `"info"` (see §3). | +| `(*Handler).handleEvent` + `allowedEventTypes` | hub/internal/api/handler.go (~L1115 / ~L1063) | `POST /api/v1/event` | The ONLY controller→hub structured-event ingest | Unknown `event_type` → 400 (add to the map FIRST). Accepted severities: info/warning/error/critical (critical since v0.31.0); anything else coerces to `"info"` — exact-match lowercase (`"Critical"` coerces). Tests: hub/internal/api/event_test.go. | | `(*Handler).handleHostReport` | hub/internal/api/handler.go (~L464) | `POST /api/v1/host-report` | Agent heartbeat ingest: denorm + guest upsert | Body cap via LimitReader; per-host key enforces `host_id` match (403 on mismatch); `received_at` is the dead-man's-switch. | | `(*Handler).handleConfigRetrieve` | hub/internal/api/handler.go (~L1484) | `GET /api/v1/config/{id}`, header `X-Retrieval-Password` | Canonical password-gated retrieval endpoint | Constant-time compare vs `cfg.RetrievalPassword`; 404-before-401 ordering. `handleArtifactManifest` mirrors it EXACTLY — keep them in lockstep. | | `writeJSON` | hub/internal/api/dr.go (~L25) | `(w, code int, v any)` | JSON responses in api package | Only used in dr.go so far; prefer it over ad-hoc byte-writes for new endpoints. | @@ -107,7 +107,7 @@ | Trap | Why it bites | Use instead | |---|---|---| | `(*Handler).handleNotify` + `formatNotificationEmail` + `sendResendEmail` (hub/internal/api/handler.go ~L1289/1624/1589) | Legacy pre-dispatcher notification trio: no cooldowns, no operator channel, no allowedEventTypes gate, duplicate Hungarian formatter. Controller path is FROZEN until slice-10 cutover. | `POST /api/v1/event` → `Dispatcher.ProcessEvent` + `notify.Format*Email` | -| Severity `"critical"` via `POST /api/v1/event` | `handleEvent` (~L1157) coerces unknown severities — including `critical` — to `"info"`, which never notifies. Silent alert loss. | Send `warning`/`error` from controllers, or extend the handleEvent switch AND allowedEventTypes together | +| Severity `"critical"` POSTed to a PRE-v0.31.0 hub | Fixed in hub v0.31.0 (`handleEvent` now accepts critical). Older hubs coerce `critical` → `"info"`, which never notifies — silent alert loss. Case-variants (`"Critical"`) still coerce on every version. | Against an old hub send `warning`/`error`; otherwise lowercase `critical` is safe | | `compareVersions` for anything security-ish (hub/internal/web/server.go ~L571) | Returns 0 (equal) on unparseable input — a garbage version passes a floor check. `gitea.compareSemver` behaves differently (lexical fallback). | Validate input with `normalizeFloorInput` first; then compareVersions is safe | | Inline `stringData` secrets à la manifests/felhom.secret.yaml | Commits real credentials to git (healthchecks superuser pw, umami APP_SECRET/POSTGRES_PASSWORD still live there). | Out-of-band `kubectl create secret` + `secretKeyRef` (hub.yaml resend-api pattern; runbook documentation/runbooks/secrets.md) | | `kubectl apply` / `kubectl set image` on manifests/ | ArgoCD app `felhom` reverts drift on next sync; live state lies about git. | Edit manifest in git → push → ArgoCD sync (CLAUDE.md steps 3–5) | diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index c4a8293..fd5cab2 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,5 +1,28 @@ # Felhom Hub — Changelog +## v0.31.0 — critical severity accepted at event ingest + visible in UI (2026-07-03) + +Fixes the gotcha the REUSE sweep surfaced: `handleEvent` coerced any severity outside +{info,warning,error} — including `"critical"` — to `"info"` at ingest, so a controller-POSTed +critical event never notified even though the dispatcher (`severityNotifies`, v0.24.0) and +`FormatOperatorEmail` already handle critical correctly. + +- **Ingest (`internal/api/handler.go` `handleEvent`):** `"critical"` added to the severity case + list. Unknown values (and case-variants like `"Critical"`) still coerce to `"info"` — the + exact-match-lowercase coercion contract is kept and now locked by test. +- **Hungarian label (`internal/notify/templates.go`):** `severityLabels["critical"] = "Kritikus hiba"` + (was missing — customer emails would have shown the raw English word). +- **UI counts:** dashboard consumer (`internal/web/server.go`) gains `EventCriticals`; + `dashboard.html` renders the critical badge FIRST in the 24h count chain (guard extended); + `customer_unified.html` gains the `{{.}} critical` summary badge before errors. +- **style.css:** defines the previously-referenced-but-undefined `.severity-critical` + (`--crit`/`--crit-dim` tokens) and `.severity-ok` (neutral, exception-color principle). No other + restyle. +- **Tests (`internal/api/event_test.go`, new):** critical preserved to store (companion red-proof: + shown failing against the pre-fix switch — stored `"info"`); unknown severity → info; unknown + event_type → 400 + nothing stored; no-auth → 401. First tests on the /event endpoint. +- REUSE.md §1/§3 updated in the same commit (the maintenance rule's first outing). + ## docs — REUSE.md introduced (2026-07-03) Cross-repo reuse-map rollout (docs-only, no code change, no version bump). New `REUSE.md` at the diff --git a/hub/internal/api/event_test.go b/hub/internal/api/event_test.go new file mode 100644 index 0000000..385144a --- /dev/null +++ b/hub/internal/api/event_test.go @@ -0,0 +1,92 @@ +package api + +import ( + "net/http" + "testing" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" +) + +func eventBody(severity, eventType string) string { + return `{"customer_id":"c1","event_type":"` + eventType + `","severity":"` + severity + `","message":"probe"}` +} + +func newEventTestHandler(t *testing.T) (*Handler, *store.Store) { + t.Helper() + h, st, _ := newTestHandler(t) + if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "p"}); err != nil { + t.Fatalf("SaveCustomerConfig: %v", err) + } + return h, st +} + +// Scenario A: a controller-POSTed "critical" event must be stored as "critical", +// not coerced to "info" (pre-v0.31.0 bug: the severity switch omitted "critical", +// so critical events silently became info and never notified). +func TestHandleEvent_CriticalPreserved(t *testing.T) { + h, st := newEventTestHandler(t) + + rr := do(h, http.MethodPost, "/event", "ckey", eventBody("critical", "test")) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, body=%s", rr.Code, rr.Body.String()) + } + evs, err := st.GetRecentEvents("c1", 10) + if err != nil { + t.Fatalf("GetRecentEvents: %v", err) + } + if len(evs) != 1 { + t.Fatalf("stored events = %d, want 1", len(evs)) + } + if evs[0].Severity != "critical" { + t.Errorf("stored severity = %q, want %q (critical must survive ingest)", evs[0].Severity, "critical") + } +} + +// Scenario B: unknown severities still coerce to "info" — the coercion contract is +// exact-match lowercase; only "critical" joined the known set. +func TestHandleEvent_UnknownSeverityCoercesToInfo(t *testing.T) { + h, st := newEventTestHandler(t) + + rr := do(h, http.MethodPost, "/event", "ckey", eventBody("banana", "test")) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, body=%s", rr.Code, rr.Body.String()) + } + evs, err := st.GetRecentEvents("c1", 10) + if err != nil { + t.Fatalf("GetRecentEvents: %v", err) + } + if len(evs) != 1 || evs[0].Severity != "info" { + t.Errorf("stored = %+v, want exactly one event with severity info", evs) + } +} + +// Scenario C: an event_type outside allowedEventTypes is rejected with 400 and +// nothing is stored (locks the allowlist behavior). +func TestHandleEvent_UnknownEventTypeRejected(t *testing.T) { + h, st := newEventTestHandler(t) + + rr := do(h, http.MethodPost, "/event", "ckey", eventBody("error", "not-a-real-type")) + if rr.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rr.Code, rr.Body.String()) + } + evs, err := st.GetRecentEvents("c1", 10) + if err != nil { + t.Fatalf("GetRecentEvents: %v", err) + } + if len(evs) != 0 { + t.Errorf("stored events = %d, want 0 after a rejected event_type", len(evs)) + } +} + +// Auth: no bearer at all is a 401 and stores nothing. +func TestHandleEvent_Unauthorized(t *testing.T) { + h, st := newEventTestHandler(t) + + rr := do(h, http.MethodPost, "/event", "", eventBody("critical", "test")) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rr.Code) + } + if evs, _ := st.GetRecentEvents("c1", 10); len(evs) != 0 { + t.Errorf("stored events = %d, want 0 without auth", len(evs)) + } +} diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index 92b9b4d..ef6e07f 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -1153,9 +1153,9 @@ func (h *Handler) handleEvent(w http.ResponseWriter, r *http.Request) { return } - // Validate/default severity + // Validate/default severity (exact-match lowercase; unknown values coerce to info) switch payload.Severity { - case "info", "warning", "error": + case "info", "warning", "error", "critical": default: payload.Severity = "info" } diff --git a/hub/internal/notify/templates.go b/hub/internal/notify/templates.go index e55caa7..c4b8aeb 100644 --- a/hub/internal/notify/templates.go +++ b/hub/internal/notify/templates.go @@ -109,9 +109,10 @@ var customerMessages = map[string]string{ // severityLabels maps severity to Hungarian labels. var severityLabels = map[string]string{ - "info": "Információ", - "warning": "Figyelmeztetés", - "error": "Hiba", + "info": "Információ", + "warning": "Figyelmeztetés", + "error": "Hiba", + "critical": "Kritikus hiba", } // FormatCustomerEmail returns (subject, textBody) for the customer channel. diff --git a/hub/internal/web/server.go b/hub/internal/web/server.go index f61e3eb..1b676fe 100644 --- a/hub/internal/web/server.go +++ b/hub/internal/web/server.go @@ -497,10 +497,11 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { type dashboardCustomer struct { store.CustomerSummary - OverallStatus string // "ok", "warn", "down", "pending" - BackupAge string - EventErrors int - EventWarnings int + OverallStatus string // "ok", "warn", "down", "pending" + BackupAge string + EventCriticals int + EventErrors int + EventWarnings int } // Build map of report customers keyed by ID @@ -537,6 +538,7 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { // Event counts (last 24h) if counts, err := s.store.CountEventsBySeverity(c.CustomerID, time.Now().Add(-24*time.Hour)); err == nil { + dc.EventCriticals = counts["critical"] dc.EventErrors = counts["error"] dc.EventWarnings = counts["warning"] } diff --git a/hub/internal/web/templates/customer_unified.html b/hub/internal/web/templates/customer_unified.html index 1906a4d..e5e75b0 100644 --- a/hub/internal/web/templates/customer_unified.html +++ b/hub/internal/web/templates/customer_unified.html @@ -488,6 +488,7 @@

Events {{if .EventCounts}} + {{with mapGet .EventCounts "critical"}}{{.}} critical{{end}} {{with mapGet .EventCounts "error"}}{{.}} error{{if gt . 1}}s{{end}}{{end}} {{with mapGet .EventCounts "warning"}}{{.}} warning{{if gt . 1}}s{{end}}{{end}} {{end}} diff --git a/hub/internal/web/templates/dashboard.html b/hub/internal/web/templates/dashboard.html index b8ccc58..1b91f02 100644 --- a/hub/internal/web/templates/dashboard.html +++ b/hub/internal/web/templates/dashboard.html @@ -54,7 +54,7 @@ {{if eq .OverallStatus "ok"}}OK{{else if eq .OverallStatus "warn"}}WARN{{else if eq .OverallStatus "disabled"}}PAUSED{{else if eq .OverallStatus "pending"}}PENDING{{else}}DOWN{{end}} - {{if eq .OverallStatus "pending"}}—{{else}}{{if gt (add .EventErrors .EventWarnings) 0}}{{if gt .EventErrors 0}}{{.EventErrors}}{{end}}{{if gt .EventWarnings 0}}{{.EventWarnings}}{{end}}{{else}}{{end}}{{end}} + {{if eq .OverallStatus "pending"}}—{{else}}{{if gt (add (add .EventCriticals .EventErrors) .EventWarnings) 0}}{{if gt .EventCriticals 0}}{{.EventCriticals}}{{end}}{{if gt .EventErrors 0}}{{.EventErrors}}{{end}}{{if gt .EventWarnings 0}}{{.EventWarnings}}{{end}}{{else}}{{end}}{{end}} {{if eq .OverallStatus "pending"}}—{{else}}{{timeAgo .ReceivedAt}}{{end}} {{if eq .OverallStatus "pending"}}—{{else}}{{formatFloat .CPUPercent}}%{{end}} {{if eq .OverallStatus "pending"}}—{{else}}{{formatFloat .MemoryPercent}}%{{end}} diff --git a/hub/internal/web/templates/style.css b/hub/internal/web/templates/style.css index 949f3de..e644bb4 100644 --- a/hub/internal/web/templates/style.css +++ b/hub/internal/web/templates/style.css @@ -645,6 +645,16 @@ code { color: #3b82f6; } +.severity-critical { + background: var(--crit-dim); + color: var(--crit); +} + +.severity-ok { + background: var(--bg-2); + color: var(--text-2); +} + /* Event filter buttons */ .event-filter { font-size: 0.8em;