Files
axion1337.chat-gitops/docs/deployment-guides/06-moderation-content-scanning.md
T

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