// Package server provides the HTTP server. package server import ( "context" "errors" "fmt" "log/slog" "net/http" "time" "github.com/getsentry/sentry-go" "github.com/go-chi/chi/v5" "go.uber.org/fx" "sneak.berlin/go/dnswatcher/internal/config" "sneak.berlin/go/dnswatcher/internal/globals" "sneak.berlin/go/dnswatcher/internal/handlers" "sneak.berlin/go/dnswatcher/internal/logger" "sneak.berlin/go/dnswatcher/internal/middleware" ) // Params contains dependencies for Server. type Params struct { fx.In Logger *logger.Logger Globals *globals.Globals Config *config.Config Middleware *middleware.Middleware Handlers *handlers.Handlers } // shutdownTimeout is how long to wait for graceful shutdown. const shutdownTimeout = 30 * time.Second // sentryFlushTimeout is how long shutdown waits for Sentry to send the // error reports it still holds. const sentryFlushTimeout = 2 * time.Second // Socket-level timeouts for the HTTP server. // // These bound time spent on the connection itself and are a distinct // control from the per-request handler budget enforced by // chimw.Timeout(requestTimeout) in routes.go: that one cancels the // request context after requestTimeout but never touches the socket, // so without the values below a peer can hold a connection open // forever (slowloris, unreaped keep-alives). // // The one hard constraint between the two controls is // writeTimeout > requestTimeout. net/http arms the write deadline // once the request headers have been read, so on a plaintext // connection it covers handler execution AND the response flush. If // writeTimeout were <= requestTimeout the server would sever the // connection before a handler that legitimately consumed its full // budget could emit anything, making the 60s budget unreachable in // practice. The margin between them is the response-flush allowance. // // The only clients of this service are browsers loading the dashboard // and a Prometheus scraper; the values are sized for those. const ( // readHeaderTimeout is the max duration for reading request // headers. readHeaderTimeout = 10 * time.Second // readTimeout bounds reading the entire request, headers plus // body. Every route here is a GET with no body, so this only // ever needs to cover headers; the extra 5s over // readHeaderTimeout is slack, not a real allowance, and keeps a // body dribbled one byte at a time from holding the read side // open indefinitely. readTimeout = 15 * time.Second // writeTimeout must exceed the requestTimeout handler budget // (60s) per the note above. The 15s difference is the allowance // for flushing a completed response to a slow client. writeTimeout = 75 * time.Second // idleTimeout reaps keep-alive connections between requests. It // is deliberately longer than the common Prometheus scrape // intervals (15s/30s/60s) so the scraper reuses its connection // rather than reconnecting every cycle, while a browser tab // left open on the dashboard stops occupying a connection // within two minutes of going quiet. idleTimeout = 120 * time.Second ) // Server is the HTTP server. type Server struct { startupTime time.Time port int sentryEnabled bool log *slog.Logger router *chi.Mux httpServer *http.Server params Params mw *middleware.Middleware handlers *handlers.Handlers } // New creates a new Server instance. func New( lifecycle fx.Lifecycle, params Params, ) (*Server, error) { srv := &Server{ port: params.Config.Port, log: params.Logger.Get(), params: params, mw: params.Middleware, handlers: params.Handlers, } lifecycle.Append(fx.Hook{ OnStart: func(_ context.Context) error { srv.startupTime = time.Now() err := srv.enableSentry() if err != nil { return err } go srv.Run() return nil }, OnStop: func(ctx context.Context) error { return srv.Shutdown(ctx) }, }) return srv, nil } // newHTTPServer builds the listening http.Server with every // socket-level timeout set. All four are set deliberately: a zero // value in net/http means "no limit", not "some default". func newHTTPServer( listenAddr string, handler http.Handler, ) *http.Server { return &http.Server{ Addr: listenAddr, Handler: handler, ReadTimeout: readTimeout, ReadHeaderTimeout: readHeaderTimeout, WriteTimeout: writeTimeout, IdleTimeout: idleTimeout, } } // Run starts the HTTP server. func (s *Server) Run() { s.SetupRoutes() listenAddr := fmt.Sprintf(":%d", s.port) s.httpServer = newHTTPServer(listenAddr, s) s.log.Info("http server starting", "addr", listenAddr) err := s.httpServer.ListenAndServe() if err != nil && !errors.Is(err, http.ErrServerClosed) { s.log.Error("http server error", "error", err) } } // Shutdown gracefully shuts down the server, then sends the error // reports Sentry still holds. func (s *Server) Shutdown(ctx context.Context) error { defer s.flushSentry() if s.httpServer == nil { return nil } s.log.Info("shutting down http server") shutdownCtx, cancel := context.WithTimeout( ctx, shutdownTimeout, ) defer cancel() err := s.httpServer.Shutdown(shutdownCtx) if err != nil { s.log.Error("http server shutdown error", "error", err) return fmt.Errorf("shutting down http server: %w", err) } s.log.Info("http server stopped") return nil } // ServeHTTP implements http.Handler. func (s *Server) ServeHTTP( writer http.ResponseWriter, request *http.Request, ) { s.router.ServeHTTP(writer, request) } // enableSentry turns on Sentry error reporting when // DNSWATCHER_SENTRY_DSN is set, and does nothing when it is not. A DSN // that Sentry cannot parse is an error, so that startup stops instead // of running without the error reporting the operator asked for. func (s *Server) enableSentry() error { if s.params.Config.SentryDSN == "" { return nil } err := sentry.Init(sentry.ClientOptions{ Dsn: s.params.Config.SentryDSN, Release: s.params.Globals.Appname + "-" + s.params.Globals.Version, // Use the transport that queues each report as it is made. With // the default one, Flush can return before sending a report made // just before it, such as one from the last request at shutdown. DisableTelemetryBuffer: true, // Send panic reports only, not Sentry's counts of what it dropped, // such as the transaction it starts for every request. DisableClientReports: true, }) if err != nil { return fmt.Errorf("invalid DNSWATCHER_SENTRY_DSN: %w", err) } s.log.Info("sentry error reporting activated") s.sentryEnabled = true return nil } // flushSentry sends the error reports Sentry still holds, waiting at // most sentryFlushTimeout. func (s *Server) flushSentry() { if !s.sentryEnabled { return } if !sentry.Flush(sentryFlushTimeout) { s.log.Warn("sentry flush timed out; some error reports were not sent") } }