// 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 // maxImportBodyBytes gilt für das Hochladen eines ZIP-Archivs zum Import // (AUSBAUSTUFEN.md 3.2). Ein exportiertes Projekt mit einigen tausend // Einträgen passt gepackt bequem hinein. Was ausgepackt daraus werden darf, // begrenzt der Importer selbst (siehe internal/importer). maxImportBodyBytes = 20 << 20 // 20 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", webServer.APIAuth(mcpHandler)) // Reihenfolge von außen nach innen: // LogRequests erfasst jede Antwort, auch abgewiesene // SecurityHeaders gilt für jede Antwort, auch für Fehlerseiten // LimitBodies deckelt den Body, bevor ein Handler ihn liest — mit // einer eigenen Grenze für /mcp und den Import, siehe // dort, warum das nicht je Route geschehen kann handler := web.LogRequests( web.SecurityHeaders( web.LimitBodies(mux, web.BodyLimits{ Web: maxWebBodyBytes, API: cfg.maxAPIBody, Import: maxImportBodyBytes, }), 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 } }