All checks were successful
check / check (push) Successful in 2m53s
chi v1.5.5's middleware.Recoverer neither logged a handler panic nor answered 500. Its pretty-printer scans the stack for a frame beginning "panic(0x", which the runtime no longer emits, so the scan never terminates early and every line reaches decorateFuncCallLine, which slices pkg[strings.Index(pkg, "."):] without checking for -1. That second panic escaped chi's own deferred function, so its WriteHeader(500) never ran: net/http closed the connection and reported its own crash, losing the original panic value entirely. Middleware.Recoverer replaces it. It writes one ERROR record through internal/logger carrying the panic value, the stack and the request id, and answers 500. http.ErrAbortHandler is re-panicked rather than swallowed, and a response the handler already committed is left alone rather than overwritten. It is registered inside every middleware that observes the response, so the 500 is the status the access log records and the metrics count, and outside the sentryhttp handler, whose Repanic option needs something further out to catch what it re-raises. Both fields are bounded in encoded bytes, as the access log's are: 512 for the panic value, since a handler may build one out of the request, and 8192 for the stack, cut at its far end so the panic site survives. MaxPanicLogLineBytes states the resulting ceiling at 10240; measured, the widest either handler produces is 8898, and the real case through the shipped chain is 3959.
203 lines
5.9 KiB
Go
203 lines
5.9 KiB
Go
package server
|
|
|
|
import (
|
|
"net/http"
|
|
"time"
|
|
|
|
sentryhttp "github.com/getsentry/sentry-go/http"
|
|
"github.com/go-chi/chi"
|
|
"github.com/go-chi/chi/middleware"
|
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
|
"sneak.berlin/go/webhooker/static"
|
|
)
|
|
|
|
// maxFormBodySize is the maximum allowed request body size (in
|
|
// bytes) for form POST endpoints. 1 MB is generous for any form
|
|
// submission while preventing abuse from oversized payloads.
|
|
const maxFormBodySize int64 = 1 * 1024 * 1024 // 1 MB
|
|
|
|
// requestTimeout is the maximum time allowed for a single HTTP
|
|
// request.
|
|
const requestTimeout = 60 * time.Second
|
|
|
|
// SetupRoutes configures all HTTP routes and middleware on the
|
|
// server's router.
|
|
func (s *Server) SetupRoutes() {
|
|
s.router = chi.NewRouter()
|
|
s.setupGlobalMiddleware()
|
|
s.setupRoutes()
|
|
}
|
|
|
|
func (s *Server) setupGlobalMiddleware() {
|
|
s.router.Use(middleware.RequestID)
|
|
s.router.Use(s.mw.SecurityHeaders())
|
|
s.router.Use(s.mw.Logging())
|
|
|
|
// Metrics middleware (only if credentials are configured)
|
|
if s.params.Config.MetricsUsername != "" {
|
|
s.router.Use(s.mw.Metrics())
|
|
}
|
|
|
|
s.router.Use(s.mw.CORS())
|
|
s.router.Use(middleware.Timeout(requestTimeout))
|
|
|
|
// Panic recovery, deliberately here rather than first. It has to
|
|
// run inside every middleware that observes the response, so the
|
|
// 500 it writes is the status the access log records and the
|
|
// metrics count, and outside the sentryhttp handler below, whose
|
|
// Repanic option needs something further out to catch what it
|
|
// re-raises. chi's own middleware.Recoverer held the first slot
|
|
// until it was measured: on a current Go release it crashes
|
|
// inside its stack pretty-printer instead of recovering, so the
|
|
// connection dropped and the original panic was never reported.
|
|
// See https://git.eeqj.de/sneak/webhooker/issues/187.
|
|
s.router.Use(s.mw.Recoverer())
|
|
|
|
// Sentry error reporting (if SENTRY_DSN is set). Repanic is
|
|
// true so panics still bubble up to the Recoverer middleware
|
|
// registered immediately above.
|
|
if s.sentryEnabled {
|
|
sentryHandler := sentryhttp.New(sentryhttp.Options{
|
|
Repanic: true,
|
|
})
|
|
s.router.Use(sentryHandler.Handle)
|
|
}
|
|
}
|
|
|
|
func (s *Server) setupRoutes() {
|
|
s.router.Get("/", s.h.HandleIndex())
|
|
|
|
s.router.Mount(
|
|
"/s",
|
|
http.StripPrefix("/s", http.FileServer(http.FS(static.Static))),
|
|
)
|
|
|
|
s.router.Route("/api/v1", func(_ chi.Router) {
|
|
// API routes will be added here.
|
|
})
|
|
|
|
s.router.Get(
|
|
"/.well-known/healthcheck",
|
|
s.h.HandleHealthCheck(),
|
|
)
|
|
|
|
// set up authenticated /metrics route:
|
|
if s.params.Config.MetricsUsername != "" {
|
|
s.router.Group(func(r chi.Router) {
|
|
r.Use(s.mw.MetricsAuth())
|
|
r.Get(
|
|
"/metrics",
|
|
http.HandlerFunc(
|
|
promhttp.Handler().ServeHTTP,
|
|
),
|
|
)
|
|
})
|
|
}
|
|
|
|
s.setupPageRoutes()
|
|
s.setupUserRoutes()
|
|
s.setupSourceRoutes()
|
|
s.setupWebhookRoutes()
|
|
}
|
|
|
|
func (s *Server) setupPageRoutes() {
|
|
s.router.Route("/pages", func(r chi.Router) {
|
|
// MaxBodySize must precede CSRF: gorilla/csrf parses the
|
|
// form, so the cap has to be installed before it runs.
|
|
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
|
r.Use(s.mw.CSRF())
|
|
r.Use(s.mw.NoCache())
|
|
|
|
// The login POST carries no pre-emptive rate limiter. Behind
|
|
// the reverse proxy production requires, with TRUSTED_PROXIES
|
|
// unset, every client shares one bucket, so a limiter spent
|
|
// on arrival lets any stranger deny the operator the only
|
|
// administrative path. The handler verifies credentials first
|
|
// and charges only failures; see Handlers.authenticateUser.
|
|
r.Get("/login", s.h.HandleLoginPage())
|
|
r.Post("/login", s.h.HandleLoginSubmit())
|
|
|
|
r.Post("/logout", s.h.HandleLogout())
|
|
})
|
|
}
|
|
|
|
func (s *Server) setupUserRoutes() {
|
|
s.router.Route("/user/{username}", func(r chi.Router) {
|
|
// MaxBodySize must precede CSRF: gorilla/csrf parses the
|
|
// form, so the cap has to be installed before it runs.
|
|
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
|
r.Use(s.mw.CSRF())
|
|
r.Use(s.mw.NoCache())
|
|
r.Use(s.mw.RequireAuth())
|
|
r.Get("/", s.h.HandleProfile())
|
|
r.With(s.mw.PasswordChangeRateLimit()).Post(
|
|
"/password", s.h.HandlePasswordChange(),
|
|
)
|
|
})
|
|
}
|
|
|
|
func (s *Server) setupSourceRoutes() {
|
|
s.router.Route("/sources", func(r chi.Router) {
|
|
// MaxBodySize must precede CSRF: gorilla/csrf parses the
|
|
// form, so the cap has to be installed before it runs.
|
|
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
|
r.Use(s.mw.CSRF())
|
|
r.Use(s.mw.NoCache())
|
|
r.Use(s.mw.RequireAuth())
|
|
r.Get("/", s.h.HandleSourceList())
|
|
r.Get("/new", s.h.HandleSourceCreate())
|
|
r.Post("/new", s.h.HandleSourceCreateSubmit())
|
|
})
|
|
|
|
s.router.Route("/source/{sourceID}", func(r chi.Router) {
|
|
// MaxBodySize must precede CSRF: gorilla/csrf parses the
|
|
// form, so the cap has to be installed before it runs.
|
|
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
|
r.Use(s.mw.CSRF())
|
|
r.Use(s.mw.NoCache())
|
|
r.Use(s.mw.RequireAuth())
|
|
r.Get("/", s.h.HandleSourceDetail())
|
|
r.Get("/edit", s.h.HandleSourceEdit())
|
|
r.Post("/edit", s.h.HandleSourceEditSubmit())
|
|
r.Post("/delete", s.h.HandleSourceDelete())
|
|
r.Get("/logs", s.h.HandleSourceLogs())
|
|
// The log page renders each body only up to its cap, so
|
|
// this is the only route that serves a whole one. It
|
|
// belongs to this group for its RequireAuth and
|
|
// NoCache; see HandleEventBodyDownload for the headers
|
|
// that keep the bytes it returns inert.
|
|
r.Get(
|
|
"/logs/{eventID}/body",
|
|
s.h.HandleEventBodyDownload(),
|
|
)
|
|
r.Post(
|
|
"/entrypoints",
|
|
s.h.HandleEntrypointCreate(),
|
|
)
|
|
r.Post(
|
|
"/entrypoints/{entrypointID}/delete",
|
|
s.h.HandleEntrypointDelete(),
|
|
)
|
|
r.Post(
|
|
"/entrypoints/{entrypointID}/toggle",
|
|
s.h.HandleEntrypointToggle(),
|
|
)
|
|
r.Post("/targets", s.h.HandleTargetCreate())
|
|
r.Post(
|
|
"/targets/{targetID}/delete",
|
|
s.h.HandleTargetDelete(),
|
|
)
|
|
r.Post(
|
|
"/targets/{targetID}/toggle",
|
|
s.h.HandleTargetToggle(),
|
|
)
|
|
})
|
|
}
|
|
|
|
func (s *Server) setupWebhookRoutes() {
|
|
s.router.With(s.mw.ReceiverRateLimit()).HandleFunc(
|
|
"/webhook/{uuid}",
|
|
s.h.HandleWebhook(),
|
|
)
|
|
}
|