11 KiB
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:
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.0crasht beim ersten Start mitinitialManager("Can't join remote room because no servers..."). Das automatische Anlegen des Management-Rooms überinitialManagerfunktioniert 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_DIRentfernt - der Container braucht jetzt explizitargs: ["bot", "--draupnir-config", "/data/config/default.yaml"], sonstTypeError: No configuration path has been found for Draupnir.(per Extraktion vondist/config.jsaus dem Image bestätigt, nicht dokumentiert gefunden). - NetworkPolicy: Der Bot muss Synapse direkt anrufen können. Da
matrix-stack-synapseintern über haproxy geroutet wird undallow-ingress-haproxystandardmäßig nur Traefik (kube-system) erlaubt, braucht Draupnir eine eigenepodSelector-Ausnahme innetworkpolicy.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:
experimentalRustCrypto: truein der Config ergänzen (viasops) - vom Hersteller selbst als "not considered production safe" gekennzeichnet, in unserem Test aber ohne Fehler gelaufen (Pod stabil, kein Crash,End-to-end encryption enabledin den Logs).- 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 überPYTHONPATHimportierbar gemacht (synapse.extraVolumes/extraVolumeMounts/extraEnv- kein Custom-Synapse-Image nötig). - Spricht ClamAVs natives INSTREAM-Protokoll direkt über Twisted-Netzwerk-Primitives
(
HostnameEndpoint/connectProtocol), nicht überasyncio- Synapse läuft auf Twisteds Reactor, nicht auf einer laufenden asyncio-Event-Loop. Ein erster Versuch mitasyncio.open_connection/wait_forschlug live mitRuntimeError: no running event loopfehl 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 bekommt400 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, FunktiondecryptFile()- der einzige Punkt im ganzen Client, an dem entschlüsselte Klartext-Bytes für jeden Anhangstyp entstehen (Bild/Audio/Video/Datei laufen alle überMediaEventHelperhier durch). Scan direkt nach dem Entschlüsseln, vor der Rückgabe alsBlob. - Versand:
apps/web/src/ContentMessages.ts, FunktionuploadFile()- 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(neuescanContent()- Funktion +ContentScanRejectedError), von beiden Stellen genutzt. Fehlertexte über die bereits bestehenden Error-Rendering-Pfade inMImageBody.tsx/MAudioBody.tsx/VideoBodyViewModel.ts/FileBodyViewModel.ts(gleiches Muster wie die schon vorhandenenDecryptError/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.
⚠️ Wichtig für zukünftige Desktop-/Electron-Forks
Dieser Fix gilt nur für den Web-Client (Browser), nicht automatisch für Element Desktop.
Element Desktop (apps/desktop im selben Monorepo) ist ein Electron-Wrapper, der die
Web-App nicht selbst baut, sondern per scripts/fetch-package.ts ein fertiges,
offiziell von element-hq/element-web signiertes Release-Tarball herunterlädt und in
ein webapp.asar packt (pnpm run fetch → pnpm run asar-webapp). Ohne weitere Änderung
würde ein Desktop-Build also die unveränderte Upstream-Version bündeln - unsere Patches
wären nicht drin, obwohl sie im selben Monorepo liegen.
Für einen zukünftigen Desktop-Fork mit dieser Funktion: fetch-package.ts akzeptiert
bereits eine beliebige URL statt einer Versionsnummer als Override
(targetVersion.includes("://")-Zweig, keine Code-Änderung nötig) - man müsste nur unseren
eigenen apps/web-Build als .tar.gz irgendwo selbst hosten (z.B. als Gitea-Release-Asset)
und pnpm run fetch https://.../unser-build.tar.gz statt der Standard-Versionsnummer
aufrufen.
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.