1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3// Package httpserver is the HTTP entry of ermite: the router, the security
4// headers, the request log and the graceful shutdown. Routes are added here
5// as the other adapters arrive: /-/health and the routes given in Config.
6package httpserver
7
8import (
9 "context"
10 "errors"
11 "fmt"
12 "net"
13 "net/http"
14 "os"
15 "time"
16
17 "github.com/bnema/zerowrap"
18)
19
20// ShutdownTimeout is how long Run waits for requests in flight after the
21// context ends.
22const ShutdownTimeout = 10 * time.Second
23
24// Check reports whether a dependency works. A nil result means healthy.
25type Check func(ctx context.Context) error
26
27// Route mounts a handler on the router.
28type Route struct {
29 // Pattern is a net/http.ServeMux pattern, such as "GET /{repo}/info/refs".
30 // It must not overlap the /-/ routes of the server.
31 Pattern string
32 Handler http.Handler
33}
34
35// Config configures a Server.
36type Config struct {
37 // Addr is the listen address, "host:port".
38 Addr string
39 // PublicURL is the public base URL of the server. When it is https://,
40 // every response carries Strict-Transport-Security.
41 PublicURL string
42 // HealthChecks run on GET /-/health, at most once per healthCacheTTL
43 // (the answer is cached, the endpoint being unauthenticated); one
44 // failure answers 503.
45 HealthChecks []Check
46 // Routes are added to the router next to /-/health.
47 Routes []Route
48 // API serves the ConnectRPC API under /-/api/ (the prefix is stripped, so
49 // it sees "/ermite.RepoService/ListRepos"). Nil serves none.
50 API http.Handler
51 // OnShutdown functions run, each in its own goroutine, when the graceful
52 // shutdown begins. Use them to end long-lived responses such as event
53 // streams, which the shutdown would otherwise wait for.
54 OnShutdown []func()
55}
56
57// APIPrefix is where the API is mounted.
58const APIPrefix = "/-/api"
59
60// Server is the HTTP server of ermite.
61type Server struct {
62 addr string
63 handler http.Handler
64 onShutdown []func()
65}
66
67// New builds the server and its routes.
68func New(cfg Config) *Server {
69 mux := http.NewServeMux()
70 // "GET" patterns also match HEAD; other methods get 405 with Allow, and
71 // unknown paths get 404.
72 mux.HandleFunc("GET /-/health", healthHandler(cfg.HealthChecks))
73 if cfg.API != nil {
74 mux.Handle(APIPrefix+"/", http.StripPrefix(APIPrefix, cfg.API))
75 }
76 for _, r := range cfg.Routes {
77 mux.Handle(r.Pattern, r.Handler)
78 }
79 return &Server{addr: cfg.Addr, handler: withRequestLog(withSecurityHeaders(mux, isHTTPS(cfg.PublicURL))), onShutdown: cfg.OnShutdown}
80}
81
82// Handler returns the root handler, for tests.
83func (s *Server) Handler() http.Handler { return s.handler }
84
85// Run listens on the configured address and serves until ctx ends, then
86// shuts down gracefully within ShutdownTimeout. It returns nil after a clean
87// shutdown. The logger of ctx is used for the server and its requests.
88func (s *Server) Run(ctx context.Context) error {
89 ln, err := net.Listen("tcp", s.addr)
90 if err != nil {
91 return fmt.Errorf("listen on %s: %w", s.addr, err)
92 }
93 return s.Serve(ctx, ln)
94}
95
96// Serve is Run on an existing listener, which it closes.
97func (s *Server) Serve(ctx context.Context, ln net.Listener) error {
98 log := zerowrap.FromCtx(ctx)
99 srv := &http.Server{
100 Handler: s.handler,
101 // Requests keep the logger of ctx but outlive its cancellation, so
102 // that a graceful shutdown lets them finish.
103 BaseContext: func(net.Listener) context.Context { return context.WithoutCancel(ctx) },
104 ReadHeaderTimeout: 10 * time.Second,
105 IdleTimeout: 2 * time.Minute,
106 // No read or write timeout: Git clones and pushes can be long.
107 }
108
109 for _, f := range s.onShutdown {
110 srv.RegisterOnShutdown(f)
111 }
112
113 errc := make(chan error, 1)
114 go func() { errc <- srv.Serve(ln) }()
115 log.Info().Str("addr", ln.Addr().String()).Msg("listening")
116
117 select {
118 case err := <-errc:
119 // Serve returns before Shutdown only on a failure.
120 return fmt.Errorf("serve: %w", err)
121 case <-ctx.Done():
122 }
123
124 log.Info().Dur("timeout", ShutdownTimeout).Msg("shutting down")
125 sctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), ShutdownTimeout)
126 defer cancel()
127 if err := srv.Shutdown(sctx); err != nil {
128 _ = srv.Close()
129 return fmt.Errorf("shutdown: %w", err)
130 }
131 if err := <-errc; err != nil && !errors.Is(err, http.ErrServerClosed) {
132 return fmt.Errorf("serve: %w", err)
133 }
134 log.Info().Msg("http server stopped")
135 return nil
136}
137
138// DirWritable returns a Check that creates and removes a file in dir.
139func DirWritable(dir string) Check {
140 return func(context.Context) error {
141 f, err := os.CreateTemp(dir, ".health-*")
142 if err != nil {
143 return fmt.Errorf("data directory is not writable: %w", err)
144 }
145 name := f.Name()
146 cerr := f.Close()
147 rerr := os.Remove(name)
148 if cerr != nil {
149 return fmt.Errorf("data directory is not writable: %w", cerr)
150 }
151 if rerr != nil {
152 return fmt.Errorf("data directory is not writable: %w", rerr)
153 }
154 return nil
155 }
156}