[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.
This commit is contained in:
Maycon Santos
2026-06-27 13:41:00 +02:00
committed by GitHub
parent 615631567a
commit b416063bcc
187 changed files with 36835 additions and 660 deletions

View File

@@ -55,6 +55,8 @@ import (
"github.com/netbirdio/netbird/proxy/internal/health"
"github.com/netbirdio/netbird/proxy/internal/k8s"
proxymetrics "github.com/netbirdio/netbird/proxy/internal/metrics"
"github.com/netbirdio/netbird/proxy/internal/middleware"
mwbuiltin "github.com/netbirdio/netbird/proxy/internal/middleware/builtin"
"github.com/netbirdio/netbird/proxy/internal/netutil"
"github.com/netbirdio/netbird/proxy/internal/proxy"
"github.com/netbirdio/netbird/proxy/internal/restrict"
@@ -77,29 +79,36 @@ type portRouter struct {
type Server struct {
ctx context.Context
mgmtClient proto.ProxyServiceClient
proxy *proxy.ReverseProxy
netbird *roundtrip.NetBird
acme *acme.Manager
mgmtClient proto.ProxyServiceClient
proxy *proxy.ReverseProxy
netbird *roundtrip.NetBird
acme *acme.Manager
staticCertWatcher *certwatch.Watcher
auth *auth.Middleware
http *http.Server
https *http.Server
debug *http.Server
healthServer *health.Server
healthChecker *health.Checker
meter *proxymetrics.Metrics
accessLog *accesslog.Logger
mainRouter *nbtcp.Router
mainPort uint16
udpMu sync.Mutex
udpRelays map[types.ServiceID]*udprelay.Relay
udpRelayWg sync.WaitGroup
portMu sync.RWMutex
portRouters map[uint16]*portRouter
svcPorts map[types.ServiceID][]uint16
lastMappings map[types.ServiceID]*proto.ProxyMapping
portRouterWg sync.WaitGroup
auth *auth.Middleware
http *http.Server
https *http.Server
debug *http.Server
healthServer *health.Server
healthChecker *health.Checker
meter *proxymetrics.Metrics
accessLog *accesslog.Logger
// middlewareManager drives per-target middleware dispatch. Always
// constructed during boot; an empty registry produces empty chains and
// the reverse-proxy stays on the no-capture fast path.
middlewareManager *middleware.Manager
// middlewareRegistry is the source of registered middleware factories.
// Concrete middlewares register themselves through init().
middlewareRegistry *middleware.Registry
mainRouter *nbtcp.Router
mainPort uint16
udpMu sync.Mutex
udpRelays map[types.ServiceID]*udprelay.Relay
udpRelayWg sync.WaitGroup
portMu sync.RWMutex
portRouters map[uint16]*portRouter
svcPorts map[types.ServiceID][]uint16
lastMappings map[types.ServiceID]*proto.ProxyMapping
portRouterWg sync.WaitGroup
// hijackTracker tracks hijacked connections (e.g. WebSocket upgrades)
// so they can be closed during graceful shutdown, since http.Server.Shutdown
@@ -236,8 +245,20 @@ type Server struct {
// in processMappings before the receive loop reconnects to resync.
// Zero uses defaultMappingBatchWatchdog.
MappingBatchWatchdog time.Duration
// MiddlewareDataDir is the base directory the middleware system uses to
// resolve file-backed configuration (e.g. the cost_meter pricing table).
// Empty means any middleware that requires a file fails at configure time.
MiddlewareDataDir string
// MiddlewareCaptureBudgetBytes overrides the proxy-wide in-flight capture
// budget passed to middleware.NewManager. Zero or negative values fall
// back to defaultMiddlewareCaptureBudgetBytes (256 MiB).
MiddlewareCaptureBudgetBytes int64
}
// defaultMiddlewareCaptureBudgetBytes is the proxy-wide in-flight capture cap
// passed to middleware.NewManager when MiddlewareCaptureBudgetBytes is unset.
const defaultMiddlewareCaptureBudgetBytes = 256 << 20
// clampIdleTimeout returns d capped to MaxSessionIdleTimeout when configured.
func (s *Server) clampIdleTimeout(d time.Duration) time.Duration {
if s.MaxSessionIdleTimeout > 0 && d > s.MaxSessionIdleTimeout {
@@ -343,6 +364,15 @@ func (s *Server) Start(ctx context.Context) error {
return err
}
// Management client must be initialised BEFORE the middleware manager —
// initMiddlewareManager passes s.mgmtClient into the builtin FactoryContext
// that the limit-check / limit-record middlewares pull from. Reversed
// order would silently disable enforcement (mgmt=nil → allow-without-
// attribution + no-record).
if err := s.initMiddlewareManager(ctx); err != nil {
return fmt.Errorf("init middleware manager: %w", err)
}
runCtx, runCancel := context.WithCancel(ctx)
s.runCancel = runCancel
@@ -562,7 +592,11 @@ func (s *Server) initNetBirdClient() {
// proxy host's resolver instead of the tunnel's DNS.
func (s *Server) initReverseProxy() {
upstreamRT := roundtrip.NewMultiTransport(s.netbird, s.Logger)
s.proxy = proxy.NewReverseProxy(s.meter.RoundTripper(upstreamRT), s.ForwardedProto, s.TrustedProxies, s.Logger)
var rpOpts []proxy.Option
if s.middlewareManager != nil {
rpOpts = append(rpOpts, proxy.WithMiddlewareManager(s.middlewareManager))
}
s.proxy = proxy.NewReverseProxy(s.meter.RoundTripper(upstreamRT), s.ForwardedProto, s.TrustedProxies, s.Logger, rpOpts...)
}
// initGeoLookup configures the GeoLite2 lookup used for country-based
@@ -2047,9 +2081,94 @@ func (s *Server) updateMapping(ctx context.Context, mapping *proto.ProxyMapping)
m := s.protoToMapping(ctx, mapping)
s.proxy.AddMapping(m)
s.meter.AddMapping(m)
s.rebuildMiddlewareChains(svcID, m)
return nil
}
// initMiddlewareManager wires the middleware subsystem at boot. It configures
// the per-process FactoryContext concrete middlewares consult, installs the
// live-service check, and binds the resolver to the registry concrete
// middlewares register themselves into via init().
func (s *Server) initMiddlewareManager(ctx context.Context) error {
if s.meter == nil {
return fmt.Errorf("middleware manager requires metrics bundle")
}
otelMeter := s.meter.Meter()
mwbuiltin.Configure(ctx, s.MiddlewareDataDir, otelMeter, s.Logger, s.mgmtClient)
mwMetrics, err := middleware.NewMetrics(otelMeter)
if err != nil {
return fmt.Errorf("init middleware metrics: %w", err)
}
budgetBytes := s.MiddlewareCaptureBudgetBytes
if budgetBytes <= 0 {
budgetBytes = defaultMiddlewareCaptureBudgetBytes
}
registry := mwbuiltin.DefaultRegistry()
mgr := middleware.NewManager(budgetBytes, mwMetrics, s.Logger)
mgr.SetResolver(middleware.NewResolver(registry))
mgr.SetLiveServiceCheck(s.isLiveService)
s.middlewareRegistry = registry
s.middlewareManager = mgr
ids := registry.IDs()
s.Logger.Infof("middleware system enabled: %d built-in middlewares registered %v, capture budget %d bytes",
len(ids), ids, budgetBytes)
return nil
}
// rebuildMiddlewareChains converts m into per-path bindings and calls
// Manager.Rebuild. Short-circuits when the middleware manager is unset.
func (s *Server) rebuildMiddlewareChains(svcID types.ServiceID, m proxy.Mapping) {
if s.middlewareManager == nil {
return
}
bindings := buildMiddlewareBindings(svcID, m)
if err := s.middlewareManager.Rebuild(string(svcID), bindings); err != nil {
s.Logger.WithError(err).WithField("service_id", svcID).Error("failed to rebuild middleware chains")
}
}
// isLiveService reports whether svcID is currently present in the live
// mapping cache. Used by the middleware manager to confirm a chain is still
// referenced before rebuilding it from cached bindings.
func (s *Server) isLiveService(svcID string) bool {
s.portMu.RLock()
defer s.portMu.RUnlock()
_, ok := s.lastMappings[types.ServiceID(svcID)]
return ok
}
// invalidateMiddlewareChains drops every middleware chain registered for svcID.
func (s *Server) invalidateMiddlewareChains(svcID types.ServiceID) {
if s.middlewareManager == nil {
return
}
s.middlewareManager.Invalidate(string(svcID))
}
// buildMiddlewareBindings converts the path targets of m into the per-path
// binding list the middleware manager's Rebuild expects. Targets without any
// middleware specs are skipped.
func buildMiddlewareBindings(svcID types.ServiceID, m proxy.Mapping) []middleware.PathTargetBinding {
if len(m.Paths) == 0 {
return nil
}
bindings := make([]middleware.PathTargetBinding, 0, len(m.Paths))
for pathID, pt := range m.Paths {
if pt == nil || len(pt.Middlewares) == 0 {
continue
}
bindings = append(bindings, middleware.PathTargetBinding{
ServiceID: string(svcID),
PathID: pathID,
Specs: pt.Middlewares,
})
}
return bindings
}
// removeMapping tears down routes/relays and the NetBird peer for a service.
// Uses the stored mapping state when available to ensure all previously
// configured routes are cleaned up.
@@ -2085,6 +2204,8 @@ func (s *Server) cleanupMappingRoutes(mapping *proto.ProxyMapping) {
svcID := types.ServiceID(mapping.GetId())
host := mapping.GetDomain()
s.invalidateMiddlewareChains(svcID)
// HTTP/TLS cleanup (only relevant when a domain is set).
if host != "" {
d := domain.Domain(host)
@@ -2192,6 +2313,12 @@ func (s *Server) protoToMapping(ctx context.Context, mapping *proto.ProxyMapping
pt.RequestTimeout = d.AsDuration()
}
pt.DirectUpstream = opts.GetDirectUpstream()
// Agent-network middleware specs + capture config + flag ride on
// the same per-target options.
pt.CaptureConfig = translateMiddlewareCaptureConfig(mapping.GetId(), opts)
pt.Middlewares = translateMiddlewareConfigs(ctx, mapping.GetId(), opts.GetMiddlewares(), s.middlewareRegistry)
pt.AgentNetwork = opts.GetAgentNetwork()
pt.DisableAccessLog = opts.GetDisableAccessLog()
}
pt.RequestTimeout = s.clampDialTimeout(pt.RequestTimeout)
paths[pathMapping.GetPath()] = pt