mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-08 07:51:28 +02:00
[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:
173
proxy/server.go
173
proxy/server.go
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user