# 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 bestehende Wege — und es sind
zwei verschiedene: eine Kundeninstanz wird über den **Proxmox-Gastagenten**
angesprochen (`guestExec`, so wie jeder `occ`-Aufruf im Produkt), ein Host per
**SSH** durch den Tunnel.

### 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`, als **eigene Mailart** im Katalog, mit dem Zweck
**System** als Vorgabe — nach dem Muster von `NewDeviceSignInMail`. Nicht
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.)

Über die Wegwahl aus Abschnitt 3a kann der Betreiber sie jederzeit auf ein
anderes Postfach legen, ohne dass eine andere Mail mitwandert.

- **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.

---

## 3a. Aus welchem Postfach eine Mail geht — je Mailart einstellbar

**Warum das hier steht:** der Betreiber will die Meldungen dieses Systems aus
einem eigenen Postfach schicken (etwa `info@` oder später ein `alert@`), ohne
dass dafür jede andere Mail mitwandert. Heute geht das nicht: die Zuordnung
kennt nur **fünf Zwecke**, und alles, was denselben Zweck hat, geht aus
demselben Postfach.

### Was es schon gibt

- `Mailbox` — Postfächer mit Adresse, Anzeigename, Zugangsdaten. Fünf sind
  vorhanden: `no-reply`, `support`, `billing`, `office`, `info`.
- `MailPurpose` — fünf Zwecke, je einem Postfach zugeordnet (`Settings`).
- `MailPreviews::all()` — der Katalog **aller 16 Mailarten** mit lesbaren Namen,
  bisher nur für die Vorschau benutzt.

### Was dazukommt

Eine Ebene **über** den Zwecken, kein Ersatz für sie:

```
Settings: mail.route.<mailart>  →  Postfach-Schlüssel   (leer = wie der Zweck)
```

`SendsFromMailbox::mailboxEnvelope()` fragt zuerst die Wegwahl der Mailart und
fällt auf den Zweck zurück, wenn keine gesetzt ist. **Bestehendes Verhalten
ändert sich damit nicht** — ohne Eintrag geht alles genau wie heute.

Damit eine Mailart ihren eigenen Weg kennen kann, braucht sie einen Schlüssel.
Der Katalog in `MailPreviews` wird dafür zur **einen Liste**: er wandert in ein
Register (`MailCatalogue`), das Schlüssel, lesbaren Namen und Vorgabe-Zweck je
Mailart führt. Die Vorschau liest daraus, die Wegwahl liest daraus, und es gibt
keine zweite Liste, die man vergessen kann.

### Oberfläche

Auf der bestehenden Seite **Mail** in der Konsole eine Tabelle: eine Zeile je
Mailart, links der lesbare Name, rechts ein Auswahlfeld mit allen aktiven
Postfächern und der Vorgabe „wie der Zweck (Support)". Gespeichert wie die
Zwecke darüber.

### Was hier NICHT gebaut wird

**Postfächer anlegen, ändern und löschen.** Die Seite kann heute nur zuordnen
und testen; ein neues `alert@` lässt sich dort nicht erzeugen. Das ist ein
eigenes Projekt mit eigenen Fallstricken — ein Postfach zu löschen, auf das noch
drei Mailarten zeigen, darf nicht still geschehen. Bis dahin stehen die fünf
vorhandenen Postfächer zur Wahl, und das genügt für dieses System.

### Prüfungen

- Ohne Eintrag geht jede Mail aus demselben Postfach wie vorher.
- Mit Eintrag geht genau diese eine Mailart woanders hin, alle anderen nicht.
- Ein Eintrag, der auf ein gelöschtes oder abgeschaltetes Postfach zeigt, fällt
  auf den Zweck zurück, statt die Mail zu verlieren.
- Jede Mailart im Katalog hat einen Vorgabe-Zweck; keine steht ohne da.

## 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

**Ein Aufruf je Instanz und Minute** über den Proxmox-Gastagenten, dazu eine
SSH-Verbindung je Host. Bei der heutigen Größe ist das nichts. Bei hundert
Instanzen sind es hundert Aufrufe 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.
