All checks were successful
check / check (push) Successful in 4m49s
METRICS_USERNAME alone mounted /metrics behind a credential map whose only password was the empty string, so `curl -u 'metrics:'` returned 200 while the startup log reported hasMetricsAuth:false. The route mount tested the username and the log tested both, so the two could disagree about whether the endpoint existed. Config.MetricsAuthEnabled is now the single value behind both: the /metrics mount, the Prometheus recording middleware and the startup log's hasMetricsAuth field all read it, and it requires both credentials. loadFromEnv rejects a half-set pair outright with an error naming both variables in either direction, so a set-but-invalid configuration aborts startup rather than degrading into an endpoint the operator did not ask for. Both unset stays valid and leaves /metrics unmounted. Tests cover all four combinations at the config layer, counting "set to the empty string" and "not set at all" as separate inputs, plus the route tree's behaviour in each state.
234 lines
7.4 KiB
Go
234 lines
7.4 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.
|
|
//
|
|
// Every route group below installs MaxBodySize(maxFormBodySize) as
|
|
// its FIRST middleware, 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()
|
|
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 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.
|
|
if s.params.Config.MetricsAuthEnabled() {
|
|
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(),
|
|
)
|
|
|
|
// 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",
|
|
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 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())
|
|
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 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())
|
|
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 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())
|
|
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 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())
|
|
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(),
|
|
)
|
|
}
|