munin/main.go
Thomas Adamek 466f1e40af Workspaces, Änderungshistorie, Papierkorb, Nutzungsübersicht, Hilfe
Umsetzung der Ausbaustufen 2.1, 3.6, 3.8, 3.9, 4.1, 4.2, 4.4, 4.6,
5.1–5.4 (Details in AUSBAUSTUFEN.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:44:52 +02:00

544 lines
20 KiB
Go

// Munin — ein projektbezogenes Gedächtnis für KI-Agenten und Menschen.
//
// Benannt nach dem Raben Odins, dessen Name „Gedächtnis" bedeutet: Er fliegt
// aus, sammelt, kehrt zurück und berichtet. Genau das tun die Agenten hier.
//
// Der Hauptzugang ist MCP (Model Context Protocol): KI-Werkzeuge legen dort
// Wissen ab und rufen es wieder auf. Die Weboberfläche dient zum Nachlesen,
// Pflegen und Verwalten.
//
// Dieses File ist der Einstiegspunkt: Es liest die Konfiguration, prüft die
// Pflichtangaben, baut die Bestandteile zusammen und startet den HTTP-Server.
package main
import (
"context"
"embed"
"errors"
"flag"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"strconv"
"syscall"
"time"
"github.com/joho/godotenv"
"munin/internal/auth"
"munin/internal/export"
"munin/internal/mcpserver"
"munin/internal/store"
"munin/internal/web"
"munin/internal/workspace"
)
// //go:embed bettet Dateien beim Kompilieren ins Binary ein. Nach dem Bauen sind
// templates/ und static/ nicht mehr als Ordner nötig — alles steckt im Programm.
// Das Ergebnis ist eine einzelne Datei, die überall läuft.
//go:embed templates/*
var templatesFS embed.FS
//go:embed static/*
var staticFS embed.FS
// ── Größenbegrenzungen ───────────────────────────────────────────────────────
//
// Ohne diese Grenzen kann ein einzelner Aufruf beliebig viel Speicher belegen.
// Das muss nicht böswillig sein — ein Agent in einer Schleife reicht aus.
const (
// maxWebBodyBytes gilt für die Formulare der Weboberfläche. Ein Eintrag kann
// lang sein, aber ein Megabyte Text ist bereits ein sehr langes Dokument.
maxWebBodyBytes = 1 << 20 // 1 MB
// maxAPIBodyBytes gilt für MCP-Aufrufe.
defaultMaxAPIBodyBytes = 4 << 20 // 4 MB
)
// ── Server-Timeouts ──────────────────────────────────────────────────────────
//
// Ohne Timeouts hält eine einzige Verbindung dauerhaft Ressourcen fest. Genau
// darauf zielt der "Slowloris"-Angriff: viele Verbindungen öffnen und die
// Anfrage extrem langsam senden, bis der Server keine neuen mehr annimmt.
const (
readHeaderTimeout = 10 * time.Second
readTimeout = 60 * time.Second
writeTimeout = 120 * time.Second
idleTimeout = 2 * time.Minute
)
// ── Schreibbudget ────────────────────────────────────────────────────────────
//
// Ein gültiger API-Key umgeht die IP-Sperre bewusst (siehe APIAuth in
// internal/web/middleware.go). Ohne eine zweite Grenze hätte damit ein Agent,
// der in einer Schleife hängt, überhaupt kein Limit mehr.
const (
// defaultWriteLimitHourly ist die Vorgabe für alle Projekte ohne eigene
// Obergrenze.
//
// Warum 1.000? Echte Agentenarbeit sind eine Handvoll "remember"-Aufrufe je
// Sitzung; auch ein Projekt mit vielen gleichzeitigen Agenten kommt kaum
// über ein paar Dutzend je Stunde. 1.000 liegt also weit über dem Bedarf und
// zugleich weit unter dem, was Schaden anrichtet — gemessene Kapazität sind
// 8.656 Schreibvorgänge je SEKUNDE (siehe AUSBAUSTUFEN.md).
//
// Die Zahl darf großzügig sein: Sie soll eine Schleife stoppen, nicht Arbeit
// rationieren. Ein zu niedriger Wert blockiert echte Nutzung und wird dann
// abgeschaltet — und damit schützt er vor gar nichts mehr.
defaultWriteLimitHourly = 1000
// writeLimitWindow ist das Zeitfenster, auf das sich die Obergrenze bezieht.
writeLimitWindow = time.Hour
)
// exportInterval bestimmt, wie oft der Markdown-Export automatisch läuft.
const exportInterval = 24 * time.Hour
// auditRetention bestimmt, wie lange Protokolleinträge aufbewahrt werden.
//
// Das Protokoll enthält IP-Adressen und Zugangsnamen, aus denen sich Personen
// ableiten lassen. Eine Aufbewahrungsfrist ist deshalb nicht nur eine Frage des
// Plattenplatzes, sondern die Voraussetzung dafür, das Protokoll überhaupt
// führen zu dürfen.
const auditRetention = 90 * 24 * time.Hour
func main() {
// Mit -export läuft nur der Export und das Programm endet danach. Gedacht
// für Sicherungsskripte und Cronjobs, die einen aktuellen Stand brauchen,
// ohne den Server anzufassen.
nurExport := flag.Bool("export", false, "Nur den Markdown-Export ausführen und beenden")
flag.Parse()
// .env laden, falls vorhanden. Echte Umgebungsvariablen haben Vorrang.
if err := godotenv.Load(); err != nil && !os.IsNotExist(err) {
log.Printf("Hinweis: .env konnte nicht geladen werden: %v", err)
}
cfg := readConfig()
// ── Datenbank ────────────────────────────────────────────────────────────
//
// Aufbau: eine zentrale Kontroll-Datenbank (Workspaces, Key-Routing) und je
// Workspace eine eigene Datei mit Projekten, Einträgen und Keys. Eine
// Abteilung kann so strukturell nie auf die Datei einer anderen zugreifen —
// siehe AUSBAUSTUFEN.md 4.4.
workspacesDir := filepath.Join(cfg.dataDir, "workspaces")
if err := os.MkdirAll(workspacesDir, 0750); err != nil {
log.Fatal("Datenverzeichnis konnte nicht angelegt werden: ", err)
}
control, err := store.OpenControl(filepath.Join(cfg.dataDir, "control.db"))
if err != nil {
log.Fatal(err)
}
defer control.Close()
// workspaces öffnet und cached die Workspace-Dateien bei Bedarf — jede
// Abteilung bekommt so ihre eigene Datenbank samt Suchindex, siehe
// internal/workspace.
workspaces := workspace.NewManager(workspacesDir)
defer workspaces.Close()
ctx := context.Background()
// Beim allerersten Start gibt es noch keinen Workspace — dann wird einer
// angelegt, damit der Server ohne Zwischenschritt benutzbar ist.
if _, err := control.EnsureDefaultWorkspace(ctx, "Intern"); err != nil {
log.Fatal(err)
}
// Der Export ist abgeschaltet, solange EXPORT_DIR nicht gesetzt ist.
var exporter *export.Exporter
if cfg.exportDir != "" {
exporter = export.New(cfg.exportDir)
}
if *nurExport {
if exporter == nil {
log.Fatal("EXPORT_DIR ist nicht gesetzt — ohne Zielverzeichnis gibt es nichts zu exportieren.")
}
stats, err := exportiereAlle(ctx, exporter, control, workspaces)
log.Printf("Export abgeschlossen: %d Dateien geschrieben, %d verwaiste entfernt", stats.Written, stats.Removed)
if err != nil {
// Die übrigen Workspaces sind trotzdem exportiert (siehe
// fuerJedenWorkspace). Der Exit-Code muss den Fehler aber melden,
// sonst hält ein Cronjob die Sicherung für vollständig.
log.Fatal("Export unvollständig: ", err)
}
return
}
// ── Bestandteile ─────────────────────────────────────────────────────────
signer := auth.NewSigner([]byte(cfg.sessionSecret))
cookies := auth.NewCookiePolicy(os.Getenv("COOKIE_SECURE"))
limiter := auth.NewRateLimiter()
writeBudget := auth.NewWriteBudget(cfg.writeLimitHourly, writeLimitWindow)
webServer, err := web.NewServer(web.Config{
Workspaces: workspaces,
Control: control,
Signer: signer,
Cookies: cookies,
Limiter: limiter,
Templates: templatesFS,
AdminToken: cfg.adminToken,
Exporter: exporter,
WriteBudget: writeBudget,
})
if err != nil {
log.Fatal("Templates konnten nicht geladen werden: ", err)
}
mcpHandler := mcpserver.New(writeBudget)
// ── Routen ───────────────────────────────────────────────────────────────
staticSub, err := fs.Sub(staticFS, "static")
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
webServer.Routes(mux, staticSub)
// Der MCP-Endpunkt hängt an der Key-Prüfung statt an der Sitzung — hier
// kommen keine Browser an, sondern KI-Werkzeuge mit einem Bearer-Key.
mux.Handle("/mcp", web.LimitBody(webServer.APIAuth(mcpHandler), cfg.maxAPIBody))
// Reihenfolge von außen nach innen:
// LogRequests erfasst jede Antwort, auch abgewiesene
// SecurityHeaders gilt für jede Antwort, auch für Fehlerseiten
// LimitBody deckelt den Body, bevor ein Handler ihn liest
handler := web.LogRequests(
web.SecurityHeaders(
web.LimitBody(mux, maxWebBodyBytes),
cfg.enableHSTS,
),
)
// ── Wiederkehrende Aufgaben ──────────────────────────────────────────────
//
// Beide laufen bei jedem Durchgang über ALLE Workspaces — auch über die,
// die erst nach dem Start angelegt wurden. Deshalb wird die Liste jedes
// Mal frisch aus der Kontroll-Datenbank geholt (siehe fuerJedenWorkspace).
go aufraeumenRegelmaessig(control, workspaces)
if exporter != nil {
go exportRegularly(exporter, control, workspaces)
log.Printf("Markdown-Export aktiv: %s (täglich)", cfg.exportDir)
}
// ── Server starten ───────────────────────────────────────────────────────
server := &http.Server{
Addr: ":" + cfg.port,
Handler: handler,
ReadHeaderTimeout: readHeaderTimeout,
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
IdleTimeout: idleTimeout,
// Standard wären 1 MB Header. So viel braucht niemand, und kleinere
// Grenzen bedeuten weniger Speicher je halb offener Verbindung.
MaxHeaderBytes: 64 << 10,
}
signalCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go func() {
alle, _ := control.ListWorkspaces(ctx)
log.Printf("Munin läuft auf http://localhost:%s (%d Workspaces)", cfg.port, len(alle))
log.Printf("Workspaces: %s (Kontrolle: %s)", workspacesDir, filepath.Join(cfg.dataDir, "control.db"))
if !cfg.enableHSTS {
log.Println("Hinweis: ENABLE_HSTS ist aus — bei Betrieb unter HTTPS auf true setzen.")
}
if cfg.writeLimitHourly <= 0 {
log.Println("Warnung: WRITE_LIMIT_HOURLY=0 — schreibende MCP-Aufrufe sind unbegrenzt.")
} else {
log.Printf("Schreibbudget: %d Aufrufe je Projekt und Stunde (Vorgabe)", cfg.writeLimitHourly)
}
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal("Server-Fehler: ", err)
}
}()
<-signalCtx.Done()
log.Println("Server wird gestoppt…")
// Laufende Anfragen zu Ende bringen, bevor der Prozess endet.
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("Fehler beim Stoppen: %v", err)
}
log.Println("Server gestoppt.")
}
// config sammelt alle Einstellungen aus der Umgebung.
type config struct {
// dataDir enthält die Kontroll-Datenbank (control.db) und den Ordner
// workspaces/ mit einer Datei je Workspace — siehe AUSBAUSTUFEN.md 4.4.
dataDir string
port string
adminToken string
sessionSecret string
enableHSTS bool
maxAPIBody int64
exportDir string
// writeLimitHourly ist die Vorgabe für Projekte ohne eigene Obergrenze.
// 0 schaltet die Begrenzung vollständig ab.
writeLimitHourly int
}
// readConfig liest die Umgebungsvariablen und prüft die Pflichtangaben.
//
// Der Server startet lieber gar nicht, als mit einem schwachen Geheimnis zu
// laufen: SESSION_SECRET signiert Sitzungen und CSRF-Tokens, ADMIN_TOKEN öffnet
// jedes Projekt. Ein erratbarer Wert wäre eine Hintertür.
func readConfig() config {
cfg := config{
dataDir: envOr("DATA_DIR", "data"),
port: envOr("PORT", "8080"),
adminToken: os.Getenv("ADMIN_TOKEN"),
sessionSecret: os.Getenv("SESSION_SECRET"),
enableHSTS: isTrue(os.Getenv("ENABLE_HSTS")),
maxAPIBody: envBytes("MAX_API_BODY_BYTES", defaultMaxAPIBodyBytes),
exportDir: os.Getenv("EXPORT_DIR"),
writeLimitHourly: envInt("WRITE_LIMIT_HOURLY", defaultWriteLimitHourly),
}
if cfg.sessionSecret == "" {
log.Fatal("SESSION_SECRET ist nicht gesetzt — signiert Sitzungen und CSRF-Tokens. Erzeugen mit: openssl rand -hex 32")
}
if len(cfg.sessionSecret) < 32 {
log.Fatal("SESSION_SECRET ist zu kurz — mindestens 32 Zeichen. Erzeugen mit: openssl rand -hex 32")
}
if cfg.adminToken == "" {
log.Fatal("ADMIN_TOKEN ist nicht gesetzt — der Plattform-Admin legt damit Projekte an. Erzeugen mit: openssl rand -hex 24")
}
if len(cfg.adminToken) < 16 {
log.Fatal("ADMIN_TOKEN ist zu kurz — mindestens 16 Zeichen. Erzeugen mit: openssl rand -hex 24")
}
// Hinter einem Reverse-Proxy muss der Server wissen, wem er die echte
// Client-Adresse glauben darf (siehe internal/auth/trustedproxy.go).
if trusted := os.Getenv("TRUSTED_PROXY"); trusted != "" {
if err := auth.SetTrustedProxies(trusted); err != nil {
log.Fatal(err)
}
log.Printf("TRUSTED_PROXY aktiv (%d Einträge) — X-Forwarded-For wird ausgewertet", auth.TrustedProxyCount())
}
return cfg
}
// fuerJedenWorkspace ruft fn für jeden Workspace mit dessen geöffneter Datei
// auf.
//
// Die Liste kommt bei jedem Aufruf frisch aus der Kontroll-Datenbank. Ein
// Workspace, der gestern über die Oberfläche angelegt wurde, ist damit beim
// nächsten Durchgang dabei, ohne dass der Server neu starten muss.
//
// Scheitert fn für einen Workspace, geht es mit den übrigen weiter: Ein
// einzelner kaputter Workspace soll nicht verhindern, dass bei allen anderen
// das Protokoll aufgeräumt oder der Export geschrieben wird. Die Fehler werden
// gesammelt und zusammen zurückgegeben (errors.Join).
func fuerJedenWorkspace(ctx context.Context, control *store.ControlStore, workspaces *workspace.Manager,
fn func(store.Workspace, *workspace.Workspace) error) error {
alle, err := control.ListWorkspaces(ctx)
if err != nil {
return fmt.Errorf("Workspaces konnten nicht gelesen werden: %w", err)
}
var fehler []error
for _, w := range alle {
offen, err := workspaces.Get(w.ID)
if err == nil {
err = fn(w, offen)
}
if err != nil {
fehler = append(fehler, fmt.Errorf("Workspace %q (%s): %w", w.Name, w.ID, err))
}
}
return errors.Join(fehler...)
}
// exportiereAlle exportiert jeden Workspace in seinen eigenen Unterordner und
// zählt die Ergebnisse zusammen.
func exportiereAlle(ctx context.Context, e *export.Exporter, control *store.ControlStore, workspaces *workspace.Manager) (export.Stats, error) {
var gesamt export.Stats
err := fuerJedenWorkspace(ctx, control, workspaces, func(w store.Workspace, offen *workspace.Workspace) error {
stats, err := e.ExportWorkspace(ctx, offen.Store, w)
gesamt.Written += stats.Written
gesamt.Removed += stats.Removed
return err
})
return gesamt, err
}
// exportRegularly schreibt den Bestand regelmäßig als Markdown-Dateien.
//
// Einmal direkt beim Start, damit auch ein Server, der nie einen ganzen Tag
// durchläuft, einen aktuellen Stand hinterlässt.
func exportRegularly(e *export.Exporter, control *store.ControlStore, workspaces *workspace.Manager) {
lauf := func() {
stats, err := exportiereAlle(context.Background(), e, control, workspaces)
if err != nil {
log.Printf("Export fehlgeschlagen: %v", err)
}
if stats.Removed > 0 {
log.Printf("Export: %d Dateien geschrieben, %d verwaiste entfernt", stats.Written, stats.Removed)
}
}
lauf()
ticker := time.NewTicker(exportInterval)
defer ticker.Stop()
for range ticker.C {
lauf()
}
}
// aufraeumenRegelmaessig räumt täglich in allen Workspaces auf: das Protokoll
// (nach 90 Tagen) und den Papierkorb (nach store.TrashRetention).
//
// Jeder Workspace führt Protokoll und Papierkorb in seiner eigenen Datei. Die
// Fristen gelten also nur dann, wenn wirklich jede Datei aufgeräumt wird — ein
// vergessener Workspace hieße: Adressen, Zugangsnamen und gelöschte Einträge
// bleiben dort unbegrenzt liegen.
func aufraeumenRegelmaessig(control *store.ControlStore, workspaces *workspace.Manager) {
// Einmal direkt beim Start, damit auch ein Server aufräumt, der nie 24
// Stunden am Stück läuft.
pruneAlle(control, workspaces)
papierkorbLeerenAlle(control, workspaces)
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for range ticker.C {
pruneAlle(control, workspaces)
papierkorbLeerenAlle(control, workspaces)
}
}
// papierkorbLeerenAlle entfernt in jedem Workspace die Einträge endgültig, die
// länger als die Frist im Papierkorb liegen (AUSBAUSTUFEN.md 3.9).
//
// Jeder entfernte Eintrag bekommt eine eigene Protokollzeile mit Herkunft
// "system". So lässt sich später auch für einen nach Ablauf verschwundenen
// Eintrag nachvollziehen, wann und warum er weg ist.
func papierkorbLeerenAlle(control *store.ControlStore, workspaces *workspace.Manager) {
ctx := context.Background()
grenze := time.Now().UTC().Add(-store.TrashRetention).Format(time.RFC3339Nano)
err := fuerJedenWorkspace(ctx, control, workspaces, func(w store.Workspace, offen *workspace.Workspace) error {
entfernt, err := offen.Store.PurgeTrash(ctx, grenze)
if err != nil {
return err
}
// entfernt ordnet jeder Projekt-ID die Liste ihrer entfernten
// Bezeichner zu. "range" über eine map liefert Schlüssel und Wert.
anzahl := 0
for projektID, slugs := range entfernt {
for _, slug := range slugs {
offen.Store.LogAudit(ctx, store.AuditEvent{
ProjectID: projektID, Actor: "system",
Action: store.ActionEntryPurge, EntrySlug: slug, Source: store.SourceSystem,
Detail: fmt.Sprintf("nach %d Tagen im Papierkorb", store.TrashRetentionDays),
})
anzahl++
}
}
if anzahl > 0 {
log.Printf("Papierkorb von %q aufgeräumt: %d Einträge endgültig entfernt", w.Name, anzahl)
}
return nil
})
if err != nil {
log.Printf("Papierkorb konnte nicht vollständig aufgeräumt werden: %v", err)
}
}
// pruneAlle räumt einmal das Protokoll jedes Workspace auf.
func pruneAlle(control *store.ControlStore, workspaces *workspace.Manager) {
ctx := context.Background()
grenze := time.Now().UTC().Add(-auditRetention).Format(time.RFC3339Nano)
err := fuerJedenWorkspace(ctx, control, workspaces, func(w store.Workspace, offen *workspace.Workspace) error {
n, err := offen.Store.PruneAudit(ctx, grenze)
if err != nil {
return err
}
if n > 0 {
log.Printf("Protokoll von %q aufgeräumt: %d Einträge älter als %d Tage entfernt",
w.Name, n, int(auditRetention.Hours()/24))
}
return nil
})
if err != nil {
log.Printf("Protokoll konnte nicht vollständig aufgeräumt werden: %v", err)
}
}
// envOr liest eine Umgebungsvariable mit Standardwert.
func envOr(key, standard string) string {
if v := os.Getenv(key); v != "" {
return v
}
return standard
}
// envBytes liest eine Byte-Angabe. Ein unbrauchbarer Wert führt zu einer Warnung
// und dem Standardwert — ein Tippfehler soll den Start nicht verhindern.
func envBytes(key string, standard int64) int64 {
roh := os.Getenv(key)
if roh == "" {
return standard
}
wert, err := strconv.ParseInt(roh, 10, 64)
if err != nil || wert <= 0 {
log.Printf("Hinweis: %s=%q ist keine gültige Byte-Angabe — es gilt der Standardwert %d", key, roh, standard)
return standard
}
return wert
}
// envInt liest eine Zahl. Wie envBytes führt ein unbrauchbarer Wert zu einer
// Warnung und dem Standardwert, statt den Start zu verhindern.
//
// Anders als envBytes ist 0 hier ein gültiger Wert — er schaltet die Begrenzung
// ab. Deshalb eine eigene Funktion statt eines Umbaus von envBytes: Dort wäre 0
// ein Tippfehler, hier eine Ansage.
func envInt(key string, standard int) int {
roh := os.Getenv(key)
if roh == "" {
return standard
}
wert, err := strconv.Atoi(roh)
if err != nil || wert < 0 {
log.Printf("Hinweis: %s=%q ist keine gültige Zahl — es gilt der Standardwert %d", key, roh, standard)
return standard
}
return wert
}
// isTrue erkennt die üblichen Schreibweisen für "an".
func isTrue(v string) bool {
switch v {
case "true", "TRUE", "True", "1", "yes", "on":
return true
default:
return false
}
}