650 lines
24 KiB
Go
650 lines
24 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"
|
|
"strings"
|
|
"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
|
|
)
|
|
|
|
// 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()
|
|
|
|
ctx := context.Background()
|
|
|
|
// ── Datenbank ────────────────────────────────────────────────────────────
|
|
//
|
|
// Aufbau: eine zentrale Kontroll-Datenbank (Workspaces, Key-Routing) und je
|
|
// Workspace ein eigener Bereich mit Projekten, Einträgen und Keys. Eine
|
|
// Abteilung kann so strukturell nie auf die Daten einer anderen zugreifen —
|
|
// siehe AUSBAUSTUFEN.md 4.4.
|
|
//
|
|
// Wo diese Bereiche liegen — SQLite-Dateien oder PostgreSQL-Schemas —
|
|
// entscheidet DB_DRIVER (AUSBAUSTUFEN.md 4.10). Ab hier weiß davon nur
|
|
// noch der Speicher.
|
|
speicher, err := oeffneSpeicher(ctx, cfg)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
defer speicher.Close()
|
|
|
|
control, err := speicher.OpenControl()
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
defer control.Close()
|
|
|
|
// workspaces öffnet und cached die Workspaces bei Bedarf — jede Abteilung
|
|
// bekommt so ihre eigene Datenbank samt Suchindex, siehe
|
|
// internal/workspace.
|
|
workspaces := workspace.NewManager(speicher)
|
|
defer workspaces.Close()
|
|
|
|
// 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)
|
|
}
|
|
|
|
// Reichen die Datenbankverbindungen für alle Workspaces? Unter PostgreSQL
|
|
// lieber jetzt mit klarer Meldung nicht starten als später unter Last mit
|
|
// abgelehnten Verbindungen scheitern. Unter SQLite gibt es diese Grenze
|
|
// nicht.
|
|
if alle, err := control.ListWorkspaces(ctx); err != nil {
|
|
log.Fatal(err)
|
|
} else if err := speicher.PruefeKapazitaet(ctx, len(alle)); 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
|
|
}
|
|
|
|
handler, err := baueHandler(control, workspaces, exporter, cfg)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
// ── 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("Speicher: %s", speicher.Beschreibung())
|
|
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.")
|
|
}
|
|
|
|
// baueHandler setzt Weboberfläche, MCP-Endpunkt und die Middleware davor zu
|
|
// einem HTTP-Handler zusammen.
|
|
//
|
|
// Eine eigene Funktion statt Teil von main, damit der Lasttest (last_test.go)
|
|
// über genau dieselbe Verdrahtung läuft wie der Betrieb — und nicht über einen
|
|
// nachgebauten Weg, der sich irgendwann unbemerkt unterscheidet.
|
|
func baueHandler(control *store.ControlStore, workspaces *workspace.Manager, exporter *export.Exporter, cfg config) (http.Handler, error) {
|
|
// ── 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 {
|
|
return nil, fmt.Errorf("Templates konnten nicht geladen werden: %w", err)
|
|
}
|
|
|
|
mcpHandler := mcpserver.New(writeBudget)
|
|
|
|
// ── Routen ───────────────────────────────────────────────────────────────
|
|
staticSub, err := fs.Sub(staticFS, "static")
|
|
if err != nil {
|
|
return nil, 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
|
|
return web.LogRequests(
|
|
web.SecurityHeaders(
|
|
web.LimitBodies(mux, web.BodyLimits{
|
|
Web: maxWebBodyBytes,
|
|
API: cfg.maxAPIBody,
|
|
Import: maxImportBodyBytes,
|
|
}),
|
|
cfg.enableHSTS,
|
|
),
|
|
), nil
|
|
}
|
|
|
|
// config sammelt alle Einstellungen aus der Umgebung.
|
|
type config struct {
|
|
// dbDriver wählt den Speicher: "sqlite" (Standard) oder "postgres" —
|
|
// siehe AUSBAUSTUFEN.md 4.10.
|
|
dbDriver string
|
|
|
|
// databaseURL ist die Verbindung zu PostgreSQL, z.B.
|
|
// postgres://munin:geheim@localhost:5432/munin?sslmode=disable.
|
|
// Nur bei dbDriver "postgres".
|
|
databaseURL string
|
|
|
|
// dataDir enthält bei SQLite die Kontroll-Datenbank (control.db) und den
|
|
// Ordner workspaces/ mit einer Datei je Workspace — siehe AUSBAUSTUFEN.md
|
|
// 4.4. Bei PostgreSQL wird er nicht gebraucht.
|
|
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{
|
|
dbDriver: strings.ToLower(envOr("DB_DRIVER", dbDriverSQLite)),
|
|
databaseURL: os.Getenv("DATABASE_URL"),
|
|
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),
|
|
}
|
|
|
|
switch cfg.dbDriver {
|
|
case dbDriverSQLite:
|
|
case dbDriverPostgres:
|
|
if cfg.databaseURL == "" {
|
|
log.Fatal("DB_DRIVER=postgres, aber DATABASE_URL ist nicht gesetzt. Beispiel: postgres://munin:geheim@localhost:5432/munin?sslmode=disable")
|
|
}
|
|
default:
|
|
log.Fatalf("DB_DRIVER=%q ist unbekannt — erlaubt sind %q (Standard) und %q.", cfg.dbDriver, dbDriverSQLite, dbDriverPostgres)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// Die Werte für DB_DRIVER.
|
|
const (
|
|
dbDriverSQLite = "sqlite"
|
|
dbDriverPostgres = "postgres"
|
|
)
|
|
|
|
// oeffneSpeicher wählt anhand von DB_DRIVER, wo die Daten liegen.
|
|
//
|
|
// Bei SQLite wird der Ordner für die Workspace-Dateien angelegt, falls er
|
|
// fehlt. Bei PostgreSQL wartet NewPostgres nötigenfalls, bis die Datenbank
|
|
// erreichbar ist, und legt das Schema der Kontroll-Datenbank an.
|
|
func oeffneSpeicher(ctx context.Context, cfg config) (store.Speicher, error) {
|
|
if cfg.dbDriver == dbDriverPostgres {
|
|
return store.NewPostgres(ctx, cfg.databaseURL)
|
|
}
|
|
|
|
workspacesDir := filepath.Join(cfg.dataDir, "workspaces")
|
|
if err := os.MkdirAll(workspacesDir, 0750); err != nil {
|
|
return nil, fmt.Errorf("Datenverzeichnis konnte nicht angelegt werden: %w", err)
|
|
}
|
|
return store.NewSQLite(filepath.Join(cfg.dataDir, "control.db"), workspacesDir), nil
|
|
}
|
|
|
|
// 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() {
|
|
beginn := time.Now()
|
|
stats, err := exportiereAlle(context.Background(), e, control, workspaces)
|
|
|
|
// Für die Systemseite: Dort steht, wann der letzte Lauf endete und ob
|
|
// er vollständig war.
|
|
ergebnis := export.Lauf{Ende: time.Now(), Dauer: time.Since(beginn), Stats: stats}
|
|
if err != nil {
|
|
ergebnis.Fehler = err.Error()
|
|
}
|
|
e.MerkeLauf(ergebnis)
|
|
|
|
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(export.Intervall)
|
|
defer ticker.Stop()
|
|
for range ticker.C {
|
|
lauf()
|
|
}
|
|
}
|
|
|
|
// aufraeumenRegelmaessig räumt täglich in allen Workspaces auf: das Protokoll
|
|
// (nach 90 Tagen), das Suchprotokoll (nach store.SearchLogRetention) 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))
|
|
}
|
|
|
|
// Das Suchprotokoll hat eine eigene, kürzere Frist: Anfragen sind
|
|
// Inhalt, und die Auswertung braucht ohnehin nur 30 Tage.
|
|
suchgrenze := time.Now().UTC().Add(-store.SearchLogRetention).Format(time.RFC3339Nano)
|
|
n, err = offen.Store.PruneSearchLog(ctx, suchgrenze)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n > 0 {
|
|
log.Printf("Suchprotokoll von %q aufgeräumt: %d Suchen älter als %d Tage entfernt",
|
|
w.Name, n, store.UsageWindowDays)
|
|
}
|
|
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
|
|
}
|
|
}
|