check / check (push) Successful in 3m15s
The access log's response writer had no Unwrap method, so http.ResponseController stopped at it and a handler's Flush returned http.ErrNotSupported. It now has one. With metrics on, adding Unwrap alone is not enough: go-http-metrics' writer only passes a flush on when the writer inside it has a Flush method, and it sat inside Logging, so the flush silently did nothing. Metrics is now registered outside Logging, and the README's middleware list follows. A new test flushes through the production router, with the defaults and with metrics and Sentry on, on a global route and inside an admin page route group. Model: opus-5-5
317 lines
11 KiB
Go
317 lines
11 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"
|
|
"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.
|
|
//
|
|
// The four admin page route groups below (/pages, /user/{username},
|
|
// /hooks and /hook/{sourceID}) install MaxBodySize(maxFormBodySize)
|
|
// right after their recoverer and error reporting, ahead of both CSRF
|
|
// and RequireAuth. Both orderings are deliberate.
|
|
//
|
|
// Ahead of CSRF because gorilla/csrf parses the form. The cap has to
|
|
// be installed before anything reads the body, or the parse runs
|
|
// under net/http's 10 MB default instead of this one.
|
|
//
|
|
// Ahead of RequireAuth because an oversize body should be refused
|
|
// before the request buys a cookie decrypt, a session load and the
|
|
// database read behind it. Rejecting first is the cheaper failure,
|
|
// and it is the ordering that keeps an unauthenticated flood from
|
|
// choosing how much session work the process does.
|
|
//
|
|
// What that ordering costs: the 413 branch is reachable
|
|
// unauthenticated, at a URL of the client's choosing and of the
|
|
// client's chosen length. So is the CSRF rejection, which sits in
|
|
// front of RequireAuth for the same reason. Both log that path, so
|
|
// both cap it — see the log calls in Middleware.MaxBodySize and
|
|
// Middleware.CSRF, which spend the same per-field budget as the
|
|
// access log.
|
|
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()
|
|
|
|
// An unknown path gets the error page. Registered before the
|
|
// global middleware, because chi wraps a not-found handler in the
|
|
// middleware already on its router, which would then run twice.
|
|
// The route groups below wrap it in their own middleware the same
|
|
// way; running theirs twice is harmless.
|
|
s.router.NotFound(s.h.HandleErrorPage(http.StatusNotFound))
|
|
|
|
s.setupGlobalMiddleware()
|
|
s.setupRoutes()
|
|
}
|
|
|
|
func (s *Server) setupGlobalMiddleware() {
|
|
s.router.Use(middleware.RequestID)
|
|
s.router.Use(s.mw.SecurityHeaders())
|
|
|
|
// Metrics recording middleware, registered only when the
|
|
// endpoint that exposes what it records is served. The
|
|
// condition is the same MetricsAuthEnabled the /metrics mount
|
|
// in setupRoutes reads.
|
|
//
|
|
// It goes outside Logging. go-http-metrics' writer passes a flush
|
|
// on only when the writer inside it has a Flush method, and the
|
|
// access log's writer has none, so inside Logging a handler's
|
|
// flush would silently do nothing.
|
|
if s.params.Config.MetricsAuthEnabled() {
|
|
s.router.Use(s.mw.Metrics())
|
|
}
|
|
|
|
s.router.Use(s.mw.Logging())
|
|
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, 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.recoverPanics(s.router, nil)
|
|
}
|
|
|
|
// recoverPanics installs on r the recoverer, answering a panic with
|
|
// page (a plain 500 when page is nil), and inside it the Sentry error
|
|
// reporting (if SENTRY_DSN is set). Repanic is true so panics still
|
|
// bubble up to the recoverer.
|
|
//
|
|
// Each admin page route group installs its own, with the error page,
|
|
// as its first middleware. A panic there is logged, reported and
|
|
// answered inside the group and never reaches the global recoverer,
|
|
// which keeps the plain 500 for every other route.
|
|
func (s *Server) recoverPanics(r chi.Router, page http.Handler) {
|
|
r.Use(s.mw.Recoverer(page))
|
|
|
|
if s.sentryEnabled.Load() {
|
|
sentryHandler := sentryhttp.New(sentryhttp.Options{
|
|
Repanic: true,
|
|
})
|
|
r.Use(sentryHandler.Handle)
|
|
}
|
|
}
|
|
|
|
func (s *Server) setupRoutes() {
|
|
s.router.Get("/", s.h.HandleIndex())
|
|
|
|
// Static assets answer GET and HEAD only. chi's default 405
|
|
// carries no Allow header, so this group supplies its own.
|
|
staticFiles := http.StripPrefix(
|
|
"/s", http.FileServer(http.FS(static.Static)),
|
|
)
|
|
|
|
s.router.Route("/s", func(r chi.Router) {
|
|
r.MethodNotAllowed(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Allow", "GET, HEAD")
|
|
http.Error(
|
|
w,
|
|
"Method Not Allowed",
|
|
http.StatusMethodNotAllowed,
|
|
)
|
|
})
|
|
r.Method(http.MethodGet, "/*", staticFiles)
|
|
r.Method(http.MethodHead, "/*", staticFiles)
|
|
})
|
|
|
|
s.router.Route("/api/v1", func(_ chi.Router) {
|
|
// API routes will be added here.
|
|
})
|
|
|
|
s.router.Get(
|
|
"/.well-known/healthcheck",
|
|
s.h.HandleHealthCheck(),
|
|
)
|
|
|
|
// Authenticated /metrics route. The condition is
|
|
// Config.MetricsAuthEnabled and never the username alone: a
|
|
// username with an empty password would otherwise mount the
|
|
// endpoint behind a credential map that accepts an empty
|
|
// password. Config rejects that combination at startup, and
|
|
// this reads the same value the startup log reports, so the
|
|
// two cannot disagree about whether the route exists.
|
|
if s.params.Config.MetricsAuthEnabled() {
|
|
s.router.Group(func(r chi.Router) {
|
|
r.Use(s.mw.MetricsAuth())
|
|
r.Get("/metrics", s.h.HandleMetrics())
|
|
})
|
|
}
|
|
|
|
s.setupPageRoutes()
|
|
s.setupUserRoutes()
|
|
s.setupSourceRoutes()
|
|
s.setupWebhookRoutes()
|
|
}
|
|
|
|
func (s *Server) setupPageRoutes() {
|
|
s.router.Route("/pages", func(r chi.Router) {
|
|
s.recoverPanics(
|
|
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
|
)
|
|
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
|
// see maxFormBodySize for why, and for what it costs.
|
|
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
|
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
|
r.Use(s.mw.NoCache())
|
|
|
|
// The login POST carries no pre-emptive rate limiter. Behind
|
|
// the reverse proxy production requires, when TRUSTED_PROXIES
|
|
// does not cover it, 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) {
|
|
s.recoverPanics(
|
|
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
|
)
|
|
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
|
// see maxFormBodySize for why, and for what it costs.
|
|
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
|
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
|
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("/hooks", func(r chi.Router) {
|
|
s.recoverPanics(
|
|
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
|
)
|
|
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
|
// see maxFormBodySize for why, and for what it costs.
|
|
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
|
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
|
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("/hook/{sourceID}", func(r chi.Router) {
|
|
s.recoverPanics(
|
|
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
|
)
|
|
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
|
// see maxFormBodySize for why, and for what it costs.
|
|
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
|
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
|
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("/events", 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(
|
|
"/events/{eventID}/body",
|
|
s.h.HandleEventBodyDownload(),
|
|
)
|
|
// Replay is the one page action that queues outbound work:
|
|
// it creates a delivery from a stored event and hands it to
|
|
// the delivery engine. The rate limit is what bounds a
|
|
// held-down button or a scripted loop; the handler
|
|
// separately refuses a replay while an earlier one for the
|
|
// same event and target is still in flight. POST only, so
|
|
// the action cannot be taken by a link, a prefetch or an
|
|
// image tag.
|
|
r.With(s.mw.ReplayRateLimit()).Post(
|
|
"/deliveries/{deliveryID}/replay",
|
|
s.h.HandleDeliveryReplay(),
|
|
)
|
|
// Resubmit is the other page action that queues outbound
|
|
// work: it copies a stored event into a new one and fans
|
|
// that out to every currently active target. It is
|
|
// deliberately repeatable, so the rate limit is the only
|
|
// bound on a held-down button; it gets its own bucket so
|
|
// that spending it does not also disable replay. POST
|
|
// only, so the action cannot be taken by a link, a
|
|
// prefetch or an image tag.
|
|
r.With(s.mw.ResubmitRateLimit()).Post(
|
|
"/events/{eventID}/resubmit",
|
|
s.h.HandleEventResubmit(),
|
|
)
|
|
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())
|
|
// The edit form is the one page that renders a target's
|
|
// destination URL and header values in full; see
|
|
// delivery.TargetConfigForm. It belongs to this group for
|
|
// its RequireAuth and NoCache, which are what keep that
|
|
// exception from reaching an unauthenticated request or a
|
|
// shared cache.
|
|
r.Get(
|
|
"/targets/{targetID}/edit",
|
|
s.h.HandleTargetEdit(),
|
|
)
|
|
r.Post(
|
|
"/targets/{targetID}/edit",
|
|
s.h.HandleTargetEditSubmit(),
|
|
)
|
|
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(
|
|
"/h/{uuid}",
|
|
s.h.HandleWebhook(),
|
|
)
|
|
}
|