munin/README.md
2026-10-19 10:13:56 +02:00

858 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Munin
Ein projektbezogenes Gedächtnis für KI-Agenten und Menschen.
> In der nordischen Überlieferung sind Hugin und Munin die Raben Odins. Munin
> steht für das Gedächtnis: Er fliegt aus, sammelt und kehrt zurück, um zu
> berichten — was hier die Agenten tun. Passend zum Torwächter **Heimdall**,
> unserem AI-Gateway.
KI-Werkzeuge (Claude, Cline, OpenCode, eigene Anwendungen) legen darin über
**MCP** ab, was sie lernen, und rufen es wieder auf. Menschen pflegen dasselbe
Wissen über eine schlanke Weboberfläche. Beides landet im selben Speicher — der
Unterschied ist ein Feld am Eintrag, kein zweites System.
```
KI-Werkzeug ──MCP──┐
├──▶ Workspace ──▶ Projekt ──▶ Einträge
Browser ──HTTP─────┘ (eine SQLite-Datei je Workspace)
```
---
## Inhalt
- [Schnellstart](#schnellstart)
- [Bauen](#bauen)
- [Grundbegriffe](#grundbegriffe)
- [MCP einrichten](#mcp-einrichten)
- [Die Werkzeuge](#die-werkzeuge)
- [Weboberfläche](#weboberfläche)
- [Konfiguration](#konfiguration)
- [Sicherheit](#sicherheit)
- [Betrieb](#betrieb)
- [Entwicklung](#entwicklung)
---
## Schnellstart
```bash
# 1. Konfiguration anlegen
cp .env.example .env
# 2. Geheimnisse erzeugen und eintragen
openssl rand -hex 32 # → SESSION_SECRET
openssl rand -hex 24 # → ADMIN_TOKEN
# 3. Starten
go run .
```
Dann `http://localhost:8080` öffnen und mit dem `ADMIN_TOKEN` anmelden.
Der weitere Weg:
1. **Workspace wählen** — nach der Anmeldung landet der Plattform-Admin unter
*Workspaces*. Beim ersten Start legt der Server den Workspace „Intern“ selbst
an; weitere (etwa einer je Abteilung) lassen sich dort anlegen.
2. **Projekt anlegen** unter *Projekte*
3. **Zugang erzeugen** — der Key wird genau einmal angezeigt
4. **MCP-Client einrichten** (die Anleitung erscheint direkt neben dem neuen Key)
---
## Bauen
`go run .` aus dem Schnellstart übersetzt bei jedem Start neu. Für den Betrieb
baut man stattdessen einmal ein **Binary**: eine einzelne ausführbare Datei, die
alles enthält. Vorlagen, CSS, JavaScript und Hilfeseiten sind per `//go:embed`
eingebettet; neben dem Binary braucht es nur die Konfiguration.
### Voraussetzungen
- **Go 1.26** oder neuer (`go version` prüft das). Eine ältere Go-Fassung ab 1.21
lädt die passende beim ersten Bauen selbst nach, das steht in `go.mod`.
- **Kein C-Compiler.** Der SQLite-Treiber (`modernc.org/sqlite`) ist in reinem
Go geschrieben.
Die Abhängigkeiten lädt `go build` beim ersten Mal selbst herunter.
### Für den eigenen Rechner
```bash
go build -o munin .
./munin
```
Das ergibt `munin` im Projektverzeichnis, gut 20 MB groß. Es steht in
`.gitignore` und landet nicht im Repository. Unter Windows `-o munin.exe`
schreiben — `-o` übernimmt den Namen genau so, ohne die Endung zu ergänzen.
### Für den Betrieb
So baut auch das Dockerfile:
```bash
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o munin .
```
| Schalter | Wirkung |
|---|---|
| `CGO_ENABLED=0` | reines Go, ein statisches Binary ohne Abhängigkeit von Systembibliotheken |
| `-trimpath` | entfernt die Pfade der Baumaschine aus dem Binary |
| `-ldflags="-s -w"` | lässt Debug-Symbole weg, das Binary schrumpft auf etwa 16 MB |
### Für ein anderes Betriebssystem
Go übersetzt ohne weitere Werkzeuge für andere Systeme. `GOOS` und `GOARCH`
geben das Ziel an, etwa vom Mac aus für einen Linux-Server:
```bash
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o munin-linux-amd64 .
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w" -o munin-linux-arm64 .
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o munin.exe .
```
Als Container: `docker build -t munin .` (siehe [Docker](#docker)).
### Starten und wo die Daten landen
```bash
./munin
```
Zwei Dinge hängen am **aktuellen Verzeichnis**, nicht am Ort des Binarys:
- **`.env`** wird von dort gelesen. Fehlt sie, müssen `SESSION_SECRET` und
`ADMIN_TOKEN` als Umgebungsvariablen gesetzt sein, sonst startet der Server
nicht.
- **`DATA_DIR`** ist standardmäßig `data`, also ebenfalls relativ zum aktuellen
Verzeichnis. Wer das Binary aus einem anderen Verzeichnis startet, bekommt
dort einen neuen, leeren Bestand.
Für den Betrieb deshalb einen absoluten Pfad setzen:
```bash
DATA_DIR=/srv/munin/data ./munin
```
Darin legt der Server beim Start an:
```
data/
├── control.db Workspaces, Zuordnung Key → Workspace, Workspace-Admins
└── workspaces/
└── <id>.db je Workspace: Projekte, Einträge, Keys, Protokolle, Papierkorb
```
Neben jeder Datenbank liegen im Betrieb `…-wal` und `…-shm`. Sie gehören dazu
und dürfen nicht einzeln gelöscht werden. Welche Pfade der Server benutzt,
steht in der Startzeile des Logs (`Workspaces: …`).
## Grundbegriffe
| Begriff | Bedeutung |
|---|---|
| **Workspace** | Ein Bereich für eine Abteilung oder ein Team, mit einer eigenen Datenbankdatei. Enthält beliebig viele Projekte. Workspaces sind strukturell getrennt: Keine Abfrage kann über eine Workspace-Grenze hinweg lesen, weil die Daten nicht in derselben Datei liegen. |
| **Projekt** | Die Einheit der Abschottung innerhalb eines Workspace. Eigene Einträge, eigene Zugänge, eigener MCP-Server. Projekte sehen sich gegenseitig nicht. |
| **Eintrag** | Ein Stück Wissen: Bezeichner, Titel, Markdown-Inhalt, Schlagworte. Von Menschen oder Agenten geschrieben. |
| **Bezeichner** (slug) | Kurzer, dauerhafter Name eines Eintrags (`deployment-prozess`). Der Schlüssel gegen Dubletten: Zweimal derselbe Bezeichner aktualisiert, statt eine zweite Fassung danebenzustellen. |
| **API-Key** | Zugang zu genau einem Projekt, mit Namen und Rolle. Wird nur als Prüfsumme gespeichert. |
| **Plattform-Admin** | Der `ADMIN_TOKEN`. Legt Workspaces und Projekte an und kommt an jedes Projekt. Arbeitet immer in genau einem gewählten Workspace und wechselt über den Umschalter in der Kopfleiste. |
| **Fassung** (version) | Zählt bei jeder Änderung eines Eintrags hoch. Die abgelösten Fassungen bleiben erhalten und lassen sich in der Weboberfläche vergleichen. |
### Warum Bezeichner statt Ordner
Ein Wiki gliedert nach Ordnern. Für ein Gedächtnis, in das Agenten schreiben,
funktioniert das nicht: Wo eine Erkenntnis „hingehört", ist selten eindeutig, und
ein Agent trifft diese Entscheidung schlecht.
Stattdessen gibt es **Schlagworte** zum Einordnen und **Verweise** zum
Verknüpfen. Im Inhalt zeigt `[[bezeichner]]` auf einen anderen Eintrag; die
Gegenrichtung („was verweist hierher") entsteht daraus von selbst und steht in
der Detailansicht wie auch in der MCP-Antwort.
---
## MCP einrichten
Nach dem Erzeugen eines Keys zeigt die Weboberfläche den passenden Abschnitt an.
Der Key bestimmt Workspace und Projekt — ein zusätzlicher Parameter ist nicht nötig. Wer mit
mehreren Projekten arbeitet, trägt mehrere Server mit je eigenem Key ein.
Alle Clients sprechen denselben Endpunkt `…/mcp` über Streamable HTTP an. Die
Konfigurationsdateien sehen trotzdem unterschiedlich aus:
| Client | Datei | Schlüssel | Transport heißt dort |
|---|---|---|---|
| Claude Code | `.mcp.json` / `~/.claude.json` | `mcpServers` | `"type": "http"` |
| Cline | `cline_mcp_settings.json` / `~/.cline/mcp.json` | `mcpServers` | `"type": "streamableHttp"` |
| OpenCode | `opencode.json` | `mcp` | `"type": "remote"` |
Die beiden ersten Zeilen sind die Falle: gleicher Schlüssel, anderer Wert für
`type`. Ein aus Claude Code kopierter Block läuft in Cline nicht.
### Claude Code
Am schnellsten über die Kommandozeile:
```bash
claude mcp add --transport http munin-plattform https://munin.intern/mcp \
--header "Authorization: Bearer wk_…"
```
Ohne `--scope` landet der Eintrag in `~/.claude.json` und gilt nur im aktuellen
Projekt; `--scope user` macht ihn überall verfügbar.
Fürs Team gehört er stattdessen als `.mcp.json` ins Repository — dann aber
**ohne** den Key im Klartext. Claude Code löst `${VAR}` aus der Umgebung auf:
```json
{
"mcpServers": {
"munin-plattform": {
"type": "http",
"url": "https://munin.intern/mcp",
"headers": { "Authorization": "Bearer ${MUNIN_KEY}" }
}
}
}
```
Jede Person setzt `MUNIN_KEY` auf ihren eigenen Key. Damit steht im Repository die
Einrichtung, nicht das Geheimnis — und im Protokoll steht trotzdem, wer geschrieben
hat.
### Cline
Über die Oberfläche: *MCP Servers* → *Configure* → *Configure MCP Servers*. In der
Kommandozeilenfassung liegt dieselbe Datei unter `~/.cline/mcp.json`.
```json
{
"mcpServers": {
"munin-plattform": {
"type": "streamableHttp",
"url": "https://munin.intern/mcp",
"headers": { "Authorization": "Bearer wk_…" },
"disabled": false,
"autoApprove": ["recall", "get", "list", "related"]
}
}
}
```
`autoApprove` nennt die Werkzeuge, die ohne Rückfrage laufen dürfen. Die vier
lesenden gehören dort hinein — sonst muss jede Suche einzeln bestätigt werden, und
ein Agent, der für jeden Blick ins Gedächtnis nachfragen muss, benutzt es nicht.
`remember` und `forget` gehören ausdrücklich **nicht** hinein: Sie verändern
gemeinsames Wissen.
### OpenCode
`opencode.json` im Projektverzeichnis oder global unter
`~/.config/opencode/opencode.json`. Anderes Schema als bei den beiden anderen —
der Block heißt `mcp`, nicht `mcpServers`:
```json
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"munin-plattform": {
"type": "remote",
"url": "https://munin.intern/mcp",
"enabled": true,
"headers": { "Authorization": "Bearer {env:MUNIN_KEY}" }
}
}
}
```
OpenCode ersetzt `{env:VAR}` aus der Umgebung und `{file:~/pfad}` durch den Inhalt
einer Datei. Ein nicht gesetztes `{env:…}` wird zur leeren Zeichenkette — der
Server antwortet dann mit `401`, nicht mit einer Fehlermeldung über die
Konfiguration.
### Danach
**Ein Key je Person und je Agent.** Nur so steht im Protokoll, wer etwas
geschrieben hat, und ein einzelner Zugang lässt sich zurückziehen, ohne alle
anderen zu stören.
Verbindung prüfen: Der Agent sollte `recall`, `get`, `list`, `related`,
`remember` und `forget` anbieten. Tut er das nicht, hilft ein Blick ins Zugriffsprotokoll des
Servers — ein `401` dort heißt falscher oder zurückgezogener Key, gar kein Eintrag
heißt, dass der Client den Server nie aufgerufen hat.
Wie man Agenten so anspricht, dass sie den Speicher auch nutzen:
[AGENTEN-GUIDE.md](AGENTEN-GUIDE.md).
---
## Die Werkzeuge
| Werkzeug | Rolle | Zweck |
|---|---|---|
| `recall` | read | Durchsucht den Speicher, auf Wunsch mit `synonyms`. Der Haupteinstieg — auch vor dem Schreiben. |
| `get` | read | Lädt einen Eintrag vollständig, samt Verweisen und Fassungsnummer. |
| `list` | read | Überblick: zuletzt geändert, alle Schlagworte. |
| `related` | read | Über `[[Verweise]]` verknüpfte Einträge, direkt und über einen Zwischeneintrag. |
| `remember` | write | Legt einen Eintrag an oder aktualisiert ihn. Fragt bei ähnlichen und bei kuratierten Einträgen nach (siehe unten). |
| `forget` | write | Legt einen Eintrag in den Papierkorb (30 Tage wiederherstellbar, nur durch Menschen). |
Der Server schickt jedem Client eine **Anleitung** (`instructions`) mit, die das
Vorgehen beschreibt — vor allem: *vor* dem Schreiben erst `recall` aufrufen. Das
ist das wirksamste Mittel gegen Dubletten und steht in
`internal/mcpserver/server.go`.
### Schreibbudget
`remember` und `forget` sind je Projekt auf **1.000 Aufrufe pro Stunde** gedeckelt
(`WRITE_LIMIT_HOURLY`). Lesen ist nie begrenzt.
Der Grund ist die Kehrseite einer anderen Entscheidung: Ein gültiger Key umgeht
die IP-Sperre bewusst, weil Deploy-Skript, MCP-Client und Browser eines Büros
sich nach außen oft eine Adresse teilen. Damit hätte ein Agent, der in einer
Schleife hängt, sonst überhaupt keine Grenze.
Das Budget hängt am **Projekt**, nicht am Key: Ein Limit je Key ließe sich
umgehen, indem man einen zweiten Key anlegt, und das darf jeder Projekt-Admin
selbst. Einzelne Projekte bekommen über *Projekte → Bearbeiten* eine eigene
Obergrenze; die Übersicht zeigt, wie viel im laufenden Fenster noch frei ist.
Ändern darf sie nur der Plattform-Admin — aus demselben Grund.
Ist das Budget erschöpft, bekommt das Modell keinen nackten Fehler, sondern die
Anweisung, abzubrechen und das Limit zu melden — sonst versucht es sofort wieder
oder weicht auf ein anderes Werkzeug aus.
### Rückfragen statt Schreiben
`remember` schreibt in zwei Fällen nicht sofort, sondern antwortet mit
`judgment_required` — als Antwort, nicht als Fehler, damit das Modell sie liest
und entscheidet:
| Fall | Antwort | Trotzdem schreiben mit |
|---|---|---|
| Neuer Bezeichner, aber ähnlicher Titel vorhanden | `candidates` (bis zu fünf) | `force: true` |
| Eintrag ist kuratiert | Erklärung | `override_curated: true` und `reason` |
Im zweiten Fall fällt der Haken „kuratiert" weg, das Protokoll vermerkt die
Begründung, und die Nutzungsseite zeigt den Eintrag unter *Zur Prüfung*, bis ein
Mensch ihn wieder kuratiert. Einzelheiten: `internal/duplicates` und
`internal/mcpserver/curated.go`. Jeder Kandidat nennt die gemeinsamen Wörter
(`matched_words`).
### Gleichzeitiges Schreiben
`get` liefert eine `version`. Wird sie bei `remember` als `expected_version`
mitgegeben, meldet der Server einen Konflikt, falls jemand anderes den Eintrag
inzwischen geändert hat — statt die fremde Änderung stillschweigend zu
überschreiben.
```
Agent A: get(deployment) → version 3
Agent B: get(deployment) → version 3
Agent A: remember(…, expected_version=3) → OK, jetzt version 4
Agent B: remember(…, expected_version=3) → Konflikt: neu laden, zusammenführen
```
---
## Weboberfläche
| Weg | Zweck |
|---|---|
| `/login` | Anmeldung mit API-Key oder Admin-Token |
| `/admin/workspaces` | Workspaces auflisten, anlegen und wählen (nur Plattform-Admin) |
| `/admin/workspaces/{id}/edit` | Workspace umbenennen oder löschen |
| `/admin/projects` | Projekte des gewählten Workspace anlegen und auflisten, mit Schreibvorgängen der letzten 30 Tage je Projekt; Markdown-Export anstoßen |
| `/admin/export.zip` | Alle Projekte des gewählten Workspace als ZIP herunterladen (Plattform- und Workspace-Admin) |
| `/admin/projects/{projekt}/edit` | Name, Beschreibung und Schreibbudget ändern; Projekt löschen |
| `/admin/audit` | Protokoll über alle Projekte des gewählten Workspace, filterbar nach Vorgang, Zugang, Projekt und Zeitraum |
| `/admin/search` | Suche in allen Projekten des gewählten Workspace, nach Projekt gruppiert (Plattform- und Workspace-Admin) |
| `/admin/overview` | Überblick des Workspace: was zu tun ist (je Projekt mit Link), Projekte, Aktivität der letzten 30 Tage (Workspace- und Plattform-Admin) |
| `/admin/system` | Zustand (was zu tun ist), Aktivität der letzten 30 Tage, Bestand und Platz je Workspace, Speicher, Bereitschaft, offene Datenbankverbindungen gegen das Budget, Fassung und Laufzeit (nur Plattform-Admin) |
| `/p/{projekt}/` | Einträge durchsuchen, nach Schlagwort filtern, veraltete zeigen (`?veraltet=1`), für viele Einträge auf einmal Schlagworte ändern oder sie in den Papierkorb legen |
| `/p/{projekt}/new` | Neuen Eintrag anlegen (`?slug=` belegt den Bezeichner vor), mit Hinweis auf ähnliche vorhandene Einträge |
| `/p/{projekt}/e/{bezeichner}` | Eintrag lesen, mit Rückverweisen und verwandten Einträgen über einen Zwischeneintrag |
| `/p/{projekt}/e/{bezeichner}/edit` | Eintrag bearbeiten, mit Vorschau |
| `/p/{projekt}/e/{bezeichner}/versions` | Alle Fassungen eines Eintrags |
| `/p/{projekt}/e/{bezeichner}/versions/{n}` | Fassung `n` mit ihrer Nachfolgerin vergleichen |
| `/p/{projekt}/graph` | Verweise zwischen den Einträgen als Graph |
| `/p/{projekt}/keys` | Zugänge verwalten, optional mit Ablaufdatum (Rolle `admin`) |
| `/p/{projekt}/export.zip` | Projekt als ZIP herunterladen, passt direkt in den Import (Rolle `admin`) |
| `/p/{projekt}/usage` | Nutzung: geänderte kuratierte Einträge zur Prüfung, Schreibvorgänge der letzten 30 Tage (Agenten/Menschen, neu/aktualisiert), Suchen der Agenten samt Anfragen ohne Treffer, Bestand, Verweise ins Leere, aktive Zugänge |
| `/p/{projekt}/audit` | Protokoll des Projekts, filterbar nach Vorgang, Zugang und Zeitraum |
| `/p/{projekt}/trash` | Papierkorb: wiederherstellen oder endgültig löschen (Schreibrecht) |
| `/hilfe` | Eingebaute Onboarding-Hilfe, oben rechts in der Kopfleiste: Einstieg, Begriffe, MCP einrichten, Arbeiten mit Agenten, Weboberfläche, Zugänge. Dazu je eine Seite für Workspace-Admins (sehen Workspace- und Plattform-Admin) und für Plattform-Admins (nur dieser). |
| `/live` | Läuft der Prozess? Ohne Anmeldung, ohne Datenbank. Für den Neustart des Containers (`/healthz` ist der alte Name und geht weiter) |
| `/ready` | Kann er Anfragen bedienen? Prüft die Datenbank, sonst `503`. Ohne Anmeldung. Für Load-Balancer und Monitoring |
### Funktionen beim Bearbeiten und Lesen
- **Vorschau:** Der Reiter *Vorschau* über dem Textfeld zeigt das Ergebnis. Der
Server rendert es mit demselben Code wie die Detailansicht, deshalb kann die
Vorschau nicht vom späteren Ergebnis abweichen.
- **Autovervollständigung für Verweise:** Nach `[[` schlägt das Textfeld passende
Einträge vor, mit derselben Suche wie `recall`. Auswählen mit Pfeiltasten und
Enter/Tab oder mit der Maus. Eingefügt wird der Bezeichner, nicht der Titel.
- **Änderungshistorie:** Vor jeder Änderung wird die bisherige Fassung gesichert,
auch bei Änderungen durch Agenten. Über *Verlauf ansehen* in der Detailansicht lässt sich jede abgelöste
Fassung zeilenweise mit ihrer Nachfolgerin vergleichen und mit Schreibrecht per
*Wiederherstellen* zurückholen — der bisherige Stand bleibt dabei selbst im
Verlauf.
- **Papierkorb:** Löschen, ob in der Oberfläche oder per `forget`, legt einen
Eintrag nur in den Papierkorb des Projekts. Dort bleibt er samt Verlauf 30
Tage und lässt sich von Menschen mit Schreibrecht wiederherstellen oder
sofort endgültig löschen. Danach räumt die tägliche Aufgabe ihn ab.
- **Graph:** Zeigt, welche Einträge aufeinander verweisen. Verweise auf
Bezeichner, zu denen es noch keinen Eintrag gibt, erscheinen als gestrichelte
Knoten. Bei mehr als 150 Einträgen bleiben die am stärksten verknüpften
sichtbar, die Seite weist darauf hin. Zoomen mit dem Mausrad, verschieben durch
Ziehen.
- **Schlagworte für viele Einträge:** Mit Schreibrecht hat die Eintragsliste
Auswahlkästchen. Für die ausgewählten Einträge lassen sich Schlagworte auf
einmal hinzufügen oder entfernen.
- **Gibt es schon?** Beim Anlegen zeigt Munin unter dem Titel vorhandene
Einträge mit ähnlichem Titel und die gemeinsamen Wörter — dieselbe Prüfung
wie bei `remember` über MCP (`internal/duplicates`), hier nur als Hinweis.
- **Rückfrage beim Verlassen:** Wer im Formular etwas geändert hat und die
Seite verlässt, ohne zu speichern, bekommt eine Rückfrage des Browsers.
- **Auch verwandt:** Unter einem Eintrag stehen neben den Rückverweisen die
Einträge, die über einen Zwischeneintrag verknüpft sind — wie `related`.
- **Veraltet:** Einträge, die seit über einem Jahr niemand geändert hat, tragen
eine Marke; ein Filter neben der Suche zeigt genau diese, die Nutzungsseite
nennt ihre Zahl.
- **Tastenkürzel:** `/` Suche, `n` neuer Eintrag, `e` bearbeiten,
`Strg+Enter` speichern.
Vorschau, Autovervollständigung, „Gibt es schon?“, Rückfrage, Tastenkürzel und
Graph brauchen JavaScript. Alles andere funktioniert auch ohne.
### Rollen
| Rolle | Darf |
|---|---|
| `read` | lesen, suchen |
| `write` | zusätzlich schreiben und löschen |
| `admin` | zusätzlich Zugänge des eigenen Projekts verwalten |
---
## Konfiguration
| Variable | Standard | Bedeutung |
|---|---|---|
| `SESSION_SECRET` | — | **Pflicht.** Signiert Sitzungen und CSRF-Tokens. Mindestens 32 Zeichen. |
| `ADMIN_TOKEN` | — | **Pflicht.** Zugang des Plattform-Admins. Mindestens 32 Zeichen. |
| `DB_DRIVER` | `sqlite` | Wo die Daten liegen: `sqlite` oder `postgres` (siehe AUSBAUSTUFEN.md 4.10) |
| `DATA_DIR` | `data` | Nur SQLite: Datenverzeichnis mit Kontroll-Datenbank (`control.db`) und `workspaces/` mit je einer Datei pro Workspace |
| `DATABASE_URL` | — | Nur PostgreSQL, dann **Pflicht**: z.B. `postgres://munin:geheim@host:5432/munin`. Schemas `munin_control` und je Workspace `munin_ws_<id>`. Lokal: `docker-compose.dev.yml` |
| `PORT` | `8080` | Port des Servers |
| `COOKIE_SECURE` | automatisch | `Secure`-Flag der Cookies. Ohne Angabe: an, außer bei localhost. |
| `ENABLE_HSTS` | `false` | HSTS-Header senden. Nur bei gültigem Zertifikat. |
| `TRUSTED_PROXY` | — | IPs/CIDR-Netze, deren `X-Forwarded-For` geglaubt wird |
| `MAX_API_BODY_BYTES` | `4194304` | Höchstgröße eines MCP-Requests (4 MB) |
| `WRITE_LIMIT_HOURLY` | `1000` | Schreibende MCP-Aufrufe je Projekt und Stunde. `0` = unbegrenzt. |
| `EXPORT_DIR` | — | Zielverzeichnis des Markdown-Exports. Leer = abgeschaltet. |
Fehlen die Pflichtangaben oder sind sie zu kurz, startet der Server nicht. Ein
schwaches Geheimnis wäre die Wurzel für alles andere — ein klarer Abbruch ist
besser als unsicherer Betrieb.
---
## Sicherheit
### Was geprüft wird
| Schutz | Wo |
|---|---|
| Workspaces in getrennten Datenbankdateien | `internal/workspace/workspace.go`, `internal/store/control.go` |
| Signierte Sitzungs-Cookies (HMAC-SHA256) | `internal/auth/session.go` |
| API-Keys nur als SHA-256-Prüfsumme gespeichert | `internal/auth/keys.go` |
| Zurückgezogener Key beendet laufende Sitzungen sofort | `internal/web/middleware.go` |
| Neues `ADMIN_TOKEN` beendet alle Sitzungen des Plattform-Admins sofort | `internal/auth/session.go` (`PlatformSubject`) |
| CSRF-Token in allen Formularen | `internal/auth/csrf.go` |
| `SameSite=Lax`, `HttpOnly`, `Secure` | `internal/auth/cookies.go` |
| Rate-Limiting je Adresse, nur auf Fehlversuche | `internal/auth/ratelimit.go` |
| Content-Security-Policy und weitere Schutz-Header | `internal/web/middleware.go` |
| Größenlimits für Request-Bodies | `internal/web/middleware.go` |
| Server-Timeouts gegen Slowloris | `main.go` |
| Optimistisches Sperren gegen stilles Überschreiben | `internal/store/entries.go` |
### Abschottung der Projekte
Sie ist **strukturell**, nicht eine Frage der richtigen Filterbedingung:
- Jeder Workspace liegt in einer **eigenen Datenbankdatei**. Welche Datei zu
einem Key gehört, steht in der zentralen Kontroll-Datenbank. Ein Key führt
deshalb nur in genau eine Datei.
- Jedes Projekt hat einen **eigenen MCP-Server**, dessen Werkzeuge fest auf
dieses Projekt verdrahtet sind
- Jedes Projekt hat einen **eigenen Suchindex** — auch die BM25-Gewichtung wird
nicht über Projektgrenzen hinweg berechnet
- Jeder Datenbankzugriff ist auf die Projekt-ID eingeschränkt
- Ein fremdes Projekt antwortet mit **404, nicht 403** — sonst ließe sich über
die Statuscodes herausfinden, welche Projekte es sonst noch gibt
### Warum SHA-256 für Keys und nicht bcrypt
Für Passwörter wären langsame Verfahren richtig: Menschen wählen aus einem
kleinen, erratbaren Raum. Ein API-Key hier ist aber 256 Bit aus `crypto/rand` —
diesen Raum durchsucht niemand. Die Langsamkeit brächte keinen Gewinn, würde aber
jeden Request bremsen. Entscheidend ist der zweite Punkt: Weil SHA-256 schnell
und deterministisch ist, wird der Key in **einem indizierten Lookup** aufgelöst
statt in einer Schleife über alle Keys.
### Prompt Injection
Der Inhalt des Speichers landet direkt im Kontext von KI-Werkzeugen. Wer in ein
Projekt schreiben darf, schreibt damit indirekt in die Prompts aller anderen, die
dieses Projekt nutzen.
Dagegen hilft kein technischer Schutz, nur Nachvollziehbarkeit: Jeder Eintrag
trägt Herkunft (`human`/`agent`) und den Namen des verwendeten Zugangs, und jede
Änderung steht im Protokoll.
### Wo die Grenzen liegen
- **Keine Benutzerkonten.** Zugänge sind Keys, keine Personen. Ein Key je Person
ist der Ersatz — geplant ist später Keycloak.
- **Projekte pflegen Plattform-Admin und Workspace-Admins.** Ein Team mit
einem Projekt-Key kann seinen eigenen Projektnamen nicht selbst korrigieren,
dafür braucht es den Admin seines Workspace (AUSBAUSTUFEN.md 4.8).
- **Kein TLS im Server selbst.** Für HTTPS gehört ein Reverse-Proxy davor.
- **Ein Plattform-Admin über allem.** Workspace-Admins sind auf ihren
Workspace beschränkt, aber wer den `ADMIN_TOKEN` hat, kommt an jeden
Workspace. Ein neues Token beendet alle Sitzungen damit sofort (siehe
`BETRIEB.md`).
- **Das Schreibbudget zählt im Arbeitsspeicher.** Nach einem Neustart steht es
wieder bei null. Als Netz gegen eine Schleife reicht das; eine verbindliche
Zusage ist es nicht.
---
## Betrieb
Was im Ernstfall zu tun ist (Server startet nicht, Platte voll, Key geleakt,
Wiederherstellen aus der Sicherung), steht im Betriebshandbuch
[`BETRIEB.md`](BETRIEB.md).
### Docker
```bash
docker build -t munin .
docker run -d --name munin \
-p 8080:8080 \
-v munin-daten:/data \
-e SESSION_SECRET="$(openssl rand -hex 32)" \
-e ADMIN_TOKEN="$(openssl rand -hex 24)" \
-e ENABLE_HSTS=true \
-e TRUSTED_PROXY=172.16.0.0/12 \
munin
```
Die Geheimnisse sollten aus einem Passwortspeicher kommen, nicht bei jedem Start
neu erzeugt werden — ein neues `SESSION_SECRET` macht alle Sitzungen ungültig.
### Mit PostgreSQL
Statt der SQLite-Dateien kann Munin eine PostgreSQL-Datenbank nutzen
(Hintergrund: [AUSBAUSTUFEN.md 4.10](AUSBAUSTUFEN.md)). Darin legt es selbst
an, was es braucht: das Schema `munin_control` und je Workspace ein Schema
`munin_ws_<id>`. Die Tabellen sind dieselben wie unter SQLite.
**Datenbank einrichten** (einmalig, als Datenbank-Admin). Am einfachsten
gehört Munin die Datenbank:
```sql
CREATE ROLE munin LOGIN PASSWORD '…';
CREATE DATABASE munin OWNER munin TEMPLATE template0 LOCALE 'C' ENCODING 'UTF8';
```
Soll die Datenbank einem anderen Benutzer gehören (etwa dem des
Datenbankbetriebs), reichen diese Mindestrechte:
```sql
CREATE ROLE munin LOGIN PASSWORD '…' NOSUPERUSER NOCREATEDB NOCREATEROLE;
GRANT CONNECT, CREATE ON DATABASE munin TO munin;
```
Mehr braucht Munin nicht: keine Rechte am Schema `public`, kein Superuser,
kein `CREATEDB`/`CREATEROLE`. Beide Wege sind erprobt (2026-10-16). Wofür
welches Recht gebraucht wird, steht in
[`BETRIEB.md`](BETRIEB.md#rechte-des-datenbankbenutzers).
| Anforderung | Warum |
|---|---|
| PostgreSQL 13 oder neuer, erprobt mit 18 | Fensterfunktionen und Identitätsspalten; pgx unterstützt 13+ |
| Munin ist Eigentümer der Datenbank **oder** hat `CONNECT, CREATE` darauf; kein Superuser | Er legt je Workspace ein Schema an. Was er anlegt, gehört ihm; deshalb darf er darin Tabellen anlegen und das Schema beim Löschen des Workspace wieder entfernen |
| Sortierung `C` (`LOCALE 'C'`) | Sortiert wie SQLite, Byte für Byte. Sonst stünde z.B. `a-c` vor `ab`. Munin warnt beim Start, wenn es anders ist |
| `max_connections` ≥ Workspaces × 3 + 14 | Je Workspace bis zu 3 Verbindungen, 4 fest, 10 Reserve. Bei 40 Workspaces also 134, `200` ist eine gute Zahl. Reicht es nicht, startet Munin nicht und nennt die nötige Zahl; ebenso wird ein neuer Workspace abgelehnt |
| Genau **ein** Munin-Prozess | Suchindex und Schreibbudget liegen im Arbeitsspeicher. Ein zweiter Prozess fände veraltete Stände |
**Starten:**
```bash
docker run -d --name munin \
-p 8080:8080 \
-v munin-daten:/data \
-e DB_DRIVER=postgres \
-e DATABASE_URL='postgres://munin@db.intern:5432/munin?sslmode=verify-full&sslrootcert=/certs/ca.pem' \
-e PGPASSWORD="…" \
-e SESSION_SECRET="…" \
-e ADMIN_TOKEN="…" \
munin
```
- **Passwort** nicht in `DATABASE_URL` schreiben, sondern über `PGPASSWORD`
oder eine `.pgpass`-Datei (beides liest Munin). Steht es doch in der URL,
müssen Sonderzeichen URL-kodiert sein (`@` → `%40`).
- **Verschlüsselung:** im Netz mindestens `sslmode=require`, besser
`verify-full` mit dem Zertifikat der eigenen CA (`sslrootcert`).
`sslmode=disable` nur, wenn die Datenbank auf demselben Rechner läuft.
- **Das Volume `/data`** braucht es nur noch für `EXPORT_DIR`, falls gesetzt.
- **Ist die Datenbank beim Start noch nicht bereit**, versucht Munin es bis zu
15-mal im Abstand von 2 Sekunden. Fällt sie im laufenden Betrieb weg,
schlagen Anfragen fehl, solange sie fehlt; abgebrochene Verbindungen
verwirft Go (`database/sql`) und baut danach neue auf. Ein Neustart von
Munin sollte also nicht nötig sein — erprobt ist das aber noch nicht, siehe
[`BETRIEB.md`](BETRIEB.md#postgresql-ist-weg).
- **Sicherung:** `scripts/backup.sh` mit `DB_DRIVER=postgres`, siehe unten.
Für die Entwicklung startet `docker-compose.dev.yml` eine passend
eingestellte Datenbank.
### Hinter einem Reverse-Proxy
```bash
TRUSTED_PROXY=127.0.0.1 # Adresse des Proxys
COOKIE_SECURE=true # falls der Proxy TLS beendet
ENABLE_HSTS=true # nur mit gültigem Zertifikat
```
Ohne `TRUSTED_PROXY` sieht der Server für alle Besucher dieselbe Adresse — das
Rate-Limiting würde dann entweder niemanden bremsen oder beim ersten Angreifer
gleich alle aussperren.
### Datensicherung
**Datenbank (SQLite).** Jeder Workspace (Abteilung) liegt in einer eigenen Datei unter
`DATA_DIR/workspaces/`, dazu kommt die zentrale Kontroll-Datenbank
`DATA_DIR/control.db`. Weil SQLite im WAL-Modus läuft, sollte im laufenden
Betrieb nicht einfach kopiert werden — `scripts/backup.sh` sichert beides
mit `sqlite3 .backup`, in einem zeitgestempelten Ordner je Lauf:
```bash
DATA_DIR=/pfad/data BACKUP_DIR=/pfad/sicherungen scripts/backup.sh
```
Für Cron, täglich um 3 Uhr mit 14 Tagen Aufbewahrung:
```cron
0 3 * * * DATA_DIR=/srv/munin/data BACKUP_DIR=/srv/backups RETENTION_DAYS=14 \
/srv/munin/scripts/backup.sh >> /var/log/munin-backup.log 2>&1
```
Da jede Abteilung ihre eigene Datei hat, lässt sich eine einzelne davon auch
gezielt sichern oder wiederherstellen, ohne die übrigen anzufassen: Server
stoppen, die gewünschte `.db`-Datei aus einem Sicherungsordner zurückkopieren,
Server wieder starten. Die genauen Schritte und was dabei zu beachten ist
(Hilfsdateien `-wal`/`-shm`, wieder gültige Keys) stehen in
[`BETRIEB.md`](BETRIEB.md#aus-einer-sicherung-wiederherstellen).
**Datenbank (PostgreSQL).** Dasselbe Skript sichert mit `pg_dump` jedes Schema
in eine eigene Datei: `control.dump` und `workspaces/munin_ws_<id>.dump`. Jede
Datei wird nach dem Schreiben auf Lesbarkeit geprüft. Ein einzelner Workspace
lässt sich so gezielt zurückspielen.
```bash
DB_DRIVER=postgres DATABASE_URL='postgres://munin@db.intern:5432/munin' \
BACKUP_DIR=/pfad/sicherungen scripts/backup.sh
```
Gebraucht werden `psql`, `pg_dump` und `pg_restore` in mindestens der Version
des Servers. Fehlen sie, lassen sie sich aus einem PostgreSQL-Container leihen
(`PSQL`, `PG_DUMP`, `PG_RESTORE`, siehe Kopf von `scripts/backup.sh`).
**Probe der Sicherung.** `scripts/sicherung-pruefen.sh` spielt eine Sicherung
zurück, startet Munin darauf (eigener Port, eigene Geheimnisse), wartet auf
`/ready`, vergleicht die Zahl der Workspaces und räumt auf. Für PostgreSQL
braucht es eine leere Probe-Datenbank (`PROBE_DATABASE_URL`), die einmal
angelegt wird; ist sie nicht leer, bricht das Skript ab.
```bash
scripts/sicherung-pruefen.sh /pfad/sicherungen/<zeitstempel>
DB_DRIVER=postgres PROBE_DATABASE_URL='postgres://munin@db.intern:5432/munin_probe' \
scripts/sicherung-pruefen.sh /pfad/sicherungen/<zeitstempel>
```
**Markdown-Export.** Ist `EXPORT_DIR` gesetzt, schreibt der Server den Bestand
aller Workspaces täglich als Markdown-Dateien dorthin: ein Ordner je Workspace,
darin ein Ordner je Projekt, eine Datei je Eintrag und eine `_index.md` als
Übersicht:
```
export/
mfk3x9a2-1a2b3c4d/ ← Workspace-ID
_workspace.md ← Name des Workspace
plattform/
_index.md
deployment.md
server-zugang.md
```
Die Workspace-Ebene ist nötig, weil Projekt-Bezeichner nur innerhalb eines
Workspace eindeutig sind. Der Ordner heißt nach der ID und nicht nach dem Namen,
damit ein Umbenennen keinen neuen Ordner erzeugt. Welcher Workspace dahinter
steckt, steht in `_workspace.md`.
> **Umstieg von älteren Fassungen:** Früher lagen die Projektordner direkt unter
> `EXPORT_DIR/`. Diese alten Ordner werden nicht mehr angefasst und können nach
> einem erfolgreichen Export gelöscht werden.
Jede Datei trägt alle Felder im Kopf (Bezeichner, Schlagworte, Herkunft, Autor,
Fassung, Zeitstempel), sodass sich der Bestand daraus auch wieder einlesen ließe.
Das eignet sich als Git-Sicherung: Ein Lauf ohne inhaltliche Änderungen fasst
keine Datei an und erzeugt deshalb auch keinen Diff.
```bash
# Auf Knopfdruck: "Jetzt exportieren" unter /admin/projects
# (exportiert nur den gerade gewählten Workspace)
# Für Cron oder ein Sicherungsskript, alle Workspaces:
./munin -export
```
Scheitert der Export eines Workspace, laufen die übrigen trotzdem durch.
`./munin -export` endet dann aber mit einem Fehlercode, damit ein Cronjob die
Sicherung nicht für vollständig hält.
**Download als ZIP.** Unabhängig von `EXPORT_DIR` lässt sich derselbe Inhalt
auch direkt im Browser herunterladen: ein Projekt über *Als ZIP herunterladen*
über der Eintragsliste (Rolle `admin`), ein ganzer Workspace über *Alle als ZIP
herunterladen* unter *Projekte*. Das ZIP entsteht bei jedem Klick frisch und
wird nicht auf dem Server abgelegt. Es hat denselben Aufbau wie der Export und
passt deshalb ohne Umpacken in den Import. Jeder Download steht im Protokoll
(`export_download`). Wer außer Admins herunterladen darf, steht an einer
einzigen Stelle: `Session.CanDownloadProject` in `internal/web/context.go`.
Der Export ist eine Sicherung, keine zweite Wahrheit — Änderungen an den Dateien
wirken nicht zurück und gehen beim nächsten Lauf verloren.
### Protokoll
Vorgänge werden 90 Tage aufbewahrt und danach automatisch gelöscht
(`auditRetention` in `main.go`). Das Protokoll enthält Adressen und Zugangsnamen,
aus denen sich Personen ableiten lassen — eine Aufbewahrungsfrist ist deshalb die
Voraussetzung dafür, es überhaupt führen zu dürfen.
Getrennt davon steht das **Suchprotokoll**: was Agenten mit `recall` gesucht
und ob sie etwas gefunden haben, ausgewertet auf der Nutzungsseite. Ohne
Adressen und Namen, aber mit dem Text der Anfragen — deshalb nur 30 Tage
(`store.SearchLogRetention`), und es verschwindet mit dem Projekt.
Jeder Workspace führt sein eigenes Protokoll in seiner eigenen Datei.
`/admin/audit` zeigt das Protokoll des gerade gewählten Workspace, nicht alle
auf einmal. Das tägliche Aufräumen läuft über alle Workspaces, auch über die,
die erst nach dem Serverstart angelegt wurden.
Bei Schreibvorgängen an Einträgen steht in der Spalte *Details*, ob der Eintrag
**angelegt** oder **aktualisiert** wurde. Daran lässt sich ablesen, ob Agenten
vorhandene Einträge fortschreiben oder immer neue anlegen.
---
## Entwicklung
```bash
go run . # starten
go test ./... # alle Tests
go vet ./... # statische Prüfung
gofmt -l . # Formatierung prüfen
```
### Aufbau
```
main.go Konfiguration, Verdrahtung, Serverstart
internal/
store/ SQLite: Schema, Migrationen, alle Abfragen
(Workspace-Dateien und Kontroll-Datenbank)
workspace/ Öffnet Workspace-Dateien bei Bedarf und hält sie offen
entry/ Fachlogik: Verweise, Textausschnitte, Zeilenvergleich
markdown/ Markdown nach HTML — ein Renderer für Einträge und Hilfe
search/ BM25-Volltextindex, einer je Projekt
auth/ Sitzungen, CSRF, Cookies, Keys, Rate-Limiting
export/ Markdown-Export als Sicherung
web/ Routen, Middleware, Handler
mcpserver/ MCP-Werkzeuge
hilfe/ Eingebaute Hilfeseiten (Markdown in seiten/)
templates/ static/ eingebettet ins Binary
scripts/backup.sh Sicherung aller Datenbankdateien
```
Eine Übersicht über jede einzelne Datei steht in [REPOMAP.md](REPOMAP.md).
**Hilfeseiten pflegen:** Die Hilfe unter `/hilfe` sind Markdown-Dateien in
`internal/help/pages/`, die mit ins Binary gebaut werden. Ändert sich eine
Funktion, gehört die Anpassung der Hilfe in denselben Commit. Eine neue Seite
braucht eine Datei *und* einen Eintrag in der Liste `pages` in
`internal/help/help.go`. Ein Test prüft, dass beides zusammenpasst und dass
alle Links auf `/hilfe/…` zu existierenden Seiten führen. Seiten mit
`NurAdmin: true` sieht nur der Plattform-Admin; allgemeine Seiten dürfen
nicht auf sie verlinken (auch das prüft ein Test). In den Seiten
werden `{{SERVER}}` und `{{PROJEKT}}` beim Anzeigen durch die Adresse des
Servers und den Bezeichner des eigenen Projekts ersetzt.
Die Richtung der Abhängigkeiten zeigt immer nach innen: `web` und `mcpserver`
kennen `store`, aber `store` kennt keinen von beiden.
### Datenbank
SQLite über `modernc.org/sqlite` — ein in Go übersetzter Nachbau, kein C — oder
PostgreSQL über `pgx`, ebenfalls reines Go. Nur deshalb lässt sich das Programm
als statisches Binary ohne Laufzeitumgebung ausliefern.
Welche Datenbank, entscheidet `DB_DRIVER`. Alle Abfragen sind für beide
dieselben: Sie werden mit `?` geschrieben und laufen durch einen kleinen
Dialekt, der für PostgreSQL `$1, $2 …` daraus macht (`internal/store/dialect.go`,
`pool.go`). Das SQL vermeidet deshalb alles, was nur eine der beiden
kann. Die Regeln stehen als Kommentar in
`internal/store/migrations/sqlite/001_init.sql`.
Es gibt zwei Schemata mit je eigenem Migrationsordner, darin je Datenbank ein
Unterordner:
| Ordner | Gilt für |
|---|---|
| `internal/store/migrations/{sqlite,postgres}/` | jeden Workspace (Projekte, Einträge, Keys, Protokoll) |
| `internal/store/migrations_control/{sqlite,postgres}/` | die Kontroll-Datenbank (Workspaces, Zuordnung Key → Workspace) |
**Schema ändern:** eine neue Datei mit der nächsten Nummer anlegen, derzeit
`008_….sql` für Workspaces, **in beiden Unterordnern** unter demselben Namen.
Sie wird automatisch angewandt und vermerkt: für die Kontroll-Datenbank beim
Start, für einen Workspace beim ersten Öffnen. Bestehende Migrationen werden
nie verändert.
Zwei Tests passen auf: `TestMigrationDirsMatch` (beide Ordner haben
dieselben Dateien, läuft immer) und `TestSchemaMatches` (beide ergeben dasselbe
Schema, braucht PostgreSQL):
```bash
docker compose -f docker-compose.dev.yml up -d
MUNIN_TEST_PG='postgres://munin:munin@localhost:5432/munin_test?sslmode=disable' go test ./...
```
---
## Was noch aussteht
[AUSBAUSTUFEN.md](AUSBAUSTUFEN.md) hält fest, was bewusst nicht gebaut wurde —
mit Messwerten, Begründung und den Auslösern, an denen man merkt, dass es Zeit
dafür ist. Unter anderem: FTS5 statt eigenem Suchindex, eine Datenbank je
Projekt, semantische Suche.