package web import ( "crypto/subtle" "errors" "fmt" "log" "net/http" "strconv" "strings" "time" "munin/internal/auth" "munin/internal/store" "munin/internal/workspace" ) // requireSession prüft den Session-Cookie und legt die Sitzung im Context ab. // // Reihenfolge der Prüfungen: // // 1. Signatur und Ablaufdatum des Cookies // 2. Gibt es den zugehörigen Key noch, und ist er nicht zurückgezogen? // 3. Bei schreibenden Requests: stimmt das CSRF-Token? // // Schritt 2 ist der Grund, warum ein zurückgezogener Key sofort wirkt: Die // Sitzung trägt nur die Key-ID, die Gültigkeit wird bei jedem Request frisch in // der Datenbank nachgesehen. func (s *Server) requireSession(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie(auth.SessionCookieName) if err != nil { s.redirectToLogin(w, r) return } subject, ok := s.signer.VerifySession(cookie.Value) if !ok { s.redirectToLogin(w, r) return } sess, ok := s.sessionFromSubject(r, subject) if !ok { s.redirectToLogin(w, r) return } if !s.checkCSRF(w, r, subject) { return } ctx := withSession(r.Context(), sess) // Ohne gewählten Workspace (Plattform-Admin vor der Workspace-Auswahl) // gibt es nichts zu öffnen — solche Sitzungen dürfen ausschließlich die // Workspace-Auswahl aufrufen, siehe requireWorkspacePicked. if sess.WorkspaceID != "" { ws, err := s.workspaces.Get(sess.WorkspaceID) if err != nil { s.serverError(w, "Workspace konnte nicht geöffnet werden", err) return } ctx = withWorkspace(ctx, ws) } next.ServeHTTP(w, r.WithContext(ctx)) }) } // sessionFromSubject baut aus dem Session-Subject die vollständige Sitzung. // // Ein Subject hat eine von vier Formen: // // - "platform/" — Plattform-Admin, noch kein Workspace // gewählt. Der Fingerabdruck muss zum aktuellen ADMIN_TOKEN passen // (siehe auth.PlatformSubject). // - "platform/:" — Plattform-Admin, mit // gewähltem Workspace. // - "wsadmin::" — Workspace-Admin, fest an seinen // Workspace gebunden (AUSBAUSTUFEN.md 4.8). // - "key::" — Projekt-Zugang, immer an genau einen // Workspace gebunden (den seines Keys). // // Die Workspace-ID steht direkt im (signierten, also fälschungssicheren) // Subject — so lässt sich jede Anfrage ohne Umweg über die Kontroll-Datenbank // der richtigen Workspace-Datei zuordnen. // // Sitzungen aus der Zeit vor dem Fingerabdruck ("platform" ohne Zusatz) passen // zu keiner Form mehr und enden. Der Plattform-Admin meldet sich einmal neu an. func (s *Server) sessionFromSubject(r *http.Request, subject string) (Session, bool) { if fingerabdruck, workspaceID, ok := auth.ParsePlatformSubject(subject); ok { // Mit einem anderen ADMIN_TOKEN entstanden: Das Token wurde seitdem // ersetzt, die Sitzung gilt nicht mehr. Vergleich in konstanter Zeit, // damit die Antwortzeit nichts über den richtigen Abdruck verrät. if subtle.ConstantTimeCompare([]byte(fingerabdruck), []byte(s.adminFingerprint)) != 1 { return Session{}, false } if workspaceID == "" { return Session{ Subject: subject, IsPlatformAdmin: true, Role: auth.RoleAdmin, }, true } ws, err := s.control.WorkspaceByID(r.Context(), workspaceID) if err != nil { return Session{}, false } return Session{ Subject: subject, IsPlatformAdmin: true, WorkspaceID: workspaceID, WorkspaceName: ws.Name, Role: auth.RoleAdmin, }, true } if rest, ok := strings.CutPrefix(subject, auth.SubjectWorkspaceAdminPrefix); ok { return s.workspaceAdminSession(r, subject, rest) } rest, ok := auth.KeyIDFromSubject(subject) if !ok { return Session{}, false } workspaceID, keyID, ok := strings.Cut(rest, ":") if !ok { return Session{}, false } ws, err := s.workspaces.Get(workspaceID) if err != nil { return Session{}, false } // Zurückgezogen oder abgelaufen: Die Sitzung endet bei der nächsten // Anfrage, nicht erst beim Ablauf des Cookies. resolved, err := ws.Store.APIKeyByID(r.Context(), keyID) if err != nil || !resolved.Valid(time.Now()) { return Session{}, false } project, err := ws.Store.ProjectByID(r.Context(), resolved.ProjectID) if err != nil { return Session{}, false } return Session{ Subject: subject, WorkspaceID: workspaceID, Key: resolved, Project: project, Role: auth.Role(resolved.Role), }, true } // workspaceAdminSession baut die Sitzung eines Workspace-Admins aus dem Rest // seines Subjects (":"). // // Wie bei den Keys wird der Zugang bei jedem Request frisch nachgesehen: Ein // zurückgezogener Zugang beendet die Sitzung sofort, ebenso ein gelöschter // Workspace (dessen Zugänge fallen per ON DELETE CASCADE mit weg). // // Der Vergleich der Workspace-IDs ist eine zweite Sicherung: Das Subject ist // signiert und damit nicht fälschbar, aber die Bindung an den Workspace soll // nicht allein davon abhängen, dass es beim Anmelden richtig gebildet wurde. func (s *Server) workspaceAdminSession(r *http.Request, subject, rest string) (Session, bool) { workspaceID, adminID, ok := strings.Cut(rest, ":") if !ok { return Session{}, false } admin, err := s.control.WorkspaceAdminByID(r.Context(), adminID) if err != nil || admin.Revoked() || admin.WorkspaceID != workspaceID { return Session{}, false } ws, err := s.control.WorkspaceByID(r.Context(), workspaceID) if err != nil { return Session{}, false } // Nebenbei die letzte Nutzung vermerken — eine Sitzung gilt 30 Tage, der // Login allein sagt also wenig darüber, ob der Zugang noch gebraucht wird. _ = s.control.TouchWorkspaceAdmin(r.Context(), admin.ID, admin.LastUsedAt) return Session{ Subject: subject, IsWorkspaceAdmin: true, WorkspaceID: workspaceID, WorkspaceName: ws.Name, WorkspaceAdmin: admin, }, true } // checkCSRF prüft bei schreibenden Requests das CSRF-Token. // // GET, HEAD und OPTIONS gelten als sichere Methoden — sie sollen nichts // verändern und brauchen kein Token. Alles andere muss eines mitschicken. func (s *Server) checkCSRF(w http.ResponseWriter, r *http.Request, subject string) bool { switch r.Method { case http.MethodGet, http.MethodHead, http.MethodOptions: return true } if !s.signer.ValidCSRFToken(auth.CSRFTokenFromRequest(r), subject) { http.Error(w, "Ungültiges oder fehlendes CSRF-Token — bitte die Seite neu laden und erneut versuchen.", http.StatusForbidden) return false } return true } // redirectToLogin schickt den Browser zum Anmeldeformular. // // Bei einem schreibenden Request wäre eine Weiterleitung irreführend: Der Browser // würde das POST als GET wiederholen und der Nutzer stünde ohne Erklärung vor dem // Formular. Deshalb dort ein klarer Statuscode. func (s *Server) redirectToLogin(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet, http.MethodHead: http.Redirect(w, r, "/login", http.StatusSeeOther) default: http.Error(w, "Sitzung abgelaufen — bitte neu anmelden.", http.StatusUnauthorized) } } // requireProject löst den Projekt-Bezeichner aus der URL auf und prüft den Zugriff. // // Alle Projektseiten liegen unter /p/{project}/… — dieser eine Ort entscheidet // damit für sämtliche Projektrouten, ob jemand hineindarf. func (s *Server) requireProject(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sess, ok := SessionFrom(r) if !ok { s.redirectToLogin(w, r) return } ws, ok := WorkspaceFrom(r) if !ok { // Plattform-Admin ohne gewählten Workspace: Projektseiten gibt es // erst, nachdem einer gewählt wurde. http.Redirect(w, r, "/admin/workspaces", http.StatusSeeOther) return } slug := r.PathValue("project") project, err := ws.Store.ProjectBySlug(r.Context(), sess.WorkspaceID, slug) if errors.Is(err, store.ErrNotFound) { http.NotFound(w, r) return } if err != nil { s.serverError(w, "Projekt konnte nicht geladen werden", err) return } // Ein fremdes Projekt wird als "nicht vorhanden" beantwortet, nicht als // "verboten". Sonst ließe sich über die Statuscodes herausfinden, welche // Projekte es sonst noch gibt. if !sess.CanAccessProject(project.ID) { http.NotFound(w, r) return } next.ServeHTTP(w, r.WithContext(withProject(r.Context(), project))) }) } // requireWorkspace lässt nur Sitzungen mit gewähltem Workspace durch. // // Betrifft nur den Plattform-Admin: Projekt- und Workspace-Admin-Sitzungen // haben ihren Workspace immer schon im Subject (siehe sessionFromSubject). Der // Plattform-Admin // dagegen kann sich frisch anmelden, ohne dass klar ist, welche Abteilung er // verwalten will — dafür ist /admin/workspaces da. func (s *Server) requireWorkspace(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if _, ok := WorkspaceFrom(r); !ok { http.Redirect(w, r, "/admin/workspaces", http.StatusSeeOther) return } next.ServeHTTP(w, r) }) } // requirePlatformAdmin lässt nur den Plattform-Admin durch. func (s *Server) requirePlatformAdmin(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sess, ok := SessionFrom(r) if !ok || !sess.IsPlatformAdmin { http.Error(w, "Nur für den Plattform-Admin verfügbar", http.StatusForbidden) return } next.ServeHTTP(w, r) }) } // requireWrite lässt nur Sitzungen durch, die Einträge verändern dürfen. func (s *Server) requireWrite(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sess, ok := SessionFrom(r) if !ok || !sess.CanWrite() { http.Error(w, "Dieser Zugang darf nur lesen.", http.StatusForbidden) return } next.ServeHTTP(w, r) }) } // requireProjectManagement lässt nur Sitzungen durch, die Projekte verwalten // dürfen: Plattform-Admin und Workspace-Admin. func (s *Server) requireProjectManagement(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sess, ok := SessionFrom(r) if !ok || !sess.CanManageProjects() { http.Error(w, "Nur für Plattform- und Workspace-Admins verfügbar", http.StatusForbidden) return } next.ServeHTTP(w, r) }) } // requireImport lässt nur Sitzungen durch, die importieren dürfen (siehe // Session.CanImport). func (s *Server) requireImport(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sess, ok := SessionFrom(r) if !ok || !sess.CanImport() { http.Error(w, "Für den Import wird die Rolle \"admin\" benötigt.", http.StatusForbidden) return } next.ServeHTTP(w, r) }) } // requireKeyManagement lässt nur Sitzungen durch, die Keys verwalten dürfen. func (s *Server) requireKeyManagement(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sess, ok := SessionFrom(r) if !ok || !sess.CanManageKeys() { http.Error(w, "Für die Key-Verwaltung wird die Rolle \"admin\" benötigt.", http.StatusForbidden) return } next.ServeHTTP(w, r) }) } // requireProjectDownload lässt nur Sitzungen durch, die ein Projekt als ZIP // herunterladen dürfen (siehe Session.CanDownloadProject). func (s *Server) requireProjectDownload(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sess, ok := SessionFrom(r) if !ok || !sess.CanDownloadProject() { http.Error(w, "Für den Download des ganzen Projekts wird die Rolle \"admin\" benötigt.", http.StatusForbidden) return } next.ServeHTTP(w, r) }) } // ── Allgemeine Schutzschichten ─────────────────────────────────────────────── // SecurityHeaders setzt bei jeder Antwort die Schutz-Header des Browsers. // // Diese Header sind Anweisungen an den Browser: Der Server kann damit Angriffe // entschärfen, die er selbst gar nicht sieht, weil sie erst im Browser des // Besuchers stattfinden. func SecurityHeaders(next http.Handler, enableHSTS bool) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() // Die Content-Security-Policy ist der wichtigste dieser Header: Sie legt // fest, woher der Browser überhaupt etwas laden und ausführen darf. // // Für uns ist sie die zweite Verteidigungslinie gegen eingeschleustes // JavaScript. Die erste ist, dass goldmark kein Roh-HTML durchlässt // (siehe internal/entry). Sollte das je gelockert werden, verhindert // diese Regel trotzdem die Ausführung. // // default-src 'self' alles nur von der eigenen Adresse // img-src … data: https: Bilder dürfen auch extern eingebunden werden // object-src 'none' keine Plugin-Einbettungen // base-uri 'none' verbietet , das sonst alle // relativen Links der Seite umbiegen könnte // form-action 'self' Formulare können nur an uns selbst senden // frame-ancestors 'none' die Seite darf in keinem fremden Rahmen // stecken (Schutz vor Clickjacking) h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data: https:; style-src 'self'; "+ "script-src 'self'; object-src 'none'; base-uri 'none'; "+ "form-action 'self'; frame-ancestors 'none'") // Verbietet dem Browser, den Inhaltstyp zu erraten. Ohne diesen Header // könnte eine als Text ausgelieferte Datei, die zufällig wie HTML // aussieht, als HTML ausgeführt werden. h.Set("X-Content-Type-Options", "nosniff") // Schutz vor Clickjacking für Browser, die frame-ancestors noch nicht // kennen. Doppelt hält besser und kostet nichts. h.Set("X-Frame-Options", "DENY") // Beim Klick auf einen externen Link soll die vollständige interne URL // nicht als Herkunft mitgehen — Projekt- und Eintragsnamen können // verraten, woran gearbeitet wird. h.Set("Referrer-Policy", "same-origin") // Der Browser soll gar nicht erst nach Kamera, Mikrofon und Standort fragen. h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") // HSTS weist den Browser an, diese Adresse künftig nur noch über HTTPS // aufzurufen. Der Header darf nur gesetzt werden, wenn HTTPS wirklich // funktioniert: Ein Browser, der ihn einmal gesehen hat, weigert sich für // die angegebene Dauer, die Seite über http zu laden — auf einer Adresse // ohne Zertifikat sperrt man sich damit selbst aus. if enableHSTS { h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") } next.ServeHTTP(w, r) }) } // LimitBody begrenzt, wie viele Bytes ein Request-Body haben darf. // // Ohne Begrenzung kann ein einzelner Aufruf beliebig viel Speicher belegen. Das // muss nicht einmal böswillig sein — ein Agent in einer Schleife oder eine // versehentlich falsche Datei reichen aus. // // http.MaxBytesReader ist genau dafür gebaut: Er ersetzt den Body durch eine // Variante, die nach maxBytes einen Fehler liefert statt weiterzulesen. func LimitBody(next http.Handler, maxBytes int64) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Body != nil { r.Body = http.MaxBytesReader(w, r.Body, maxBytes) } next.ServeHTTP(w, r) }) } // BodyLimits sind die Höchstgrößen eines Request-Bodys je Art von Route. type BodyLimits struct { Web int64 // Formulare der Weboberfläche API int64 // MCP-Aufrufe unter /mcp Import int64 // Hochladen eines ZIP-Archivs (siehe handlers_import.go) } // LimitBodies begrenzt den Body wie LimitBody, wählt die Grenze aber nach der // Route. // // # Warum eine Stelle statt LimitBody je Route? // // Zwei ineinander geschachtelte MaxBytesReader begrenzen immer auf den // KLEINEREN Wert. Ein großzügiges Limit an einer einzelnen Route unter einem // strengen Limit für alles wirkt deshalb nie. Genau so war MCP früher // verdrahtet: MAX_API_BODY_BYTES stand auf 4 MB, das äußere Limit von 1 MB // schnitt trotzdem ab. Hier wird die Grenze einmal außen gewählt, bevor // irgendetwas anderes den Body anfasst. func LimitBodies(next http.Handler, limits BodyLimits) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { grenze := limits.Web switch { case r.URL.Path == "/mcp": grenze = limits.API case r.Method == http.MethodPost && istImportPfad(r.URL.Path): grenze = limits.Import } if r.Body != nil { r.Body = http.MaxBytesReader(w, r.Body, grenze) } next.ServeHTTP(w, r) }) } // istImportPfad erkennt "/p/{projekt}/import". Der Mux hat zu diesem // Zeitpunkt noch nicht entschieden, welche Route passt, deshalb wird der Pfad // hier von Hand zerlegt: genau drei Teile, der erste "p", der letzte "import". func istImportPfad(pfad string) bool { teile := strings.Split(strings.Trim(pfad, "/"), "/") return len(teile) == 3 && teile[0] == "p" && teile[2] == "import" } // ── API-Zugang (MCP) ───────────────────────────────────────────────────────── // APIAuth prüft den Bearer-Key von MCP- und API-Aufrufen und legt die Sitzung im // Context ab. // // Wichtig ist die Reihenfolge: erst den Key prüfen, dann die IP-Sperre. // Andersherum wäre naheliegender, hätte aber einen unangenehmen Nebeneffekt — // Deploy-Skript, MCP-Client und Browser eines Büros teilen sich nach außen oft // eine Adresse, und ein einziger falsch konfigurierter Client würde alle anderen // mitsperren. Gegen das Durchprobieren hilft die Reihenfolge trotzdem: Ein // Angreifer hat per Definition keinen gültigen Key und landet im unteren Zweig. func (s *Server) APIAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ip := auth.ClientIP(r) key := "" if header := r.Header.Get("Authorization"); strings.HasPrefix(header, "Bearer ") { key = strings.TrimPrefix(header, "Bearer ") } if auth.LooksLikeKey(key) { hash := auth.HashKey(key) // Erster Schritt: welcher Workspace? Erst danach lässt sich die // richtige Datei überhaupt ansprechen (siehe ControlStore in // internal/store/control.go und AUSBAUSTUFEN.md 4.4). workspaceID, err := s.control.ResolveKeyWorkspace(r.Context(), hash) if err == nil { var ws *workspace.Workspace ws, err = s.workspaces.Get(workspaceID) if err == nil { var resolved store.ResolvedKey resolved, err = ws.Store.ResolveKeyHash(r.Context(), hash) if err == nil { s.limiter.Reset(ip) // Nebenbei vermerken, dass der Key benutzt wurde — damit man // in der Verwaltung sieht, welche Keys noch aktiv sind. Ein // Fehler dabei ist unerheblich und darf den Request nicht // scheitern lassen. _ = ws.Store.TouchAPIKey(r.Context(), resolved.Key.ID, resolved.Key.LastUsedAt) sess := Session{ Subject: auth.SubjectKeyPrefix + workspaceID + ":" + resolved.Key.ID, WorkspaceID: workspaceID, Key: resolved.Key, Project: resolved.Project, Role: auth.Role(resolved.Key.Role), } ctx := withSession(r.Context(), sess) ctx = withProject(ctx, resolved.Project) ctx = withWorkspace(ctx, ws) next.ServeHTTP(w, r.WithContext(ctx)) return } } } } if allowed, retryAfter := s.limiter.Allow(ip); !allowed { sekunden := int(retryAfter.Seconds()) + 1 w.Header().Set("Retry-After", strconv.Itoa(sekunden)) http.Error(w, fmt.Sprintf("Zu viele fehlgeschlagene Versuche. Bitte in %d Sekunden erneut versuchen.", sekunden), http.StatusTooManyRequests) return } s.limiter.RecordFailure(ip) // Ein ungültiger Key lässt sich keinem Workspace zuordnen — es gibt // also keine passende Datei für einen Audit-Eintrag. Das Protokoll // verzeichnet nur Vorgänge innerhalb eines Workspace; ein anonymer // Fehlversuch gehört ins allgemeine Server-Log. log.Printf("Ungültiger API-Key von %s", ip) http.Error(w, "Ungültiger oder fehlender API-Key", http.StatusUnauthorized) }) } // ── Zugriffsprotokoll ──────────────────────────────────────────────────────── // statusSchreiber merkt sich den Statuscode einer Antwort. // // http.ResponseWriter verrät ihn nicht — man kann ihn setzen, aber nicht lesen. // Deshalb dieser dünne Mantel, der WriteHeader abfängt. type statusSchreiber struct { http.ResponseWriter status int bytes int } func (w *statusSchreiber) WriteHeader(code int) { w.status = code w.ResponseWriter.WriteHeader(code) } func (w *statusSchreiber) Write(b []byte) (int, error) { // Wer direkt schreibt, ohne WriteHeader aufzurufen, erzeugt implizit ein 200. if w.status == 0 { w.status = http.StatusOK } n, err := w.ResponseWriter.Write(b) w.bytes += n return n, err } // Unwrap gibt den darunterliegenden ResponseWriter zurück. // // http.ResponseController nutzt diese Methode, um an Fähigkeiten des // eingewickelten Schreibers heranzukommen. Der MCP-Transport geht diesen Weg für // seine gestreamten Antworten — ohne Unwrap bliebe dort alles im Puffer hängen. func (w *statusSchreiber) Unwrap() http.ResponseWriter { return w.ResponseWriter } // Flush reicht das Leeren des Puffers durch. // // Unwrap deckt nur Aufrufer ab, die http.ResponseController verwenden. Die // ältere und weiter verbreitete Schreibweise ist eine direkte Typzusicherung: // // if f, ok := w.(http.Flusher); ok { f.Flush() } // // Die schlägt bei einem eingewickelten Schreiber fehl, wenn er die Methode nicht // selbst anbietet — und zwar lautlos: Der Aufrufer nimmt an, dass Leeren eben // nicht unterstützt wird, und schreibt weiter in den Puffer. Solche Fehler // bemerkt man erst im Betrieb, deshalb hier ausdrücklich. func (w *statusSchreiber) Flush() { if f, ok := w.ResponseWriter.(http.Flusher); ok { f.Flush() } } // LogRequests schreibt zu jeder Anfrage eine Zeile ins Serverlog. // // # Warum das nötig ist // // Das Audit-Log hält fest, wer etwas geändert hat — es sagt aber nichts über // Lesezugriffe, Fehlerantworten oder Antwortzeiten. Ohne Zugriffsprotokoll sieht // man weder, dass ein Agent in einer Schleife hängt, noch dass eine Seite // langsam geworden ist, noch dass jemand Pfade abklappert. // // # Was bewusst nicht protokolliert wird // // - Die Query-Zeichenkette. Dort stehen Suchbegriffe, und die sind Inhalt, // kein Betriebsdatum. Der Pfad allein reicht, um zu sehen, was aufgerufen wurde. // - Statische Dateien und die Prüfungen /live, /ready (und /healthz). Das // erzeugt nur Rauschen; die Prüfungen laufen alle paar Sekunden. Ein // Ausfall der Datenbank steht trotzdem im Log, siehe meldeBereitschaft. func LogRequests(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/static/") || istPruefpfad(r.URL.Path) { next.ServeHTTP(w, r) return } start := time.Now() schreiber := &statusSchreiber{ResponseWriter: w} // Halter für den Namen des Zugangs mitgeben. Die Anmelde-Middleware // weiter innen trägt ihn ein — siehe aktorHalter in context.go. halter := &aktorHalter{} r = r.WithContext(withAktorHalter(r.Context(), halter)) next.ServeHTTP(schreiber, r) wer := halter.name if wer == "" { wer = "—" // nicht angemeldet oder abgewiesen } status := schreiber.status if status == 0 { status = http.StatusOK // Handler hat nichts geschrieben } log.Printf("%d %-6s %-44s %8s %s", status, r.Method, r.URL.Path, time.Since(start).Round(time.Microsecond), wer) }) } // istPruefpfad erkennt die Prüfungen für Container und Load-Balancer. func istPruefpfad(pfad string) bool { return pfad == "/live" || pfad == "/ready" || pfad == "/healthz" }