Die meisten Monitoring-Setups, die ich bei Kunden sehe, beantworten eine Frage
gut: Läuft der Server noch? CPU, Arbeitsspeicher, Plattenplatz, vielleicht ein
Alarm, wenn die Website nicht antwortet. Das ist richtig und wichtig. Was sie
nicht beantworten: Wer hat sich gestern um 23:40 Uhr angemeldet, und warum
hat sich danach /etc/sudoers geändert?
Diese Frage stellt sich meistens erst, wenn schon etwas passiert ist. Dann
sucht jemand in /var/log/auth.log, sofern die Datei noch da ist. Dabei
reichen dieselben Werkzeuge, die ohnehin für das technische Monitoring da
sind, auch für die Sicherheit: Grafana Alloy sammelt ein, Prometheus (oder Mimir) zählt und alarmiert, Loki hebt die Logzeilen auf, Grafana zeigt alles an einer Stelle.
So überwache ich meine eigenen Server, unter anderem den Docker-Host, auf dem interne Dienste laufen, und die Gateways vor meinem Kubernetes-Cluster. In diesem Beitrag baust du das Schritt für Schritt nach.
Was du am Ende hast
- Einen Alarm bei jeder SSH-Anmeldung, mit Benutzer, Quell-IP, Anmeldemethode und dem Fingerprint des verwendeten Schlüssels.
- Einen Alarm bei Änderungen an heiklen Dateien: SSH-Konfiguration,
authorized_keys, Benutzer und Gruppen,sudoers, systemd-Units, Netzwerkkonfiguration. - Eine Liste aller Befehle, die in Login-Sitzungen ausgeführt wurden.
- Zahlen zu Fehlversuchen und fail2ban-Sperren.
- Einen Alarm, wenn der Server gar keine Daten mehr liefert. Denn wer einbricht und klug ist, schaltet als Erstes die Protokollierung ab.
Die Bausteine
| Baustein | Aufgabe |
|---|---|
| auditd | Protokolliert im Kernel, wer welche Datei geändert und welchen Befehl gestartet hat |
| journald | Enthält die Meldungen von sshd, sudo und fail2ban |
| Alloy | Liest beides, macht daraus Zähler (Metriken) und schickt die Zeilen an Loki |
| Prometheus | Speichert die Zähler und löst Alarme aus |
| Loki | Speichert die Logzeilen, durchsuchbar für die Details |
| Grafana | Dashboard: wer, wann, von wo, was |
Der Trick ist die Arbeitsteilung: Metriken für Alarme, Logs für Details. Ein Alarm „SSH-Login auf web-01: deploy von 203.0.113.7“ kommt sofort. Welche Befehle in dieser Sitzung liefen, siehst du dann mit einem Klick in Loki.
1. auditd: der Kernel schreibt mit
auditd ist bei Debian und Ubuntu ein Paket entfernt:
apt install auditd Die Regeln kommen in eine eigene Datei, /etc/audit/rules.d/50-security.rules:
# Jeder Befehl aus einer Login-Sitzung. Dienste haben keine auid und tauchen hier nicht auf.
-a always,exit -F arch=b64 -S execve,execveat -F auid!=unset -k session_cmd
-a always,exit -F arch=b32 -S execve,execveat -F auid!=unset -k session_cmd
# Kernel-Module laden und entladen
-a always,exit -F arch=b64 -S init_module,finit_module,delete_module -k kernel_modules
# Schreibzugriffe (w) und Rechteänderungen (a) an heiklen Dateien
-w /etc/ssh/ -p wa -k ssh_config
-w /root/.ssh/ -p wa -k ssh_keys
-w /etc/passwd -p wa -k identity
-w /etc/shadow -p wa -k identity
-w /etc/group -p wa -k identity
-w /etc/sudoers -p wa -k identity
-w /etc/sudoers.d/ -p wa -k identity
-w /etc/systemd/system/ -p wa -k systemd_units
-w /etc/netplan/ -p wa -k network_config
-w /etc/sysctl.d/ -p wa -k sysctl
-w /usr/local/bin/ -p wa -k local_bin
# Wer die Überwachung selbst anfasst
-w /etc/audit/ -p wa -k audit_tamper
-w /etc/alloy/ -p wa -k audit_tamper Zwei Dinge sind hier wichtig:
-kist der Schlüssel. Er taucht später in jeder Logzeile auf und wird zum Label in Grafana. Wähle wenige, sprechende Namen, nicht einen pro Datei.auidist die Login-ID. Sie wird beim Anmelden gesetzt und bleibt erhalten, auch nachsudo -i. Darum weißt du hinterher, dass esannawar, die als rootsystemctl editaufgerufen hat, und nicht einfach „root“.
Auf einem Docker-Host gehören noch /etc/docker/ und das Verzeichnis mit
deinen Compose-Dateien dazu. Danach laden und prüfen:
augenrules --load
auditctl -l Damit Alloy das Audit-Log ohne root-Rechte lesen kann, in /etc/audit/auditd.conf die Gruppe setzen und auditd neu starten:
log_group = adm 2. Alloy installieren
Alloy kommt aus der Paketquelle von Grafana:
mkdir -p /etc/apt/keyrings
wget -qO- https://apt.grafana.com/gpg.key | gpg --dearmor > /etc/apt/keyrings/grafana.gpg
echo "deb [signed-by=/etc/apt/keyrings/grafana.gpg] https://apt.grafana.com stable main" \
> /etc/apt/sources.list.d/grafana.list
apt update && apt install alloy
# Alloy darf Journal und Audit-Log lesen
usermod -aG adm,systemd-journal alloy 3. Die Alloy-Konfiguration
Jetzt der Kern. Die Konfiguration steht in /etc/alloy/config.alloy und hat
drei Teile: Journal lesen, Audit-Log lesen, Ergebnis wegschicken.
Journal: SSH-Anmeldungen und Fehlversuche
loki.relabel "journal" {
forward_to = []
rule {
source_labels = ["__journal__systemd_unit"]
target_label = "unit"
}
}
loki.source.journal "journal" {
forward_to = [loki.process.journal.receiver]
relabel_rules = loki.relabel.journal.rules
max_age = "12h"
labels = { job = "journal" }
}
loki.process "journal" {
forward_to = [loki.write.default.receiver]
// Erfolgreiche Anmeldung: Methode, Benutzer, Quelle und Fingerprint des Schlüssels
stage.match {
selector = "{unit=\"ssh.service\"} |~ \"Accepted \""
stage.regex {
expression = "Accepted (?P<method>\\S+) for (?P<user>\\S+) from (?P<source_ip>\\S+) port \\d+ [^:\\s]+(?:: \\S+ (?P<key_fp>\\S+))?"
}
stage.labels {
values = { method = "", user = "", source_ip = "", key_fp = "" }
}
stage.metrics {
metric.counter {
name = "ssh_logins_total"
description = "Erfolgreiche SSH-Anmeldungen"
match_all = true
action = "inc"
}
}
}
// Fehlversuche nur zählen, ohne IP als Label
stage.match {
selector = "{unit=\"ssh.service\"} |~ \"Invalid user|Failed (password|publickey)\""
stage.metrics {
metric.counter {
name = "ssh_failed_logins_total"
description = "Fehlgeschlagene SSH-Anmeldeversuche"
match_all = true
action = "inc"
}
}
}
stage.static_labels {
values = { host = "web-01" }
}
} Die Zeile, auf die der Regex passt, sieht so aus:
Accepted publickey for anna from 203.0.113.7 port 51522 ssh2: ED25519 SHA256:3kQ... Aus stage.metrics macht Alloy einen Zähler namens loki_process_custom_ssh_logins_total, mit den Labels user, source_ip, method und key_fp. Bei den erfolgreichen Anmeldungen ist die IP als Label
in Ordnung, das sind wenige. Bei den Fehlversuchen nicht: Ein offener SSH-Port
sieht am Tag Tausende IPs, und jede würde eine eigene Zeitreihe anlegen.
Audit-Log: heikle Dateien und Befehle
local.file_match "audit" {
path_targets = [{ "__path__" = "/var/log/audit/audit.log", "job" = "audit" }]
}
loki.source.file "audit" {
targets = local.file_match.audit.targets
forward_to = [loki.process.audit.receiver]
}
loki.process "audit" {
forward_to = [loki.write.default.receiver]
// Satztyp (SYSCALL, EXECVE, ...) und den auditd-Schlüssel als Labels
stage.regex {
expression = "^type=(?P<type>\\S+) "
}
stage.regex {
expression = "key=\"(?P<key>[^\"]+)\""
}
stage.labels {
values = { type = "", key = "" }
}
stage.match {
selector = "{type=\"SYSCALL\", key=~\"ssh_config|ssh_keys|identity|systemd_units|network_config|sysctl|local_bin|kernel_modules|audit_tamper\"}"
stage.metrics {
metric.counter {
name = "audit_sensitive_events_total"
description = "Änderungen an überwachten Dateien und Kernel-Modulen"
match_all = true
action = "inc"
}
}
}
stage.static_labels {
values = { host = "web-01" }
}
} Hier landet nur key als Label am Zähler, also der Bereich (z. B. identity), nicht der einzelne Dateipfad. Den Pfad, den Benutzer und das
Programm liest du im Dashboard aus der Logzeile.
Wohin damit?
Zum Schluss braucht Alloy ein Ziel für Logs und Metriken. Die selbst gezählten
Metriken holt prometheus.exporter.self ab, ein Filter lässt nur die eigenen
Zähler durch:
prometheus.exporter.self "alloy" { }
prometheus.scrape "alloy" {
targets = prometheus.exporter.self.alloy.targets
scrape_interval = "30s"
forward_to = [prometheus.relabel.security.receiver]
}
prometheus.relabel "security" {
forward_to = [prometheus.remote_write.default.receiver]
rule {
source_labels = ["__name__"]
regex = "loki_process_custom_.*|alloy_build_info"
action = "keep"
}
}
prometheus.remote_write "default" {
external_labels = { host = "web-01" }
endpoint {
url = "https://prometheus.example.com/api/v1/write"
}
}
loki.write "default" {
endpoint {
url = "https://loki.example.com/loki/api/v1/push"
}
} Bei einem eigenen Prometheus muss dort der Remote-Write-Empfang eingeschaltet
sein (--web.enable-remote-write-receiver). Alternativ fragt Prometheus Alloy
direkt auf Port 12345 ab, dann entfällt prometheus.remote_write.
Danach systemctl restart alloy. Unter http://localhost:12345 zeigt Alloy
eine Übersicht aller Komponenten. Steht dort alles auf grün, melde dich einmal
neu per SSH an und prüfe:
curl -s localhost:12345/metrics | grep loki_process_custom_ssh_logins_total 4. Alarme, die auch beim ersten Mal auslösen
Jetzt die Regeln für Prometheus. Hier steckt eine Falle, über die ich selbst
gestolpert bin: Der Zähler ssh_logins_total existiert erst nach der ersten
Anmeldung. Ein naives increase(...[5m]) > 0 sieht beim ersten Mal nur einen
einzigen Wert und rechnet daraus keinen Anstieg. Die erste Anmeldung von einer
neuen IP, also genau die interessante, würde keinen Alarm auslösen.
Die Lösung ist ein zweiter Teil, der neue Zeitreihen erkennt:
groups:
- name: server-security
rules:
- alert: SshLogin
expr: |
max by (host, user, source_ip, method, key_fp) (
increase(loki_process_custom_ssh_logins_total[5m]) > 0
or
(loki_process_custom_ssh_logins_total
unless loki_process_custom_ssh_logins_total offset 5m)
)
labels:
severity: warning
annotations:
summary: 'SSH-Login auf {{ $labels.host }}: {{ $labels.user }} von {{ $labels.source_ip }}'
description: 'Anmeldung per {{ $labels.method }}, Schlüssel {{ $labels.key_fp }}.'
- alert: SensitiveFileChange
expr: |
max by (host, key) (
increase(loki_process_custom_audit_sensitive_events_total[5m]) > 0
or
(loki_process_custom_audit_sensitive_events_total
unless loki_process_custom_audit_sensitive_events_total offset 5m)
)
labels:
severity: warning
annotations:
summary: 'Änderung an {{ $labels.key }} auf {{ $labels.host }}'
- alert: SshBruteForce
expr: sum by (host) (increase(loki_process_custom_ssh_failed_logins_total[15m])) > 50
labels:
severity: warning
# Ohne Daten gibt es keine anderen Alarme. Server aus, Netz weg oder Alloy gestoppt.
- alert: SecurityLoggingDown
expr: absent_over_time(alloy_build_info{host="web-01"}[5m])
labels:
severity: critical unless ... offset 5m heißt: „Zeitreihen, die es jetzt gibt, aber vor fünf
Minuten noch nicht gab.“ Das ist genau die erste Anmeldung.
Der letzte Alarm ist der wichtigste. Alle anderen hängen davon ab, dass Alloy läuft. Wer auf dem Server root ist, kann Alloy stoppen. Dann soll das nicht still passieren, sondern selbst einen Alarm auslösen.
5. Das Dashboard: wer, wann, von wo, was
Für die Details fragt Grafana Loki direkt ab. Drei Panels reichen für den Anfang.
Wer hat sich angemeldet? Als Tabelle:
sum by (host, user, source_ip, key_fp) (
count_over_time({job="journal", unit="ssh.service"} |= "Accepted " [$__range])
) Welche heiklen Dateien wurden geändert, und mit welchem Programm? Als
Log-Panel, mit line_format lesbar gemacht:
{job="audit", type="SYSCALL", key=~"ssh_config|ssh_keys|identity|systemd_units|audit_tamper"}
| regexp `auid=(?P<auid>\d+) .*ses=(?P<ses>\d+) comm="(?P<comm>[^"]*)" exe="(?P<exe>[^"]*)"`
| line_format `{{.host}} {{.key}} {{.exe}} (auid={{.auid}} ses={{.ses}})` Heraus kommt so etwas wie web-01 identity /usr/bin/visudo (auid=1001 ses=42). Die auid ist die Benutzer-ID aus /etc/passwd, ses die
Sitzungsnummer. Damit findest du alle anderen Befehle derselben Sitzung.
Welche Befehle liefen?
{job="audit", type="EXECVE"} | regexp `argc=\d+ (?P<args>.*)` | line_format `{{.host}} {{.args}}` Dazu eine Zeitreihe mit Fehlversuchen und fail2ban-Sperren, und du hast auf einen Blick, ob es ruhig ist.
Was du in der Praxis wissen solltest
Deine eigenen Anmeldungen lösen auch Alarme aus. Das ist gewollt. Wenn
Ansible oder ein Deployment-Skript sich anmeldet und Dateien ändert, kommt eine
Meldung. Am Anfang nervt das, nach einer Woche liest man diese Meldungen wie
ein Protokoll: „Ah, das war mein Update.“ Kommt eine Meldung, die du nicht
zuordnen kannst, ist das der Moment, in dem sich das Ganze lohnt. Wenn es zu
viel wird, filtere bekannte Schlüssel deiner Automatisierung per key_fp aus
dem Alarm heraus, nicht aus der Protokollierung.
Logs gehören vom Server weg. Wer root auf dem Server hat, kann /var/log/audit/audit.log löschen. Was schon bei Loki auf einem anderen System
liegt, nicht. Darum schickt Alloy die Zeilen sofort weg, statt sie nur lokal zu
sammeln. Das ist der eigentliche Sicherheitsgewinn gegenüber „wir schauen dann
in die Logs“.
Befehle protokollieren betrifft Menschen. Wenn auf dem Server Mitarbeiter arbeiten, sag ihnen, dass ihre Befehle mitgeschrieben werden, und halte fest, wofür und wie lange. Gibt es einen Betriebsrat, gehört er bei so etwas dazu. Das ist keine Rechtsberatung, aber eine Erfahrung: Offen kommuniziert ist es nie ein Problem.
Nicht zu viele Labels. Alles, was in stage.labels landet, wird in Loki
zu einem eigenen Stream. Benutzer und IP bei erfolgreichen Logins sind in
Ordnung, Dateipfade oder Befehle als Label nicht. Die bleiben in der Logzeile.
Ohne eigenen Monitoring-Stack
Alloy, auditd und die Regeln sind schnell aufgesetzt. Aufwendiger ist das, was dahinter steht: Prometheus, Loki und Grafana selbst betreiben, mit Updates, Speicherplatz, Backups, und so abgesichert, dass die Sicherheitslogs nicht ausgerechnet dort offen herumliegen.
Genau dafür habe ich MaaS gebaut, meine Plattform für Managed Observability. Du bekommst Grafana, Prometheus-kompatible Metriken (Mimir) und Loki als fertigen, abgeschotteten Mandanten, gehostet in der EU. Im Dashboard legst du einen Service-Account an, und in der Alloy-Konfiguration oben ändern sich nur die beiden Ziele:
prometheus.remote_write "default" {
external_labels = { host = "web-01" }
endpoint {
url = "<Metrik-Adresse aus dem MaaS-Dashboard>"
oauth2 {
client_id = "<client-id>"
client_secret_file = "/etc/alloy/client-secret"
token_url = "<Token-Adresse aus dem MaaS-Dashboard>"
}
}
}
loki.write "default" {
endpoint {
url = "<Log-Adresse aus dem MaaS-Dashboard>"
oauth2 {
client_id = "<client-id>"
client_secret_file = "/etc/alloy/client-secret"
token_url = "<Token-Adresse aus dem MaaS-Dashboard>"
}
}
} Alloy holt das Token selbst und erneuert es. Das Client-Secret liegt in einer
Datei mit chmod 640 und Gruppe alloy, nicht in der Konfiguration.
Und wenn du das alles nicht selbst einrichten willst: Ich setze die Überwachung für deine Server auf, passe die Liste der heiklen Dateien an das an, was bei dir tatsächlich läuft, und richte die Alarme so ein, dass sie dich erreichen, ohne zu nerven. Mehr dazu unter IT-Service für Geschäftskunden, oder schreib mir einfach.