All checks were successful
check / check (push) Successful in 3m41s
The plaintext listener bound `:PORT`, so it answered on every interface with no way to say otherwise. That published the admin UI and the unauthenticated receiver in cleartext beside whatever TLS proxy was in front of them, reachable from any host that could route to the machine. BIND_ADDRESS now selects the address. The binary defaults to 127.0.0.1, which is the safe answer for a bare host: reaching webhooker from elsewhere becomes a deliberate act. The image sets 0.0.0.0, which is the correct answer inside a container, where the network namespace is already the boundary and exposure is decided by the publish flag instead — so `-p 127.0.0.1:8080:8080` is what the README shows. Existing container deployments are unaffected. Only IP address literals are accepted: hostnames, host:port and CIDR blocks abort startup naming the variable and the value, and a literal that is not an address of this host fails at listen and exits non-zero. The http.Server is now built in New rather than in the serving goroutine, and sentryEnabled is atomic. Both fields were written by the serving goroutine and read by the fx stop hook with nothing ordering them, and the OnStart hook returns before that goroutine has necessarily run: cleanShutdown could dereference a nil httpServer on an early SIGTERM, and both reads raced. No test started and stopped the server, so nothing observed it. Closes #226. README gains a "Deployment behind a reverse proxy" section: a working nginx server block, and the five things that are silent when wrong — bind or firewall the app port, WEBHOOKER_ENVIRONMENT=prod, TRUSTED_PROXIES, Host as $http_host rather than $host, and keeping the proxy's access log because webhooker's own records only the proxy.
284 lines
7.7 KiB
Go
284 lines
7.7 KiB
Go
// Package server wires up HTTP routes and manages the
|
|
// application lifecycle.
|
|
package server
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"time"
|
|
|
|
"go.uber.org/fx"
|
|
"sneak.berlin/go/webhooker/internal/config"
|
|
"sneak.berlin/go/webhooker/internal/globals"
|
|
"sneak.berlin/go/webhooker/internal/handlers"
|
|
"sneak.berlin/go/webhooker/internal/logger"
|
|
"sneak.berlin/go/webhooker/internal/middleware"
|
|
|
|
"github.com/getsentry/sentry-go"
|
|
"github.com/go-chi/chi"
|
|
)
|
|
|
|
const (
|
|
// ShutdownTimeout is the maximum time to wait for the HTTP
|
|
// server to finish in-flight requests during shutdown.
|
|
//
|
|
// It must stay strictly below the fx stop timeout in
|
|
// cmd/webhooker, which bounds the whole stop sequence: a drain
|
|
// that used the entire sequence budget would leave nothing for
|
|
// the hooks that run after the server, including the database
|
|
// close. It is exported so that relationship can be tested.
|
|
ShutdownTimeout = 3 * time.Second
|
|
|
|
// TailHookReserve is the share of the fx stop budget this hook
|
|
// refuses to spend, leaving it for the hooks that run after the
|
|
// server: the delivery engine, the healthcheck, the webhook DB
|
|
// manager and the database close.
|
|
TailHookReserve = 2 * time.Second
|
|
|
|
// sentryFlushTimeout is the longest wait for Sentry to flush
|
|
// pending events during shutdown, before the remaining stop
|
|
// budget is taken into account.
|
|
sentryFlushTimeout = 2 * time.Second
|
|
|
|
// minSentryFlush is the shortest flush worth attempting. Below
|
|
// it the remaining budget goes to the tail hooks instead.
|
|
minSentryFlush = 250 * time.Millisecond
|
|
)
|
|
|
|
// ListenFailureExitCode is the status the process exits with when the
|
|
// HTTP listener cannot be established, or dies for a reason other
|
|
// than a requested shutdown. It must stay non-zero: systemd
|
|
// `Restart=on-failure` and Docker's restart policies key off it, and a
|
|
// zero exit would read as a deliberate stop.
|
|
const ListenFailureExitCode = 1
|
|
|
|
// SentryFlushBudget reports how long the Sentry flush may run when
|
|
// remaining is the time left on the fx stop context after the HTTP
|
|
// drain. sentry.Flush takes a bare duration and honours no context,
|
|
// so this clamp is the only thing keeping a stalled flush from
|
|
// spending the tail hooks' share of the budget on top of a
|
|
// full-length drain. TailHookReserve is held back, and anything
|
|
// under minSentryFlush is skipped rather than attempted uselessly.
|
|
func SentryFlushBudget(remaining time.Duration) time.Duration {
|
|
budget := min(remaining-TailHookReserve, sentryFlushTimeout)
|
|
if budget < minSentryFlush {
|
|
return 0
|
|
}
|
|
|
|
return budget
|
|
}
|
|
|
|
//nolint:revive // ServerParams is a standard fx naming convention.
|
|
type ServerParams struct {
|
|
fx.In
|
|
|
|
Logger *logger.Logger
|
|
Globals *globals.Globals
|
|
Config *config.Config
|
|
Middleware *middleware.Middleware
|
|
Handlers *handlers.Handlers
|
|
Shutdowner fx.Shutdowner
|
|
}
|
|
|
|
// Server is the main HTTP server that wires up routes and manages
|
|
// graceful shutdown.
|
|
type Server struct {
|
|
startupTime time.Time
|
|
|
|
// sentryEnabled is written by the serving goroutine, in
|
|
// enableSentry, and read by the fx stop hook in cleanShutdown.
|
|
// Nothing orders those two: the OnStart hook returns as soon as
|
|
// the goroutine is spawned, so a stop can be running while
|
|
// enableSentry is still deciding. It is atomic to supply the
|
|
// edge the goroutines do not.
|
|
sentryEnabled atomic.Bool
|
|
log *slog.Logger
|
|
cancelFunc context.CancelFunc
|
|
httpServer *http.Server
|
|
router *chi.Mux
|
|
params ServerParams
|
|
mw *middleware.Middleware
|
|
h *handlers.Handlers
|
|
}
|
|
|
|
// New creates a Server that starts the HTTP listener on fx start
|
|
// and stops it gracefully.
|
|
func New(lc fx.Lifecycle, params ServerParams) (*Server, error) {
|
|
s := new(Server)
|
|
s.params = params
|
|
s.mw = params.Middleware
|
|
s.h = params.Handlers
|
|
s.log = params.Logger.Get()
|
|
s.httpServer = s.newHTTPServer()
|
|
|
|
lc.Append(fx.Hook{
|
|
OnStart: func(_ context.Context) error {
|
|
s.startupTime = time.Now()
|
|
go s.Run()
|
|
|
|
return nil
|
|
},
|
|
OnStop: func(ctx context.Context) error {
|
|
s.cleanShutdown(ctx)
|
|
|
|
return nil
|
|
},
|
|
})
|
|
|
|
return s, nil
|
|
}
|
|
|
|
// Run configures Sentry and starts serving HTTP requests.
|
|
func (s *Server) Run() {
|
|
s.configure()
|
|
|
|
// logging before sentry, because sentry logs
|
|
s.enableSentry()
|
|
|
|
s.serve()
|
|
}
|
|
|
|
// MaintenanceMode returns whether the server is in maintenance
|
|
// mode.
|
|
func (s *Server) MaintenanceMode() bool {
|
|
return s.params.Config.MaintenanceMode
|
|
}
|
|
|
|
func (s *Server) enableSentry() {
|
|
s.sentryEnabled.Store(false)
|
|
|
|
if s.params.Config.SentryDSN == "" {
|
|
return
|
|
}
|
|
|
|
err := sentry.Init(sentryClientOptions(
|
|
s.params.Config.SentryDSN,
|
|
fmt.Sprintf(
|
|
"%s-%s",
|
|
s.params.Globals.Appname,
|
|
s.params.Globals.Version,
|
|
),
|
|
))
|
|
if err != nil {
|
|
s.log.Error("sentry init failure", "error", err)
|
|
// Don't use fatal since we still want the service to run
|
|
return
|
|
}
|
|
|
|
s.log.Info("sentry error reporting activated")
|
|
s.sentryEnabled.Store(true)
|
|
}
|
|
|
|
// serve installs the signal watcher, starts the listener and blocks
|
|
// until the server's context is cancelled. The process exit status is
|
|
// fx's to decide — from a signal, or from the code
|
|
// shutdownOnListenFailure hands the Shutdowner — so this reports
|
|
// nothing back to its caller.
|
|
func (s *Server) serve() {
|
|
ctx, cancelFunc := context.WithCancel(context.Background())
|
|
s.cancelFunc = cancelFunc
|
|
|
|
// signal watcher
|
|
go func() {
|
|
c := make(chan os.Signal, 1)
|
|
|
|
signal.Ignore(syscall.SIGPIPE)
|
|
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
|
// block and wait for signal
|
|
sig := <-c
|
|
s.log.Info("signal received", "signal", sig.String())
|
|
|
|
if s.cancelFunc != nil {
|
|
// cancelling the main context will trigger a clean
|
|
// shutdown via the fx OnStop hook.
|
|
s.cancelFunc()
|
|
}
|
|
}()
|
|
|
|
go s.serveUntilShutdown()
|
|
|
|
<-ctx.Done()
|
|
// Shutdown is handled by the fx OnStop hook (cleanShutdown).
|
|
// Do not call cleanShutdown() here to avoid double invocation.
|
|
}
|
|
|
|
// shutdownOnListenFailure ends the application after the HTTP
|
|
// listener failed. The fx OnStart hook returns as soon as the serving
|
|
// goroutine is spawned, so nothing downstream of it ever learns that
|
|
// the listen failed: fx reports RUNNING and the process sits alive
|
|
// with nothing bound, which is invisible to systemd and Docker
|
|
// restart policies. Asking the Shutdowner to stop the app with a
|
|
// non-zero code is what turns that into a visible failure.
|
|
//
|
|
// The context cancel that follows only unwinds serve()'s own wait.
|
|
// The shutdown itself runs through fx's normal stop sequence, so the
|
|
// clean-shutdown drain in cleanShutdown is reached unchanged.
|
|
func (s *Server) shutdownOnListenFailure() {
|
|
err := s.params.Shutdowner.Shutdown(
|
|
fx.ExitCode(ListenFailureExitCode),
|
|
)
|
|
if err != nil {
|
|
s.log.Error("shutdown request failed", "error", err)
|
|
}
|
|
|
|
if s.cancelFunc != nil {
|
|
s.cancelFunc()
|
|
}
|
|
}
|
|
|
|
func (s *Server) cleanupForExit() {
|
|
s.log.Info("cleaning up")
|
|
}
|
|
|
|
func (s *Server) cleanShutdown(ctx context.Context) {
|
|
ctxShutdown, shutdownCancel := context.WithTimeout(
|
|
ctx, ShutdownTimeout,
|
|
)
|
|
defer shutdownCancel()
|
|
|
|
err := s.httpServer.Shutdown(ctxShutdown)
|
|
if err != nil {
|
|
s.log.Error(
|
|
"server clean shutdown failed", "error", err,
|
|
)
|
|
}
|
|
|
|
s.cleanupForExit()
|
|
|
|
if s.sentryEnabled.Load() {
|
|
s.flushSentry(ctx)
|
|
}
|
|
}
|
|
|
|
// flushSentry drains Sentry's queue inside what is left of the fx
|
|
// stop budget. A context carrying no deadline — a caller outside the
|
|
// fx lifecycle — gets the full timeout.
|
|
func (s *Server) flushSentry(ctx context.Context) {
|
|
flush := sentryFlushTimeout
|
|
|
|
if deadline, ok := ctx.Deadline(); ok {
|
|
flush = SentryFlushBudget(time.Until(deadline))
|
|
}
|
|
|
|
if flush <= 0 {
|
|
s.log.Warn(
|
|
"skipping sentry flush, stop budget exhausted",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
sentry.Flush(flush)
|
|
}
|
|
|
|
func (s *Server) configure() {
|
|
// identify ourselves in the logs
|
|
s.params.Logger.Identify()
|
|
}
|