214 lines
13 KiB
Markdown
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.
|