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