detached b156bd8

feat(httpserver): serve health with security headers and graceful shutdown

commit
b156bd841cf7200bbf98494a31520600e1a810f1
author
brice · · 2026-10-10 06:16 UTC
parent
aa54e60
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +443 -0
A internal/adapters/httpserver/health.go +28 -0
@@ -0,0 +1,28 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package httpserver
4+
5+import (
6+ "net/http"
7+
8+ "github.com/bnema/zerowrap"
9+)
10+
11+// healthHandler answers 200 "ok" when every check passes and 503 otherwise.
12+// The failure is logged, never sent: the body stays the same for any cause.
13+func healthHandler(checks []Check) http.HandlerFunc {
14+ return func(w http.ResponseWriter, r *http.Request) {
15+ w.Header().Set("Cache-Control", "no-store")
16+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
17+ for _, check := range checks {
18+ if err := check(r.Context()); err != nil {
19+ log := zerowrap.FromCtx(r.Context())
20+ log.Warn().Err(err).Msg("health check failed")
21+ w.WriteHeader(http.StatusServiceUnavailable)
22+ _, _ = w.Write([]byte("unavailable\n"))
23+ return
24+ }
25+ }
26+ _, _ = w.Write([]byte("ok\n"))
27+ }
28+}
@@ -0,0 +1,28 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package httpserver
4+
5+import (
6+ "net/http"
7+
8+ "github.com/bnema/zerowrap"
9+)
10+
11+// healthHandler answers 200 "ok" when every check passes and 503 otherwise.
12+// The failure is logged, never sent: the body stays the same for any cause.
13+func healthHandler(checks []Check) http.HandlerFunc {
14+ return func(w http.ResponseWriter, r *http.Request) {
15+ w.Header().Set("Cache-Control", "no-store")
16+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
17+ for _, check := range checks {
18+ if err := check(r.Context()); err != nil {
19+ log := zerowrap.FromCtx(r.Context())
20+ log.Warn().Err(err).Msg("health check failed")
21+ w.WriteHeader(http.StatusServiceUnavailable)
22+ _, _ = w.Write([]byte("unavailable\n"))
23+ return
24+ }
25+ }
26+ _, _ = w.Write([]byte("ok\n"))
27+ }
28+}
A internal/adapters/httpserver/middleware.go +71 -0
@@ -0,0 +1,71 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package httpserver
4+
5+import (
6+ "net/http"
7+ "time"
8+
9+ "github.com/bnema/zerowrap"
10+)
11+
12+// contentSecurityPolicy allows scripts from the origin plus 'unsafe-eval'
13+// for htmx attribute expressions; inline scripts stay forbidden.
14+const contentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'"
15+
16+// withSecurityHeaders sets the security headers on every response, before
17+// the handler runs, so that errors and 404s carry them too.
18+func withSecurityHeaders(next http.Handler) http.Handler {
19+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
20+ h := w.Header()
21+ h.Set("Content-Security-Policy", contentSecurityPolicy)
22+ h.Set("X-Frame-Options", "DENY")
23+ h.Set("X-Content-Type-Options", "nosniff")
24+ h.Set("Referrer-Policy", "same-origin")
25+ next.ServeHTTP(w, r)
26+ })
27+}
28+
29+// statusRecorder remembers the status code of a response.
30+type statusRecorder struct {
31+ http.ResponseWriter
32+ status int
33+}
34+
35+func (s *statusRecorder) WriteHeader(code int) {
36+ if s.status == 0 {
37+ s.status = code
38+ }
39+ s.ResponseWriter.WriteHeader(code)
40+}
41+
42+func (s *statusRecorder) Write(b []byte) (int, error) {
43+ if s.status == 0 {
44+ s.status = http.StatusOK
45+ }
46+ return s.ResponseWriter.Write(b)
47+}
48+
49+// Unwrap lets http.ResponseController reach Flush and friends.
50+func (s *statusRecorder) Unwrap() http.ResponseWriter { return s.ResponseWriter }
51+
52+// withRequestLog logs method, path, status and duration at debug level. It
53+// never logs the query string or any header: they can carry credentials.
54+func withRequestLog(next http.Handler) http.Handler {
55+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
56+ start := time.Now()
57+ rec := &statusRecorder{ResponseWriter: w}
58+ next.ServeHTTP(rec, r)
59+ status := rec.status
60+ if status == 0 {
61+ status = http.StatusOK
62+ }
63+ log := zerowrap.FromCtx(r.Context())
64+ log.Debug().
65+ Str("method", r.Method).
66+ Str("path", r.URL.Path).
67+ Int("status", status).
68+ Dur("duration", time.Since(start)).
69+ Msg("request")
70+ })
71+}
@@ -0,0 +1,71 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package httpserver
4+
5+import (
6+ "net/http"
7+ "time"
8+
9+ "github.com/bnema/zerowrap"
10+)
11+
12+// contentSecurityPolicy allows scripts from the origin plus 'unsafe-eval'
13+// for htmx attribute expressions; inline scripts stay forbidden.
14+const contentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'"
15+
16+// withSecurityHeaders sets the security headers on every response, before
17+// the handler runs, so that errors and 404s carry them too.
18+func withSecurityHeaders(next http.Handler) http.Handler {
19+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
20+ h := w.Header()
21+ h.Set("Content-Security-Policy", contentSecurityPolicy)
22+ h.Set("X-Frame-Options", "DENY")
23+ h.Set("X-Content-Type-Options", "nosniff")
24+ h.Set("Referrer-Policy", "same-origin")
25+ next.ServeHTTP(w, r)
26+ })
27+}
28+
29+// statusRecorder remembers the status code of a response.
30+type statusRecorder struct {
31+ http.ResponseWriter
32+ status int
33+}
34+
35+func (s *statusRecorder) WriteHeader(code int) {
36+ if s.status == 0 {
37+ s.status = code
38+ }
39+ s.ResponseWriter.WriteHeader(code)
40+}
41+
42+func (s *statusRecorder) Write(b []byte) (int, error) {
43+ if s.status == 0 {
44+ s.status = http.StatusOK
45+ }
46+ return s.ResponseWriter.Write(b)
47+}
48+
49+// Unwrap lets http.ResponseController reach Flush and friends.
50+func (s *statusRecorder) Unwrap() http.ResponseWriter { return s.ResponseWriter }
51+
52+// withRequestLog logs method, path, status and duration at debug level. It
53+// never logs the query string or any header: they can carry credentials.
54+func withRequestLog(next http.Handler) http.Handler {
55+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
56+ start := time.Now()
57+ rec := &statusRecorder{ResponseWriter: w}
58+ next.ServeHTTP(rec, r)
59+ status := rec.status
60+ if status == 0 {
61+ status = http.StatusOK
62+ }
63+ log := zerowrap.FromCtx(r.Context())
64+ log.Debug().
65+ Str("method", r.Method).
66+ Str("path", r.URL.Path).
67+ Int("status", status).
68+ Dur("duration", time.Since(start)).
69+ Msg("request")
70+ })
71+}
A internal/adapters/httpserver/server.go +121 -0
@@ -0,0 +1,121 @@
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; for now it serves /-/health.
6+package httpserver
7+
8+import (
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.
22+const ShutdownTimeout = 10 * time.Second
23+
24+// Check reports whether a dependency works. A nil result means healthy.
25+type Check func(ctx context.Context) error
26+
27+// Config configures a Server.
28+type Config struct {
29+ // Addr is the listen address, "host:port".
30+ Addr string
31+ // HealthChecks run on every GET /-/health; one failure answers 503.
32+ HealthChecks []Check
33+}
34+
35+// Server is the HTTP server of ermite.
36+type Server struct {
37+ addr string
38+ handler http.Handler
39+}
40+
41+// New builds the server and its routes.
42+func New(cfg Config) *Server {
43+ mux := http.NewServeMux()
44+ // "GET" patterns also match HEAD; other methods get 405 with Allow, and
45+ // unknown paths get 404.
46+ mux.HandleFunc("GET /-/health", healthHandler(cfg.HealthChecks))
47+ return &Server{addr: cfg.Addr, handler: withRequestLog(withSecurityHeaders(mux))}
48+}
49+
50+// Handler returns the root handler, for tests.
51+func (s *Server) Handler() http.Handler { return s.handler }
52+
53+// Run listens on the configured address and serves until ctx ends, then
54+// shuts down gracefully within ShutdownTimeout. It returns nil after a clean
55+// shutdown. The logger of ctx is used for the server and its requests.
56+func (s *Server) Run(ctx context.Context) error {
57+ ln, err := net.Listen("tcp", s.addr)
58+ if err != nil {
59+ return fmt.Errorf("listen on %s: %w", s.addr, err)
60+ }
61+ return s.Serve(ctx, ln)
62+}
63+
64+// Serve is Run on an existing listener, which it closes.
65+func (s *Server) Serve(ctx context.Context, ln net.Listener) error {
66+ log := zerowrap.FromCtx(ctx)
67+ srv := &http.Server{
68+ Handler: s.handler,
69+ // Requests keep the logger of ctx but outlive its cancellation, so
70+ // that a graceful shutdown lets them finish.
71+ BaseContext: func(net.Listener) context.Context { return context.WithoutCancel(ctx) },
72+ ReadHeaderTimeout: 10 * time.Second,
73+ IdleTimeout: 2 * time.Minute,
74+ // No read or write timeout: Git clones and pushes can be long.
75+ }
76+
77+ errc := make(chan error, 1)
78+ go func() { errc <- srv.Serve(ln) }()
79+ log.Info().Str("addr", ln.Addr().String()).Msg("listening")
80+
81+ select {
82+ case err := <-errc:
83+ // Serve returns before Shutdown only on a failure.
84+ return fmt.Errorf("serve: %w", err)
85+ case <-ctx.Done():
86+ }
87+
88+ log.Info().Dur("timeout", ShutdownTimeout).Msg("shutting down")
89+ sctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), ShutdownTimeout)
90+ defer cancel()
91+ if err := srv.Shutdown(sctx); err != nil {
92+ _ = srv.Close()
93+ return fmt.Errorf("shutdown: %w", err)
94+ }
95+ if err := <-errc; err != nil && !errors.Is(err, http.ErrServerClosed) {
96+ return fmt.Errorf("serve: %w", err)
97+ }
98+ log.Info().Msg("http server stopped")
99+ return nil
100+}
101+
102+// DirWritable returns a Check that creates and removes a file in dir.
103+func DirWritable(dir string) Check {
104+ return func(context.Context) error {
105+ f, err := os.CreateTemp(dir, ".health-*")
106+ if err != nil {
107+ return fmt.Errorf("data directory is not writable: %w", err)
108+ }
109+ name := f.Name()
110+ cerr := f.Close()
111+ rerr := os.Remove(name)
112+ if cerr != nil {
113+ return fmt.Errorf("data directory is not writable: %w", cerr)
114+ }
115+ if rerr != nil {
116+ return fmt.Errorf("data directory is not writable: %w", rerr)
117+ }
118+ return nil
119+ }
120+}
121+
@@ -0,0 +1,121 @@
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; for now it serves /-/health.
6+package httpserver
7+
8+import (
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.
22+const ShutdownTimeout = 10 * time.Second
23+
24+// Check reports whether a dependency works. A nil result means healthy.
25+type Check func(ctx context.Context) error
26+
27+// Config configures a Server.
28+type Config struct {
29+ // Addr is the listen address, "host:port".
30+ Addr string
31+ // HealthChecks run on every GET /-/health; one failure answers 503.
32+ HealthChecks []Check
33+}
34+
35+// Server is the HTTP server of ermite.
36+type Server struct {
37+ addr string
38+ handler http.Handler
39+}
40+
41+// New builds the server and its routes.
42+func New(cfg Config) *Server {
43+ mux := http.NewServeMux()
44+ // "GET" patterns also match HEAD; other methods get 405 with Allow, and
45+ // unknown paths get 404.
46+ mux.HandleFunc("GET /-/health", healthHandler(cfg.HealthChecks))
47+ return &Server{addr: cfg.Addr, handler: withRequestLog(withSecurityHeaders(mux))}
48+}
49+
50+// Handler returns the root handler, for tests.
51+func (s *Server) Handler() http.Handler { return s.handler }
52+
53+// Run listens on the configured address and serves until ctx ends, then
54+// shuts down gracefully within ShutdownTimeout. It returns nil after a clean
55+// shutdown. The logger of ctx is used for the server and its requests.
56+func (s *Server) Run(ctx context.Context) error {
57+ ln, err := net.Listen("tcp", s.addr)
58+ if err != nil {
59+ return fmt.Errorf("listen on %s: %w", s.addr, err)
60+ }
61+ return s.Serve(ctx, ln)
62+}
63+
64+// Serve is Run on an existing listener, which it closes.
65+func (s *Server) Serve(ctx context.Context, ln net.Listener) error {
66+ log := zerowrap.FromCtx(ctx)
67+ srv := &http.Server{
68+ Handler: s.handler,
69+ // Requests keep the logger of ctx but outlive its cancellation, so
70+ // that a graceful shutdown lets them finish.
71+ BaseContext: func(net.Listener) context.Context { return context.WithoutCancel(ctx) },
72+ ReadHeaderTimeout: 10 * time.Second,
73+ IdleTimeout: 2 * time.Minute,
74+ // No read or write timeout: Git clones and pushes can be long.
75+ }
76+
77+ errc := make(chan error, 1)
78+ go func() { errc <- srv.Serve(ln) }()
79+ log.Info().Str("addr", ln.Addr().String()).Msg("listening")
80+
81+ select {
82+ case err := <-errc:
83+ // Serve returns before Shutdown only on a failure.
84+ return fmt.Errorf("serve: %w", err)
85+ case <-ctx.Done():
86+ }
87+
88+ log.Info().Dur("timeout", ShutdownTimeout).Msg("shutting down")
89+ sctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), ShutdownTimeout)
90+ defer cancel()
91+ if err := srv.Shutdown(sctx); err != nil {
92+ _ = srv.Close()
93+ return fmt.Errorf("shutdown: %w", err)
94+ }
95+ if err := <-errc; err != nil && !errors.Is(err, http.ErrServerClosed) {
96+ return fmt.Errorf("serve: %w", err)
97+ }
98+ log.Info().Msg("http server stopped")
99+ return nil
100+}
101+
102+// DirWritable returns a Check that creates and removes a file in dir.
103+func DirWritable(dir string) Check {
104+ return func(context.Context) error {
105+ f, err := os.CreateTemp(dir, ".health-*")
106+ if err != nil {
107+ return fmt.Errorf("data directory is not writable: %w", err)
108+ }
109+ name := f.Name()
110+ cerr := f.Close()
111+ rerr := os.Remove(name)
112+ if cerr != nil {
113+ return fmt.Errorf("data directory is not writable: %w", cerr)
114+ }
115+ if rerr != nil {
116+ return fmt.Errorf("data directory is not writable: %w", rerr)
117+ }
118+ return nil
119+ }
120+}
121+
A internal/adapters/httpserver/server_test.go +223 -0
@@ -0,0 +1,223 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package httpserver_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "encoding/json"
9+ "errors"
10+ "io"
11+ "net"
12+ "net/http"
13+ "net/http/httptest"
14+ "os"
15+ "strings"
16+ "sync"
17+ "testing"
18+ "time"
19+
20+ "github.com/bnema/zerowrap"
21+ "github.com/stretchr/testify/assert"
22+ "github.com/stretchr/testify/require"
23+
24+ "bnema.dev/ermite/internal/adapters/httpserver"
25+)
26+
27+var securityHeaders = map[string]string{
28+ "Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'",
29+ "X-Frame-Options": "DENY",
30+ "X-Content-Type-Options": "nosniff",
31+ "Referrer-Policy": "same-origin",
32+}
33+
34+func do(t *testing.T, h http.Handler, method, target string) *httptest.ResponseRecorder {
35+ t.Helper()
36+ rec := httptest.NewRecorder()
37+ h.ServeHTTP(rec, httptest.NewRequest(method, target, nil))
38+ return rec
39+}
40+
41+func TestHealth(t *testing.T) {
42+ ok := httpserver.Check(func(context.Context) error { return nil })
43+ fail := httpserver.Check(func(context.Context) error { return errors.New("kv down: secret detail") })
44+
45+ tests := []struct {
46+ name string
47+ checks []httpserver.Check
48+ method string
49+ wantStatus int
50+ wantBody string
51+ }{
52+ {"no checks", nil, "GET", 200, "ok\n"},
53+ {"all pass", []httpserver.Check{ok, ok}, "GET", 200, "ok\n"},
54+ {"head", []httpserver.Check{ok}, "HEAD", 200, ""}, // the real server drops the body
55+ {"one fails", []httpserver.Check{ok, fail}, "GET", 503, "unavailable\n"},
56+ {"head fails", []httpserver.Check{fail}, "HEAD", 503, ""},
57+ {"post", []httpserver.Check{ok}, "POST", 405, ""},
58+ {"delete", []httpserver.Check{ok}, "DELETE", 405, ""},
59+ }
60+ for _, tt := range tests {
61+ t.Run(tt.name, func(t *testing.T) {
62+ h := httpserver.New(httpserver.Config{HealthChecks: tt.checks}).Handler()
63+ rec := do(t, h, tt.method, "/-/health")
64+ assert.Equal(t, tt.wantStatus, rec.Code)
65+ if tt.method == "GET" {
66+ assert.Equal(t, tt.wantBody, rec.Body.String())
67+ }
68+ assert.NotContains(t, rec.Body.String(), "secret detail", "the failure cause stays in the log")
69+ if tt.wantStatus == 405 {
70+ assert.Contains(t, rec.Header().Get("Allow"), "GET")
71+ }
72+ for k, v := range securityHeaders {
73+ assert.Equal(t, v, rec.Header().Get(k), "%s on %s", k, tt.name)
74+ }
75+ })
76+ }
77+}
78+
79+func TestHeadOverTheWire(t *testing.T) {
80+ ts := httptest.NewServer(httpserver.New(httpserver.Config{}).Handler())
81+ defer ts.Close()
82+ resp, err := http.Head(ts.URL + "/-/health")
83+ require.NoError(t, err)
84+ defer func() { _ = resp.Body.Close() }()
85+ assert.Equal(t, 200, resp.StatusCode)
86+ body, err := io.ReadAll(resp.Body)
87+ require.NoError(t, err)
88+ assert.Empty(t, body)
89+}
90+
91+func TestNotFoundCarriesSecurityHeaders(t *testing.T) {
92+ h := httpserver.New(httpserver.Config{}).Handler()
93+ for _, target := range []string{"/", "/nope", "/-/", "/-/health/x", "/-/healthz", "/some-repo.git/info/refs"} {
94+ rec := do(t, h, "GET", target)
95+ assert.Equal(t, 404, rec.Code, target)
96+ for k, v := range securityHeaders {
97+ assert.Equal(t, v, rec.Header().Get(k), "%s on %s", k, target)
98+ }
99+ }
100+}
101+
102+type syncBuffer struct {
103+ mu sync.Mutex
104+ b bytes.Buffer
105+}
106+
107+func (s *syncBuffer) Write(p []byte) (int, error) {
108+ s.mu.Lock()
109+ defer s.mu.Unlock()
110+ return s.b.Write(p)
111+}
112+
113+func (s *syncBuffer) String() string {
114+ s.mu.Lock()
115+ defer s.mu.Unlock()
116+ return s.b.String()
117+}
118+
119+func TestRequestLog(t *testing.T) {
120+ var buf syncBuffer
121+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &buf}))
122+ h := httpserver.New(httpserver.Config{}).Handler()
123+
124+ req := httptest.NewRequestWithContext(ctx, "GET", "/missing?token=hunter2", nil)
125+ req.Header.Set("Authorization", "Bearer hunter2")
126+ h.ServeHTTP(httptest.NewRecorder(), req)
127+
128+ var entry map[string]any
129+ require.NoError(t, json.Unmarshal(bytes.TrimSpace([]byte(buf.String())), &entry), buf.String())
130+ assert.Equal(t, "debug", entry["level"])
131+ assert.Equal(t, "GET", entry["method"])
132+ assert.Equal(t, "/missing", entry["path"])
133+ assert.EqualValues(t, 404, entry["status"])
134+ assert.Contains(t, entry, "duration")
135+ assert.NotContains(t, buf.String(), "hunter2")
136+
137+ // At info level a request logs nothing.
138+ var quiet syncBuffer
139+ ctx = zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &quiet}))
140+ h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequestWithContext(ctx, "GET", "/missing", nil))
141+ assert.Empty(t, quiet.String())
142+}
143+
144+func TestDirWritable(t *testing.T) {
145+ dir := t.TempDir()
146+ check := httpserver.DirWritable(dir)
147+ require.NoError(t, check(context.Background()))
148+ entries, err := os.ReadDir(dir)
149+ require.NoError(t, err)
150+ assert.Empty(t, entries, "the probe file is removed")
151+
152+ assert.Error(t, httpserver.DirWritable(dir+"/missing")(context.Background()))
153+
154+ if os.Geteuid() != 0 {
155+ require.NoError(t, os.Chmod(dir, 0o500))
156+ t.Cleanup(func() { _ = os.Chmod(dir, 0o700) })
157+ assert.Error(t, check(context.Background()))
158+ }
159+}
160+
161+func TestServeAndGracefulShutdown(t *testing.T) {
162+ ln, err := net.Listen("tcp", "127.0.0.1:0")
163+ require.NoError(t, err)
164+ ctx, cancel := context.WithCancel(context.Background())
165+ defer cancel()
166+
167+ started := make(chan struct{})
168+ release := make(chan struct{})
169+ slow := httpserver.Check(func(context.Context) error {
170+ close(started)
171+ <-release
172+ return nil
173+ })
174+ srv := httpserver.New(httpserver.Config{HealthChecks: []httpserver.Check{slow}})
175+ done := make(chan error, 1)
176+ go func() { done <- srv.Serve(ctx, ln) }()
177+
178+ // A request in flight when shutdown starts is allowed to finish.
179+ type result struct {
180+ body string
181+ err error
182+ }
183+ res := make(chan result, 1)
184+ go func() {
185+ resp, err := http.Get("http://" + ln.Addr().String() + "/-/health")
186+ if err != nil {
187+ res <- result{err: err}
188+ return
189+ }
190+ defer func() { _ = resp.Body.Close() }()
191+ b, err := io.ReadAll(resp.Body)
192+ res <- result{body: string(b), err: err}
193+ }()
194+ <-started
195+ cancel()
196+ select {
197+ case err := <-done:
198+ t.Fatalf("Serve returned while a request was in flight: %v", err)
199+ case <-time.After(200 * time.Millisecond):
200+ }
201+ close(release)
202+
203+ r := <-res
204+ require.NoError(t, r.err)
205+ assert.Equal(t, "ok\n", r.body)
206+ select {
207+ case err := <-done:
208+ assert.NoError(t, err)
209+ case <-time.After(5 * time.Second):
210+ t.Fatal("Serve did not return after the request finished")
211+ }
212+ _, err = net.DialTimeout("tcp", ln.Addr().String(), 200*time.Millisecond)
213+ assert.Error(t, err, "the listener is closed")
214+}
215+
216+func TestRunListenError(t *testing.T) {
217+ ln, err := net.Listen("tcp", "127.0.0.1:0")
218+ require.NoError(t, err)
219+ defer func() { _ = ln.Close() }()
220+ err = httpserver.New(httpserver.Config{Addr: ln.Addr().String()}).Run(context.Background())
221+ require.Error(t, err)
222+ assert.True(t, strings.Contains(err.Error(), "listen on"), err.Error())
223+}
@@ -0,0 +1,223 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package httpserver_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "encoding/json"
9+ "errors"
10+ "io"
11+ "net"
12+ "net/http"
13+ "net/http/httptest"
14+ "os"
15+ "strings"
16+ "sync"
17+ "testing"
18+ "time"
19+
20+ "github.com/bnema/zerowrap"
21+ "github.com/stretchr/testify/assert"
22+ "github.com/stretchr/testify/require"
23+
24+ "bnema.dev/ermite/internal/adapters/httpserver"
25+)
26+
27+var securityHeaders = map[string]string{
28+ "Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'",
29+ "X-Frame-Options": "DENY",
30+ "X-Content-Type-Options": "nosniff",
31+ "Referrer-Policy": "same-origin",
32+}
33+
34+func do(t *testing.T, h http.Handler, method, target string) *httptest.ResponseRecorder {
35+ t.Helper()
36+ rec := httptest.NewRecorder()
37+ h.ServeHTTP(rec, httptest.NewRequest(method, target, nil))
38+ return rec
39+}
40+
41+func TestHealth(t *testing.T) {
42+ ok := httpserver.Check(func(context.Context) error { return nil })
43+ fail := httpserver.Check(func(context.Context) error { return errors.New("kv down: secret detail") })
44+
45+ tests := []struct {
46+ name string
47+ checks []httpserver.Check
48+ method string
49+ wantStatus int
50+ wantBody string
51+ }{
52+ {"no checks", nil, "GET", 200, "ok\n"},
53+ {"all pass", []httpserver.Check{ok, ok}, "GET", 200, "ok\n"},
54+ {"head", []httpserver.Check{ok}, "HEAD", 200, ""}, // the real server drops the body
55+ {"one fails", []httpserver.Check{ok, fail}, "GET", 503, "unavailable\n"},
56+ {"head fails", []httpserver.Check{fail}, "HEAD", 503, ""},
57+ {"post", []httpserver.Check{ok}, "POST", 405, ""},
58+ {"delete", []httpserver.Check{ok}, "DELETE", 405, ""},
59+ }
60+ for _, tt := range tests {
61+ t.Run(tt.name, func(t *testing.T) {
62+ h := httpserver.New(httpserver.Config{HealthChecks: tt.checks}).Handler()
63+ rec := do(t, h, tt.method, "/-/health")
64+ assert.Equal(t, tt.wantStatus, rec.Code)
65+ if tt.method == "GET" {
66+ assert.Equal(t, tt.wantBody, rec.Body.String())
67+ }
68+ assert.NotContains(t, rec.Body.String(), "secret detail", "the failure cause stays in the log")
69+ if tt.wantStatus == 405 {
70+ assert.Contains(t, rec.Header().Get("Allow"), "GET")
71+ }
72+ for k, v := range securityHeaders {
73+ assert.Equal(t, v, rec.Header().Get(k), "%s on %s", k, tt.name)
74+ }
75+ })
76+ }
77+}
78+
79+func TestHeadOverTheWire(t *testing.T) {
80+ ts := httptest.NewServer(httpserver.New(httpserver.Config{}).Handler())
81+ defer ts.Close()
82+ resp, err := http.Head(ts.URL + "/-/health")
83+ require.NoError(t, err)
84+ defer func() { _ = resp.Body.Close() }()
85+ assert.Equal(t, 200, resp.StatusCode)
86+ body, err := io.ReadAll(resp.Body)
87+ require.NoError(t, err)
88+ assert.Empty(t, body)
89+}
90+
91+func TestNotFoundCarriesSecurityHeaders(t *testing.T) {
92+ h := httpserver.New(httpserver.Config{}).Handler()
93+ for _, target := range []string{"/", "/nope", "/-/", "/-/health/x", "/-/healthz", "/some-repo.git/info/refs"} {
94+ rec := do(t, h, "GET", target)
95+ assert.Equal(t, 404, rec.Code, target)
96+ for k, v := range securityHeaders {
97+ assert.Equal(t, v, rec.Header().Get(k), "%s on %s", k, target)
98+ }
99+ }
100+}
101+
102+type syncBuffer struct {
103+ mu sync.Mutex
104+ b bytes.Buffer
105+}
106+
107+func (s *syncBuffer) Write(p []byte) (int, error) {
108+ s.mu.Lock()
109+ defer s.mu.Unlock()
110+ return s.b.Write(p)
111+}
112+
113+func (s *syncBuffer) String() string {
114+ s.mu.Lock()
115+ defer s.mu.Unlock()
116+ return s.b.String()
117+}
118+
119+func TestRequestLog(t *testing.T) {
120+ var buf syncBuffer
121+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &buf}))
122+ h := httpserver.New(httpserver.Config{}).Handler()
123+
124+ req := httptest.NewRequestWithContext(ctx, "GET", "/missing?token=hunter2", nil)
125+ req.Header.Set("Authorization", "Bearer hunter2")
126+ h.ServeHTTP(httptest.NewRecorder(), req)
127+
128+ var entry map[string]any
129+ require.NoError(t, json.Unmarshal(bytes.TrimSpace([]byte(buf.String())), &entry), buf.String())
130+ assert.Equal(t, "debug", entry["level"])
131+ assert.Equal(t, "GET", entry["method"])
132+ assert.Equal(t, "/missing", entry["path"])
133+ assert.EqualValues(t, 404, entry["status"])
134+ assert.Contains(t, entry, "duration")
135+ assert.NotContains(t, buf.String(), "hunter2")
136+
137+ // At info level a request logs nothing.
138+ var quiet syncBuffer
139+ ctx = zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &quiet}))
140+ h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequestWithContext(ctx, "GET", "/missing", nil))
141+ assert.Empty(t, quiet.String())
142+}
143+
144+func TestDirWritable(t *testing.T) {
145+ dir := t.TempDir()
146+ check := httpserver.DirWritable(dir)
147+ require.NoError(t, check(context.Background()))
148+ entries, err := os.ReadDir(dir)
149+ require.NoError(t, err)
150+ assert.Empty(t, entries, "the probe file is removed")
151+
152+ assert.Error(t, httpserver.DirWritable(dir+"/missing")(context.Background()))
153+
154+ if os.Geteuid() != 0 {
155+ require.NoError(t, os.Chmod(dir, 0o500))
156+ t.Cleanup(func() { _ = os.Chmod(dir, 0o700) })
157+ assert.Error(t, check(context.Background()))
158+ }
159+}
160+
161+func TestServeAndGracefulShutdown(t *testing.T) {
162+ ln, err := net.Listen("tcp", "127.0.0.1:0")
163+ require.NoError(t, err)
164+ ctx, cancel := context.WithCancel(context.Background())
165+ defer cancel()
166+
167+ started := make(chan struct{})
168+ release := make(chan struct{})
169+ slow := httpserver.Check(func(context.Context) error {
170+ close(started)
171+ <-release
172+ return nil
173+ })
174+ srv := httpserver.New(httpserver.Config{HealthChecks: []httpserver.Check{slow}})
175+ done := make(chan error, 1)
176+ go func() { done <- srv.Serve(ctx, ln) }()
177+
178+ // A request in flight when shutdown starts is allowed to finish.
179+ type result struct {
180+ body string
181+ err error
182+ }
183+ res := make(chan result, 1)
184+ go func() {
185+ resp, err := http.Get("http://" + ln.Addr().String() + "/-/health")
186+ if err != nil {
187+ res <- result{err: err}
188+ return
189+ }
190+ defer func() { _ = resp.Body.Close() }()
191+ b, err := io.ReadAll(resp.Body)
192+ res <- result{body: string(b), err: err}
193+ }()
194+ <-started
195+ cancel()
196+ select {
197+ case err := <-done:
198+ t.Fatalf("Serve returned while a request was in flight: %v", err)
199+ case <-time.After(200 * time.Millisecond):
200+ }
201+ close(release)
202+
203+ r := <-res
204+ require.NoError(t, r.err)
205+ assert.Equal(t, "ok\n", r.body)
206+ select {
207+ case err := <-done:
208+ assert.NoError(t, err)
209+ case <-time.After(5 * time.Second):
210+ t.Fatal("Serve did not return after the request finished")
211+ }
212+ _, err = net.DialTimeout("tcp", ln.Addr().String(), 200*time.Millisecond)
213+ assert.Error(t, err, "the listener is closed")
214+}
215+
216+func TestRunListenError(t *testing.T) {
217+ ln, err := net.Listen("tcp", "127.0.0.1:0")
218+ require.NoError(t, err)
219+ defer func() { _ = ln.Close() }()
220+ err = httpserver.New(httpserver.Config{Addr: ln.Addr().String()}).Run(context.Background())
221+ require.Error(t, err)
222+ assert.True(t, strings.Contains(err.Error(), "listen on"), err.Error())
223+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close