mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-16 11:39:57 +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.
247 lines
11 KiB
Go
247 lines
11 KiB
Go
package proxy
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"google.golang.org/protobuf/types/known/durationpb"
|
|
|
|
"github.com/netbirdio/netbird/proxy/internal/middleware"
|
|
"github.com/netbirdio/netbird/shared/management/proto"
|
|
)
|
|
|
|
// stubFactory builds a stub Middleware so the registry's IsKnown check
|
|
// passes for the configured id. The translator never invokes the
|
|
// middleware, so the methods only need to satisfy the interface.
|
|
type stubFactory struct {
|
|
id string
|
|
slot middleware.Slot
|
|
}
|
|
|
|
func (f stubFactory) ID() string { return f.id }
|
|
func (f stubFactory) New(_ []byte) (middleware.Middleware, error) {
|
|
return stubMiddleware(f), nil
|
|
}
|
|
|
|
type stubMiddleware struct {
|
|
id string
|
|
slot middleware.Slot
|
|
}
|
|
|
|
func (m stubMiddleware) ID() string { return m.id }
|
|
func (m stubMiddleware) Version() string { return "test" }
|
|
func (m stubMiddleware) Slot() middleware.Slot { return m.slot }
|
|
func (m stubMiddleware) AcceptedContentTypes() []string { return nil }
|
|
func (m stubMiddleware) MetadataKeys() []string { return nil }
|
|
func (m stubMiddleware) MutationsSupported() bool { return false }
|
|
func (m stubMiddleware) Close() error { return nil }
|
|
func (m stubMiddleware) Invoke(context.Context, *middleware.Input) (*middleware.Output, error) {
|
|
panic("stubMiddleware.Invoke must not be called in translator tests")
|
|
}
|
|
|
|
// newTestRegistry returns a fresh registry pre-populated with the given
|
|
// middleware ids in the matching slot.
|
|
func newTestRegistry(t *testing.T, entries map[string]middleware.Slot) *middleware.Registry {
|
|
t.Helper()
|
|
r := middleware.NewRegistry()
|
|
for id, slot := range entries {
|
|
require.NoError(t, r.Register(stubFactory{id: id, slot: slot}), "stub registration must succeed")
|
|
}
|
|
return r
|
|
}
|
|
|
|
func TestTranslateMiddlewareConfigs_EmptyInput(t *testing.T) {
|
|
assert.Nil(t, translateMiddlewareConfigs(context.Background(), "target-a", nil, nil),
|
|
"nil input should translate to nil")
|
|
assert.Nil(t, translateMiddlewareConfigs(context.Background(), "target-a", []*proto.MiddlewareConfig{}, nil),
|
|
"empty input should translate to nil")
|
|
}
|
|
|
|
func TestTranslateMiddlewareConfigs_KnownIDs(t *testing.T) {
|
|
registry := newTestRegistry(t, map[string]middleware.Slot{
|
|
"llm_request_parser": middleware.SlotOnRequest,
|
|
"llm_response_parser": middleware.SlotOnResponse,
|
|
})
|
|
in := []*proto.MiddlewareConfig{
|
|
{
|
|
Id: "llm_request_parser",
|
|
Enabled: true,
|
|
Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST,
|
|
ConfigJson: []byte(`{"foo":"bar"}`),
|
|
FailMode: proto.MiddlewareConfig_FAIL_OPEN,
|
|
Timeout: durationpb.New(250 * time.Millisecond),
|
|
CanMutate: true,
|
|
},
|
|
{
|
|
Id: "llm_response_parser",
|
|
Enabled: false,
|
|
Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_RESPONSE,
|
|
ConfigJson: nil,
|
|
FailMode: proto.MiddlewareConfig_FAIL_CLOSED,
|
|
Timeout: durationpb.New(50 * time.Millisecond),
|
|
},
|
|
}
|
|
|
|
out := translateMiddlewareConfigs(context.Background(), "target-a", in, registry)
|
|
require.Len(t, out, 2, "two known middlewares should produce two specs")
|
|
|
|
assert.Equal(t, "llm_request_parser", out[0].ID, "first id should match")
|
|
assert.Equal(t, middleware.SlotOnRequest, out[0].Slot, "first slot should be on_request")
|
|
assert.True(t, out[0].Enabled, "first spec should be enabled")
|
|
assert.Equal(t, middleware.FailOpen, out[0].FailMode, "first spec should be fail-open")
|
|
assert.Equal(t, 250*time.Millisecond, out[0].Timeout, "first spec timeout should pass through")
|
|
assert.True(t, out[0].CanMutate, "first spec should permit mutations")
|
|
assert.Equal(t, []byte(`{"foo":"bar"}`), out[0].RawConfig, "first spec raw config should match")
|
|
|
|
assert.Equal(t, "llm_response_parser", out[1].ID, "second id should match")
|
|
assert.Equal(t, middleware.SlotOnResponse, out[1].Slot, "second slot should be on_response")
|
|
assert.False(t, out[1].Enabled, "second spec should be disabled")
|
|
assert.Equal(t, middleware.FailClosed, out[1].FailMode, "second spec should be fail-closed")
|
|
assert.Equal(t, 50*time.Millisecond, out[1].Timeout, "second spec timeout should pass through")
|
|
assert.Nil(t, out[1].RawConfig, "second spec raw config should be nil")
|
|
}
|
|
|
|
func TestTranslateMiddlewareConfigs_UnknownIDSkipped(t *testing.T) {
|
|
registry := newTestRegistry(t, map[string]middleware.Slot{
|
|
"llm_request_parser": middleware.SlotOnRequest,
|
|
})
|
|
in := []*proto.MiddlewareConfig{
|
|
{Id: "llm_request_parser", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST},
|
|
{Id: "not_registered", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST},
|
|
}
|
|
out := translateMiddlewareConfigs(context.Background(), "target-unknown", in, registry)
|
|
require.Len(t, out, 1, "unknown id must be skipped")
|
|
assert.Equal(t, "llm_request_parser", out[0].ID, "remaining entry should be the known one")
|
|
}
|
|
|
|
func TestTranslateMiddlewareConfigs_NilRegistrySkipsValidation(t *testing.T) {
|
|
in := []*proto.MiddlewareConfig{
|
|
{Id: "anything_goes", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST},
|
|
}
|
|
out := translateMiddlewareConfigs(context.Background(), "target-nilreg", in, nil)
|
|
require.Len(t, out, 1, "nil registry must accept any non-empty id")
|
|
assert.Equal(t, "anything_goes", out[0].ID, "id should pass through unchecked")
|
|
}
|
|
|
|
func TestTranslateMiddlewareConfigs_TimeoutClamps(t *testing.T) {
|
|
registry := newTestRegistry(t, map[string]middleware.Slot{
|
|
"llm_request_parser": middleware.SlotOnRequest,
|
|
})
|
|
in := []*proto.MiddlewareConfig{
|
|
{Id: "llm_request_parser", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST, Timeout: nil},
|
|
{Id: "llm_request_parser", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST, Timeout: durationpb.New(time.Microsecond)},
|
|
{Id: "llm_request_parser", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST, Timeout: durationpb.New(time.Hour)},
|
|
}
|
|
out := translateMiddlewareConfigs(context.Background(), "target-clamp", in, registry)
|
|
require.Len(t, out, 3, "clamping must keep all three entries")
|
|
assert.Equal(t, middleware.DefaultTimeout, out[0].Timeout, "zero timeout should default")
|
|
assert.Equal(t, middleware.MinTimeout, out[1].Timeout, "below-min timeout should clamp up")
|
|
assert.Equal(t, middleware.MaxTimeout, out[2].Timeout, "above-max timeout should clamp down")
|
|
}
|
|
|
|
func TestTranslateMiddlewareConfigs_FailModeMapping(t *testing.T) {
|
|
registry := newTestRegistry(t, map[string]middleware.Slot{
|
|
"llm_request_parser": middleware.SlotOnRequest,
|
|
})
|
|
in := []*proto.MiddlewareConfig{
|
|
{Id: "llm_request_parser", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST},
|
|
{Id: "llm_request_parser", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST, FailMode: proto.MiddlewareConfig_FAIL_CLOSED},
|
|
}
|
|
out := translateMiddlewareConfigs(context.Background(), "target-failmode", in, registry)
|
|
require.Len(t, out, 2, "both entries should translate")
|
|
assert.Equal(t, middleware.FailOpen, out[0].FailMode, "default fail mode should be open")
|
|
assert.Equal(t, middleware.FailClosed, out[1].FailMode, "explicit fail closed should map")
|
|
}
|
|
|
|
func TestTranslateMiddlewareConfigs_SlotMapping(t *testing.T) {
|
|
registry := newTestRegistry(t, map[string]middleware.Slot{
|
|
"req": middleware.SlotOnRequest,
|
|
"resp": middleware.SlotOnResponse,
|
|
"term": middleware.SlotTerminal,
|
|
})
|
|
in := []*proto.MiddlewareConfig{
|
|
{Id: "req", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST},
|
|
{Id: "resp", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_RESPONSE},
|
|
{Id: "term", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_TERMINAL},
|
|
{Id: "req", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_UNSPECIFIED},
|
|
}
|
|
out := translateMiddlewareConfigs(context.Background(), "target-slot", in, registry)
|
|
require.Len(t, out, 3, "unspecified slot entry must be skipped")
|
|
assert.Equal(t, middleware.SlotOnRequest, out[0].Slot, "on_request slot mapping")
|
|
assert.Equal(t, middleware.SlotOnResponse, out[1].Slot, "on_response slot mapping")
|
|
assert.Equal(t, middleware.SlotTerminal, out[2].Slot, "terminal slot mapping")
|
|
}
|
|
|
|
func TestTranslateMiddlewareConfigs_EmptyIDSkipped(t *testing.T) {
|
|
registry := newTestRegistry(t, map[string]middleware.Slot{
|
|
"llm_request_parser": middleware.SlotOnRequest,
|
|
})
|
|
in := []*proto.MiddlewareConfig{
|
|
{Id: "", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST},
|
|
{Id: "llm_request_parser", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST},
|
|
}
|
|
out := translateMiddlewareConfigs(context.Background(), "target-empty-id", in, registry)
|
|
require.Len(t, out, 1, "empty id must be dropped")
|
|
assert.Equal(t, "llm_request_parser", out[0].ID, "remaining entry should be valid")
|
|
}
|
|
|
|
// TestTranslateMiddlewareConfigs_TruncatesAboveCap proves the translator
|
|
// truncates lists that exceed MaxMiddlewaresPerChain rather than dropping
|
|
// the whole slice, matching the documented G3 behaviour.
|
|
func TestTranslateMiddlewareConfigs_TruncatesAboveCap(t *testing.T) {
|
|
registry := newTestRegistry(t, map[string]middleware.Slot{
|
|
"llm_request_parser": middleware.SlotOnRequest,
|
|
})
|
|
overCap := middleware.MaxMiddlewaresPerChain + 1
|
|
in := make([]*proto.MiddlewareConfig, 0, overCap)
|
|
for i := 0; i < overCap; i++ {
|
|
in = append(in, &proto.MiddlewareConfig{
|
|
Id: "llm_request_parser",
|
|
Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST,
|
|
})
|
|
}
|
|
out := translateMiddlewareConfigs(context.Background(), "target-truncate", in, registry)
|
|
assert.Len(t, out, middleware.MaxMiddlewaresPerChain, "over-cap input must be truncated to MaxMiddlewaresPerChain")
|
|
}
|
|
|
|
func TestTranslateMiddlewareConfigs_AllowsListAtCap(t *testing.T) {
|
|
registry := newTestRegistry(t, map[string]middleware.Slot{
|
|
"llm_request_parser": middleware.SlotOnRequest,
|
|
})
|
|
in := make([]*proto.MiddlewareConfig, 0, middleware.MaxMiddlewaresPerChain)
|
|
for i := 0; i < middleware.MaxMiddlewaresPerChain; i++ {
|
|
in = append(in, &proto.MiddlewareConfig{
|
|
Id: "llm_request_parser",
|
|
Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST,
|
|
})
|
|
}
|
|
out := translateMiddlewareConfigs(context.Background(), "target-cap", in, registry)
|
|
assert.Len(t, out, middleware.MaxMiddlewaresPerChain, "list at the cap boundary must translate fully")
|
|
}
|
|
|
|
func TestProtoToMiddlewareSlot(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in proto.MiddlewareSlot
|
|
want middleware.Slot
|
|
wantOk bool
|
|
}{
|
|
{"unspecified", proto.MiddlewareSlot_MIDDLEWARE_SLOT_UNSPECIFIED, 0, false},
|
|
{"on_request", proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST, middleware.SlotOnRequest, true},
|
|
{"on_response", proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_RESPONSE, middleware.SlotOnResponse, true},
|
|
{"terminal", proto.MiddlewareSlot_MIDDLEWARE_SLOT_TERMINAL, middleware.SlotTerminal, true},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, ok := protoToMiddlewareSlot(tc.in)
|
|
assert.Equal(t, tc.wantOk, ok, "ok flag for %s", tc.name)
|
|
if tc.wantOk {
|
|
assert.Equal(t, tc.want, got, "slot mapping for %s", tc.name)
|
|
}
|
|
})
|
|
}
|
|
}
|