
🌐 Auch auf: English · Français · Español
Nachdem ich diese Woche zwei Nextcloud-Instanzen auf neue Server umgezogen hatte, stellte ich eine simple Frage: Ziehen meine drei Server – dieser Blog und die beiden Nextclouds – unterschiedliche Angreifer an? Die Antwort lautete größtenteils: „Nein, überall dasselbe Hintergrundrauschen.“ Bis auf eine Zeile, die mich nicht losließ: SSH. Es läuft auf einem Nicht-Standard-Port, akzeptiert nur Schlüssel, und fail2ban passt darauf auf. Und trotzdem verzeichnete allein der Blog rund 16 Verbindungsversuche pro Stunde zum SSH-Daemon, aus aller Welt.
Die Sache ist: Ich verbinde mich ausschließlich aus Deutschland. Warum sollte ein Server in einem deutschen Rechenzentrum also überhaupt mit dem Rest des Planeten SSH sprechen? Der Ablauf wie immer: Ich stelle die Fragen und treffe die Entscheidungen, mein KI-Co-Admin (Claude) wühlt sich durch die Logs, schreibt den Code und prüft jede Zahl doppelt, bevor wir ihr trauen.
📊 Vorher: Wer klopft an Port 10022?
In den letzten acht Tagen schafften es 3.089 Verbindungen von 510 verschiedenen IP-Adressen bis zum SSH-Daemon des Blogs (meine eigenen Verbindungen nicht mitgezählt). Reingekommen ist keine davon – es gibt kein Passwort zum Raten –, aber jede einzelne kostet einen Handshake, eine Logzeile und eine Auswertung durch fail2ban. Woher sie kamen:
| Land | Anteil der Versuche |
|---|---|
| China | 30 % |
| USA | 16 % |
| Südkorea | 4 % |
| Deutschland | 4 % |
| Indien, Großbritannien, Hongkong, Vietnam, Südafrika | je 3 % |
| Alle anderen | ~31 % |
Die ausprobierten Benutzernamen sind das übliche Wörterbuch: admin, ubuntu, user, test, ftpuser, deploy, git, steam. Auf den beiden Nextcloud-Servern sah das Bild genauso aus: 98 % (Blog) bzw. 99 % (Nextcloud) aller fehlgeschlagenen SSH-Versuche kamen von außerhalb Deutschlands. Und die wenigen deutschen? Fast ausschließlich gemietete Server bei den großen deutschen Hostern – auch Scanner mieten hier Maschinen.
🤔 „Aber Angreifer nehmen doch einfach ein deutsches VPN!“
Stimmt, und da will ich ehrlich sein: Ein Länderfilter ist keine Sicherheitsgrenze. Wer es gezielt auf mich abgesehen hat, mietet für ein paar Euro einen deutschen VPS und spaziert einfach daran vorbei. Der eigentliche Schutz bleibt genau da, wo er war: Anmeldung nur per Schlüssel, fail2ban und Patches. Wozu also der Aufwand?
- Rauschen. 98 % weniger Müll in den Logs heißt, dass der eine Eintrag, auf den es ankommt, tatsächlich auffällt.
- Die nächste Zero-Day-Lücke. Wenn ein echtes Loch in OpenSSH auftaucht (man denke an regreSSHion, CVE-2024-6387, aus dem Jahr 2024), ist die erste Welle ein Massenscan des gesamten Internets. Ein Server, der auf 98 % dieses Internets nicht antwortet, ist beim Großteil dieser Welle schlicht nicht dabei.
- Kosten: so gut wie keine – siehe unten.
🌍 Woher kommt „Deutschland“?
Eine kommerzielle GeoIP-Datenbank braucht es nicht. Die fünf Regional Internet Registries veröffentlichen täglich, welche IP-Blöcke sie welchem Land zugeteilt haben. Für Deutschland ist das delegated-ripencc-extended-latest vom RIPE NCC: etwa 8.700 IPv4- und 3.100 IPv6-Blöcke, rund 126 Millionen IPv4-Adressen. Zusammengefasst in nftables-Intervall-Sets sind das 6.639 + 3.013 Einträge.
Kostet das RAM oder CPU? Wir haben gemessen statt geraten:
| Was | Gemessen |
|---|---|
| Kernel-Speicher für beide Sets | ~1,5 MB |
| Laden der kompletten Liste | 0,06 s |
| Lookup pro neuer SSH-Verbindung | ~14 Vergleiche (sortierter Intervallbaum), Nanosekunden |
| Auswirkung auf den Web-Traffic | keine – geprüft werden nur neue Verbindungen auf Port 10022 |
Wichtig: RIR-Daten sagen, an wen ein Adressblock vergeben wurde, nicht, wo das Gerät physisch steht. Für die Frage „Ist das ein deutscher Provider oder Hoster?“ ist das aber genau die richtige.
🧱 Das neue Ansible-Feature: eine Variable
Alle drei Server entstehen aus demselben Ansible-Repository, und die Firewall ist die gemeinsame Rolle common_firewall. Der Länderfilter gehört jetzt dazu – standardmäßig aus, pro Host einschaltbar:
# inventory/host_vars/<host>/vars.yml
fw_ssh_geo_countries: [DE]
fw_ssh_geo_extra: [] # optional: CIDRs that are always allowedIm gerenderten nftables-Regelwerk bekommt die SSH-Regel einfach einen Set-Lookup dazu:
ip daddr 203.0.113.10 tcp dport 10022 ct state new ip saddr @ssh_geo4 accept
ip6 daddr 2001:db8::1 tcp dport 10022 ip6 saddr @ssh_geo6 acceptAlles, was nicht passt, fällt durch zur bestehenden Portscan-Erkennung und zum Default-Drop – genau so, als wäre der Port geschlossen. Ein kleines Skript, ssh-geo-update, lädt die RIPE-Datei herunter, behält die Blöcke der konfigurierten Länder, fasst sie zusammen und schreibt /etc/nftables/ssh-geo.nft. Ein systemd-Timer aktualisiert das jeden Sonntag in der Nacht.
🪤 Der spannende Teil: wie man sich nicht selbst aussperrt
Eine Liste erlaubter Adressen hat einen fiesen Fehlerfall: Ist sie jemals leer, kommt niemand mehr rein – ich eingeschlossen. Die meiste Arbeit steckte darin, genau das unmöglich zu machen:
- Firewall-Reloads. Mein Regelwerk legt bei jedem Reload seine komplette Tabelle neu an. Das haben wir Anfang der Woche auf die harte Tour gelernt, als ein Reload still und leise die Ban-Sets von fail2ban geleert hat. Hier wäre es schlimmer: Leere Sets bedeuten SSH zu für alle. Deshalb wird die Liste per include in die Regelwerk-Datei eingebunden und in derselben atomaren Transaktion neu befüllt. Es gibt nie einen Moment ohne sie.
- Der erste Lauf. Ansible baut die Liste bevor es das neue Regelwerk schreibt. Schlägt der Download fehl, bricht das Playbook genau dort ab, bevor auch nur eine Regel jemanden aussperren könnte.
- Kaputte Daten. Das Update behält die alte Liste, wenn der Download fehlschlägt, wenn die RIPE-Daten älter als 14 Tage sind oder wenn die neue Liste plötzlich um mehr als 20 % schrumpft. Live-Updates fassen nur die beiden Sets an; die Sets von fail2ban und alle anderen Regeln bleiben unberührt.
- SELinux. Im ersten Entwurf lag die Liste unter
/var/lib. Unter AlmaLinux lädt die nftables-Unit das Regelwerk aber in einer eingeschränkten SELinux-Domäne, die/etcliest, und ein nicht lesbares Include würde beim Booten das gesamte Regelwerk scheitern lassen. Also wohnt die Liste in/etc/nftables. - Integritätsüberwachung. AIDE überwacht
/etcund mailt jede Änderung. Der Timer läuft deshalb sonntags zwischen 02:30 und 03:00 Uhr, nach dem nächtlichen AIDE-Check und vor der Baseline-Aktualisierung um 04:00 Uhr, damit das wöchentliche Update nie einen Fehlalarm auslöst. - Der Notausgang. Im Ausland oder in einem fremden VPN? Die Webkonsole des Providers funktioniert weiterhin mit dem Root-Passwort, und
fw_ssh_geo_extranimmt jede Adresse auf, die du brauchst.
Vor dem Einschalten hat Claude das Template für alle drei Hosts gerendert und mit den laufenden Regelwerken verglichen: Ohne die Variable ist die Ausgabe byte-identisch mit vorher, die beiden Nextcloud-Server haben vom neuen Code also nicht einmal etwas mitbekommen. Das neue Regelwerk des Blogs lief durch nft -c (Prüfmodus) auf dem echten Host, bevor das Playbook überhaupt gestartet wurde. Weil der Blog der unkritischste der drei ist, war er als Erster dran.
📉 Nachher: Stille
Um zu zählen, was der Filter abweist, habe ich vorübergehend eine eigene Log-Regel für abgewiesene SSH-Versuche eingebaut (das normale Drop-Log ist auf zwei Zeilen pro Minute für alles begrenzt). Das erste Messfenster:
| Vorher (8 Tage) | Nachher (erste ~2 Stunden) | |
|---|---|---|
| Verbindungen bis zum SSH-Daemon | 16,3 pro Stunde | 0 |
| Vom Länderfilter abgewiesen | – | 41 Versuche von 9 IPs (China, USA, Hongkong, Macau, Mexiko, Kanada, Großbritannien) |
| Meine eigenen Logins aus Deutschland | funktionieren | funktionieren weiterhin |
Zwei Stunden sind ein kurzes Fenster, die genauen Zahlen also bitte mit Vorsicht genießen – aber null gegenüber sechzehn pro Stunde ist kein Rundungsfehler. Die temporäre Log-Regel ist wieder weg; der Filter bleibt.
👀 Bonus: Wer liest diesen Blog eigentlich?
Wo wir schon in den Logs steckten, wollte ich noch etwas anderes wissen: Ich habe in letzter Zeit viele neue Beiträge veröffentlicht – finden jetzt mehr Leute den Blog über Suchmaschinen? Dafür hat das Repo ein kleines Skript, scripts/analytics/visitor-stats.py. Es läuft direkt auf dem Server, weil Besucher-IPs nach DSGVO personenbezogene Daten sind und den Host nie verlassen; ausgegeben werden nur aggregierte, maskierte Zahlen. Diese Woche hat es gelernt, auch Besuche über Suchmaschinen zu zählen.
Zuerst das Ernüchternde: Von 18.084 Requests im Messzeitraum stammte die überwältigende Mehrheit von Maschinen. 81 % aller Seitenaufrufe kamen von Bots, die sich auch als solche ausgeben (Amazonbot, PetalBot, SemrushBot, PerplexityBot, AhrefsBot, Googlebot, ClaudeBot, …), und ein guter Teil des Rests waren Headless-Browser in Cloud-Rechenzentren, die eine Seite samt allen Assets in einer Sekunde laden und wieder verschwinden. Was nach dem Filtern übrig blieb:
| Kennzahl | Wert |
|---|---|
| Echte Besucher | ~17 pro Tag, ~120 pro Woche |
| Top-Länder | USA, China (vermutlich noch ein paar getarnte Scraper), Italien, Großbritannien, Niederlande, Schweiz, Deutschland, … |
| Über eine Suchmaschine gekommen | 13 von 33 Besuchern (Google 7, DuckDuckGo 4, Bing 2) |
| Am meisten gelesen | die Startseite, die Anleitung zu Samba unter Debian 13, der Beitrag zur iLO-Temperaturhistorie |
Eine ehrliche Einschränkung: K3s behält Container-Logs nur ein paar Tage, das hier ist also eine Momentaufnahme von zwei Tagen, kein Trend. Ob der Suchmaschinen-Traffic mit den neuen Beiträgen wächst, kann ich erst beantworten, wenn der Server eine tägliche, anonyme Strichliste führt – das ist der nächste Punkt auf der Liste. Und ja: Eine IP ist kein Mensch. Das sind Größenordnungen, keine Volkszählung.
📦 Zum Mitnehmen
Alles liegt im öffentlichen Repo github.com/aptupgrademe/www_k3s (Secrets bleiben natürlich lokal): Die Firewall-Rolle mit dem neuen Feature steckt in roles/common_firewall, die Besucherstatistik in scripts/analytics. Nützliche Befehle, sobald es läuft:
# is this address allowed to reach SSH?
nft get element inet filter ssh_geo4 { 203.0.113.7 }
# when does the next refresh run, and what did the last one do?
systemctl list-timers ssh-geo-update.timer
journalctl -u ssh-geo-update.service🎯 Fazit
Ein Nicht-Standard-Port versteckt SSH vor fast niemandem, und die reine Schlüssel-Anmeldung macht das Anklopfen harmlos, aber nicht leise. SSH auf das eine Land zu beschränken, aus dem ich tatsächlich arbeite, hat für 1,5 MB RAM praktisch das gesamte Rauschen beseitigt. Das Schwierige war nicht der Filter selbst, sondern sicherzustellen, dass eine leere Liste, ein Firewall-Reload, SELinux oder ein kaputter Download mich niemals aussperren können. Der Blog macht den Anfang; wenn er sich eine Weile benimmt, folgen die beiden Nextcloud-Server mit einer einzigen Zeile YAML.





