coverage~bigbes/sr-ht-ecore3bd158fbmiddleware/middleware.go

Coverage
83.7% 36/43 statements
Δ
Blob
2ab0c94
1 // Package middleware is the shared HTTP middleware for the custom services of a
2 // self-hosted SourceHut instance (compare, spec, dolt, cover, bench, ...).
3 //
4 // Three things every one of those services needs, and every one of them had
5 // copied byte for byte into its own web/router.go: the cache policy that keeps a
6 // page rendered behind a login cookie out of every cache that is not the
7 // viewer's own, the panic guard that turns a bug in a handler into the service's
8 // own error page instead of a dropped connection, and the 499 that separates a
9 // viewer who closed the tab from a server that broke. The copies were identical
10 // down to their comments, which is the sign that they were never three
11 // decisions — they were one, made once and pasted twice. This package is the one
12 // copy.
13 //
14 // Usage, in the order the donors install them:
15 //
16 // r.Use(middleware.RecoverPanics(func(w http.ResponseWriter, r *http.Request, recovered any) {
17 // s.renderError(w, r, http.StatusInternalServerError, internalMessage)
18 // }))
19 // r.Use(middleware.PrivateCache)
20 //
21 // RecoverPanics goes outermost so that it covers every later middleware as well
22 // as the handlers; PrivateCache goes inside it so that the error page it renders
23 // carries the same headers as any other answer.
24 //
25 // What deliberately did not move here is the donors' getHead helper. It is three
26 // lines, and all three are chi's — it takes a chi.Router and calls Get and Head
27 // on it — so hoisting it would put a router dependency in a package whose whole
28 // point is that it needs nothing but net/http. It lives in the sibling package
29 // chimw instead, which is allowed the router dependency because knowing what a
30 // route is is what that package is for.
31 //
32 // Panics are reported through slog's default logger rather than a logger this
33 // package is handed. A library has no business choosing a handler: the service
34 // installs its own — scribe's tinted one on this instance — with
35 // slog.SetDefault at startup, and everything logged here lands in the same
36 // stream, with the same masking rules, as the service's own lines. Handing a
37 // *slog.Logger to RecoverPanics would buy configurability nobody wants and cost
38 // every caller a parameter.
39 package middleware
40
41 import (
42 "bufio"
43 "errors"
44 "io"
45 "log/slog"
46 "net"
47 "net/http"
48 "runtime/debug"
49 )
50
51 // StatusClientClosedRequest is nginx's 499: the caller went away — hung up, or
52 // ran out of its own deadline — before the answer was written.
53 //
54 // No RFC defines it, and that costs nothing, because the one certain thing about
55 // this response is that nobody reads it: the context it reports on is the
56 // request's own, and it ended before there was anything to send. So the code is
57 // chosen for the operator rather than for the client. What matters is that it is
58 // not in the 5xx range — that is the rate an alert is written against, and a
59 // browser navigating away mid-render, or a CI job that pressed ^C, must not page
60 // anybody — and 499 is the value the log pipelines in front of a SourceHut
61 // instance already understand, because the nginx that terminates TLS for one has
62 // been writing it for this exact event since long before any of these services
63 // existed.
64 //
65 // The alternatives are each worse in their own way. 500 is a false statement
66 // about the instance: nothing broke, and whoever is woken by it finds a healthy
67 // service. 408 is standard but means the other half of "nobody finished" — the
68 // server gave up waiting for a body still arriving, which is the server's
69 // problem and is safe to retry — and several clients do retry it automatically,
70 // which is the last thing to tell a caller that cancelled on purpose. 504 names
71 // a gateway timing out upstream, and there is no upstream here.
72 //
73 // It belongs in a middleware package rather than next to one service's status
74 // mapping because both surfaces of every service reach for it: the HTML side
75 // when a render's context is already cancelled, the API side in its
76 // error-to-status switch.
77 const StatusClientClosedRequest = 499
78
79 // PrivateCache marks every answer of a surface as one no cache may reuse for
80 // another viewer.
81 //
82 // These services render per-viewer documents at URLs that say nothing about the
83 // viewer: a token list, a repository page that is a page to its owner and a 404
84 // to everyone else, a dashboard of somebody's own runs. A cache with no
85 // instruction treats a 200 to a GET as reusable, so one proxy, one CDN or one
86 // browser on a shared machine is all it takes for somebody to be served another
87 // account's page — a disclosure arriving by a route no visibility check can
88 // stand in front of.
89 //
90 // The policy is "private, no-store" and not merely "no-cache" because the two
91 // answer different questions. no-cache still permits a *stored* copy, and only
92 // requires it to be revalidated before reuse; the copy sits in the shared proxy
93 // and on the disk of the shared machine either way, and a revalidation carries
94 // the next viewer's cookie, not the one the page was rendered for. private bars
95 // the shared caches from keeping it at all, no-store bars the private ones from
96 // writing it down, and for a page whose most sensitive form contains a live
97 // credential in plaintext, "do not write this to disk" is the instruction that
98 // was actually meant.
99 //
100 // Vary names Cookie and Authorization for the same reason: they are the two
101 // inputs that decide who the page is for, so any cache that does keep something
102 // must at least not hand it to a request that presented different ones.
103 //
104 // It is a middleware and not a line in the render path because render is not the
105 // only writer: /healthz is text/plain, static assets are bytes, a badge is an
106 // image, and a header this important must not depend on which write path a
107 // future page picks. The headers are set before the handler runs, so a handler
108 // that needs different ones — the static handler, which serves immutable
109 // hashed-name assets — overrides them with Set.
110 2 func PrivateCache(next http.Handler) http.Handler {
111 2 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
112 2 SetPrivateCache(w)
113 2 next.ServeHTTP(w, r)
114 2 })
115 }
116
117 // SetPrivateCache writes the two headers PrivateCache exists for.
118 //
119 // It is split out because a service also answers from places the router's
120 // middleware chain does not reach: the deny path called by the authentication
121 // middleware in front of the whole mount, before the router has seen the request
122 // at all, is the donors' example. Those answers are as viewer-specific as any
123 // page and must not be cached either.
124 3 func SetPrivateCache(w http.ResponseWriter) {
125 3 w.Header().Set("Cache-Control", "private, no-store")
126 3 w.Header().Set("Vary", "Cookie, Authorization")
127 3 }
128
129 // RecoverPanics turns a panicking handler into the error page the service would
130 // have written for any other bug.
131 //
132 // Without it a panic reaches net/http, which logs it and closes the connection
133 // without a response: the viewer sees a browser error page, not the service's,
134 // and an operator sees a stack with no request context around it. It is this and
135 // not chi's middleware.Recoverer because that one answers with plain text, and
136 // these surfaces answer with pages.
137 //
138 // render is the seam. Each service renders its own 500 — its own chrome, its own
139 // message, its own template set — so the middleware cannot write the page, only
140 // decide when one is owed. The callback takes the recovered value so that a
141 // service which wants to classify the panic can, and a service which does not
142 // ignores the parameter; a renderer with the donors' (w, r, status, message)
143 // shape is passed as a one-line closure rather than being reshaped:
144 //
145 // middleware.RecoverPanics(func(w http.ResponseWriter, r *http.Request, _ any) {
146 // s.renderError(w, r, http.StatusInternalServerError, internalMessage)
147 // })
148 //
149 // A nil render is a wiring mistake and panics here, at construction, rather than
150 // at 3am inside a deferred function where the only thing left to do about it is
151 // drop the connection.
152 //
153 // The panic value and the stack are logged here and never handed to the viewer —
154 // an error from below names tables, queries and paths. Logging is the
155 // middleware's job and not the callback's because the stack is only reachable
156 // from inside the deferred function that recovered; a service left to log it
157 // would sooner or later log the value alone, and a panic without a stack is a
158 // bug report with the address torn off.
159 //
160 // Two panics are not this middleware's to answer.
161 //
162 // http.ErrAbortHandler is re-panicked, because the standard library defines it
163 // as "this handler is giving up on this connection on purpose": net/http expects
164 // to see it, drops the connection silently and logs nothing. Answering it with a
165 // page would resurrect a response somebody deliberately abandoned.
166 //
167 // A panic that happens *after* the response has started is answered by dropping
168 // the connection, not by rendering. This is where this package parts with its
169 // donors, which called their error renderer unconditionally: with bytes already
170 // on the wire that write is a superfluous WriteHeader the standard library logs
171 // and ignores, followed by an error page appended to the middle of a truncated
172 // one — a body that is neither document, with a 200 status line in front of it
173 // claiming both are fine. Panicking with http.ErrAbortHandler instead makes the
174 // failure legible: the connection dies mid-body, the client sees a short read
175 // against the Content-Length it was promised (or a chunked stream with no
176 // terminator) and reports a failed transfer, which is what happened. Detecting
177 // this is what the response writer is wrapped for.
178 9 func RecoverPanics(render func(w http.ResponseWriter, r *http.Request, recovered any)) func(http.Handler) http.Handler {
179 9 if render == nil {
180 1 panic("middleware: RecoverPanics needs a render callback")
181 }
182 8 return func(next http.Handler) http.Handler {
183 8 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
184 8 tracked := &startTracker{ResponseWriter: w}
185 8 defer func() {
186 8 recovered := recover()
187 8 if recovered == nil {
188 1 return
189 1 }
190 7 if err, ok := recovered.(error); ok && errors.Is(err, http.ErrAbortHandler) {
191 1 panic(recovered)
192 }
193 6 slog.ErrorContext(r.Context(), "panic serving a request",
194 6 "method", r.Method,
195 6 "path", r.URL.Path,
196 6 "panic", recovered,
197 6 "stack", string(debug.Stack()))
198 6 if tracked.started {
199 2 // Half a page is already out. There is no status line left
200 2 // to send and nothing useful to append; abandon the
201 2 // connection instead of corrupting the body further.
202 2 panic(http.ErrAbortHandler)
203 }
204 4 renderOnce(render, tracked, r, recovered)
205 }()
206 8 next.ServeHTTP(tracked, r)
207 })
208 }
209 }
210
211 // renderOnce calls render under a guard of its own, so that a panic while
212 // rendering the error page cannot be fed back into the path that renders error
213 // pages.
214 //
215 // The second failure is logged and the connection dropped. It is deliberately
216 // not a second attempt at a page: whatever is broken — a template, the chrome,
217 // the store the chrome reads a username from — is exactly what the retry would
218 // use, and the third try would be no different from the second. One log line
219 // naming both panics is what an operator needs; a loop is what they would
220 // otherwise get.
221 func renderOnce(
222 render func(w http.ResponseWriter, r *http.Request, recovered any),
223 w http.ResponseWriter,
224 r *http.Request,
225 recovered any,
226 4 ) {
227 4 defer func() {
228 4 second := recover()
229 4 if second == nil {
230 2 return
231 2 }
232 2 if err, ok := second.(error); ok && errors.Is(err, http.ErrAbortHandler) {
233 1 panic(second)
234 }
235 1 slog.ErrorContext(r.Context(), "panic rendering the error page",
236 1 "method", r.Method,
237 1 "path", r.URL.Path,
238 1 "panic", second,
239 1 "original_panic", recovered,
240 1 "stack", string(debug.Stack()))
241 1 panic(http.ErrAbortHandler)
242 }()
243 4 render(w, r, recovered)
244 }
245
246 // startTracker records whether anything has reached the wire yet.
247 //
248 // "The response has started" is the one fact RecoverPanics needs and net/http
249 // does not expose: by the time a panic is recovered, the only way to know
250 // whether a status line has already gone out is to have watched for it. Every
251 // method that commits the response — an explicit WriteHeader, the implicit one
252 // inside the first Write, a Flush, a Hijack — sets the flag before delegating.
253 //
254 // Wrapping a ResponseWriter costs the concrete type behind it, so the methods a
255 // handler may reasonably reach for are carried across:
256 //
257 // - Unwrap is the net/http convention (Go 1.20+) that lets an
258 // http.ResponseController find the real writer, which is how deadlines and
259 // flushes are meant to be reached through wrappers like this one.
260 // - Flush and Hijack are implemented directly as well, because plenty of code
261 // still type-asserts for http.Flusher and http.Hijacker rather than going
262 // through the controller. They delegate through the controller, which
263 // returns http.ErrNotSupported if the writer underneath genuinely cannot do
264 // it — the same outcome as the assertion having failed, minus the silence.
265 // - ReadFrom keeps http.ServeContent and io.Copy on the fast path: net/http's
266 // own writer implements io.ReaderFrom, and losing it would turn every static
267 // asset into a buffered copy loop.
268 type startTracker struct {
269 http.ResponseWriter
270 started bool
271 }
272
273 4 func (t *startTracker) WriteHeader(status int) {
274 4 t.started = true
275 4 t.ResponseWriter.WriteHeader(status)
276 4 }
277
278 4 func (t *startTracker) Write(b []byte) (int, error) {
279 4 t.started = true
280 4 return t.ResponseWriter.Write(b)
281 4 }
282
283 // Unwrap gives http.ResponseController the writer this one wraps.
284 0 func (t *startTracker) Unwrap() http.ResponseWriter {
285 0 return t.ResponseWriter
286 0 }
287
288 // Flush commits whatever is buffered, which starts the response.
289 1 func (t *startTracker) Flush() {
290 1 t.started = true
291 1 // The error is the writer saying it cannot flush, which is what an
292 1 // unsatisfied http.Flusher assertion would have said by not existing.
293 1 _ = http.NewResponseController(t.ResponseWriter).Flush()
294 1 }
295
296 // Hijack hands the connection to the caller, after which nothing here can write
297 // a status line — so the response counts as started.
298 0 func (t *startTracker) Hijack() (net.Conn, *bufio.ReadWriter, error) {
299 0 t.started = true
300 0 return http.NewResponseController(t.ResponseWriter).Hijack()
301 0 }
302
303 // ReadFrom preserves the io.ReaderFrom fast path of the writer underneath.
304 0 func (t *startTracker) ReadFrom(src io.Reader) (int64, error) {
305 0 t.started = true
306 0 if rf, ok := t.ResponseWriter.(io.ReaderFrom); ok {
307 0 return rf.ReadFrom(src)
308 0 }
309 // Copy to the wrapped writer and not to t, or this is a recursion.
310 0 return io.Copy(t.ResponseWriter, src)
311 }