[proxy] Reverse-proxy middleware framework, chain, and request plumbing

The per-target middleware chain (slots, dispatcher, mutation gate, metadata
merger), body capture, access-log terminal sink, and the proxy wiring that
builds + runs chains for synthesized agent-network services.
This commit is contained in:
mlsmaycon
2026-06-27 00:43:35 +02:00
parent 6613d194ef
commit d4d158a8f3
33 changed files with 4790 additions and 52 deletions
+63
View File
@@ -0,0 +1,63 @@
package middleware
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
)
// ErrExpectContinue is returned when a middleware attempts to replace
// the body of a request that advertised Expect: 100-continue.
var ErrExpectContinue = errors.New("body replace rejected: request has Expect: 100-continue")
// ErrOriginalNotDrained is returned when the original body was not
// fully consumed before replacement. This prevents the backend from
// seeing a mix of original bytes and the replacement.
var ErrOriginalNotDrained = errors.New("body replace rejected: original body not drained")
// ErrContentLengthMismatch is returned when the client-advertised
// Content-Length disagrees with the number of bytes actually read from
// the body (short-read).
var ErrContentLengthMismatch = errors.New("body replace rejected: content-length mismatch (short read)")
// ValidateBodyReplace runs the smuggling-prevention rules before a
// body replacement is applied. Callers must pass originalDrained=true
// once they have read r.Body to EOF.
func ValidateBodyReplace(r *http.Request, newBody []byte, originalDrained bool) error {
if r == nil {
return errors.New("body replace rejected: nil request")
}
if strings.EqualFold(r.Header.Get("Expect"), "100-continue") {
return ErrExpectContinue
}
if !originalDrained {
return ErrOriginalNotDrained
}
if cl := r.Header.Get("Content-Length"); cl != "" && r.ContentLength > 0 {
parsed, err := strconv.ParseInt(cl, 10, 64)
if err == nil && parsed != r.ContentLength {
return fmt.Errorf("%w: header=%d actual=%d", ErrContentLengthMismatch, parsed, r.ContentLength)
}
}
return nil
}
// ApplyBodyReplace swaps r.Body for a reader over newBody, recomputes
// Content-Length, and strips Transfer-Encoding and Trailer so no stale
// framing reaches the backend.
func ApplyBodyReplace(r *http.Request, newBody []byte) {
if r == nil {
return
}
r.Body = io.NopCloser(bytes.NewReader(newBody))
r.ContentLength = int64(len(newBody))
r.Header.Set("Content-Length", strconv.Itoa(len(newBody)))
r.Header.Del("Transfer-Encoding")
r.Header.Del("Trailer")
r.TransferEncoding = nil
r.Trailer = nil
}
@@ -0,0 +1,344 @@
// Package bodytap owns the framework-side body capture used by the
// middleware chain. Request capture buffers up to N bytes of the
// request body for middleware inspection while replaying the original
// stream to the upstream. Response capture tees up to N bytes off the
// streaming response while every byte continues to flow to the client
// untouched.
//
// The package is the single owner of body access — middlewares never
// read req.Body or hijack the response writer. All inspection happens
// against the buffer surfaced by the tap, so streaming remains
// transparent to the client even when middlewares need access to the
// payload.
package bodytap
import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"sync"
)
// MaxRoutingScanBytes bounds how far ScanRoutingFields will read into a
// request body to recover routing fields when the normal capture is
// bypassed for size. Sized to comfortably hold a 1M-token context
// request (whose `model` field a client may place after a multi-MB
// `messages` array) while still capping pathological inputs.
const MaxRoutingScanBytes int64 = 32 << 20
// Request bypass reasons emitted as the `mw.capture.bypass_reason`
// metadata key by the chain when a request body is not surfaced.
const (
BypassUpgradeHeader = "upgrade_header"
BypassConnectionUpgrd = "connection_upgrade"
BypassContentType = "content_type_not_allowed"
BypassBudget = "capture_budget_exhausted"
BypassNoConfig = "no_capture_config"
BypassNoMiddlewares = "no_middlewares"
BypassCapZero = "cap_zero"
BypassContentLengthCap = "content_length_over_cap"
)
// DefaultCaptureBudgetBytes is the default global capture-budget size.
const DefaultCaptureBudgetBytes int64 = 256 << 20
// Config holds per-target body capture limits after clamp validation.
// A zero MaxRequestBytes / MaxResponseBytes disables capture in that
// direction.
type Config struct {
MaxRequestBytes int64
MaxResponseBytes int64
ContentTypes []string
}
// Budget is the global token-bucket semaphore shared across all
// in-flight captures so a single misbehaving target cannot exhaust the
// proxy.
type Budget interface {
Acquire(n int64) bool
Release(n int64)
}
// NewBudget returns a Budget with the given total byte cap. A zero or
// negative total disables the budget check.
func NewBudget(total int64) Budget {
return &budget{total: total}
}
type budget struct {
mu sync.Mutex
used int64
total int64
}
func (b *budget) Acquire(n int64) bool {
if n <= 0 {
return true
}
b.mu.Lock()
defer b.mu.Unlock()
if b.total <= 0 {
return true
}
if b.used+n > b.total {
return false
}
b.used += n
return true
}
func (b *budget) Release(n int64) {
if n <= 0 {
return
}
b.mu.Lock()
defer b.mu.Unlock()
if b.total <= 0 {
return
}
b.used -= n
if b.used < 0 {
b.used = 0
}
}
// CaptureRequest reads up to cfg.MaxRequestBytes from r.Body into a
// buffer suitable for middleware inspection, replacing r.Body with a
// replay reader so the upstream still sees the original bytes. When
// bypass != "" no body is read and r.Body is left untouched. The
// returned release function must be invoked once the request is fully
// processed; it returns the acquired budget tokens to the shared pool.
// release is always non-nil and is safe to defer immediately after the
// call.
func CaptureRequest(r *http.Request, cfg *Config, b Budget) (body []byte, truncated bool, originalSize int64, bypass string, release func(), err error) {
release = func() {}
if r == nil {
return nil, false, 0, BypassNoConfig, release, nil
}
if cfg == nil {
return nil, false, 0, BypassNoConfig, release, nil
}
if cfg.MaxRequestBytes <= 0 {
return nil, false, 0, BypassCapZero, release, nil
}
if r.Header.Get("Upgrade") != "" {
return nil, false, 0, BypassUpgradeHeader, release, nil
}
if strings.EqualFold(r.Header.Get("Connection"), "upgrade") {
return nil, false, 0, BypassConnectionUpgrd, release, nil
}
if !contentTypeAllowed(r.Header.Get("Content-Type"), cfg.ContentTypes) {
return nil, false, 0, BypassContentType, release, nil
}
originalSize = parseContentLength(r.Header.Get("Content-Length"))
if originalSize > cfg.MaxRequestBytes {
return nil, true, originalSize, BypassContentLengthCap, release, nil
}
limit := cfg.MaxRequestBytes
if b != nil && !b.Acquire(limit) {
return nil, false, originalSize, BypassBudget, release, nil
}
if b != nil {
var released sync.Once
release = func() {
released.Do(func() { b.Release(limit) })
}
}
if r.Body == nil || r.Body == http.NoBody {
release()
release = func() {}
return nil, false, originalSize, "", release, nil
}
limited := io.LimitReader(r.Body, limit+1)
buf, readErr := io.ReadAll(limited)
if readErr != nil && !errors.Is(readErr, io.EOF) {
release()
release = func() {}
return nil, false, originalSize, "", release, readErr
}
truncated = int64(len(buf)) > limit
if truncated {
replay := append([]byte(nil), buf...)
viewable := buf[:limit]
r.Body = &replayReadCloser{replay: bytes.NewReader(replay), tail: r.Body}
return viewable, true, originalSize, "", release, nil
}
_ = r.Body.Close()
r.Body = io.NopCloser(bytes.NewReader(buf))
if originalSize <= 0 {
originalSize = int64(len(buf))
}
return buf, false, originalSize, "", release, nil
}
// replayReadCloser replays the captured prefix and then forwards the
// remaining bytes from the original body so the upstream sees the
// full request stream even when capture truncates.
type replayReadCloser struct {
replay *bytes.Reader
tail io.ReadCloser
drained bool
}
func (r *replayReadCloser) Read(p []byte) (int, error) {
if !r.drained {
n, err := r.replay.Read(p)
if n > 0 {
return n, nil
}
if errors.Is(err, io.EOF) {
r.drained = true
} else if err != nil {
return 0, err
}
}
return r.tail.Read(p)
}
func (r *replayReadCloser) Close() error {
return r.tail.Close()
}
// ScanRoutingFields recovers the LLM routing fields ("model" and
// "stream") from a request whose normal capture was bypassed or
// truncated for size. It reads up to maxScan bytes of r.Body to locate
// the top-level keys — clients (e.g. Claude Code) may place `model`
// after a multi-MB `messages` array — then restores r.Body so the
// upstream still receives the full, untouched stream. Only the small
// routing fields are extracted; the prompt is never buffered for
// capture, keeping memory bounded. Returns ok=false when the body isn't
// a JSON object, the model field isn't found within maxScan, or on a
// read error.
func ScanRoutingFields(r *http.Request, maxScan int64) (model string, stream bool, ok bool) {
if r == nil || r.Body == nil || r.Body == http.NoBody || maxScan <= 0 {
return "", false, false
}
limited := io.LimitReader(r.Body, maxScan+1)
buf, readErr := io.ReadAll(limited)
if readErr != nil && !errors.Is(readErr, io.EOF) {
// Mid-stream read error (e.g. client disconnect): restore the bytes
// read so far plus the untouched tail and abort, rather than
// forwarding only the partial prefix as if it were the whole body.
r.Body = &replayReadCloser{replay: bytes.NewReader(append([]byte(nil), buf...)), tail: r.Body}
return "", false, false
}
if int64(len(buf)) > maxScan {
// Body exceeds the scan ceiling: restore the read prefix plus the
// untouched tail so the upstream still gets every byte.
r.Body = &replayReadCloser{replay: bytes.NewReader(append([]byte(nil), buf...)), tail: r.Body}
} else {
_ = r.Body.Close()
r.Body = io.NopCloser(bytes.NewReader(buf))
}
return scanTopLevelModelStream(buf)
}
// scanTopLevelModelStream walks the top level of a JSON object via a
// streaming token reader, extracting the "model" string and "stream"
// bool without materialising large values (each non-target value is
// skipped as a RawMessage). Tolerant of truncation: returns whatever was
// found before a malformed/short tail.
func scanTopLevelModelStream(body []byte) (model string, stream bool, ok bool) {
dec := json.NewDecoder(bytes.NewReader(body))
tok, err := dec.Token()
if err != nil {
return "", false, false
}
if d, isDelim := tok.(json.Delim); !isDelim || d != '{' {
return "", false, false
}
for dec.More() {
keyTok, err := dec.Token()
if err != nil {
return model, stream, ok
}
key, _ := keyTok.(string)
switch key {
case "model":
var v string
if dec.Decode(&v) == nil {
model, ok = v, true
}
case "stream":
var v bool
if dec.Decode(&v) == nil {
stream = v
}
default:
// Skip the value by walking tokens instead of decoding it into
// a json.RawMessage — a multi-MB messages array would otherwise
// be materialised in full just to be discarded.
if err := skipValue(dec); err != nil {
return model, stream, ok
}
}
}
return model, stream, ok
}
// skipValue consumes one JSON value from dec without materialising it.
// Scalars are a single token; objects/arrays are walked to their matching
// close delimiter so nested structures are skipped in bounded memory.
func skipValue(dec *json.Decoder) error {
tok, err := dec.Token()
if err != nil {
return err
}
d, isDelim := tok.(json.Delim)
if !isDelim || (d != '{' && d != '[') {
return nil
}
depth := 1
for depth > 0 {
tok, err := dec.Token()
if err != nil {
return err
}
if d, ok := tok.(json.Delim); ok {
switch d {
case '{', '[':
depth++
case '}', ']':
depth--
}
}
}
return nil
}
func contentTypeAllowed(ct string, allowed []string) bool {
if len(allowed) == 0 {
return false
}
media := ct
if idx := strings.Index(ct, ";"); idx >= 0 {
media = ct[:idx]
}
media = strings.TrimSpace(strings.ToLower(media))
for _, a := range allowed {
if strings.EqualFold(strings.TrimSpace(a), media) {
return true
}
}
return false
}
func parseContentLength(v string) int64 {
if v == "" {
return 0
}
parsed, err := strconv.ParseInt(v, 10, 64)
if err != nil || parsed < 0 {
return 0
}
return parsed
}
@@ -0,0 +1,189 @@
package bodytap
import (
"bytes"
"net/http"
"sync"
"github.com/netbirdio/netbird/proxy/internal/responsewriter"
)
// CapturingResponseWriter wraps an http.ResponseWriter, forwards bytes
// immediately to the client, and tees a bounded copy into an internal
// buffer for middleware inspection. Streaming-aware in the sense that
// every byte the upstream emits flows to the client without queuing
// — the tee just sees a bounded prefix. SSE-aware parsing happens in
// the response middleware against the buffered prefix; this writer
// makes no attempt to demux event boundaries.
//
// Flusher and Hijacker are preserved via responsewriter.PassthroughWriter.
type CapturingResponseWriter struct {
*responsewriter.PassthroughWriter
mu sync.Mutex
buf bytes.Buffer
cap int64
status int
statusSet bool
written int64
truncated bool
stopped bool
releaseBuf func()
released sync.Once
bypassed bool
bypassReas string
acquiredCap int64
}
// NewCapturingResponseWriter returns a writer that tees up to maxBytes
// into a capped buffer while forwarding bytes to the underlying writer
// immediately. When budget is non-nil the writer pre-acquires maxBytes
// from it and the returned wrapper must be released by calling
// Release() once the response is fully forwarded. If the budget cannot
// be acquired the writer falls back to forwarding the response
// unmodified, exposes Bypassed()=true with reason BypassBudget, and
// releases nothing.
func NewCapturingResponseWriter(w http.ResponseWriter, maxBytes int64, b Budget) *CapturingResponseWriter {
cw := &CapturingResponseWriter{
PassthroughWriter: responsewriter.New(w),
cap: maxBytes,
status: http.StatusOK,
releaseBuf: func() {},
}
if maxBytes <= 0 {
// Capture disabled: mark stopped so Write never tees and never
// flags truncation (a zero cap means "don't capture", not
// "captured nothing").
cw.stopped = true
return cw
}
if b == nil {
return cw
}
if !b.Acquire(maxBytes) {
cw.bypassed = true
cw.bypassReas = BypassBudget
cw.cap = 0
cw.stopped = true
return cw
}
cw.acquiredCap = maxBytes
cw.releaseBuf = func() { b.Release(maxBytes) }
return cw
}
// Release returns the response capture budget acquired at construction
// back to the shared pool. Idempotent. Safe to call from a defer
// immediately after construction even when the writer ended up
// bypassing the budget.
func (c *CapturingResponseWriter) Release() {
if c == nil {
return
}
c.released.Do(func() {
if c.releaseBuf != nil {
c.releaseBuf()
}
})
}
// Bypassed reports whether the writer fell through to a no-tee
// passthrough because the response capture budget could not be
// acquired.
func (c *CapturingResponseWriter) Bypassed() bool {
if c == nil {
return false
}
c.mu.Lock()
defer c.mu.Unlock()
return c.bypassed
}
// BypassReason returns the bypass code recorded by the budget check.
// Empty when capture proceeded normally.
func (c *CapturingResponseWriter) BypassReason() string {
if c == nil {
return ""
}
c.mu.Lock()
defer c.mu.Unlock()
return c.bypassReas
}
// WriteHeader records the status code and forwards it to the underlying
// writer. Only the first call commits the status — matching HTTP semantics,
// where superfluous WriteHeader calls (and any call after the body has
// started) are ignored — so Status() reflects the code actually sent.
func (c *CapturingResponseWriter) WriteHeader(status int) {
c.mu.Lock()
if c.statusSet {
c.mu.Unlock()
return
}
c.status = status
c.statusSet = true
c.mu.Unlock()
c.PassthroughWriter.WriteHeader(status)
}
// Write forwards p to the underlying writer unmodified and copies up
// to the remaining buffer capacity into the tee buffer.
func (c *CapturingResponseWriter) Write(p []byte) (int, error) {
n, err := c.PassthroughWriter.Write(p)
if n > 0 {
c.mu.Lock()
// The first byte commits the status (implicit 200 if WriteHeader was
// never called); a later WriteHeader must not change Status().
c.statusSet = true
c.written += int64(n)
if !c.stopped {
remaining := c.cap - int64(c.buf.Len())
if remaining <= 0 {
c.truncated = true
c.stopped = true
} else {
take := int64(n)
if take > remaining {
take = remaining
c.truncated = true
c.stopped = true
}
c.buf.Write(p[:take])
}
}
c.mu.Unlock()
}
return n, err
}
// Status returns the captured status code (defaults to 200 when
// WriteHeader has not been called).
func (c *CapturingResponseWriter) Status() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.status
}
// Body returns a copy of the buffered response prefix.
func (c *CapturingResponseWriter) Body() []byte {
c.mu.Lock()
defer c.mu.Unlock()
out := make([]byte, c.buf.Len())
copy(out, c.buf.Bytes())
return out
}
// Truncated reports whether the buffered prefix stopped short of the
// full response stream.
func (c *CapturingResponseWriter) Truncated() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.truncated
}
// BytesWritten returns the total number of bytes forwarded to the
// underlying writer.
func (c *CapturingResponseWriter) BytesWritten() int64 {
c.mu.Lock()
defer c.mu.Unlock()
return c.written
}
@@ -0,0 +1,86 @@
package bodytap
import (
"fmt"
"io"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// makeBigAnthropicBody builds a request body shaped like Claude Code's:
// a multi-MB "messages" array with the routing fields (model, stream)
// placed AFTER it, which is the ordering that defeats a prefix-only
// capture.
func makeBigAnthropicBody(t *testing.T, model string, stream bool, messagesBytes int) string {
t.Helper()
filler := strings.Repeat("x", messagesBytes)
return fmt.Sprintf(
`{"max_tokens":64000,"messages":[{"role":"user","content":%q}],"model":%q,"stream":%t}`,
filler, model, stream,
)
}
func TestScanRoutingFields_ModelAfterLargeMessages(t *testing.T) {
body := makeBigAnthropicBody(t, "claude-opus-4-8", true, 3<<20) // 3 MiB messages
req := httptest.NewRequest("POST", "https://x/v1/messages", strings.NewReader(body))
model, stream, ok := ScanRoutingFields(req, MaxRoutingScanBytes)
require.True(t, ok, "model must be recovered even when it follows a multi-MB messages array")
assert.Equal(t, "claude-opus-4-8", model, "model field must be extracted")
assert.True(t, stream, "stream field must be extracted")
// Body must be fully restored for the upstream.
got, err := io.ReadAll(req.Body)
require.NoError(t, err)
assert.Equal(t, body, string(got), "the full request body must be replayed to upstream after scanning")
}
func TestScanRoutingFields_SmallBody(t *testing.T) {
body := `{"model":"claude-opus-4-8","stream":false,"messages":[]}`
req := httptest.NewRequest("POST", "https://x/v1/messages", strings.NewReader(body))
model, stream, ok := ScanRoutingFields(req, MaxRoutingScanBytes)
require.True(t, ok)
assert.Equal(t, "claude-opus-4-8", model)
assert.False(t, stream)
got, _ := io.ReadAll(req.Body)
assert.Equal(t, body, string(got), "small bodies must also be restored intact")
}
func TestScanRoutingFields_NoModel(t *testing.T) {
body := `{"stream":true,"messages":[]}`
req := httptest.NewRequest("POST", "https://x/v1/messages", strings.NewReader(body))
_, _, ok := ScanRoutingFields(req, MaxRoutingScanBytes)
assert.False(t, ok, "ok must be false when no model field is present")
got, _ := io.ReadAll(req.Body)
assert.Equal(t, body, string(got), "body must be restored even when model is absent")
}
func TestScanRoutingFields_NotJSON(t *testing.T) {
body := "this is not json at all"
req := httptest.NewRequest("POST", "https://x/v1/messages", strings.NewReader(body))
_, _, ok := ScanRoutingFields(req, MaxRoutingScanBytes)
assert.False(t, ok, "ok must be false for a non-JSON body")
}
func TestScanRoutingFields_ModelBeyondScanCeiling(t *testing.T) {
// model sits after 4 MiB of messages but the scan ceiling is 1 MiB:
// model can't be recovered, yet the full body must still replay.
body := makeBigAnthropicBody(t, "claude-opus-4-8", true, 4<<20)
req := httptest.NewRequest("POST", "https://x/v1/messages", strings.NewReader(body))
_, _, ok := ScanRoutingFields(req, 1<<20)
assert.False(t, ok, "model beyond the scan ceiling is not recoverable")
got, err := io.ReadAll(req.Body)
require.NoError(t, err)
assert.Equal(t, body, string(got), "the full body must still replay to upstream even when the scan gives up")
}
+320
View File
@@ -0,0 +1,320 @@
package middleware
import (
"context"
"net/http"
"sync"
)
// boundMiddleware pairs a validated spec with the resolved middleware
// instance the chain will invoke.
type boundMiddleware struct {
spec Spec
mw Middleware
}
// Chain is the ordered set of middlewares that run for a specific
// target. Chains are immutable once built; Manager produces a new
// Chain on every Rebuild.
//
// Ordering: middlewares are kept in registration order. RunRequest
// iterates the SlotOnRequest middlewares in order; RunResponse
// iterates the SlotOnResponse middlewares in reverse order
// (middleware-style LIFO so the last to see the request is the first
// to see the response); RunTerminal iterates the SlotTerminal
// middlewares in registration order, after every on_response slot has
// emitted, so the metadata bag they observe is complete.
//
// Close drains in-flight invocations and tears down each middleware.
// Callers swapping a chain via Manager invoke Close on the old chain
// after the swap so live requests finish on the previous instance.
type Chain struct {
targetID string
all []boundMiddleware
onRequest []int
onResponse []int
terminal []int
dispatcher *Dispatcher
inflight sync.WaitGroup
}
// NewChain assembles a Chain from the bound middlewares. The slice
// order is the registration order; the chain captures index slices
// per slot so iteration does not re-scan the slot field per call.
func NewChain(targetID string, bound []boundMiddleware, d *Dispatcher) *Chain {
c := &Chain{
targetID: targetID,
all: bound,
dispatcher: d,
}
for i, bm := range bound {
switch bm.spec.Slot {
case SlotOnRequest:
c.onRequest = append(c.onRequest, i)
case SlotOnResponse:
c.onResponse = append(c.onResponse, i)
case SlotTerminal:
c.terminal = append(c.terminal, i)
}
}
return c
}
// Close waits for outstanding invocations against this chain to
// finish (bounded by ctx) and releases the middleware instances bound
// to it. Safe to call once the chain has been removed from the
// routing snapshot. Subsequent Run* calls are still safe (return
// without invoking) but Close itself is one-shot.
func (c *Chain) Close(ctx context.Context) error {
if c == nil {
return nil
}
if ctx == nil {
ctx = context.Background()
}
done := make(chan struct{})
go func() {
c.inflight.Wait()
close(done)
}()
select {
case <-done:
case <-ctx.Done():
// Drain timed out: requests may still be running against these
// middleware instances, so tearing them down now risks a
// use-after-close. Leave them (a bounded leak) and surface the
// timeout; the runaway backstop in the Manager already alerts.
return ctx.Err()
}
for _, bm := range c.all {
if bm.mw == nil {
continue
}
if err := bm.mw.Close(); err != nil {
c.dispatcher.logger.Debugf("middleware %s close: %v", bm.spec.ID, err)
}
}
return nil
}
// Empty reports whether the chain has no middlewares.
func (c *Chain) Empty() bool {
return c == nil || len(c.all) == 0
}
// TargetID returns the key used to find this chain.
func (c *Chain) TargetID() string {
if c == nil {
return ""
}
return c.targetID
}
// IDs returns the ordered list of middleware IDs bound to this chain.
func (c *Chain) IDs() []string {
if c == nil {
return nil
}
out := make([]string, len(c.all))
for i, bm := range c.all {
out[i] = bm.spec.ID
}
return out
}
// RunRequest iterates the on_request slot in registration order. Deny
// short-circuits the remaining middlewares and returns the deny
// output. The caller owns applying mutations to the real request and
// merging the metadata returned in `merged` into the captured-data
// bag passed to subsequent slots.
//
// Each middleware sees the metadata emitted by earlier middlewares in
// the same slot — this is how llm_guardrail reads
// llm.request_prompt_raw from llm_request_parser without a side
// channel, and how cost_meter reads tokens emitted by
// llm_response_parser on the response leg.
//
// If any middleware emits a non-nil Mutations.RewriteUpstream while
// satisfying the mutation gates (CanMutate && MutationsSupported), the
// latest such value is returned to the caller. Last-write-wins so the
// last middleware in the slot can override an earlier rewrite.
func (c *Chain) RunRequest(ctx context.Context, r *http.Request, in *Input, acc *Accumulator) (denied *Output, merged []KV, rewrite *UpstreamRewrite, err error) {
if c.Empty() || len(c.onRequest) == 0 {
return nil, nil, nil, nil
}
c.inflight.Add(1)
defer c.inflight.Done()
running := append([]KV(nil), in.Metadata...)
for _, idx := range c.onRequest {
bm := c.all[idx]
call := cloneInputFor(in, SlotOnRequest)
call.Metadata = append([]KV(nil), running...)
out, invErr := c.dispatcher.Invoke(ctx, bm.spec, bm.mw, call)
if invErr != nil && out == nil {
continue
}
if out == nil {
continue
}
accepted, rejected := acc.Emit(bm.spec.ID, bm.spec.MetadataKeys, out.Metadata)
for _, rej := range rejected {
c.dispatcher.metrics.IncMetadataRejected(ctx, bm.spec.ID, rej.Reason)
}
merged = append(merged, accepted...)
running = append(running, accepted...)
if out.Decision == DecisionDeny {
c.dispatcher.metrics.IncRequest(ctx, bm.spec.ID, c.targetID, "deny")
return out, merged, rewrite, nil
}
c.dispatcher.metrics.IncRequest(ctx, bm.spec.ID, c.targetID, "allow")
if rw := mutationRewrite(bm.spec, out.Mutations); rw != nil {
rewrite = rw
}
if r != nil && bm.spec.CanMutate && out.Mutations != nil {
applyMutations(ctx, c.dispatcher, bm.spec, r, out.Mutations)
}
}
return nil, merged, rewrite, nil
}
// RunResponse iterates the on_response slot in reverse registration
// order, matching the middleware "last in, first out" convention so
// the last middleware to see the request is the first to see the
// response. Middlewares cannot deny; they emit metadata.
//
// As with RunRequest, each middleware sees the metadata emitted by
// earlier middlewares in this slot — accumulated in the order the
// middlewares run (LIFO of registration). cost_meter relies on this
// to read llm.input_tokens / llm.output_tokens that
// llm_response_parser emitted just before it.
func (c *Chain) RunResponse(ctx context.Context, in *Input, acc *Accumulator) (merged []KV) {
if c.Empty() || len(c.onResponse) == 0 {
return nil
}
c.inflight.Add(1)
defer c.inflight.Done()
running := append([]KV(nil), in.Metadata...)
for i := len(c.onResponse) - 1; i >= 0; i-- {
bm := c.all[c.onResponse[i]]
call := cloneInputFor(in, SlotOnResponse)
call.Metadata = append([]KV(nil), running...)
out, _ := c.dispatcher.Invoke(ctx, bm.spec, bm.mw, call)
if out == nil {
continue
}
accepted, rejected := acc.Emit(bm.spec.ID, bm.spec.MetadataKeys, out.Metadata)
for _, rej := range rejected {
c.dispatcher.metrics.IncMetadataRejected(ctx, bm.spec.ID, rej.Reason)
}
merged = append(merged, accepted...)
running = append(running, accepted...)
c.dispatcher.metrics.IncRequest(ctx, bm.spec.ID, c.targetID, "passthrough")
}
return merged
}
// RunTerminal iterates the terminal slot in registration order, after
// every on_response middleware has emitted. Terminal middlewares
// observe the full metadata bag carried in `in.Metadata` plus any
// emissions from terminal middlewares that ran before them; they
// cannot deny and cannot mutate.
func (c *Chain) RunTerminal(ctx context.Context, in *Input, acc *Accumulator) (merged []KV) {
if c.Empty() || len(c.terminal) == 0 {
return nil
}
c.inflight.Add(1)
defer c.inflight.Done()
running := append([]KV(nil), in.Metadata...)
for _, idx := range c.terminal {
bm := c.all[idx]
call := cloneInputFor(in, SlotTerminal)
call.Metadata = append([]KV(nil), running...)
out, _ := c.dispatcher.Invoke(ctx, bm.spec, bm.mw, call)
if out == nil {
continue
}
accepted, rejected := acc.Emit(bm.spec.ID, bm.spec.MetadataKeys, out.Metadata)
for _, rej := range rejected {
c.dispatcher.metrics.IncMetadataRejected(ctx, bm.spec.ID, rej.Reason)
}
merged = append(merged, accepted...)
running = append(running, accepted...)
c.dispatcher.metrics.IncRequest(ctx, bm.spec.ID, c.targetID, "terminal")
}
return merged
}
// mutationRewrite returns the upstream rewrite carried in m when the
// spec's mutation gates allow it. The rewrite itself is not applied
// here; the caller (reverse proxy) decides whether to honour it.
func mutationRewrite(spec Spec, m *Mutations) *UpstreamRewrite {
if m == nil || m.RewriteUpstream == nil {
return nil
}
if !spec.CanMutate || !spec.MutationsSupported {
return nil
}
return m.RewriteUpstream
}
func applyMutations(ctx context.Context, d *Dispatcher, spec Spec, r *http.Request, m *Mutations) {
if m == nil {
return
}
add, remove, blocked := FilterHeaderMutations(m)
for _, h := range blocked {
d.metrics.IncHeaderMutationBlocked(ctx, spec.ID, h)
}
for _, name := range remove {
r.Header.Del(name)
}
for _, kv := range add {
r.Header.Add(kv.Key, kv.Value)
}
if len(m.BodyReplace) == 0 {
return
}
if err := ValidateBodyReplace(r, m.BodyReplace, true); err != nil {
d.logger.Warnf("middleware %s body replace rejected: %v", spec.ID, err)
return
}
ApplyBodyReplace(r, m.BodyReplace)
}
// cloneInputFor deep-copies the mutation-prone fields of Input so
// each middleware receives an isolated view.
func cloneInputFor(in *Input, slot Slot) *Input {
if in == nil {
return nil
}
out := *in
out.Slot = slot
out.Headers = cloneKVs(in.Headers)
out.RespHeaders = cloneKVs(in.RespHeaders)
out.Metadata = cloneKVs(in.Metadata)
if len(in.UserGroups) > 0 {
out.UserGroups = append([]string(nil), in.UserGroups...)
}
if len(in.UserGroupNames) > 0 {
out.UserGroupNames = append([]string(nil), in.UserGroupNames...)
}
if len(in.Body) > 0 {
out.Body = append([]byte(nil), in.Body...)
}
if len(in.RespBody) > 0 {
out.RespBody = append([]byte(nil), in.RespBody...)
}
return &out
}
func cloneKVs(in []KV) []KV {
if len(in) == 0 {
return nil
}
out := make([]KV, len(in))
copy(out, in)
return out
}
+370
View File
@@ -0,0 +1,370 @@
package middleware
import (
"context"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// fakeMiddleware is a minimal Middleware for chain composition tests.
// It records the metadata the dispatcher hands to it and emits a
// caller-supplied Output. Tests use the recorded snapshot to assert
// that earlier-in-slot emissions are visible to later middlewares.
type fakeMiddleware struct {
id string
slot Slot
keys []string
emit []KV
decision Decision
mutationsSupported bool
canMutate bool
mutations *Mutations
// seen captures the in.Metadata snapshot the dispatcher passed to
// Invoke, so tests can assert ordering and visibility.
seen []KV
}
func (f *fakeMiddleware) ID() string { return f.id }
func (f *fakeMiddleware) Version() string { return "test" }
func (f *fakeMiddleware) Slot() Slot { return f.slot }
func (f *fakeMiddleware) AcceptedContentTypes() []string { return nil }
func (f *fakeMiddleware) MetadataKeys() []string { return f.keys }
func (f *fakeMiddleware) MutationsSupported() bool { return f.mutationsSupported }
func (f *fakeMiddleware) Close() error { return nil }
func (f *fakeMiddleware) Invoke(_ context.Context, in *Input) (*Output, error) {
f.seen = append([]KV(nil), in.Metadata...)
out := &Output{Decision: f.decision, Metadata: append([]KV(nil), f.emit...)}
if f.mutations != nil {
m := *f.mutations
out.Mutations = &m
}
return out, nil
}
// chainFor builds a Chain over the given middlewares with a noop
// dispatcher.
func chainFor(t *testing.T, mws ...*fakeMiddleware) *Chain {
t.Helper()
bound := make([]boundMiddleware, len(mws))
for i, mw := range mws {
bound[i] = boundMiddleware{
spec: Spec{
ID: mw.id,
Slot: mw.slot,
Enabled: true,
MetadataKeys: mw.keys,
CanMutate: mw.canMutate,
MutationsSupported: mw.mutationsSupported,
},
mw: mw,
}
}
disp := NewDispatcher(nil, nil)
return NewChain("t-1", bound, disp)
}
// TestChain_RunRequest_ThreadsMetadataAcrossMiddlewares locks that
// each on_request middleware sees metadata emitted by earlier
// middlewares in the same slot. Regression cover for the original
// chain.go where every iteration cloned from the same source `in` and
// later middlewares (e.g. llm_guardrail) couldn't read what the first
// (e.g. llm_request_parser) had just emitted.
func TestChain_RunRequest_ThreadsMetadataAcrossMiddlewares(t *testing.T) {
first := &fakeMiddleware{
id: "first",
slot: SlotOnRequest,
keys: []string{"foo.k"},
emit: []KV{{Key: "foo.k", Value: "v"}},
}
second := &fakeMiddleware{
id: "second",
slot: SlotOnRequest,
keys: []string{"bar.k"},
emit: []KV{{Key: "bar.k", Value: "z"}},
}
c := chainFor(t, first, second)
acc := NewAccumulator(0)
denied, merged, rewrite, err := c.RunRequest(context.Background(), nil, &Input{}, acc)
require.NoError(t, err)
assert.Nil(t, denied, "no deny without DecisionDeny")
assert.Nil(t, rewrite, "no rewrite without Mutations.RewriteUpstream")
require.Len(t, second.seen, 1, "the second middleware must observe one prior emission")
assert.Equal(t, "foo.k", second.seen[0].Key, "second middleware must see the first middleware's key")
assert.Equal(t, "v", second.seen[0].Value, "second middleware must see the first middleware's value")
require.Len(t, merged, 2, "merged slice contains both middleware emissions")
}
// TestChain_RunResponse_ThreadsMetadataAcrossMiddlewares does the
// same for the response slot. The response slot iterates in reverse
// registration order, so the middleware registered LAST runs first.
// This test asserts that a middleware running later (in reverse
// order) sees the metadata emitted by the one that ran before it.
func TestChain_RunResponse_ThreadsMetadataAcrossMiddlewares(t *testing.T) {
// Registration order: [outer, inner].
// Reverse iteration runs inner first, outer second.
// outer must see inner's emission.
outer := &fakeMiddleware{
id: "outer",
slot: SlotOnResponse,
keys: []string{"outer.k"},
emit: []KV{{Key: "outer.k", Value: "o"}},
}
inner := &fakeMiddleware{
id: "inner",
slot: SlotOnResponse,
keys: []string{"inner.k"},
emit: []KV{{Key: "inner.k", Value: "i"}},
}
c := chainFor(t, outer, inner)
acc := NewAccumulator(0)
merged := c.RunResponse(context.Background(), &Input{}, acc)
require.Len(t, outer.seen, 1, "outer must observe inner's emission")
assert.Equal(t, "inner.k", outer.seen[0].Key)
require.Len(t, merged, 2, "merged slice contains both response emissions")
}
// TestChain_RunResponse_CostMeterScenario simulates the synth-service
// chain shape (response_parser registered AFTER cost_meter so reverse
// iter runs response_parser first). The cost_meter analogue must see
// the tokens response_parser just emitted — this is the exact
// regression that produced cost.skipped=missing_tokens in the live
// access logs.
func TestChain_RunResponse_CostMeterScenario(t *testing.T) {
// Synthesizer registers cost_meter first, response_parser second.
costMeter := &fakeMiddleware{
id: "cost_meter",
slot: SlotOnResponse,
keys: []string{"cost.usd_total", "cost.skipped"},
}
respParser := &fakeMiddleware{
id: "llm_response_parser",
slot: SlotOnResponse,
keys: []string{"llm.input_tokens", "llm.output_tokens"},
emit: []KV{
{Key: "llm.input_tokens", Value: "13"},
{Key: "llm.output_tokens", Value: "259"},
},
}
c := chainFor(t, costMeter, respParser)
acc := NewAccumulator(0)
_ = c.RunResponse(context.Background(), &Input{}, acc)
require.Len(t, costMeter.seen, 2, "cost_meter must observe both token keys emitted by response_parser")
keys := []string{costMeter.seen[0].Key, costMeter.seen[1].Key}
assert.ElementsMatch(t, []string{"llm.input_tokens", "llm.output_tokens"}, keys,
"cost_meter must see the exact keys response_parser emitted")
values := []string{costMeter.seen[0].Value, costMeter.seen[1].Value}
assert.ElementsMatch(t, []string{"13", "259"}, values, "cost_meter must see the exact token counts")
for _, kv := range costMeter.seen {
_, err := strconv.Atoi(kv.Value)
assert.NoError(t, err, "values handed to cost_meter must be numeric (regression for missing_tokens)")
}
}
// TestChain_RunResponse_DetachedContextStillRecords guards the metering
// fix in reverseproxy.go. The response/terminal phase runs after the body
// is forwarded, so a streaming client has usually disconnected by then,
// cancelling its request context. The dispatcher derives each middleware's
// context from the one passed here and short-circuits to fail-mode the
// instant it's Done, which silently drops token/cost metering. The reverse
// proxy now detaches that phase with context.WithoutCancel; this proves a
// context detached from an already-cancelled parent still lets a response
// middleware emit. (The cancelled-parent direction is intentionally not
// asserted: the dispatcher's select over ctx.Done vs the result channel is
// racy when both are ready, which is exactly why the bug was intermittent.)
func TestChain_RunResponse_DetachedContextStillRecords(t *testing.T) {
resp := &fakeMiddleware{
id: "recorder",
slot: SlotOnResponse,
keys: []string{"llm.input_tokens"},
emit: []KV{{Key: "llm.input_tokens", Value: "42"}},
decision: DecisionPassthrough,
}
c := chainFor(t, resp)
clientCtx, cancel := context.WithCancel(context.Background())
cancel() // client disconnected after the stream completed
require.Error(t, clientCtx.Err(), "client context must be cancelled for the test to be meaningful")
detached := context.WithoutCancel(clientCtx)
require.NoError(t, detached.Err(), "detached context must not inherit the client's cancellation")
acc := NewAccumulator(MaxRequestMetadataBytes)
merged := c.RunResponse(detached, &Input{Slot: SlotOnResponse}, acc)
var got string
for _, kv := range merged {
if kv.Key == "llm.input_tokens" {
got = kv.Value
}
}
assert.Equal(t, "42", got, "response middleware must still emit token metadata under the detached context")
}
// TestChain_RunRequest_LatestRewriteWins asserts that when two
// on_request middlewares both emit an UpstreamRewrite, the chain
// returns the value from the later middleware.
func TestChain_RunRequest_LatestRewriteWins(t *testing.T) {
first := &fakeMiddleware{
id: "first",
slot: SlotOnRequest,
mutationsSupported: true,
canMutate: true,
mutations: &Mutations{RewriteUpstream: &UpstreamRewrite{Scheme: "https", Host: "first.test"}},
}
second := &fakeMiddleware{
id: "second",
slot: SlotOnRequest,
mutationsSupported: true,
canMutate: true,
mutations: &Mutations{RewriteUpstream: &UpstreamRewrite{Scheme: "https", Host: "second.test"}},
}
c := chainFor(t, first, second)
acc := NewAccumulator(0)
denied, _, rewrite, err := c.RunRequest(context.Background(), nil, &Input{}, acc)
require.NoError(t, err)
assert.Nil(t, denied, "neither middleware denies")
require.NotNil(t, rewrite, "chain must surface the rewrite emitted by the on_request slot")
assert.Equal(t, "https", rewrite.Scheme, "rewrite scheme must come from the later middleware")
assert.Equal(t, "second.test", rewrite.Host, "rewrite host must come from the later middleware (last-write-wins)")
}
// TestChain_RunRequest_NoRewrite_NilReturn asserts the chain returns a
// nil rewrite when no middleware emits one.
func TestChain_RunRequest_NoRewrite_NilReturn(t *testing.T) {
first := &fakeMiddleware{id: "first", slot: SlotOnRequest}
second := &fakeMiddleware{id: "second", slot: SlotOnRequest}
c := chainFor(t, first, second)
acc := NewAccumulator(0)
denied, _, rewrite, err := c.RunRequest(context.Background(), nil, &Input{}, acc)
require.NoError(t, err)
assert.Nil(t, denied, "neither middleware denies")
assert.Nil(t, rewrite, "chain must return nil rewrite when no middleware emits one")
}
// TestChain_ApplyMutations_RewriteGatedOnCanMutate asserts that a
// middleware emitting an UpstreamRewrite with CanMutate=false has its
// rewrite filtered out by the chain. The dispatcher's filterOutput
// already clears Mutations when the gates fail; the chain's defensive
// gate inside mutationRewrite mirrors that contract so a stale
// Mutations field cannot leak through.
func TestChain_ApplyMutations_RewriteGatedOnCanMutate(t *testing.T) {
mw := &fakeMiddleware{
id: "first",
slot: SlotOnRequest,
mutationsSupported: true,
canMutate: false,
mutations: &Mutations{RewriteUpstream: &UpstreamRewrite{Scheme: "https", Host: "denied.test"}},
}
c := chainFor(t, mw)
acc := NewAccumulator(0)
denied, _, rewrite, err := c.RunRequest(context.Background(), nil, &Input{}, acc)
require.NoError(t, err)
assert.Nil(t, denied, "middleware does not deny")
assert.Nil(t, rewrite, "rewrite must be filtered when CanMutate=false")
}
// TestChain_RunRequest_PropagatesUserGroups asserts the chain forwards
// Input.UserGroups verbatim through cloneInputFor so policy-aware
// middlewares (e.g. llm_policy_check) can authorise without an extra
// management round-trip.
func TestChain_RunRequest_PropagatesUserGroups(t *testing.T) {
groupCapture := &userGroupCaptureMiddleware{
id: "group-capture",
slot: SlotOnRequest,
}
c := chainFor(t, groupCapture.fake())
groupCapture.bind(c)
acc := NewAccumulator(0)
in := &Input{UserGroups: []string{"g1"}}
denied, _, _, err := c.RunRequest(context.Background(), nil, in, acc)
require.NoError(t, err)
assert.Nil(t, denied, "no deny without DecisionDeny")
require.Len(t, groupCapture.seenGroups, 1, "middleware must observe the caller's UserGroups")
assert.Equal(t, "g1", groupCapture.seenGroups[0], "UserGroups must reach the middleware verbatim")
}
// userGroupCaptureMiddleware is a fakeMiddleware variant that records
// Input.UserGroups during Invoke. It exists so the cloneInputFor
// behaviour for the new field can be asserted without leaking into
// every other chain test.
type userGroupCaptureMiddleware struct {
id string
slot Slot
seenGroups []string
fakeMW *fakeMiddleware
}
func (u *userGroupCaptureMiddleware) fake() *fakeMiddleware {
u.fakeMW = &fakeMiddleware{id: u.id, slot: u.slot}
return u.fakeMW
}
func (u *userGroupCaptureMiddleware) bind(c *Chain) {
for i, bm := range c.all {
if bm.spec.ID != u.id {
continue
}
c.all[i].mw = userGroupRecorder{
fakeMiddleware: u.fakeMW,
parent: u,
}
}
}
type userGroupRecorder struct {
*fakeMiddleware
parent *userGroupCaptureMiddleware
}
func (r userGroupRecorder) Invoke(ctx context.Context, in *Input) (*Output, error) {
r.parent.seenGroups = append([]string(nil), in.UserGroups...)
return r.fakeMiddleware.Invoke(ctx, in)
}
// TestChain_RunTerminal_SeesAccumulatedMetadata locks that terminal
// middlewares observe the full bag (the caller-supplied in.Metadata
// plus any prior terminal emissions).
func TestChain_RunTerminal_SeesAccumulatedMetadata(t *testing.T) {
first := &fakeMiddleware{
id: "term-1",
slot: SlotTerminal,
keys: []string{"term.first"},
emit: []KV{{Key: "term.first", Value: "1"}},
}
second := &fakeMiddleware{
id: "term-2",
slot: SlotTerminal,
keys: []string{"term.second"},
}
c := chainFor(t, first, second)
acc := NewAccumulator(0)
in := &Input{Metadata: []KV{{Key: "ext.k", Value: "ext"}}}
merged := c.RunTerminal(context.Background(), in, acc)
require.Len(t, second.seen, 2, "second terminal must see ext bag + first terminal's emission")
got := map[string]string{}
for _, kv := range second.seen {
got[kv.Key] = kv.Value
}
assert.Equal(t, "ext", got["ext.k"], "external bag carries through")
assert.Equal(t, "1", got["term.first"], "first terminal's emission visible to second terminal")
assert.Len(t, merged, 1, "only first terminal emitted; second emitted nothing")
}
+81
View File
@@ -0,0 +1,81 @@
package middleware
import (
"encoding/json"
"net/http"
"regexp"
)
var codeRegex = regexp.MustCompile(`^[a-z][a-z0-9._-]{0,63}$`)
// denyResponse is the on-wire shape rendered by RenderDenyResponse.
// Keeping this as a typed struct ensures we never leak
// middleware-supplied bytes outside known fields.
type denyResponse struct {
Code string `json:"code"`
Message string `json:"message,omitempty"`
Details map[string]string `json:"details,omitempty"`
Middleware string `json:"middleware,omitempty"`
}
// RenderDenyResponse writes a structured JSON deny body. Status is
// clamped to [400, 499] excluding 401 (to avoid conflicts with the
// proxy's auth flow). All middleware-supplied strings are redacted and
// truncated. On any validation failure the function writes a generic
// 403.
func RenderDenyResponse(w http.ResponseWriter, middlewareID string, reason *DenyReason, defaultStatus int) {
status := clampDenyStatus(defaultStatus)
if reason == nil || !codeRegex.MatchString(reason.Code) {
writeGenericDeny(w, middlewareID, status)
return
}
resp := denyResponse{
Code: reason.Code,
Message: truncate(Scan(reason.Message), 256),
Middleware: truncate(Scan(middlewareID), 64),
}
if n := len(reason.Details); n > 0 {
resp.Details = make(map[string]string, min(n, 8))
for k, v := range reason.Details {
if len(resp.Details) >= 8 {
break
}
safeKey := truncate(Scan(k), 64)
if safeKey == "" {
continue
}
resp.Details[safeKey] = truncate(Scan(v), 256)
}
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(resp); err != nil {
return
}
}
func writeGenericDeny(w http.ResponseWriter, middlewareID string, status int) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(denyResponse{Code: "middleware.error", Middleware: truncate(Scan(middlewareID), 64)})
}
func clampDenyStatus(s int) int {
if s < 400 || s >= 500 {
return http.StatusForbidden
}
if s == http.StatusUnauthorized {
return http.StatusForbidden
}
return s
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}
+189
View File
@@ -0,0 +1,189 @@
package middleware
import (
"context"
"errors"
"fmt"
"reflect"
"runtime"
"time"
log "github.com/sirupsen/logrus"
)
// Dispatcher reliability kinds reported via
// proxy.middleware.errors_total{kind=...}.
const (
ErrorKindPanic = "panic"
ErrorKindTimeout = "timeout"
ErrorKindInvokeError = "invoke_error"
)
// Dispatcher drives a single middleware invocation with panic
// recovery, deadline, and output filtering. Safe for concurrent use.
type Dispatcher struct {
metrics *Metrics
logger *log.Logger
}
// NewDispatcher returns a dispatcher that emits on the provided
// metrics bundle and logger. A nil metrics bundle falls back to a noop
// instrument set; a nil logger falls back to the standard logger.
func NewDispatcher(metrics *Metrics, logger *log.Logger) *Dispatcher {
if metrics == nil {
metrics, _ = NewMetrics(nil)
}
if logger == nil {
logger = log.StandardLogger()
}
return &Dispatcher{metrics: metrics, logger: logger}
}
// Invoke runs a single middleware under the reliability wrappers:
// deadline, panic recovery (type + truncated stack only), fail-mode,
// metric emission, and output filtering. The returned output is always
// safe to apply.
func (d *Dispatcher) Invoke(ctx context.Context, spec Spec, mw Middleware, in *Input) (*Output, error) {
if mw == nil {
return nil, fmt.Errorf("middleware %s: instance unavailable", spec.ID)
}
timeout := clampTimeout(spec.Timeout)
callCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
d.metrics.IncInvocation(ctx, spec.ID)
start := time.Now()
type result struct {
out *Output
err error
}
ch := make(chan result, 1)
go func() {
defer func() {
if r := recover(); r != nil {
stack := make([]byte, 4<<10)
n := runtime.Stack(stack, false)
requestID := ""
if in != nil {
requestID = in.RequestID
}
d.logger.Warnf("middleware %s panic: request_id=%s type=%s stack=%s",
spec.ID, requestID, reflect.TypeOf(r).String(), stack[:n])
ch <- result{err: panicError{msg: fmt.Sprintf("middleware %s panic: %s", spec.ID, reflect.TypeOf(r).String())}}
}
}()
out, err := mw.Invoke(callCtx, in)
ch <- result{out: out, err: err}
}()
var (
out *Output
invErr error
kind string
)
select {
case <-callCtx.Done():
invErr = callCtx.Err()
kind = ErrorKindTimeout
case res := <-ch:
out = res.out
invErr = res.err
if invErr != nil {
kind = d.classifyError(invErr)
}
}
d.metrics.ObserveDuration(ctx, spec.ID, time.Since(start).Milliseconds())
if invErr != nil {
d.metrics.IncError(ctx, spec.ID, kind)
return d.failMode(spec, kind), invErr
}
return d.filterOutput(spec, out), nil
}
func (d *Dispatcher) classifyError(err error) string {
if err == nil {
return ""
}
if errors.Is(err, context.DeadlineExceeded) {
return ErrorKindTimeout
}
var pe panicError
if errors.As(err, &pe) {
return ErrorKindPanic
}
return ErrorKindInvokeError
}
// panicError marks an error as coming from the recover branch so the
// classifier can tag it without string inspection.
type panicError struct{ msg string }
func (p panicError) Error() string { return p.msg }
// failMode converts an error into a synthesised output per the
// middleware's fail-mode. An mw.<id>.error_kind metadata entry is
// attached so operators can alert on error rate even when the
// decision is fail-open. Slot constraints still apply: response and
// terminal slots clamp deny back to passthrough in filterOutput.
func (d *Dispatcher) failMode(spec Spec, kind string) *Output {
meta := []KV{{Key: fmt.Sprintf(KeyFrameworkErrorKindFmt, spec.ID), Value: kind}}
if spec.FailMode == FailClosed && spec.Slot == SlotOnRequest {
return &Output{
Decision: DecisionDeny,
DenyStatus: 500,
DenyReason: &DenyReason{Code: "middleware.error"},
Metadata: meta,
}
}
return &Output{Decision: DecisionAllow, Metadata: meta}
}
// filterOutput applies the output-filter pipeline (slot-aware decision
// clamp, mutations gate) so downstream consumers never see
// middleware-supplied values that violate the contract. Metadata is
// passed through; the Accumulator is the single owner of allowlist +
// caps + redaction (called by Chain).
func (d *Dispatcher) filterOutput(spec Spec, out *Output) *Output {
if out == nil {
return &Output{Decision: DecisionAllow}
}
if spec.Slot != SlotOnRequest && out.Decision == DecisionDeny {
out.Decision = DecisionPassthrough
out.DenyStatus = 0
out.DenyReason = nil
}
if out.Decision == DecisionDeny {
if out.DenyStatus == 0 {
out.DenyStatus = 403
} else {
out.DenyStatus = clampDenyStatus(out.DenyStatus)
}
}
if !spec.CanMutate || !spec.MutationsSupported {
out.Mutations = nil
}
if spec.Slot == SlotTerminal {
out.Mutations = nil
}
return out
}
func clampTimeout(d time.Duration) time.Duration {
if d <= 0 {
return DefaultTimeout
}
if d < MinTimeout {
return MinTimeout
}
if d > MaxTimeout {
return MaxTimeout
}
return d
}
+99
View File
@@ -0,0 +1,99 @@
package middleware
import "strings"
var denyHeaders = []string{
"Authorization",
"Connection",
"Cookie",
"Set-Cookie",
"Forwarded",
"Keep-Alive",
"Proxy-Authorization",
"Proxy-Authenticate",
"Proxy-Connection",
"TE",
"Upgrade",
"Via",
"X-Real-IP",
"X-Request-ID",
"Host",
"Content-Length",
"Transfer-Encoding",
"Trailer",
}
var denyHeaderPrefixes = []string{
"X-Authenticated-",
"X-Forwarded-",
"X-Remote-",
"X-NetBird-",
}
// IsHeaderMutable reports whether a middleware is allowed to mutate
// the named header. The check is case-insensitive and honours both
// exact matches and the compiled-in prefix denylist.
func IsHeaderMutable(name string) bool {
if name == "" {
return false
}
if !isHeaderFieldName(name) {
return false
}
for _, d := range denyHeaders {
if strings.EqualFold(d, name) {
return false
}
}
for _, p := range denyHeaderPrefixes {
if len(name) >= len(p) && strings.EqualFold(name[:len(p)], p) {
return false
}
}
return true
}
// isHeaderFieldName reports whether name is a valid RFC 7230 header
// field-name (a non-empty token of tchar octets). Rejects names with
// spaces, control characters, or separators that could enable header
// injection or smuggling when applied to the outbound request.
func isHeaderFieldName(name string) bool {
for i := 0; i < len(name); i++ {
c := name[i]
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') {
continue
}
switch c {
case '!', '#', '$', '%', '&', '\'', '*', '+', '-', '.', '^', '_', '`', '|', '~':
continue
default:
return false
}
}
return true
}
// FilterHeaderMutations returns the subsets of HeadersAdd and
// HeadersRemove that are safe to apply, plus the list of blocked
// header names so the dispatcher can increment the blocked-header
// metric.
func FilterHeaderMutations(m *Mutations) (filteredAdd []KV, filteredRemove []string, blocked []string) {
if m == nil {
return nil, nil, nil
}
for _, kv := range m.HeadersAdd {
if IsHeaderMutable(kv.Key) {
filteredAdd = append(filteredAdd, kv)
continue
}
blocked = append(blocked, kv.Key)
}
for _, name := range m.HeadersRemove {
if IsHeaderMutable(name) {
filteredRemove = append(filteredRemove, name)
continue
}
blocked = append(blocked, name)
}
return filteredAdd, filteredRemove, blocked
}
+86
View File
@@ -0,0 +1,86 @@
package middleware
// Metadata key namespace constants shared across the built-in
// middlewares. Each domain owns a prefix; middlewares declare their
// per-key allowlist drawn from these constants. Agents implementing
// the G2 middlewares import this file so the dashboard's expanded-row
// viewer and the access-log writer see a stable key surface.
//
// Key shape rules (enforced by the metadata accumulator):
// - Lowercase ASCII letters, digits, dot, underscore, hyphen.
// - At least one dot separating namespace from leaf.
// - Max length: MaxMetadataKeyBytes.
const (
// LLM request-side metadata (emitted by llm_request_parser).
KeyLLMProvider = "llm.provider"
KeyLLMModel = "llm.model"
KeyLLMStream = "llm.stream"
KeyLLMRequestPromptRaw = "llm.request_prompt_raw"
KeyLLMCaptureTruncated = "llm.capture_truncated"
// KeyLLMSessionID groups requests of the same conversation / coding
// session, read from the per-provider session marker in the request
// body. Empty for clients that don't send one.
KeyLLMSessionID = "llm.session_id"
// LLM response-side metadata (emitted by llm_response_parser).
//nolint:gosec // metadata key name, not a credential
KeyLLMInputTokens = "llm.input_tokens"
//nolint:gosec // metadata key name, not a credential
KeyLLMOutputTokens = "llm.output_tokens"
//nolint:gosec // metadata key name, not a credential
KeyLLMTotalTokens = "llm.total_tokens"
// LLM cached-input bucket. For OpenAI it's the SUBSET of input
// tokens that hit the prompt cache (prompt_tokens_details.
// cached_tokens) — billed at the cached_input_per_1k rate when
// configured. For Anthropic it's cache_read_input_tokens, which
// is ADDITIVE to llm.input_tokens — billed at cache_read_per_1k.
// cost_meter switches formula on llm.provider.
//nolint:gosec // metadata key name, not a credential
KeyLLMCachedInputTokens = "llm.cached_input_tokens"
// LLM cache-creation bucket (Anthropic only). ADDITIVE to
// llm.input_tokens; billed at cache_creation_per_1k.
//nolint:gosec // metadata key name, not a credential
KeyLLMCacheCreationTokens = "llm.cache_creation_tokens"
KeyLLMResponseCompletion = "llm.response_completion"
// Guardrail outcomes (emitted by llm_guardrail). The guardrail
// also re-emits llm.request_prompt as a redacted variant of the
// raw prompt and drops llm.request_prompt_raw from the bag.
KeyLLMRequestPrompt = "llm.request_prompt"
KeyLLMPolicyDecision = "llm_policy.decision"
KeyLLMPolicyReason = "llm_policy.reason"
// LLM router routing decision (emitted by llm_router). The router
// stamps the resolved provider id so downstream middlewares and
// the access-log emitter can attribute the request without
// re-parsing the body.
KeyLLMResolvedProviderID = "llm.resolved_provider_id"
// LLM authorising groups for this request (emitted by llm_router
// on the allow path). Carries the comma-separated intersection of
// the caller's UserGroups with the resolved route's
// AllowedGroupIDs — i.e. the groups that actually authorise this
// specific request, NOT every group the peer happens to be in.
// Identity-stamping middlewares use this for per-request tag
// attribution so unrelated group memberships don't leak into
// downstream gateways' spend logs.
KeyLLMAuthorisingGroups = "llm.authorising_groups"
// LLM policy attribution (emitted by llm_limit_check on the allow
// path). Names the policy that paid for this request and the
// dimension counters the post-flight llm_limit_record middleware
// must tick. Empty when no applicable policy has any caps
// configured (catch-all-allow attribution).
KeyLLMSelectedPolicyID = "llm.selected_policy_id"
KeyLLMAttributionGroupID = "llm.attribution_group_id"
KeyLLMAttributionWindowS = "llm.attribution_window_seconds"
// Cost metering (emitted by cost_meter).
KeyCostUSDTotal = "cost.usd_total"
KeyCostSkipped = "cost.skipped"
// Framework-emitted error markers. Use the mw.<id>.* prefix to
// distinguish framework-injected entries from middleware-emitted
// metadata.
KeyFrameworkErrorKindFmt = "mw.%s.error_kind"
)
+412
View File
@@ -0,0 +1,412 @@
package middleware
import (
"context"
"fmt"
"strings"
"sync"
"sync/atomic"
"time"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/proxy/internal/middleware/bodytap"
)
// chainCloseTimeout bounds how long closeChainsAsync waits for an
// individual chain to drain before forcing teardown. Set to 2x
// MaxTimeout so a middleware blocked on the dispatcher's per-Invoke
// deadline always wins; anything running longer is a runaway and gets
// force-closed.
const chainCloseTimeout = 2 * MaxTimeout
// PathTargetBinding is the minimal per-path binding the server passes
// to Rebuild. It carries the stable keys Manager uses for snapshot
// lookups plus the validated middleware spec list for that path.
type PathTargetBinding struct {
ServiceID string
PathID string
Specs []Spec
}
// LiveServiceCheck reports whether the given service ID is still
// present in the proxy's live mapping cache. The Manager calls it
// during InvalidateMiddleware so a chain whose service has been
// removed since the last Rebuild is not resurrected from the binding
// cache, closing the auth-revocation race.
type LiveServiceCheck func(serviceID string) bool
// chainTable holds the immutable per-target chain snapshot. It is
// cloned into a new instance on every Rebuild and swapped in via
// atomic.Pointer. The reverse index byMiddleware lets
// InvalidateMiddleware find the chain keys that reference a given
// middleware without scanning the whole table.
type chainTable struct {
byTarget map[string]*Chain
byMiddleware map[string]map[string]struct{}
}
func newChainTable() *chainTable {
return &chainTable{
byTarget: make(map[string]*Chain),
byMiddleware: make(map[string]map[string]struct{}),
}
}
func (c *chainTable) clone() *chainTable {
out := newChainTable()
for k, v := range c.byTarget {
out.byTarget[k] = v
}
for id, keys := range c.byMiddleware {
set := make(map[string]struct{}, len(keys))
for k := range keys {
set[k] = struct{}{}
}
out.byMiddleware[id] = set
}
return out
}
func (c *chainTable) addChain(key string, ch *Chain) {
c.byTarget[key] = ch
if ch == nil {
return
}
for _, bm := range ch.all {
set, ok := c.byMiddleware[bm.spec.ID]
if !ok {
set = make(map[string]struct{})
c.byMiddleware[bm.spec.ID] = set
}
set[key] = struct{}{}
}
}
func (c *chainTable) removeChain(key string) (*Chain, []string) {
ch, ok := c.byTarget[key]
if !ok {
return nil, nil
}
delete(c.byTarget, key)
if ch == nil {
return nil, nil
}
ids := make([]string, 0, len(ch.all))
for _, bm := range ch.all {
ids = append(ids, bm.spec.ID)
set, ok := c.byMiddleware[bm.spec.ID]
if !ok {
continue
}
delete(set, key)
if len(set) == 0 {
delete(c.byMiddleware, bm.spec.ID)
}
}
return ch, ids
}
// Manager owns the per-target middleware chains, the global capture
// budget, and the shared dispatcher. Readers (ChainFor) are lock-free;
// writers (Rebuild, Invalidate*) serialise on writeMu so two
// concurrent mapping updates do not lose writes.
type Manager struct {
writeMu sync.Mutex
chains atomic.Pointer[chainTable]
budget bodytap.Budget
metrics *Metrics
logger *log.Logger
dispatcher *Dispatcher
resolver *Resolver
lastBindings map[string]PathTargetBinding
liveServiceCheck atomic.Pointer[LiveServiceCheck]
}
// NewManager constructs a Manager with the given capture budget size.
// A zero or negative budget falls back to bodytap.DefaultCaptureBudgetBytes.
func NewManager(budgetBytes int64, metrics *Metrics, logger *log.Logger) *Manager {
if metrics == nil {
metrics, _ = NewMetrics(nil)
}
if logger == nil {
logger = log.StandardLogger()
}
if budgetBytes <= 0 {
budgetBytes = bodytap.DefaultCaptureBudgetBytes
}
m := &Manager{
budget: bodytap.NewBudget(budgetBytes),
metrics: metrics,
logger: logger,
dispatcher: NewDispatcher(metrics, logger),
lastBindings: make(map[string]PathTargetBinding),
}
m.chains.Store(newChainTable())
return m
}
// SetResolver installs the resolver used by Rebuild. Safe to call
// once at boot before any Rebuild; not safe to swap concurrently.
func (m *Manager) SetResolver(r *Resolver) {
m.resolver = r
}
// SetLiveServiceCheck installs a callback the Manager uses to confirm
// a service ID still maps to a live mapping before resurrecting its
// chain from the binding cache during InvalidateMiddleware. A nil fn
// disables the check.
func (m *Manager) SetLiveServiceCheck(fn LiveServiceCheck) {
if fn == nil {
m.liveServiceCheck.Store(nil)
return
}
m.liveServiceCheck.Store(&fn)
}
// Budget returns the shared capture budget.
func (m *Manager) Budget() bodytap.Budget {
return m.budget
}
// Metrics returns the shared metrics bundle.
func (m *Manager) Metrics() *Metrics {
return m.metrics
}
// Dispatcher returns the shared dispatcher (primarily for testing).
func (m *Manager) Dispatcher() *Dispatcher {
return m.dispatcher
}
// Rebuild replaces every chain keyed by serviceID with the provided
// bindings. Entries for other services are preserved. Replaced chains
// are closed asynchronously after the atomic swap so in-flight
// requests against the previous chain finish before middleware
// resources are released.
func (m *Manager) Rebuild(serviceID string, bindings []PathTargetBinding) error {
m.writeMu.Lock()
defer m.writeMu.Unlock()
cur := m.chains.Load()
next := cur.clone()
prefix := serviceID + "|"
var retired []*Chain
for k := range cur.byTarget {
if !strings.HasPrefix(k, prefix) {
continue
}
ch, _ := next.removeChain(k)
if ch != nil {
retired = append(retired, ch)
}
delete(m.lastBindings, k)
}
for _, b := range bindings {
if b.ServiceID != serviceID {
return fmt.Errorf("binding service %q does not match rebuild service %q", b.ServiceID, serviceID)
}
key := chainKey(b.ServiceID, b.PathID)
m.lastBindings[key] = cloneBinding(b)
chain := m.buildChain(b)
if chain == nil || chain.Empty() {
delete(m.lastBindings, key)
continue
}
next.addChain(key, chain)
}
m.chains.Store(next)
m.closeChainsAsync(retired)
return nil
}
// Invalidate drops every chain for the given service ID.
func (m *Manager) Invalidate(serviceID string) {
m.writeMu.Lock()
defer m.writeMu.Unlock()
cur := m.chains.Load()
next := cur.clone()
prefix := serviceID + "|"
var retired []*Chain
for k := range cur.byTarget {
if !strings.HasPrefix(k, prefix) {
continue
}
ch, _ := next.removeChain(k)
if ch != nil {
retired = append(retired, ch)
}
delete(m.lastBindings, k)
}
for k := range m.lastBindings {
if strings.HasPrefix(k, prefix) {
delete(m.lastBindings, k)
}
}
m.chains.Store(next)
m.closeChainsAsync(retired)
}
// InvalidateMiddleware rebuilds only the chains that reference id.
func (m *Manager) InvalidateMiddleware(id string) {
if id == "" {
return
}
m.writeMu.Lock()
defer m.writeMu.Unlock()
cur := m.chains.Load()
keys, ok := cur.byMiddleware[id]
if !ok || len(keys) == 0 {
return
}
affected := make([]string, 0, len(keys))
for k := range keys {
affected = append(affected, k)
}
next := cur.clone()
var retired []*Chain
check := m.loadLiveServiceCheck()
for _, k := range affected {
ch, _ := next.removeChain(k)
if ch != nil {
retired = append(retired, ch)
}
b, ok := m.lastBindings[k]
if !ok {
delete(m.lastBindings, k)
continue
}
if check != nil && !check(b.ServiceID) {
m.logger.Debugf("middleware %s: skipping rebuild for %s; service no longer live", id, k)
delete(m.lastBindings, k)
continue
}
chain := m.buildChain(b)
if chain == nil || chain.Empty() {
delete(m.lastBindings, k)
continue
}
next.addChain(k, chain)
}
m.chains.Store(next)
m.closeChainsAsync(retired)
}
func (m *Manager) loadLiveServiceCheck() LiveServiceCheck {
p := m.liveServiceCheck.Load()
if p == nil {
return nil
}
return *p
}
// InvalidateAll drops every chain.
func (m *Manager) InvalidateAll() {
m.writeMu.Lock()
defer m.writeMu.Unlock()
cur := m.chains.Load()
retired := make([]*Chain, 0, len(cur.byTarget))
for _, c := range cur.byTarget {
retired = append(retired, c)
}
m.chains.Store(newChainTable())
for k := range m.lastBindings {
delete(m.lastBindings, k)
}
m.closeChainsAsync(retired)
}
func (m *Manager) closeChainsAsync(retired []*Chain) {
if len(retired) == 0 {
return
}
chains := make([]*Chain, len(retired))
copy(chains, retired)
go func() {
for _, c := range chains {
ctx, cancel := context.WithTimeout(context.Background(), chainCloseTimeout)
start := time.Now()
if err := c.Close(ctx); err != nil {
if m.metrics != nil {
m.metrics.IncError(context.Background(), c.TargetID(), "chain_close_timeout")
}
m.logger.Warnf("middleware chain %s close exceeded %s after %s: %v",
c.TargetID(), chainCloseTimeout, time.Since(start), err)
}
cancel()
}
}()
}
// ChainFor returns the chain for serviceID/pathID or nil if none is
// registered. Lock-free.
func (m *Manager) ChainFor(serviceID, pathID string) *Chain {
tbl := m.chains.Load()
if tbl == nil {
return nil
}
c, ok := tbl.byTarget[chainKey(serviceID, pathID)]
if !ok {
return nil
}
return c
}
// buildChain resolves each enabled spec and returns the assembled
// chain. Returns a nil chain when no middlewares are bound; resolver
// errors per middleware are logged and counted but do not abort the
// chain.
func (m *Manager) buildChain(b PathTargetBinding) *Chain {
if len(b.Specs) == 0 || m.resolver == nil {
return nil
}
bound := make([]boundMiddleware, 0, len(b.Specs))
for _, spec := range b.Specs {
if !spec.Enabled {
continue
}
mw, merged, err := m.resolver.Resolve(spec)
if err != nil {
m.logger.Warnf("middleware %s resolve on target %s/%s: %v", spec.ID, b.ServiceID, b.PathID, err)
m.metrics.IncError(context.Background(), spec.ID, "resolve_error")
continue
}
if mw == nil {
continue
}
bound = append(bound, boundMiddleware{spec: merged, mw: mw})
}
if len(bound) == 0 {
return nil
}
return NewChain(chainKey(b.ServiceID, b.PathID), bound, m.dispatcher)
}
// cloneBinding returns a deep copy of b suitable for caching across
// mapping updates.
func cloneBinding(b PathTargetBinding) PathTargetBinding {
out := PathTargetBinding{
ServiceID: b.ServiceID,
PathID: b.PathID,
}
if len(b.Specs) == 0 {
return out
}
out.Specs = make([]Spec, len(b.Specs))
for i, s := range b.Specs {
out.Specs[i] = s.Clone()
}
return out
}
func chainKey(serviceID, pathID string) string {
return serviceID + "|" + pathID
}
+99
View File
@@ -0,0 +1,99 @@
package middleware
import "regexp"
// keyRegex constrains metadata keys to the cross-domain shape
// described in keys.go. At least one dot, lowercase ASCII / digits /
// dot / underscore / hyphen only, length within MaxMetadataKeyBytes.
var keyRegex = regexp.MustCompile(`^[a-z][a-z0-9_-]*(\.[a-z0-9][a-z0-9_-]*)+$`)
// MetadataRejection describes a single rejected key/value so the
// dispatcher can emit per-reason counter increments.
type MetadataRejection struct {
Key string
Reason string
}
// Rejection reasons reported by Accumulator.Emit.
const (
MetadataReasonBadKey = "bad_key"
MetadataReasonNotAllowlisted = "not_allowlisted"
MetadataReasonKeyTooLong = "key_too_long"
MetadataReasonValueTooLong = "value_too_long"
MetadataReasonMiddlewareCap = "middleware_cap"
MetadataReasonRequestCap = "request_cap"
)
// Accumulator enforces per-middleware and per-request metadata caps.
// Not safe for concurrent use; callers hold one inside a single chain
// execution.
type Accumulator struct {
perMiddlewareUsed map[string]int
totalUsed int
maxPerRequest int
}
// NewAccumulator returns an accumulator configured for the per-request
// total cap. A maxPerRequest of zero means use MaxRequestMetadataBytes.
func NewAccumulator(maxPerRequest int) *Accumulator {
if maxPerRequest <= 0 {
maxPerRequest = MaxRequestMetadataBytes
}
return &Accumulator{
perMiddlewareUsed: make(map[string]int),
maxPerRequest: maxPerRequest,
}
}
// Emit validates the candidate metadata against the middleware's
// allowlist and the global caps, redacts each accepted value, and
// returns the accepted entries plus any rejections for metric emission.
func (a *Accumulator) Emit(middlewareID string, allow []string, out []KV) ([]KV, []MetadataRejection) {
if len(out) == 0 {
return nil, nil
}
allowSet := make(map[string]struct{}, len(allow))
for _, k := range allow {
allowSet[k] = struct{}{}
}
accepted := make([]KV, 0, len(out))
var rejected []MetadataRejection
for _, kv := range out {
if len(kv.Key) == 0 || len(kv.Key) > MaxMetadataKeyBytes {
rejected = append(rejected, MetadataRejection{Key: kv.Key, Reason: MetadataReasonKeyTooLong})
continue
}
if !keyRegex.MatchString(kv.Key) {
rejected = append(rejected, MetadataRejection{Key: kv.Key, Reason: MetadataReasonBadKey})
continue
}
if _, ok := allowSet[kv.Key]; !ok {
rejected = append(rejected, MetadataRejection{Key: kv.Key, Reason: MetadataReasonNotAllowlisted})
continue
}
if len(kv.Value) > MaxMetadataValueBytes {
rejected = append(rejected, MetadataRejection{Key: kv.Key, Reason: MetadataReasonValueTooLong})
continue
}
redacted := Scan(kv.Value)
cost := len(kv.Key) + len(redacted)
if a.perMiddlewareUsed[middlewareID]+cost > MaxMiddlewareMetadataBytes {
rejected = append(rejected, MetadataRejection{Key: kv.Key, Reason: MetadataReasonMiddlewareCap})
continue
}
if a.totalUsed+cost > a.maxPerRequest {
rejected = append(rejected, MetadataRejection{Key: kv.Key, Reason: MetadataReasonRequestCap})
continue
}
a.perMiddlewareUsed[middlewareID] += cost
a.totalUsed += cost
accepted = append(accepted, KV{Key: kv.Key, Value: redacted})
}
return accepted, rejected
}
+171
View File
@@ -0,0 +1,171 @@
package middleware
import (
"context"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/metric/noop"
)
// Metrics is the bundle of OTel instruments emitted by the middleware
// dispatcher. The constructor falls back to a noop meter when given
// nil so tests can skip metrics wiring entirely.
type Metrics struct {
requestsTotal metric.Int64Counter
durationMs metric.Int64Histogram
invocationsTotal metric.Int64Counter
errorsTotal metric.Int64Counter
metadataRejectedTotal metric.Int64Counter
headerMutationBlocked metric.Int64Counter
captureBypassTotal metric.Int64Counter
}
// NewMetrics registers the proxy.middleware.* instruments on the
// given meter. A nil meter is treated as the global no-op provider.
func NewMetrics(meter metric.Meter) (*Metrics, error) {
if meter == nil {
meter = noop.NewMeterProvider().Meter("proxy.middleware.noop")
}
m := &Metrics{}
var err error
m.requestsTotal, err = meter.Int64Counter(
"proxy.middleware.requests_total",
metric.WithUnit("1"),
metric.WithDescription("Middleware invocations grouped by outcome"),
)
if err != nil {
return nil, err
}
m.durationMs, err = meter.Int64Histogram(
"proxy.middleware.duration_ms",
metric.WithUnit("milliseconds"),
metric.WithDescription("Middleware Invoke latency"),
)
if err != nil {
return nil, err
}
m.invocationsTotal, err = meter.Int64Counter(
"proxy.middleware.invocations_total",
metric.WithUnit("1"),
metric.WithDescription("Middleware Invoke heartbeat counter"),
)
if err != nil {
return nil, err
}
m.errorsTotal, err = meter.Int64Counter(
"proxy.middleware.errors_total",
metric.WithUnit("1"),
metric.WithDescription("Middleware errors grouped by kind"),
)
if err != nil {
return nil, err
}
m.metadataRejectedTotal, err = meter.Int64Counter(
"proxy.middleware.metadata_rejected_total",
metric.WithUnit("1"),
metric.WithDescription("Middleware metadata entries rejected by the allowlist/caps"),
)
if err != nil {
return nil, err
}
m.headerMutationBlocked, err = meter.Int64Counter(
"proxy.middleware.header_mutation_blocked_total",
metric.WithUnit("1"),
metric.WithDescription("Middleware header mutations dropped by the denylist"),
)
if err != nil {
return nil, err
}
m.captureBypassTotal, err = meter.Int64Counter(
"proxy.middleware.capture_bypass_total",
metric.WithUnit("1"),
metric.WithDescription("Capture bypasses grouped by reason"),
)
if err != nil {
return nil, err
}
return m, nil
}
// IncRequest increments proxy.middleware.requests_total with the
// middleware, target, and outcome labels.
func (m *Metrics) IncRequest(ctx context.Context, middlewareID, targetID, outcome string) {
if m == nil {
return
}
m.requestsTotal.Add(ctx, 1, metric.WithAttributes(
attribute.String("middleware", middlewareID),
attribute.String("target_id", targetID),
attribute.String("outcome", outcome),
))
}
// ObserveDuration records the middleware Invoke latency in milliseconds.
func (m *Metrics) ObserveDuration(ctx context.Context, middlewareID string, ms int64) {
if m == nil {
return
}
m.durationMs.Record(ctx, ms, metric.WithAttributes(attribute.String("middleware", middlewareID)))
}
// IncInvocation increments the heartbeat counter regardless of outcome.
func (m *Metrics) IncInvocation(ctx context.Context, middlewareID string) {
if m == nil {
return
}
m.invocationsTotal.Add(ctx, 1, metric.WithAttributes(attribute.String("middleware", middlewareID)))
}
// IncError increments the error counter with the given failure kind label.
func (m *Metrics) IncError(ctx context.Context, middlewareID, kind string) {
if m == nil {
return
}
m.errorsTotal.Add(ctx, 1, metric.WithAttributes(
attribute.String("middleware", middlewareID),
attribute.String("kind", kind),
))
}
// IncMetadataRejected increments the rejected-metadata counter for a reason.
func (m *Metrics) IncMetadataRejected(ctx context.Context, middlewareID, reason string) {
if m == nil {
return
}
m.metadataRejectedTotal.Add(ctx, 1, metric.WithAttributes(
attribute.String("middleware", middlewareID),
attribute.String("reason", reason),
))
}
// IncHeaderMutationBlocked increments the blocked-header counter.
func (m *Metrics) IncHeaderMutationBlocked(ctx context.Context, middlewareID, header string) {
if m == nil {
return
}
m.headerMutationBlocked.Add(ctx, 1, metric.WithAttributes(
attribute.String("middleware", middlewareID),
attribute.String("header", header),
))
}
// IncCaptureBypass increments the capture-bypass counter for a reason.
func (m *Metrics) IncCaptureBypass(ctx context.Context, targetID, reason string) {
if m == nil {
return
}
m.captureBypassTotal.Add(ctx, 1, metric.WithAttributes(
attribute.String("target_id", targetID),
attribute.String("reason", reason),
))
}
+47
View File
@@ -0,0 +1,47 @@
package middleware
import "context"
// Middleware is the surface exposed by each concrete implementation.
// The Manager invokes it through the Dispatcher, passing a cloned
// Input. Each middleware lives in exactly one Slot.
//
// Close releases any resources owned by the middleware instance
// (background goroutines, file handles). It is invoked when the chain
// holding the middleware is replaced or torn down. Implementations
// must be idempotent and safe to call after construction even when
// Invoke was never called.
type Middleware interface {
ID() string
Version() string
Slot() Slot
// AcceptedContentTypes lists the request/response content types
// the middleware needs the body for. Empty slice means the
// middleware does not inspect the body.
AcceptedContentTypes() []string
// MetadataKeys is the closed set of metadata keys this middleware
// may emit. The accumulator drops anything outside this allowlist.
MetadataKeys() []string
// MutationsSupported reports whether the middleware may emit
// header / body mutations. A spec with CanMutate=true is honoured
// only when the implementation also supports mutations.
MutationsSupported() bool
Invoke(ctx context.Context, in *Input) (*Output, error)
Close() error
}
// Factory builds a configured Middleware instance from raw config
// bytes shipped on the wire. Each registered middleware ID has a
// single factory in the registry. Factory.New returns an error when
// the config is malformed or violates a per-middleware invariant; the
// chain build path logs the error, increments the resolve_error metric,
// and skips the middleware.
type Factory interface {
ID() string
New(rawConfig []byte) (Middleware, error)
}
+79
View File
@@ -0,0 +1,79 @@
package middleware
import (
"regexp"
"strings"
)
// Redaction scope: Scan handles the narrow, high-signal set of
// secrets we are comfortable masking with a regex. The intent is
// "make accidental leaks impossible to miss at a glance", not "be a
// DLP product". Contributors adding more patterns should weigh false
// positives carefully — a metadata value that over-redacts benign
// strings is strictly worse than one that misses a rare format.
var (
jwtRegex = regexp.MustCompile(`eyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}`)
pemRegex = regexp.MustCompile(`-----BEGIN [A-Z ]+-----[\s\S]*?-----END [A-Z ]+-----`)
awsKeyRegex = regexp.MustCompile(`AKIA[0-9A-Z]{16}`)
bearerRegex = regexp.MustCompile(`(?i)\b(?:bearer|token|api[_-]?key|authorization)[\s:=]+([A-Za-z0-9_\-\.]{40,})`)
ccCandidateRgx = regexp.MustCompile(`\b(?:\d[ -]?){13,19}\b`)
)
// Scan redacts high-signal secret patterns from value. Matches are
// replaced with `[REDACTED:<kind>]`. Non-matching input is returned
// unchanged.
func Scan(value string) string {
if value == "" {
return value
}
result := value
result = pemRegex.ReplaceAllString(result, "[REDACTED:pem]")
result = jwtRegex.ReplaceAllString(result, "[REDACTED:jwt]")
result = awsKeyRegex.ReplaceAllString(result, "[REDACTED:aws_key]")
result = bearerRegex.ReplaceAllStringFunc(result, func(match string) string {
sub := bearerRegex.FindStringSubmatch(match)
if len(sub) < 2 {
return "[REDACTED:bearer]"
}
return strings.Replace(match, sub[1], "[REDACTED:bearer]", 1)
})
result = ccCandidateRgx.ReplaceAllStringFunc(result, func(match string) string {
digits := stripNonDigits(match)
if len(digits) < 13 || len(digits) > 19 {
return match
}
if !luhn(digits) {
return match
}
return "[REDACTED:cc]"
})
return result
}
func stripNonDigits(s string) string {
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
if r >= '0' && r <= '9' {
b.WriteRune(r)
}
}
return b.String()
}
func luhn(digits string) bool {
sum := 0
alt := false
for i := len(digits) - 1; i >= 0; i-- {
n := int(digits[i] - '0')
if alt {
n *= 2
if n > 9 {
n -= 9
}
}
sum += n
alt = !alt
}
return sum%10 == 0
}
+121
View File
@@ -0,0 +1,121 @@
package middleware
import (
"fmt"
"sync"
)
// Registry maps middleware IDs to their factories. The proxy installs
// a single Registry at boot; concrete middlewares register themselves
// from init() functions inside their own packages so the boot wiring
// only needs an anonymous import.
//
// Registry is safe for concurrent reads after boot. Register / Unregister
// take the write lock; Get and IDs take the read lock.
type Registry struct {
mu sync.RWMutex
factories map[string]Factory
}
// NewRegistry returns an empty registry.
func NewRegistry() *Registry {
return &Registry{factories: make(map[string]Factory)}
}
// Register installs the factory under its ID. Returns an error when an
// ID is already registered — collisions are programmer errors and must
// be visible at boot rather than silently last-write-wins.
func (r *Registry) Register(f Factory) error {
if f == nil {
return fmt.Errorf("middleware registry: nil factory")
}
id := f.ID()
if id == "" {
return fmt.Errorf("middleware registry: factory has empty id")
}
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.factories[id]; exists {
return fmt.Errorf("middleware registry: %q already registered", id)
}
r.factories[id] = f
return nil
}
// MustRegister panics on error. Intended for init() registration so
// duplicate IDs surface at startup.
func (r *Registry) MustRegister(f Factory) {
if err := r.Register(f); err != nil {
panic(err)
}
}
// Get returns the factory for id, or nil when no factory is
// registered.
func (r *Registry) Get(id string) Factory {
r.mu.RLock()
defer r.mu.RUnlock()
return r.factories[id]
}
// IDs returns the registered IDs in unspecified order. Used by the
// management translator to reject specs that reference unknown IDs at
// apply time.
func (r *Registry) IDs() []string {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]string, 0, len(r.factories))
for id := range r.factories {
out = append(out, id)
}
return out
}
// IsKnown reports whether id has a registered factory.
func (r *Registry) IsKnown(id string) bool {
return r.Get(id) != nil
}
// Resolver wraps a Registry and produces a configured Middleware
// instance from a Spec. The Manager uses this during chain build.
type Resolver struct {
registry *Registry
}
// NewResolver returns a resolver backed by the registry.
func NewResolver(registry *Registry) *Resolver {
if registry == nil {
registry = NewRegistry()
}
return &Resolver{registry: registry}
}
// Resolve builds a Middleware instance and merges runtime-only fields
// (version, accepted content types, metadata key allowlist, mutation
// support) onto the spec.
//
// Return semantics:
// - (mw, mergedSpec, nil): instance built, include in chain.
// - (nil, spec, nil): id not registered; silently skip.
// - (nil, spec, err): factory rejected the config (logged + counted
// by Manager, other middlewares still bind).
func (r *Resolver) Resolve(spec Spec) (Middleware, Spec, error) {
f := r.registry.Get(spec.ID)
if f == nil {
return nil, spec, nil
}
mw, err := f.New(spec.RawConfig)
if err != nil {
return nil, spec, fmt.Errorf("middleware %s factory: %w", spec.ID, err)
}
if mw.Slot() != spec.Slot {
_ = mw.Close()
return nil, spec, fmt.Errorf("middleware %s slot mismatch: spec=%d impl=%d", spec.ID, spec.Slot, mw.Slot())
}
merged := spec
merged.Version = mw.Version()
merged.MetadataKeys = append([]string(nil), mw.MetadataKeys()...)
merged.AcceptedContentTypes = append([]string(nil), mw.AcceptedContentTypes()...)
merged.MutationsSupported = mw.MutationsSupported()
return mw, merged, nil
}
+44
View File
@@ -0,0 +1,44 @@
package middleware
import "time"
// Spec is the apply-time, validated representation of a per-target
// middleware configuration merged with the runtime-only fields
// compiled into the middleware implementation.
//
// The wire shape is RawConfig (JSON bytes) instead of the older
// params map[string]string. Each middleware unmarshals RawConfig into
// its own typed config struct, surfacing structural validation errors
// at construction rather than per-invocation lookups.
type Spec struct {
ID string
Slot Slot
Version string
Enabled bool
FailMode FailMode
Timeout time.Duration
RawConfig []byte
CanMutate bool
// Runtime-only fields populated from the registered middleware at
// chain build time; not sourced from proto.
MetadataKeys []string
AcceptedContentTypes []string
MutationsSupported bool
}
// Clone returns a deep copy of the spec safe to cache across mapping
// updates.
func (s Spec) Clone() Spec {
out := s
if len(s.RawConfig) > 0 {
out.RawConfig = append([]byte(nil), s.RawConfig...)
}
if len(s.MetadataKeys) > 0 {
out.MetadataKeys = append([]string(nil), s.MetadataKeys...)
}
if len(s.AcceptedContentTypes) > 0 {
out.AcceptedContentTypes = append([]string(nil), s.AcceptedContentTypes...)
}
return out
}
+253
View File
@@ -0,0 +1,253 @@
// Package middleware defines the per-target middleware chain that runs
// inside the reverse proxy hot path. It is the only chain wired into
// the request path.
//
// Concepts:
// - Slot: the position a middleware occupies in the chain. A
// middleware lives in exactly one slot — separate concerns become
// separate middlewares.
// - Decision: the on_request slot can DENY; on_response and terminal
// slots can only PASSTHROUGH. The dispatcher clamps decisions that
// violate this contract.
// - Metadata: the only side-channel between middlewares. Each
// middleware declares an allowlist of keys it may emit; the merger
// enforces caps and namespace rules.
package middleware
import "time"
// Slot identifies where in the request lifecycle a middleware runs.
// A middleware declares a single slot. Splitting per-purpose work
// (request parsing vs response parsing vs cost metering) into separate
// slot-keyed middlewares is the explicit architectural choice for the
// agent-network use case; no middleware participates in more than one
// slot.
type Slot int
const (
// SlotOnRequest runs before the upstream call. Middlewares in this
// slot may DENY the request, mutate headers/body (when permitted),
// and emit metadata derived from the request envelope.
SlotOnRequest Slot = 1
// SlotOnResponse runs after the upstream returns. Middlewares in
// this slot observe the response, emit metadata, and may mutate
// response headers when permitted. They cannot DENY.
SlotOnResponse Slot = 2
// SlotTerminal runs after every SlotOnResponse middleware has
// emitted. Terminal middlewares observe the full metadata bag and
// ship it to external sinks (access log, metrics export). They
// cannot DENY and cannot mutate the response.
SlotTerminal Slot = 3
)
// FailMode controls how the dispatcher reacts when a middleware
// returns an error, times out, or panics. Observer middlewares default
// to FailOpen; policy middlewares should default to FailClosed.
type FailMode int
const (
// FailOpen allows the request to proceed when a middleware fails.
FailOpen FailMode = 0
// FailClosed denies the request when a middleware fails. Only
// meaningful for SlotOnRequest middlewares.
FailClosed FailMode = 1
)
// Decision captures the outcome of a middleware invocation as observed
// by the dispatcher. Response-phase middlewares always return
// DecisionPassthrough; the dispatcher clamps any other value.
type Decision int
const (
// DecisionAllow lets the request proceed.
DecisionAllow Decision = 0
// DecisionDeny stops the chain and returns a rendered deny
// response. Only honoured in SlotOnRequest.
DecisionDeny Decision = 1
// DecisionPassthrough is the response-phase neutral outcome.
DecisionPassthrough Decision = 2
)
// Resource limits enforced by the proxy at config apply time and by
// the dispatcher at runtime. Per-target values supplied by management
// are clamped to these bounds.
const (
// MaxBodyCapBytes is the proxy-wide upper bound for per-direction
// body capture. Sized to hold a full LLM streaming response (token
// usage rides the trailing SSE event, so the captured prefix must
// reach the end of the stream); a single response is bounded by the
// model's max output tokens, so this is a real ceiling, not a
// treadmill. Request capture stays well under this — oversized
// requests use the tolerant routing scan instead of buffering.
MaxBodyCapBytes int64 = 8 << 20
// MinTimeout is the proxy-wide lower bound for per-middleware
// Invoke timeouts.
MinTimeout = 10 * time.Millisecond
// MaxTimeout is the proxy-wide upper bound for per-middleware
// Invoke timeouts.
MaxTimeout = 5 * time.Second
// DefaultTimeout is used when the per-target timeout is zero or
// unset.
DefaultTimeout = 500 * time.Millisecond
// MaxMiddlewareMetadataBytes is the per-middleware metadata total
// cap.
MaxMiddlewareMetadataBytes = 16 << 10
// MaxRequestMetadataBytes is the per-request metadata total cap
// across all middlewares in the chain. Earlier middlewares win
// when the budget is exhausted.
MaxRequestMetadataBytes = 32 << 10
// MaxMetadataKeyBytes is the maximum length of a metadata key.
MaxMetadataKeyBytes = 96
// MaxMetadataValueBytes is the maximum length of a metadata value.
MaxMetadataValueBytes = 4 << 10
// MaxMiddlewaresPerChain caps the number of middleware entries
// accepted per chain at the proxy translator and the management
// REST API. Mirrors the chain invocation cap so a misconfigured
// mapping cannot push the chain clone cost beyond a known bound.
MaxMiddlewaresPerChain = 16
)
// KV is the canonical header/metadata representation used across the
// middleware boundary. We use a slice of KV instead of http.Header
// because it preserves key order, is cheap to deep-copy per
// invocation, and is directly representable in a future protobuf
// envelope.
type KV struct {
Key string
Value string
}
// Input is the immutable envelope handed to each middleware. The
// dispatcher deep-copies Headers, Body, Metadata, RespHeaders, and
// RespBody before each invocation so middlewares cannot mutate the
// shared in-flight copies; mutations must flow through Output.Mutations.
type Input struct {
Slot Slot
RequestID string
TargetID string
Method string
URL string
Headers []KV
Body []byte
BodyTruncated bool
OriginalBodySize int64
Status int
RespHeaders []KV
RespBody []byte
RespBodyTruncated bool
OriginalRespSize int64
ServiceID string
AccountID string
UserID string
// UserEmail is the calling user's email address when the auth path
// resolves a user record. Empty for non-OIDC schemes (PIN/Password/
// Header) and for legacy session JWTs minted before the email claim
// was introduced. Identity-stamping middlewares (e.g.
// llm_identity_inject) prefer this over UserID for upstream gateways
// that key budgets / attribution on a human-readable identifier.
UserEmail string
AuthMethod string
SourceIP string
// UserGroups captures the calling peer's group memberships at
// request time, surfaced from the proxy's auth flow so policy-aware
// middlewares can authorise without an extra management round-trip.
UserGroups []string
// UserGroupNames carries the human-readable display names paired
// positionally with UserGroups (UserGroupNames[i] is the name of
// UserGroups[i]). Identity-stamping middlewares prefer names for
// upstream tags so attribution dashboards stay readable. Slice may
// be shorter than UserGroups for tokens minted before names were
// resolvable; consumers should fall back to ids for missing
// positions.
UserGroupNames []string
Metadata []KV
// AgentNetwork is true when the target is a synthesised
// agent-network service. Carried on the input so the access-log
// terminal middleware can stamp the proto field without re-deriving
// from the service ID.
AgentNetwork bool
}
// DenyReason is the structured payload a middleware returns alongside
// a DecisionDeny. The proxy renders it through a fixed JSON template
// so middlewares cannot emit arbitrary bytes to the wire.
type DenyReason struct {
Code string
Message string
Details map[string]string
}
// Output is the value each middleware returns to the dispatcher. The
// dispatcher applies the output filter (clamp, mutations gate) before
// any side effect reaches the shared request.
type Output struct {
Decision Decision
DenyStatus int
DenyReason *DenyReason
Metadata []KV
Mutations *Mutations
}
// Mutations describes the deltas a middleware wants applied to the
// in-flight request. The dispatcher filters HeadersAdd/HeadersRemove
// through the compiled-in denylist and runs BodyReplace through the
// body policy before anything is applied. RewriteUpstream redirects
// the outbound target (scheme + host) for the request; the chain
// returns the latest non-nil rewrite to the reverse proxy.
type Mutations struct {
HeadersAdd []KV
HeadersRemove []string
BodyReplace []byte
RewriteUpstream *UpstreamRewrite
}
// UpstreamRewrite redirects the request's outbound target. Only
// scheme+host are honoured; path, query, and body are untouched. The
// reverse proxy reads the rewrite (when non-nil) instead of the
// PathTarget URL configured by the synth, so a single shared synth
// service can fan out to many upstreams selected per request.
//
// AuthHeader and StripHeaders carry the upstream auth substitution
// the router needs. They bypass the framework's HeadersAdd /
// HeadersRemove denylist (which blocks Authorization, Cookie, etc.
// from middleware mutation) on the grounds that the proxy itself is
// the entity rewriting auth here, not an arbitrary middleware. The
// reverse proxy applies them directly to the upstream request after
// the chain's regular mutation phase, so a malicious or misconfigured
// middleware can still emit RewriteUpstream but only the proxy's
// trusted upstream-build path actually unpacks AuthHeader.
type UpstreamRewrite struct {
Scheme string
Host string
// Path, when non-empty, replaces the path component of the
// proxy's effective upstream URL. The rewrite path is then joined
// with the agent's request path by httputil.ProxyRequest.SetURL —
// e.g. rewrite Path="/v1/{account}/{gateway}/compat" + agent
// request "/chat/completions" → outbound
// "/v1/{account}/{gateway}/compat/chat/completions". Used by
// llm_router to honor the operator-configured upstream path on
// gateways like Cloudflare AI Gateway whose URL contains
// account / gateway segments that the agent's app doesn't know
// about. Empty Path leaves the original target's path
// untouched (the historical behavior).
Path string
// StripPathPrefix, when non-empty, is removed from the front of the agent's
// request path before it is joined onto the upstream URL. Used for
// gateway-namespace prefixes (e.g. a client addressing Bedrock as
// "/bedrock/model/{id}/invoke") that must not reach the real upstream, whose
// native path is "/model/{id}/invoke". Empty leaves the request path intact.
StripPathPrefix string
AuthHeader *AuthHeader
StripHeaders []string
}
// AuthHeader is a single name/value pair the proxy injects on the
// upstream request after stripping the client's auth headers.
type AuthHeader struct {
Name string
Value string
}