mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-25 16:19:07 +02: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.
187 lines
7.2 KiB
Go
187 lines
7.2 KiB
Go
package llm_limit_check
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"google.golang.org/grpc"
|
|
|
|
"github.com/netbirdio/netbird/proxy/internal/middleware"
|
|
"github.com/netbirdio/netbird/shared/management/proto"
|
|
)
|
|
|
|
// fakeMgmt is a minimal builtin.MgmtClient stub that lets the test
|
|
// drive CheckLLMPolicyLimits responses without a real gRPC dial.
|
|
type fakeMgmt struct {
|
|
checkResp *proto.CheckLLMPolicyLimitsResponse
|
|
checkErr error
|
|
checkReq *proto.CheckLLMPolicyLimitsRequest
|
|
}
|
|
|
|
func (f *fakeMgmt) CheckLLMPolicyLimits(_ context.Context, in *proto.CheckLLMPolicyLimitsRequest, _ ...grpc.CallOption) (*proto.CheckLLMPolicyLimitsResponse, error) {
|
|
f.checkReq = in
|
|
return f.checkResp, f.checkErr
|
|
}
|
|
|
|
func (f *fakeMgmt) RecordLLMUsage(_ context.Context, _ *proto.RecordLLMUsageRequest, _ ...grpc.CallOption) (*proto.RecordLLMUsageResponse, error) {
|
|
return &proto.RecordLLMUsageResponse{}, nil
|
|
}
|
|
|
|
func runInvoke(t *testing.T, m *Middleware, in *middleware.Input) *middleware.Output {
|
|
t.Helper()
|
|
out, err := m.Invoke(context.Background(), in)
|
|
require.NoError(t, err, "Invoke must not propagate transport errors")
|
|
require.NotNil(t, out, "Invoke must always return an Output")
|
|
return out
|
|
}
|
|
|
|
// TestInvoke_AllowStampsAttributionMetadata covers the happy path:
|
|
// management returns an allow decision with selected_policy_id +
|
|
// attribution_group_id + window_seconds, the middleware emits all three
|
|
// onto the metadata bag so the post-flight llm_limit_record
|
|
// middleware has everything it needs to tick the right counter.
|
|
func TestInvoke_AllowStampsAttributionMetadata(t *testing.T) {
|
|
mgmt := &fakeMgmt{
|
|
checkResp: &proto.CheckLLMPolicyLimitsResponse{
|
|
Decision: "allow",
|
|
SelectedPolicyId: "pol-X",
|
|
AttributionGroupId: "grp-engineers",
|
|
WindowSeconds: 86_400,
|
|
},
|
|
}
|
|
m := New(mgmt, nil)
|
|
|
|
out := runInvoke(t, m, &middleware.Input{
|
|
AccountID: "acc-1",
|
|
UserID: "user-bob",
|
|
UserGroups: []string{"grp-engineers"},
|
|
Metadata: []middleware.KV{
|
|
{Key: middleware.KeyLLMResolvedProviderID, Value: "prov-1"},
|
|
{Key: middleware.KeyLLMModel, Value: "gpt-4o"},
|
|
},
|
|
})
|
|
|
|
assert.Equal(t, middleware.DecisionAllow, out.Decision)
|
|
assert.Equal(t, "acc-1", mgmt.checkReq.GetAccountId(), "account_id must round-trip onto the RPC")
|
|
assert.Equal(t, "user-bob", mgmt.checkReq.GetUserId())
|
|
assert.Equal(t, []string{"grp-engineers"}, mgmt.checkReq.GetGroupIds())
|
|
assert.Equal(t, "prov-1", mgmt.checkReq.GetProviderId(), "resolved provider id must come from metadata")
|
|
assert.Equal(t, "gpt-4o", mgmt.checkReq.GetModel(), "model must come from metadata")
|
|
|
|
want := map[string]string{
|
|
middleware.KeyLLMPolicyDecision: "allow",
|
|
middleware.KeyLLMSelectedPolicyID: "pol-X",
|
|
middleware.KeyLLMAttributionGroupID: "grp-engineers",
|
|
middleware.KeyLLMAttributionWindowS: "86400",
|
|
}
|
|
got := map[string]string{}
|
|
for _, kv := range out.Metadata {
|
|
got[kv.Key] = kv.Value
|
|
}
|
|
assert.Equal(t, want, got, "attribution metadata must land on the bag for the response leg to consume")
|
|
}
|
|
|
|
// TestInvoke_DenyConvertsToProxyDeny proves the deny envelope round-
|
|
// trips: management's deny code becomes the proxy framework's deny
|
|
// payload at status 403, and the deny reason text is preserved so
|
|
// operators can debug from the access log.
|
|
func TestInvoke_DenyConvertsToProxyDeny(t *testing.T) {
|
|
mgmt := &fakeMgmt{
|
|
checkResp: &proto.CheckLLMPolicyLimitsResponse{
|
|
Decision: "deny",
|
|
DenyCode: "llm_policy.token_cap_exceeded",
|
|
DenyReason: "group token cap exhausted on policy pol-X (used 1000 of 1000)",
|
|
},
|
|
}
|
|
m := New(mgmt, nil)
|
|
|
|
out := runInvoke(t, m, &middleware.Input{
|
|
AccountID: "acc-1",
|
|
UserGroups: []string{"grp-engineers"},
|
|
Metadata: []middleware.KV{{Key: middleware.KeyLLMResolvedProviderID, Value: "prov-1"}},
|
|
})
|
|
|
|
assert.Equal(t, middleware.DecisionDeny, out.Decision)
|
|
assert.Equal(t, 403, out.DenyStatus, "policy denials are 403 — same as llm_router's")
|
|
require.NotNil(t, out.DenyReason, "deny envelope must carry a reason payload")
|
|
assert.Equal(t, "llm_policy.token_cap_exceeded", out.DenyReason.Code, "canonical deny code surfaces to the caller")
|
|
// The public message must stay generic: the management reason names
|
|
// internal quota detail (used/cap, rule id) that must not leak.
|
|
assert.Equal(t, "LLM policy limit exceeded", out.DenyReason.Message, "public deny message must be generic")
|
|
assert.NotContains(t, out.DenyReason.Message, "exhausted", "internal quota detail must not reach the caller")
|
|
assert.NotContains(t, out.DenyReason.Message, "1000", "internal cap numbers must not reach the caller")
|
|
}
|
|
|
|
// TestInvoke_NoMgmtClientPassesThrough proves the partial-wiring
|
|
// safety: a middleware constructed without a management client
|
|
// allows every request without attribution. This makes a half-set-up
|
|
// environment indistinguishable from "no enforcement" rather than
|
|
// breaking the chain.
|
|
func TestInvoke_NoMgmtClientPassesThrough(t *testing.T) {
|
|
m := New(nil, nil)
|
|
|
|
out := runInvoke(t, m, &middleware.Input{
|
|
AccountID: "acc-1",
|
|
UserGroups: []string{"grp-engineers"},
|
|
Metadata: []middleware.KV{{Key: middleware.KeyLLMResolvedProviderID, Value: "prov-1"}},
|
|
})
|
|
|
|
assert.Equal(t, middleware.DecisionAllow, out.Decision)
|
|
for _, kv := range out.Metadata {
|
|
assert.NotEqual(t, middleware.KeyLLMSelectedPolicyID, kv.Key,
|
|
"no mgmt client = no attribution metadata; record middleware then skips its write")
|
|
}
|
|
}
|
|
|
|
// TestInvoke_NoResolvedProviderPassesThrough covers the defensive
|
|
// path: when llm_router didn't set llm.resolved_provider_id (which
|
|
// only happens on the deny side of llm_router), the gate must NOT
|
|
// stack a second deny on top — pass through and let the upstream
|
|
// deny stand.
|
|
func TestInvoke_NoResolvedProviderPassesThrough(t *testing.T) {
|
|
m := New(&fakeMgmt{}, nil)
|
|
|
|
out := runInvoke(t, m, &middleware.Input{
|
|
AccountID: "acc-1",
|
|
Metadata: []middleware.KV{},
|
|
})
|
|
|
|
assert.Equal(t, middleware.DecisionAllow, out.Decision,
|
|
"no resolved provider = the gate has nothing to check; never deny on top of an upstream allow")
|
|
}
|
|
|
|
// TestInvoke_RPCErrorFailsOpen proves the fail-open contract: a
|
|
// transport error from management does NOT deny the request. v1
|
|
// trades enforcement strictness for availability — an unreachable
|
|
// management server otherwise turns into a total LLM outage.
|
|
func TestInvoke_RPCErrorFailsOpen(t *testing.T) {
|
|
m := New(&fakeMgmt{checkErr: errors.New("connection refused")}, nil)
|
|
|
|
out := runInvoke(t, m, &middleware.Input{
|
|
AccountID: "acc-1",
|
|
UserGroups: []string{"grp-engineers"},
|
|
Metadata: []middleware.KV{{Key: middleware.KeyLLMResolvedProviderID, Value: "prov-1"}},
|
|
})
|
|
|
|
assert.Equal(t, middleware.DecisionAllow, out.Decision,
|
|
"transport errors must not cascade into total LLM outages — operators audit via access log")
|
|
}
|
|
|
|
// TestMetadataKeys_Allowlist locks the closed set this middleware can
|
|
// emit. The accumulator drops anything outside this list; adding a
|
|
// new emission means updating both the slice and this test.
|
|
func TestMetadataKeys_Allowlist(t *testing.T) {
|
|
keys := New(nil, nil).MetadataKeys()
|
|
want := []string{
|
|
middleware.KeyLLMSelectedPolicyID,
|
|
middleware.KeyLLMAttributionGroupID,
|
|
middleware.KeyLLMAttributionWindowS,
|
|
middleware.KeyLLMPolicyDecision,
|
|
middleware.KeyLLMPolicyReason,
|
|
}
|
|
assert.ElementsMatch(t, want, keys)
|
|
}
|