Files
netbird/proxy/internal/middleware/dispatcher.go
Maycon Santos b416063bcc [management,proxy] Agent network: per-account LLM gateway (policy, metering, multi-provider) (#6555)
* [agent-network] Shared proto, OpenAPI schema, and generated types

* [agent-network] Management: store, manager, synthesizer, policy engine, provider catalog, HTTP/gRPC API

Adds the account-scoped agent-network module: provider/policy/budget CRUD and
store, the reverse-proxy service synthesizer, policy selection + limit
enforcement, the provider catalog (incl. Vertex AI and AWS Bedrock entries),
and the management HTTP + proxy gRPC surfaces.

* [management] Fix agent-network proxy-peer fan-out on affected-peer recompute

The affected-peers resolver loaded only persisted reverse-proxy services, but
agent-network services are synthesized on demand and never persisted. As a
result the embedded proxy peer was never folded into the affected set when a
client's group changed, so the proxy received no network-map update for a newly
authorised client and rejected its handshake until a full resync (restart).

loadProxyServices now merges the synthesized agent-network services (injected
via a registration hook to avoid an import cycle), so proxy peers learn newly
authorised clients immediately.

* [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.

* [proxy] LLM parsers, pricing, and builtin middlewares (OpenAI, Anthropic, Vertex AI, AWS Bedrock)

Request/response parsers and SSE/event-stream metering, the embedded pricing
table, and the builtin middleware set: request parser, router, policy
limit-check/record, cost meter, guardrail, identity inject, response parser.
Includes the path-routed providers — Google Vertex AI (keyfile:: service-account
OAuth minting) and AWS Bedrock (bearer auth, invoke/converse/streaming, optional
/bedrock prefix) — plus the Models allowlist and unmeterable-publisher deny.

* [proxy] IPv6 in-place apply and TCP accept-loop hardening on netstack listeners

* [agent-network] End-to-end test suite, module docs, and deployment preset

* [agent-network] Fix codespell typos and exclude false positives

- labelgen word pool: vermillion -> vermilion, racoon -> raccoon.
- codespell ignore list: add flate (Go compress/flate package), recordin
  (a test-local identifier), and unparseable (a valid alternative spelling used
  consistently across identifiers + a metadata-value constant).

* [management] Set LastSeen on injected proxy peer in realstack test (MySQL strict-mode)

The injected embedded proxy peer had a PeerStatus with a zero LastSeen, which
serializes to '0000-00-00' and is rejected by MySQL in strict mode (SQLite
tolerates it). Set LastSeen to a valid time so SaveAccount succeeds on both
engines.

* [agent-network] Remove e2e shell-script suite from this branch

The end-to-end shell scripts under scripts/e2e/ are maintained in a separate
testing suite and are not part of this change set.

* [agent-network] Polish module docs: remove internal review scaffolding, fix links, verify diagrams

Strip PR-review framing, commit references, absolute paths, and stale internal
references from the agent-network module docs; fix broken relative links; verify
all diagrams against the current architecture. Remove the internal AI-reviewer
prompt file.

* [management] Refine session expiration handling to support 3-state encoding for SSO deadlines

* [agent-network] Relocate agentnetwork package to internals/modules

Move management/server/agentnetwork (and its catalog/, labelgen/, types/
subpackages) to management/internals/modules/agentnetwork, alongside the
reverse-proxy module, and rewrite all importers. Pure relocation: package names,
the synthesizer + affectedpeers registration hook, and store access (shared
store.Store) are unchanged, so no import cycle is introduced (affectedpeers
still depends only on the agentnetwork/types leaf).

* [agent-network] Co-locate HTTP handlers in the module (RegisterEndpoints)

Move the agent-network HTTP handlers from server/http/handlers/agentnetwork into
the module at internals/modules/agentnetwork/handlers (package handlers) and
rename the entrypoint AddEndpoints -> RegisterEndpoints, matching the
reverse-proxy module convention. Wiring in http/handler.go updated accordingly.
2026-06-27 13:41:00 +02:00

190 lines
4.9 KiB
Go

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
}