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