Files

214 lines
13 KiB
Markdown

# Moderation Bot & Content Scanning
**Status**: ✅ Draupnir deployed (2026-07-29, Closes Issue #18) | ✅ Content Scanner deployed + live getestet (2026-07-29, Closes Issue #19)
**Konfiguration**: `apps/production/draupnir*.yaml`, `apps/production/clamav*.yaml`, `apps/production/clamav_spam_checker.py`
## 1. Draupnir (Moderationsbot)
Community-Nachfolger von Mjolnir. Läuft als eigener Bot-Account (`@draupnir:axion1337.chat`),
verwaltet Ban-Listen ("Policy Rooms") und setzt sie in geschützten Räumen durch.
### Warum Draupnir statt Mjolnir?
Mjolnir gilt als Vorgänger-Projekt und wird von der Community nicht mehr aktiv weiterentwickelt;
Draupnir ist der aktive Fork mit denselben Kernfunktionen plus Erweiterungen (u.a. native
Rust-Crypto-Unterstützung, siehe unten).
### Bot-Account & Zugriff (Bootstrap)
Da Authentifizierung über MAS läuft (kein klassisches `registration_shared_secret`), wird der
Bot-Account über MAS' eigenes CLI-Tool angelegt:
```bash
kubectl exec -it -n matrix deploy/matrix-stack-matrix-authentication-service -- \
mas-cli manage register-user draupnir --yes
kubectl exec -it -n matrix deploy/matrix-stack-matrix-authentication-service -- \
mas-cli manage issue-compatibility-token draupnir
```
Der ausgegebene Token wird per `sops apps/production/draupnir-secret.yaml` manuell eingetragen
(kein automatisierter Schritt - der Token darf nirgends unverschlüsselt landen).
### Wichtige Stolpersteine (live gefunden, nicht aus der Doku ableitbar)
- **Version**: `gnuxie/draupnir:v2.9.0` crasht beim ersten Start mit `initialManager`
("Can't join remote room because no servers..."). Das automatische Anlegen des
Management-Rooms über `initialManager` funktioniert erst **ab v3.1.0**. Aktuell deployt:
`v3.1.0`.
- **CLI-Argument statt Env-Var**: v3.x hat die automatische Config-Erkennung über
`NODE_CONFIG_DIR` entfernt - der Container braucht jetzt explizit
`args: ["bot", "--draupnir-config", "/data/config/default.yaml"]`, sonst
`TypeError: No configuration path has been found for Draupnir.` (per Extraktion von
`dist/config.js` aus dem Image bestätigt, nicht dokumentiert gefunden).
- **NetworkPolicy**: Der Bot muss Synapse direkt anrufen können. Da `matrix-stack-synapse`
intern über haproxy geroutet wird und `allow-ingress-haproxy` standardmäßig nur Traefik
(`kube-system`) erlaubt, braucht Draupnir eine eigene `podSelector`-Ausnahme in
`networkpolicy.yaml` - sonst schlägt jede Anfrage an den Homeserver silent fehl.
### Verschlüsselter Management-Room
Standardmäßig unverschlüsselt (Draupnirs zugrundeliegende Bot-Library aktiviert Crypto nicht
automatisch). Für einen verschlüsselten Management-Room:
1. `experimentalRustCrypto: true` in der Config ergänzen (via `sops`) - vom Hersteller selbst
als "not considered production safe" gekennzeichnet, in unserem Test aber ohne Fehler
gelaufen (Pod stabil, kein Crash, `End-to-end encryption enabled` in den Logs).
2. Verschlüsselung ist eine Raum-Eigenschaft, die beim Erstellen gesetzt wird - das Flag allein
verschlüsselt einen bereits bestehenden Management-Room **nicht** rückwirkend. Dafür in
Element: Raumeinstellungen → Sicherheit & Datenschutz → Verschlüsselung aktivieren.
### Profilbild setzen
Erfordert eine `mxc://`-URL (Bild muss zuerst hochgeladen werden, z.B. per Chat an den Bot
senden, dann in Element per "View Source" die `mxc://`-URL kopieren):
```
!draupnir avatar mxc://<server>/<media-id>
```
### Befehle (Kurzreferenz)
Alle Befehle im (verschlüsselten) Management-Room, Präfix `!draupnir`:
| Befehl | Zweck |
|--------|-------|
| `status` | Bot-Status, beobachtete Listen, geschützte Räume |
| `rooms add <room>` | Raum unter Draupnirs Schutz stellen (Voraussetzung für Bans!) |
| `list create <shortcode> <alias>` | Neue Policy-Liste anlegen (wird automatisch beobachtet + geschützt) |
| `watch <shortcode>` | Zusätzliche Policy-Liste beobachten |
| `ban <user> <liste> <grund>` | **Wichtig**: 2. Argument ist die Policy-Liste, NICHT der Ziel-Raum! Der Ban gilt automatisch in allen Räumen, die diese Liste beobachten und geschützt sind |
| `kick <user> <room> <grund>` | Direkter, sofortiger Kick aus einem konkreten Raum (ohne Listen-Umweg) |
| `rules` | Zeigt die Regeln einer Policy-Liste an |
| `unban <user> <liste>` | Regel wieder entfernen |
**Live getestet** (2026-07-29): Testraum geschützt, Policy-Liste angelegt, Testnutzer über
`ban`+Liste erfolgreich aus dem geschützten Raum entfernt. Kernmechanismus bestätigt
funktionsfähig.
## 2. Content Scanner (Issue #19)
**Verworfener erster Ansatz**: `matrix-content-scanner-python` ist ein Proxy, den der
**Client** explizit statt der normalen Media-Endpunkte aufrufen muss - Synapse selbst leitet
nichts automatisch dorthin um. Diese client-seitige Unterstützung existiert nur noch in
veralteten, nicht mehr gepflegten Android/iOS-SDKs; weder aktuelles Element Web noch Element X
unterstützen das (geprüft: kein `content_scanner`-Hook im offenen `element-x-android`-Repo).
Element selbst hat echtes serverseitiges Scanning - aber nur in der kommerziellen
**Element Pro** + **ESS Pro**-Kombination, nicht in unserer offenen ESS-Community-Installation.
**Tatsächlich umgesetzt**: ein eigenes, kleines Synapse-Modul (`clamav_spam_checker.py`),
das Synapses echten, dokumentierten Hook `check_media_file_for_spam` nutzt - läuft
**serverseitig**, transparent für jeden Client, ganz ohne Mitwirkung des Clients. Kein
fertiges Modul dafür existiert (auch das verbreitete `synapse-http-antispam`-Brückenmodul
schließt genau diesen Callback explizit aus), daher selbst geschrieben.
**Architektur**:
- ClamAV (`clamav/clamav:1.5.3`) läuft als eigener Pod, PVC für die Signatur-Datenbank.
- Das Modul (`apps/production/clamav_spam_checker.py`) wird per ConfigMap gemounted und über
`PYTHONPATH` importierbar gemacht (`synapse.extraVolumes`/`extraVolumeMounts`/`extraEnv` -
kein Custom-Synapse-Image nötig).
- Spricht ClamAVs natives INSTREAM-Protokoll direkt über **Twisted**-Netzwerk-Primitives
(`HostnameEndpoint`/`connectProtocol`), nicht über `asyncio` - Synapse läuft auf Twisteds
Reactor, nicht auf einer laufenden asyncio-Event-Loop. Ein erster Versuch mit
`asyncio.open_connection`/`wait_for` schlug live mit `RuntimeError: no running event loop`
fehl und fiel dadurch (durch das eigene Fail-Open-Verhalten) unbemerkt auf "durchlassen"
zurück - die EICAR-Testdatei wurde beim ersten Versuch nicht erkannt. Nach Umstellung auf
Twisted-Primitives funktioniert es sauber.
- **Fail-open** bei Scanner-Fehlern (Verbindungsfehler/Timeout → Datei wird durchgelassen,
laut geloggt) - ein ClamAV-Ausfall soll nicht alle Uploads auf dem Homeserver blockieren.
**Live getestet und bestätigt** (2026-07-29):
- Normale Datei in unverschlüsseltem Raum → läuft durch (kein Regressionsschaden).
- EICAR-Testdatei in unverschlüsseltem Raum → zuverlässig blockiert
(`ClamAV rejected an upload: Eicar-Test-Signature`, Client bekommt `400 Bad content` -
Synapse gibt bewusst keine Begründung an den Client zurück, nur in den Server-Logs sichtbar).
- EICAR-Testdatei in verschlüsseltem Raum/DM → **läuft durch** - erwartete, strukturelle
Grenze: Synapse hat bei E2EE nie den Entschlüsselungsschlüssel, sieht nur Ciphertext. Nur
ein kooperierender Client könnte das lösen (siehe oben, existiert nicht offen verfügbar).
**Bekannte Deckungslücke (Stand vor der Client-Erweiterung unten)**: schützt nur
unverschlüsselte Räume/DMs - keine Warnung/Kennzeichnung für Nutzer in verschlüsselten
Räumen, dass dort kein Scanning stattfindet. Folgeidee (Issue #43, LOW): Grafana-Dashboard
über die bestehenden Loki-Logs, um Erkennungen/Scanner-Ausfälle sichtbar zu machen.
## 3. Client-seitiges Scanning für verschlüsselte Räume (Issue #19-Erweiterung, 2026-07-29)
Da Synapse bei E2EE-Räumen strukturell nie den Schlüssel hat, kann nur der **Client**
Klartext scannen - einmal beim Senden (vor der Verschlüsselung), einmal beim Empfangen
(nach der Entschlüsselung). Umgesetzt in `ThreadNet-Web` (Fork von Element Web).
### Architektur
Ein neuer, eigener HTTP-Dienst (`apps/production/clamav-http-scanner.py`, eigenes Image via
`clamav-http-scanner-Dockerfile`) macht denselben ClamAV-Pod für Browser-JS erreichbar
(clamd spricht nur rohes TCP, das kann ein Browser nicht). Erreichbar unter
`https://axion1337.chat/_scan`. Auth über Synapses eigenen
`/_matrix/client/v3/account/whoami`-Endpunkt (kein eigenes Auth-System nötig) - verhindert,
dass der Dienst zu einem offenen "teste dein Malware gegen unseren Virenscanner"-Orakel für
das ganze Internet wird. Fail-open bei Scanner-Fehlern, wie beim Synapse-Modul.
**Zwei Patch-Stellen im `ThreadNet-Web`-Fork** (im Repo
`rohana.axion1337.de/sorb/ThreadNet-Web.git`, nicht in diesem gitops-Repo):
- **Empfang**: `apps/web/src/utils/DecryptFile.ts`, Funktion `decryptFile()` - der einzige
Punkt im ganzen Client, an dem entschlüsselte Klartext-Bytes für *jeden* Anhangstyp
entstehen (Bild/Audio/Video/Datei laufen alle über `MediaEventHelper` hier durch). Scan
direkt nach dem Entschlüsseln, vor der Rückgabe als `Blob`.
- **Versand**: `apps/web/src/ContentMessages.ts`, Funktion `uploadFile()` - die eine
gemeinsame Funktion für alle Anhangs-Uploads (Hauptdatei, generierte Thumbnails,
Sprachnachrichten), unabhängig davon ob der Zielraum verschlüsselt ist. Scan direkt nach
dem Einlesen der Datei, vor Verschlüsselung/Upload.
- Gemeinsame Hilfsdatei: `apps/web/src/utils/ContentScanner.ts` (neue `scanContent()`-
Funktion + `ContentScanRejectedError`), von beiden Stellen genutzt. Fehlertexte über die
bereits bestehenden Error-Rendering-Pfade in `MImageBody.tsx`/`MAudioBody.tsx`/
`VideoBodyViewModel.ts`/`FileBodyViewModel.ts` (gleiches Muster wie die schon vorhandenen
`DecryptError`/`DownloadError`).
**Live getestet** (2026-07-29):
- EICAR in verschlüsseltem Gruppenraum ("testgruppe") und in 1:1-DMs zwischen zwei echten
Accounts - in beiden Fällen zuverlässig **vor dem Upload** blockiert. Vorher (nur
Synapse-Modul) lief das durch.
- Empfangsseite unabhängig vom Absender bestätigt: EICAR über einen echten, ungepatchten
Client (app.element.io) in denselben verschlüsselten Raum geschickt (simuliert einen
fremden/föderierten Absender ohne unseren Patch) - beim Download-/Anzeigeversuch im
gepatchten `ThreadNet-Web`-Client greift der Scanner zuverlässig. Beweist, dass der
Empfangs-Hook unabhängig vom sendenden Client funktioniert, nicht nur als Selbstschutz
für eigene Uploads.
### ⚠️ Wichtig für Desktop-/Electron-Builds (korrigiert, siehe Issue #44)
**Dieser Fix ist im Web-Client (Browser, das laufende `threadnet-web`-Container-Image)
bestätigt live wirksam. Ob er auch im Electron-Client wirkt, hängt am tatsächlichen
Build-Prozess - und der ist aktuell nicht automatisiert.**
Element Desktop (`apps/desktop` im selben Monorepo) baut die Web-App nicht selbst, sondern
packt ein fertiges `webapp`-Verzeichnis in ein `webapp.asar`. *Woher* dieses Verzeichnis
kommt, hängt vom Aufrufer ab:
- **Standard-Fallback** (`pnpm run fetch <version>` ohne Artefakt): lädt ein offiziell von
`element-hq/element-web` signiertes Release-Tarball herunter - **Upstream, ohne unsere
Patches**.
- **Mit eigenem Build** (`webapp-artifact`-Mechanismus in `build_desktop_prepare.yaml`,
gedacht für CI): würde unseren eigenen `apps/web`-Output übernehmen, **inklusive** aller
Fork-Anpassungen.
Der zweite Weg ist im Repo als GitHub-Actions-Pipeline (`build-and-test.yaml`) angelegt,
läuft aber **nicht automatisch** - kein registrierter Runner, und der vorgelagerte Build-Job
checkt zudem noch `element-hq/element-web` (Upstream) statt des eigenen Forks aus, ein Rest
der ursprünglichen Upstream-CI. Die bereits existierende Desktop-Build (mit der
Discord-Style-Raumliste) entstand nach aktuellem Stand aus einem **manuellen, lokalen**
Build-Durchlauf, nicht aus einem reproduzierbaren, automatisierten Prozess.
**Konsequenz für heute**: die Scan-Patches sind im `ThreadNet-Web`-Fork-Code enthalten und
würden in jedem zukünftigen (manuellen oder automatisierten) Desktop-Build aus diesem Fork
mitkommen - sie sind aber **nicht automatisch** in einer bereits existierenden
Desktop-Installation gelandet, ohne dass jemand den Build-Vorgang erneut manuell durchführt.
Neues Backlog-Item dafür angelegt:
[Issue #44](https://rohana.axion1337.de/sorb/axion1337.chat-gitops/issues/44) - Build-Job
auf den eigenen Fork umstellen + funktionierenden Runner aufsetzen, damit Fork-Änderungen
zuverlässig und automatisch auch im Desktop-Client landen.
**Element X (Mobile, iOS/Android)** ist davon komplett unberührt - eigene Codebasis auf
Basis von `matrix-rust-sdk`, kein gemeinsamer Code mit `ThreadNet-Web`. Ein Schutz dort
wäre ein separates, eigenständiges Projekt.