333 lines
9.0 KiB
Go
333 lines
9.0 KiB
Go
package httpx
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"runtime/debug"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Middleware wraps a handler. Chain applies them so that the first listed runs
|
|
// outermost.
|
|
type Middleware func(http.Handler) http.Handler
|
|
|
|
// Chain wraps h with mw, outermost first.
|
|
func Chain(h http.Handler, mw ...Middleware) http.Handler {
|
|
for i := len(mw) - 1; i >= 0; i-- {
|
|
h = mw[i](h)
|
|
}
|
|
return h
|
|
}
|
|
|
|
type ctxKey int
|
|
|
|
const (
|
|
ctxKeyRequestID ctxKey = iota
|
|
ctxKeyLogState
|
|
)
|
|
|
|
// ---------------------------------------------------------------- request id
|
|
|
|
// RequestIDFrom returns the request id assigned by WithRequestID.
|
|
func RequestIDFrom(ctx context.Context) string {
|
|
id, _ := ctx.Value(ctxKeyRequestID).(string)
|
|
return id
|
|
}
|
|
|
|
// WithRequestID assigns each request an id, echoes it in X-Request-Id, and makes
|
|
// it available to handlers and the access log.
|
|
//
|
|
// An inbound X-Request-Id is adopted only when the peer is a trusted proxy and
|
|
// the value is short and printable: it ends up in log records, and an arbitrary
|
|
// client-controlled string there is a log-forging primitive.
|
|
func WithRequestID(trusted []netip.Prefix) Middleware {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
id := ""
|
|
if in := r.Header.Get("X-Request-Id"); in != "" && sanitaryID(in) {
|
|
if addr, ok := peerAddr(r); ok && inAny(addr, trusted) {
|
|
id = in
|
|
}
|
|
}
|
|
if id == "" {
|
|
id = newID()
|
|
}
|
|
w.Header().Set("X-Request-Id", id)
|
|
ctx := context.WithValue(r.Context(), ctxKeyRequestID, id)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
}
|
|
|
|
func newID() string {
|
|
var b [8]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
// crypto/rand cannot fail on any supported platform; if it somehow does,
|
|
// an empty id is better than taking down the request.
|
|
return ""
|
|
}
|
|
return hex.EncodeToString(b[:])
|
|
}
|
|
|
|
func sanitaryID(s string) bool {
|
|
if len(s) == 0 || len(s) > 64 {
|
|
return false
|
|
}
|
|
for i := 0; i < len(s); i++ {
|
|
c := s[i]
|
|
if c < 0x21 || c > 0x7e {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// ------------------------------------------------------------- log enrichment
|
|
|
|
// logState collects attributes that handlers discover mid-request (project,
|
|
// deployment, key id) so the single access-log line can carry them.
|
|
type logState struct {
|
|
mu sync.Mutex
|
|
attrs []slog.Attr
|
|
}
|
|
|
|
// LogAttr attaches a key/value pair to this request's access-log line. It is a
|
|
// no-op outside the middleware chain, so handlers may call it unconditionally.
|
|
//
|
|
// Never pass a token, an Authorization header, or any part of either.
|
|
func LogAttr(ctx context.Context, key string, value any) {
|
|
st, ok := ctx.Value(ctxKeyLogState).(*logState)
|
|
if !ok {
|
|
return
|
|
}
|
|
st.mu.Lock()
|
|
st.attrs = append(st.attrs, slog.Any(key, value))
|
|
st.mu.Unlock()
|
|
}
|
|
|
|
// -------------------------------------------------------------- access logger
|
|
|
|
// AccessLog emits exactly one record per request.
|
|
//
|
|
// It logs the method, the URL path, and nothing else from the request: headers
|
|
// are never logged (Authorization carries a bearer token) and neither is the raw
|
|
// query string. Handlers add their own context with LogAttr.
|
|
func AccessLog(log *slog.Logger, trusted []netip.Prefix) Middleware {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
st := &logState{}
|
|
ctx := context.WithValue(r.Context(), ctxKeyLogState, st)
|
|
r = r.WithContext(ctx)
|
|
|
|
rec := &recorder{ResponseWriter: w, status: http.StatusOK}
|
|
start := time.Now()
|
|
next.ServeHTTP(rec, r)
|
|
dur := time.Since(start)
|
|
|
|
level := slog.LevelInfo
|
|
switch {
|
|
case rec.status >= 500:
|
|
level = slog.LevelError
|
|
case rec.status == http.StatusNotFound || rec.status == http.StatusMethodNotAllowed:
|
|
// Ordinary outcomes on a public listener: a crawler, a stale link,
|
|
// a missing favicon. Logging them as warnings would make warnings
|
|
// the bulk of the file and hide the ones that mean something.
|
|
level = slog.LevelInfo
|
|
case rec.status >= 400:
|
|
level = slog.LevelWarn
|
|
case r.URL.Path == "/healthz" || r.URL.Path == "/readyz":
|
|
level = slog.LevelDebug
|
|
}
|
|
if !log.Enabled(ctx, level) {
|
|
return
|
|
}
|
|
|
|
st.mu.Lock()
|
|
extra := st.attrs
|
|
st.mu.Unlock()
|
|
|
|
attrs := make([]slog.Attr, 0, 8+len(extra))
|
|
attrs = append(attrs,
|
|
slog.String("method", r.Method),
|
|
slog.String("path", r.URL.Path),
|
|
slog.Int("status", rec.status),
|
|
slog.Int64("bytes", rec.written),
|
|
slog.Float64("dur_ms", float64(dur.Microseconds())/1000),
|
|
)
|
|
if ip, ok := ClientIP(r, trusted); ok {
|
|
attrs = append(attrs, slog.String("ip", ip.String()))
|
|
}
|
|
if id := RequestIDFrom(ctx); id != "" {
|
|
attrs = append(attrs, slog.String("req_id", id))
|
|
}
|
|
attrs = append(attrs, extra...)
|
|
log.LogAttrs(ctx, level, "request", attrs...)
|
|
})
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------ recovery
|
|
|
|
// Recover turns a handler panic into a 500 instead of tearing down the process,
|
|
// logging the stack. The panic value itself never reaches the client.
|
|
func Recover(log *slog.Logger) Middleware {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
v := recover()
|
|
if v == nil {
|
|
return
|
|
}
|
|
// ErrAbortHandler is the documented way to abort a response;
|
|
// net/http expects to handle it and logs nothing.
|
|
if err, ok := v.(error); ok && errors.Is(err, http.ErrAbortHandler) {
|
|
panic(v)
|
|
}
|
|
log.ErrorContext(r.Context(), "handler panic",
|
|
"panic", v,
|
|
"method", r.Method,
|
|
"path", r.URL.Path,
|
|
"req_id", RequestIDFrom(r.Context()),
|
|
"stack", string(debug.Stack()),
|
|
)
|
|
if rec, ok := w.(*recorder); ok && rec.wroteHeader {
|
|
return // response already begun; nothing safe left to send
|
|
}
|
|
WriteJSON(w, http.StatusInternalServerError,
|
|
newInternalEnvelope(RequestIDFrom(r.Context())))
|
|
}()
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- client IP
|
|
|
|
// ClientIP returns the address to attribute the request to. X-Forwarded-For is
|
|
// honoured only when the direct peer is itself a trusted proxy; otherwise any
|
|
// client could forge its own source address and defeat per-IP rate limiting.
|
|
func ClientIP(r *http.Request, trusted []netip.Prefix) (netip.Addr, bool) {
|
|
peer, ok := peerAddr(r)
|
|
if !ok {
|
|
return netip.Addr{}, false
|
|
}
|
|
if !inAny(peer, trusted) {
|
|
return peer, true
|
|
}
|
|
// Walk right to left and take the first address that is not itself trusted:
|
|
// everything to its right was appended by infrastructure we control, and
|
|
// everything to its left may have been forged by the client.
|
|
xff := r.Header.Values("X-Forwarded-For")
|
|
for i := len(xff) - 1; i >= 0; i-- {
|
|
parts := strings.Split(xff[i], ",")
|
|
for j := len(parts) - 1; j >= 0; j-- {
|
|
addr, err := netip.ParseAddr(strings.TrimSpace(parts[j]))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
addr = addr.Unmap()
|
|
if !inAny(addr, trusted) {
|
|
return addr, true
|
|
}
|
|
}
|
|
}
|
|
return peer, true
|
|
}
|
|
|
|
func peerAddr(r *http.Request) (netip.Addr, bool) {
|
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
host = r.RemoteAddr
|
|
}
|
|
addr, err := netip.ParseAddr(host)
|
|
if err != nil {
|
|
return netip.Addr{}, false
|
|
}
|
|
return addr.Unmap(), true
|
|
}
|
|
|
|
func inAny(addr netip.Addr, prefixes []netip.Prefix) bool {
|
|
for _, p := range prefixes {
|
|
if p.Contains(addr) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ------------------------------------------------------------ response record
|
|
|
|
// recorder observes the status and byte count without altering behaviour.
|
|
//
|
|
// It implements Unwrap, ReadFrom, Flush and Hijack so that wrapping costs
|
|
// nothing: without ReadFrom the static handler would lose the sendfile fast
|
|
// path that io.Copy takes when the destination is the raw *http.response.
|
|
type recorder struct {
|
|
http.ResponseWriter
|
|
status int
|
|
written int64
|
|
wroteHeader bool
|
|
}
|
|
|
|
func (r *recorder) WriteHeader(status int) {
|
|
if r.wroteHeader {
|
|
return
|
|
}
|
|
r.status = status
|
|
r.wroteHeader = true
|
|
r.ResponseWriter.WriteHeader(status)
|
|
}
|
|
|
|
func (r *recorder) Write(b []byte) (int, error) {
|
|
if !r.wroteHeader {
|
|
r.WriteHeader(http.StatusOK)
|
|
}
|
|
n, err := r.ResponseWriter.Write(b)
|
|
r.written += int64(n)
|
|
return n, err
|
|
}
|
|
|
|
func (r *recorder) ReadFrom(src io.Reader) (int64, error) {
|
|
if !r.wroteHeader {
|
|
r.WriteHeader(http.StatusOK)
|
|
}
|
|
rf, ok := r.ResponseWriter.(io.ReaderFrom)
|
|
if !ok {
|
|
n, err := io.Copy(r.ResponseWriter, src)
|
|
r.written += n
|
|
return n, err
|
|
}
|
|
n, err := rf.ReadFrom(src)
|
|
r.written += n
|
|
return n, err
|
|
}
|
|
|
|
func (r *recorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }
|
|
|
|
func (r *recorder) Flush() {
|
|
if f, ok := r.ResponseWriter.(http.Flusher); ok {
|
|
if !r.wroteHeader {
|
|
r.WriteHeader(http.StatusOK)
|
|
}
|
|
f.Flush()
|
|
}
|
|
}
|
|
|
|
func (r *recorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
|
h, ok := r.ResponseWriter.(http.Hijacker)
|
|
if !ok {
|
|
return nil, nil, errors.New("httpx: ResponseWriter does not support hijacking")
|
|
}
|
|
return h.Hijack()
|
|
}
|