// 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" "flag" "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/search" "munin/internal/store" "munin/internal/web" ) // //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 ──────────────────────────────────────────────────────────── if err := os.MkdirAll(filepath.Dir(cfg.dbPath), 0750); err != nil { log.Fatal("Datenverzeichnis konnte nicht angelegt werden: ", err) } db, err := store.Open(cfg.dbPath) if err != nil { log.Fatal(err) } defer db.Close() ctx := context.Background() workspace, err := db.EnsureWorkspace(ctx, "Intern") if 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(db, cfg.exportDir) } if *nurExport { if exporter == nil { log.Fatal("EXPORT_DIR ist nicht gesetzt — ohne Zielverzeichnis gibt es nichts zu exportieren.") } stats, err := exporter.ExportAll(ctx, workspace.ID) if err != nil { log.Fatal("Export fehlgeschlagen: ", err) } log.Printf("Export abgeschlossen: %d Dateien geschrieben, %d verwaiste entfernt", stats.Written, stats.Removed) return } // ── Bestandteile ───────────────────────────────────────────────────────── signer := auth.NewSigner([]byte(cfg.sessionSecret)) cookies := auth.NewCookiePolicy(os.Getenv("COOKIE_SECURE")) limiter := auth.NewRateLimiter() writeBudget := auth.NewWriteBudget(cfg.writeLimitHourly, writeLimitWindow) searchRegistry := search.NewRegistry(db) webServer, err := web.NewServer(web.Config{ Store: db, Search: searchRegistry, Signer: signer, Cookies: cookies, Limiter: limiter, Templates: templatesFS, WorkspaceID: workspace.ID, AdminToken: cfg.adminToken, Exporter: exporter, WriteBudget: writeBudget, }) if err != nil { log.Fatal("Templates konnten nicht geladen werden: ", err) } mcpHandler := mcpserver.New(db, searchRegistry, 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 ────────────────────────────────────────────── go pruneAuditRegularly(db) if exporter != nil { go exportRegularly(exporter, workspace.ID) 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() { projekte, _ := db.ListProjects(ctx, workspace.ID) log.Printf("Munin läuft auf http://localhost:%s (%d Projekte)", cfg.port, len(projekte)) log.Printf("Datenbank: %s", cfg.dbPath) 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 { dbPath 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{ dbPath: envOr("DB_PATH", "data/munin.db"), 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 } // 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, workspaceID string) { lauf := func() { stats, err := e.ExportAll(context.Background(), workspaceID) if err != nil { log.Printf("Export fehlgeschlagen: %v", err) return } 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() } } // pruneAuditRegularly räumt das Protokoll täglich auf. func pruneAuditRegularly(db *store.Store) { // Einmal direkt beim Start, damit auch ein Server aufräumt, der nie 24 // Stunden am Stück läuft. prune(db) ticker := time.NewTicker(24 * time.Hour) defer ticker.Stop() for range ticker.C { prune(db) } } func prune(db *store.Store) { grenze := time.Now().UTC().Add(-auditRetention).Format(time.RFC3339Nano) n, err := db.PruneAudit(context.Background(), grenze) if err != nil { log.Printf("Protokoll konnte nicht aufgeräumt werden: %v", err) return } if n > 0 { log.Printf("Protokoll aufgeräumt: %d Einträge älter als %d Tage entfernt", n, int(auditRetention.Hours()/24)) } } // 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 } }