From f846955e58b09c88c97bb369e7dfe837da81d753 Mon Sep 17 00:00:00 2001 From: Thomas Adamek Date: Tue, 15 Sep 2026 20:36:26 +0200 Subject: [PATCH] initial commit after rewrite --- .dockerignore | 29 + .env.example | 80 ++ .gitignore | 16 + AGENTEN-GUIDE.md | 191 ++++ AUSBAUSTUFEN.md | 448 ++++++++ Dockerfile | 63 ++ README.md | 484 +++++++++ REPOMAP.md | 278 +++++ go.mod | 27 + go.sum | 70 ++ internal/auth/auth_test.go | 487 +++++++++ internal/auth/cookies.go | 104 ++ internal/auth/csrf.go | 49 + internal/auth/doc.go | 22 + internal/auth/keys.go | 110 ++ internal/auth/ratelimit.go | 249 +++++ internal/auth/session.go | 109 ++ internal/auth/trustedproxy.go | 179 ++++ internal/entry/entry.go | 131 +++ internal/entry/entry_test.go | 163 +++ internal/export/export.go | 309 ++++++ internal/export/export_test.go | 344 +++++++ internal/mcpserver/mcpserver_test.go | 561 ++++++++++ internal/mcpserver/server.go | 227 +++++ internal/mcpserver/tools.go | 419 ++++++++ internal/search/index.go | 405 ++++++++ internal/search/registry.go | 102 ++ internal/search/search_test.go | 266 +++++ internal/store/audit.go | 165 +++ internal/store/entries.go | 502 +++++++++ internal/store/keys.go | 179 ++++ internal/store/links.go | 55 + internal/store/migrations/001_init.sql | 126 +++ .../002_projekt_in_nebentabellen.sql | 47 + .../003_schreibbudget_je_projekt.sql | 26 + internal/store/models.go | 186 ++++ internal/store/projects.go | 275 +++++ internal/store/store.go | 278 +++++ internal/store/store_test.go | 808 +++++++++++++++ internal/web/context.go | 147 +++ internal/web/handlers_admin.go | 494 +++++++++ internal/web/handlers_auth.go | 122 +++ internal/web/handlers_entries.go | 286 ++++++ internal/web/middleware.go | 430 ++++++++ internal/web/server.go | 287 ++++++ internal/web/web_test.go | 963 ++++++++++++++++++ main.go | 423 ++++++++ static/confirm.js | 30 + static/img/munin_logo.svg | 1 + static/img/munin_logo_dark.svg | 1 + static/img/munin_logo_lean.svg | 1 + static/img/munin_logo_transparent.svg | 1 + static/style.css | 678 ++++++++++++ templates/audit.html | 52 + templates/edit.html | 64 ++ templates/entries.html | 64 ++ templates/entry.html | 45 + templates/keys.html | 130 +++ templates/login.html | 31 + templates/partials.html | 118 +++ templates/project-edit.html | 77 ++ templates/projects.html | 100 ++ 62 files changed, 13114 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 AGENTEN-GUIDE.md create mode 100644 AUSBAUSTUFEN.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 REPOMAP.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/auth/auth_test.go create mode 100644 internal/auth/cookies.go create mode 100644 internal/auth/csrf.go create mode 100644 internal/auth/doc.go create mode 100644 internal/auth/keys.go create mode 100644 internal/auth/ratelimit.go create mode 100644 internal/auth/session.go create mode 100644 internal/auth/trustedproxy.go create mode 100644 internal/entry/entry.go create mode 100644 internal/entry/entry_test.go create mode 100644 internal/export/export.go create mode 100644 internal/export/export_test.go create mode 100644 internal/mcpserver/mcpserver_test.go create mode 100644 internal/mcpserver/server.go create mode 100644 internal/mcpserver/tools.go create mode 100644 internal/search/index.go create mode 100644 internal/search/registry.go create mode 100644 internal/search/search_test.go create mode 100644 internal/store/audit.go create mode 100644 internal/store/entries.go create mode 100644 internal/store/keys.go create mode 100644 internal/store/links.go create mode 100644 internal/store/migrations/001_init.sql create mode 100644 internal/store/migrations/002_projekt_in_nebentabellen.sql create mode 100644 internal/store/migrations/003_schreibbudget_je_projekt.sql create mode 100644 internal/store/models.go create mode 100644 internal/store/projects.go create mode 100644 internal/store/store.go create mode 100644 internal/store/store_test.go create mode 100644 internal/web/context.go create mode 100644 internal/web/handlers_admin.go create mode 100644 internal/web/handlers_auth.go create mode 100644 internal/web/handlers_entries.go create mode 100644 internal/web/middleware.go create mode 100644 internal/web/server.go create mode 100644 internal/web/web_test.go create mode 100644 main.go create mode 100644 static/confirm.js create mode 100644 static/img/munin_logo.svg create mode 100644 static/img/munin_logo_dark.svg create mode 100644 static/img/munin_logo_lean.svg create mode 100644 static/img/munin_logo_transparent.svg create mode 100644 static/style.css create mode 100644 templates/audit.html create mode 100644 templates/edit.html create mode 100644 templates/entries.html create mode 100644 templates/entry.html create mode 100644 templates/keys.html create mode 100644 templates/login.html create mode 100644 templates/partials.html create mode 100644 templates/project-edit.html create mode 100644 templates/projects.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f39f934 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,29 @@ +# Was Docker beim Bauen NICHT in den Kontext kopieren soll. +# Weniger Kontext = schnellerer Bau. + +# Datenbank kommt als Volume, nicht ins Abbild +data/ + +# Lokal gebautes Binary +munin + +# Git-Metadaten braucht Docker nicht +.git + +# Dokumentation ist kein Laufzeit-Artefakt +README.md +REPOMAP.md + +# Geheimnisse gehören nicht in den Kontext. +# +# Im fertigen Abbild landen sie zwar nicht (die zweite Stufe kopiert nur das +# Binary), aber die Bau-Stufe enthält sie — und die liegt im Docker-Zwischen- +# speicher und wird in manchen CI-Umgebungen sogar mit veröffentlicht. +.env +.env.* + +# Editor- und Werkzeugverzeichnisse +.claude +.opencode +.cline +.DS_Store diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ea7fd39 --- /dev/null +++ b/.env.example @@ -0,0 +1,80 @@ +# Wissensspeicher — Konfigurationsvorlage +# +# Kopieren und mit eigenen Werten befüllen: +# cp .env.example .env +# +# .env enthält Geheimnisse und steht in .gitignore. Niemals einchecken. + +# ── Pflichtangaben ──────────────────────────────────────────────────────────── + +# Signiert Sitzungs-Cookies und CSRF-Tokens. Mindestens 32 Zeichen. +# openssl rand -hex 32 +SESSION_SECRET= + +# Zugang des Plattform-Admins: legt Projekte an und löscht sie, kommt an jedes +# Projekt. Mindestens 16 Zeichen. +# openssl rand -hex 24 +ADMIN_TOKEN= + +# ── Server ──────────────────────────────────────────────────────────────────── + +# Pfad zur SQLite-Datenbank. Der Ordner wird beim Start angelegt. +DB_PATH=data/munin.db + +# Port, auf dem der Server lauscht. +PORT=8080 + +# ── Sicherheit und Betrieb ──────────────────────────────────────────────────── + +# Secure-Flag der Cookies: Der Browser schickt sie dann nur über HTTPS. +# +# Ohne Angabe wird automatisch entschieden — bei Zugriff über localhost aus, +# sonst an. Für die lokale Entwicklung ist damit nichts zu tun. Ausdrücklich auf +# true setzen, wenn ein Reverse-Proxy das TLS beendet und intern http spricht. +# COOKIE_SECURE=true + +# HSTS-Header senden: weist den Browser an, die Adresse künftig nur noch über +# HTTPS aufzurufen. +# +# NUR einschalten, wenn ein gültiges Zertifikat vorhanden ist — ein Browser, der +# den Header einmal gesehen hat, weigert sich ein Jahr lang, die Seite über http +# zu laden. +# ENABLE_HSTS=true + +# Vertrauenswürdige Reverse-Proxys — kommagetrennte IPs und/oder CIDR-Netze. +# +# Nur wenn ein Request von einer dieser Adressen kommt, wird X-Forwarded-For +# ausgewertet, um die echte Client-Adresse zu bestimmen (wichtig für +# Rate-Limiting und Protokoll). Ohne Angabe wird der Header ignoriert — er ist +# frei fälschbar. +# TRUSTED_PROXY=127.0.0.1,10.0.0.0/8 + +# Höchstgröße eines MCP-Requests in Bytes. Standard: 4194304 (4 MB). +# MAX_API_BODY_BYTES=4194304 + +# Schreibbudget: wie oft ein Projekt je Stunde über MCP schreiben darf +# (remember und forget). Standard: 1000. Lesen ist nie begrenzt. +# +# Warum es das gibt: Ein gültiger API-Key umgeht die IP-Sperre bewusst — sonst +# würde ein falsch konfigurierter Client alle anderen im selben Büro mitsperren. +# Damit hätte aber ein Agent, der in einer Schleife hängt, überhaupt keine +# Grenze mehr. Diese hier ist sein Netz. +# +# Die Zahl darf großzügig sein: Sie soll eine Schleife stoppen, nicht Arbeit +# rationieren. Einzelne Projekte bekommen bei Bedarf eine eigene Obergrenze +# unter /admin/projects. +# +# 0 schaltet die Begrenzung vollständig ab. +# WRITE_LIMIT_HOURLY=1000 + +# ── Markdown-Export ─────────────────────────────────────────────────────────── + +# Zielverzeichnis für den Export. Ohne Angabe ist der Export abgeschaltet. +# +# Der Bestand wird täglich (und auf Knopfdruck) als Markdown-Dateien dorthin +# geschrieben — ein Ordner je Projekt. Gedacht als Sicherung, die sich in ein +# Git-Repository legen lässt und auch ohne dieses Programm lesbar bleibt. +# +# Ein Lauf ohne inhaltliche Änderungen fasst keine Datei an und erzeugt damit +# auch keinen Git-Diff. +# EXPORT_DIR=data/export diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96e1878 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Kompiliertes Binary +munin + +# Datenbank und alles darunter — gehört nicht ins Repository +data/ + +# Geheimnisse — NIEMALS einchecken +.env + +# macOS +.DS_Store + +# Editor- und Werkzeugverzeichnisse +.claude +.opencode +.cline diff --git a/AGENTEN-GUIDE.md b/AGENTEN-GUIDE.md new file mode 100644 index 0000000..3b8c4e9 --- /dev/null +++ b/AGENTEN-GUIDE.md @@ -0,0 +1,191 @@ +# Agenten anbinden + +Wie man KI-Werkzeuge so einrichtet und anspricht, dass sie den Munin +tatsächlich benutzen. + +--- + +## 1. Verbinden + +Key erzeugen unter `/p//keys`. Der Klartext wird genau einmal angezeigt. + +Die fertigen Konfigurationsblöcke für **Claude Code**, **Cline** und **OpenCode** +stehen im [README](README.md#mcp-einrichten) — dort auch, worin sich die drei +Dateien unterscheiden. Kurzfassung: + +| Client | Datei | Schlüssel | `type` | +|---|---|---|---| +| Claude Code | `.mcp.json` | `mcpServers` | `http` | +| Cline | `cline_mcp_settings.json` | `mcpServers` | `streamableHttp` | +| OpenCode | `opencode.json` | `mcp` | `remote` | + +Ein Server je Projekt. Der Key bestimmt das Projekt — kein weiterer Parameter nötig. + +**Ein Key je Person und je Agent.** Der Key-Name landet als Autor an jedem Eintrag +und im Protokoll. + +--- + +## 2. Werkzeuge + +| Werkzeug | Parameter | Rolle | +|---|---|---| +| `recall` | `query`, `tags?`, `limit?` | read | +| `get` | `slug` | read | +| `list` | `tag?`, `curated?`, `limit?` | read | +| `remember` | `title`, `content`, `slug?`, `tags?`, `expected_version?` | write | +| `forget` | `slug` | write | + +--- + +## 3. Das eigentliche Problem + +Der Server schickt jedem Client eine Anleitung mit (`instructions`). Viele Clients +gewichten sie schwach oder ignorieren sie. **Ohne eigene Anweisung im Projekt- +oder Systemprompt greift ein Agent selten von selbst zum Werkzeug** — er antwortet +lieber aus seinem eigenen Wissen. + +Deshalb der folgende Block. + +--- + +## 4. Projektprompt + +In `CLAUDE.md`, `.clinerules`, „Custom Instructions" oder den Systemprompt der +eigenen Anwendung: + +```markdown +## Munin + +Für dieses Projekt gibt es einen Munin über MCP (Werkzeuge: recall, +get, list, remember, forget). Er ist das gemeinsame Gedächtnis des Teams. + +LESEN — vor jeder Antwort zu projektspezifischen Fragen: +- Zuerst `recall` mit der Frage aufrufen, nicht mit Stichworten. +- Passenden Treffer mit `get` vollständig laden. +- Erst wenn nichts Passendes da ist, aus eigenem Wissen antworten — und das + kenntlich machen. +- Nie raten, wenn es um Prozesse, Zugänge oder Entscheidungen dieses Teams geht. + +SCHREIBEN — sobald etwas festgehalten werden sollte: +- IMMER zuerst `recall`, ob es zum Thema schon einen Eintrag gibt. +- Gibt es einen: mit DESSEN slug `remember` aufrufen. Das aktualisiert ihn. +- Nur wenn wirklich nichts passt, einen neuen slug wählen. +- Vor dem Aktualisieren `get` aufrufen und die gelieferte `version` als + `expected_version` mitgeben. + +WAS festgehalten wird: +- Entscheidungen mit Begründung +- Abläufe, die sich wiederholen +- Lösungen für Probleme, die länger gedauert haben +- Nicht: Zwischenstände, Diskussionsverlauf, was im Code steht + +WIE festgehalten wird: +- Destilliert, nicht als Protokoll. Ein Eintrag beantwortet eine Frage. +- Verwandtes mit [[bezeichner]] verknüpfen. +- Schlagworte sparsam: 1–3 aus dem bestehenden Bestand (`list` zeigt sie). +``` + +--- + +## 5. Bezeichner (slug) + +Der slug ist der Schlüssel gegen Dubletten: gleicher slug = Aktualisierung. + +| gut | schlecht | warum | +|---|---|---| +| `deployment-prozess` | `notiz-2026-01-15` | Datum ist kein Thema | +| `fehler-zugriff-verweigert` | `problem-3` | nicht wiederfindbar | +| `entscheidung-sqlite` | `sqlite-entscheidung-final-v2` | Fassungen zählt das System | + +Regeln: `a-z`, `0-9`, `-`, `_`. Kurz, sprechend, dauerhaft. Ohne Angabe wird er +aus dem Titel abgeleitet. + +--- + +## 6. Muster für einzelne Aufgaben + +**Nachschlagen** + +``` +Wie läuft bei uns das Deployment? Schau zuerst im Munin nach. +``` + +**Festhalten nach getaner Arbeit** + +``` +Halte fest, was wir über den abgelaufenen Token herausgefunden haben. +Prüfe vorher mit recall, ob es dazu schon einen Eintrag gibt — dann +ergänze diesen, statt einen neuen anzulegen. +``` + +**Bestand aufräumen** + +``` +Rufe list auf und nenne mir Einträge, die dasselbe Thema doppelt behandeln. +Nichts ändern, nur auflisten. +``` + +**Einarbeiten** + +``` +Verschaffe dir mit list und recall einen Überblick über den Munin +und fasse zusammen, was dieses Team über Betrieb und Deployment festgehalten hat. +``` + +**Vorhandenes fortschreiben** + +``` +Lies deployment-prozess mit get, ergänze den Schritt mit dem Rollback und +schreibe zurück — mit expected_version aus dem get. +``` + +--- + +## 7. Was Dubletten erzeugt + +| Ursache | Gegenmittel | +|---|---| +| Agent schreibt ohne vorher zu suchen | „IMMER zuerst recall" im Projektprompt | +| Neuer slug für vorhandenes Thema | slug-Regeln oben; `recall` liefert den vorhandenen | +| Zeitstempel im slug | verbieten — Fassungen zählt das System | +| Jede Erkenntnis als eigener Eintrag | „Ein Eintrag beantwortet eine Frage" | + +--- + +## 8. Prüfen, ob es wirkt + +**Protokoll:** `/p//audit` — jeder Schreibvorgang mit Zugang und Zeit. + +**Faustregel:** Nach ein paar Wochen sollte das Verhältnis von Aktualisierungen zu +Neuanlagen steigen. Viele Neuanlagen bei ähnlichen Themen heißt: Der Projektprompt +greift nicht. + +**In der Oberfläche:** Einträge mit Herkunft „Agent" und ohne Marke „kuratiert" +sind ungeprüfte Beobachtungen. Wer sie durchsieht und bestätigt, setzt den Haken +„Kuratiert" — Agenten sehen das Feld über `recall` und `get`. + +--- + +## 9. Rollen + +| Rolle | darf | wofür | +|---|---|---| +| `read` | recall, get, list | Agenten, die nur auskunftsfähig sein sollen | +| `write` | zusätzlich remember, forget | der Normalfall | +| `admin` | zusätzlich Zugänge verwalten | Menschen | + +Ein Agent, der ohne Aufsicht läuft, bekommt `read`. Schreibrecht dort, wo jemand +die Einträge auch durchsieht. + +--- + +## 10. Grenzen + +- **Suche ist wortbasiert.** „wie deployen wir" findet „Release-Prozess" nicht. + Im Zweifel `list` für den Überblick. +- **Kein Zurück.** `forget` löscht endgültig, alte Fassungen werden nicht + aufgehoben. Veraltetes lieber mit `remember` richtigstellen. +- **Was drinsteht, landet im Kontext aller.** Wer schreiben darf, schreibt + indirekt in die Prompts der anderen — Herkunft und Autor stehen deshalb an + jedem Eintrag. diff --git a/AUSBAUSTUFEN.md b/AUSBAUSTUFEN.md new file mode 100644 index 0000000..f8f85a6 --- /dev/null +++ b/AUSBAUSTUFEN.md @@ -0,0 +1,448 @@ +# Ausbaustufen — Munin + +Was bewusst **nicht** gebaut wurde, warum, und woran man merkt, dass es Zeit dafür ist. + +Der Sinn dieses Dokuments ist, dass niemand — auch nicht wir selbst in einem +halben Jahr — dieselben Fragen noch einmal von vorne durchdenken muss. Wo Zahlen +stehen, sind sie gemessen, nicht geschätzt; die Messumgebung ist jeweils genannt. + +--- + +## Teil 1 — Entschieden und aufgeschoben + +Diese Punkte sind durchgerechnet. Die Entscheidung lautet jeweils „später", +nicht „nie". + +### 1.1 FTS5 statt eigenem Suchindex + +**Stand heute:** Ein handgeschriebener BM25-Index im Arbeitsspeicher +(`internal/search`, 464 Zeilen plus 195 Zeilen Tests), einer je Projekt, beim +ersten Zugriff aufgebaut. + +**Gemessen** (10.000 Einträge à 150 Wörter, Wortschatz 5.000, Zipf-verteilt): + +| | eigener Index | FTS5 | +|---|---|---| +| Arbeitsspeicher | 65 MB | 0 | +| Platte | 0 | 54 MB | +| Aufbau | 171 ms | 280 ms | +| Suche | 12 µs | 208 µs | + +FTS5 ist im reinen Go-Treiber verfügbar (`ENABLE_FTS5`), der statische Build +bliebe also erhalten. + +**Warum aufgeschoben:** + +- Der Suchindex ist **abgeleitete Daten**. Er lässt sich jederzeit aus `entries` + neu aufbauen — eine Umstellung später bedeutet keine Datenmigration. Der übliche + Grund, so etwas früh zu erledigen, greift hier nicht. +- Es löst nicht den eigentlichen Engpass. Der ist die semantische Trefferqualität + (siehe 2.2), und die kann FTS5 so wenig wie BM25 von Hand. +- FTS5 gibt es nur in SQLite. Der eigene Index ist von der Datenbank unabhängig + und hält damit den Weg zu PostgreSQL offen. + +**Auslöser für die Umstellung:** + +- Ein Projekt jenseits von grob 50.000 Einträgen (~300 MB Arbeitsspeicher allein + für dessen Index) +- Der Start dauert spürbar, weil viele große Projekte gleichzeitig angefragt werden +- Phrasensuche, `NEAR` oder boolesche Operatoren werden gebraucht — die bringt + FTS5 mit, der eigene Index nicht + +**Aufwand:** ein bis zwei Tage. Virtuelle Tabelle mit `content=`-Verweis auf +`entries`, Befüllung aus dem Bestand, `internal/search` entfällt. + +### 1.2 Eine SQLite-Datei je Projekt + +**Stand heute:** Eine Datei für alles, zwei Verbindungspools — acht Verbindungen +zum Lesen, eine zum Schreiben. + +**Gemessen:** + +| | | +|---|---| +| Schreibvorgänge über die eine Schreibverbindung | 8.656/s | +| Lesen in Projekt A, ungestört | 82 µs | +| Lesen in Projekt A, während drei Projekte im Dauerlauf schreiben | 121 µs | + +Hundert Agenten, die jede Sekunde einen Eintrag ablegen, sind 100 +Schreibvorgänge/s — etwa **1 % der Kapazität**. + +**Warum aufgeschoben:** Es bräuchte trotzdem eine zentrale Datenbank, denn drei +Dinge braucht man, *bevor* man das Projekt kennt: `ResolveKeyHash` (Key → +Projekt), `ListProjects` und `EnsureWorkspace`. Daraus folgt: + +- Globales Protokoll und „Projekte mit Eintragszahl" werden aus einer Abfrage + N Abfragen plus Zusammenführen in Go +- Migrationen laufen über jede Datei, auch beim Anlegen eines Projekts +- Verbindungsverwaltung mit Verdrängung nötig (50 Projekte × 9 Verbindungen) +- Kein gemeinsamer Commit über zentrale und Projektdatenbank — „Projekt anlegen + samt erstem Key" wäre nicht mehr atomar + +**Auslöser:** + +- Anhaltend über ~1.000 Schreibvorgänge/s (10 % der Kapazität) +- Ein Projekt jenseits weniger GB, sodass Sicherung und `VACUUM` unangenehm werden +- Verbindliche Löschzusagen je Projekt, bei denen „Zeilen gelöscht" nicht reicht + +**Billigere Schritte davor:** mehrere Einträge in einer Transaktion bündeln, +WAL-Prüfpunkte seltener setzen. + +### 1.3 Zeichenketten im Suchindex teilen + +`internal/search` hält in `docTerms` je Dokument eine Liste **aller seiner +verschiedenen Wörter** als eigene Zeichenketten. Bei 10.000 Einträgen sind das +über eine Million einzeln abgelegte Zeichenketten, obwohl im Projekt vielleicht +nur 20.000 verschiedene Wörter vorkommen. + +Die Liste ist nötig: ohne sie müsste beim Ändern oder Löschen der komplette Index +durchsucht werden. Sie ließe sich aber verkleinern, indem gleiche Wörter dieselbe +Zeichenkette teilen. **Geschätzt ein Drittel bis die Hälfte** des Speichers. + +**Warum aufgeschoben:** Löst dasselbe Problem wie 1.1, nur halb. Wenn der +Speicher wirklich drückt, ist FTS5 der gründlichere Schritt. + +### 1.4 sqlc für die Datenschicht + +War empfohlen, dann verworfen: sqlc ist nicht installiert, und ein zusätzlicher +Codegenerator bedeutet, dass `go build` allein nicht mehr reicht. Handgeschriebenes +`database/sql` ist außerdem besser lesbar für jemanden, der Go lernt. + +**Nachrüstbar,** ohne eine Zeile SQL zu ändern — das SQL ist bereits so +geschrieben, wie sqlc es erwartet. + +--- + +## Teil 2 — Trefferqualität + +**Hier liegt der eigentliche Engpass.** Alles andere in diesem Dokument ist +Ressourcenoptimierung; das hier entscheidet darüber, ob ein Agent findet, was er +sucht. + +### 2.1 Umlaute im Tokenizer falten — ✅ umgesetzt + +`tokenize` bildet `ä→a`, `ö→o`, `ü→u` und `ß→ss` ab, beim Indizieren wie beim +Suchen (siehe `faltUmlaut` in `internal/search/index.go`). + +``` +prüfung ↔ prufung ✓ +größe ↔ grosse ✓ +prüfung ↔ pruefung ✗ +``` + +**Korrektur zur früheren Fassung dieses Abschnitts:** Dort stand, die Umschreibung +mit e (`ue`, `oe`, `ae`) werde mit abgedeckt. Das stimmt nicht. Sie ließe sich nur +abdecken, indem man auch `ue` zu `u` zusammenzieht — dann würde aber aus „neue" +ein „nu" und aus „Steuer" ein „Stur". Der Schaden wäre größer als der Nutzen. +Die Punkte wegzulassen ist ohnehin die weit häufigere Schreibweise. + +Gegenüber FTS5 mit `remove_diacritics` ein Vorteil: Das faltet zwar Umlaute, aber +kein `ß` — dort findet „grosse" die „Größe" nicht. + +### 2.2 Semantische Suche + +**Problem:** BM25 vergleicht Wörter. Fragt ein Agent „wie deployen wir", findet er +einen Eintrag namens „Release-Prozess" nicht — kein Wort passt. + +**Lösung:** Einbettungen (Embeddings) plus Vektorsuche, kombiniert mit BM25 +(„hybride Suche"): BM25 ist stark bei seltenen, exakten Begriffen — Fehlercodes, +Eigennamen —, die Vektorsuche bei Umschreibungen. + +**Bauform:** ein eigener kleiner Dienst, angebunden über HTTP. Python hat dafür +das deutlich reichere Ökosystem; die Go-Anwendung bliebe ein Binary. + +**Aufwand:** mehrere Tage, plus laufender Betrieb eines zweiten Dienstes und +eines Einbettungsmodells. + +**Vorher versuchen:** die `instructions` an echten Anfragen schärfen (siehe 3.1). +Erfahrungsgemäß holt das einen guten Teil des Unterschieds, kostet aber nichts. + +### 2.3 Lange Einträge zerlegen + +`get` liefert einen Eintrag ganz oder gar nicht. Ein Eintrag mit 3.000 Wörtern +belegt beim Agenten Kontext, von dem er zwei Absätze braucht. + +**Lösung:** Einträge in Abschnitte zerlegen (an Überschriften) und abschnittsweise +zurückgeben, mit Verweis auf den vollständigen Eintrag. + +**Vorher prüfen:** ob überhaupt so lange Einträge entstehen. Bei einem +Agentengedächtnis sind die meisten Einträge kurz — dann wäre das eine Lösung ohne +Problem. + +### 2.4 Stoppwörter und Wortstämme + +Häufige Wörter („und", „der", „ist") blähen den Index auf, ohne zu unterscheiden. +Und „Deployment" findet „Deployments" nicht. + +Beides ist Standardwerkzeug jeder Suchmaschine, kostet aber Genauigkeit bei +Fachbegriffen. **Erst messen, ob es im Alltag stört** — BM25 gewichtet häufige +Wörter ohnehin bereits herunter. + +--- + +## Teil 3 — Funktionen für Agenten + +### 3.1 Die `instructions` an echten Anfragen schärfen + +Der Text in `internal/mcpserver/server.go` steht bei jedem Client im Kontext und +entscheidet darüber, ob ein Agent vor dem Schreiben sucht oder blind Dubletten +anlegt. + +**Vorgehen:** Nach ein paar Wochen Betrieb ins Protokoll schauen — wie viele +`remember`-Aufrufe legen neu an, wie viele aktualisieren? Ein hoher Anteil +Neuanlagen bei ähnlichen Themen heißt: Die Anleitung greift nicht. + +**Kostet nichts und wirkt sofort.** Deshalb steht es hier ganz oben. + +### 3.2 Importer als Gegenstück zum Export + +Der Markdown-Export ist heute eine Einbahnstraße. Das Dateiformat trägt alle +Felder im Kopf, ein Einlesen wäre also möglich. + +**Wozu:** Umzug auf einen anderen Server, Wiederherstellung aus der Sicherung, +Erstbefüllung aus vorhandener Dokumentation. + +**Aufwand:** ein halber Tag. Zu klären wäre, was bei einem Zusammenstoß passiert +— überschreiben, überspringen oder abbrechen. + +### 3.3 Dubletten über Ähnlichkeit erkennen + +Heute verhindert nur der gleiche Bezeichner eine Dublette. Wählt ein Agent einen +neuen Slug für dasselbe Thema, steht die Erkenntnis zweimal da. + +**Lösung:** Bei `remember` mit neuem Bezeichner den vorhandenen Suchindex über +Titel und Inhalt laufen lassen. Liegt ein Treffer über einer Schwelle, den +Eintrag **nicht** anlegen, sondern die Kandidaten zurückgeben und den Agenten +entscheiden lassen: + +```json +{ + "created": false, + "judgment_required": true, + "candidates": [ + { "slug": "deployment-prozess", "title": "Deployment-Prozess", "score": 0.81 } + ], + "hint": "Passt einer davon? Dann mit dessen slug erneut remember aufrufen. + Sonst mit force: true anlegen." +} +``` + +Der entscheidende Punkt ist, die Entscheidung **zurückzugeben** statt sie zu +treffen. Ein Agent, der einen Hinweis bekommt, korrigiert sich; einer, dem der +Aufruf stillschweigend abgelehnt wird, versucht es mit einem noch schlechteren +Bezeichner. + +Braucht keine Einbettungen — der BM25-Index liegt bereits vor. + +**Vorbild:** [Engram](https://github.com/Gentleman-Programming/engram) macht genau +das über FTS5 (`candidates[]` plus `judgment_required` bei `mem_save`). Das Muster +ist gut und ließe sich mit unserem Index eins zu eins übernehmen. + +### 3.4 Bezeichner vorschlagen lassen + +Der slug ist der Schlüssel gegen Dubletten (siehe 3.3), aber der Agent muss ihn +raten. Ein Werkzeug `suggest_slug(thema)` würde ihm die Arbeit abnehmen: + +- gibt es zum Thema bereits einen Eintrag → dessen slug zurückgeben +- sonst einen neuen vorschlagen, der zu den vorhandenen passt + +Damit wird aus „hoffentlich wählt das Modell einen guten Bezeichner" ein +Nachschlagevorgang. **Aufwand gering**, die Bausteine (Suche, `Slugify`) liegen vor. + +**Vorbild:** Engrams `mem_suggest_topic_key`. + +### 3.5 Aktualität von Wissen + +Ein Agentengedächtnis veraltet still. Ein Eintrag über den Deployment-Prozess von +vor zwei Jahren sieht genauso aus wie einer von gestern — beide kommen mit +demselben Gewicht aus `recall` zurück. + +**Kleiner Schritt zuerst:** Das Änderungsdatum steht bereits in jeder Antwort, +wird aber nicht betont. Einträge jenseits einer Frist zusätzlich mit einem +sichtbaren Vermerk versehen: + +```json +{ "slug": "deployment-prozess", "updated_at": "2024-03-11", + "stale": true, "hint": "Seit 18 Monaten unverändert — vor Verwendung prüfen." } +``` + +**Größerer Schritt:** eine Prüffrist je Eintrag, die nach Art unterschiedlich +schnell abläuft — ein Zugangsverfahren altert schneller als eine +Grundsatzentscheidung. Dazu eine Ansicht „fällige Einträge" für Menschen und ein +Bestätigen, das die Frist zurücksetzt. + +**Vorbild:** Engram führt dafür `review_after` und `expires_at` je Beobachtung, +mit einer Verfallslogik je Typ, sowie ein `mem_review` zum Abarbeiten. Deutlich +ausgebauter als das, was wir bräuchten — aber die Richtung stimmt. + +### 3.6 Änderungshistorie + +`entries.version` zählt hoch, aber alte Fassungen werden nicht aufgehoben. +Überschreibt ein Agent etwas Richtiges mit etwas Falschem, ist das Richtige weg. + +**Lösung:** Tabelle `entry_versions` mit dem vorherigen Inhalt bei jedem +Schreibvorgang. Kostet Platz, gibt aber ein Zurück — und macht die Frage „wer hat +das wann geändert" beantwortbar. + +**Alternative ohne Aufwand:** der Markdown-Export in einem Git-Repository. Dann +liegt die Historie dort, mit `git log` und `git diff`. + +### 3.7 Verwandte Einträge + +Der Verweisgraph (`entry_links`) wird bisher nur für Rückverweise genutzt. Ein +Werkzeug `related(slug)`, das Nachbarn im Graphen liefert, wäre für einen Agenten +ein zweiter Weg zum Kontext — neben der Suche. + +**Aufwand:** gering, die Daten liegen vor. + +### 3.8 Obergrenze gegen durchdrehende Agenten — ✅ umgesetzt + +Ein gültiger Key umgeht das Rate-Limit bewusst (siehe `APIAuth` in +`internal/web/middleware.go`) — richtig für Menschen hinter einer gemeinsamen +Adresse, aber eine Schleife im Agenten hatte damit kein Limit. + +**Umgesetzt als `auth.WriteBudget`:** ein Fixed-Window-Zähler über schreibende +MCP-Aufrufe, Vorgabe **1.000 je Stunde** (`WRITE_LIMIT_HOURLY`), durchgesetzt in +`Handler.requireWrite` in `internal/mcpserver/tools.go`. Lesen bleibt +unbegrenzt. + +**Korrektur zum ursprünglichen Vorschlag:** Dort stand „je Key und Stunde". Das +Budget hängt jetzt am **Projekt**. Der Grund: Ein Limit je Key lässt sich +umgehen, indem man einen zweiten Key anlegt — und das darf jeder Projekt-Admin +selbst. Es hätte also nur vor dem Versehen geschützt, nicht vor dem Verbrauch. +Einzelne Projekte bekommen unter `/admin/projects` eine eigene Obergrenze; setzen +darf sie nur der Plattform-Admin, aus demselben Grund. + +**Drei Dinge, die beim Bauen auffielen:** + +1. **Die Obergrenze darf nicht zwischengespeichert werden.** Die MCP-Server je + Projekt sind Closures über eine Projektzeile, die beim ersten Zugriff gelesen + wurde — eine Änderung in der Verwaltung wäre dort nie angekommen. Genommen + wird deshalb `sess.Project.WriteLimitHourly` aus der Sitzung: Die hat + `APIAuth` für genau diesen Request frisch aus der Datenbank geholt. Aktuell, + und ohne zusätzliche Abfrage im Schreibpfad. + +2. **Jede Ablehnung zu protokollieren hätte das Problem nur verschoben.** Ein + Agent in einer Schleife hätte statt der Einträge das Protokoll vollgeschrieben. + Deshalb gibt `Consume` als dritten Wert zurück, der wievielte Aufruf über der + Grenze das war — vermerkt wird nur der erste je Fenster. + +3. **Die Fehlermeldung geht an ein Sprachmodell, nicht an einen Menschen.** Ein + nackter Fehler führt dazu, dass es sofort wieder versucht oder auf ein anderes + Werkzeug ausweicht. Die Meldung sagt darum ausdrücklich, was zu tun ist: + abbrechen und das Limit melden. + +**Was das Budget nicht ist:** Der Zählerstand liegt im Arbeitsspeicher und ist +nach einem Neustart weg. Als Netz gegen eine Schleife reicht das. Sobald eine +verbindliche Zusage gebraucht wird — oder mehr als ein Prozess läuft —, gehört +der Zähler in die Datenbank. + +--- + +## Teil 4 — Betrieb + +### 4.1 Zugriffsprotokoll — ✅ umgesetzt + +`LogRequests` in `internal/web/middleware.go` schreibt je Anfrage eine Zeile: + +``` +200 POST /mcp 1.475ms claude-desktop +202 POST /mcp 149µs claude-desktop +401 POST /mcp 193µs — +``` + +Statische Dateien und der Healthcheck bleiben draußen (Rauschen), die +Query-Zeichenkette ebenfalls — dort stehen Suchbegriffe, und die sind Inhalt, +kein Betriebsdatum. + +**Zwei Fallstricke, die beim Bauen auffielen:** + +1. Der Mantel um den `ResponseWriter` verdeckte `http.Flusher`. Der MCP-Transport + kommt über `http.ResponseController` und damit über `Unwrap()` heran, die + verbreitetere direkte Typzusicherung `w.(http.Flusher)` hätte aber lautlos + fehlgeschlagen — der Aufrufer nimmt dann an, Leeren werde nicht unterstützt. + Deshalb ist `Flush()` ausdrücklich implementiert. + +2. Der Zugang stand nicht in der Zeile. Das Protokoll liegt ganz außen, damit es + auch abgewiesene Anfragen erfasst — zu diesem Zeitpunkt ist aber noch niemand + angemeldet, und die Sitzung landet später in einer abgeleiteten Kopie des + Requests. Gelöst über einen veränderbaren Halter im Context (`aktorHalter`), + den die Anmelde-Middleware befüllt. + +### 4.2 Automatisierte Tests bei jeder Änderung — ✅ umgesetzt + +`.github/workflows/test.yml` prüft bei jedem Push und jedem Pull Request: +Formatierung, `go vet`, `go test -race` und einen Bau mit `CGO_ENABLED=0`. + +`-race` ist hier kein Beiwerk: Suchindex, Rate-Limiter und die Datenbankpools +werden von vielen Anfragen gleichzeitig benutzt. Der Bau ohne CGO schlägt fehl, +sobald jemand eine Abhängigkeit hinzufügt, die C braucht — das statische Binary +ist die Grundlage des ganzen Deployments. + +### 4.3 Keycloak statt Key-Anmeldung + +Vorgesehen und im Schema bereits berücksichtigt: Das Protokoll verweist auf +`api_key_id`, nicht auf Klarnamen. Kommt eine Nutzerverwaltung dazu, lässt sich +`user_id` danebenstellen, ohne die Historie zu verlieren. + +**Zu ergänzen:** Tabellen `users` und `memberships`, OIDC-Anmeldung, Rollen an +Personen statt an Keys. + +### 4.4 Mehrere Workspaces + +Die Spalte `workspace_id` steckt von Anfang an in allen Tabellen, es gibt aber nur +einen und keine Oberfläche dafür. Nötig, sobald Abteilungen sich gegenseitig +nicht sehen sollen. + +### 4.5 Kennzahlen + +Ein `/metrics`-Endpunkt mit Anfragen, Dauern, Eintragszahlen. Sinnvoll, sobald es +ein Überwachungssystem gibt, in das es hineinfließt — vorher nicht. + +--- + +## Teil 5 — Oberfläche + +| | Nutzen | Aufwand | +|---|---|---| +| Vorschau beim Bearbeiten | Markdown sofort sehen | mittel (braucht JavaScript) | +| Autovervollständigung für `[[…]]` | Verweise ohne Abtippen | mittel | +| Graph-Ansicht der Verweise | Zusammenhänge sehen | hoch | +| Vergleich zweier Fassungen | setzt 3.6 voraus | mittel | +| Einträge in großer Zahl bearbeiten | Schlagworte nachziehen | gering | + +Nichts davon ist dringend: Die Oberfläche ist bewusst der zweite Zugang, MCP ist +der erste. + +--- + +## Empfohlene Reihenfolge + +Wenn es demnächst weitergehen soll, in dieser Folge — jeweils kleiner Aufwand, +sichtbarer Gewinn: + +~~1. Zugriffsprotokoll (4.1)~~ · ~~2. Umlaute falten (2.1)~~ · +~~3. Automatisierte Tests (4.2)~~ — erledigt. + +~~4. Obergrenze je Projekt (3.8)~~ — erledigt. + +Als Nächstes: + +1. **`instructions` schärfen** (3.1) — braucht ein paar Wochen echter Nutzung. + Das Zugriffsprotokoll und `/p//audit` liefern jetzt die Daten dafür: + Wie viele `remember`-Aufrufe legen neu an, wie viele aktualisieren? +2. **Kandidaten bei Dubletten** (3.3) und **Bezeichner vorschlagen** (3.4) — + beide ändern den MCP-Vertrag und sollten bewusst entschieden werden, nicht + nebenbei. + +Nebenbei zu beobachten: ob 1.000 Aufrufe je Stunde die richtige Zahl sind. Die +Vermutung ist, dass echte Projekte weit darunter bleiben — nachsehen lässt es +sich in der Spalte „Schreibbudget" unter `/admin/projects`. Wenn dort nie etwas +knapp wird, ist die Zahl gut gewählt; wird sie regelmäßig erreicht, ist entweder +das Limit zu eng oder ein Agent tut etwas Unsinniges. + +Alles darüber hinaus erst, wenn der Betrieb zeigt, dass es gebraucht wird. + +**Was ausdrücklich nicht auf dieser Liste steht:** FTS5, getrennte Datenbanken, +semantische Suche. Alle drei sind gute Ideen für den Fall, dass ihre Auslöser +eintreten — und drei gute Wege, Zeit in Probleme zu stecken, die es noch nicht gibt. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dea9f8e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,63 @@ +# ── Stufe 1: Bauen ──────────────────────────────────────────────────────────── +# Das offizielle Go-Image bringt Compiler und Werkzeuge mit. Es ist groß (~800 MB), +# landet aber NICHT im fertigen Abbild — dafür sorgt der zweistufige Aufbau. +FROM golang:1.26-alpine AS builder + +WORKDIR /app + +# Zuerst nur die Abhängigkeitsdateien kopieren und laden. +# Docker legt jeden Schritt im Zwischenspeicher ab: Ändert sich nur der Code, +# aber nicht go.mod/go.sum, müssen die Abhängigkeiten nicht erneut geladen werden. +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +# CGO_ENABLED=0 → reines Go ohne C-Anteile, ergibt ein statisches Binary. +# Möglich ist das nur, weil der SQLite-Treiber (modernc.org/sqlite) +# selbst in Go geschrieben ist — die verbreitete Alternative +# mattn/go-sqlite3 bindet C ein und würde das hier verhindern. +# -trimpath → entfernt lokale Pfade aus dem Binary; sie stünden sonst in +# Fehlerausgaben und verrieten Details der Baumaschine. +# -ldflags="-s -w" → lässt Debug-Symbole weg, das Binary wird kleiner. +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o munin . + + +# ── Stufe 2: Laufzeit ───────────────────────────────────────────────────────── +# Alpine ist ein minimales Linux (~5 MB). Kein Compiler, keine Quelldateien. +FROM alpine:3.21 + +WORKDIR /app + +# Nur das fertige Binary übernehmen. Templates und statische Dateien stecken +# darin eingebettet (//go:embed in main.go), sie brauchen keinen eigenen Ordner. +COPY --from=builder /app/munin . + +# wget wird für den Healthcheck weiter unten gebraucht; in Alpine steckt es in +# busybox und ist bereits vorhanden. + +EXPOSE 8080 + +ENV DB_PATH=/data/munin.db +ENV PORT=8080 + +# Im Container steht praktisch immer ein Reverse-Proxy mit HTTPS davor. +ENV COOKIE_SECURE=true + +# ADMIN_TOKEN und SESSION_SECRET haben bewusst keinen Vorgabewert — ohne sie +# startet der Server nicht. Ein erratbarer Standardwert wäre eine Hintertür. + +# Als gewöhnlicher Benutzer laufen statt als root: Bricht jemand über eine Lücke +# aus der Anwendung aus, hat er sonst sofort volle Rechte im Container. +# Der Datenordner gehört diesem Benutzer, damit die Datenbank beschreibbar bleibt. +RUN adduser -D -u 10001 munin && mkdir -p /data && chown -R munin:munin /data /app +USER munin + +# Die Datenbank gehört in ein Volume — sonst ist sie beim nächsten +# "docker run" verschwunden. +VOLUME ["/data"] + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --quiet --tries=1 --spider http://127.0.0.1:8080/healthz || exit 1 + +CMD ["./munin"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..1a46ba4 --- /dev/null +++ b/README.md @@ -0,0 +1,484 @@ +# 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──┐ + ├──▶ Projekt ──▶ Einträge (SQLite) +Browser ──HTTP─────┘ +``` + +--- + +## Inhalt + +- [Schnellstart](#schnellstart) +- [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. **Projekt anlegen** unter *Projekte* +2. **Zugang erzeugen** — der Key wird genau einmal angezeigt +3. **MCP-Client einrichten** (die Anleitung erscheint direkt neben dem neuen Key) + +--- + +## Grundbegriffe + +| Begriff | Bedeutung | +|---|---| +| **Projekt** | Die Einheit der Abschottung. Eigene Einträge, eigene Zugänge, eigener MCP-Endpunkt. 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 Projekte an, kommt an jedes Projekt. | + +### 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 das 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"] + } + } +} +``` + +`autoApprove` nennt die Werkzeuge, die ohne Rückfrage laufen dürfen. Die drei +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`, `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. 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. | +| `remember` | write | Legt einen Eintrag an oder aktualisiert ihn. | +| `forget` | write | Löscht einen Eintrag. | + +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. + +### 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/projects` | Projekte anlegen und auflisten (nur Plattform-Admin) | +| `/admin/projects/{projekt}/edit` | Name, Beschreibung und Schreibbudget ändern; Projekt löschen | +| `/p/{projekt}/` | Einträge durchsuchen, nach Schlagwort filtern | +| `/p/{projekt}/e/{bezeichner}` | Eintrag lesen, mit Rückverweisen | +| `/p/{projekt}/keys` | Zugänge verwalten (Rolle `admin`) | +| `/p/{projekt}/audit` | Protokoll des Projekts | +| `/healthz` | Healthcheck, ohne Anmeldung | + +### 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 16 Zeichen. | +| `DB_PATH` | `data/munin.db` | Pfad zur SQLite-Datenbank | +| `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 | +|---|---| +| 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` | +| 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: + +- 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 pflegt nur der Plattform-Admin.** Ein Team kann seinen eigenen + Projektnamen nicht selbst korrigieren. Solange es wenige Projekte gibt, ist + eine Berechtigungsstufe weniger den Umweg wert. +- **Kein TLS im Server selbst.** Für HTTPS gehört ein Reverse-Proxy davor. +- **Ein Workspace.** Die Struktur für mehrere ist im Schema angelegt + (`workspace_id`), aber nicht in der Oberfläche. +- **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 + +### 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. + +### 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.** Alles steckt in einer Datei. Weil SQLite im WAL-Modus läuft, sollte +sie nicht im laufenden Betrieb kopiert werden: + +```bash +sqlite3 /pfad/munin.db ".backup '/sicherung/wissen-$(date +%F).db'" +``` + +**Markdown-Export.** Ist `EXPORT_DIR` gesetzt, schreibt der Server den Bestand +täglich als Markdown-Dateien dorthin — ein Ordner je Projekt, eine Datei je +Eintrag, dazu eine `_index.md` als Übersicht: + +``` +export/ + plattform/ + _index.md + deployment.md + server-zugang.md +``` + +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 +# Für Cron oder ein Sicherungsskript: +./munin -export +``` + +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. + +--- + +## 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 + entry/ Fachlogik: Markdown, Verweise, Textausschnitte + 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 +templates/ static/ eingebettet ins Binary +``` + +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. Nur +deshalb lässt sich das Programm als statisches Binary ohne Laufzeitumgebung +ausliefern. + +Das SQL vermeidet bewusst alles, was nur SQLite kann (kein `AUTOINCREMENT`, keine +Datumsfunktionen, keine `INSERT OR REPLACE`), damit ein Umstieg auf PostgreSQL +offenbleibt. Die Regeln stehen als Kommentar in +`internal/store/migrations/001_init.sql`. + +**Schema ändern:** eine neue Datei `003_….sql` in `migrations/` anlegen. Sie wird +beim nächsten Start automatisch angewandt und vermerkt; bestehende Migrationen +werden nie verändert. + +--- + +## 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. diff --git a/REPOMAP.md b/REPOMAP.md new file mode 100644 index 0000000..60236af --- /dev/null +++ b/REPOMAP.md @@ -0,0 +1,278 @@ +# Repomap — Munin + +Übersicht aller Dateien und Ordner mit ihrer Aufgabe. + +``` +munin/ +├── main.go ← Einstiegspunkt. Liest die Konfiguration, prüft die +│ Pflichtangaben (SESSION_SECRET, ADMIN_TOKEN inkl. +│ Mindestlänge), öffnet die Datenbank, setzt die +│ Bestandteile zusammen und startet den Server. +│ Enthält außerdem die Server-Timeouts (gegen +│ Slowloris), die Body-Größenlimits und die +│ tägliche Aufräumaufgabe fürs Protokoll. +│ +├── go.mod / go.sum ← Moduldefinition und Prüfsummen der Abhängigkeiten. +│ +├── Dockerfile ← Zweistufiger Bau: Stufe 1 kompiliert mit dem +│ Go-Compiler, Stufe 2 packt nur das fertige Binary +│ in ein minimales Alpine-Abbild. Läuft als +│ gewöhnlicher Benutzer, mit Healthcheck und Volume +│ für die Datenbank. +│ +├── .env.example ← Vorlage für .env, mit Erklärung jeder Variable. +├── .gitignore / .dockerignore ← Schließen Datenbank, Binary und Geheimnisse aus. +│ +├── README.md ← Anleitung: Schnellstart, MCP einrichten, Werkzeuge, +│ Konfiguration, Sicherheit, Betrieb. +├── AGENTEN-GUIDE.md ← Wie man KI-Werkzeuge anbindet und anspricht, +│ damit sie den Speicher tatsächlich nutzen: +│ Projektprompt zum Übernehmen, slug-Regeln, +│ Muster für einzelne Aufgaben. +├── AUSBAUSTUFEN.md ← Was bewusst nicht gebaut wurde: Messwerte, +│ Begründung und die Auslöser, an denen man +│ merkt, dass es Zeit dafür wird. +├── REPOMAP.md ← Diese Datei. +│ +├── internal/ +│ │ +│ ├── store/ ── Die einzige Stelle, die SQL kennt ────────────── +│ │ ├── store.go ← Öffnet die Datenbank, setzt die nötigen PRAGMAs +│ │ │ (WAL, busy_timeout, foreign_keys) und wendet die +│ │ │ Migrationen an. Erklärt auch, warum der Treiber +│ │ │ modernc.org/sqlite ist: Er ist reines Go und +│ │ │ erlaubt damit ein statisches Binary. +│ │ │ +│ │ ├── models.go ← Die Datentypen (Project, APIKey, Entry, …), die +│ │ │ Fehlerwerte (ErrNotFound, ErrConflict, +│ │ │ ErrDuplicate) sowie ID- und Zeitbehandlung. +│ │ │ NewID erzeugt sortierbare IDs in Go statt per +│ │ │ AUTOINCREMENT — das hält den Umstieg auf eine +│ │ │ andere Datenbank offen. +│ │ │ +│ │ ├── projects.go ← Workspace und Projekte: anlegen, lesen, ändern, +│ │ │ löschen. Enthält ValidSlug und Slugify sowie +│ │ │ SetProjectWriteLimit für die eigene Obergrenze +│ │ │ eines Projekts. +│ │ │ +│ │ ├── keys.go ← API-Keys. ResolveKeyHash ist der heißeste Pfad im +│ │ │ Programm (jeder MCP-Request) und deshalb ein +│ │ │ indizierter Lookup über die Prüfsumme, keine +│ │ │ Schleife. Zurückgezogene Keys gelten als nicht +│ │ │ vorhanden, der Datensatz bleibt fürs Protokoll. +│ │ │ +│ │ ├── entries.go ← Einträge. PutEntry legt an ODER aktualisiert — +│ │ │ der Bezeichner entscheidet, und genau das ist der +│ │ │ Mechanismus gegen Dubletten. Enthält das +│ │ │ optimistische Sperren (ExpectedVersion) und das +│ │ │ Nachladen der Schlagworte in einer Abfrage statt +│ │ │ einer pro Eintrag. +│ │ │ +│ │ ├── links.go ← Erkennt [[bezeichner]] im Inhalt und ersetzt sie +│ │ │ beim Rendern. Das ersetzt die frühere +│ │ │ Ordnerhierarchie als Struktur zwischen Einträgen. +│ │ │ +│ │ ├── audit.go ← Protokoll: schreiben, lesen, aufräumen. Die +│ │ │ Aufbewahrungsfrist ist nicht nur eine Frage des +│ │ │ Plattenplatzes — das Protokoll enthält Adressen +│ │ │ und Zugangsnamen. +│ │ │ +│ │ └── migrations/ +│ │ ├── 001_init.sql ← Das Schema. Der Kopfkommentar nennt die Regeln +│ │ │ für portables SQL, an die sich alle weiteren +│ │ │ Migrationen halten sollen. +│ │ ├── 002_projekt_in_nebentabellen.sql +│ │ ← Führt project_id auch in entry_tags und +│ │ entry_links. Eine bewusste Redundanz gegen zwei +│ │ gemessene Bremsen: das Schlagwortmenü lief über +│ │ alle Einträge (6,7 ms bei jedem Seitenaufruf), +│ │ und bei den Rückverweisen kippte durch ein +│ │ ORDER BY die Verknüpfungsreihenfolge +│ │ (3,1 ms statt 9 µs). +│ │ └── 003_schreibbudget_je_projekt.sql +│ │ ← Spalte write_limit_hourly an projects. Vorgabe 0 +│ │ heißt "keine eigene Angabe" — dann gilt der +│ │ Standard aus WRITE_LIMIT_HOURLY. So muss die +│ │ Migration keine Daten anfassen. +│ │ +│ ├── auth/ ── Wer ist das? ──────────────────────────────────── +│ │ ├── doc.go ← Erklärt die beiden Wege hinein (API-Key im Header, +│ │ │ Sitzungs-Cookie im Browser) und was hier bewusst +│ │ │ NICHT liegt: die Frage, was jemand darf. +│ │ │ +│ │ ├── keys.go ← Erzeugen, Prüfsumme bilden, Form prüfen. Enthält +│ │ │ die Rollen (read/write/admin) und die Begründung, +│ │ │ warum SHA-256 statt bcrypt richtig ist. +│ │ │ +│ │ ├── session.go ← Signierte Sitzungs-Cookies (HMAC-SHA256). Der +│ │ │ Cookie trägt nur eine Behauptung ("ich bin Key X"), +│ │ │ nie den Key selbst. +│ │ │ +│ │ ├── csrf.go ← CSRF-Tokens aus Sitzung und Geheimnis, ohne Zustand +│ │ │ auf dem Server. Der Kopfkommentar erklärt den +│ │ │ Angriff, gegen den das schützt. +│ │ │ +│ │ ├── cookies.go ← Setzt HttpOnly, SameSite und Secure an einer +│ │ │ Stelle. Secure ist ohne Konfiguration an, außer +│ │ │ bei localhost — damit die lokale Entwicklung über +│ │ │ http weiter funktioniert. +│ │ │ +│ │ ├── ratelimit.go ← Zwei Begrenzungen auf einem gemeinsamen +│ │ │ Fixed-Window-Zähler: RateLimiter sperrt eine +│ │ │ Adresse nach zu vielen Fehlversuchen, +│ │ │ WriteBudget deckelt die schreibenden +│ │ │ MCP-Aufrufe je Projekt und Stunde. Letzteres +│ │ │ hängt am Projekt und nicht am Key — ein +│ │ │ Key-Limit ließe sich mit einem zweiten Key +│ │ │ umgehen. +│ │ │ +│ │ └── trustedproxy.go ← Entscheidet, wann X-Forwarded-For geglaubt wird: +│ │ nur bei einer über TRUSTED_PROXY hinterlegten +│ │ Absenderadresse. Ohne Konfiguration wird der +│ │ Header ignoriert — er ist frei fälschbar. +│ │ +│ ├── entry/ ── Wie sieht ein Eintrag aus? ───────────────────── +│ │ └── entry.go ← Markdown nach HTML (goldmark, ohne Roh-HTML), +│ │ Auflösen der [[…]]-Verweise, Textausschnitte für +│ │ Trefferlisten samt sicherer Hervorhebung. +│ │ +│ ├── export/ ── Sicherung als Markdown ───────────────────────── +│ │ └── export.go ← Schreibt jedes Projekt als Ordner voller +│ │ Markdown-Dateien, mit allen Feldern im Dateikopf. +│ │ Gedacht als Git-fähige Sicherung: Ein Lauf ohne +│ │ inhaltliche Änderungen fasst keine Datei an und +│ │ erzeugt deshalb auch keinen Diff. Verwaiste +│ │ Dateien gelöschter Einträge werden entfernt, +│ │ fremde Dateien im Zielordner bleiben unberührt. +│ │ +│ ├── search/ ── Volltextsuche ────────────────────────────────── +│ │ ├── index.go ← Invertierter Index mit BM25-Bewertung. Titel zählen +│ │ │ dreifach, Schlagworte zweifach. Enthält die +│ │ │ Präfix-Rückfallebene: Ohne sie findet "deploy" +│ │ │ den "Deployment-Prozess" nicht — bei einer +│ │ │ KI-Anfrage ein stiller Fehltreffer. +│ │ │ +│ │ └── registry.go ← Ein Index je Projekt, verzögert aufgebaut. Getrennte +│ │ Indizes statt eines gemeinsamen mit Filter: So wird +│ │ die Abschottung strukturell, und die BM25-Werte +│ │ werden nicht über Projektgrenzen hinweg verzerrt. +│ │ +│ ├── web/ ── HTTP: Browser und API ────────────────────────── +│ │ ├── context.go ← Session-Typ (wer greift zu, was darf er) und die +│ │ │ Ablage im Request-Context. CanAccessProject ist +│ │ │ die Mandantentrennung der Weboberfläche. +│ │ │ +│ │ ├── middleware.go ← Sitzungsprüfung (inkl. "gilt der Key noch?"), +│ │ │ CSRF, Projektauflösung aus der URL, Rollenprüfung. +│ │ │ Dazu die allgemeinen Schutzschichten +│ │ │ SecurityHeaders und LimitBody sowie APIAuth für +│ │ │ den MCP-Endpunkt. +│ │ │ +│ │ ├── server.go ← Der Server-Typ, alle Routen und das Rendern der +│ │ │ Templates (erst in einen Puffer, dann senden — +│ │ │ sonst gäbe es bei einem Fehler eine halbe Seite +│ │ │ mit Status 200). +│ │ │ +│ │ ├── handlers_auth.go ← Anmelden, Abmelden, Startseite. +│ │ ├── handlers_entries.go ← Einträge auflisten, suchen, ansehen, bearbeiten. +│ │ └── handlers_admin.go ← Projekte, Zugänge, Protokollansicht. Die +│ │ Bearbeitungsseite je Projekt (Name, +│ │ Beschreibung, Schreibbudget, Löschen) liegt +│ │ hier — bewusst eine Seite je Projekt statt +│ │ eines Auswahlfelds über alle: Nur so stehen die +│ │ tatsächlichen Werte in den Feldern, und ein +│ │ Speichern ohne Änderung überschreibt nichts. +│ │ +│ └── mcpserver/ ── Die Hauptschnittstelle ──────────────────────── +│ ├── server.go ← Handler und Transport (Streamable HTTP), ein +│ │ MCP-Server je Projekt. Enthält den Text der +│ │ `instructions` — das wirksamste Stellrad für die +│ │ Qualität der Antworten, weil er bei jedem Client +│ │ im Kontext steht. +│ │ +│ └── tools.go ← Die fünf Werkzeuge: recall, get, list, remember, +│ forget. Jedes ist eine Closure über genau ein +│ Projekt und kann strukturell nicht an ein anderes. +│ +├── templates/ ← HTML der Weboberfläche. partials.html enthält die +│ gemeinsamen Blöcke "kopf" und "fuss", die jede +│ Seite einbindet — kein verschachteltes Layout. +│ +├── static/ +│ ├── style.css ← Das gesamte Aussehen, ohne Framework. Farben als +│ │ Variablen ganz oben, helle und dunkle Darstellung. +│ └── confirm.js ← Bestätigungsabfrage vor dem Löschen. Liest das +│ Attribut data-confirm, statt onclick="" im HTML zu +│ verwenden — Inline-JavaScript verbietet die +│ Content-Security-Policy. +│ +└── data/ ← Datenbank (nicht im Repository, als Volume gemountet) + └── munin.db +``` + +## Tests + +Jedes Package hat seine Tests direkt daneben. Sie laufen mit `go test ./...` ohne +weitere Vorbereitung — die Datenbanktests legen jeweils eine echte SQLite-Datei +in einem Testverzeichnis an, damit auch Migrationen und Fremdschlüssel geprüft +werden. + +| Datei | Schwerpunkt | +|---|---| +| `internal/auth/auth_test.go` | Signatur, Manipulation, Ablauf, CSRF, Key-Form, Rollen, Cookie-Flags, Rate-Limiting, X-Forwarded-For | +| `internal/store/store_test.go` | Migrationen, Slug-Prüfung, Abschottung, Dublettenvermeidung, optimistisches Sperren, Verweise, Protokoll | +| `internal/entry/entry_test.go` | HTML-Escaping, Verweisauflösung, Textausschnitte, sichere Hervorhebung | +| `internal/search/search_test.go` | Bewertung, Präfix-Rückfallebene, Aktualisieren und Entfernen | +| `internal/export/export_test.go` | Dateikopf und YAML-Maskierung, Aufräumen verwaister Dateien, Stabilität über mehrere Läufe | +| `internal/web/web_test.go` | Anmeldung, zurückgezogene Keys, CSRF, **Projektabschottung**, Rollen, Header | +| `internal/mcpserver/mcpserver_test.go` | Werkzeugbestand, `instructions`, Dublettenvermeidung, Konflikte, Rollen, **Projektabschottung** | + +## Datenfluss: Agent ruft Wissen ab + +``` +KI-Werkzeug POST /mcp { "method": "tools/call", "params": { "name": "recall", … } } + → web/middleware.go (SecurityHeaders, LimitBody) + → web/middleware.go (APIAuth) Key → Prüfsumme → Lookup → Projekt + Rolle + in den Context; Fehlversuche in die Sperre + → mcpserver/server.go (ServeHTTP) Projekt aus dem Context → dessen MCP-Server + → mcpserver/tools.go (recall) + → search/registry.go Index dieses Projekts (beim ersten Mal + aus der Datenbank aufgebaut) + → search/index.go (Search) BM25, bei Bedarf Präfix-Rückfallebene + → store/entries.go (EntryBySlug) Treffer nachladen + → entry/entry.go (Snippet) Textausschnitt je Treffer + → JSON-Antwort mit Trefferliste und Hinweis zum weiteren Vorgehen +``` + +## Datenfluss: Agent legt Wissen ab + +``` +KI-Werkzeug POST /mcp { … "name": "remember", "arguments": { "slug": …, "content": … } } + → web/middleware.go (APIAuth) wie oben + → mcpserver/tools.go (remember) + → requireWrite() Rolle read wird hier abgewiesen + → store/entries.go (PutEntry) eine Transaktion: + · Bezeichner vorhanden? → aktualisieren + · ExpectedVersion prüfen → ggf. Konflikt + · Schlagworte und Verweise ersetzen + · Herkunft (agent) und Zugangsname setzen + → search/registry.go (Update) Index nachziehen + → store/audit.go (LogAudit) wer, was, wann + → Antwort: Bezeichner, neue Fassung, angelegt oder aktualisiert +``` + +## Datenfluss: Mensch bearbeitet im Browser + +``` +Browser POST /p/plattform/e/deployment/save + → web/middleware.go (SecurityHeaders, LimitBody) + → web/middleware.go (requireSession) Cookie prüfen → Key aus der Datenbank + laden → gilt er noch? → CSRF-Token prüfen + → web/middleware.go (requireProject) Projekt aus der URL; fremdes Projekt → 404 + → web/middleware.go (requireWrite) Rollenprüfung + → web/handlers_entries.go (handleEntrySave) + → store/entries.go (PutEntry) Herkunft: human, Autor: Key-Name + → search/registry.go (Update) + → store/audit.go (LogAudit) + → Weiterleitung zur Detailansicht +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c876f7f --- /dev/null +++ b/go.mod @@ -0,0 +1,27 @@ +module munin + +go 1.26.4 + +require ( + github.com/joho/godotenv v1.5.1 + github.com/modelcontextprotocol/go-sdk v1.6.1 + github.com/yuin/goldmark v1.8.2 + modernc.org/sqlite v1.58.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sys v0.47.0 // indirect + modernc.org/libc v1.75.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e1568ed --- /dev/null +++ b/go.sum @@ -0,0 +1,70 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= +github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= +github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= +modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w= +modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus= +modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.58.0 h1:38u40/bwkfM7f0Myhosl+SEMltSDxnGdQf8o6Kjmys0= +modernc.org/sqlite v1.58.0/go.mod h1:rsD2CckafgObKC4DhBlGBf+RiHxkc3hINGt1Xw32tVY= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..0142f7c --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,487 @@ +package auth + +import ( + "encoding/base64" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +var testSigner = NewSigner([]byte("ein-test-secret-das-lang-genug-ist-fuer-tests")) + +// ── Sitzungen ──────────────────────────────────────────────────────────────── + +func TestSessionRoundTrip(t *testing.T) { + cookie := testSigner.SignSession("key:abc123") + + subject, ok := testSigner.VerifySession(cookie) + if !ok { + t.Fatal("frisch signierte Sitzung wurde als ungültig abgelehnt") + } + if subject != "key:abc123" { + t.Errorf("subject = %q, erwartet %q", subject, "key:abc123") + } +} + +// TestSessionRejectsWrongSecret ist der Kern des Verfahrens: ohne das richtige +// Geheimnis darf sich niemand eine Sitzung ausstellen können. +func TestSessionRejectsWrongSecret(t *testing.T) { + cookie := testSigner.SignSession(SubjectPlatformAdmin) + fremder := NewSigner([]byte("ein-voellig-anderes-secret")) + + if _, ok := fremder.VerifySession(cookie); ok { + t.Error("Sitzung wurde mit falschem Secret akzeptiert") + } +} + +// TestSessionRejectsTampering prüft den naheliegendsten Angriff: den Cookie +// auslesen (er ist nur base64, nicht verschlüsselt), "key:xyz" durch "platform" +// ersetzen und wieder einsetzen. +func TestSessionRejectsTampering(t *testing.T) { + cookie := testSigner.SignSession("key:abc123") + _, signature, _ := strings.Cut(cookie, ".") + + gefaelscht := base64.RawURLEncoding.EncodeToString( + fmt.Appendf(nil, "%s|%d", SubjectPlatformAdmin, time.Now().Add(time.Hour).Unix()), + ) + + if _, ok := testSigner.VerifySession(gefaelscht + "." + signature); ok { + t.Error("manipulierter Cookie wurde akzeptiert — die Signatur greift nicht") + } +} + +// TestSessionRejectsExpired baut die Nutzlast von Hand mit einem Zeitstempel in +// der Vergangenheit und signiert sie korrekt — so wird wirklich nur die +// Ablaufprüfung getestet, ohne eine Stunde zu warten. +func TestSessionRejectsExpired(t *testing.T) { + payload := fmt.Sprintf("key:abc123|%d", time.Now().Add(-time.Minute).Unix()) + cookie := base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + testSigner.sign(payload) + + if _, ok := testSigner.VerifySession(cookie); ok { + t.Error("abgelaufene Sitzung wurde akzeptiert") + } +} + +// TestSessionRejectsMalformed wirft der Prüfung kaputte Eingaben vor die Füße. +// Wichtig ist vor allem, dass nichts davon zu einem Absturz führt. +func TestSessionRejectsMalformed(t *testing.T) { + cases := []string{ + "", + "kein-punkt-drin", + ".", + "nicht-base64!!!.abcdef", + base64.RawURLEncoding.EncodeToString([]byte("ohne-trenner")) + ".abcdef", + base64.RawURLEncoding.EncodeToString([]byte("key:a|keine-zahl")) + ".abcdef", + } + + for _, cookie := range cases { + if _, ok := testSigner.VerifySession(cookie); ok { + t.Errorf("kaputter Cookie wurde akzeptiert: %q", cookie) + } + } +} + +func TestKeyIDFromSubject(t *testing.T) { + if id, ok := KeyIDFromSubject("key:abc123"); !ok || id != "abc123" { + t.Errorf("KeyIDFromSubject = (%q, %v), erwartet (\"abc123\", true)", id, ok) + } + if _, ok := KeyIDFromSubject(SubjectPlatformAdmin); ok { + t.Error("Plattform-Admin hat fälschlich eine Key-ID geliefert") + } +} + +// ── CSRF ───────────────────────────────────────────────────────────────────── + +func TestCSRFToken(t *testing.T) { + token := testSigner.CSRFToken("key:abc123") + + if !testSigner.ValidCSRFToken(token, "key:abc123") { + t.Error("gültiges CSRF-Token wurde abgelehnt") + } + if testSigner.ValidCSRFToken(token, "key:anderer") { + t.Error("CSRF-Token einer Sitzung galt auch für eine andere") + } + if testSigner.ValidCSRFToken("", "key:abc123") { + t.Error("leeres CSRF-Token wurde akzeptiert") + } +} + +// TestTokenDomainSeparation prüft, dass ein CSRF-Token (das offen im HTML steht) +// nicht als Session-Signatur durchgeht. +func TestTokenDomainSeparation(t *testing.T) { + subject := "key:abc123" + csrf := testSigner.CSRFToken(subject) + + if _, ok := testSigner.VerifySession( + base64.RawURLEncoding.EncodeToString([]byte(subject)) + "." + csrf, + ); ok { + t.Error("ein CSRF-Token wurde als gültige Session-Signatur akzeptiert") + } +} + +// ── API-Keys ───────────────────────────────────────────────────────────────── + +func TestGenerateKeyFormat(t *testing.T) { + key, err := GenerateKey() + if err != nil { + t.Fatalf("Key konnte nicht erzeugt werden: %v", err) + } + if !strings.HasPrefix(key, KeyPrefix) { + t.Errorf("Key %q hat nicht das Präfix %q", key, KeyPrefix) + } + if !LooksLikeKey(key) { + t.Errorf("selbst erzeugter Key besteht die Formprüfung nicht: %q", key) + } +} + +// TestGenerateKeyIsUnique prüft, dass wirklich Zufall im Spiel ist. +func TestGenerateKeyIsUnique(t *testing.T) { + gesehen := make(map[string]bool) + for i := 0; i < 100; i++ { + key, err := GenerateKey() + if err != nil { + t.Fatal(err) + } + if gesehen[key] { + t.Fatal("zweimal derselbe Key erzeugt") + } + gesehen[key] = true + } +} + +func TestHashKey(t *testing.T) { + key := "wk_" + strings.Repeat("a", 64) + + if HashKey(key) != HashKey(key) { + t.Error("derselbe Key ergibt unterschiedliche Hashes") + } + if HashKey(key) == HashKey(key+"x") { + t.Error("unterschiedliche Keys ergeben denselben Hash") + } + // Der Hash darf den Key nicht enthalten — sonst wäre die Datenbank so gut + // wie eine Klartext-Ablage. + if strings.Contains(HashKey(key), key) { + t.Error("der Hash enthält den Key im Klartext") + } +} + +func TestLooksLikeKey(t *testing.T) { + gueltig := KeyPrefix + strings.Repeat("ab", 32) + + cases := map[string]bool{ + gueltig: true, + "": false, + "wk_": false, + strings.Repeat("ab", 32): false, // ohne Präfix + KeyPrefix + "zz": false, // zu kurz + KeyPrefix + strings.Repeat("g", 64): false, // kein Hex + } + + for key, erwartet := range cases { + if got := LooksLikeKey(key); got != erwartet { + t.Errorf("LooksLikeKey(%.20q…) = %v, erwartet %v", key, got, erwartet) + } + } +} + +// ── Rollen ─────────────────────────────────────────────────────────────────── + +func TestRolePermissions(t *testing.T) { + cases := []struct { + role Role + valid bool + write bool + manageKeys bool + }{ + {RoleRead, true, false, false}, + {RoleWrite, true, true, false}, + {RoleAdmin, true, true, true}, + {Role("unsinn"), false, false, false}, + {Role(""), false, false, false}, + } + + for _, tc := range cases { + t.Run(string(tc.role), func(t *testing.T) { + if tc.role.Valid() != tc.valid { + t.Errorf("Valid() = %v, erwartet %v", tc.role.Valid(), tc.valid) + } + if tc.role.CanWrite() != tc.write { + t.Errorf("CanWrite() = %v, erwartet %v", tc.role.CanWrite(), tc.write) + } + if tc.role.CanManageKeys() != tc.manageKeys { + t.Errorf("CanManageKeys() = %v, erwartet %v", tc.role.CanManageKeys(), tc.manageKeys) + } + }) + } +} + +// ── Cookie-Policy ──────────────────────────────────────────────────────────── + +func TestCookiePolicySecure(t *testing.T) { + cases := []struct { + name string + env string + host string + erwartet bool + }{ + {"Standard, echte Domain", "", "munin.intern", true}, + {"Standard, localhost", "", "localhost:8080", false}, + {"Standard, 127.0.0.1", "", "127.0.0.1:8080", false}, + {"erzwungen an, localhost", "true", "localhost:8080", true}, + {"erzwungen aus, echte Domain", "false", "munin.intern", false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + policy := NewCookiePolicy(tc.env) + r := httptest.NewRequest(http.MethodGet, "http://"+tc.host+"/", nil) + r.Host = tc.host + + if got := policy.Secure(r); got != tc.erwartet { + t.Errorf("Secure = %v, erwartet %v", got, tc.erwartet) + } + }) + } +} + +func TestCookiePolicyApplySetsFlags(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "http://munin.intern/", nil) + r.Host = "munin.intern" + + cookie := &http.Cookie{Name: "test", Value: "wert"} + NewCookiePolicy("").Apply(cookie, r) + + if !cookie.HttpOnly { + t.Error("HttpOnly fehlt") + } + if !cookie.Secure { + t.Error("Secure fehlt") + } + if cookie.SameSite != http.SameSiteLaxMode { + t.Errorf("SameSite = %v, erwartet Lax", cookie.SameSite) + } + if cookie.Path != "/" { + t.Errorf("Path = %q, erwartet \"/\"", cookie.Path) + } +} + +// ── Rate-Limiting ──────────────────────────────────────────────────────────── + +func TestRateLimiterBlocksAfterMax(t *testing.T) { + limiter := NewRateLimiter() + const ip = "203.0.113.7" + + for i := 0; i < rateLimitMax; i++ { + if allowed, _ := limiter.Allow(ip); !allowed { + t.Fatalf("bereits nach %d Versuchen gesperrt — zu früh", i) + } + limiter.RecordFailure(ip) + } + + allowed, retryAfter := limiter.Allow(ip) + if allowed { + t.Errorf("nach %d Fehlversuchen immer noch erlaubt", rateLimitMax) + } + if retryAfter <= 0 { + t.Errorf("retryAfter = %v, erwartet einen positiven Wert", retryAfter) + } +} + +func TestRateLimiterIsPerIP(t *testing.T) { + limiter := NewRateLimiter() + for i := 0; i < rateLimitMax; i++ { + limiter.RecordFailure("203.0.113.7") + } + + if allowed, _ := limiter.Allow("198.51.100.4"); !allowed { + t.Error("eine andere IP wurde mitgesperrt") + } +} + +func TestRateLimiterResetClearsCounter(t *testing.T) { + limiter := NewRateLimiter() + const ip = "203.0.113.7" + + for i := 0; i < rateLimitMax; i++ { + limiter.RecordFailure(ip) + } + limiter.Reset(ip) + + if allowed, _ := limiter.Allow(ip); !allowed { + t.Error("nach Reset immer noch gesperrt") + } +} + +// ── Schreibbudget ──────────────────────────────────────────────────────────── + +func TestWriteBudgetAllowsUpToLimit(t *testing.T) { + budget := NewWriteBudget(3, time.Hour) + const projekt = "p1" + + for i := 1; i <= 3; i++ { + ok, _, ueber := budget.Consume(projekt, 0) + if !ok { + t.Fatalf("Aufruf %d von 3 abgelehnt", i) + } + if ueber != 0 { + t.Errorf("Aufruf %d: ueberschreitung = %d, erwartet 0", i, ueber) + } + } + + ok, retryAfter, ueber := budget.Consume(projekt, 0) + if ok { + t.Error("der vierte Aufruf wurde angenommen") + } + if retryAfter <= 0 { + t.Errorf("retryAfter = %v, erwartet einen positiven Wert", retryAfter) + } + // Genau 1 beim ersten abgelehnten Aufruf — daran hängt, dass die + // Überschreitung nur einmal je Fenster protokolliert wird. + if ueber != 1 { + t.Errorf("ueberschreitung = %d, erwartet 1 beim ersten abgelehnten Aufruf", ueber) + } + + if _, _, ueber := budget.Consume(projekt, 0); ueber != 2 { + t.Errorf("ueberschreitung = %d, erwartet 2 beim zweiten abgelehnten Aufruf", ueber) + } +} + +func TestWriteBudgetIsPerProject(t *testing.T) { + budget := NewWriteBudget(1, time.Hour) + + budget.Consume("p1", 0) + if ok, _, _ := budget.Consume("p1", 0); ok { + t.Fatal("Vorbedingung verfehlt: p1 ist noch nicht am Limit") + } + + if ok, _, _ := budget.Consume("p2", 0); !ok { + t.Error("ein anderes Projekt wurde mitgesperrt") + } +} + +// TestWriteBudgetProjectLimitBeatsDefault prüft die Auflösung der Obergrenze: +// erst die eigene Angabe des Projekts, sonst der Standard. +func TestWriteBudgetProjectLimitBeatsDefault(t *testing.T) { + budget := NewWriteBudget(100, time.Hour) + + budget.Consume("eng", 1) + if ok, _, _ := budget.Consume("eng", 1); ok { + t.Error("die eigene Obergrenze (1) wurde nicht angewandt") + } + + // 0 heißt "keine eigene Angabe" — dann gilt der großzügige Standard. + if ok, _, _ := budget.Consume("standard", 0); !ok { + t.Error("bei projektLimit 0 wurde nicht der Standard verwendet") + } +} + +// TestWriteBudgetZeroDefaultDisables prüft die Notbremse: WRITE_LIMIT_HOURLY=0 +// schaltet die Begrenzung vollständig ab. +func TestWriteBudgetZeroDefaultDisables(t *testing.T) { + budget := NewWriteBudget(0, time.Hour) + + for i := 0; i < 50; i++ { + if ok, _, _ := budget.Consume("p1", 0); !ok { + t.Fatalf("Aufruf %d abgelehnt, obwohl die Begrenzung aus ist", i+1) + } + } +} + +func TestWriteBudgetRemainingCountsDown(t *testing.T) { + budget := NewWriteBudget(5, time.Hour) + + if frei, grenze := budget.Remaining("p1", 0); frei != 5 || grenze != 5 { + t.Errorf("frei/grenze = %d/%d, erwartet 5/5 vor dem ersten Aufruf", frei, grenze) + } + + budget.Consume("p1", 0) + budget.Consume("p1", 0) + + if frei, _ := budget.Remaining("p1", 0); frei != 3 { + t.Errorf("frei = %d, erwartet 3 nach zwei Aufrufen", frei) + } + + // Remaining selbst darf nichts verbrauchen. + if frei, _ := budget.Remaining("p1", 0); frei != 3 { + t.Errorf("frei = %d — Remaining hat mitgezählt", frei) + } +} + +// TestWriteBudgetWindowExpires prüft, dass ein abgelaufenes Fenster den Zähler +// zurücksetzt statt ihn fortzuschreiben. +func TestWriteBudgetWindowExpires(t *testing.T) { + budget := NewWriteBudget(1, time.Millisecond) + + budget.Consume("p1", 0) + if ok, _, _ := budget.Consume("p1", 0); ok { + t.Fatal("Vorbedingung verfehlt: p1 ist noch nicht am Limit") + } + + time.Sleep(5 * time.Millisecond) + + if ok, _, _ := budget.Consume("p1", 0); !ok { + t.Error("nach Ablauf des Fensters immer noch gesperrt") + } +} + +// ── Client-IP / Trusted Proxy ──────────────────────────────────────────────── + +func TestClientIPIgnoresForwardedByDefault(t *testing.T) { + if err := SetTrustedProxies(""); err != nil { + t.Fatal(err) + } + + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.RemoteAddr = "10.0.0.5:1234" + r.Header.Set("X-Forwarded-For", "1.2.3.4") + + // Ohne TRUSTED_PROXY ist der Header frei erfunden und muss ignoriert werden — + // sonst könnte sich jeder Angreifer bei jedem Versuch eine neue IP geben und + // das Rate-Limiting damit aushebeln. + if got := ClientIP(r); got != "10.0.0.5" { + t.Errorf("ClientIP = %q, erwartet %q", got, "10.0.0.5") + } +} + +func TestClientIPUsesForwardedFromTrustedProxy(t *testing.T) { + if err := SetTrustedProxies("10.0.0.0/8"); err != nil { + t.Fatal(err) + } + defer SetTrustedProxies("") + + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.RemoteAddr = "10.0.0.5:1234" + r.Header.Set("X-Forwarded-For", "1.2.3.4") + + if got := ClientIP(r); got != "1.2.3.4" { + t.Errorf("ClientIP = %q, erwartet %q", got, "1.2.3.4") + } +} + +// TestClientIPTakesRightmostUntrusted prüft den Fall, dass der Client selbst +// schon einen X-Forwarded-For mitschickt und der Proxy nur anhängt. +func TestClientIPTakesRightmostUntrusted(t *testing.T) { + if err := SetTrustedProxies("10.0.0.0/8"); err != nil { + t.Fatal(err) + } + defer SetTrustedProxies("") + + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.RemoteAddr = "10.0.0.5:1234" + // "9.9.9.9" ist die Erfindung des Angreifers, "1.2.3.4" seine echte Adresse. + r.Header.Set("X-Forwarded-For", "9.9.9.9, 1.2.3.4") + + if got := ClientIP(r); got != "1.2.3.4" { + t.Errorf("ClientIP = %q, erwartet %q — die erfundene IP wurde übernommen", got, "1.2.3.4") + } +} + +func TestSetTrustedProxiesRejectsGarbage(t *testing.T) { + if err := SetTrustedProxies("keine-ip"); err == nil { + t.Error("unsinnige Angabe wurde akzeptiert") + } + SetTrustedProxies("") +} diff --git a/internal/auth/cookies.go b/internal/auth/cookies.go new file mode 100644 index 0000000..19bec44 --- /dev/null +++ b/internal/auth/cookies.go @@ -0,0 +1,104 @@ +// Dieses File bündelt die Sicherheits-Einstellungen aller Cookies an einer Stelle. +// +// Vorher setzte jeder Handler seine Cookies selbst und jeder setzte etwas andere +// Flags — der Session-Cookie hatte HttpOnly, aber weder Secure noch SameSite. +// Solche Kleinigkeiten übersieht man leicht, deshalb gibt es jetzt genau eine +// Funktion die ein Cookie "scharf schaltet". +// +// # Die drei Flags und was sie bewirken +// +// - HttpOnly: JavaScript im Browser kann das Cookie nicht auslesen. Schützt +// davor, dass eine XSS-Lücke direkt zur Übernahme der Session führt. +// +// - Secure: Der Browser schickt das Cookie nur über HTTPS. Ohne dieses Flag +// würde ein einziger versehentlicher http://-Aufruf die Session im Klartext +// durchs Netz schicken. +// +// - SameSite: Der Browser schickt das Cookie nicht mit, wenn die Anfrage von +// einer fremden Seite ausgelöst wurde. Das ist die erste Verteidigungslinie +// gegen CSRF (siehe csrf.go für die zweite). +package auth + +import ( + "net" + "net/http" + "strings" +) + +// CookiePolicy entscheidet, ob Cookies mit dem Secure-Flag gesetzt werden. +type CookiePolicy struct { + // explicit ist gesetzt, wenn die Umgebungsvariable COOKIE_SECURE einen + // eindeutigen Wert hat. Ein Zeiger, weil wir drei Zustände brauchen: + // "erzwungen an", "erzwungen aus" und "nicht gesetzt, bitte selbst entscheiden". + explicit *bool +} + +// NewCookiePolicy baut die Policy aus dem Wert der Umgebungsvariable COOKIE_SECURE. +// +// Leer (der Normalfall) bedeutet: automatisch entscheiden — siehe Secure. +func NewCookiePolicy(envValue string) CookiePolicy { + switch strings.ToLower(strings.TrimSpace(envValue)) { + case "true", "1", "yes", "on": + yes := true + return CookiePolicy{explicit: &yes} + case "false", "0", "no", "off": + no := false + return CookiePolicy{explicit: &no} + default: + return CookiePolicy{} + } +} + +// Secure entscheidet für einen konkreten Request, ob das Secure-Flag gesetzt wird. +// +// Ohne explizite Konfiguration gilt: Secure ist an — außer der Aufruf kommt von +// localhost. Das ist bewusst so herum gewählt: +// +// - In Produktion (eigene Domain, HTTPS) ist das sichere Verhalten der Standard, +// ohne dass man daran denken muss eine Variable zu setzen. +// - Bei der lokalen Entwicklung über http://localhost:8080 würde ein gesetztes +// Secure-Flag den Login kaputt machen (der Browser würde das Cookie verwerfen), +// deshalb dort automatisch aus. Localhost gilt im Browser ohnehin als +// "secure context", es geht also keine Sicherheit verloren. +// +// Wer hinter einem Reverse-Proxy mit TLS-Terminierung fährt, kann mit +// COOKIE_SECURE=true nachhelfen; wer bewusst ohne TLS testet, mit COOKIE_SECURE=false. +func (p CookiePolicy) Secure(r *http.Request) bool { + if p.explicit != nil { + return *p.explicit + } + return !isLocalhost(r) +} + +// Apply setzt Pfad und alle Sicherheits-Flags eines Cookies. +// Name, Wert und Ablaufzeit bleiben Sache des Aufrufers. +func (p CookiePolicy) Apply(c *http.Cookie, r *http.Request) { + c.Path = "/" + c.HttpOnly = true + c.Secure = p.Secure(r) + + // Lax statt Strict: bei Strict würde ein Link von außen ins Wiki (z.B. aus + // einer Chat-Nachricht) den Nutzer trotz gültiger Session auf der Login-Seite + // landen lassen, weil der Browser das Cookie beim ersten Aufruf zurückhält. + // Lax schickt das Cookie bei normaler Navigation mit, aber nicht bei einem + // von außen ausgelösten POST — genau die Fälle die wir schützen wollen. + c.SameSite = http.SameSiteLaxMode +} + +// isLocalhost prüft, ob der Request an localhost gerichtet war. +// +// Ausgewertet wird r.Host (die vom Client angefragte Adresse), nicht r.RemoteAddr: +// Wer über http://localhost:8080 entwickelt, soll das lockere Verhalten bekommen; +// wer über eine echte Domain zugreift, das strenge — auch wenn der Request +// zufällig von derselben Maschine kommt. +func isLocalhost(r *http.Request) bool { + host := r.Host + + // Den Port abtrennen, falls einer dranhängt ("localhost:8080" → "localhost"). + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + + host = strings.ToLower(strings.Trim(host, "[]")) + return host == "localhost" || host == "127.0.0.1" || host == "::1" +} diff --git a/internal/auth/csrf.go b/internal/auth/csrf.go new file mode 100644 index 0000000..7508c5b --- /dev/null +++ b/internal/auth/csrf.go @@ -0,0 +1,49 @@ +package auth + +import ( + "crypto/subtle" + "net/http" +) + +// CSRFFieldName ist der Name des versteckten Formularfelds in den Templates. +const CSRFFieldName = "csrf_token" + +// CSRFHeaderName erlaubt es, das Token bei einem fetch()-Aufruf im Header statt +// im Formular zu schicken. +const CSRFHeaderName = "X-CSRF-Token" + +// CSRFToken berechnet das Token zu einer Sitzung. +// +// # Wogegen schützt das? +// +// CSRF ("Cross-Site Request Forgery") nutzt aus, dass der Browser Cookies +// automatisch mitschickt. Eine fremde Seite kann ein Formular auf unseren Server +// abschicken lassen, und für den Server sieht es aus wie eine Aktion des +// angemeldeten Nutzers: +// +//
+// +// +// Das Token unterbindet das: Die fremde Seite kennt es nicht und kann es wegen +// der Same-Origin-Policy des Browsers auch nicht auslesen. +// +// Das Präfix "csrf:" sorgt für Domänentrennung — dasselbe Geheimnis signiert auch +// Session-Cookies, und eine Signatur aus dem einen Zusammenhang darf im anderen +// nicht gelten. +func (s *Signer) CSRFToken(subject string) string { + return s.sign("csrf:" + subject) +} + +// ValidCSRFToken prüft ein übermitteltes Token gegen die Sitzung. +func (s *Signer) ValidCSRFToken(token, subject string) bool { + expected := s.CSRFToken(subject) + return subtle.ConstantTimeCompare([]byte(token), []byte(expected)) == 1 +} + +// CSRFTokenFromRequest holt das Token aus dem Formular oder dem Header. +func CSRFTokenFromRequest(r *http.Request) string { + if token := r.Header.Get(CSRFHeaderName); token != "" { + return token + } + return r.FormValue(CSRFFieldName) +} diff --git a/internal/auth/doc.go b/internal/auth/doc.go new file mode 100644 index 0000000..c48c6a4 --- /dev/null +++ b/internal/auth/doc.go @@ -0,0 +1,22 @@ +// Package auth bündelt alles, was mit Authentifizierung und Sitzungen zu tun hat. +// +// # Zwei Wege hinein +// +// Es gibt genau zwei Arten, sich gegenüber dem Server auszuweisen: +// +// 1. API-Key im Authorization-Header ("Bearer wk_…") — für MCP-Clients, +// Agenten und Skripte. Der Key gehört immer zu genau einem Projekt. +// +// 2. Session-Cookie — für die Web-Oberfläche. Der Cookie entsteht durch eine +// einmalige Anmeldung mit demselben API-Key und trägt danach nur noch eine +// signierte Behauptung ("ich bin Key X"), nie den Key selbst. +// +// Dazu kommt der Plattform-Admin (Umgebungsvariable ADMIN_TOKEN), der Projekte +// anlegen und löschen darf — der einzige Zugang, der nicht an ein Projekt gebunden ist. +// +// # Was hier NICHT liegt +// +// Die Frage "darf dieser Key das?" beantwortet nicht dieses Package, sondern die +// Rolle am Key (siehe Role) in Verbindung mit den Prüfungen in internal/web. +// Hier geht es nur darum, WER jemand ist — nicht, was er darf. +package auth diff --git a/internal/auth/keys.go b/internal/auth/keys.go new file mode 100644 index 0000000..bb120b5 --- /dev/null +++ b/internal/auth/keys.go @@ -0,0 +1,110 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" +) + +// KeyPrefix steht vor jedem API-Key. +// +// Zwei Gründe dafür: +// - Man erkennt auf einen Blick, worum es sich handelt, wenn ein Key irgendwo +// in einer Konfigurationsdatei oder einem Chat auftaucht. +// - Werkzeuge zum Aufspüren versehentlich veröffentlichter Secrets (GitHub +// Secret Scanning und ähnliche) suchen nach genau solchen Präfixen. +const KeyPrefix = "wk_" + +// keyBytes ist die Länge des Zufallsanteils eines Keys. +// 32 Byte = 256 Bit — weit jenseits von allem, was sich durchprobieren lässt. +const keyBytes = 32 + +// Role beschreibt, was ein Key darf. +// +// Bewusst nur drei Stufen: mehr Feingranularität kostet Verständlichkeit und +// wird bei einem internen Werkzeug erfahrungsgemäß nie genutzt. +type Role string + +const ( + // RoleRead darf lesen und suchen — für Agenten, die nur Wissen abrufen sollen. + RoleRead Role = "read" + + // RoleWrite darf zusätzlich Einträge anlegen, ändern und löschen. + RoleWrite Role = "write" + + // RoleAdmin darf zusätzlich die Keys des eigenen Projekts verwalten. + RoleAdmin Role = "admin" +) + +// Valid prüft, ob eine Rolle einen der bekannten Werte hat. +// Wichtig beim Lesen aus der Datenbank: dort steht Text, und Text kann alles sein. +func (r Role) Valid() bool { + switch r { + case RoleRead, RoleWrite, RoleAdmin: + return true + default: + return false + } +} + +// CanWrite sagt, ob diese Rolle Einträge verändern darf. +func (r Role) CanWrite() bool { + return r == RoleWrite || r == RoleAdmin +} + +// CanManageKeys sagt, ob diese Rolle die Keys des Projekts verwalten darf. +func (r Role) CanManageKeys() bool { + return r == RoleAdmin +} + +// GenerateKey erzeugt einen neuen API-Key im Klartext. +// +// Der Klartext existiert nur genau einmal — direkt nach dem Erzeugen wird er dem +// Nutzer angezeigt und danach nirgends gespeichert (siehe HashKey). +func GenerateKey() (string, error) { + buf := make([]byte, keyBytes) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("Zufallszahlen konnten nicht erzeugt werden: %w", err) + } + return KeyPrefix + hex.EncodeToString(buf), nil +} + +// HashKey bildet den Speicher-Hash eines API-Keys. +// +// # Warum SHA-256 und nicht bcrypt/Argon2? +// +// Für Passwörter wären langsame Hashverfahren richtig: Menschen wählen Passwörter +// aus einem kleinen, erratbaren Raum, und die künstliche Langsamkeit macht das +// Durchprobieren teuer. +// +// Ein API-Key hier ist aber kein Passwort, sondern 256 Bit aus crypto/rand. Diesen +// Raum kann niemand durchsuchen — auch nicht mit beliebig schneller Hardware. Die +// Langsamkeit brächte also keinen Sicherheitsgewinn, würde aber jeden einzelnen +// Request ausbremsen. +// +// Entscheidend ist der zweite Punkt: Weil SHA-256 schnell und deterministisch ist, +// kann der Hash direkt als indizierte Spalte durchsucht werden — ein Lookup statt +// einer Schleife über alle Keys. Die alte Lösung verglich den Key nacheinander mit +// jedem Team; das skaliert nicht und wurde mit jedem neuen Projekt langsamer. +func HashKey(key string) string { + sum := sha256.Sum256([]byte(key)) + return hex.EncodeToString(sum[:]) +} + +// LooksLikeKey prüft die äußere Form eines Keys, bevor die Datenbank befragt wird. +// +// Das ist keine Sicherheitsprüfung, sondern spart einen Datenbankzugriff bei +// offensichtlichem Unsinn (leerer Header, abgeschnittener Key, Tippfehler). +func LooksLikeKey(key string) bool { + if !strings.HasPrefix(key, KeyPrefix) { + return false + } + rest := key[len(KeyPrefix):] + if len(rest) != keyBytes*2 { + return false + } + _, err := hex.DecodeString(rest) + return err == nil +} diff --git a/internal/auth/ratelimit.go b/internal/auth/ratelimit.go new file mode 100644 index 0000000..80f9df7 --- /dev/null +++ b/internal/auth/ratelimit.go @@ -0,0 +1,249 @@ +// Dieses File enthält zwei Begrenzungen, die sich dieselbe Mechanik teilen: +// +// - RateLimiter sperrt eine IP nach zu vielen fehlgeschlagenen Anmeldungen. +// - WriteBudget deckelt, wie oft ein Projekt je Stunde schreiben darf. +// +// Beide sind Fixed-Window-Zähler: Ein Zeitfenster beginnt beim ersten Ereignis +// und endet nach einer festen Dauer; danach fängt die Zählung bei null an. +package auth + +import ( + "sync" + "time" +) + +// ── Der gemeinsame Unterbau ────────────────────────────────────────────────── + +// fensterStand ist der Zählerstand eines Schlüssels im laufenden Fenster. +type fensterStand struct { + anzahl int + fensterBis time.Time +} + +// fensterZaehler zählt Ereignisse je Schlüssel innerhalb eines Zeitfensters. +// +// Was der Schlüssel bedeutet, entscheidet der Benutzer des Zählers: beim +// RateLimiter ist es eine IP-Adresse, beim WriteBudget eine Projekt-ID. Für den +// Zähler ist beides nur eine Zeichenkette — deshalb lässt sich dieselbe +// Mechanik für beides verwenden. +type fensterZaehler struct { + mu sync.Mutex + fenster time.Duration + staende map[string]*fensterStand +} + +// neuerZaehler erstellt einen Zähler und startet einen Hintergrund-Job, der +// abgelaufene Einträge regelmäßig entfernt. +// +// Ohne das Aufräumen würde die Map unbegrenzt wachsen: Jede IP, die einmal +// vorbeigeschaut hat, bliebe für immer darin stehen — bei Scannern und Bots sind +// das schnell sehr viele. +func neuerZaehler(fenster time.Duration) *fensterZaehler { + z := &fensterZaehler{ + fenster: fenster, + staende: make(map[string]*fensterStand), + } + + go func() { + ticker := time.NewTicker(10 * time.Minute) + defer ticker.Stop() + for range ticker.C { + z.aufraeumen() + } + }() + + return z +} + +// lies gibt den aktuellen Stand zurück, ohne zu zählen. +// restlaufzeit ist die verbleibende Dauer des Fensters. +func (z *fensterZaehler) lies(schluessel string) (anzahl int, restlaufzeit time.Duration) { + z.mu.Lock() + defer z.mu.Unlock() + + stand, vorhanden := z.staende[schluessel] + now := time.Now() + + // Kein Eintrag oder Fenster abgelaufen: der Schlüssel steht bei null. + if !vorhanden || now.After(stand.fensterBis) { + return 0, 0 + } + return stand.anzahl, stand.fensterBis.Sub(now) +} + +// zaehle erhöht den Zähler um eins und gibt den neuen Stand zurück. +func (z *fensterZaehler) zaehle(schluessel string) (anzahl int, restlaufzeit time.Duration) { + z.mu.Lock() + defer z.mu.Unlock() + + now := time.Now() + stand, vorhanden := z.staende[schluessel] + + // Ein abgelaufenes Fenster wird nicht fortgeschrieben, sondern durch ein + // frisches ersetzt — sonst würde der alte Stand weitergezählt. + if !vorhanden || now.After(stand.fensterBis) { + stand = &fensterStand{fensterBis: now.Add(z.fenster)} + z.staende[schluessel] = stand + } + + stand.anzahl++ + return stand.anzahl, stand.fensterBis.Sub(now) +} + +// vergiss löscht den Zähler eines Schlüssels. +func (z *fensterZaehler) vergiss(schluessel string) { + z.mu.Lock() + defer z.mu.Unlock() + delete(z.staende, schluessel) +} + +// aufraeumen entfernt abgelaufene Einträge aus der Map. +func (z *fensterZaehler) aufraeumen() { + z.mu.Lock() + defer z.mu.Unlock() + + now := time.Now() + for schluessel, stand := range z.staende { + if now.After(stand.fensterBis) { + delete(z.staende, schluessel) + } + } +} + +// ── Anmeldeversuche ────────────────────────────────────────────────────────── + +const ( + rateLimitMax = 5 // erlaubte Fehlversuche pro Fenster + rateLimitWindow = 1 * time.Minute // Fenstergröße +) + +// RateLimiter begrenzt fehlgeschlagene Anmeldungen pro Client-IP — +// ohne das könnte ein Angreifer beliebig oft Passwörter und API-Keys +// durchprobieren. +type RateLimiter struct { + zaehler *fensterZaehler +} + +// NewRateLimiter erstellt einen Rate-Limiter für Anmeldeversuche. +func NewRateLimiter() *RateLimiter { + return &RateLimiter{zaehler: neuerZaehler(rateLimitWindow)} +} + +// Allow prüft ob die IP noch einen Versuch frei hat, ohne ihn zu verbrauchen. +// retryAfter gibt an wie lange bei einer Sperre noch gewartet werden muss. +func (l *RateLimiter) Allow(ip string) (ok bool, retryAfter time.Duration) { + anzahl, restlaufzeit := l.zaehler.lies(ip) + if anzahl >= rateLimitMax { + return false, restlaufzeit + } + return true, 0 +} + +// RecordFailure zählt einen fehlgeschlagenen Anmeldeversuch für die IP. +func (l *RateLimiter) RecordFailure(ip string) { + l.zaehler.zaehle(ip) +} + +// Reset löscht den Zähler einer IP nach einem erfolgreichen Zugriff. +func (l *RateLimiter) Reset(ip string) { + l.zaehler.vergiss(ip) +} + +// ── Schreibbudget je Projekt ───────────────────────────────────────────────── + +// WriteBudget deckelt, wie oft ein Projekt innerhalb eines Zeitfensters +// schreiben darf. +// +// # Warum je Projekt und nicht je Key? +// +// Ein Limit je Key ließe sich umgehen, indem man einen zweiten Key anlegt — das +// darf jeder Projekt-Admin selbst. Damit schützte es nur vor dem Versehen (eine +// Schleife im Agenten), nicht vor dem Verbrauch. Das Projekt ist die Einheit der +// Abschottung, also gehört auch das Budget dorthin. +// +// # Warum überhaupt? +// +// Ein gültiger API-Key umgeht die IP-Sperre bewusst (siehe APIAuth in +// internal/web/middleware.go): Deploy-Skript, MCP-Client und Browser eines Büros +// teilen sich nach außen oft eine Adresse. Damit hätte aber eine durchdrehende +// Schleife im Agenten überhaupt keine Grenze mehr. +// +// # Was das Budget NICHT ist +// +// Der Zählerstand liegt im Arbeitsspeicher und ist nach einem Neustart weg. Bei +// Fehlversuchen ist das gleichgültig, bei einem Stundenbudget lädt es zum +// Umgehen ein. Für eine verbindliche Zusage müsste der Zähler in die Datenbank — +// siehe AUSBAUSTUFEN.md. Solange genau ein Prozess läuft, reicht das hier. +type WriteBudget struct { + zaehler *fensterZaehler + standard int +} + +// NewWriteBudget erstellt ein Schreibbudget. +// +// standard gilt für alle Projekte, die keine eigene Obergrenze gesetzt haben. +// Ein Wert von 0 oder kleiner schaltet die Begrenzung vollständig ab. +func NewWriteBudget(standard int, fenster time.Duration) *WriteBudget { + return &WriteBudget{ + zaehler: neuerZaehler(fenster), + standard: standard, + } +} + +// Consume verbucht einen Schreibvorgang für ein Projekt und sagt, ob er noch ins +// Budget passte. +// +// projektLimit ist die eigene Obergrenze des Projekts; bei 0 gilt der Standard +// aus NewWriteBudget. Die Obergrenze wird bei jedem Aufruf mitgegeben statt im +// Budget gespeichert — so wirkt eine Änderung in der Verwaltung sofort, ohne +// dass hier irgendein zwischengespeicherter Wert veralten könnte. +// +// Gezählt wird auch der abgelehnte Aufruf. Sonst könnte ein Agent, der stur +// weiterversucht, das Fenster nie volllaufen lassen — und der Zählerstand wäre +// keine ehrliche Auskunft darüber, wie viel gerade los ist. +// +// ueberschreitung sagt, der wievielte Aufruf über der Grenze das war: 0 solange +// alles im Rahmen ist, 1 beim allerersten abgelehnten Aufruf, dann aufsteigend. +// +// Dieser dritte Rückgabewert sieht nach Ballast aus, hat aber einen konkreten +// Zweck: Der Aufrufer soll die Überschreitung protokollieren können, ohne bei +// JEDEM abgelehnten Aufruf eine Zeile zu schreiben. Ein Agent in einer Schleife +// erzeugt sonst genau die Datenflut, die das Budget verhindern soll — nur eben +// im Protokoll statt in den Einträgen. Mit ueberschreitung == 1 als Bedingung +// entsteht je Fenster genau ein Vermerk. +func (b *WriteBudget) Consume(projektID string, projektLimit int) (ok bool, retryAfter time.Duration, ueberschreitung int) { + grenze := b.grenzeFuer(projektLimit) + if grenze <= 0 { + return true, 0, 0 // Begrenzung abgeschaltet + } + + anzahl, restlaufzeit := b.zaehler.zaehle(projektID) + if anzahl > grenze { + return false, restlaufzeit, anzahl - grenze + } + return true, 0, 0 +} + +// Remaining sagt, wie viele Schreibvorgänge im laufenden Fenster noch frei sind, +// ohne etwas zu verbrauchen. Für die Anzeige in der Verwaltung. +func (b *WriteBudget) Remaining(projektID string, projektLimit int) (frei int, grenze int) { + grenze = b.grenzeFuer(projektLimit) + if grenze <= 0 { + return 0, 0 + } + + anzahl, _ := b.zaehler.lies(projektID) + frei = grenze - anzahl + if frei < 0 { + frei = 0 + } + return frei, grenze +} + +// grenzeFuer löst die geltende Obergrenze auf: erst das Projekt, dann der Standard. +func (b *WriteBudget) grenzeFuer(projektLimit int) int { + if projektLimit > 0 { + return projektLimit + } + return b.standard +} diff --git a/internal/auth/session.go b/internal/auth/session.go new file mode 100644 index 0000000..b2ea629 --- /dev/null +++ b/internal/auth/session.go @@ -0,0 +1,109 @@ +package auth + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "fmt" + "strconv" + "strings" + "time" +) + +// SessionCookieName ist der Name des Cookies, der die signierte Sitzung trägt. +// +// Wird der Name geändert, gelten alle bestehenden Sitzungen als nicht vorhanden — +// jeder muss sich einmal neu anmelden. Harmlos, aber kein Grund, ihn ohne Anlass +// anzufassen. +const SessionCookieName = "munin_session" + +// SessionTTL bestimmt, wie lange eine Sitzung ohne erneute Anmeldung gilt. +const SessionTTL = 30 * 24 * time.Hour + +// SubjectPlatformAdmin ist das Session-Subject des Plattform-Admins (ADMIN_TOKEN). +const SubjectPlatformAdmin = "platform" + +// SubjectKeyPrefix steht vor der Key-ID im Session-Subject einer normalen Anmeldung. +const SubjectKeyPrefix = "key:" + +// Signer signiert und prüft alles, was der Server einem Browser mitgibt: +// Session-Cookies und CSRF-Tokens. +// +// Ein eigener Typ statt loser Funktionen, damit das Geheimnis genau einmal +// übergeben wird und nicht durch jeden Aufruf gereicht werden muss. +type Signer struct { + secret []byte +} + +// NewSigner erzeugt einen Signer aus dem SESSION_SECRET. +func NewSigner(secret []byte) *Signer { + return &Signer{secret: secret} +} + +// sign berechnet den HMAC-SHA256 einer Nutzlast. +// +// HMAC ist ein Prüfsummenverfahren mit Geheimnis: Wer das Geheimnis nicht kennt, +// kann zu einer Nutzlast keine passende Prüfsumme bilden. Damit kann der Server +// erkennen, ob ein Cookie-Inhalt von ihm selbst stammt oder unterwegs verändert wurde. +func (s *Signer) sign(payload string) string { + mac := hmac.New(sha256.New, s.secret) + mac.Write([]byte(payload)) + return hex.EncodeToString(mac.Sum(nil)) +} + +// SignSession erstellt den Cookie-Wert für ein Subject. +// +// subject ist entweder SubjectPlatformAdmin oder "key:". +// +// Aufbau: base64("|") + "." + HMAC +// +// Der Cookie ist lesbar, aber nicht fälschbar. Er enthält bewusst keinen Key — +// wer ihn stiehlt, bekommt eine Sitzung, aber kein dauerhaftes Zugangsmittel für +// die API. Und ob der zugehörige Key überhaupt noch gültig ist, prüft der Server +// bei jedem Request neu in der Datenbank (siehe internal/web). +func (s *Signer) SignSession(subject string) string { + expiry := time.Now().Add(SessionTTL).Unix() + payload := fmt.Sprintf("%s|%d", subject, expiry) + encoded := base64.RawURLEncoding.EncodeToString([]byte(payload)) + return encoded + "." + s.sign(payload) +} + +// VerifySession prüft Signatur und Ablaufdatum und gibt das Subject zurück. +func (s *Signer) VerifySession(cookieValue string) (subject string, ok bool) { + encoded, signature, found := strings.Cut(cookieValue, ".") + if !found { + return "", false + } + + payloadBytes, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return "", false + } + payload := string(payloadBytes) + + // Reihenfolge ist wichtig: erst die Signatur prüfen, dann den Inhalt auswerten. + // Alles vor dieser Zeile stammt aus einer nicht vertrauenswürdigen Quelle. + if subtle.ConstantTimeCompare([]byte(signature), []byte(s.sign(payload))) != 1 { + return "", false + } + + subject, expiryStr, found := strings.Cut(payload, "|") + if !found { + return "", false + } + + expiry, err := strconv.ParseInt(expiryStr, 10, 64) + if err != nil || time.Now().Unix() > expiry { + return "", false + } + + return subject, true +} + +// KeyIDFromSubject holt die API-Key-ID aus einem Session-Subject. +// Beim Plattform-Admin gibt es keine — dann ist ok false. +func KeyIDFromSubject(subject string) (keyID string, ok bool) { + return strings.CutPrefix(subject, SubjectKeyPrefix) +} diff --git a/internal/auth/trustedproxy.go b/internal/auth/trustedproxy.go new file mode 100644 index 0000000..9b8fb32 --- /dev/null +++ b/internal/auth/trustedproxy.go @@ -0,0 +1,179 @@ +// Dieses File regelt, wann dem Header X-Forwarded-For geglaubt wird. +// +// # Das Problem +// +// Läuft das Wiki direkt am Netz, ist r.RemoteAddr die echte IP des Besuchers. +// Läuft davor ein Reverse-Proxy (nginx, Caddy, Traefik, Cloudflare), sieht der +// Server nur noch die IP des Proxys — für alle Besucher dieselbe. Das +// IP-Rate-Limiting (ratelimit.go) wäre damit wirkungslos: entweder es sperrt +// niemanden aus, oder es sperrt beim ersten Angreifer gleich alle. +// +// Der Proxy schickt die echte IP deshalb im Header X-Forwarded-For mit. +// +// # Warum man diesem Header nicht einfach glauben darf +// +// Header kann jeder Client frei setzen. Würde der Server X-Forwarded-For +// bedingungslos übernehmen, bräuchte ein Angreifer nur bei jedem Login-Versuch +// eine andere Fantasie-IP zu schicken — das Rate-Limiting wäre komplett +// ausgehebelt, und das Audit-Log stünde voller erfundener Adressen. +// +// # Die Lösung +// +// Der Header wird nur ausgewertet, wenn der Request tatsächlich von einem als +// vertrauenswürdig konfigurierten Proxy kommt. Konfiguriert wird das über die +// Umgebungsvariable TRUSTED_PROXY, z.B.: +// +// TRUSTED_PROXY=127.0.0.1,10.0.0.0/8 +// +// Ist sie nicht gesetzt (Standard), wird der Header ignoriert — sicher per Default. +package auth + +import ( + "fmt" + "net" + "net/http" + "net/netip" + "strings" + "sync" +) + +// trustedProxies hält die konfigurierten Netze. Wird einmal beim Start über +// SetTrustedProxies gefüllt und danach nur noch gelesen. +// +// Ein Paket-globaler Wert ist hier vertretbar, weil clientIP() von sehr vielen +// Stellen aufgerufen wird und die Konfiguration prozessweit gilt. Der RWMutex +// schützt trotzdem sauber, falls die Konfiguration doch einmal später gesetzt wird. +var ( + trustedProxiesMu sync.RWMutex + trustedProxies []netip.Prefix +) + +// SetTrustedProxies liest die TRUSTED_PROXY-Konfiguration ein. +// +// Akzeptiert eine kommaseparierte Liste aus einzelnen IPs ("127.0.0.1") und +// CIDR-Netzen ("10.0.0.0/8"). Eine einzelne IP wird intern zu einem Netz mit +// voller Präfixlänge — dann gilt für beide Fälle derselbe Vergleich. +func SetTrustedProxies(value string) error { + var prefixes []netip.Prefix + + for _, raw := range strings.Split(value, ",") { + entry := strings.TrimSpace(raw) + if entry == "" { + continue + } + + if strings.Contains(entry, "/") { + prefix, err := netip.ParsePrefix(entry) + if err != nil { + return fmt.Errorf("TRUSTED_PROXY: %q ist kein gültiges Netz: %w", entry, err) + } + prefixes = append(prefixes, prefix) + continue + } + + addr, err := netip.ParseAddr(entry) + if err != nil { + return fmt.Errorf("TRUSTED_PROXY: %q ist keine gültige IP-Adresse: %w", entry, err) + } + prefixes = append(prefixes, netip.PrefixFrom(addr, addr.BitLen())) + } + + trustedProxiesMu.Lock() + defer trustedProxiesMu.Unlock() + trustedProxies = prefixes + + return nil +} + +// TrustedProxyCount gibt zurück wie viele Netze konfiguriert sind — nur damit +// main.go beim Start eine sinnvolle Zeile ins Log schreiben kann. +func TrustedProxyCount() int { + trustedProxiesMu.RLock() + defer trustedProxiesMu.RUnlock() + return len(trustedProxies) +} + +// ClientIP ermittelt die Adresse des Aufrufers. +// +// Normalerweise ist das r.RemoteAddr. Steht ein Reverse-Proxy davor, ist das +// dessen Adresse — dann wird X-Forwarded-For ausgewertet, aber nur wenn der +// Absender über TRUSTED_PROXY als vertrauenswürdig hinterlegt ist. +func ClientIP(r *http.Request) string { + if ip, ok := forwardedClientIP(r); ok { + return ip + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +// forwardedClientIP liefert die echte Client-IP aus X-Forwarded-For — aber nur +// wenn der direkte Absender ein vertrauenswürdiger Proxy ist. +func forwardedClientIP(r *http.Request) (string, bool) { + trustedProxiesMu.RLock() + prefixes := trustedProxies + trustedProxiesMu.RUnlock() + + if len(prefixes) == 0 { + return "", false + } + + // Wer klopft direkt an? Nur dessen Vertrauenswürdigkeit zählt. + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return "", false + } + peer, err := netip.ParseAddr(host) + if err != nil { + return "", false + } + if !isTrustedProxy(peer, prefixes) { + return "", false + } + + header := r.Header.Get("X-Forwarded-For") + if header == "" { + return "", false + } + + // Format: "client, proxy1, proxy2" — ganz links steht der ursprüngliche Client. + // + // Wir gehen von rechts nach links und überspringen alle vertrauenswürdigen + // Proxys; der erste Eintrag der kein bekannter Proxy ist, ist der Client. + // Das ist wichtiger als es klingt: würde man stur den linkesten Eintrag nehmen, + // könnte ein Client einfach ein eigenes "X-Forwarded-For: 1.2.3.4" mitschicken, + // das der Proxy dann nur ergänzt — und die erfundene IP stünde ganz links. + parts := strings.Split(header, ",") + for i := len(parts) - 1; i >= 0; i-- { + candidate := strings.TrimSpace(parts[i]) + addr, err := netip.ParseAddr(candidate) + if err != nil { + // Unlesbarer Eintrag — ab hier ist der Kette nicht mehr zu trauen. + return "", false + } + if isTrustedProxy(addr, prefixes) { + continue + } + return addr.String(), true + } + + // Alle Einträge waren bekannte Proxys — dann bleibt nur der direkte Absender. + return "", false +} + +// isTrustedProxy prüft ob eine Adresse in einem der konfigurierten Netze liegt. +func isTrustedProxy(addr netip.Addr, prefixes []netip.Prefix) bool { + // IPv4-Adressen können in IPv6-Schreibweise ankommen (::ffff:127.0.0.1). + // Unmap macht daraus wieder eine normale IPv4-Adresse, sonst schlägt der + // Vergleich mit einem IPv4-Netz fehl. + addr = addr.Unmap() + + for _, prefix := range prefixes { + if prefix.Contains(addr) { + return true + } + } + return false +} diff --git a/internal/entry/entry.go b/internal/entry/entry.go new file mode 100644 index 0000000..88cbe23 --- /dev/null +++ b/internal/entry/entry.go @@ -0,0 +1,131 @@ +// Package entry enthält die Fachlogik rund um einen Wissenseintrag: die +// Darstellung als HTML, Textausschnitte für Suchergebnisse und die Auflösung +// der [[slug]]-Verweise. +// +// Bewusst getrennt von internal/store: Dort geht es darum, wie ein Eintrag +// gespeichert wird, hier darum, wie er aussieht. Beides ändert sich aus +// unterschiedlichen Gründen. +package entry + +import ( + "bytes" + "fmt" + "html" + "strings" + + "github.com/yuin/goldmark" + + "munin/internal/store" +) + +// RenderHTML wandelt den Markdown-Inhalt eines Eintrags in HTML. +// +// # Sicherheitshinweis +// +// goldmark lässt in der Standardeinstellung eingebettetes Roh-HTML NICHT durch — +// aus im Markdown wird ein Kommentar, kein Skript. +// Genau darauf verlässt sich die Weboberfläche, wenn sie das Ergebnis ohne +// weiteres Escaping einsetzt. +// +// Diese Einstellung darf deshalb nie auf "unsafe" gestellt werden. Als zweite +// Verteidigungslinie steht die Content-Security-Policy (siehe internal/web). +func RenderHTML(markdown string, linkBase string) (string, error) { + // Verweise vor dem Rendern in Markdown-Links umschreiben. Danach wäre es zu + // spät: goldmark hätte die eckigen Klammern dann schon als Text behandelt. + vorbereitet := store.ReplaceLinks(markdown, func(slug string) string { + return fmt.Sprintf("[%s](%s%s)", slug, linkBase, slug) + }) + + var puffer bytes.Buffer + if err := goldmark.Convert([]byte(vorbereitet), &puffer); err != nil { + return "", fmt.Errorf("Inhalt konnte nicht dargestellt werden: %w", err) + } + return puffer.String(), nil +} + +// RenderPlain gibt den Inhalt als reinen Text zurück — Verweise werden auf ihren +// Slug reduziert. Gedacht für Textausschnitte, wo Markdown-Zeichen nur stören. +func RenderPlain(markdown string) string { + return store.ReplaceLinks(markdown, func(slug string) string { return slug }) +} + +// snippetRadius bestimmt, wie viel Text vor und nach einem Treffer im Ausschnitt steht. +const snippetRadius = 120 + +// Snippet gibt einen Textausschnitt um den ersten Treffer des Suchbegriffs zurück. +// +// Der Ausschnitt ist bewusst kurz: Er dient dazu, einen Treffer einzuordnen — +// bei einer KI-Anfrage entscheidet er darüber, ob der ganze Eintrag nachgeladen +// wird. Wer den vollen Inhalt will, holt ihn gezielt. +func Snippet(content, query string) string { + text := RenderPlain(content) + + idx := strings.Index(strings.ToLower(text), strings.ToLower(query)) + if idx == -1 { + // Kein wörtlicher Treffer (z.B. weil der Fund über die Präfix-Suche kam) — + // dann der Anfang des Texts als Vorschau. + return kuerzen(text, snippetRadius+snippetRadius) + } + + start := max(idx-snippetRadius, 0) + ende := min(idx+len(query)+snippetRadius, len(text)) + + ausschnitt := strings.TrimSpace(text[start:ende]) + if start > 0 { + ausschnitt = "…" + ausschnitt + } + if ende < len(text) { + ausschnitt += "…" + } + return ausschnitt +} + +// kuerzen schneidet einen Text auf höchstens n Zeichen. +func kuerzen(text string, n int) string { + text = strings.TrimSpace(text) + if len(text) <= n { + return text + } + return strings.TrimSpace(text[:n]) + "…" +} + +// HighlightSnippet baut einen Textausschnitt mit hervorgehobenem Treffer als +// fertiges HTML. +// +// Wichtig: Erst wird der Text escaped, dann werden die -Auszeichnungen +// eingesetzt. Andersherum könnte ein Eintragsinhalt eigenes HTML einschleusen. +func HighlightSnippet(content, query string) string { + ausschnitt := Snippet(content, query) + sicher := html.EscapeString(ausschnitt) + + if query == "" { + return sicher + } + + // Im escapten Text nach dem (ebenfalls escapten) Suchbegriff suchen, damit + // die Positionen zusammenpassen. + nadel := html.EscapeString(query) + idx := strings.Index(strings.ToLower(sicher), strings.ToLower(nadel)) + if idx == -1 { + return sicher + } + + return sicher[:idx] + "" + sicher[idx:idx+len(nadel)] + "" + sicher[idx+len(nadel):] +} + +// TitleFromContent rät einen Titel aus dem Inhalt — die erste Überschrift oder +// die erste Zeile. Wird verwendet, wenn ein Agent keinen Titel mitgeschickt hat. +func TitleFromContent(content string) string { + for _, zeile := range strings.Split(content, "\n") { + zeile = strings.TrimSpace(zeile) + if zeile == "" { + continue + } + zeile = strings.TrimLeft(zeile, "#") + zeile = strings.TrimSpace(zeile) + if zeile != "" { + return kuerzen(zeile, 120) + } + } + return "Ohne Titel" +} diff --git a/internal/entry/entry_test.go b/internal/entry/entry_test.go new file mode 100644 index 0000000..32bdf27 --- /dev/null +++ b/internal/entry/entry_test.go @@ -0,0 +1,163 @@ +package entry + +import ( + "strings" + "testing" +) + +// TestRenderHTMLEscapesRawHTML ist ein Sicherheitstest. +// +// Das gerenderte Ergebnis wird in der Weboberfläche ohne weiteres Escaping +// eingesetzt. Der Schutz davor, dass jemand über einen Eintrag ein ", + "", + "", + } + + for _, eingabe := range boesartig { + html, err := RenderHTML(eingabe, "/p/test/e/") + if err != nil { + t.Fatalf("RenderHTML(%q): %v", eingabe, err) + } + if strings.Contains(html, "", "
  • ", ""} { + if !strings.Contains(html, erwartet) { + t.Errorf("%s fehlt im Ergebnis: %s", erwartet, html) + } + } +} + +func TestRenderPlainStripsLinkSyntax(t *testing.T) { + got := RenderPlain("Siehe [[server-zugang]] für Details.") + if strings.Contains(got, "[[") { + t.Errorf("die Verweis-Klammern blieben stehen: %q", got) + } + if !strings.Contains(got, "server-zugang") { + t.Errorf("der Bezeichner ging verloren: %q", got) + } +} + +// ── Textausschnitte ────────────────────────────────────────────────────────── + +func TestSnippetAroundMatch(t *testing.T) { + text := strings.Repeat("Vorlauf. ", 40) + "GESUCHT " + strings.Repeat("Nachlauf. ", 40) + + got := Snippet(text, "GESUCHT") + + if !strings.Contains(got, "GESUCHT") { + t.Error("der Treffer fehlt im Ausschnitt") + } + // Der Ausschnitt soll kurz sein — er dient dem Einordnen, nicht dem Lesen. + if len(got) > 400 { + t.Errorf("Ausschnitt ist %d Zeichen lang, das ist zu viel", len(got)) + } + // Vorne und hinten wurde gekürzt, das muss sichtbar sein. + if !strings.HasPrefix(got, "…") || !strings.HasSuffix(got, "…") { + t.Errorf("die Auslassungszeichen fehlen: %q", got) + } +} + +func TestSnippetWithoutMatch(t *testing.T) { + // Kein wörtlicher Treffer — etwa weil der Fund über die Präfix-Suche kam. + got := Snippet("Ein kurzer Text ohne den Suchbegriff.", "deploy") + + if got == "" { + t.Error("ohne Treffer kam gar kein Ausschnitt zurück") + } + if !strings.Contains(got, "kurzer Text") { + t.Errorf("es wurde nicht der Textanfang genommen: %q", got) + } +} + +func TestSnippetShortText(t *testing.T) { + got := Snippet("Kurz.", "Kurz") + if got != "Kurz." { + t.Errorf("Snippet = %q, erwartet %q", got, "Kurz.") + } +} + +// TestHighlightSnippetEscapesFirst ist ein Sicherheitstest: Der Inhalt muss +// escaped werden, BEVOR die Hervorhebung eingesetzt wird. Andersherum könnte ein +// Eintrag eigenes HTML in die Trefferliste einschleusen. +func TestHighlightSnippetEscapesFirst(t *testing.T) { + got := HighlightSnippet("Ein im Text", "script") + + if strings.Contains(got, "\n\nText", Source: store.SourceHuman, + }); err != nil { + t.Fatal(err) + } + + key := erzeugeKey(t, db, projekt.ID, "leser", auth.RoleRead) + cookie := anmelden(t, h, key) + + r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/e/boese", nil) + r.AddCookie(cookie) + + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + if strings.Contains(w.Body.String(), "") { + t.Error("eingeschleustes + + +{{end}} + +{{define "kopf"}}{{template "html-anfang" .}} + +{{/* Schmale Kopfleiste über die ganze Breite: Marke und Abmelden. Alles + Weitere steht in der Seitenleiste. */}} +
    +
    + Munin + + + + +
    +
    + +
    + {{/* ── Seitenleiste ────────────────────────────────────────────────────── + Enthält drei Gruppen: die Projekte, die Bereiche des aktuellen Projekts + und dessen Schlagworte. Die Schlagworte stehen hier statt als Filterzeile + über der Liste — sie sind Navigation, und die Liste bekommt so mehr Platz. */}} + + +
    + {{if .Error}}
    {{.Error}}
    {{end}} + {{if .Flash}}
    {{.Flash}}
    {{end}} +{{end}} + +{{define "fuss"}} +
    +
    +{{template "html-ende" .}} +{{end}} diff --git a/templates/project-edit.html b/templates/project-edit.html new file mode 100644 index 0000000..d13840f --- /dev/null +++ b/templates/project-edit.html @@ -0,0 +1,77 @@ +{{template "kopf" .}} + +
    +
    +

    {{.Data.Item.Project.Name}}

    + {{.Data.Item.Project.Slug}} · {{.Data.Item.Entries}} Einträge +
    + +
    + +
    +
    + + +
    + + +
    + +
    + + +
    + +
    + + Steht fest — er steckt in der MCP-Konfiguration jedes Clients. + +
    + +
    + + + 0 = Standardwert des Servers{{if .Data.Item.Limit}} ({{.Data.Item.Limit}}){{end}}. + {{if .Data.Item.Limit}}Im laufenden Fenster noch frei: {{.Data.Item.Frei}}.{{end}} + + +
    + +
    + + Abbrechen +
    +
    +
    + +
    +

    Gefahrenzone

    + Das Löschen entfernt alle {{.Data.Item.Entries}} Einträge und sämtliche + Zugänge dieses Projekts unwiederbringlich. Der Markdown-Export bleibt erhalten, + sofern einer eingerichtet ist. + +
    +
    + + + +
    + + Zum Löschen {{.Data.Item.Project.Slug}} eintippen. + +
    + +
    + +
    +
    +
    +
    + +{{template "fuss" .}} diff --git a/templates/projects.html b/templates/projects.html new file mode 100644 index 0000000..d2deb65 --- /dev/null +++ b/templates/projects.html @@ -0,0 +1,100 @@ +{{template "kopf" .}} + +
    +
    +

    Projekte

    + Jedes Projekt hat eigene Einträge, eigene Zugänge und einen + eigenen MCP-Endpunkt. Projekte sehen sich gegenseitig nicht. +
    + {{if .ExportEnabled}} +
    + {{/* Der Export läuft ohnehin täglich. Der Knopf ist für den Fall, dass + man unmittelbar vor einer Sicherung einen aktuellen Stand braucht. */}} +
    + + +
    +
    + {{end}} +
    + +{{if .Data.Projects}} +
    + + + + + + + + + + + + {{range .Data.Projects}} + + + + + + + + {{end}} + +
    ProjektBezeichnerEinträgeSchreibbudgetVerwaltung
    + {{.Project.Name}} + {{if .Project.Description}}
    {{.Project.Description}}
    {{end}} +
    {{.Project.Slug}}{{.Entries}} + {{if .Limit}} + {{.Frei}} / {{.Limit}} +
    + {{if .EigenesLimit}}eigene Grenze{{else}}Standard{{end}} · je Stunde +
    + {{else}} + unbegrenzt + {{end}} +
    + Bearbeiten · + Zugänge +
    +
    +{{else}} +
    +

    Noch kein Projekt angelegt.

    +

    Ein Projekt ist die Einheit der Abschottung — meist eines je Team.

    +
    +{{end}} + +
    +

    Neues Projekt

    + Der Bezeichner erscheint in der URL und in der MCP-Konfiguration + und lässt sich später nicht mehr ändern. + +
    +
    + + +
    + + +
    + +
    + + Leer lassen, um ihn aus dem Namen abzuleiten. + +
    + +
    + + +
    + +
    + +
    +
    +
    +
    + +{{template "fuss" .}}