add realip, requestlog, recoverer middlewares

Initial implementation of lightmux-contrib, a sibling module to
lightmux that hosts opinionated middlewares with one sub-package per
middleware:

- realip: resolves the originating client IP from CF-Connecting-IP,
  True-Client-IP, X-Real-IP, or X-Forwarded-For. Optional peer-CIDR
  allowlist via netip.Prefix.
- requestlog: emits a structured http.request record (method, path,
  status, duration, client) per request via splinter.
- recoverer: catches panics, wraps with errx under op "recoverer",
  logs with stack, and writes a 500 response.

Each package exposes a single New(...) constructor returning
func(http.Handler) http.Handler. The contrib module intentionally
does not import lightmux — middlewares interoperate via the standard
stdlib middleware shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
juancwu 2026-04-26 14:03:04 +00:00
commit b26ef7439e
10 changed files with 624 additions and 0 deletions

61
requestlog/requestlog.go Normal file
View file

@ -0,0 +1,61 @@
// Package requestlog emits a structured "http.request" record per request
// (method, path, status, duration, client) via splinter.
package requestlog
import (
"net/http"
"time"
"git.juancwu.dev/juancwu/splinter"
)
type statusRecorder struct {
http.ResponseWriter
status int
wrote bool
}
func (s *statusRecorder) WriteHeader(code int) {
if !s.wrote {
s.status = code
s.wrote = true
}
s.ResponseWriter.WriteHeader(code)
}
func (s *statusRecorder) Write(b []byte) (int, error) {
if !s.wrote {
s.status = http.StatusOK
s.wrote = true
}
return s.ResponseWriter.Write(b)
}
// New returns a request-logging middleware. Pass nil to use splinter.Default()
// resolved at request time; otherwise records flow through the supplied logger.
func New(l *splinter.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
if l == nil {
splinter.Info("http.request",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"duration", time.Since(start),
"client", r.RemoteAddr,
)
return
}
l.Info("http.request",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"duration", time.Since(start),
"client", r.RemoteAddr,
)
})
}
}

View file

@ -0,0 +1,92 @@
package requestlog
import (
"bytes"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.juancwu.dev/juancwu/splinter"
)
func captureSplinter(t *testing.T) *bytes.Buffer {
t.Helper()
var buf bytes.Buffer
logger := splinter.New(splinter.WithStream(splinter.NewConsoleStream(
splinter.ConsoleJSON,
splinter.LevelDebug,
splinter.ConsoleWriter(&buf),
)))
prev := splinter.SetDefault(logger)
t.Cleanup(func() { splinter.SetDefault(prev) })
return &buf
}
func TestNew(t *testing.T) {
buf := captureSplinter(t)
h := New(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTeapot)
}))
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/foo", nil))
if rr.Code != http.StatusTeapot {
t.Errorf("status code = %d, want 418", rr.Code)
}
out := buf.String()
for _, want := range []string{`"method":"GET"`, `"path":"/foo"`, `"status":418`, `"client":"192.0.2.1:1234"`} {
if !strings.Contains(out, want) {
t.Errorf("log output missing %s\nfull output: %s", want, out)
}
}
}
func TestNewDefaultStatusOK(t *testing.T) {
buf := captureSplinter(t)
h := New(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("hi"))
}))
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil))
if !strings.Contains(buf.String(), `"status":200`) {
t.Errorf("expected default status 200 in log, got %q", buf.String())
}
}
func TestNewWithCustomLogger(t *testing.T) {
defaultBuf := captureSplinter(t)
var customBuf bytes.Buffer
custom := splinter.New(splinter.WithStream(splinter.NewConsoleStream(
splinter.ConsoleJSON,
splinter.LevelDebug,
splinter.ConsoleWriter(&customBuf),
)))
h := New(custom)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusAccepted)
}))
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/x", nil))
if !strings.Contains(customBuf.String(), `"path":"/x"`) {
t.Errorf("custom logger did not receive record: %q", customBuf.String())
}
if defaultBuf.Len() != 0 {
t.Errorf("default logger should not have been written to, got: %q", defaultBuf.String())
}
}
func TestNewNilFallsBackToDefault(t *testing.T) {
buf := captureSplinter(t)
h := New(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/y", nil))
if !strings.Contains(buf.String(), `"path":"/y"`) {
t.Errorf("nil logger should fall back to splinter.Default(): %q", buf.String())
}
}