Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a106151f1 | ||
|
|
be6b5f0657 | ||
|
|
e9c13dcf22 | ||
|
|
4cb9bfb2bc | ||
|
|
e0808bad90 | ||
|
|
1bbff5e749 | ||
|
|
32f89afebf | ||
|
|
ff87cb25fb | ||
|
|
0bd77e28d8 | ||
|
|
cdfadc0f26 | ||
|
|
b6007c50fd | ||
|
|
f45e01219b | ||
|
|
8c06329e33 | ||
|
|
6ffab68583 | ||
|
|
ea33c3b7b0 | ||
|
|
5b09a12287 | ||
|
|
682adbdd54 | ||
|
|
9594ec80ec | ||
|
|
b067c93677 | ||
|
|
25bb5dd44e | ||
|
|
adb8cf3a11 | ||
|
|
8c7a57dbf1 | ||
|
|
edac97e931 | ||
|
|
a400f8a4ba | ||
|
|
9deb205dce |
@@ -0,0 +1,28 @@
|
|||||||
|
# AGENTS.md — threadnet-operating
|
||||||
|
|
||||||
|
> **Die Gruppenregeln sind kanonisch im `management`-Repo:**
|
||||||
|
> [`AGENTS.md`](https://git.lab/axion1337.chat/management/-/blob/main/AGENTS.md)
|
||||||
|
> — von außerhalb des Labs über den Gitea-Mirror lesbar:
|
||||||
|
> `https://rohana.axion1337.de/sorb/management`. Dort stehen Repo-Topologie und
|
||||||
|
> Mirror-Regeln, das Kanban-Framework (Status-Labels, WIP-Limit 2, ADR-Pflicht),
|
||||||
|
> Deploy-Übergabe und AAR-Verfahren, Secrets-Handhabung und die
|
||||||
|
> Karpathy-Leitlinien. Sie gelten für **jede** Session in diesem Repo.
|
||||||
|
> Hier steht nur, was zusätzlich für dieses Repository gilt.
|
||||||
|
|
||||||
|
## Was dieses Repo ist
|
||||||
|
|
||||||
|
Monitoring-Stack auf **CFGMON**: Prometheus, Loki, Grafana, Alloy, Alertmanager und der
|
||||||
|
CVE-Exporter — als Compose, **nicht** von Flux verwaltet. Deploy manuell auf dem Host
|
||||||
|
(`/opt/threadnet-operating/monitoring`).
|
||||||
|
|
||||||
|
## Was hier besonders zählt
|
||||||
|
|
||||||
|
- **Alarmregeln** liegen in `monitoring/prometheus/alerts.yml`; die Zustellung läuft über
|
||||||
|
`alertmanager/matrix-alerts.py` in den Matrix-Security-Raum. Eine Regel, die nie feuern
|
||||||
|
kann, ist schlimmer als keine — **fehlende Zeitreihen erzeugen Stille, keinen Alarm**
|
||||||
|
(deshalb die `or`-Fallbacks in `axion-backup`).
|
||||||
|
- **Config-Änderungen kommen nicht automatisch an.** Prometheus und Alertmanager mounten seit
|
||||||
|
2026-08-15 ihr Config-**Verzeichnis**; bei den übrigen Diensten (loki, alloy, die Skripte)
|
||||||
|
hängt der Einzeldatei-Mount weiter am Inode und braucht `--force-recreate` nach `git pull`.
|
||||||
|
Details und Prüfbefehl in `monitoring/README.md`.
|
||||||
|
- Ob eine Änderung wirklich greift, sieht man **nur im Container**, nie auf der Platte.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Read AGENTS.md — the canonical instruction file for this repository. All rules live there.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# threadnet-operating
|
||||||
|
|
||||||
|
Der Betriebs-/Monitoring-Stack für den Operating-Host **CFGMON**: Prometheus,
|
||||||
|
Loki, Grafana, Alloy, Alertmanager und der CVE-Exporter — vollständig als Code,
|
||||||
|
ein `docker compose up -d` stellt ihn auf einem frischen Host wieder her.
|
||||||
|
|
||||||
|
**→ [`monitoring/README.md`](monitoring/README.md)** ist die eigentliche
|
||||||
|
Betriebsanleitung (Deployment, Config-Fallen, Alerting, CVE-Pipeline).
|
||||||
|
|
||||||
|
## Wo was liegt
|
||||||
|
|
||||||
|
| Pfad | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| `monitoring/` | der Stack: Compose, Prometheus, Loki, Grafana, Alertmanager, Alloy |
|
||||||
|
| `monitoring/cve/` | CVE-Exporter (Trivy-Scan → Prometheus-Metriken), [ADR-0003](https://git.lab/axion1337.chat/management/-/blob/main/decisions/0003-cve-meldeweg-aggregiert.md) |
|
||||||
|
| `monitoring/grafana/` | Datasources und Dashboards als Code |
|
||||||
|
|
||||||
|
## Offene Punkte
|
||||||
|
|
||||||
|
Kein Backlog in diesem Repo. Offene Punkte sind **Issues** im
|
||||||
|
[management-Projekt](https://git.lab/axion1337.chat/management/-/issues)
|
||||||
|
([ADR-0005](https://git.lab/axion1337.chat/management/-/blob/main/decisions/0005-pm-framework-kanban.md)) —
|
||||||
|
sie betreffen meist mehrere Hosts, eine Liste je Repo würde auseinanderlaufen.
|
||||||
|
Für diesen Stack einschlägig sind unter anderem
|
||||||
|
[#8 Remote-Write und Loki ohne Auth](https://git.lab/axion1337.chat/management/-/issues/8),
|
||||||
|
[#9 Grafana-Credentials](https://git.lab/axion1337.chat/management/-/issues/9) und
|
||||||
|
[#10 Gitea-Backups off-host](https://git.lab/axion1337.chat/management/-/issues/10);
|
||||||
|
Bestand und Historie zum Host stehen in
|
||||||
|
[`hosts/cfgmon.md`](https://git.lab/axion1337.chat/management/-/blob/main/hosts/cfgmon.md).
|
||||||
|
|
||||||
|
**Kanonisch ist git.lab** ([ADR-0001](https://git.lab/axion1337.chat/management/-/blob/main/decisions/0001-gitlab-kanonisch-push-mirror.md),
|
||||||
|
[ADR-0002](https://git.lab/axion1337.chat/management/-/blob/main/decisions/0002-issues-und-management-ins-lab.md)).
|
||||||
|
Von außerhalb des Labs ist derselbe Stand über den Push-Mirror
|
||||||
|
[`sorb/management`](https://rohana.axion1337.de/sorb/management) lesbar — dorthin
|
||||||
|
aber **nie pushen**, der Mirror überschreibt.
|
||||||
@@ -2,3 +2,17 @@
|
|||||||
# Werte in Single-Quotes, damit Sonderzeichen shell-safe sind.
|
# Werte in Single-Quotes, damit Sonderzeichen shell-safe sind.
|
||||||
GRAFANA_ADMIN_USER='admin'
|
GRAFANA_ADMIN_USER='admin'
|
||||||
GRAFANA_ADMIN_PASSWORD='changeme'
|
GRAFANA_ADMIN_PASSWORD='changeme'
|
||||||
|
|
||||||
|
# Alerting (gitops#32) - Matrix-Zustellung durch den eigenen Bot @alerts in den
|
||||||
|
# dedizierten Alerts-Raum im Operating-Space (Entscheidung 2026-07-31: eigener Bot
|
||||||
|
# + eigener Raum, Token-Trennung vom maintenance-notify-Bot auf dem MATRIX-Host).
|
||||||
|
# Token erzeugen (Wert direkt hier eintragen, nicht in Chats/Logs):
|
||||||
|
# kubectl exec -n matrix deploy/matrix-stack-matrix-authentication-service -- \
|
||||||
|
# mas-cli manage issue-compatibility-token alerts
|
||||||
|
# Bot ist dem Raum bereits beigetreten (2026-07-31, inkl. Avatar + Testnachricht).
|
||||||
|
MATRIX_ALERT_HOMESERVER=https://matrix.axion1337.chat
|
||||||
|
MATRIX_ALERT_ROOM_ID=!qavWkXbhLPfGvqtifj:axion1337.chat
|
||||||
|
MATRIX_ALERT_TOKEN=changeme
|
||||||
|
|
||||||
|
# CVE-/Release-Raum fuer release-watch (gitops#47) - @alerts dort einladen!
|
||||||
|
MATRIX_RELEASE_ROOM_ID=!YRJvcEbVXtRlUIkNld:axion1337.chat
|
||||||
|
|||||||
@@ -13,6 +13,45 @@ docker network create traefik # falls noch nicht vorhanden
|
|||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Config-Aenderungen: was wirklich ankommt
|
||||||
|
|
||||||
|
**Prometheus und Alertmanager mounten seit 2026-08-15 ihr Config-VERZEICHNIS**
|
||||||
|
(`./prometheus`, `./alertmanager`) statt einzelner Dateien. Damit ist die frueher
|
||||||
|
hier beschriebene Inode-Falle fuer sie beseitigt: `git pull` ersetzt Dateien per
|
||||||
|
Rename (neuer Inode); ein Einzeldatei-Mount zeigt danach weiter auf die **alte**
|
||||||
|
Datei, waehrend `SIGHUP` seelenruhig Erfolg meldet. Verzeichnis-Mounts loesen bei
|
||||||
|
jedem Zugriff ueber den Pfad auf.
|
||||||
|
|
||||||
|
Warum strukturell statt per Anleitung: genau diese Falle war hier bereits
|
||||||
|
ausfuehrlich dokumentiert -- samt richtigem Kommando und Pruefbefehl -- und hat am
|
||||||
|
2026-08-14 trotzdem zugeschlagen (geladen wurden die alten Alert-Regeln, Reload
|
||||||
|
meldete Erfolg). Eine Fussangel, die man nur durch Lesen umgeht, umgeht man
|
||||||
|
irgendwann nicht.
|
||||||
|
|
||||||
|
Nach einem `git pull`, der Configs anfasst:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d # Mount-/Service-Aenderungen -> Container wird neu erstellt
|
||||||
|
```
|
||||||
|
|
||||||
|
Reicht bei Prometheus/Alertmanager fuer den Inhalt bereits ein Reload, ist das in
|
||||||
|
Ordnung -- die Datei ist jetzt wirklich die neue.
|
||||||
|
|
||||||
|
**Noch als Einzeldatei gemountet** (gleiche Falle, dort weiterhin
|
||||||
|
`--force-recreate` noetig): `loki/loki-config.yaml`, `alloy/config.alloy` sowie die
|
||||||
|
Skripte `matrix-alerts.py`, `release-watch.py`, `cve-exporter.py`.
|
||||||
|
|
||||||
|
Ob eine Aenderung angekommen ist, sieht man nur **im Container**, nie auf der Platte:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec prometheus md5sum /etc/prometheus/alerts.yml
|
||||||
|
md5sum prometheus/alerts.yml # muessen uebereinstimmen
|
||||||
|
```
|
||||||
|
|
||||||
|
Grafana liest **Provider-Definitionen** nur beim Start: ein neuer Ordner in
|
||||||
|
`provisioning/dashboards/dashboards.yml` braucht `docker compose restart grafana`.
|
||||||
|
Dashboard-JSONs innerhalb eines bestehenden Providers werden laufend nachgezogen.
|
||||||
|
|
||||||
## Struktur
|
## Struktur
|
||||||
|
|
||||||
| Pfad | Inhalt |
|
| Pfad | Inhalt |
|
||||||
@@ -23,6 +62,9 @@ docker compose up -d
|
|||||||
| `alloy/config.alloy` | Docker-Log-Collection -> Loki, node-exporter -> Prometheus |
|
| `alloy/config.alloy` | Docker-Log-Collection -> Loki, node-exporter -> Prometheus |
|
||||||
| `grafana/provisioning/` | Datasources (feste UIDs!) und Dashboard-Provider |
|
| `grafana/provisioning/` | Datasources (feste UIDs!) und Dashboard-Provider |
|
||||||
| `grafana/dashboards/<ordner>/*.json` | Dashboards, je Unterordner ein Grafana-Ordner |
|
| `grafana/dashboards/<ordner>/*.json` | Dashboards, je Unterordner ein Grafana-Ordner |
|
||||||
|
| `cve/images.txt` | Liste der real deployten Images, Inventur aus dem Cluster |
|
||||||
|
| `cve/scan-loop.sh` | Trivy-Scan-Schleife (24h), schreibt JSON-Reports |
|
||||||
|
| `cve/cve-exporter.py` | Reports -> Prometheus-Metriken, mit First-Seen-State |
|
||||||
|
|
||||||
## Externe Abhaengigkeiten
|
## Externe Abhaengigkeiten
|
||||||
|
|
||||||
@@ -42,6 +84,57 @@ aber die JSON-Datei im Repo ist die Quelle der Wahrheit: UI-Aenderungen
|
|||||||
muessen exportiert und committet werden, sonst gehen sie beim naechsten
|
muessen exportiert und committet werden, sonst gehen sie beim naechsten
|
||||||
Datei-Update verloren.
|
Datei-Update verloren.
|
||||||
|
|
||||||
|
## CVE-Pipeline (gitops#47)
|
||||||
|
|
||||||
|
`cve-scan` scannt alle 24h die Images aus `cve/images.txt` mit Trivy und legt
|
||||||
|
JSON-Reports in ein Volume; `cve-exporter` serviert sie als Metriken auf
|
||||||
|
`:9101`, Prometheus scraped sie als Job `cve_exporter`. Dashboard:
|
||||||
|
**Security / CVE-Uebersicht (Trivy)**.
|
||||||
|
|
||||||
|
Der Exporter merkt sich je `(CVE, Target)` den Erstfund in einem persistenten
|
||||||
|
State (`cve_exporter_state`-Volume) -- daher kommt die Spalte
|
||||||
|
"erstmals gesehen". Verschwindet ein Finding, faellt der Eintrag raus
|
||||||
|
(= "geschlossen").
|
||||||
|
|
||||||
|
Bei Stack-Aenderungen `cve/images.txt` nachziehen, sonst scannt die Pipeline
|
||||||
|
an der Realitaet vorbei.
|
||||||
|
|
||||||
|
### Alarm-Zustellung (frueher stummgeschaltet — seit gitops#51 wieder scharf)
|
||||||
|
|
||||||
|
**Stand 2026-08-15: Alarme werden zugestellt.** `alertmanager.yml` hat nur noch die
|
||||||
|
Default-Route auf den `matrix`-Receiver; die frueher hier beschriebene
|
||||||
|
`room="security"`-Route auf den Null-Receiver existiert nicht mehr. Alarme **ohne**
|
||||||
|
`room`-Label (u.a. die `axion-basics`- und `axion-backup`-Gruppen) laufen ueber
|
||||||
|
diese Default-Route.
|
||||||
|
|
||||||
|
Historie, damit der Grund der damaligen Stummschaltung nicht verlorengeht: die
|
||||||
|
CVE-Regeln erzeugten je eine Alarm-Instanz **pro CVE pro Image** (beim ersten Lauf
|
||||||
|
59 CRITICAL + 445 HIGH), `matrix-alerts.py` schickte eine Nachricht pro Alarm,
|
||||||
|
Synapse rate-limitete nach ~10 Nachrichten mit 429, und weil `save_state()` hinter
|
||||||
|
der Sende-Schleife stand, wiederholte Alertmanager die komplette Gruppe mit noch
|
||||||
|
leerer Deduplizierung. Behoben durch Aggregation auf `count by (target, severity)`
|
||||||
|
(eine Instanz je Image statt je CVE); Details im Dashboard `cve-overview`.
|
||||||
|
Vollstaendige Historie: gitops#51 / AAR 2026-08-01.
|
||||||
|
|
||||||
|
**Zustellfehler sind seit 2026-08-15 selbst ueberwacht:** Prometheus scrapt
|
||||||
|
Alertmanager (`operating_alertmanager`) und `AlertDeliveryFailing` schlaegt an,
|
||||||
|
wenn `alertmanager_notifications_failed_total` steigt. Vorher war Alertmanager zwar
|
||||||
|
Alarm-Ziel, aber kein Scrape-Target — eine reissende Alarmkette haette sich also
|
||||||
|
selbst nicht melden koennen.
|
||||||
|
|
||||||
|
### Kleinere offene Punkte der Pipeline
|
||||||
|
|
||||||
|
- `TrivyScanStale` kann ein Image, das **nie** erfolgreich gescannt wurde,
|
||||||
|
nicht melden: ohne ersten Report gibt es keine Serie, an der
|
||||||
|
`time() - trivy_last_scan_timestamp` haengen koennte. Ein dauerhaft
|
||||||
|
fehlschlagendes Image bleibt damit still. `TargetDown` deckt nur den toten
|
||||||
|
Exporter ab, nicht den einzelnen blinden Fleck.
|
||||||
|
- `coturn/coturn:latest` ist als einziges Image ungepinnt -- Scan-Ergebnisse
|
||||||
|
sind dadurch nicht reproduzierbar.
|
||||||
|
- Der Exporter prunt den First-Seen-State bei **jedem** Scrape anhand der
|
||||||
|
gerade gelesenen Reports. Ein transienter Lesefehler (`except: continue`)
|
||||||
|
loescht die Erstfund-Zeitstempel des betroffenen Targets dauerhaft.
|
||||||
|
|
||||||
## Offene Punkte / Sicherheit
|
## Offene Punkte / Sicherheit
|
||||||
|
|
||||||
- `9090`, `3100`, `9100` sind auf der oeffentlichen IP ohne Auth erreichbar
|
- `9090`, `3100`, `9100` sind auf der oeffentlichen IP ohne Auth erreichbar
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Alertmanager (gitops#32): alles an den Matrix-Receiver (wartung-Raum).
|
||||||
|
route:
|
||||||
|
receiver: matrix
|
||||||
|
group_by: [alertname, instance]
|
||||||
|
group_wait: 1m
|
||||||
|
group_interval: 5m
|
||||||
|
repeat_interval: 4h
|
||||||
|
# CVE-Alarme laufen seit gitops#51 aggregiert (eine Instanz pro Image statt
|
||||||
|
# pro CVE) und wieder ueber den Matrix-Receiver — Historie des Alarm-Sturms
|
||||||
|
# und der Stummschaltung: gitops#51 / AAR 2026-08-01.
|
||||||
|
|
||||||
|
receivers:
|
||||||
|
- name: matrix
|
||||||
|
webhook_configs:
|
||||||
|
- url: http://matrix-alerts:8080/alert
|
||||||
|
send_resolved: true
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Minimaler Alertmanager-Webhook -> Matrix-Raum (gitops#32). Gleiche Machart wie
|
||||||
|
# maintenance-notify (Issue #24 im gitops-Repo): purer Stdlib-HTTP-Server, Bot-Token
|
||||||
|
# aus der Umgebung, Nachricht per Client-Server-API.
|
||||||
|
#
|
||||||
|
# Ein Alarm = EINE Nachricht: beim Firing wird pro Alarm (Alertmanager-Fingerprint)
|
||||||
|
# eine Nachricht gesendet und deren Event-ID gemerkt; beim Resolved wird dieselbe
|
||||||
|
# Nachricht per m.replace-Edit durchgestrichen und abgehakt statt eine neue zu
|
||||||
|
# posten (Wunsch sorb 2026-08-01: append-only wird unuebersichtlich). Die Zuordnung
|
||||||
|
# ueberlebt Container-Restarts via State-Datei; ohne Zuordnung (z.B. nach Neubau)
|
||||||
|
# faellt Resolved auf eine eigenstaendige ✅-Nachricht zurueck.
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
import uuid
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
HOMESERVER = os.environ["MATRIX_HOMESERVER"]
|
||||||
|
ROOM_ID = os.environ["MATRIX_ROOM_ID"]
|
||||||
|
TOKEN = os.environ["MATRIX_TOKEN"]
|
||||||
|
STATE_FILE = os.environ.get("MATRIX_STATE_FILE", "/tmp/matrix-alerts-state.json")
|
||||||
|
# Raum-Routing (gitops#47): Alerts mit Label room="<name>" landen im Raum aus
|
||||||
|
# MATRIX_ROOM_<NAME> (z.B. room="security" -> MATRIX_ROOM_SECURITY), alles
|
||||||
|
# andere im Default-Raum. Der Bot muss in jedem Zielraum Mitglied sein.
|
||||||
|
ROOM_MAP = {k[len("MATRIX_ROOM_"):].lower(): v
|
||||||
|
for k, v in os.environ.items()
|
||||||
|
if k.startswith("MATRIX_ROOM_") and k != "MATRIX_ROOM_ID" and v}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(STATE_FILE) as f:
|
||||||
|
_persisted = json.load(f)
|
||||||
|
state = _persisted.get("alerts", _persisted) # alt: flaches Format
|
||||||
|
recent_resolved = _persisted.get("recent_resolved", [])
|
||||||
|
except Exception:
|
||||||
|
state = {}
|
||||||
|
recent_resolved = []
|
||||||
|
|
||||||
|
|
||||||
|
def save_state():
|
||||||
|
try:
|
||||||
|
with open(STATE_FILE, "w") as f:
|
||||||
|
json.dump({"alerts": state, "recent_resolved": recent_resolved[-200:]}, f)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"state save failed: {e}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def matrix_put(content, room=ROOM_ID):
|
||||||
|
url = (f"{HOMESERVER}/_matrix/client/v3/rooms/{urllib.parse.quote(room)}"
|
||||||
|
f"/send/m.room.message/{uuid.uuid4()}")
|
||||||
|
req = urllib.request.Request(url, data=json.dumps(content).encode(), method="PUT",
|
||||||
|
headers={"Authorization": f"Bearer {TOKEN}",
|
||||||
|
"Content-Type": "application/json"})
|
||||||
|
return json.loads(urllib.request.urlopen(req, timeout=10).read()).get("event_id")
|
||||||
|
|
||||||
|
|
||||||
|
def send_text(text, room=ROOM_ID):
|
||||||
|
return matrix_put({"msgtype": "m.text", "body": text}, room)
|
||||||
|
|
||||||
|
|
||||||
|
def edit_resolved(event_id, old_text, room=ROOM_ID):
|
||||||
|
# m.replace-Edit: Original wird in Element in-place ersetzt (durchgestrichen + Haken)
|
||||||
|
new_body = f"✅ ~~{old_text}~~"
|
||||||
|
new_html = f"✅ <del>{html.escape(old_text)}</del>"
|
||||||
|
matrix_put({
|
||||||
|
"msgtype": "m.text",
|
||||||
|
"body": f"* {new_body}",
|
||||||
|
"m.new_content": {"msgtype": "m.text", "body": new_body,
|
||||||
|
"format": "org.matrix.custom.html", "formatted_body": new_html},
|
||||||
|
"m.relates_to": {"rel_type": "m.replace", "event_id": event_id},
|
||||||
|
}, room)
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def do_POST(self):
|
||||||
|
if self.path != "/alert":
|
||||||
|
self.send_response(404); self.end_headers(); return
|
||||||
|
data = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))))
|
||||||
|
failed = 0
|
||||||
|
for a in data.get("alerts", []):
|
||||||
|
name = a.get("labels", {}).get("alertname", "?")
|
||||||
|
summary = a.get("annotations", {}).get("summary", "")
|
||||||
|
fp = a.get("fingerprint", "")
|
||||||
|
room = ROOM_MAP.get(a.get("labels", {}).get("room", ""), ROOM_ID)
|
||||||
|
try:
|
||||||
|
if a.get("status") == "firing":
|
||||||
|
if fp in state: # re-notify/Batch-Retry -> keine Doppelnachricht
|
||||||
|
continue
|
||||||
|
text = f"\U0001F534 [firing] {name}: {summary}"
|
||||||
|
event_id = send_text(text, room)
|
||||||
|
if fp and event_id:
|
||||||
|
state[fp] = {"event_id": event_id, "text": text, "room": room}
|
||||||
|
else:
|
||||||
|
if fp in recent_resolved: # Batch-Retry -> Fallback nicht doppeln
|
||||||
|
continue
|
||||||
|
known = state.pop(fp, None)
|
||||||
|
if known:
|
||||||
|
edit_resolved(known["event_id"], known["text"], known.get("room", ROOM_ID))
|
||||||
|
else:
|
||||||
|
send_text(f"✅ [resolved] {name}: {summary}", room)
|
||||||
|
recent_resolved.append(fp)
|
||||||
|
save_state() # inkrementell: Teilfortschritt uebersteht Fehler/Retry
|
||||||
|
time.sleep(1) # Synapse-Ratelimit (rc_message) nicht reizen
|
||||||
|
except Exception as e:
|
||||||
|
failed += 1
|
||||||
|
self.log_message("matrix send failed (%s): %s", name, e)
|
||||||
|
save_state()
|
||||||
|
time.sleep(2)
|
||||||
|
if failed:
|
||||||
|
# Alertmanager wiederholt den Batch; Dedup liefert nur den Rest nach
|
||||||
|
self.send_response(502); self.end_headers(); return
|
||||||
|
self.send_response(200); self.end_headers()
|
||||||
|
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
print(fmt % args, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
ThreadingHTTPServer(("0.0.0.0", 8080), Handler).serve_forever()
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Release-/Advisory-Watch fuer den Element-Stack (gitops#22). Gleiche Machart wie
|
||||||
|
# matrix-alerts.py: purer Stdlib-Daemon, Bot-Token aus der Umgebung, Nachricht per
|
||||||
|
# Client-Server-API in den Alerts-Raum.
|
||||||
|
#
|
||||||
|
# Quelle sind die oeffentlichen GitHub-Release-Atom-Feeds (kein API-Token noetig).
|
||||||
|
# Element veroeffentlicht Security-Fixes als Releases - der Feed ist damit der
|
||||||
|
# praktikable Advisory-Kanal; echte GHSA-Advisories haben keinen oeffentlichen Feed.
|
||||||
|
# Neue Eintraege werden einmalig als 📦-Notiz gemeldet; Security-verdaechtige
|
||||||
|
# Titel/Inhalte (CVE/security/vulnerab...) bekommen 🚨 und stehen vorn.
|
||||||
|
#
|
||||||
|
# Abgrenzung (Frage sorb 2026-08-01): REPOS unten ist eine HANDGEPFLEGTE Liste -
|
||||||
|
# de facto ein Mini-SBOM auf Repo-Granularitaet, ohne Versions-/Dependency-Wissen.
|
||||||
|
# Bei Stack-Aenderungen (neue Komponente, Fork-Wechsel) muss sie mitgezogen werden.
|
||||||
|
# Trivy (gitops#31/#47) braucht dagegen keine Pflege: es leitet sein SBOM selbst
|
||||||
|
# aus den Images ab. Beide ergaenzen sich, ersetzen sich nicht.
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
import uuid
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
HOMESERVER = os.environ["MATRIX_HOMESERVER"]
|
||||||
|
# Eigener CVE-/Release-Raum (Entscheidung sorb 2026-08-01, gitops#47);
|
||||||
|
# Fallback auf den Alerts-Raum, solange der Bot dort noch nicht eingeladen ist.
|
||||||
|
ROOM_ID = os.environ.get("MATRIX_RELEASE_ROOM_ID") or os.environ["MATRIX_ROOM_ID"]
|
||||||
|
TOKEN = os.environ["MATRIX_TOKEN"]
|
||||||
|
STATE_FILE = os.environ.get("RELEASE_WATCH_STATE_FILE", "/state/release-watch.json")
|
||||||
|
INTERVAL = int(os.environ.get("RELEASE_WATCH_INTERVAL", "21600")) # 6h
|
||||||
|
|
||||||
|
REPOS = [
|
||||||
|
"element-hq/synapse",
|
||||||
|
"element-hq/ess-helm",
|
||||||
|
"element-hq/element-web",
|
||||||
|
"element-hq/matrix-authentication-service",
|
||||||
|
"element-hq/element-call",
|
||||||
|
]
|
||||||
|
SECURITY_RE = re.compile(r"cve|security|vulnerab|advisory", re.IGNORECASE)
|
||||||
|
ATOM = "{http://www.w3.org/2005/Atom}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(STATE_FILE) as f:
|
||||||
|
seen = json.load(f) # repo -> [entry ids]
|
||||||
|
except Exception:
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
|
||||||
|
def send_notice(text):
|
||||||
|
url = (f"{HOMESERVER}/_matrix/client/v3/rooms/{urllib.parse.quote(ROOM_ID)}"
|
||||||
|
f"/send/m.room.message/{uuid.uuid4()}")
|
||||||
|
req = urllib.request.Request(url, data=json.dumps({"msgtype": "m.notice", "body": text}).encode(),
|
||||||
|
method="PUT", headers={"Authorization": f"Bearer {TOKEN}",
|
||||||
|
"Content-Type": "application/json"})
|
||||||
|
urllib.request.urlopen(req, timeout=10).read()
|
||||||
|
|
||||||
|
|
||||||
|
def check(repo):
|
||||||
|
feed = urllib.request.urlopen(f"https://github.com/{repo}/releases.atom", timeout=20).read()
|
||||||
|
root = ET.fromstring(feed)
|
||||||
|
entries = root.findall(f"{ATOM}entry")
|
||||||
|
known = set(seen.get(repo, []))
|
||||||
|
first_run = repo not in seen
|
||||||
|
new = []
|
||||||
|
for e in entries:
|
||||||
|
eid = e.findtext(f"{ATOM}id", "")
|
||||||
|
title = e.findtext(f"{ATOM}title", "?").strip()
|
||||||
|
link = ""
|
||||||
|
le = e.find(f"{ATOM}link")
|
||||||
|
if le is not None:
|
||||||
|
link = le.get("href", "")
|
||||||
|
content = e.findtext(f"{ATOM}content", "") or ""
|
||||||
|
if eid and eid not in known:
|
||||||
|
new.append((eid, title, link, bool(SECURITY_RE.search(title + " " + content[:2000]))))
|
||||||
|
# Erstlauf: nur Stand merken, nicht den Raum mit Historie fluten
|
||||||
|
seen[repo] = [e.findtext(f"{ATOM}id", "") for e in entries][:30]
|
||||||
|
if first_run:
|
||||||
|
return
|
||||||
|
for eid, title, link, is_sec in new:
|
||||||
|
icon = "\U0001F6A8" if is_sec else "\U0001F4E6"
|
||||||
|
kind = "Security-verdaechtiges Release" if is_sec else "Neues Release"
|
||||||
|
send_notice(f"{icon} {kind}: {repo} — {title}\n{link}")
|
||||||
|
|
||||||
|
|
||||||
|
while True:
|
||||||
|
for repo in REPOS:
|
||||||
|
try:
|
||||||
|
check(repo)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"{repo}: {exc}", flush=True)
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
|
||||||
|
with open(STATE_FILE, "w") as f:
|
||||||
|
json.dump(seen, f)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"state: {exc}", flush=True)
|
||||||
|
time.sleep(INTERVAL)
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Trivy-JSON -> Prometheus-Metriken (gitops#47). Stdlib-only, Machart wie die
|
||||||
|
# uebrigen Monitoring-Helfer. Liest die Reports des cve-scan-Sidecars und
|
||||||
|
# serviert /metrics; merkt sich je (CVE, Target) den Erstfund (first_seen),
|
||||||
|
# damit der geforderte Zeitstrahl (erstmals gesehen / geschlossen) abbildbar
|
||||||
|
# ist - "geschlossen" = Serie verschwindet, resolved kommt via Alertmanager.
|
||||||
|
#
|
||||||
|
# Schema (Pflichtfelder-Vorgabe sorb: CVE-ID, Mitigation, Zeitstrahl, Ort, Typ):
|
||||||
|
# trivy_vuln_info{cve,severity,target,target_type,host,pkg,installed,fixed_version} 1
|
||||||
|
# (nur HIGH/CRITICAL als Einzelserien - Kardinalitaet)
|
||||||
|
# trivy_vuln_count{target,target_type,host,severity} <n> (alle Severities)
|
||||||
|
# trivy_vuln_first_seen_timestamp{cve,target} <unix>
|
||||||
|
# trivy_last_scan_timestamp{target,target_type,host} <unix>
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
RESULTS = os.environ.get("RESULTS_DIR", "/results")
|
||||||
|
STATE_FILE = os.environ.get("STATE_FILE", "/state/first-seen.json")
|
||||||
|
# Ort: Container-Images laufen (bis auf Weiteres) alle auf dem MATRIX-Host;
|
||||||
|
# Host-rootfs-Scans (Ausbaustufe) bringen ihren Hostnamen im Dateinamen mit.
|
||||||
|
DEFAULT_HOST = os.environ.get("DEFAULT_HOST", "matrix")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(STATE_FILE) as f:
|
||||||
|
first_seen = json.load(f) # "cve|target" -> unix-ts
|
||||||
|
except Exception:
|
||||||
|
first_seen = {}
|
||||||
|
|
||||||
|
|
||||||
|
def esc(v):
|
||||||
|
return str(v).replace("\\", "\\\\").replace('"', '\\"').replace("\n", " ")
|
||||||
|
|
||||||
|
|
||||||
|
def collect():
|
||||||
|
lines = []
|
||||||
|
now = int(time.time())
|
||||||
|
seen_keys = set()
|
||||||
|
gelesene_targets = set() # nur DEREN Erstfunde duerfen geprunt werden
|
||||||
|
berichte = 0
|
||||||
|
lesefehler = 0
|
||||||
|
for fn in sorted(os.listdir(RESULTS)):
|
||||||
|
if not fn.endswith(".json"):
|
||||||
|
continue
|
||||||
|
berichte += 1
|
||||||
|
path = os.path.join(RESULTS, fn)
|
||||||
|
try:
|
||||||
|
rep = json.load(open(path))
|
||||||
|
except Exception as e:
|
||||||
|
# Frueher: stilles 'continue'. Ein einmaliger Lesefehler (Datei wird
|
||||||
|
# gerade geschrieben, kurzer I/O-Fehler) liess die Findings dieses
|
||||||
|
# Targets aus seen_keys verschwinden - und der Prune unten loeschte
|
||||||
|
# ihre Erstfund-Zeitstempel DAUERHAFT. Der Zeitstrahl war damit weg,
|
||||||
|
# ohne dass irgendetwas gemeldet haette. Jetzt zaehlbar und sichtbar.
|
||||||
|
lesefehler += 1
|
||||||
|
print(f"report unlesbar: {fn}: {e}", flush=True)
|
||||||
|
continue
|
||||||
|
target = rep.get("ArtifactName", fn[:-5])
|
||||||
|
gelesene_targets.add(target)
|
||||||
|
ttype = "host" if rep.get("ArtifactType") in ("filesystem", "rootfs") else "image"
|
||||||
|
host = fn.split("__host__")[1].split(".json")[0] if "__host__" in fn else DEFAULT_HOST
|
||||||
|
base = f'target="{esc(target)}",target_type="{ttype}",host="{esc(host)}"'
|
||||||
|
lines.append(f'trivy_last_scan_timestamp{{{base}}} {int(os.path.getmtime(path))}')
|
||||||
|
counts = {}
|
||||||
|
for res in rep.get("Results") or []:
|
||||||
|
for v in res.get("Vulnerabilities") or []:
|
||||||
|
sev = v.get("Severity", "UNKNOWN")
|
||||||
|
counts[sev] = counts.get(sev, 0) + 1
|
||||||
|
if sev in ("HIGH", "CRITICAL"):
|
||||||
|
cve = v.get("VulnerabilityID", "?")
|
||||||
|
key = f"{cve}|{target}"
|
||||||
|
if key not in first_seen:
|
||||||
|
first_seen[key] = now
|
||||||
|
seen_keys.add(key)
|
||||||
|
lines.append(
|
||||||
|
'trivy_vuln_info{cve="%s",severity="%s",%s,pkg="%s",installed="%s",fixed_version="%s"} 1'
|
||||||
|
% (esc(cve), sev, base, esc(v.get("PkgName", "?")),
|
||||||
|
esc(v.get("InstalledVersion", "?")), esc(v.get("FixedVersion", ""))))
|
||||||
|
lines.append(f'trivy_vuln_first_seen_timestamp{{cve="{esc(cve)}",target="{esc(target)}"}} {first_seen[key]}')
|
||||||
|
for sev, n in sorted(counts.items()):
|
||||||
|
lines.append(f'trivy_vuln_count{{{base},severity="{sev}"}} {n}')
|
||||||
|
# State kompakt halten: verschwundene Findings raus (= "geschlossen").
|
||||||
|
# ABER nur fuer Targets, deren Bericht in DIESEM Durchgang auch wirklich
|
||||||
|
# gelesen wurde. Sonst loescht ein einzelner Lesefehler den Zeitstrahl eines
|
||||||
|
# Targets, das es noch gibt - unwiederbringlich, weil "erstmals gesehen"
|
||||||
|
# danach auf 'jetzt' neu anfaengt.
|
||||||
|
for k in list(first_seen):
|
||||||
|
ziel = k.split("|", 1)[1] if "|" in k else ""
|
||||||
|
if ziel in gelesene_targets and k not in seen_keys:
|
||||||
|
del first_seen[k]
|
||||||
|
|
||||||
|
# Sichtbarkeit des Scanners selbst: ein still gestorbener Scanner macht blind,
|
||||||
|
# und TrivyScanStale kann ein Target, das NIE einen Bericht hatte, nicht melden
|
||||||
|
# (ohne Serie kein time()-Vergleich). Diese beiden Zahlen schliessen die Luecke
|
||||||
|
# so weit, wie sie ohne Soll-Liste zu schliessen ist.
|
||||||
|
lines.append(f"trivy_reports_total {berichte}")
|
||||||
|
lines.append(f"trivy_report_read_errors {lesefehler}")
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
|
||||||
|
with open(STATE_FILE, "w") as f:
|
||||||
|
json.dump(first_seen, f)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"state save failed: {e}", flush=True)
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path != "/metrics":
|
||||||
|
self.send_response(404); self.end_headers(); return
|
||||||
|
body = collect().encode()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/plain; version=0.0.4")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
ThreadingHTTPServer(("0.0.0.0", 9101), Handler).serve_forever()
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
clamav/clamav:1.5.3
|
||||||
|
coturn/coturn:latest
|
||||||
|
docker.io/grafana/alloy:v1.7.5
|
||||||
|
docker.io/library/haproxy:3.2-alpine
|
||||||
|
docker.io/library/postgres:17.9-bookworm
|
||||||
|
docker.io/livekit/livekit-server:v1.10.0
|
||||||
|
docker.io/postgres:17-alpine
|
||||||
|
docker.io/prometheuscommunity/postgres-exporter:v0.18.1
|
||||||
|
ghcr.io/element-hq/ess-helm/matrix-tools:0.7.3
|
||||||
|
ghcr.io/element-hq/matrix-authentication-service:1.15.0
|
||||||
|
ghcr.io/fluxcd/helm-controller:v1.5.3
|
||||||
|
ghcr.io/fluxcd/kustomize-controller:v1.8.3
|
||||||
|
ghcr.io/fluxcd/notification-controller:v1.8.3
|
||||||
|
ghcr.io/fluxcd/source-controller:v1.8.2
|
||||||
|
ghcr.io/goauthentik/server:2026.2.3
|
||||||
|
gnuxie/draupnir:v3.1.0
|
||||||
|
nginx:1.26-alpine
|
||||||
|
oci.element.io/element-admin:0.1.11
|
||||||
|
oci.element.io/lk-jwt-service:0.4.2
|
||||||
|
oci.element.io/synapse:v1.151.0-ess.1
|
||||||
|
quay.io/jetstack/cert-manager-cainjector:v1.14.0
|
||||||
|
quay.io/jetstack/cert-manager-controller:v1.14.0
|
||||||
|
quay.io/jetstack/cert-manager-webhook:v1.14.0
|
||||||
|
quay.io/prometheus-operator/prometheus-config-reloader:v0.81.0
|
||||||
|
registry.k8s.io/kube-state-metrics/kube-state-metrics:v2.15.0
|
||||||
|
rohana.axion1337.de/sorb/axion-backup:v2
|
||||||
|
rohana.axion1337.de/sorb/axion-secret-rotation:v1
|
||||||
|
rohana.axion1337.de/sorb/clamav-http-scanner:v1.0.0
|
||||||
|
rohana.axion1337.de/sorb/threadnet-web:v0.3.0
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Trivy-Scan-Schleife (gitops#47): scannt alle 24h die Liste der real deployten
|
||||||
|
# Images (cve/images.txt, aus dem Cluster inventarisiert) und legt die
|
||||||
|
# JSON-Reports fuer den cve-exporter ab. Kein Abbruch bei Einzelfehlern -
|
||||||
|
# ein nicht mehr pullbares Image darf den Rest nicht verhindern (der Exporter
|
||||||
|
# alarmiert ueber trivy_last_scan_timestamp, wenn ein Report veraltet).
|
||||||
|
set -u
|
||||||
|
RESULTS=/results
|
||||||
|
IMAGES=/config/images.txt
|
||||||
|
INTERVAL="${SCAN_INTERVAL_SECONDS:-86400}"
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
while IFS= read -r img; do
|
||||||
|
case "$img" in ""|\#*) continue;; esac
|
||||||
|
safe=$(echo "$img" | tr '/:@' '___')
|
||||||
|
echo "scan: $img"
|
||||||
|
trivy image --scanners vuln --format json --output "$RESULTS/$safe.json.tmp" "$img" \
|
||||||
|
&& mv "$RESULTS/$safe.json.tmp" "$RESULTS/$safe.json" \
|
||||||
|
|| echo "FEHLER bei $img (Report bleibt auf altem Stand)"
|
||||||
|
done < "$IMAGES"
|
||||||
|
echo "runde fertig, schlafe ${INTERVAL}s"
|
||||||
|
sleep "$INTERVAL"
|
||||||
|
done
|
||||||
@@ -4,7 +4,12 @@ services:
|
|||||||
container_name: prometheus
|
container_name: prometheus
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
# Verzeichnis- statt Einzeldatei-Mount: Docker haengt Einzeldatei-Mounts am
|
||||||
|
# Inode auf, und `git pull` ersetzt Dateien per Rename (neuer Inode) - der
|
||||||
|
# Container sah die Aenderung dann nie, waehrend SIGHUP brav Erfolg meldete.
|
||||||
|
# Verzeichnis-Mounts loesen bei jedem Zugriff ueber den Pfad auf.
|
||||||
|
# Config-Pfade bleiben unveraendert (--config.file zeigt weiter dorthin).
|
||||||
|
- ./prometheus:/etc/prometheus:ro
|
||||||
- prometheus_data:/prometheus
|
- prometheus_data:/prometheus
|
||||||
command:
|
command:
|
||||||
- '--config.file=/etc/prometheus/prometheus.yml'
|
- '--config.file=/etc/prometheus/prometheus.yml'
|
||||||
@@ -20,6 +25,98 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- traefik
|
- traefik
|
||||||
|
|
||||||
|
alertmanager:
|
||||||
|
image: prom/alertmanager:v0.28.1
|
||||||
|
container_name: alertmanager
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
# Verzeichnis-Mount, gleicher Grund wie bei Prometheus. Die beiden .py aus
|
||||||
|
# diesem Ordner liegen dadurch mit unter /etc/alertmanager - ungenutzt und
|
||||||
|
# harmlos, alertmanager laedt ausschliesslich --config.file.
|
||||||
|
- ./alertmanager:/etc/alertmanager:ro
|
||||||
|
- alertmanager_data:/alertmanager
|
||||||
|
command:
|
||||||
|
- '--config.file=/etc/alertmanager/alertmanager.yml'
|
||||||
|
- '--storage.path=/alertmanager'
|
||||||
|
# bewusst kein oeffentlicher Port - nur Prometheus/Receiver im traefik-Netz
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
|
||||||
|
# Alertmanager-Webhook -> Matrix (wartung-Raum), gleiche Machart wie
|
||||||
|
# maintenance-notify (gitops Issue #24). Secrets kommen aus .env.
|
||||||
|
matrix-alerts:
|
||||||
|
image: python:3.13-slim
|
||||||
|
container_name: matrix-alerts
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- MATRIX_HOMESERVER=${MATRIX_ALERT_HOMESERVER}
|
||||||
|
- MATRIX_ROOM_ID=${MATRIX_ALERT_ROOM_ID}
|
||||||
|
- MATRIX_TOKEN=${MATRIX_ALERT_TOKEN}
|
||||||
|
# Raum-Routing (gitops#47): Alerts mit Label room=security -> Security-Raum
|
||||||
|
- MATRIX_ROOM_SECURITY=${MATRIX_RELEASE_ROOM_ID:-}
|
||||||
|
# Ohne das liegt der State unter /tmp im Writable Layer: der ueberlebt
|
||||||
|
# zwar ein "compose restart", aber kein "up -d", das den Container neu
|
||||||
|
# baut -- also genau jeden Deploy. Dann verlieren offene Alarme ihre
|
||||||
|
# Event-Zuordnung und loesen sich ueber den Fallback als separate
|
||||||
|
# Nachricht auf, statt die Firing-Nachricht abzuhaken.
|
||||||
|
- MATRIX_STATE_FILE=/state/matrix-alerts-state.json
|
||||||
|
volumes:
|
||||||
|
- ./alertmanager/matrix-alerts.py:/app/matrix-alerts.py:ro
|
||||||
|
- matrix_alerts_data:/state
|
||||||
|
command: python3 /app/matrix-alerts.py
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
|
||||||
|
# Release-/Advisory-Watch (gitops#22): meldet neue Releases der Element-Stack-
|
||||||
|
# Upstreams in den Alerts-Raum (🚨 bei Security-Verdacht). Gleicher Bot/Raum
|
||||||
|
# wie matrix-alerts, eigener State (Erstlauf merkt nur, flutet nicht).
|
||||||
|
release-watch:
|
||||||
|
image: python:3.13-slim
|
||||||
|
container_name: release-watch
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- MATRIX_HOMESERVER=${MATRIX_ALERT_HOMESERVER}
|
||||||
|
- MATRIX_ROOM_ID=${MATRIX_ALERT_ROOM_ID}
|
||||||
|
# CVE-/Release-Raum (gitops#47): leer lassen = Fallback Alerts-Raum
|
||||||
|
- MATRIX_RELEASE_ROOM_ID=${MATRIX_RELEASE_ROOM_ID:-}
|
||||||
|
- MATRIX_TOKEN=${MATRIX_ALERT_TOKEN}
|
||||||
|
volumes:
|
||||||
|
- ./alertmanager/release-watch.py:/app/release-watch.py:ro
|
||||||
|
- release_watch_data:/state
|
||||||
|
command: python3 /app/release-watch.py
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
|
||||||
|
# CVE-Pipeline (gitops#47), Teil 1: Trivy scannt die real deployten Images
|
||||||
|
# (cve/images.txt - Inventur aus dem Cluster, bei Stack-Aenderungen nachziehen)
|
||||||
|
cve-scan:
|
||||||
|
image: aquasec/trivy:0.58.2
|
||||||
|
container_name: cve-scan
|
||||||
|
restart: unless-stopped
|
||||||
|
entrypoint: ["/bin/sh", "/config/scan-loop.sh"]
|
||||||
|
volumes:
|
||||||
|
- ./cve:/config:ro
|
||||||
|
- cve_results:/results
|
||||||
|
- cve_trivy_cache:/root/.cache
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
|
||||||
|
# Teil 2: Reports -> Prometheus-Metriken (Schema + Pflichtfelder siehe gitops#47)
|
||||||
|
cve-exporter:
|
||||||
|
image: python:3.13-slim
|
||||||
|
container_name: cve-exporter
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- RESULTS_DIR=/results
|
||||||
|
- STATE_FILE=/state/first-seen.json
|
||||||
|
volumes:
|
||||||
|
- ./cve/cve-exporter.py:/app/cve-exporter.py:ro
|
||||||
|
- cve_results:/results:ro
|
||||||
|
- cve_exporter_state:/state
|
||||||
|
command: python3 /app/cve-exporter.py
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
|
||||||
loki:
|
loki:
|
||||||
image: grafana/loki:3.7.1
|
image: grafana/loki:3.7.1
|
||||||
container_name: loki
|
container_name: loki
|
||||||
@@ -44,6 +141,10 @@ services:
|
|||||||
- /var/log:/var/log:ro
|
- /var/log:/var/log:ro
|
||||||
- /var/lib/docker/containers:/var/lib/docker/containers:ro
|
- /var/lib/docker/containers:/var/lib/docker/containers:ro
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
# Muss persistent sein, sonst ist die Positions-Datei nach jedem
|
||||||
|
# Recreate weg und Alloy liest alle Container-Logs von vorn. Loki
|
||||||
|
# weist die alten Eintraege dann ab (reject_old_samples, Default 7d).
|
||||||
|
- alloy_data:/var/lib/alloy
|
||||||
command: run --storage.path=/var/lib/alloy/data /etc/alloy/config.alloy
|
command: run --storage.path=/var/lib/alloy/data /etc/alloy/config.alloy
|
||||||
cap_add:
|
cap_add:
|
||||||
- DAC_READ_SEARCH
|
- DAC_READ_SEARCH
|
||||||
@@ -72,7 +173,7 @@ services:
|
|||||||
- "traefik.docker.network=traefik"
|
- "traefik.docker.network=traefik"
|
||||||
- "traefik.http.routers.grafana.rule=Host(`selendis.axion1337.de`)"
|
- "traefik.http.routers.grafana.rule=Host(`selendis.axion1337.de`)"
|
||||||
- "traefik.http.routers.grafana.entrypoints=websecure"
|
- "traefik.http.routers.grafana.entrypoints=websecure"
|
||||||
- "traefik.http.routers.grafana.tls.certresolver=le"
|
- "traefik.http.routers.grafana.tls.certresolver=letsencrypt"
|
||||||
- "traefik.http.services.grafana.loadbalancer.server.port=3000"
|
- "traefik.http.services.grafana.loadbalancer.server.port=3000"
|
||||||
depends_on:
|
depends_on:
|
||||||
- prometheus
|
- prometheus
|
||||||
@@ -101,8 +202,15 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
prometheus_data:
|
prometheus_data:
|
||||||
|
alertmanager_data:
|
||||||
|
matrix_alerts_data:
|
||||||
|
release_watch_data:
|
||||||
|
cve_results:
|
||||||
|
cve_trivy_cache:
|
||||||
|
cve_exporter_state:
|
||||||
grafana_data:
|
grafana_data:
|
||||||
loki_data:
|
loki_data:
|
||||||
|
alloy_data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
traefik:
|
traefik:
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"title": "CVE-Übersicht (Trivy)",
|
||||||
|
"uid": "cve-overview",
|
||||||
|
"tags": ["security", "cve", "gitops-47"],
|
||||||
|
"timezone": "browser",
|
||||||
|
"schemaVersion": 39,
|
||||||
|
"refresh": "5m",
|
||||||
|
"time": { "from": "now-30d", "to": "now" },
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"type": "stat", "title": "Offene CRITICAL", "id": 1,
|
||||||
|
"gridPos": { "x": 0, "y": 0, "w": 4, "h": 4 },
|
||||||
|
"fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "red", "value": 1 } ] } }, "overrides": [] },
|
||||||
|
"targets": [ { "expr": "sum(trivy_vuln_count{severity=\"CRITICAL\"}) or vector(0)", "instant": true, "refId": "A" } ]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "stat", "title": "Offene HIGH", "id": 2,
|
||||||
|
"gridPos": { "x": 4, "y": 0, "w": 4, "h": 4 },
|
||||||
|
"fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "orange", "value": 1 } ] } }, "overrides": [] },
|
||||||
|
"targets": [ { "expr": "sum(trivy_vuln_count{severity=\"HIGH\"}) or vector(0)", "instant": true, "refId": "A" } ]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "stat", "title": "MEDIUM/LOW (Summe)", "id": 3,
|
||||||
|
"gridPos": { "x": 8, "y": 0, "w": 4, "h": 4 },
|
||||||
|
"targets": [ { "expr": "sum(trivy_vuln_count{severity=~\"MEDIUM|LOW\"}) or vector(0)", "instant": true, "refId": "A" } ]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "stat", "title": "Ältester Scan (Stunden)", "id": 4,
|
||||||
|
"gridPos": { "x": 12, "y": 0, "w": 4, "h": 4 },
|
||||||
|
"fieldConfig": { "defaults": { "unit": "h", "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "red", "value": 48 } ] } }, "overrides": [] },
|
||||||
|
"targets": [ { "expr": "(time() - min(trivy_last_scan_timestamp)) / 3600", "instant": true, "refId": "A" } ]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "table", "title": "Offene CVEs (HIGH/CRITICAL) — CVE · Ort · Typ · Paket · Fix · seit", "id": 5,
|
||||||
|
"gridPos": { "x": 0, "y": 4, "w": 24, "h": 12 },
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "trivy_vuln_info", "instant": true, "format": "table", "refId": "A" },
|
||||||
|
{ "expr": "trivy_vuln_first_seen_timestamp * 1000", "instant": true, "format": "table", "refId": "B" }
|
||||||
|
],
|
||||||
|
"transformations": [
|
||||||
|
{ "id": "merge", "options": {} },
|
||||||
|
{ "id": "organize", "options": {
|
||||||
|
"excludeByName": { "Time": true, "__name__": true, "job": true, "instance": true, "Value #A": true },
|
||||||
|
"renameByName": { "cve": "CVE", "severity": "Severity", "host": "Ort (Host)", "target_type": "Typ", "target": "Target", "pkg": "Paket", "installed": "Installiert", "fixed_version": "Fix-Version", "Value #B": "Erstmals gesehen" }
|
||||||
|
} }
|
||||||
|
],
|
||||||
|
"fieldConfig": { "defaults": {}, "overrides": [
|
||||||
|
{ "matcher": { "id": "byName", "options": "Erstmals gesehen" }, "properties": [ { "id": "unit", "value": "dateTimeAsIso" } ] },
|
||||||
|
{ "matcher": { "id": "byName", "options": "CVE" }, "properties": [ { "id": "links", "value": [ { "title": "NVD (Mitigation/Details)", "targetBlank": true, "url": "https://nvd.nist.gov/vuln/detail/${__value.text}" } ] } ] }
|
||||||
|
] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "state-timeline", "title": "Zeitstrahl: offene HIGH/CRITICAL je Target", "id": 6,
|
||||||
|
"gridPos": { "x": 0, "y": 16, "w": 24, "h": 8 },
|
||||||
|
"targets": [ { "expr": "count by (target) (trivy_vuln_info)", "legendFormat": "{{target}}", "refId": "A" } ],
|
||||||
|
"fieldConfig": { "defaults": { "custom": { "fillOpacity": 70 } }, "overrides": [] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timeseries", "title": "Funde je Severity über Zeit", "id": 7,
|
||||||
|
"gridPos": { "x": 0, "y": 24, "w": 24, "h": 8 },
|
||||||
|
"targets": [ { "expr": "sum by (severity) (trivy_vuln_count)", "legendFormat": "{{severity}}", "refId": "A" } ]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -37,3 +37,10 @@ providers:
|
|||||||
allowUiUpdates: true
|
allowUiUpdates: true
|
||||||
options:
|
options:
|
||||||
path: /var/lib/grafana/dashboards/general
|
path: /var/lib/grafana/dashboards/general
|
||||||
|
|
||||||
|
- name: security
|
||||||
|
folder: 'Security'
|
||||||
|
type: file
|
||||||
|
allowUiUpdates: true
|
||||||
|
options:
|
||||||
|
path: /var/lib/grafana/dashboards/security
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
# Alert-Regeln (gitops#32). Bewusst wenige, dafuer die real erlebten Fehlerklassen:
|
||||||
|
# - TargetDown: "Exporter/Dienst tot und keiner merkt es" (coturn-Klasse)
|
||||||
|
# - KubePodRestartLoop: Crashloops im k3s-Cluster (36k-coturn-Restarts-Klasse)
|
||||||
|
# - HostOom/HostLowMemory/HostLowDisk/HostHighSwap: Ressourcendruck der Hosts
|
||||||
|
groups:
|
||||||
|
- name: axion-basics
|
||||||
|
rules:
|
||||||
|
- alert: TargetDown
|
||||||
|
expr: up == 0
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "Target {{ $labels.job }}/{{ $labels.instance }} ist seit 5m nicht erreichbar"
|
||||||
|
|
||||||
|
- alert: KubePodRestartLoop
|
||||||
|
expr: increase(kube_pod_container_status_restarts_total[1h]) > 3
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Pod {{ $labels.namespace }}/{{ $labels.pod }} restartet wiederholt ({{ $value | printf \"%.0f\" }}x in 1h)"
|
||||||
|
|
||||||
|
- alert: HostOomKill
|
||||||
|
expr: increase(node_vmstat_oom_kill[15m]) > 0
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "OOM-Kill auf {{ $labels.instance }}"
|
||||||
|
|
||||||
|
- alert: HostLowMemory
|
||||||
|
expr: node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes < 0.08
|
||||||
|
for: 15m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "{{ $labels.instance }}: <8% RAM verfuegbar seit 15m"
|
||||||
|
|
||||||
|
- alert: HostHighSwap
|
||||||
|
expr: (node_memory_SwapTotal_bytes - node_memory_SwapFree_bytes) / node_memory_SwapTotal_bytes > 0.5
|
||||||
|
for: 30m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "{{ $labels.instance }}: >50% Swap in Nutzung seit 30m"
|
||||||
|
|
||||||
|
- alert: HostLowDisk
|
||||||
|
expr: node_filesystem_avail_bytes{mountpoint="/",fstype!~"tmpfs|overlay"} / node_filesystem_size_bytes{mountpoint="/",fstype!~"tmpfs|overlay"} < 0.10
|
||||||
|
for: 15m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "{{ $labels.instance }}: <10% Platz auf / frei"
|
||||||
|
|
||||||
|
# CVE-Pipeline (gitops#47): Funde aus den Trivy-Scans. Pflichtfelder je Alarm:
|
||||||
|
# CVE-ID, Mitigation (Fix-Version + NVD-Link), Ort (host), Typ (target_type);
|
||||||
|
# der Zeitstrahl kommt aus trivy_vuln_first_seen_timestamp + resolved-Edit.
|
||||||
|
# Alle Regeln routen per room-Label in den Security-Raum.
|
||||||
|
- name: axion-cve
|
||||||
|
rules:
|
||||||
|
# Aggregiert pro Image statt pro CVE (gitops#51, Entscheidung sorb
|
||||||
|
# 2026-08-01): ~1000 Einzelalarme waeren Rauschen; die menschlich
|
||||||
|
# handhabbare Einheit ist "Image X hat N kritische CVEs". Die
|
||||||
|
# CVE-Details (Pflichtfelder) liegen im Grafana-Dashboard cve-overview,
|
||||||
|
# die trivy_vuln_info-Einzelserien bleiben dafuer erhalten.
|
||||||
|
- alert: TrivyCriticalVulns
|
||||||
|
expr: count by (target, target_type, host) (trivy_vuln_info{severity="CRITICAL"}) > 0
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
room: security
|
||||||
|
annotations:
|
||||||
|
summary: "{{ $labels.target }} [{{ $labels.target_type }}@{{ $labels.host }}]: {{ $value }} CRITICAL-CVEs — Details: https://selendis.axion1337.de/d/cve-overview"
|
||||||
|
- alert: TrivyHighVulns
|
||||||
|
expr: count by (target, target_type, host) (trivy_vuln_info{severity="HIGH"}) > 0
|
||||||
|
for: 24h
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
room: security
|
||||||
|
annotations:
|
||||||
|
summary: "{{ $labels.target }} [{{ $labels.target_type }}@{{ $labels.host }}]: {{ $value }} HIGH-CVEs (seit 24h offen) — Details: https://selendis.axion1337.de/d/cve-overview"
|
||||||
|
- alert: TrivyScanStale
|
||||||
|
expr: time() - trivy_last_scan_timestamp > 172800
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
room: security
|
||||||
|
annotations:
|
||||||
|
summary: "CVE-Scan fuer {{ $labels.target }} ist aelter als 2 Tage — Scanner pruefen (ein still gestorbener Scanner macht blind)"
|
||||||
|
# TrivyScanStale hat eine Blindstelle, die es prinzipiell nicht schliessen
|
||||||
|
# kann: ein Target OHNE je erfolgreichen Bericht hat gar keine Serie, an der
|
||||||
|
# 'time() - ...' haengen koennte. Es bleibt also still. Die beiden Regeln
|
||||||
|
# darunter fangen die zwei Faelle ab, die dahinterstecken.
|
||||||
|
- alert: TrivyReportUnreadable
|
||||||
|
expr: trivy_report_read_errors > 0
|
||||||
|
for: 30m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
room: security
|
||||||
|
annotations:
|
||||||
|
summary: "{{ $value }} CVE-Bericht(e) sind seit 30 min unlesbar — die betroffenen Targets werden NICHT ausgewertet und faellt sonst niemandem auf"
|
||||||
|
- alert: TrivyNoReports
|
||||||
|
expr: trivy_reports_total == 0
|
||||||
|
for: 1h
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
room: security
|
||||||
|
annotations:
|
||||||
|
summary: "Der CVE-Exporter findet ueberhaupt keine Berichte mehr — der Scanner liefert nichts, die Schwachstellen-Sicht ist blind"
|
||||||
|
|
||||||
|
# Sicherungen (management #0030). Bis 2026-08-14 gab es hierzu KEINE Regel: ein
|
||||||
|
# fehlgeschlagenes naechtliches Backup waere unbemerkt geblieben - genau der stille
|
||||||
|
# Ausfall, den das Issue beschreibt. Metriken kommen aus kube-state-metrics im
|
||||||
|
# Matrix-Cluster (Alloy -> remote_write, kube_job_*/kube_cronjob_* passieren den
|
||||||
|
# Filter, der nur go_.*|process_.* verwirft).
|
||||||
|
- name: axion-backup
|
||||||
|
rules:
|
||||||
|
# Ein Backup- oder Probe-Job ist fehlgeschlagen.
|
||||||
|
- alert: BackupJobFailed
|
||||||
|
expr: max by (namespace, job_name) (kube_job_status_failed{job_name=~"(synapse|authentik|wikijs)-backup.*|restore-drill.*"}) > 0
|
||||||
|
for: 15m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "Backup-Job {{ $labels.namespace }}/{{ $labels.job_name }} ist fehlgeschlagen — ohne Sicherung ist der naechste Ausfall ein Datenverlust"
|
||||||
|
|
||||||
|
# Der CronJob plant gar nicht mehr (ausgesetzt, geloescht, Cluster-Problem).
|
||||||
|
# 26h Toleranz fuer die taeglichen Laeufe um 03:00/03:15/03:30.
|
||||||
|
# Fallback auf die Anlagezeit: ein frisch (neu) angelegter CronJob hat noch
|
||||||
|
# keine last_schedule_time-Serie - ein Ausdruck ueber eine fehlende Serie
|
||||||
|
# liefert nichts, der Alarm koennte also nie feuern. max by(...) fasst beide
|
||||||
|
# Metriken zu EINER Serie je CronJob zusammen; ohne das wuerde die nie
|
||||||
|
# aktualisierte created-Serie nach Ablauf der Frist dauerhaft falsch feuern
|
||||||
|
# ('or' matcht inkl. __name__, ergibt also eine Vereinigung beider Serien).
|
||||||
|
- alert: BackupNotRunning
|
||||||
|
expr: time() - max by (namespace, cronjob) (kube_cronjob_status_last_schedule_time{cronjob=~"(synapse|authentik|wikijs)-backup"} or kube_cronjob_created{cronjob=~"(synapse|authentik|wikijs)-backup"}) > 93600
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "Seit ueber 26h kein Lauf von {{ $labels.cronjob }} — CronJob ausgesetzt oder verschwunden?"
|
||||||
|
|
||||||
|
# Die monatliche Restore-Probe laeuft nicht mehr. Eine Sicherung, die nie
|
||||||
|
# zurueckgespielt wurde, ist eine Vermutung — deshalb ist auch das Ausbleiben
|
||||||
|
# der PRUEFUNG ein Alarm, nicht nur ihr Fehlschlag. 40 Tage = Monatsrhythmus + Puffer.
|
||||||
|
# Bis zum ersten REGULAEREN Lauf gibt es keine last_schedule_time-Serie (ein
|
||||||
|
# manuell ausgeloester Job setzt sie nicht) - ohne Fallback waere dieser
|
||||||
|
# Alarm bis dahin blind. Gleiche max-by-Konstruktion wie oben.
|
||||||
|
- alert: RestoreDrillStale
|
||||||
|
expr: time() - max by (namespace, cronjob) (kube_cronjob_status_last_schedule_time{cronjob="restore-drill"} or kube_cronjob_created{cronjob="restore-drill"}) > 3456000
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Restore-Probe seit ueber 40 Tagen nicht gelaufen — Wiederherstellbarkeit ist wieder unbewiesen (Verfahren: notfallhandbuch)"
|
||||||
|
|
||||||
|
# Ein Backup-CronJob existiert gar nicht mehr (geloescht, Kustomization
|
||||||
|
# entfernt, Namespace weg). Bei den Alarmen oben verschwindet dann die
|
||||||
|
# Serie - und eine verschwundene Serie ist in Prometheus kein Alarm,
|
||||||
|
# sondern Stille. Genau das war der Fehler, den dieses Regelwerk
|
||||||
|
# verhindern soll, also ist das Fehlen selbst der Alarm.
|
||||||
|
# for: 30m puffert kurze Luecken der Metrik-Pipeline ab.
|
||||||
|
- alert: BackupCronJobMissing
|
||||||
|
expr: >-
|
||||||
|
absent(kube_cronjob_info{namespace="matrix", cronjob="synapse-backup"})
|
||||||
|
or absent(kube_cronjob_info{namespace="matrix", cronjob="wikijs-backup"})
|
||||||
|
or absent(kube_cronjob_info{namespace="authentik", cronjob="authentik-backup"})
|
||||||
|
or absent(kube_cronjob_info{namespace="matrix", cronjob="restore-drill"})
|
||||||
|
for: 30m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "Ein Backup-/Probe-CronJob fehlt im Cluster — Sicherung oder Wiederherstellungs-Nachweis laeuft nicht mehr"
|
||||||
|
|
||||||
|
# Die Alarmkette meldet ihr eigenes Reissen. Ironie inklusive: schlaegt die
|
||||||
|
# Zustellung komplett fehl, kommt auch dieser Alarm nicht an - er ist dann
|
||||||
|
# aber in Prometheus/Grafana sichtbar, statt dass gar nichts existiert.
|
||||||
|
# Teilausfaelle (ein Receiver von mehreren, Rate-Limit 429) meldet er sauber.
|
||||||
|
- alert: AlertDeliveryFailing
|
||||||
|
expr: increase(alertmanager_notifications_failed_total[15m]) > 0
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "Alertmanager konnte Benachrichtigungen nicht zustellen ({{ $labels.integration }}) — Alarme laufen ins Leere"
|
||||||
|
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
global:
|
global:
|
||||||
scrape_interval: 15s
|
scrape_interval: 15s
|
||||||
|
|
||||||
|
rule_files:
|
||||||
|
- /etc/prometheus/alerts.yml
|
||||||
|
|
||||||
|
alerting:
|
||||||
|
alertmanagers:
|
||||||
|
- static_configs:
|
||||||
|
- targets: ["alertmanager:9093"]
|
||||||
|
|
||||||
# Zusaetzlich zu diesen Scrape-Targets kommen Metriken per Remote-Write rein
|
# Zusaetzlich zu diesen Scrape-Targets kommen Metriken per Remote-Write rein
|
||||||
# (k3s-Cluster: flux, kube_state_metrics; Matrix-Server: synapse).
|
# (k3s-Cluster: flux, kube_state_metrics; Matrix-Server: synapse).
|
||||||
scrape_configs:
|
scrape_configs:
|
||||||
@@ -18,6 +26,14 @@ scrape_configs:
|
|||||||
static_configs:
|
static_configs:
|
||||||
- targets: ["cadvisor:8080"]
|
- targets: ["cadvisor:8080"]
|
||||||
|
|
||||||
|
# Alertmanager-Eigenmetriken. Ohne diesen Job sind ZUSTELLfehler unsichtbar:
|
||||||
|
# Prometheus kennt alertmanager zwar als Alarm-Ziel (oben unter alerting:),
|
||||||
|
# scrapt ihn aber nicht - eine still reissende Alarmkette waere also genau das,
|
||||||
|
# was niemand meldet. Liefert u.a. alertmanager_notifications_failed_total.
|
||||||
|
- job_name: "operating_alertmanager"
|
||||||
|
static_configs:
|
||||||
|
- targets: ["alertmanager:9093"]
|
||||||
|
|
||||||
- job_name: "operating_traefik"
|
- job_name: "operating_traefik"
|
||||||
metrics_path: "/metrics"
|
metrics_path: "/metrics"
|
||||||
static_configs:
|
static_configs:
|
||||||
@@ -39,3 +55,8 @@ scrape_configs:
|
|||||||
- job_name: "gameserver_cadvisor"
|
- job_name: "gameserver_cadvisor"
|
||||||
static_configs:
|
static_configs:
|
||||||
- targets: ["157.90.155.206:8080"]
|
- targets: ["157.90.155.206:8080"]
|
||||||
|
|
||||||
|
# CVE-Exporter (gitops#47)
|
||||||
|
- job_name: "cve_exporter"
|
||||||
|
static_configs:
|
||||||
|
- targets: ["cve-exporter:9101"]
|
||||||
|
|||||||
Reference in New Issue
Block a user