mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-21 06:09:07 +02:00
[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:
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user