mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-17 03:59:56 +00:00
* [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.
345 lines
9.8 KiB
Go
345 lines
9.8 KiB
Go
// 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
|
|
}
|