Files
job-search/internal/server/server.go
T
bwilliams 9905ffb4cb Served the frontend's build output from server.frontend.dir
internal/frontend serves a Vite build from an fs.FS: hashed files under
/assets/ are cached for a year, HTML and other files get no-cache, ETags are
computed (and cached for a build that can't change), precompressed variants
are served when the client accepts them, and a path without an extension
falls back to index.html for client-side routes. Only GET and HEAD are
allowed.

server.WithFrontend serves it for every path outside /api and /ws, behind
compression and the frontend group's hooks. job-search serve passes the
directory in server.frontend.dir, checked for an index.html at start; empty
means no frontend. Its option building moved into serverOptions, with the
access log.
2026-10-16 16:00:34 +01:00

450 lines
13 KiB
Go

// Package server assembles the HTTP server for the serve command.
package server
import (
"context"
"crypto/tls"
"errors"
"fmt"
"io/fs"
"log"
"log/slog"
"net"
"net/http"
"net/netip"
"strconv"
"strings"
"time"
"github.com/pires/go-proxyproto"
"git.avsdev.uk/Go/job-search/internal/api"
"git.avsdev.uk/Go/job-search/internal/config"
"git.avsdev.uk/Go/job-search/internal/events"
"git.avsdev.uk/Go/job-search/internal/logging"
"git.avsdev.uk/Go/job-search/internal/middleware"
"git.avsdev.uk/Go/job-search/internal/store"
"git.avsdev.uk/Go/job-search/internal/util"
"git.avsdev.uk/Go/job-search/internal/websocket"
)
// Group identifies where hook middleware is inserted.
type Group int
// The route groups hook middleware can be added to (see WithMiddleware).
const (
// GroupGlobal is every request, after real-IP, request-ID and logging.
GroupGlobal Group = iota
// GroupAPI is /api/*, before CSRF protection.
GroupAPI
// GroupWebSocket is /ws upgrade requests.
GroupWebSocket
// GroupFrontend is the static frontend (see WithFrontend).
GroupFrontend
)
// Middleware is the standard net/http middleware shape.
type Middleware = func(http.Handler) http.Handler
// Option customises a Server.
type Option func(*Server)
// WithLogger makes the server, its middleware and its WebSocket hub log to
// logger instead of slog.Default().
func WithLogger(logger *slog.Logger) Option {
return func(s *Server) {
s.log = logger
}
}
// WithAccessLog makes the server write a line to logger for every request, in
// the Combined Log Format (see logging.AccessLog), as well as the request
// lines it logs with slog. The line is written when the request ends, and
// covers requests that middleware refuses.
func WithAccessLog(logger *log.Logger) Option {
return func(s *Server) {
s.accessLog = logger
}
}
// WithStore makes the server's API serve the opportunities in `store` under
// /api/opportunities (see api.WithStore). Without it there are no
// opportunity routes.
func WithStore(store *store.Store) Option {
return func(s *Server) {
s.store = store
}
}
// WithEvents connects the server to bus: every event published to it is
// pushed to the server's WebSocket clients (as websocket.TypeEvent
// messages), and the server's API publishes its changes to it (see
// api.WithEvents).
func WithEvents(bus *events.Bus) Option {
return func(s *Server) {
s.events = bus
}
}
// WithFrontend makes the server serve the frontend's build output, files,
// for every path outside /api and /ws. Without it there is no frontend: such
// paths get 404.
func WithFrontend(files fs.FS) Option {
return func(s *Server) {
s.frontend = files
}
}
// WithMiddleware appends middleware to a group. This is the generic hook
// (auth, metrics, tracing, custom header rules, ...).
func WithMiddleware(group Group, middlewares ...Middleware) Option {
return func(server *Server) {
server.hooks[group] = append(server.hooks[group], middlewares...)
}
}
// WithRateLimiter replaces the configured rate limiter of a group (for
// example with a Redis-backed implementation). Pass a nil Limiter to
// disable limiting for the group. A nil key defaults to the client IP.
func WithRateLimiter(
group Group, limiter middleware.Limiter, key middleware.KeyFunc,
) Option {
return func(server *Server) {
server.limiters[group] = limiterHook{
limiter: limiter,
key: key,
set: true,
}
}
}
// limiterHook is a group's rate limiter set by WithRateLimiter, replacing the
// configured one. set distinguishes a nil limiter (no limiting) from none.
type limiterHook struct {
limiter middleware.Limiter
key middleware.KeyFunc
set bool
}
// Server is the application server.
type Server struct {
conf *config.JSCServer
log *slog.Logger
// accessLog is where the access log is written, or nil for none.
accessLog *log.Logger
// frontend is the frontend's build output, or nil for none.
frontend fs.FS
hooks map[Group][]Middleware
limiters map[Group]limiterHook
// trusted are the proxies whose forwarding headers are believed.
trusted []netip.Prefix
// proxyProtocolTrusted are the sources allowed to send a PROXY header.
proxyProtocolTrusted []netip.Prefix
csrf *middleware.CSRF
hub *websocket.Hub
// store is the opportunities' database, or nil for none.
store *store.Store
// events is where events are pushed from and the API publishes to, or
// nil for none.
events *events.Bus
// api is the REST API mounted under /api, built from the options.
api *api.API
started time.Time
handler http.Handler
// backgroundCtx is for background work, such as the rate limiters'
// janitors; backgroundCancel stops it.
backgroundCtx context.Context
backgroundCancel context.CancelFunc
}
// New builds the server and its router. It logs to slog.Default() unless
// given WithLogger, and serves no frontend unless given WithFrontend.
func New(conf *config.JSCServer, opts ...Option) (*Server, error) {
server := &Server{
conf: conf,
hooks: make(map[Group][]Middleware),
limiters: make(map[Group]limiterHook),
started: time.Now(),
}
for _, option := range opts {
option(server)
}
server.api = api.New(
api.WithStore(server.store),
api.WithEvents(server.events),
)
var err error
server.trusted, err = util.ParseNetPrefixes(conf.Proxy.TrustedProxies)
if err != nil {
return nil, fmt.Errorf("parsing proxy.trusted: %w", err)
}
proxyProtocolSources := conf.Proxy.ProxyProtocol.TrustedSources
if len(proxyProtocolSources) == 0 {
proxyProtocolSources = conf.Proxy.TrustedProxies
}
server.proxyProtocolTrusted, err = util.ParseNetPrefixes(
proxyProtocolSources,
)
if err != nil {
return nil, fmt.Errorf(
"parsing proxy.proxy_protocol.trusted_sources: %w", err,
)
}
server.csrf, err = middleware.NewCSRF(
conf.CSRF, middleware.WithLogger(server.untaggedLogger()),
)
if err != nil {
return nil, err
}
server.hub = websocket.NewHub(
conf.WebSocket,
websocket.WithLogger(server.untaggedLogger()),
websocket.WithTrustedOrigins(
conf.CSRF.CrossOriginProtection.TrustedOrigins,
),
)
server.backgroundCtx, server.backgroundCancel = context.WithCancel(
context.Background(),
)
server.handler, err = server.routes()
if err != nil {
server.backgroundCancel()
return nil, err
}
if server.accessLog != nil {
server.handler = logging.AccessLog(server.accessLog, true, server.handler)
}
// Subscribed last, so a server that failed to build is never pushed to.
if server.events != nil {
server.events.Subscribe(func(event events.Event) {
server.hub.PushEvent(event.Type, event.Payload)
})
}
return server, nil
}
// untaggedLogger returns the logger given WithLogger, or slog.Default(), for
// the server's middleware and hub to tag with their own components.
func (s *Server) untaggedLogger() *slog.Logger {
if s.log == nil {
return slog.Default()
}
return s.log
}
// logger returns the logger the server logs to, tagged with its component.
func (s *Server) logger() *slog.Logger {
return s.untaggedLogger().With("component", "server")
}
// Handler exposes the root handler (useful for httptest).
func (s *Server) Handler() http.Handler { return s.handler }
// Run listens and serves until ctx is cancelled, then shuts down gracefully.
// With TLS, it also serves an HTTP to HTTPS redirect if tls.redirect_http_port
// is set.
func (s *Server) Run(ctx context.Context) error {
defer s.backgroundCancel()
conf := s.conf
addr := net.JoinHostPort(conf.Host, strconv.Itoa(conf.Port))
listen, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("listen %s: %w", addr, err)
}
if conf.Proxy.ProxyProtocol.Enabled {
listen = &proxyproto.Listener{
Listener: listen,
ConnPolicy: s.proxyPolicy,
ReadHeaderTimeout: noLimitIfUnset(
conf.Proxy.ProxyProtocol.ReadHeaderTimeout,
),
}
}
httpServer := &http.Server{
Handler: s.handler,
ReadHeaderTimeout: noLimitIfUnset(conf.Timeouts.ReadHeaderTimeout),
ReadTimeout: conf.Timeouts.ReadTimeout,
WriteTimeout: conf.Timeouts.WriteTimeout,
IdleTimeout: noLimitIfUnset(conf.Timeouts.IdleTimeout),
MaxHeaderBytes: maxHeaderBytes(conf),
ErrorLog: slog.NewLogLogger(
s.logger().Handler(), slog.LevelWarn,
),
}
if conf.TLS.Enabled {
minVersion := uint16(tls.VersionTLS12)
if conf.TLS.MinVersion == "1.3" {
minVersion = tls.VersionTLS13
}
httpServer.TLSConfig = &tls.Config{
MinVersion: minVersion,
}
}
serveErrors := make(chan error, 2)
go func() {
s.logger().Info(
"listening",
"addr", listen.Addr().String(),
"tls", conf.TLS.Enabled,
"proxy_protocol", conf.Proxy.ProxyProtocol.Enabled,
)
var err error
if conf.TLS.Enabled {
err = httpServer.ServeTLS(listen, conf.TLS.CertFile, conf.TLS.KeyFile)
} else {
err = httpServer.Serve(listen)
}
serveErrors <- err
}()
var redirect *http.Server
if conf.TLS.Enabled && conf.TLS.RedirectHTTPPort != 0 {
httpAddr := net.JoinHostPort(
conf.Host, strconv.Itoa(conf.TLS.RedirectHTTPPort),
)
redirect = &http.Server{
Addr: httpAddr,
Handler: middleware.RequestChecks(*conf)(httpsRedirect(addr)),
ReadHeaderTimeout: noLimitIfUnset(conf.Timeouts.ReadHeaderTimeout),
IdleTimeout: noLimitIfUnset(conf.Timeouts.IdleTimeout),
}
go func() {
s.logger().Info("http->https redirect listening", "addr", httpAddr)
serveErrors <- redirect.ListenAndServe()
}()
}
select {
case err := <-serveErrors:
if !errors.Is(err, http.ErrServerClosed) {
return err
}
case <-ctx.Done():
}
s.logger().Info("shutting down")
shutdownCtx, cancel := util.WithTimeout(
context.Background(), conf.Timeouts.ShutdownTimeout,
)
defer cancel()
// Stop accepting first, then close WebSockets (hijacked connections are
// not tracked by http.Server.Shutdown), then drain in-flight requests.
var errs []error
shutdownErr := make(chan error, 1)
go func() {
shutdownErr <- httpServer.Shutdown(shutdownCtx)
}()
if err := s.hub.Shutdown(shutdownCtx); err != nil {
errs = append(errs, fmt.Errorf("websocket shutdown: %w", err))
}
if err := <-shutdownErr; err != nil {
errs = append(errs, fmt.Errorf("http shutdown: %w", err))
}
if redirect != nil {
redirect.Shutdown(shutdownCtx) //nolint:errcheck,gosec // only redirects; nothing to drain
}
return errors.Join(errs...)
}
// noLimitIfUnset returns timeout for a field that reads 0 as some other
// timeout, such as http.Server's ReadHeaderTimeout and IdleTimeout (which
// use ReadTimeout) or the PROXY listener's ReadHeaderTimeout (10s): -1,
// which those fields read as no limit, if timeout is 0 or less, as the
// configuration's timeouts mean.
func noLimitIfUnset(timeout time.Duration) time.Duration {
if timeout <= 0 {
return -1
}
return timeout
}
// defaultMaxHeaderBytes is the largest request header block when
// max_header_bytes is 0 or less: 8KB, as the configuration's default. The
// configuration rejects -1 (no limit), as http.Server can't have one.
const defaultMaxHeaderBytes = 8_000
// maxHeaderBytes is the largest request header block: max_header_bytes, or
// defaultMaxHeaderBytes if that is 0 or less.
func maxHeaderBytes(conf *config.JSCServer) int {
if conf.MaxHeaderBytes <= 0 {
return defaultMaxHeaderBytes
}
return int(conf.MaxHeaderBytes)
}
// proxyPolicy decides per connection how to treat a PROXY header. Only
// trusted sources may supply one. Untrusted peers are SKIPped (the header is
// not parsed; a spoofed one becomes garbage to the HTTP parser and fails).
// Never return a non-ErrInvalidUpstream error here: it would stop Accept.
func (s *Server) proxyPolicy(
options proxyproto.ConnPolicyOptions,
) (proxyproto.Policy, error) {
ip := addrOf(options.Upstream)
for _, prefix := range s.proxyProtocolTrusted {
if ip.IsValid() && prefix.Contains(ip) {
if s.conf.Proxy.ProxyProtocol.Required {
return proxyproto.REQUIRE, nil
}
return proxyproto.USE, nil
}
}
return proxyproto.SKIP, nil
}
// addrOf returns the IP address of a connection's address (unmapped to IPv4
// if it is an IPv4-mapped IPv6 address), or the zero Addr if it has none.
func addrOf(netAddr net.Addr) netip.Addr {
switch tcpAddr := netAddr.(type) {
case *net.TCPAddr:
ip, _ := netip.AddrFromSlice(tcpAddr.IP)
return ip.Unmap()
default:
addrPort, err := netip.ParseAddrPort(netAddr.String())
if err != nil {
return netip.Addr{}
}
return addrPort.Addr().Unmap()
}
}
// httpsRedirect redirects every request permanently to the same host and
// path over HTTPS, on the port of tlsAddr (the TLS listener's address),
// which is left out if it is 443.
func httpsRedirect(tlsAddr string) http.Handler {
_, port, _ := net.SplitHostPort(tlsAddr)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host := r.Host
if hostname, _, err := net.SplitHostPort(host); err == nil {
host = hostname
}
if strings.Contains(host, ":") { // bare IPv6
host = "[" + host + "]"
}
if port != "" && port != "443" {
host = net.JoinHostPort(strings.Trim(host, "[]"), port)
}
// The redirect only switches the scheme (and port) of the URL the
// client asked for, on the host it asked for, so it can't send the
// client anywhere else; RequestChecks has checked that host.
http.Redirect( //nolint:gosec // G710: see above
w, r, "https://"+host+r.URL.RequestURI(), http.StatusPermanentRedirect,
)
})
}