init
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user