// Package server provides the HTTP server. package server import ( "context" "errors" "fmt" "log/slog" "net/http" "time" "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 // 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 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() 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. func (s *Server) Shutdown(ctx context.Context) error { 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) }