From dca40761a41cadda3f768b5cdbcf3727dac37c30 Mon Sep 17 00:00:00 2001 From: Debian Date: Fri, 31 Jul 2026 10:58:27 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20CrowdSec=20journald-Acquisition=20+=20A?= =?UTF-8?q?larm-Quittieren/L=C3=B6schen=20=E2=80=94=20v1.3.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(crowdsec/packaging): postinst setzt die HAProxy-Acquisition deterministisch auf die journald-Unit (haproxy.service) statt der cscli-setup-Datei-Default (/var/log/haproxy.log existiert nicht → CrowdSec las nichts → HTTP/CVE-Szenarien liefen leer). Self-healing auf jedem configure; admin-Custom bleibt unangetastet. - feat(alerts): Alarme (alert_events) bulk quittieren + löschen. Migration 0045 (acknowledged_at + Teil-Index). Dashboard-Karte zählt nur noch OFFENE (open=true) → Quittieren lässt die "Aktuelle Alerts"-Meldung verschwinden, History bleibt. Events-Tab: Row-Selection, Quittieren/Löschen (Auswahl) + "Alle quittieren", Status-Spalte (offen/quittiert). Audit-geloggt. Co-Authored-By: Claude Opus 4.8 --- VERSION | 2 +- .../0045_alert_events_acknowledge.sql | 22 ++++ internal/handlers/alerts.go | 64 +++++++++- internal/services/alerts/alerts.go | 70 +++++++++-- management-ui/src/i18n/locales/de/common.json | 9 +- management-ui/src/i18n/locales/en/common.json | 9 +- management-ui/src/pages/Alerts/index.tsx | 109 ++++++++++++++---- management-ui/src/pages/Dashboard/index.tsx | 2 +- management-ui/src/styles/enterprise.css | 3 + .../debian/edgeguard-api/DEBIAN/postinst | 31 +++++ 10 files changed, 279 insertions(+), 42 deletions(-) create mode 100644 internal/database/migrations/0045_alert_events_acknowledge.sql diff --git a/VERSION b/VERSION index 8c9698a..aaf8be7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.4 \ No newline at end of file +1.3.5 \ No newline at end of file diff --git a/internal/database/migrations/0045_alert_events_acknowledge.sql b/internal/database/migrations/0045_alert_events_acknowledge.sql new file mode 100644 index 0000000..6f89e98 --- /dev/null +++ b/internal/database/migrations/0045_alert_events_acknowledge.sql @@ -0,0 +1,22 @@ +-- +goose Up +-- +goose StatementBegin + +-- Quittieren von Alarmen. acknowledged_at = NULL → offen (zählt im Dashboard). +-- Gesetzt → quittiert (bleibt als History sichtbar, zählt aber nicht mehr auf +-- der Startseiten-Karte "Aktuelle Alerts"). alert_events ist node-lokal +-- (localOnlyTables) → kein Replikations-Effekt. +ALTER TABLE alert_events + ADD COLUMN IF NOT EXISTS acknowledged_at TIMESTAMPTZ; + +-- Teil-Index für den Dashboard-Query (nur offene, newest-first). +CREATE INDEX IF NOT EXISTS idx_alert_events_open + ON alert_events (fired_at DESC) + WHERE acknowledged_at IS NULL; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP INDEX IF EXISTS idx_alert_events_open; +ALTER TABLE alert_events DROP COLUMN IF EXISTS acknowledged_at; +-- +goose StatementEnd diff --git a/internal/handlers/alerts.go b/internal/handlers/alerts.go index e483332..ebf16d3 100644 --- a/internal/handlers/alerts.go +++ b/internal/handlers/alerts.go @@ -18,7 +18,10 @@ import ( // PUT /api/v1/alerts/channels/:id // DELETE /api/v1/alerts/channels/:id // POST /api/v1/alerts/test — Test-Event in alle aktiven Channels -// GET /api/v1/alerts/events?limit=N — History +// GET /api/v1/alerts/events?limit=N&open=true — History (open=nur offene) +// POST /api/v1/alerts/events/acknowledge — Bulk-Quittieren {ids:[…]} +// POST /api/v1/alerts/events/acknowledge-all — alle offenen quittieren +// POST /api/v1/alerts/events/delete — Bulk-Löschen {ids:[…]} type AlertsHandler struct { Service *alerts.Service Audit *audit.Repo @@ -37,6 +40,9 @@ func (h *AlertsHandler) Register(rg *gin.RouterGroup) { g.DELETE("/channels/:id", h.DeleteChannel) g.POST("/test", h.TestFire) g.GET("/events", h.ListEvents) + g.POST("/events/acknowledge", h.AcknowledgeEvents) + g.POST("/events/acknowledge-all", h.AcknowledgeAllEvents) + g.POST("/events/delete", h.DeleteEvents) } func (h *AlertsHandler) ListChannels(c *gin.Context) { @@ -125,10 +131,64 @@ func (h *AlertsHandler) ListEvents(c *gin.Context) { limit = n } } - out, err := h.Service.ListEvents(c.Request.Context(), limit) + // ?open=true → nur offene (nicht quittierte) Events. Nutzt die + // Dashboard-Karte, damit Quittieren die Meldung verschwinden lässt. + openOnly := c.Query("open") == "true" + out, err := h.Service.ListEvents(c.Request.Context(), limit, openOnly) if err != nil { response.Internal(c, err) return } response.OK(c, gin.H{"events": out}) } + +// eventIDsRequest ist der Body für Bulk-Quittieren/-Löschen. +type eventIDsRequest struct { + IDs []int64 `json:"ids"` +} + +// AcknowledgeEvents quittiert die übergebenen Event-IDs. +func (h *AlertsHandler) AcknowledgeEvents(c *gin.Context) { + var req eventIDsRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err) + return + } + n, err := h.Service.Acknowledge(c.Request.Context(), req.IDs) + if err != nil { + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "alert.events.acknowledge", + strconv.Itoa(len(req.IDs)), gin.H{"ids": req.IDs, "acknowledged": n}, h.NodeID) + response.OK(c, gin.H{"acknowledged": n}) +} + +// AcknowledgeAllEvents quittiert alle offenen Events. +func (h *AlertsHandler) AcknowledgeAllEvents(c *gin.Context) { + n, err := h.Service.AcknowledgeAll(c.Request.Context()) + if err != nil { + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "alert.events.acknowledge_all", + "all", gin.H{"acknowledged": n}, h.NodeID) + response.OK(c, gin.H{"acknowledged": n}) +} + +// DeleteEvents löscht die übergebenen Event-IDs endgültig. +func (h *AlertsHandler) DeleteEvents(c *gin.Context) { + var req eventIDsRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err) + return + } + n, err := h.Service.DeleteEvents(c.Request.Context(), req.IDs) + if err != nil { + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "alert.events.delete", + strconv.Itoa(len(req.IDs)), gin.H{"ids": req.IDs, "deleted": n}, h.NodeID) + response.OK(c, gin.H{"deleted": n}) +} diff --git a/internal/services/alerts/alerts.go b/internal/services/alerts/alerts.go index 8dcfcdd..4908e56 100644 --- a/internal/services/alerts/alerts.go +++ b/internal/services/alerts/alerts.go @@ -62,13 +62,14 @@ type EmailSettings struct { // Event ist eine Row in alert_events. type Event struct { - ID int64 `json:"id"` - Kind string `json:"kind"` - Severity Severity `json:"severity"` - Subject string `json:"subject"` - Message string `json:"message"` - SentTo json.RawMessage `json:"sent_to"` - FiredAt time.Time `json:"fired_at"` + ID int64 `json:"id"` + Kind string `json:"kind"` + Severity Severity `json:"severity"` + Subject string `json:"subject"` + Message string `json:"message"` + SentTo json.RawMessage `json:"sent_to"` + FiredAt time.Time `json:"fired_at"` + AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"` } // SendResult pro Channel — landet als JSON-Array in sent_to. @@ -170,14 +171,19 @@ func (s *Service) DeleteChannel(ctx context.Context, id int64) error { return nil } -// ListEvents liefert die letzten N Events newest-first. -func (s *Service) ListEvents(ctx context.Context, limit int) ([]Event, error) { +// ListEvents liefert die letzten N Events newest-first. Wenn openOnly +// gesetzt ist, werden nur noch offene (nicht quittierte) Events geliefert — +// das nutzt die Dashboard-Karte, damit Quittieren die Meldung verschwinden +// lässt. +func (s *Service) ListEvents(ctx context.Context, limit int, openOnly bool) ([]Event, error) { if limit <= 0 || limit > 500 { limit = 100 } rows, err := s.Pool.Query(ctx, ` -SELECT id, kind, severity, subject, message, sent_to, fired_at -FROM alert_events ORDER BY fired_at DESC, id DESC LIMIT $1`, limit) +SELECT id, kind, severity, subject, message, sent_to, fired_at, acknowledged_at +FROM alert_events +WHERE ($2::bool = false OR acknowledged_at IS NULL) +ORDER BY fired_at DESC, id DESC LIMIT $1`, limit, openOnly) if err != nil { return nil, err } @@ -186,7 +192,7 @@ FROM alert_events ORDER BY fired_at DESC, id DESC LIMIT $1`, limit) for rows.Next() { var e Event if err := rows.Scan(&e.ID, &e.Kind, &e.Severity, &e.Subject, - &e.Message, &e.SentTo, &e.FiredAt); err != nil { + &e.Message, &e.SentTo, &e.FiredAt, &e.AcknowledgedAt); err != nil { return nil, err } out = append(out, e) @@ -194,6 +200,46 @@ FROM alert_events ORDER BY fired_at DESC, id DESC LIMIT $1`, limit) return out, rows.Err() } +// Acknowledge quittiert die angegebenen Events (setzt acknowledged_at=NOW() +// bei noch offenen). Liefert die Anzahl geänderter Rows. +func (s *Service) Acknowledge(ctx context.Context, ids []int64) (int64, error) { + if len(ids) == 0 { + return 0, nil + } + tag, err := s.Pool.Exec(ctx, + `UPDATE alert_events SET acknowledged_at = NOW() + WHERE id = ANY($1) AND acknowledged_at IS NULL`, ids) + if err != nil { + return 0, err + } + return tag.RowsAffected(), nil +} + +// AcknowledgeAll quittiert alle offenen Events — Backing für den +// "Alle quittieren"-Button. +func (s *Service) AcknowledgeAll(ctx context.Context) (int64, error) { + tag, err := s.Pool.Exec(ctx, + `UPDATE alert_events SET acknowledged_at = NOW() WHERE acknowledged_at IS NULL`) + if err != nil { + return 0, err + } + return tag.RowsAffected(), nil +} + +// DeleteEvents löscht die angegebenen Events endgültig. Liefert die Anzahl +// gelöschter Rows. +func (s *Service) DeleteEvents(ctx context.Context, ids []int64) (int64, error) { + if len(ids) == 0 { + return 0, nil + } + tag, err := s.Pool.Exec(ctx, + `DELETE FROM alert_events WHERE id = ANY($1)`, ids) + if err != nil { + return 0, err + } + return tag.RowsAffected(), nil +} + // Cleanup löscht alert_events älter als keepDays und liefert die Anzahl // gelöschter Rows. make_interval(days => $1) nimmt $1 sauber als int — // der frühere ($1 || ' days')::interval-Ansatz erzwang text und scheiterte diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index d80d35b..711ac3f 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -1505,6 +1505,12 @@ "emptyEventsDesc": "Triggers (Cert-Expiry, Backup-Fail, Cluster-Drift, License-Invalid, etc.) haben noch keinen Event gefeuert. Wenn sie feuern, landen sie hier und werden an die konfigurierten Channels zugestellt.", "noChannels": "kein Channel aktiv", "confirmDelete": "Channel {{name}} wirklich löschen?", + "acknowledge": "Quittieren", + "acknowledgeAll": "Alle quittieren", + "acknowledged": "Quittiert", + "acked": "quittiert", + "open": "offen", + "confirmDeleteEvents": "{{n}} Event(s) endgültig löschen?", "kindWebhook": "Webhook (Slack/Discord/Teams/HTTP-Endpoint)", "kindEmail": "E-Mail (SMTP)", "smtp": { @@ -1525,7 +1531,8 @@ "time": "Zeit", "severity": "Severity", "subject": "Betreff", - "delivered": "Gesendet" + "delivered": "Gesendet", + "status": "Status" } }, "remotes": { diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index 3a827d7..4fc9a15 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -1505,6 +1505,12 @@ "emptyEventsDesc": "Triggers (cert expiry, backup failure, cluster drift, license invalid, etc.) haven't fired any events yet. When they do, they land here and get delivered to the configured channels.", "noChannels": "no active channel", "confirmDelete": "Really delete channel {{name}}?", + "acknowledge": "Acknowledge", + "acknowledgeAll": "Acknowledge all", + "acknowledged": "Acknowledged", + "acked": "acknowledged", + "open": "open", + "confirmDeleteEvents": "Permanently delete {{n}} event(s)?", "kindWebhook": "Webhook (Slack/Discord/Teams/HTTP endpoint)", "kindEmail": "Email (SMTP)", "smtp": { @@ -1525,7 +1531,8 @@ "time": "Time", "severity": "Severity", "subject": "Subject", - "delivered": "Delivered" + "delivered": "Delivered", + "status": "Status" } }, "remotes": { diff --git a/management-ui/src/pages/Alerts/index.tsx b/management-ui/src/pages/Alerts/index.tsx index c7a5c67..d82232c 100644 --- a/management-ui/src/pages/Alerts/index.tsx +++ b/management-ui/src/pages/Alerts/index.tsx @@ -42,6 +42,7 @@ interface AlertEvent { message: string sent_to: SendResult[] fired_at: string + acknowledged_at?: string | null } interface ChannelFormValues { @@ -99,6 +100,29 @@ export default function AlertsPage() { const [form] = Form.useForm() const [filterSev, setFilterSev] = useState() const [filterKind, setFilterKind] = useState() + const [selectedIds, setSelectedIds] = useState([]) + + // Nach Quittieren/Löschen sowohl die Event-Liste als auch die Dashboard- + // Karte (queryKey ['alerts','events','recent']) invalidieren — Prefix-Match. + const refreshEvents = () => { + setSelectedIds([]) + void qc.invalidateQueries({ queryKey: ['alerts', 'events'] }) + } + const ackMut = useMutation({ + mutationFn: (ids: number[]) => apiClient.post('/alerts/events/acknowledge', { ids }), + onSuccess: () => { message.success(t('alerts.acknowledged')); refreshEvents() }, + onError: (e: Error) => message.error(e.message), + }) + const ackAllMut = useMutation({ + mutationFn: () => apiClient.post('/alerts/events/acknowledge-all'), + onSuccess: () => { message.success(t('alerts.acknowledged')); refreshEvents() }, + onError: (e: Error) => message.error(e.message), + }) + const delEventsMut = useMutation({ + mutationFn: (ids: number[]) => apiClient.post('/alerts/events/delete', { ids }), + onSuccess: () => { message.success(t('common.delete')); refreshEvents() }, + onError: (e: Error) => message.error(e.message), + }) const kindOptions = useMemo(() => { const kinds = [...new Set((events.data ?? []).map(e => e.kind))].sort() @@ -276,6 +300,14 @@ export default function AlertsPage() { ) }, }, + { + title: t('alerts.col.status'), key: 'ack', width: 110, + render: (_, r: AlertEvent) => r.acknowledged_at + ? + {t('alerts.acked')} + + : {t('alerts.open')}, + }, ] const kind = Form.useWatch('kind', form) @@ -351,32 +383,61 @@ export default function AlertsPage() { label: t('alerts.tabs.events'), children: ( - - - +
+ + + + + + + + delEventsMut.mutate(selectedIds)} + disabled={isViewer || selectedIds.length === 0}> + + + + + + +
setSelectedIds(keys as number[]), + }} + rowClassName={(r) => (r.acknowledged_at ? 'eg-row-muted' : '')} pagination={{ pageSize: 25 }} locale={{ emptyText: ( fetchList('/alerts/events?limit=10', 'events'), + queryFn: () => fetchList('/alerts/events?limit=10&open=true', 'events'), refetchInterval: 60_000, }) const services = useQuery({ diff --git a/management-ui/src/styles/enterprise.css b/management-ui/src/styles/enterprise.css index 66041a3..b3a45a8 100644 --- a/management-ui/src/styles/enterprise.css +++ b/management-ui/src/styles/enterprise.css @@ -1160,6 +1160,9 @@ h1, h2, h3, h4, h5, h6 { .threat-tile.threat-tile--warn { opacity: 1; border-left: 3px solid #F59E0B; } .threat-tile.threat-tile--crit { opacity: 1; border-left: 3px solid #DC2626; } .threat-tile.threat-tile--idle { opacity: 0.55; border-left: 3px solid transparent; } + +/* Quittierte Alert-Events (Alerts → Events-Tab) werden abgedimmt. */ +tr.eg-row-muted > td { opacity: 0.5; } .threat-tile-value--hit { color: #EF4444; } .threat-tile-value--warn { color: #F59E0B; } .threat-tile-value--crit { color: #DC2626; } diff --git a/packaging/debian/edgeguard-api/DEBIAN/postinst b/packaging/debian/edgeguard-api/DEBIAN/postinst index 405a647..b195b00 100755 --- a/packaging/debian/edgeguard-api/DEBIAN/postinst +++ b/packaging/debian/edgeguard-api/DEBIAN/postinst @@ -823,6 +823,37 @@ EOSQL fi fi + # ── CrowdSec HAProxy-Acquisition auf journald zeigen ────────── + # EdgeGuards HAProxy loggt nach journald (log /dev/log local0/1) — + # es gibt KEINE /var/log/haproxy.log. `cscli setup` rät aber auf eine + # datei-basierte Quelle → CrowdSec liest nichts → die HTTP-/CVE- + # Szenarien laufen leer (WAF-artiger Web-Schutz tot). Wir setzen die + # Acquisition deterministisch auf die haproxy.service-Journal-Unit + # (analog zur sshd-Quelle). Läuft auf JEDEM configure (self-healing + # auch für Bestandsinstalls). Admin-Custom bleibt unangetastet: nur + # überschreiben, wenn Datei fehlt / noch die kaputte Datei-Default + # (/var/log/haproxy.log) enthält / von uns stammt. + if command -v cscli >/dev/null 2>&1; then + CS_HAPROXY_ACQUIS="/etc/crowdsec/acquis.d/setup.haproxy.yaml" + if [ ! -f "$CS_HAPROXY_ACQUIS" ] \ + || grep -q '/var/log/haproxy.log' "$CS_HAPROXY_ACQUIS" 2>/dev/null \ + || grep -q 'Managed by EdgeGuard' "$CS_HAPROXY_ACQUIS" 2>/dev/null; then + install -d -m 0755 /etc/crowdsec/acquis.d + cat > "$CS_HAPROXY_ACQUIS" <<'ACQUIS' +# Managed by EdgeGuard. HAProxy loggt nach journald (log /dev/log local0/1), +# es gibt keine /var/log/haproxy.log. journalctl-Quelle analog zur sshd-Acquis. +source: journalctl +journalctl_filter: + - "_SYSTEMD_UNIT=haproxy.service" +labels: + type: haproxy +ACQUIS + # SIGHUP-Reload lädt die Acquisition neu; Restart als Fallback. + systemctl reload crowdsec 2>/dev/null \ + || systemctl restart crowdsec 2>/dev/null || true + fi + fi + # ── Render initial service configs ─────────────────────────── # Writes /etc/edgeguard/haproxy/haproxy.cfg + nftables.d/ # ruleset.nft from the (just-migrated, empty) PG state.