
🌐 Auch auf: English · Français · Español
Teil 5 der Ansible-Tour. Teil 1 hat das Gerüst erklärt, Teil 2 den Server und die Plattform, die Teile 3 und 4 den Blog und Nextcloud. Dieser Teil sammelt jede Sicherheitsrolle an einer Stelle, über beide Stacks hinweg. Er beschreibt das Release v3.3.0.
Defense in Depth hat die Idee beschrieben: neun Schichten zwischen dem Internet und den Daten, jede fängt, was die vorige verpasst hat. Jener Beitrag hat gesagt, was die Schichten sind. Dieser zeigt, wie sie gebaut sind – Rolle für Rolle, mit den Handlern, den deklarierten Eingaben, den Templates und den Fehlern, die sie geprägt haben. Neun Rollen, von außen nach innen:
| Schicht | Rolle | Schützt vor |
|---|---|---|
| Netzwerk | common_firewall | Allem, was nicht ausdrücklich erlaubt ist, Portscans, einer Hintertür durch Kubernetes |
| Anmeldung | common_ssh | Passwortraten, schwacher Kryptografie, Admin-Sitzungen, die zu viel dürfen |
| Reaktion | common_fail2ban | Wiederholten Versuchen – Login-Raten, Scanner, bekannte Angriffspfade |
| Betriebssystem | common_cis_hardening | Ungenutzten Funktionen, schwachen Voreinstellungen, Lecks von Systeminformationen |
| Eindämmung | common_selinux | Einem kompromittierten Prozess, der an Dateien kommt, die ihn nichts angehen |
| Erkennung | common_auditd, common_aide_refresh, common_rkhunter, common_clamav | Änderungen, die nie passieren dürften, Rootkits, Schadsoftware in Uploads – bemerkt, gemeldet |
Zwei weitere Schichten wurden anderswo behandelt und werden am Ende nur zusammengefasst: die Netzwerkhärtung des Kernels (common_sysctl, Teil 2) und die Härtung der Pods selbst (Teile 3 und 4). Und eine hat einen eigenen Beitrag: der SSH-Login-Alarm.
Ein Hinweis zum Detailgrad: Der Code ist öffentlich und die Serie erklärt ihn, also kann ihn auch ein Angreifer lesen. Das ist in Ordnung – der Schutz muss aus Schlüsseln, Schichten und Alarmen kommen, nicht aus Geheimhaltung. Genaue Schwellenwerte halte ich hier aber vage. Sie stehen im Repository für alle, die sie brauchen.
🧱 common_firewall: eine Tabelle, eine Erlaubnisliste und ein paar Fallen
Die Firewall ist nftables, der Nachfolger von iptables im Linux-Kernel. Wer iptables kennt: Die Unterschiede, die hier zählen, sind ein Werkzeug (nft) für IPv4 und IPv6 statt zweier Regelwerke; Tabellen und Ketten, die man selbst anlegt und an eine Stelle im Paketweg des Kernels hängt (einen Hook, mit einer Priorität); eingebaute Sets von Adressen mit automatischem Ablauf statt des separaten Werkzeugs ipset; und – am wichtigsten für Automatisierung – eine Regeldatei wird als eine atomare Transaktion geladen: Die alten Regeln bleiben aktiv, bis die neuen vollständig stehen. Die Migration von iptables ist ein älterer Beitrag.
Die Rolle, Schritt für Schritt
- nftables installieren.
- DHCP oder statisch? Die Rolle fragt NetworkManager, wie die Uplink-Schnittstelle ihre Adressen bekommt, und speichert die Antwort in zwei Facts (Teil 1 hat das als Beispiel für „erkennen statt deklarieren“ genommen). Nur wo DHCP genutzt wird, rendert das Template die DHCP-Regeln – ohne sie scheitert die Verlängerung der Lease, und die Adresse geht verloren.
- Der SSH-Länderfilter, wenn beim Host
fw_ssh_geo_countriesgesetzt ist (alle drei Server erlauben SSH nur aus einem Land – die Geschichte steht hier). Ein Skript lädt die Adressbereiche dieses Landes von der regionalen Registry herunter und schreibt sie in eine nftables-Datei; ein systemd-Timer erneuert sie wöchentlich. Dieser Block läuft absichtlich vor dem Schreiben des Regelwerks: Das Regelwerk bindet die Datei ein, und scheitert schon der allererste Download, hält das Play an, bevor es überhaupt eine Regel gibt, die SSH aussperren könnte. Der ganze Abschnitt ist einblockmit einemwhen, und ein spiegelbildlicher Block entfernt alles wieder, wenn der Filter abgeschaltet wird. - Das Regelwerk aus
fw.nft.j2nach/etc/sysconfig/nftables.conf– die Datei, die der Dienst von AlmaLinux tatsächlich liest, nicht die/etc/nftables.conf, die viele Anleitungen nennen. Mitvalidate: nft -c -f %s: Ansible schreibt die gerenderte Datei an einen temporären Ort, führt die Syntaxprüfung darauf aus und ersetzt die echte Datei nur, wenn die Prüfung besteht. Ein Tippfehler erreicht nie die Konfiguration des Servers. - Ein systemd-Drop-in, der Reload und Stop ändert. Mehr dazu unten – es ist die wichtigste Zeile der Rolle.
- nftables einschalten, starten – außer beim allerersten Lauf.
- Aufräumen: ein altes iptables-Skript aus der Zeit vor dieser Rolle (der Blog lief eine Weile mit zwei Firewalls übereinander, und die alte verwarf DHCP-Verlängerungen), die ungenutzte
/etc/nftables.confund firewalld, das maskiert statt entfernt wird – das Paket zu entfernen würde auch nftables und fail2ban entfernen, weil sie in der Paketierung voneinander abhängen.
Niemals „flush ruleset“
Der mitgelieferte nftables-Dienst lädt mit flush ruleset neu – jede Regel im Kernel löschen – und lädt dann die Datei. Auf einem Kubernetes-Node ist das eine Falle. kube-proxy, Flannel und der in K3s eingebaute Network-Policy-Controller schreiben ebenfalls eigene Regeln in nftables, in eigene Tabellen. Ein Flush löscht auch diese, und für 30 bis 90 Sekunden, bis sie es bemerken und neu schreiben, funktionieren Kubernetes-Services nicht – Nextcloud erreichte nach jedem Firewall-Reload seine Datenbank nicht. Also ersetzt das Regelwerk nur seine eigene Tabelle, und der Drop-in bringt systemd bei, dasselbe zu tun:
# fw.nft.j2, first lines
table inet filter
delete table inet filter
table inet filter {
...
}
# /etc/systemd/system/nftables.service.d/10-own-table-only.conf
[Service]
ExecReload=
ExecReload=/sbin/nft -f /etc/sysconfig/nftables.conf
ExecStop=
ExecStop=/sbin/nft delete table inet filterDie erste Zeile wirkt seltsam: Warum eine Tabelle deklarieren, nur um sie zu löschen? Weil delete scheitert, wenn die Tabelle nicht existiert – bei einem frischen Boot. Sie zuerst zu deklarieren macht das Löschen immer sicher, und weil die ganze Datei eine Transaktion ist, gibt es keinen Moment ohne Firewall. Im Drop-in leert das leere ExecReload= den mitgelieferten Befehl, bevor der neue gesetzt wird – derselbe Trick wie beim Update-Timer in Teil 2.
Was das Regelwerk tut
Das Template ist lang, aber sein Aufbau ist einfach. Jede Kette hat policy drop: Was nicht ausdrücklich erlaubt ist, wird verworfen – ausgehender Verkehr eingeschlossen.
- Input: Loopback; Pakete, die zu einer bestehenden Verbindung gehören (
ct state established,related– das Connection Tracking merkt sich, was erlaubt wurde); dann Müll verwerfen (ungültiger Zustand, Pakete mit unmöglichen Flag-Kombinationen, gefälschte Handshake-Antworten, RST-Fluten); dann die Sperr-Sets; dann die Ausnahmen, die der Cluster braucht (Pods, die den API-Server erreichen, und auf Nextcloud-Hosts die Host-Datenbank und Redis – dasstack-Beispiel aus Teil 1); dann Web-Verkehr, SSH (aus der Länderliste) und die ICMP-Typen, die IPv4 und vor allem IPv6 zum Funktionieren brauchen. Zuletzt, vor dem Policy-Drop, eine ratenbegrenzte Logzeile. - Sperr-Sets:
banned4/banned6für fail2ban, mit einem Timeout, sodass Einträge von selbst ablaufen, undscanban4/scanban6für die Portscan-Erkennung. - Portscan-Erkennung, in der Firewall selbst: Ein Paket, das das Ende der Input-Kette erreicht, wird gleich verworfen – ein geschlossener Port. Die Regel zählt solche Verbindungsversuche pro Absenderadresse in einem dynamischen Set; eine Quelle, die ein paar Dutzend pro Minute überschreitet, landet für eine Stunde in
scanbanund wird dann auf allen Ports blockiert. Ein Mensch, der sich bei einem Port vertippt, schickt eine Handvoll; ein Scanner schickt Hunderte. Der SSH-Standardport wird nicht mitgezählt, sodass ein paarmal vergessener eigener Port nie den Admin aussperrt. fail2ban könnte das nicht – es liest nur Logs, und das Drop-Log ist absichtlich ratenbegrenzt. - Prerouting: schließt eine Hintertür, die Kubernetes öffnet. kube-proxy leitet den NodePort-Bereich auf jeder lokalen Adresse weiter, die öffentliche eingeschlossen, und dieser Verkehr läuft durch die Forward-Kette, nicht durch Input. Die Metrik-Exporter aus den Teilen 3 und 4 waren so aus dem Internet erreichbar. Diese Kette verwirft neue Verbindungen zu diesem Bereich auf den öffentlichen Adressen. Ihre Priorität ist genau gewählt – nach dem Connection Tracking, bevor Kubernetes das Ziel umschreibt –, und diese Genauigkeit hat eine Geschichte: Zuerst lief sie früher, vor dem Connection Tracking, und verwarf auch die Antworten auf eigene ausgehende Verbindungen der Pods, wann immer deren zufällig gewählter Quellport in diesen Bereich fiel. Etwa 4 Prozent der ausgehenden Pod-Verbindungen liefen zwei Monate lang in ein Zeitlimit, bis ein Abnahmetest eines neuen Servers es fand.
- Forward: Pod-Verkehr in beide Richtungen; Output: DNS, NTP, HTTP(S), Mail-Einlieferung, DHCP wo nötig, das Pod-Netz und der API-Server – sonst verlässt nichts den Server.
Der erste Lauf: warum der Server neu startet
Auf einem frischen Server lauscht SSH noch auf Port 22, und keine Firewall ist aktiv. Die SSH-Rolle (unten) verlegt SSH auf seinen neuen Port, diese Rolle schreibt die Firewall. Beides live zu aktivieren, würde die Verbindung kappen, über die Ansible arbeitet: Die bestehende Verbindung steht noch nicht im Connection Tracking, passt auf keine Regel, und ihre Pakete werden still verworfen – das Play hängt einfach. Also startet die Rolle beim ersten Lauf nftables nicht, sondern endet stattdessen mit einem Neustart:
- name: Firewall | First run only – reboot to activate the new SSH port and firewall together
ansible.builtin.include_tasks:
file: first_run_reboot.yml
apply:
tags: [common_firewall]
when: ssh_first_runIn first_run_reboot.yml: systemctl reboot über die aktuelle Verbindung auf Port 22 schicken, mit async: 1, poll: 0 (abschicken und vergessen – die Verbindung wird sterben), ansible_port mit set_fact auf den neuen Port umstellen, die alte Verbindung mit meta: reset_connection verwerfen und mit wait_for_connection warten, bis der Server auf dem neuen Port antwortet. Das Play macht dann dort weiter. Drei Ansible-Details machen das möglich:
include_tasks, nichtimport_tasks:meta: reset_connectionignoriertwhen. In einer statisch importierten Datei mit Bedingung lief es bei jedem Lauf und gab eine Warnung aus. Ein dynamisches Include wertet zuerst seine Bedingung aus und lädt erst dann die Datei.apply: tags: Tags an einem dynamischen Include erreichen die Tasks darin nicht.applyreicht sie weiter, sodass das Include auch mit--tags common_firewallfunktioniert.- Der Fact
ssh_first_runkommt von der SSH-Rolle, die direkt davor lief:set_fact-Werte bleiben für alle späteren Rollen auf diesem Host verfügbar. Er ist wahr, wenn sich das Play über Port 22 verbunden hat.
Eine Sache bleibt Handarbeit, und das Play gibt eine Erinnerung aus: Der neue Port muss vor dem nächsten Lauf in die vars.yml des Hosts eingetragen werden. Eine frühere Fassung versuchte den Neustart über den neuen Port – und scheiterte sofort, weil Task-Variablen auch für die Verbindung gelten, die den Neustart schickt. Die heutige Reihenfolge (Neustart über den alten Port, dann umstellen) ist daraus entstanden.
Handler: neu laden, dann die Sperren wiederherstellen
- name: Reload nftables
ansible.builtin.service:
name: nftables
state: reloaded
- name: Re-apply fail2ban bans after the nftables reload
ansible.builtin.command: systemctl try-restart fail2ban
listen: Reload nftablesDie Tabelle zu ersetzen, ersetzt auch die Sperr-Sets – leer. fail2ban bemerkte das nicht und führte diese Adressen weiter als „gesperrt“, während die Firewall sie wieder hereinließ: Nach einem Playbook-Lauf existierten die meisten Sperren auf zwei Servern nur noch im Speicher von fail2ban. Der zweite Handler abonniert mit listen dieselbe Benachrichtigung und läuft nach dem ersten, weil Handler in der Reihenfolge laufen, in der sie definiert sind. Ein Neustart lässt fail2ban jede aktive Sperre aus seiner Datenbank neu anwenden. try-restart tut nichts, wo fail2ban nicht läuft.
Nachtrag (19.10.2026): Nur noch nftables, auf jedem Host. Auf dem Blog lagen noch die EPEL-Pakete iptables-legacy und iptables-services, deshalb war iptables das legacy-Backend, und K3s hielt seine Regeln in legacy-Tabellen, unsichtbar für nft list ruleset. common_firewall entfernt diese Pakete jetzt, und common_cis_hardening sperrt die legacy-Tabellen-Module (iptable_*, ip6table_*). Nicht ip_tables selbst: iptables-nft holt sich daraus das ICMP-Match – auch das zu sperren ließ den Policy-Controller von K3s nach einem Neustart abstürzen und legte den Blog fünf Minuten lahm.
🔑 common_ssh: die Tür, und was man drinnen tun kann
Port, SELinux und die Konfiguration
SSH zieht auf einen Nicht-Standard-Port um. Das ist für sich keine Sicherheit – ein Scanner findet ihn –, aber es entfernt fast das ganze automatisierte Rauschen, das auf Port 22 zielt, und hält so Logs und fail2ban aussagekräftig. SELinux erlaubt sshd nur, auf Ports mit dem Label ssh_port_t zu lauschen, also liest die Rolle die Label-Liste einmal und fügt dann entweder das Label hinzu (semanage port -a) oder ändert ein bestehendes (-m) – zwei Tasks mit gegensätzlichen Bedingungen auf derselben registrierten Ausgabe, einer davon mit einem regex_search, das prüft, ob der Port schon unter dem richtigen Typ geführt wird.
Die Konfigurationsdatei ist streng: nur Schlüssel, keine Passwörter und kein Keyboard-Interactive-Login, nur root darf sich anmelden (es gibt keine anderen Konten, die SSH brauchen), sehr wenige Versuche und Sitzungen pro Verbindung, wenig Zeit zur Authentifizierung, keine Weiterleitung von Ports, X11 oder Tunneln und eine kurze Liste moderner Schlüsselaustausch-, Verschlüsselungs- und MAC-Verfahren. LogLevel VERBOSE protokolliert bei jedem Login den Fingerabdruck des verwendeten Schlüssels.
Der erste Wert gewinnt
Eine Lektion von zwei Servern. sshd_config bindet ganz oben /etc/ssh/sshd_config.d/*.conf ein, und bei sshd gewinnt der erste Wert eines Schlüsselworts. Das Image des Anbieters für die Nextcloud-Server legt dort eine 50-cloud-init.conf ab, die PasswordAuthentication yes sagt – und die schlug still das „no“ in der Hauptdatei. Die eigene 50-redhat.conf von AlmaLinux tat dasselbe mit der X11-Weiterleitung auf allen drei Servern. Die Lösung ist ein Drop-in namens 00-hardening.conf: Er wird zuerst einsortiert, seine Werte gewinnen also, egal was ein Image später hinzufügt.
Die Sandbox um sshd
Ein systemd-Drop-in schränkt den sshd-Dienst ein – und alles, was von ihm gestartet wird, erbt die Einschränkungen, jede SSH-Sitzung und jeder Ansible-Task eingeschlossen. Das ist der Zweck und das Risiko zugleich:
[Service]
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictRealtime=yes
LockPersonality=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINKNiemand, der sich per SSH anmeldet, kann ein Kernel-Modul laden oder einen Kernel-Parameter ändern – auch kein Angreifer mit gestohlenem Schlüssel. Legitime Änderungen gehen über systemd-run, das Teil 2 erklärt hat. Der Kommentar über dem Drop-in listet, was ausprobiert und wieder entfernt wurde, und jede Entfernung ist eine Lektion:
AF_NETLINKmuss erlaubt sein: Ohne es scheiternnft,ipundssin jeder SSH-Sitzung – auch dann, wenn man gerade versucht, die Firewall zu reparieren.- Kein
RestrictSUIDSGID: Es blockierte das Setzen von setuid-Bits selbst für root, und Paketinstallationen, die solche Dateien mitbringen, scheiterten. - Kein
PrivateTmp: Das private/tmpvon sshd wird von jeder Sitzung geerbt, sodass Dateien, die über SSH in/tmpabgelegt wurden, für cron und andere Dienste unsichtbar waren, und jeder sshd-Neustart zerstörte es unter laufenden Sitzungen –dnfscheiterte zweimal daran.
Die allgemeine Regel, die die Rolle festhält: Sandboxing pro Dienst gehört auf Dienste, die keine Sitzungen starten. Der MariaDB-Drop-in in Teil 4 ist das Beispiel dafür, es vollständig zu tun.
Den Server neu starten, über den man verbunden ist
Der heikelste Teil der Rolle ist ihre Handler-Kette. sshd neu zu starten, kappt die Verbindung, über die Ansible läuft. Die Handler, in der Reihenfolge ihrer Definition (das ist die Reihenfolge, in der sie laufen):
| Handler | Was er tut |
|---|---|
Validate sshd config | sshd -t – eine kaputte Konfiguration hält das Play an, bevor sshd damit neu gestartet wird |
Reload systemd units for sshd | den Drop-in neu einlesen; ohne das würde systemd sshd aus seiner zwischengespeicherten alten Unit neu starten |
Reload sshd | der Neustart – mit async: 30, poll: 0: anstoßen und nicht auf eine Antwort warten, die über einen sterbenden Kanal nie ankommen kann |
Reset connection after sshd restart | meta: reset_connection: die zwischengespeicherte, jetzt tote SSH-Verbindung wegwerfen |
Confirm sshd is accepting connections again | wait_for_connection: neu verbinden; kam sshd nicht zurück, scheitert das Play hier – laut, solange man vermutlich noch eine andere Sitzung offen hat |
Es ist absichtlich ein Neustart, kein Reload: systemd wendet Sandbox-Einstellungen nur an, wenn es einen Prozess startet. Ein Reload würde dem laufenden sshd ein Signal schicken, und Änderungen am Drop-in würden nie wirksam. Bevor async dazukam, starb der Neustart-Task mit „Module result deserialization failed“ – die Antwort des Moduls kam nie an, weil seine Verbindung weg war.
Eine Einschränkung ist in der Rolle dokumentiert: Die Haupt-sshd_config benachrichtigt nur den Validierungs-Handler. Eine Änderung an dieser einen Datei wird geprüft, wird aber erst mit dem nächsten sshd-Neustart wirksam – durch eine Drop-in-Änderung oder einen Reboot. Die Drop-ins, die die sicherheitskritischen Einstellungen tragen, starten sshd selbst neu.
Die Rolle endet mit dem Login-Alarm (STEP 8), der einen eigenen Beitrag hat, einem Warnbanner und einer ersetzten /etc/issue, die am Login-Prompt niemandem mehr verrät, welches Betriebssystem und welche Kernel-Version hier laufen.
🚫 common_fail2ban: reagieren auf das, was die Logs sagen
fail2ban liest Logdateien, erkennt Angriffe mit regulären Ausdrücken (Filtern), zählt sie pro Adresse und führt nach zu vielen Treffern in einem Zeitfenster eine Aktion aus – hier: die Adresse in das Sperr-Set der Firewall legen. Sieben Jails verbinden einen Filter, ein Log und Grenzwerte:
| Jail | Beobachtet | Reagiert auf |
|---|---|---|
sshd | das System-Journal | fehlgeschlagene SSH-Logins |
nginx-k3s-wp-login | das Ingress-Log | fehlgeschlagene WordPress-Logins |
nginx-k3s-nc-login | das Ingress-Log | fehlgeschlagene Nextcloud-Logins |
nginx-k3s-grafana-login | das Ingress-Log | fehlgeschlagene Grafana-Logins (strenger) |
nginx-k3s-scanner | das Ingress-Log | Anfrageschübe, wie sie für Schwachstellenscanner typisch sind |
nginx-k3s-bad-paths | das Ingress-Log | Pfade, die hier nie legitim sind – /.env, /.git/, Web-Shells, Admin-Werkzeuge, die es nicht gibt –, mit fast null Toleranz und einer langen Sperre |
recidive | das eigene Log von fail2ban | Adressen, die immer wieder gesperrt werden: eine viel längere Sperre auf allen Ports |
Die HTTP-Jails lesen das Log des Ingress-Controllers direkt auf dem Host – nginx läuft in einem Pod, aber sein Log landet in /var/log/pods/, in einem Format mit containerd-Präfix, das die eigenen Filter berücksichtigen. Das schafft ein eigenes Problem: Der Name der Logdatei ändert sich mit jedem Container-Neustart, und fail2ban löst den Pfad nur beim Start eines Jails auf. Nach einem Reboot startete fail2ban vor dem Ingress-Container und las dann eine tote Datei – die HTTP-Jails sperrten tagelang nichts. Ein kleines Skript an einem systemd-Timer lädt diese Jails jetzt neu, sobald sich die Menge der Logdateien ändert; sein Zustand liegt in /run, sodass die erste Prüfung nach jedem Boot immer neu lädt.
Die Sperraktion – und ein Fehler, gefunden beim Schreiben dieses Beitrags
[Definition]
actionban = nft add element inet filter <nftset> { <ip> timeout <bantime>s }
actionunban = nft delete element inet filter <nftset> { <ip> } 2>/dev/null; true
[Init]
nftset = banned4
[Init?family=inet6]
nftset = banned6Die Aktion schreibt die Adresse direkt in das Firewall-Set, mit der Sperrzeit als Timeout des Elements – nftables entfernt sie von selbst; das Entsperren ist nur Aufräumen. Die beiden [Init]-Abschnitte wählen das Set nach Adressfamilie. Dieser Teil ist neu: Beim Lesen der Rolle für diesen Beitrag fiel mir auf, dass die Jails für jede Adresse nftset=banned4 übergaben. Jede IPv6-Sperre versuchte also, eine IPv6-Adresse in ein IPv4-Set zu legen, und nft verweigerte mit „Address family for hostname not supported“ – auf allen drei Servern. fail2ban protokollierte „Ban“, die Firewall ließ die Adresse herein, banned6 blieb leer. Die Korrektur wurde gegen eine Kopie der Live-Konfiguration getestet (fail2ban-client -t und -d und der nft-Befehl im Prüfmodus), bevor sie ins Repository kam. Die eigene Portscan-Erkennung der Firewall war nie betroffen – sie behandelt beide Familien selbst. Die Lektion für Betreiber: Eine Logzeile „Ban“ ist kein Beweis, dass irgendetwas blockiert wurde. Schau ins Set.
Zwei kleinere Details. Das eigene Log von fail2ban muss vor seinem ersten Start existieren, weil das recidive-Jail es liest und fail2ban alle Logs prüft, bevor es sein eigenes anlegt – ein frischer Server scheiterte genau daran. Die Rolle legt es mit state: touch und modification_time: preserve an, sodass der Task nicht bei jedem Lauf „changed“ ist. Und der einzige Handler startet fail2ban einmal am Ende des Plays neu, egal wie viele Dateien sich geändert haben – was auch alle aktiven Sperren neu anwendet.
Nachtrag 19.10.2026: Der WordPress-Login liegt jetzt hinter derselben Länderliste, die SSH schützt. nginx im Blog-Pod beantwortet /wp-login.php und /wp-admin/ für Adressen außerhalb Deutschlands mit 403 – bevor PHP läuft, Brute-Force-Versuche aus dem Ausland erreichen also weder WordPress noch fail2ban. ssh-geo-update schreibt dieselben RIPE-Blöcke als nginx-geo-Datei und lädt nginx nach seiner wöchentlichen Aktualisierung neu; admin-ajax.php und admin-post.php bleiben offen, weil Besucherfunktionen sie nutzen. Wer sich aus dem Ausland anmelden will, braucht ein VPN mit deutschem Ausgang oder einen Eintrag in fw_ssh_geo_extra.
🛡️ common_cis_hardening: der Benchmark, mit Urteilsvermögen angewendet
Der CIS-Benchmark ist eine lange Checkliste zur Härtung eines Betriebssystems. Ihn gegen diese Server laufen zu lassen, brachte über hundert Befunde. Diese Rolle setzt die um, die sicher sind – und ihr Kopf beginnt mit einer Liste dessen, was sie bewusst nicht tut, jeweils mit Begründung. Diese Liste ist der nützlichste Teil für alle, die ein ähnliches Setup betreiben:
- PAM-Änderungen (Passwortqualität, Sperre nach Fehlversuchen): PAM stand nie unter dem Verwaltungswerkzeug, das der Benchmark voraussetzt; es einzuschalten schreibt den Authentifizierungs-Stack um, und ein Fehler dort sperrt jeden lokalen Login aus. SSH läuft ohnehin nur mit Schlüsseln.
- Ein Bootloader-Passwort: schützt vor physischem Zugriff, den ein VPS nicht hat, und erschwert die Rettungskonsole des Anbieters.
- Eine eigene Krypto-Policy: ändert TLS- und SSH-Verfahren systemweit und könnte Dinge kaputtmachen, die schwer zu testen sind.
- IP-Forwarding und Rückwegfilterung: von Kubernetes benötigt, siehe Teil 2.
Was sie umsetzt, jeder Punkt eine kleine Gruppe von Tasks:
- AIDE, ein Prüfwerkzeug für Dateiintegrität: Es führt eine Datenbank mit Prüfsummen und Attributen von Systemdateien und meldet jeden Unterschied. Die Rolle installiert es, nimmt die Pod-Log-Verzeichnisse aus (sie ändern sich bei jedem Pod-Start und sind kein Manipulationsziel), baut einmal die erste Datenbank (
stat→when: not ... exists, übersystemd-run, weil die SSH-Sandbox Teile des Dateisystems verbirgt) und plant zwei Cronjobs: die Prüfung um Mitternacht, die nur mailt, wenn sie etwas findet, und eine Erneuerung der Referenz um 04:00 – nach den nächtlichen Updates und dem Neustart-Fenster, damit ein nachts installiertes Kernel-Update in der nächsten Mitternacht nicht als Tausende „geänderte Dateien“ auftaucht. Dieser Fehlalarm ist einmal passiert, mit genau so einem Kernel-Update. - Kernel-Module für alte Dateisysteme und USB-Speicher werden blockiert (
install <module> /bin/false). - journald behält Logs dauerhaft und komprimiert, mit einer Größenobergrenze; ein Handler startet es neu, wenn sich diese Datei ändert.
- Keine Core-Dumps: Ein Absturzabbild eines privilegierten Prozesses kann Passwörter und Sitzungstokens enthalten.
- cron und at: restriktive Rechte, und nur root darf sie benutzen.
- sudo: ein Pseudo-Terminal, eine Logdatei und keine zwischengespeicherten Anmeldedaten – geschrieben mit
validate: "visudo -cf %s". Das ist das wichtigste Sicherheitsnetz der Rolle: Eine kaputte sudoers-Datei kann sudo unbrauchbar machen, und die Validierung garantiert, dass eine kaputte Fassung nie die echte ersetzt. - Der Standardbenutzer des Images: Das Image des Anbieters für die Nextcloud-Server legt einen Benutzer mit sudo ohne Passwort an. Die Rolle entfernt diese sudo-Zeile (wieder mit
visudo-Validierung) und gibt dem Benutzer keine Login-Shell. Löschen kann sie den Benutzer nicht: Dieselbe numerische Benutzer-ID wird von einem Prozess in einem Pod genutzt, sodass der Host einen laufenden Prozess zeigt, der ihm „gehört“.getentliest zuerst die Benutzerdatenbank, und(getent_passwd | default({})).get('almalinux') is not noneist eine sichere Art zu fragen „gibt es diesen Benutzer?“, ohne Fehler, wenn es ihn nicht gibt. - Ungenutzte Dienste und Pakete: Bluetooth, der rsync-Daemon (aber nicht der rsync-Client, den die Backups brauchen – eine frühere Fassung entfernte das ganze Paket und legte sie lahm), der NFS/Samba-Client-Stack, den das Cloud-Image mitbringt, und cloud-init nach der Einrichtung.
- Mount-Optionen:
/tmp,/var/tmpund/dev/shmmitnodev,nosuid,noexec– von dort kann nichts ausgeführt werden.ansible.posix.mountmitstate: mountedschreibt die Zeile in/etc/fstabund hängt sofort neu ein;presentwürde nur die Datei schreiben. Deshalb wird der K3s-Installer in Teil 2 alssh /tmp/k3s-install.shgestartet, statt ausgeführt zu werden.
🏷️ common_selinux: Labels, die einen Einbruch eindämmen
SELinux gibt jeder Datei und jedem Prozess einen Typ, und die Richtlinie sagt, welche Prozesstypen welche Dateitypen anfassen dürfen. Ein Webserver-Prozess läuft als ein Typ und darf Webinhalte lesen, aber nicht etwa SSH-Schlüssel – selbst wenn er als root läuft und selbst wenn die Dateirechte es erlauben würden. Für Container ist die Regel einfach: Container-Prozesse laufen als container_t und dürfen nur Dateien vom Typ container_file_t schreiben. Ein Container, der aus seinem eigenen Dateisystem ausbricht, findet einen Host vor, auf dem fast nichts dieses Label trägt.
Beim Schreiben dieses Teils gefunden (19.10.2026): Wochenlang galt diese Regel hier nur zur Hälfte. SELinux stand auf enforcing, und die Verzeichnisse trugen container_file_t – aber K3s startet seine Container-Runtime ohne SELinux-Unterstützung, solange in seiner Konfiguration nicht selinux: true steht, und das stand dort nicht. Jeder Pod lief als spc_t oder unconfined_service_t, durch nichts beschränkt. Ein Blick auf ps -eZ zeigte es; keine Prüfung hatte je danach gefragt. common_k3s installiert jetzt die Policy k3s-selinux, setzt die Option und startet die Workloads einmal neu. Seitdem läuft jeder Anwendungsprozess als container_t mit eigenen MCS-Kategorien – kein Pod läuft mehr als spc_t. Die Lehre für jedes SELinux-Setup: die Domain der laufenden Prozesse prüfen, nicht nur den Modus und die Labels.
Das ist auch der Grund, warum die Reihenfolge des Playbooks zählt (Teil 1): Die Host-Verzeichnisse, die in die Pods eingehängt werden, müssen container_file_t tragen, bevor die Pods starten. Diese Rolle legt sie an – mit den Besitzern, die die Container erwarten, aus dem Profil des Stacks – und versieht sie mit Labels:
- name: SELinux | Read the local file-context rules
ansible.builtin.command: semanage fcontext -l -C
register: semanage_fc_local
changed_when: false
- name: SELinux | Label the HostPath directories as container_file_t
ansible.builtin.command: >
semanage fcontext -a -t container_file_t "{{ item.path }}(/.*)?"
loop: "{{ selinux_profiles[stack] }}"
when: >-
(item.path ~ '(/.*)?') not in
(semanage_fc_local.stdout_lines | select | map('split') | map('first') | list)semanage fcontext -a fügt der Richtlinie eine Regel hinzu – „dieser Pfad und alles darunter ist container_file_t“ –, die auch ein komplettes Neu-Labeln des Systems übersteht; restorecon -R wendet die Regel dann auf die bereits vorhandenen Dateien an, und neue Dateien erben sie. Die Logik „erst lesen“ hat eine frische Geschichte: Ein Update der SELinux-Werkzeuge scheiterte bei einer bestehenden Regel nicht mehr, sondern gab „already defined, modifying instead“ auf der normalen Ausgabe aus – die alte Prüfung suchte nach dem Fehler und meldete bei jedem Lauf „changed“. Jetzt liest die Rolle die lokalen Regeln (-C), nimmt mit map('split') | map('first') die erste Spalte jeder Zeile und fügt nur hinzu, was fehlt.
Die argument_specs der Rolle zeigen ein Feature, das man kennen sollte: stack ist mit choices: [wordpress, nextcloud] deklariert – ein Tippfehler in der Play-Variable des Playbooks scheitert vor dem ersten Task. Die Rolle setzt SELinux außerdem mit dem Modul aus ansible.posix auf enforcing (der alte eingebaute Name funktioniert nur noch über eine Umleitung), und sie ist bei einer Einstellung ehrlich: einem Boolean, dessen Kommentar früher behauptete, er erlaube ausgehende Verbindungen. Das tut er nicht – ausgehende Verbindungen funktionieren ohne ihn –, und ob ihn noch etwas braucht, ist eine offene Frage, aufgeschrieben statt geraten.
🔎 Erkennung: auditd, AIDE, rkhunter, ClamAV
Die bisherigen Schichten versuchen, einen Angreifer draußen zu halten. Diese vier gehen davon aus, dass jemand hereingekommen ist – oder dass sich etwas geändert hat, das sich nicht hätte ändern sollen – und sorgen dafür, dass es bemerkt wird. Ihre gemeinsame Regel: still, wenn alles in Ordnung ist, eine Mail, wenn nicht. Ein Detektor, der jede Nacht mailt, gewöhnt einen daran, ihn zu ignorieren.
common_auditd: das Audit-Log des Kernels, und vier Schlüssel, die mailen
auditd zeichnet auf, was der Kernel beobachten soll. Die Regeldatei beobachtet Schreibzugriffe auf Benutzer- und Gruppendateien, die sudo-Konfiguration, die SSH-Konfiguration, cron, die Netzwerkkonfiguration, die Audit-Konfiguration selbst sowie die Konfiguration, Tokens, Zertifikate und das Auto-Deploy-Verzeichnis von K3s; und sie zeichnet jede Ausführung der Werkzeuge zum Laden von Modulen und von sudo auf. Jede Regel hat einen Schlüssel, sodass ausearch -k <key> die Ereignisse später findet. Ein Handler kompiliert die Regeln mit augenrules --load in den Kernel, wenn sich die Datei ändert.
auditd protokolliert nur. Ein kleines Skript macht alle zehn Minuten per cron aus vier Schlüsseln Mails: das Auto-Deploy-Verzeichnis von K3s (K3s wendet alles an, was dort hineingeschrieben wird – ein perfekter Ort für eine Hintertür im Cluster, und dieses Repository schreibt nie dorthin), das Cluster-Token, die Cluster-Zertifikate und die Benutzerdatenbank. Ein Checkpoint pro Schlüssel sorgt dafür, dass jedes Ereignis genau einmal gemeldet wird. Die lauten Schlüssel – sudo, Module, SSH-Konfiguration, cron – werden bewusst nicht gemailt: Sie schlagen bei normaler Administration und bei Ansible-Läufen an und bleiben für Untersuchungen verfügbar.
Nachtrag (19.10.2026): Jeder AIDE-Lauf erzeugte eine Reihe von SELinux-Meldungen, weil AIDE statfs() auf das vfat-/boot/efi aufruft, obwohl es ausgeschlossen ist. Ein lokales Policy-Modul mit einer einzigen Regel, aide_dosfs (allow aide_t dosfs_t:filesystem getattr;), gebaut mit checkmodule/semodule_package und nur bei geänderter Quelle geladen, hat das beendet – die Hosts melden jetzt gar keine AVCs mehr.
common_aide_refresh: die letzte Rolle jedes Laufs
Jeder Playbook-Lauf ändert Dateien, die AIDE beobachtet – schon ein Paket-Update tut das. Ohne frische Referenz meldet die nächste Mitternachtsprüfung die Änderungen des Laufs selbst als Einbruch. Also erneuert die allerletzte Rolle jedes Playbooks die Referenz, aber nur, wenn sich wirklich etwas geändert hat:
- name: AIDE refresh | Compare with the baseline and write the new database (one scan)
ansible.builtin.command: systemd-run --wait --pipe --quiet --collect -- aide --update
register: aide_update
changed_when: false
failed_when: false
- name: AIDE refresh | Activate the new baseline (differences were found)
ansible.builtin.command: mv /var/lib/aide/aide.db.new.gz /var/lib/aide/aide.db.gz
changed_when: true
when:
- aide_update.rc is defined
- aide_update.rc in range(1, 8)
- aide_db_new.stat.existsDer Exit-Code von AIDE ist eine Bitmaske: 1 heißt, Dateien kamen hinzu, 2 entfernt, 4 geändert – 1 bis 7 heißt also „Unterschiede gefunden und erfasst“, was direkt nach einem Playbook-Lauf normal ist. 0 heißt identisch (die neue Datenbank wird verworfen), und 14 und mehr ist ein Fehler (gemeldet, Referenz unangetastet). range(1, 8) ist Jinja für 1 bis 7 – das Ende ist exklusiv. failed_when: false ist entscheidend: Die erste Fassung behandelte einen Code ungleich null als Fehler und brach das Play ab, bevor die neue Datenbank aktiviert werden konnte. Nur das mv meldet „changed“, sodass eine erneuerte Referenz genau einmal in der Zusammenfassung erscheint. Und ein Scan erledigt sowohl den Vergleich als auch die neue Datenbank – die alte Fassung scannte alles zweimal.
Hier gibt es einen Zielkonflikt, den ein Betreiber verstehen sollte: Die Referenz zu erneuern akzeptiert alle aktuellen Unterschiede – auch solche, die nicht vom Playbook verursacht wurden. Die werden von der Mitternachtsprüfung vor dem Lauf gefangen, deshalb sind ihre Mails wichtig.
common_rkhunter: Rootkits, jede Nacht
rkhunter vergleicht System-Binaries mit einer gespeicherten Datenbank ihrer Eigenschaften und sucht nach Spuren bekannter Rootkits. Die Rolle setzt seine Optionen mit lineinfile in einer Schleife über Schlüssel/Wert-Paare, baut die Datenbank (--propupd) und plant eine nächtliche Prüfung, die nur Warnungen meldet. Zwei Details:
- Ein kleines dnf-Plugin, das die Rolle in das Plugin-Verzeichnis von dnf schreibt, führt nach jeder Pakettransaktion
--propupdaus – nächtliche automatische Updates wie manuelle. Ohne es wäre jedes Update in der nächsten Nacht eine „Datei geändert“-Warnung. - Das Paket bringt ein eigenes tägliches cron-Skript mit, also lief auf jedem Server zweimal pro Nacht ein Scan. Es nur zu löschen, hielte nicht – das nächste Paket-Update würde es zurückbringen. Ein einzeiliges rpm-Makro,
%_netsharedpath, sagt rpm, diesen Pfad nie wieder zu installieren.
common_clamav: Schadsoftware in den Dateien anderer
Ein nächtlicher ClamAV-Scan dessen, was Nutzer hochladen – die Medien-Uploads des Blogs, die Nutzerdaten von Nextcloud –, mit niedriger CPU- und Plattenpriorität, der nur mailt, wenn er etwas findet, und nie selbst etwas löscht. Der Beweis, dass er wirklich etwas findet, ist ein älterer Beitrag. Die Rolle wählt ihre Einstellungen aus einem Profil wie common_selinux und hat ein hübsches Stück Jinja: Die Liste der zu überspringenden Medien-Endungen wird Buchstabe für Buchstabe in ein Muster ohne Groß-/Kleinschreibung verwandelt – aus jpg wird [jJ][pP][gG] –, weil der Scanner keine Option für Ausnahmen ohne Beachtung der Groß-/Kleinschreibung hat. Auf Nextcloud werden die erzeugten Vorschaubilder übersprungen (sie werden aus Dateien erzeugt, die ohnehin gescannt werden, und mit ihnen dauerte ein voller Scan sechzehn Stunden), und der Scan läuft um 04:30, nach dem Neustart-Fenster – zu einer früheren Uhrzeit beendete ihn der Neustart in jeder Update-Nacht.
📦 Die Schichten aus anderen Teilen, auf einen Blick
| Schicht | Wo | Kurz |
|---|---|---|
| Netzwerkhärtung des Kernels | Teil 2, common_sysctl | keine ICMP-Redirects, kein Source-Routing, SYN-Cookies, ASLR, eingeschränktes ptrace, Kernel-Log nur für root, RFC 1337, geschützte Dateien in /tmp |
| Härtung der Pods | Teile 3 und 4 | alle Capabilities entzogen und nur die nötigen zurück, keine Rechteausweitung, schreibgeschützte Root-Dateisysteme, wo möglich keine root-Benutzer, keine Service-Account-Tokens |
| NetworkPolicies | Teile 3 und 4 | Datenbank und Cache nur von der Anwendung aus; Collabora von beiden abgeschnitten |
| Image-Richtlinie | Teile 3 und 4 | nur freigegebene Image-Repositorys dürfen laufen |
| Pod Security Standards | Teile 3 und 4 | warnen und protokollieren, nicht durchsetzen – Host-Verzeichnisse würden abgelehnt |
| Audit-Log des Clusters | Teil 2 | wer was in Kubernetes getan hat, Secrets nur mit Metadaten protokolliert |
| Login-Alarm | eigener Beitrag | eine Mail für jeden interaktiven SSH-Login |
🛠️ Im Betrieb: Sicherheit im Alltag
Nachsehen
# fail2ban: which jails, how many bans
fail2ban-client status
fail2ban-client status nginx-k3s-bad-paths
# What is really blocked right now (fail2ban and the port-scan detector)
nft list set inet filter banned4
nft list set inet filter banned6
nft list set inet filter scanban4
# Audit events of one key
ausearch -k k3s_manifests -i
# The whole firewall as it is loaded
nft list table inet filterMails aus diesem Teil
| Bedeutung | Was tun | |
|---|---|---|
| SSH-Login-Alarm | Jemand hat sich interaktiv angemeldet | Wenn du es nicht warst: sofort handeln – last -i prüfen, Schlüssel wechseln |
| AIDE hat Änderungen an der Dateiintegrität gefunden | Systemdateien weichen von der Referenz ab | Die Liste lesen. Nach einer geplanten Änderung: erwartet. Sonst: untersuchen, bevor der nächste Playbook-Lauf die Referenz erneuert |
| auditd-Ereignis (k3s_manifests, token, tls, identity) | Etwas, das sich nie ändern dürfte, hat sich geändert | Als Vorfall behandeln, bis es erklärt ist |
| rkhunter-Warnungen | Ein Binary oder eine Einstellung wirkt verdächtig | Oft eine Paketänderung – mit den letzten Updates vergleichen; sonst untersuchen |
| ClamAV hat etwas gefunden | Eine Datei in den Uploads oder Nutzerdaten passte auf eine Signatur | Die Datei ansehen und wer sie hochgeladen hat; ClamAV hat nichts gelöscht |
Wenn du dich selbst aussperrst
- Eigene Adresse gesperrt:
fail2ban-client set <jail> unbanip <address>von einer anderen Verbindung aus, oder warten, bis die Sperre abläuft. Für die Portscan-Sperre:nft delete element inet filter scanban4 { <address> }. Teste Angriffspfade nie von dem Rechner aus, von dem du administrierst – ich habe mich zweimal selbst gesperrt. - Außerhalb des erlaubten Landes: deine Adresse zu
fw_ssh_geo_extrahinzufügen und die Firewall-Rolle aus dem erlaubten Gebiet heraus laufen lassen – oder die Web-Konsole des Anbieters nutzen. - SSH kommt nach einer Änderung nicht zurück: die Web-Konsole des Anbieters mit dem root-Passwort. Deshalb sagt Teil 2: Teste die Konsole, bevor du sie brauchst.
🧭 Wie es weitergeht
Teil 6 schließt die Tour mit dem Monitoring ab: die Exporter, Prometheus, Grafana mit seinen Alarmen und der Monit-Wachhund – was jeder davon beobachtet und was ihre Mails bedeuten.

