Spec: Fruehwarnsystem gegen Anmeldeversuche

Entworfen mit dem Betreiber. Vier Entscheidungen stehen fest: gesperrt wird die
ADRESSE des Angreifers (nicht das Konto, nicht die Instanz), ab 10 Fehlversuchen
in 10 Minuten, fuer eine Stunde mit Verdopplung bei Wiederholung, und aufgehoben
wird von dem, den es angeht — der Inhaber im Portal, der Betreiber in der
Konsole.

Der Kern des Entwurfs ist eine nftables-Menge mit Ablaufzeit, deren Regel UNTER
'ct state established,related accept' steht. Damit ist 'wer drin ist, bleibt
drin' eine Eigenschaft des Netzes und kein Versprechen in unserem Code. Und die
Stunde laeuft im Kernel ab — eine Sperre kann nicht liegenbleiben, weil eine
Warteschlange klemmt.

Ehrlich benannt: auf einem fertig uebernommenen Host gibt es kein oeffentliches
SSH mehr, der Host-Melder greift also enger als sein Name klingt. Und eine
SSH-Verbindung je Instanz und Minute traegt heute, aber nicht bei hundert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/versandtakt
nexxo 2026-08-03 12:12:38 +02:00
parent 4001a38411
commit 9deb5eca60
1 changed files with 256 additions and 0 deletions

View File

@ -0,0 +1,256 @@
# Frühwarnsystem: Angriffsversuche erkennen und die Adresse sperren
**Stand:** 2026-08-03 · entworfen mit dem Betreiber
## Das Problem
Heute merkt niemand, wenn jemand an einer Kundeninstanz Passwörter durchprobiert.
Nextcloud bremst zwar von sich aus, aber es sagt es keinem, es hört nie auf zu
antworten, und die Adresse darf es weiter versuchen — Monate lang, wenn es sein
muss. Auf der Host-Seite ist es dasselbe: der Betreiber erfährt von einem
Anlauf gar nichts.
Gebaut wird deshalb dreierlei, nicht mehr: **erkennen**, **die Adresse aus dem
Netz nehmen**, **den Betroffenen benachrichtigen und ihm den Schlüssel zum
Aufsperren geben**.
## Was ausdrücklich NICHT gebaut wird
- Kein Sperren von Benutzerkonten. Ein Angreifer, der Kontonamen durchprobiert,
könnte sonst der Reihe nach die ganze Firma aussperren.
- Keine dauerhafte Sperre. Jede läuft ab. Ein System, das jemanden für immer
aussperrt, sperrt irgendwann den Falschen für immer aus.
- Keine Software im Gast. Kein Agent, kein fail2ban, nichts, was mitinstalliert
und mitgepflegt werden müsste.
- Kein Eingriff in laufende Sitzungen. Niemand wird ausgeloggt.
## Die Entscheidungen des Betreibers
| Frage | Entscheidung |
|---|---|
| Was wird gesperrt? | **Die Adresse des Angreifers** — nicht das Konto, nicht die Instanz |
| Ab wann? | **10 Fehlversuche in 10 Minuten** von derselben Adresse |
| Wie lange? | **1 Stunde**, danach löst sie sich von selbst. Wiederholung verdoppelt (max. 24 h) |
| Wer erfährt es? | Instanz: **Mail an den Inhaber**, Eintrag für den Betreiber (ohne Mail). Host: **Mail an den Betreiber** |
| Wer hebt auf? | Instanz: der **Inhaber** im Portal — und der Betreiber in der Konsole. Host: nur der Betreiber |
Mitarbeiter kommen nicht ins Portal; sie melden sich ausschließlich an der
Nextcloud an. Sie sehen von alldem nichts.
---
## 1. Erkennen
Zwei Quellen, beide nur **gelesen**, beide über den bestehenden SSH-Weg.
### Kundeninstanz — die Nextcloud-Protokolldatei
Nextcloud schreibt jede gescheiterte Anmeldung als JSON-Zeile mit `remoteAddr`
und `time`. Gelesen wird ab einem gemerkten Byte-Versatz, damit jeder Lauf nur
das Neue sieht:
```
tail -c +<offset> data/nextcloud.log
```
Der Versatz steht als `security_log_offset` (bigint) an der Instanz. Ist die
Datei **kleiner** als der Versatz, wurde sie rotiert — dann wird bei 0 begonnen.
Gezählt werden nur Zeilen, deren `message` mit `Login failed:` beginnt.
*Warum die Datei und nicht Nextclouds eigene Sperrtabelle:* das Protokollformat
ist über Versionen hinweg stabil, `occ security:bruteforce:*` ist es nicht — die
Unterbefehle kamen und wechselten über die letzten Hauptversionen.
### Host — die fehlgeschlagenen SSH-Anmeldungen
```
journalctl -u ssh -u sshd --since '<zuletzt gesehen>' -o cat
```
Gezählt werden `Failed password` und `Invalid user`, die Adresse steht in
derselben Zeile. Der Zeitpunkt steht als `security_log_seen_at` am Host.
**Ehrlich zur Reichweite:** auf einem fertig übernommenen Host ist Port 22 nur
noch aus dem Tunnel erreichbar (`SecureHostFirewall`). Von außen kann dort
niemand probieren. Dieser Melder greift für genau drei Fälle: **während der
Übernahme**, **nachdem jemand das Notfallskript benutzt hat**, und für alles,
was über 80/443 hereinkommt. Das ist weniger, als „Host-Angriffe erkennen"
klingt — es steht hier, damit es später niemanden überrascht.
### Zeitfenster und Schwelle
Ein gleitendes Fenster von **10 Minuten**. Ab **10 Fehlversuchen** derselben
Adresse wird gesperrt. Die Zählung passiert in der Anwendung, nicht im Gast:
was gelesen wurde, wandert in eine kurzlebige Liste je Subjekt und Adresse.
### Wer NIEMALS gesperrt wird
Eine Sperrliste, die sich selbst aussperren kann, ist eine Falle. Ausgenommen
sind daher, hart und ohne Schalter:
- das WireGuard-Verwaltungsnetz (`10.66.0.0/24`) — **darüber erreicht CluPilot
den Host überhaupt**
- `127.0.0.1` und `::1`
- die öffentliche Adresse des CluPilot-Servers selbst
## 2. Sperren
Nicht in Nextcloud, sondern in der **Host-Firewall**. `SecureHostFirewall`
bekommt zwei benannte Mengen mit Ablaufzeit und eine Regel:
```
set clupilot_blocked { type ipv4_addr; flags timeout; }
set clupilot_blocked6 { type ipv6_addr; flags timeout; }
chain input {
ct state established,related accept
ip saddr @clupilot_blocked drop
ip6 saddr @clupilot_blocked6 drop
}
```
**Die Reihenfolge ist die eigentliche Zusage.** Die Sperrregel steht *unter*
`ct state established,related accept`. Wer schon verbunden ist, bleibt
verbunden — auf Paketebene, nicht als Versprechen in unserem Code. Genau das
war die Anforderung: „die bereits drinnen sind, bleiben angemeldet und merken
davon nichts."
Sperren und Aufheben sind je ein Befehl:
```
nft add element inet clupilot_filter clupilot_blocked { 203.0.113.7 timeout 3600s }
nft delete element inet clupilot_filter clupilot_blocked { 203.0.113.7 }
```
Die Ablaufzeit verwaltet **der Kernel**. Die Stunde läuft also auch dann ab,
wenn CluPilot gerade steht — eine Sperre kann nicht liegenbleiben, weil eine
Warteschlange klemmt.
**Nach einem Neustart des Hosts** ist die Menge leer (sie lebt im Speicher).
Derselbe Zeitplan, der erkennt, trägt bei jedem Lauf die noch nicht abgelaufenen
Sperren erneut ein — mit der **Restlaufzeit**, nicht der ursprünglichen. Das ist
idempotent und braucht keinen eigenen Mechanismus.
### Verdopplung bei Wiederholung
Kommt dieselbe Adresse am selben Subjekt innerhalb von **24 Stunden** erneut
über die Schwelle, verdoppelt sich die Dauer: 1 h → 2 h → 4 h → 8 h → 16 h →
**24 h als Obergrenze**. Der Zähler steht als `strikes` am Datensatz.
## 3. Wer es erfährt und wer aufsperrt
### Datenmodell
`security_blocks`:
| Spalte | Bedeutung |
|---|---|
| `uuid` | für Adressen in der Oberfläche |
| `host_id` / `instance_id` | genau **eines** von beiden gesetzt |
| `ip` | die gesperrte Adresse |
| `reason` | `instance_login` oder `host_ssh` |
| `attempts` | wie viele Fehlversuche die Sperre ausgelöst haben |
| `strikes` | die wievielte Sperre dieser Adresse an diesem Subjekt |
| `blocked_at`, `expires_at` | wann, und bis wann |
| `released_at`, `released_by` | wer sie vorzeitig aufgehoben hat, wenn jemand |
Cursor: `instances.security_log_offset` (bigint), `hosts.security_log_seen_at`.
### Benachrichtigung
`SecurityBlockMail`, aus dem **System**-Postfach — nach dem Muster von
`NewDeviceSignInMail`. Nicht aus Support: das ist kein Gespräch, und eine
Antwort darauf soll kein Ticket öffnen. (Nebenbei umgeht das auch das bekannte
Problem des Support-Postfachs, dem der SMTP-Benutzername fehlt.)
- **Instanz gesperrt** → an die Kontoadresse des Inhabers, mit Link auf die
Sperrliste im Portal.
- **Host gesperrt** → an den Betreiber.
- **Instanz gesperrt, aus Sicht des Betreibers** → kein Mail, nur der Eintrag
an der Instanz. So wollte es der Betreiber ausdrücklich.
**Höchstens eine Mail je Instanz und Stunde.** Ein Angreifer, der Adressen
durchwechselt, erzeugt sonst zwanzig Mails, und die zwanzigste liest niemand
mehr. Die weiteren Sperren stehen vollständig in der Liste.
### Portal (Inhaber)
Eigene Seite **Sicherheit** mit der Liste: Adresse, wann, wie viele Versuche,
wann sie von selbst abläuft. Je Zeile ein Knopf **Sperre aufheben** — der ein
Bestätigungs-Modal öffnet (R23), nicht direkt handelt.
Der Inhaber sieht **nur die Sperren seiner eigenen Instanzen**. Nie die eines
anderen Kunden, nie die des Hosts.
### Konsole (Betreiber)
- Auf der **Instanz-Detailseite** ein Abschnitt mit denselben Zeilen und
demselben Knopf.
- Auf der **Host-Detailseite** dasselbe für Host-Sperren.
- Auf der **Übersicht** ein Hinweis, solange irgendwo eine Sperre aktiv ist.
## 4. Wann es läuft
Ein Auftrag `ScanForIntrusions`, jede Minute über den Zeitplan, auf der
**Provisionierungs-Warteschlange** — nur die steht im Tunnel und erreicht Hosts
und Gäste.
Je Lauf: über alle aktiven Hosts und alle aktiven Instanzen; lesen, zählen,
sperren, wieder eintragen was noch gilt.
## 5. Fehlerbehandlung
- **Ein Gast antwortet nicht.** Übersprungen, Versatz unverändert, keine
Meldung. Ein Gast, der gerade neu startet, ist kein Vorfall.
- **Ein `nft`-Aufruf scheitert.** Der Datensatz wird trotzdem angelegt und beim
nächsten Lauf erneut versucht — das Wiedereintragen für den Neustartfall
erledigt das ohnehin. Eine Sperre, die nur in der Datenbank steht, ist
sichtbar; eine, die nur in der Firewall steht, wäre unsichtbar.
- **Das Protokoll ist unlesbar oder leer.** Kein Fehler. Kein Zählstand, keine
Sperre.
- **Die Mail scheitert.** Die Sperre bleibt. Zustellung ist nicht die Bedingung
für Schutz.
## 6. Prüfungen
- Zehn Fehlversuche im Fenster erzeugen eine Sperre; neun nicht.
- Zehn Fehlversuche über zwei Fenster verteilt erzeugen keine.
- Eine Adresse aus dem Verwaltungsnetz wird **nie** gesperrt — auch nicht bei
hundert Versuchen.
- Wiederholung verdoppelt, und die Obergrenze von 24 Stunden hält.
- Der erzeugte `nft`-Aufruf enthält die **Restlaufzeit**, nicht die
ursprüngliche Dauer.
- Die Sperrregel steht im erzeugten Regelwerk **unter**
`ct state established,related accept` — als Test über die gerenderte
Konfiguration, nicht über den Quelltext.
- Ein Inhaber sieht die Sperren eines fremden Kunden nicht und kann sie nicht
aufheben (403).
- Höchstens eine Mail je Instanz und Stunde.
- Ein rotiertes Protokoll (Datei kleiner als der Versatz) beginnt bei 0, statt
ins Leere zu lesen.
## 7. Bekannte Grenzen
**Eine SSH-Verbindung je Instanz und Minute.** Bei der heutigen Größe ist das
nichts. Bei hundert Instanzen sind es hundert Verbindungen pro Minute — dann
braucht es einen anderen Weg (ein Melder im Gast, der meldet, statt gefragt zu
werden). Das steht hier als bekannte Grenze, nicht als Überraschung für später.
**Geteilte Adressen.** Hinter einer Adresse können unbeteiligte Menschen
sitzen — Büro-NAT, Mobilfunk. Sie kommen dann eine Stunde lang nicht neu
hinein. Abgefedert durch: die kurze Dauer, den Knopf des Inhabers, und dass
Angemeldete unberührt bleiben.
**Kein Schutz vor langsamem Durchprobieren.** Wer neun Versuche pro zehn
Minuten macht, läuft unter der Schwelle durch. Das ist der Preis dafür, dass
ein vertippter Mitarbeiter nicht ausgesperrt wird.
## 8. Was danach kommt (nicht Teil dieser Spec)
Die Mitarbeiterverwaltung im Portal — Konten anlegen mit Einladungsmail, Rechte
setzen — ist ein **eigenes Projekt** mit eigener Spec. Sie hängt nicht an
diesem hier und dieses nicht an ihr. Offen ist dort vor allem die Frage, wie ein
Mitarbeiter an sein Passwort kommt, ohne dass es durch CluPilots Hände geht.