mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-22 16:31:28 +02:00
Compare commits
1 Commits
vertex-gua
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a99dd1d77 |
@@ -34,8 +34,6 @@ const (
|
||||
// - Handling connection establishment based on peer signaling
|
||||
//
|
||||
// The implementation is not thread-safe; it is protected by engine.syncMsgMux.
|
||||
// The only exception is ActivatePeer, which is safe for concurrent use so the
|
||||
// DNS warm-up path can call it without contending on the engine mutex.
|
||||
type ConnMgr struct {
|
||||
peerStore *peerstore.Store
|
||||
statusRecorder *peer.Status
|
||||
@@ -44,10 +42,6 @@ type ConnMgr struct {
|
||||
rosenpassEnabled bool
|
||||
|
||||
lazyConnMgr *manager.Manager
|
||||
// lazyConnMgrMu guards the lazyConnMgr pointer for readers outside the
|
||||
// engine loop (ActivatePeer). Writers hold it in addition to
|
||||
// engine.syncMsgMux; all other reads stay under engine.syncMsgMux only.
|
||||
lazyConnMgrMu sync.RWMutex
|
||||
|
||||
wg sync.WaitGroup
|
||||
lazyCtx context.Context
|
||||
@@ -244,20 +238,12 @@ func (e *ConnMgr) RemovePeerConn(peerKey string) {
|
||||
conn.Log.Infof("removed peer from lazy conn manager")
|
||||
}
|
||||
|
||||
// ActivatePeer wakes an idle lazy connection. Unlike the rest of ConnMgr it is
|
||||
// safe for concurrent use: the lazy manager pointer is read under lazyConnMgrMu
|
||||
// and the manager itself is internally synchronized, so callers outside the
|
||||
// engine loop (DNS warm-up) do not need engine.syncMsgMux.
|
||||
func (e *ConnMgr) ActivatePeer(ctx context.Context, conn *peer.Conn) {
|
||||
e.lazyConnMgrMu.RLock()
|
||||
lazyConnMgr := e.lazyConnMgr
|
||||
started := lazyConnMgr != nil && e.lazyCtxCancel != nil
|
||||
e.lazyConnMgrMu.RUnlock()
|
||||
if !started {
|
||||
if !e.isStartedWithLazyMgr() {
|
||||
return
|
||||
}
|
||||
|
||||
if found := lazyConnMgr.ActivatePeer(conn.GetKey()); found {
|
||||
if found := e.lazyConnMgr.ActivatePeer(conn.GetKey()); found {
|
||||
if err := conn.Open(ctx); err != nil {
|
||||
conn.Log.Errorf("failed to open connection: %v", err)
|
||||
}
|
||||
@@ -282,21 +268,16 @@ func (e *ConnMgr) Close() {
|
||||
|
||||
e.lazyCtxCancel()
|
||||
e.wg.Wait()
|
||||
|
||||
e.lazyConnMgrMu.Lock()
|
||||
e.lazyConnMgr = nil
|
||||
e.lazyConnMgrMu.Unlock()
|
||||
}
|
||||
|
||||
func (e *ConnMgr) initLazyManager(engineCtx context.Context) {
|
||||
cfg := manager.Config{
|
||||
InactivityThreshold: inactivityThresholdEnv(),
|
||||
}
|
||||
|
||||
e.lazyConnMgrMu.Lock()
|
||||
e.lazyConnMgr = manager.NewManager(cfg, engineCtx, e.peerStore, e.iface)
|
||||
|
||||
e.lazyCtx, e.lazyCtxCancel = context.WithCancel(engineCtx)
|
||||
e.lazyConnMgrMu.Unlock()
|
||||
|
||||
e.wg.Add(1)
|
||||
go func() {
|
||||
@@ -335,10 +316,7 @@ func (e *ConnMgr) closeManager(ctx context.Context) {
|
||||
|
||||
e.lazyCtxCancel()
|
||||
e.wg.Wait()
|
||||
|
||||
e.lazyConnMgrMu.Lock()
|
||||
e.lazyConnMgr = nil
|
||||
e.lazyConnMgrMu.Unlock()
|
||||
|
||||
for _, peerID := range e.peerStore.PeersPubKey() {
|
||||
e.peerStore.PeerConnOpen(ctx, peerID)
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/lazyconn"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/peerstore"
|
||||
"github.com/netbirdio/netbird/monotime"
|
||||
)
|
||||
|
||||
func TestResolveLazyForce(t *testing.T) {
|
||||
@@ -49,58 +38,3 @@ func TestResolveLazyForce(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type mockLazyWGIface struct{}
|
||||
|
||||
func (mockLazyWGIface) RemovePeer(string) error { return nil }
|
||||
func (mockLazyWGIface) UpdatePeer(string, []netip.Prefix, time.Duration, *net.UDPAddr, *wgtypes.Key) error {
|
||||
return nil
|
||||
}
|
||||
func (mockLazyWGIface) IsUserspaceBind() bool { return false }
|
||||
func (mockLazyWGIface) Address() wgaddr.Address { return wgaddr.Address{} }
|
||||
func (mockLazyWGIface) LastActivities() map[string]monotime.Time { return nil }
|
||||
func (mockLazyWGIface) MTU() uint16 { return 1280 }
|
||||
|
||||
// TestConnMgr_ActivatePeerConcurrentWithLifecycle exercises ActivatePeer from
|
||||
// non-engine goroutines (the DNS warm-up path) racing the manager lifecycle,
|
||||
// which stays on the engine loop. Run with -race: it fails if ActivatePeer
|
||||
// still requires engine.syncMsgMux for safety.
|
||||
func TestConnMgr_ActivatePeerConcurrentWithLifecycle(t *testing.T) {
|
||||
t.Setenv(lazyconn.EnvLazyConn, "on")
|
||||
|
||||
status := peer.NewRecorder("https://mgm")
|
||||
store := peerstore.NewConnStore()
|
||||
connMgr := NewConnMgr(&EngineConfig{}, status, store, mockLazyWGIface{})
|
||||
|
||||
conn := newTestPeerConn(t, "peerA")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
connMgr.Start(ctx)
|
||||
|
||||
done := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
for range 4 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
connMgr.ActivatePeer(ctx, conn)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Let the activators spin against the started manager, then tear it down
|
||||
// underneath them and let them spin against the stopped manager.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
connMgr.Close()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
close(done)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -37,43 +36,7 @@ type resolver interface {
|
||||
// record is left alone (it points at something outside our mesh, e.g.
|
||||
// a non-peer upstream).
|
||||
type PeerConnectivity interface {
|
||||
IsConnectedByIP(ip netip.Addr) (known, connected bool)
|
||||
}
|
||||
|
||||
// PeerActivator wakes lazy-connection peers on demand. The local resolver calls
|
||||
// it with the tunnel IPs an answer points at, so a peer that is idle (lazily
|
||||
// disconnected) starts connecting at DNS-resolution time rather than racing the
|
||||
// client's first request packet. nil disables warm-up.
|
||||
type PeerActivator interface {
|
||||
// ActivatePeersByIP triggers wake-up for the peer(s) owning addrs and blocks
|
||||
// until one is connected or ctx (a short per-query budget) expires. It is a
|
||||
// fast no-op for unknown or already-connected addresses.
|
||||
ActivatePeersByIP(ctx context.Context, addrs []netip.Addr)
|
||||
}
|
||||
|
||||
const (
|
||||
defaultLazyWarmupTimeout = 2 * time.Second
|
||||
envLazyWarmupTimeout = "NB_DNS_LAZY_WARMUP_TIMEOUT"
|
||||
)
|
||||
|
||||
// lazyWarmupTimeoutFromEnv returns the per-query budget for waking a
|
||||
// lazy-connection peer a DNS answer points at. Tunable via
|
||||
// NB_DNS_LAZY_WARMUP_TIMEOUT (a Go duration). Parsed once at construction time.
|
||||
func lazyWarmupTimeoutFromEnv() time.Duration {
|
||||
v := os.Getenv(envLazyWarmupTimeout)
|
||||
if v == "" {
|
||||
return defaultLazyWarmupTimeout
|
||||
}
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
log.Warnf("invalid %s value %q, using default %s: %v", envLazyWarmupTimeout, v, defaultLazyWarmupTimeout, err)
|
||||
return defaultLazyWarmupTimeout
|
||||
}
|
||||
if d <= 0 {
|
||||
log.Warnf("non-positive %s value %q, using default %s", envLazyWarmupTimeout, v, defaultLazyWarmupTimeout)
|
||||
return defaultLazyWarmupTimeout
|
||||
}
|
||||
return d
|
||||
IsConnectedByIP(ip string) (known, connected bool)
|
||||
}
|
||||
|
||||
type Resolver struct {
|
||||
@@ -88,12 +51,6 @@ type Resolver struct {
|
||||
// filter and preserves the legacy "return whatever is registered"
|
||||
// behaviour for callers that never wire a status source.
|
||||
peerConn PeerConnectivity
|
||||
// peerActivator, when non-nil, is called at resolution time to warm the
|
||||
// lazy connection to the peer(s) an answer points at. nil disables warm-up.
|
||||
peerActivator PeerActivator
|
||||
// warmupTimeout is the per-query budget for the lazy-connection warm-up
|
||||
// wait, resolved from the environment once at construction time.
|
||||
warmupTimeout time.Duration
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
@@ -102,12 +59,11 @@ type Resolver struct {
|
||||
func NewResolver() *Resolver {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &Resolver{
|
||||
records: make(map[dns.Question][]dns.RR),
|
||||
domains: make(map[domain.Domain]struct{}),
|
||||
zones: make(map[domain.Domain]bool),
|
||||
warmupTimeout: lazyWarmupTimeoutFromEnv(),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
records: make(map[dns.Question][]dns.RR),
|
||||
domains: make(map[domain.Domain]struct{}),
|
||||
zones: make(map[domain.Domain]bool),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,14 +76,6 @@ func (d *Resolver) SetPeerConnectivity(p PeerConnectivity) {
|
||||
d.peerConn = p
|
||||
}
|
||||
|
||||
// SetPeerActivator wires the DNS-time lazy-connection warm-up. Pass nil to
|
||||
// disable. Safe to call multiple times; the latest value wins.
|
||||
func (d *Resolver) SetPeerActivator(a PeerActivator) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
d.peerActivator = a
|
||||
}
|
||||
|
||||
func (d *Resolver) MatchSubdomains() bool {
|
||||
return true
|
||||
}
|
||||
@@ -174,9 +122,6 @@ func (d *Resolver) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
|
||||
replyMessage.RecursionAvailable = true
|
||||
|
||||
result := d.lookupRecords(logger, question)
|
||||
// Warm before filtering: activation flips a lazily-idle target to connected,
|
||||
// which then lets it survive the disconnected-peer filter below.
|
||||
d.warmLazyPeers(question, result.records)
|
||||
result.records = d.filterDisconnectedPeerAnswers(logger, question, result.records)
|
||||
replyMessage.Authoritative = !result.hasExternalData
|
||||
replyMessage.Answer = result.records
|
||||
@@ -550,8 +495,8 @@ func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns
|
||||
kept := make([]dns.RR, 0, len(records))
|
||||
var dropped int
|
||||
for _, rr := range records {
|
||||
ip, ok := extractRecordAddr(rr)
|
||||
if !ok {
|
||||
ip := extractRecordIP(rr)
|
||||
if ip == "" {
|
||||
kept = append(kept, rr)
|
||||
continue
|
||||
}
|
||||
@@ -573,54 +518,22 @@ func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns
|
||||
return kept
|
||||
}
|
||||
|
||||
// warmLazyPeers triggers lazy-connection wake-up for the peers a resolved
|
||||
// answer points at and waits briefly for one to connect, so the caller's first
|
||||
// request doesn't race the connection establishment. Warm-up is scoped to
|
||||
// match-only (non-authoritative) zones — the synthesized private-service zones
|
||||
// and user-created zones whose records point at specific peers. The account's
|
||||
// peer zone is authoritative, so plain peer-name lookups never trigger warm-up;
|
||||
// otherwise resolving any peer's name would wake its idle connection, defeating
|
||||
// laziness mesh-wide. No-op when no activator is wired (lazy connections
|
||||
// disabled) or the answer carries no peer IPs.
|
||||
func (d *Resolver) warmLazyPeers(question dns.Question, records []dns.RR) {
|
||||
d.mu.RLock()
|
||||
activator := d.peerActivator
|
||||
var nonAuth, found bool
|
||||
if activator != nil {
|
||||
nonAuth, found = d.findZone(question.Name)
|
||||
}
|
||||
d.mu.RUnlock()
|
||||
if activator == nil || !found || !nonAuth {
|
||||
return
|
||||
}
|
||||
|
||||
var addrs []netip.Addr
|
||||
for _, rr := range records {
|
||||
if addr, ok := extractRecordAddr(rr); ok {
|
||||
addrs = append(addrs, addr)
|
||||
}
|
||||
}
|
||||
if len(addrs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(d.ctx, d.warmupTimeout)
|
||||
defer cancel()
|
||||
activator.ActivatePeersByIP(ctx, addrs)
|
||||
}
|
||||
|
||||
// extractRecordAddr returns the IP address carried by an A or AAAA record.
|
||||
// ok is false for any other record type or a record with no address.
|
||||
func extractRecordAddr(rr dns.RR) (netip.Addr, bool) {
|
||||
// extractRecordIP returns the dotted-decimal / colon-hex IP carried by
|
||||
// an A or AAAA record, or "" for any other record type.
|
||||
func extractRecordIP(rr dns.RR) string {
|
||||
switch r := rr.(type) {
|
||||
case *dns.A:
|
||||
addr, ok := netip.AddrFromSlice(r.A)
|
||||
return addr.Unmap(), ok
|
||||
if r.A == nil {
|
||||
return ""
|
||||
}
|
||||
return r.A.String()
|
||||
case *dns.AAAA:
|
||||
addr, ok := netip.AddrFromSlice(r.AAAA)
|
||||
return addr.Unmap(), ok
|
||||
if r.AAAA == nil {
|
||||
return ""
|
||||
}
|
||||
return r.AAAA.String()
|
||||
}
|
||||
return netip.Addr{}, false
|
||||
return ""
|
||||
}
|
||||
|
||||
// Update replaces all zones and their records
|
||||
|
||||
@@ -37,8 +37,8 @@ type mockPeerConnectivity struct {
|
||||
byIP map[string]struct{ known, connected bool }
|
||||
}
|
||||
|
||||
func (m mockPeerConnectivity) IsConnectedByIP(ip netip.Addr) (known, connected bool) {
|
||||
v, ok := m.byIP[ip.String()]
|
||||
func (m mockPeerConnectivity) IsConnectedByIP(ip string) (known, connected bool) {
|
||||
v, ok := m.byIP[ip]
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/dns/test"
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
)
|
||||
|
||||
// recordingActivator records the addresses it was asked to warm and returns
|
||||
// immediately, so ServeDNS is not blocked by the test.
|
||||
type recordingActivator struct {
|
||||
mu sync.Mutex
|
||||
called bool
|
||||
addrs []netip.Addr
|
||||
}
|
||||
|
||||
func (r *recordingActivator) ActivatePeersByIP(_ context.Context, addrs []netip.Addr) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.called = true
|
||||
r.addrs = append(r.addrs, addrs...)
|
||||
}
|
||||
|
||||
func serveA(t *testing.T, resolver *Resolver, name string) *dns.Msg {
|
||||
t.Helper()
|
||||
var resp *dns.Msg
|
||||
w := &test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}
|
||||
resolver.ServeDNS(w, new(dns.Msg).SetQuestion(name, dns.TypeA))
|
||||
return resp
|
||||
}
|
||||
|
||||
// serviceZone registers rec in a match-only (non-authoritative) zone, the shape
|
||||
// the synthesized private-service zones arrive in.
|
||||
func serviceZone(t *testing.T, resolver *Resolver, zone string, records ...nbdns.SimpleRecord) {
|
||||
t.Helper()
|
||||
resolver.Update([]nbdns.CustomZone{{
|
||||
Domain: zone,
|
||||
Records: records,
|
||||
NonAuthoritative: true,
|
||||
}})
|
||||
}
|
||||
|
||||
func TestLocalResolver_WarmsLazyPeerOnResolve(t *testing.T) {
|
||||
rec := nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"}
|
||||
resolver := NewResolver()
|
||||
serviceZone(t, resolver, "proxy.netbird.cloud", rec)
|
||||
|
||||
act := &recordingActivator{}
|
||||
resolver.SetPeerActivator(act)
|
||||
|
||||
resp := serveA(t, resolver, rec.Name)
|
||||
require.NotNil(t, resp, "resolver must answer")
|
||||
require.NotEmpty(t, resp.Answer, "answer must carry the A record")
|
||||
|
||||
act.mu.Lock()
|
||||
defer act.mu.Unlock()
|
||||
assert.True(t, act.called, "activator must be invoked for a service-zone A answer")
|
||||
assert.Contains(t, act.addrs, netip.MustParseAddr("100.64.0.7"), "activator must receive the answer's peer IP")
|
||||
}
|
||||
|
||||
func TestLocalResolver_NoActivatorNoWarmup(t *testing.T) {
|
||||
// With no activator wired the resolver behaves exactly as before.
|
||||
rec := nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"}
|
||||
resolver := NewResolver()
|
||||
serviceZone(t, resolver, "proxy.netbird.cloud", rec)
|
||||
|
||||
resp := serveA(t, resolver, rec.Name)
|
||||
require.NotNil(t, resp, "resolver must still answer without an activator")
|
||||
require.NotEmpty(t, resp.Answer, "answer must carry the A record")
|
||||
}
|
||||
|
||||
func TestLocalResolver_NoWarmupForMissingRecord(t *testing.T) {
|
||||
// A query that resolves to nothing must not invoke the activator (no IPs).
|
||||
resolver := NewResolver()
|
||||
serviceZone(t, resolver, "proxy.netbird.cloud",
|
||||
nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"})
|
||||
|
||||
act := &recordingActivator{}
|
||||
resolver.SetPeerActivator(act)
|
||||
|
||||
serveA(t, resolver, "absent.proxy.netbird.cloud.")
|
||||
|
||||
act.mu.Lock()
|
||||
defer act.mu.Unlock()
|
||||
assert.False(t, act.called, "activator must not be invoked when there is no answer")
|
||||
}
|
||||
|
||||
func TestLocalResolver_NoWarmupInAuthoritativeZone(t *testing.T) {
|
||||
// The account's peer zone is authoritative; resolving a peer's name there
|
||||
// must not wake its lazy connection — warm-up is scoped to match-only
|
||||
// (non-authoritative) zones such as the synthesized private-service zones.
|
||||
rec := nbdns.SimpleRecord{Name: "peer.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.9"}
|
||||
resolver := NewResolver()
|
||||
resolver.Update([]nbdns.CustomZone{{
|
||||
Domain: "netbird.cloud",
|
||||
Records: []nbdns.SimpleRecord{rec},
|
||||
}})
|
||||
|
||||
act := &recordingActivator{}
|
||||
resolver.SetPeerActivator(act)
|
||||
|
||||
resp := serveA(t, resolver, rec.Name)
|
||||
require.NotNil(t, resp, "resolver must answer")
|
||||
require.NotEmpty(t, resp.Answer, "answer must carry the A record")
|
||||
|
||||
act.mu.Lock()
|
||||
defer act.mu.Unlock()
|
||||
assert.False(t, act.called, "activator must not be invoked for authoritative-zone answers")
|
||||
}
|
||||
|
||||
func TestLazyWarmupTimeoutFromEnv(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
envSet bool
|
||||
want time.Duration
|
||||
}{
|
||||
{name: "unset uses default", want: defaultLazyWarmupTimeout},
|
||||
{name: "valid overrides", value: "5s", envSet: true, want: 5 * time.Second},
|
||||
{name: "invalid falls back", value: "not-a-duration", envSet: true, want: defaultLazyWarmupTimeout},
|
||||
{name: "negative falls back", value: "-1s", envSet: true, want: defaultLazyWarmupTimeout},
|
||||
{name: "zero falls back", value: "0s", envSet: true, want: defaultLazyWarmupTimeout},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.envSet {
|
||||
t.Setenv(envLazyWarmupTimeout, tt.value)
|
||||
}
|
||||
assert.Equal(t, tt.want, lazyWarmupTimeoutFromEnv())
|
||||
assert.Equal(t, tt.want, NewResolver().warmupTimeout, "constructor must resolve the timeout once")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRecordAddr(t *testing.T) {
|
||||
t.Run("A record yields unmapped v4", func(t *testing.T) {
|
||||
// net.ParseIP returns the 16-byte v4-in-v6 form, the same shape
|
||||
// miekg/dns stores after parsing an A record; the extracted address
|
||||
// must compare equal to a plain v4 netip.Addr.
|
||||
addr, ok := extractRecordAddr(&dns.A{A: net.ParseIP("100.64.0.7")})
|
||||
require.True(t, ok)
|
||||
assert.True(t, addr.Is4())
|
||||
assert.Equal(t, netip.MustParseAddr("100.64.0.7"), addr)
|
||||
})
|
||||
|
||||
t.Run("AAAA record yields v6", func(t *testing.T) {
|
||||
addr, ok := extractRecordAddr(&dns.AAAA{AAAA: net.ParseIP("fd00::1")})
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, netip.MustParseAddr("fd00::1"), addr)
|
||||
})
|
||||
|
||||
t.Run("A record without address", func(t *testing.T) {
|
||||
_, ok := extractRecordAddr(&dns.A{})
|
||||
assert.False(t, ok)
|
||||
})
|
||||
|
||||
t.Run("non-address record", func(t *testing.T) {
|
||||
_, ok := extractRecordAddr(&dns.CNAME{Target: "target.netbird.cloud."})
|
||||
assert.False(t, ok)
|
||||
})
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"github.com/miekg/dns"
|
||||
|
||||
dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config"
|
||||
"github.com/netbirdio/netbird/client/internal/dns/local"
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
@@ -93,11 +92,6 @@ func (m *MockServer) SetFirewall(Firewall) {
|
||||
// Mock implementation - no-op
|
||||
}
|
||||
|
||||
// SetPeerActivator mock implementation of SetPeerActivator from Server interface
|
||||
func (m *MockServer) SetPeerActivator(local.PeerActivator) {
|
||||
// Mock implementation - no-op
|
||||
}
|
||||
|
||||
// BeginBatch mock implementation of BeginBatch from Server interface
|
||||
func (m *MockServer) BeginBatch() {
|
||||
// Mock implementation - no-op
|
||||
|
||||
@@ -82,7 +82,6 @@ type Server interface {
|
||||
PopulateManagementDomain(mgmtURL *url.URL) error
|
||||
SetRouteSources(selected, active func() route.HAMap)
|
||||
SetFirewall(Firewall)
|
||||
SetPeerActivator(local.PeerActivator)
|
||||
}
|
||||
|
||||
type nsGroupsByDomain struct {
|
||||
@@ -492,13 +491,6 @@ func (s *DefaultServer) SetFirewall(fw Firewall) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetPeerActivator wires the DNS-time lazy-connection warm-up on the local
|
||||
// resolver. Injected after the connection manager exists (it does not at
|
||||
// DNS-server construction time). Pass nil to disable.
|
||||
func (s *DefaultServer) SetPeerActivator(a local.PeerActivator) {
|
||||
s.localResolver.SetPeerActivator(a)
|
||||
}
|
||||
|
||||
// Stop stops the server
|
||||
func (s *DefaultServer) Stop() {
|
||||
s.ctxCancel()
|
||||
@@ -1443,11 +1435,11 @@ type localPeerConnectivity struct {
|
||||
|
||||
// IsConnectedByIP looks the IP up in the peerstore and surfaces both
|
||||
// the known and connected bits. Used by Resolver.filterDisconnectedPeerAnswers.
|
||||
func (l localPeerConnectivity) IsConnectedByIP(ip netip.Addr) (known, connected bool) {
|
||||
func (l localPeerConnectivity) IsConnectedByIP(ip string) (known, connected bool) {
|
||||
if l.status == nil {
|
||||
return false, false
|
||||
}
|
||||
state, ok := l.status.PeerStateByIP(ip.String())
|
||||
state, ok := l.status.PeerStateByIP(ip)
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/peerstore"
|
||||
)
|
||||
|
||||
const dnsActivationPollInterval = 50 * time.Millisecond
|
||||
|
||||
// dnsPeerActivator wakes lazy-connection peers from the DNS resolution path. It
|
||||
// implements dns/local.PeerActivator. DNS queries run on their own goroutines,
|
||||
// so it only touches state that is safe for concurrent use — ConnMgr.ActivatePeer,
|
||||
// peerstore.Store and peer.Status — and never takes the engine's syncMsgMux,
|
||||
// keeping DNS resolution from contending with network-map processing.
|
||||
type dnsPeerActivator struct {
|
||||
connMgr *ConnMgr
|
||||
peerStore *peerstore.Store
|
||||
status *peer.Status
|
||||
// ctx is the engine's long-lived context. The connection dial is tied to it
|
||||
// (not the per-query DNS wait budget) so a handshake that outlasts the wait
|
||||
// still completes in the background rather than being cancelled at the deadline.
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// ActivatePeersByIP triggers wake-up for the peer(s) owning addrs and waits
|
||||
// until one is connected or ctx (the per-query DNS wait budget) expires.
|
||||
// Activation itself is tied to the engine's long-lived context so the dial
|
||||
// survives a wait that times out. Unknown or already-connected addresses are
|
||||
// skipped, so the steady-state (warm) path adds no latency.
|
||||
func (a *dnsPeerActivator) ActivatePeersByIP(ctx context.Context, addrs []netip.Addr) {
|
||||
if a == nil || a.connMgr == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var pending []string
|
||||
for _, addr := range addrs {
|
||||
ip := addr.String()
|
||||
st, ok := a.status.PeerStateByIP(ip)
|
||||
if !ok || st.ConnStatus == peer.StatusConnected {
|
||||
continue
|
||||
}
|
||||
conn, ok := a.peerStore.PeerConn(st.PubKey)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
a.connMgr.ActivatePeer(a.ctx, conn)
|
||||
pending = append(pending, ip)
|
||||
}
|
||||
|
||||
if len(pending) == 0 {
|
||||
return
|
||||
}
|
||||
a.waitConnected(ctx, pending)
|
||||
}
|
||||
|
||||
// waitConnected blocks until any of ips reports a connected peer or ctx expires.
|
||||
func (a *dnsPeerActivator) waitConnected(ctx context.Context, ips []string) {
|
||||
ticker := time.NewTicker(dnsActivationPollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
for _, ip := range ips {
|
||||
if st, ok := a.status.PeerStateByIP(ip); ok && st.ConnStatus == peer.StatusConnected {
|
||||
return
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/peerstore"
|
||||
)
|
||||
|
||||
func newTestPeerConn(t *testing.T, key string) *peer.Conn {
|
||||
t.Helper()
|
||||
conn, err := peer.NewConn(peer.ConnConfig{
|
||||
Key: key,
|
||||
LocalKey: "local",
|
||||
WgConfig: peer.WgConfig{
|
||||
AllowedIps: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
|
||||
},
|
||||
}, peer.ServiceDependencies{})
|
||||
require.NoError(t, err)
|
||||
return conn
|
||||
}
|
||||
|
||||
func newTestDNSPeerActivator(t *testing.T) (*dnsPeerActivator, *peer.Status, *peerstore.Store) {
|
||||
t.Helper()
|
||||
status := peer.NewRecorder("https://mgm")
|
||||
store := peerstore.NewConnStore()
|
||||
// ConnMgr without Start: the lazy manager is nil, so ActivatePeer is a
|
||||
// no-op — these tests exercise the activator's skip/wait logic.
|
||||
connMgr := NewConnMgr(&EngineConfig{}, status, store, nil)
|
||||
return &dnsPeerActivator{
|
||||
connMgr: connMgr,
|
||||
peerStore: store,
|
||||
status: status,
|
||||
ctx: context.Background(),
|
||||
}, status, store
|
||||
}
|
||||
|
||||
func TestDNSPeerActivator_NilSafe(t *testing.T) {
|
||||
var a *dnsPeerActivator
|
||||
a.ActivatePeersByIP(context.Background(), []netip.Addr{netip.MustParseAddr("100.64.0.1")})
|
||||
}
|
||||
|
||||
// TestDNSPeerActivator_SkipsUnknownAndConnectedPeers verifies the steady-state
|
||||
// (warm) path adds no latency: already-connected and unknown addresses never
|
||||
// enter the wait loop.
|
||||
func TestDNSPeerActivator_SkipsUnknownAndConnectedPeers(t *testing.T) {
|
||||
a, status, store := newTestDNSPeerActivator(t)
|
||||
|
||||
require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", "fd00::1"))
|
||||
require.NoError(t, status.UpdatePeerState(peer.State{PubKey: "peerA", ConnStatus: peer.StatusConnected}))
|
||||
store.AddPeerConn("peerA", newTestPeerConn(t, "peerA"))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
a.ActivatePeersByIP(ctx, []netip.Addr{
|
||||
netip.MustParseAddr("100.64.0.1"), // known, connected -> skipped
|
||||
netip.MustParseAddr("fd00::1"), // known via IPv6, connected -> skipped
|
||||
netip.MustParseAddr("100.64.0.99"), // unknown -> skipped
|
||||
})
|
||||
require.Less(t, time.Since(start), time.Second, "no pending peer must mean no wait")
|
||||
}
|
||||
|
||||
// TestDNSPeerActivator_WaitsForPendingPeerToConnect verifies the wait loop
|
||||
// returns as soon as a pending peer reports connected, well before the
|
||||
// per-query budget expires.
|
||||
func TestDNSPeerActivator_WaitsForPendingPeerToConnect(t *testing.T) {
|
||||
a, status, store := newTestDNSPeerActivator(t)
|
||||
|
||||
require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", ""))
|
||||
store.AddPeerConn("peerA", newTestPeerConn(t, "peerA"))
|
||||
|
||||
go func() {
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
_ = status.UpdatePeerState(peer.State{PubKey: "peerA", ConnStatus: peer.StatusConnected})
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")})
|
||||
elapsed := time.Since(start)
|
||||
|
||||
require.GreaterOrEqual(t, elapsed, 100*time.Millisecond, "must wait for the pending peer")
|
||||
require.Less(t, elapsed, 5*time.Second, "must return on connect, not at the deadline")
|
||||
}
|
||||
|
||||
// TestDNSPeerActivator_ReturnsAtBudgetWhenPeerStaysIdle verifies a peer that
|
||||
// never connects releases the DNS response at the per-query budget instead of
|
||||
// blocking it indefinitely.
|
||||
func TestDNSPeerActivator_ReturnsAtBudgetWhenPeerStaysIdle(t *testing.T) {
|
||||
a, status, store := newTestDNSPeerActivator(t)
|
||||
|
||||
require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", ""))
|
||||
store.AddPeerConn("peerA", newTestPeerConn(t, "peerA"))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")})
|
||||
elapsed := time.Since(start)
|
||||
|
||||
require.GreaterOrEqual(t, elapsed, 250*time.Millisecond, "must wait out the budget for a pending peer")
|
||||
require.Less(t, elapsed, 5*time.Second, "must not block past the budget")
|
||||
}
|
||||
|
||||
// TestDNSPeerActivator_NoWaitWithoutPeerConn verifies a known-but-idle peer
|
||||
// with no connection object in the store is not waited on: there is nothing to
|
||||
// activate, so waiting could only ever time out.
|
||||
func TestDNSPeerActivator_NoWaitWithoutPeerConn(t *testing.T) {
|
||||
a, status, _ := newTestDNSPeerActivator(t)
|
||||
|
||||
require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", ""))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")})
|
||||
require.Less(t, time.Since(start), time.Second, "peer without a conn must not be waited on")
|
||||
}
|
||||
@@ -654,16 +654,6 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
|
||||
e.connMgr = NewConnMgr(e.config, e.statusRecorder, e.peerStore, wgIface)
|
||||
e.connMgr.Start(e.ctx)
|
||||
|
||||
// Wire DNS-time lazy-connection warm-up now that the connection manager
|
||||
// exists (it does not at DNS-server construction time). A DNS answer that
|
||||
// points at an idle peer then wakes it before the client's first request.
|
||||
e.dnsServer.SetPeerActivator(&dnsPeerActivator{
|
||||
connMgr: e.connMgr,
|
||||
peerStore: e.peerStore,
|
||||
status: e.statusRecorder,
|
||||
ctx: e.ctx,
|
||||
})
|
||||
|
||||
e.srWatcher = guard.NewSRWatcher(e.signal, e.relayManager, e.mobileDep.IFaceDiscover, iceCfg)
|
||||
e.srWatcher.Start(peer.IsForceRelayed())
|
||||
|
||||
|
||||
@@ -91,22 +91,14 @@ func availableProviders() []providerCase {
|
||||
if region == "" {
|
||||
region = "us-east-1"
|
||||
}
|
||||
// A valid Bedrock inference-profile id (region prefix + date + version),
|
||||
// overridable per account. `global.` profiles can be invoked from any
|
||||
// region; set AWS_BEDROCK_MODEL to match the enabled profile for the token.
|
||||
model := os.Getenv("AWS_BEDROCK_MODEL")
|
||||
if model == "" {
|
||||
model = "global.anthropic.claude-haiku-4-5-20251001-v1:0"
|
||||
}
|
||||
ps = append(ps, providerCase{name: "bedrock", catalogID: "bedrock_api", upstream: "https://bedrock-runtime." + region + ".amazonaws.com", apiKey: k, model: model, kind: harness.WireBedrock})
|
||||
ps = append(ps, providerCase{name: "bedrock", catalogID: "bedrock_api", upstream: "https://bedrock-runtime." + region + ".amazonaws.com", apiKey: k, model: "us.anthropic.claude-haiku-4-5", kind: harness.WireBedrock})
|
||||
}
|
||||
return ps
|
||||
}
|
||||
|
||||
// providerRequest builds a create request for a matrix provider: enabled, with
|
||||
// a uniquely-priced model registered under the id an operator would paste —
|
||||
// the normalized catalog id for Bedrock, the raw form elsewhere (including the
|
||||
// "@version" Vertex id, which the router must normalize to route).
|
||||
// a uniquely-priced model for body-routed providers and none for the
|
||||
// path-routed Vertex (whose model lives in the request path).
|
||||
func providerRequest(pc providerCase) api.AgentNetworkProviderRequest {
|
||||
req := api.AgentNetworkProviderRequest{
|
||||
Name: pc.name,
|
||||
@@ -115,19 +107,10 @@ func providerRequest(pc providerCase) api.AgentNetworkProviderRequest {
|
||||
ApiKey: &pc.apiKey,
|
||||
Enabled: ptr(true),
|
||||
}
|
||||
// The router matches the normalized catalog id. Bedrock's request model
|
||||
// travels as a region-prefixed inference-profile id in the URL path
|
||||
// (us.anthropic...), which the router strips before matching, so register
|
||||
// the normalized form here or routing fails as model_not_routable. Vertex is
|
||||
// registered with the raw "@version" id the Google console documents — the
|
||||
// router normalizes the registered id, so the operator can paste either
|
||||
// form; keeping the raw form here guards that normalization end to end.
|
||||
modelID := pc.model
|
||||
if pc.kind == harness.WireBedrock {
|
||||
modelID = catalogModel(pc)
|
||||
}
|
||||
req.Models = &[]api.AgentNetworkProviderModel{
|
||||
{Id: modelID, InputPer1k: 0.001, OutputPer1k: 0.002},
|
||||
if pc.kind != harness.WireVertex {
|
||||
req.Models = &[]api.AgentNetworkProviderModel{
|
||||
{Id: pc.model, InputPer1k: 0.001, OutputPer1k: 0.002},
|
||||
}
|
||||
}
|
||||
return req
|
||||
}
|
||||
@@ -218,13 +201,11 @@ func TestProvidersMatrix(t *testing.T) {
|
||||
t.Cleanup(func() { _ = cl.Terminate(context.Background()) })
|
||||
|
||||
require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management")
|
||||
// Resolve first: the DNS lookup triggers the lazy-connection warm-up, waking
|
||||
// the proxy peer so WaitProxyPeer then observes it connected.
|
||||
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
|
||||
require.NoError(t, err, "resolve agent-network endpoint to proxy IP")
|
||||
if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil {
|
||||
t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background()))
|
||||
}
|
||||
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
|
||||
require.NoError(t, err, "resolve agent-network endpoint to proxy IP")
|
||||
|
||||
for _, pc := range matrix {
|
||||
pc := pc
|
||||
|
||||
@@ -4,7 +4,6 @@ package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -16,29 +15,13 @@ import (
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
// bedrockRegionPrefixes and bedrockVersionSuffix mirror the proxy's Bedrock
|
||||
// model normalization (region/inference-profile prefix + version suffix) so the
|
||||
// provider is registered under the same catalog key the router matches against.
|
||||
var (
|
||||
bedrockRegionPrefixes = []string{"us.", "eu.", "apac.", "global."}
|
||||
bedrockVersionSuffix = regexp.MustCompile(`-(\d{8}-)?v\d+(:\d+)?$`)
|
||||
)
|
||||
|
||||
// catalogModel returns the normalized catalog id the proxy stamps for a
|
||||
// path-routed provider's configured model — the form the router and guardrail
|
||||
// allowlist compare against (Bedrock region prefix + version stripped, Vertex
|
||||
// @version stripped).
|
||||
// path-routed provider's configured model — the form the guardrail allowlist is
|
||||
// compared against (region prefix / @version stripped).
|
||||
func catalogModel(pc providerCase) string {
|
||||
switch pc.kind {
|
||||
case harness.WireBedrock:
|
||||
m := pc.model
|
||||
for _, p := range bedrockRegionPrefixes {
|
||||
if strings.HasPrefix(m, p) {
|
||||
m = m[len(p):]
|
||||
break
|
||||
}
|
||||
}
|
||||
return bedrockVersionSuffix.ReplaceAllString(m, "")
|
||||
return strings.TrimPrefix(pc.model, "us.")
|
||||
case harness.WireVertex:
|
||||
return strings.SplitN(pc.model, "@", 2)[0]
|
||||
default:
|
||||
@@ -125,15 +108,7 @@ func TestModelAllowlistEnforced(t *testing.T) {
|
||||
require.NoError(t, perr, "create provider %s", pc.name)
|
||||
id := prov.Id
|
||||
ids = append(ids, id)
|
||||
// Vertex allowlists the raw "@version" id an operator copies from the
|
||||
// Google console; the guardrail must normalize the entry to match the
|
||||
// version-stripped request model. Other providers allowlist the
|
||||
// normalized catalog id.
|
||||
if pc.kind == harness.WireVertex {
|
||||
allowed = append(allowed, pc.model)
|
||||
} else {
|
||||
allowed = append(allowed, catalogModel(pc))
|
||||
}
|
||||
allowed = append(allowed, catalogModel(pc))
|
||||
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), id) })
|
||||
}
|
||||
|
||||
@@ -172,13 +147,11 @@ func TestModelAllowlistEnforced(t *testing.T) {
|
||||
t.Cleanup(func() { _ = cl.Terminate(context.Background()) })
|
||||
|
||||
require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management")
|
||||
// Resolve first: the DNS lookup triggers the lazy-connection warm-up, waking
|
||||
// the proxy peer so WaitProxyPeer then observes it connected.
|
||||
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
|
||||
require.NoError(t, err, "resolve agent-network endpoint to proxy IP")
|
||||
if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil {
|
||||
t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background()))
|
||||
}
|
||||
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
|
||||
require.NoError(t, err, "resolve agent-network endpoint to proxy IP")
|
||||
|
||||
for _, pc := range providers {
|
||||
pc := pc
|
||||
@@ -190,25 +163,6 @@ func TestModelAllowlistEnforced(t *testing.T) {
|
||||
// the upstream), regardless of whether it is a real catalog model.
|
||||
assert.Equal(t, 403, sendModel(ctx, t, cl, settings.Endpoint, proxyIP, pc, disallowedModel(pc)),
|
||||
"model outside the allowlist must be denied for %s", pc.name)
|
||||
|
||||
if pc.kind != harness.WireVertex {
|
||||
return
|
||||
}
|
||||
// The unversioned model id — the shape an Anthropic SDK client sends
|
||||
// when configured without an "@version" suffix (customer-reported) —
|
||||
// must pass the same allowlist entry and resolve upstream.
|
||||
assert.Equal(t, 200, sendModel(ctx, t, cl, settings.Endpoint, proxyIP, pc, catalogModel(pc)),
|
||||
"unversioned model id must be permitted for %s", pc.name)
|
||||
// Token counting: the URL carries the literal "count-tokens"
|
||||
// pseudo-model and the real model travels in the body (Claude Code
|
||||
// sends one such call per request). Allowed body model → served.
|
||||
code, _, err := cl.VertexCountTokens(ctx, settings.Endpoint, proxyIP, pc.project, pc.region, pc.model)
|
||||
require.NoError(t, err, "count-tokens request must reach the proxy for %s", pc.name)
|
||||
assert.Equal(t, 200, code, "count-tokens with an allowlisted body model must be permitted for %s", pc.name)
|
||||
// Disallowed body model → denied before the upstream.
|
||||
code, _, err = cl.VertexCountTokens(ctx, settings.Endpoint, proxyIP, pc.project, pc.region, disallowedModel(pc))
|
||||
require.NoError(t, err, "count-tokens request must reach the proxy for %s", pc.name)
|
||||
assert.Equal(t, 403, code, "count-tokens with a body model outside the allowlist must be denied for %s", pc.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,13 +104,11 @@ func TestProviderSkipTLSVerification(t *testing.T) {
|
||||
t.Cleanup(func() { _ = cl.Terminate(context.Background()) })
|
||||
|
||||
require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management")
|
||||
// Resolve first: the DNS lookup triggers the lazy-connection warm-up, waking
|
||||
// the proxy peer so WaitProxyPeer then observes it connected.
|
||||
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
|
||||
require.NoError(t, err, "resolve endpoint to proxy IP")
|
||||
if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil {
|
||||
t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background()))
|
||||
}
|
||||
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
|
||||
require.NoError(t, err, "resolve endpoint to proxy IP")
|
||||
|
||||
// Positive: skip=true reaches the self-signed upstream. Retry to absorb
|
||||
// tunnel/DNS jitter on the first call; success also proves the path works.
|
||||
|
||||
@@ -106,13 +106,11 @@ func TestVLLMProvider(t *testing.T) {
|
||||
t.Cleanup(func() { _ = cl.Terminate(context.Background()) })
|
||||
|
||||
require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management")
|
||||
// Resolve first: the DNS lookup triggers the lazy-connection warm-up, waking
|
||||
// the proxy peer so WaitProxyPeer then observes it connected.
|
||||
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
|
||||
require.NoError(t, err, "resolve endpoint to proxy IP")
|
||||
if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil {
|
||||
t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background()))
|
||||
}
|
||||
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
|
||||
require.NoError(t, err, "resolve endpoint to proxy IP")
|
||||
|
||||
before, _ := srv.ListAccessLogs(ctx)
|
||||
sessionID := "e2e-session-vllm"
|
||||
|
||||
@@ -231,17 +231,6 @@ func (cl *Client) Vertex(ctx context.Context, endpoint, proxyIP, project, region
|
||||
return cl.post(ctx, endpoint, proxyIP, path, body, withSessionID(nil, sessionID))
|
||||
}
|
||||
|
||||
// VertexCountTokens issues the Anthropic-on-Vertex token-count POST over the
|
||||
// tunnel. The URL carries the literal "count-tokens" pseudo-model and the real
|
||||
// model travels in the body — the one Vertex shape whose model is body-routed,
|
||||
// sent by Claude Code before each request. The proxy must resolve the body
|
||||
// model for the guardrail allowlist and routing.
|
||||
func (cl *Client) VertexCountTokens(ctx context.Context, endpoint, proxyIP, project, region, model string) (int, string, error) {
|
||||
path := fmt.Sprintf("/v1/projects/%s/locations/%s/publishers/anthropic/models/count-tokens:rawPredict", project, region)
|
||||
body := fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":"hi"}]}`, model)
|
||||
return cl.post(ctx, endpoint, proxyIP, path, body, nil)
|
||||
}
|
||||
|
||||
// Bedrock issues a native AWS Bedrock InvokeModel POST over the tunnel. The
|
||||
// model id is carried in the request path (/model/{id}/invoke), so the proxy
|
||||
// routes by path; the body uses the bedrock anthropic_version rather than a
|
||||
|
||||
22
go.mod
22
go.mod
@@ -24,7 +24,7 @@ require (
|
||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3
|
||||
google.golang.org/grpc v1.80.0
|
||||
google.golang.org/grpc v1.81.1
|
||||
google.golang.org/protobuf v1.36.11
|
||||
)
|
||||
|
||||
@@ -116,11 +116,11 @@ require (
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117
|
||||
github.com/yusufpapurcu/wmi v1.2.4
|
||||
github.com/zcalusic/sysinfo v1.1.3
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0
|
||||
go.opentelemetry.io/otel v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.64.0
|
||||
go.opentelemetry.io/otel/metric v1.43.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0
|
||||
go.opentelemetry.io/otel v1.44.0
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.66.0
|
||||
go.opentelemetry.io/otel/metric v1.44.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0
|
||||
go.uber.org/mock v0.6.0
|
||||
go.uber.org/zap v1.27.0
|
||||
goauthentik.io/api/v3 v3.2023051.3
|
||||
@@ -292,7 +292,7 @@ require (
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/otlptranslator v1.0.0 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/russellhaering/goxmldsig v1.6.0 // indirect
|
||||
github.com/ryanuber/go-glob v1.0.0 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
@@ -309,15 +309,15 @@ require (
|
||||
go.mongodb.org/mongo-driver v1.17.9 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/tools v0.47.0 // indirect
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
gopkg.in/square/go-jose.v2 v2.6.0 // indirect
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
|
||||
46
go.sum
46
go.sum
@@ -578,8 +578,8 @@ github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTU
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos=
|
||||
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
|
||||
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
|
||||
github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=
|
||||
@@ -684,26 +684,28 @@ go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5
|
||||
go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 h1:2yEATaop1/a1I4psnSLgWVPLWwCzkqWakgJy7xTDVy0=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.64.0 h1:g0LRDXMX/G1SEZtK8zl8Chm4K6GBwRkjPKE36LxiTYs=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.64.0/go.mod h1:UrgcjnarfdlBDP3GjDIJWe6HTprwSazNjwsI+Ru6hro=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.66.0 h1:vkrK8PAznv2NKt2r+kdu252ccGzkEqLc2aSXbQIALYQ=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.66.0/go.mod h1:V/UB6D3vMF/UBOL5igAsAYnk1nG/bzYYTzvsB16cy7o=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA=
|
||||
go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I=
|
||||
go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
@@ -714,8 +716,8 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
goauthentik.io/api/v3 v3.2023051.3 h1:NebAhD/TeTWNo/9X3/Uj+rM5fG1HaiLOlKTNLQv9Qq4=
|
||||
goauthentik.io/api/v3 v3.2023051.3/go.mod h1:nYECml4jGbp/541hj8GcylKQG1gVBsKppHy4+7G8u4U=
|
||||
@@ -882,10 +884,10 @@ google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgn
|
||||
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
||||
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
package llm
|
||||
|
||||
import "strings"
|
||||
|
||||
// NormalizeVertexModel strips the "@version" suffix from a Google Vertex AI
|
||||
// publisher model id so it matches the catalog/pricing key, e.g.
|
||||
// "claude-sonnet-4-5@20250929" -> "claude-sonnet-4-5". It is the single source
|
||||
// of truth shared by the request parser (which normalizes the request model
|
||||
// from the URL path), the router (which normalizes the operator's registered
|
||||
// Vertex model ids), and the guardrail (which normalizes allowlist entries) so
|
||||
// both spellings of the same model compare equal.
|
||||
func NormalizeVertexModel(modelID string) string {
|
||||
if at := strings.Index(modelID, "@"); at >= 0 {
|
||||
return modelID[:at]
|
||||
}
|
||||
return modelID
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNormalizeVertexModel(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"claude-sonnet-4-5@20250929": "claude-sonnet-4-5",
|
||||
"claude-opus-4-6@20250514": "claude-opus-4-6",
|
||||
// Bare ids (the form Vertex resolves to the default version) pass through.
|
||||
"claude-opus-4-6": "claude-opus-4-6",
|
||||
// Numeric version aliases used by Google publisher models.
|
||||
"text-embedding-005@001": "text-embedding-005",
|
||||
// A leading "@" yields the empty string; callers treat that as "no model".
|
||||
"@cf/meta/llama-3-8b": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
require.Equal(t, want, NormalizeVertexModel(in), "normalize %q", in)
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"context"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/netbirdio/netbird/proxy/internal/llm"
|
||||
"github.com/netbirdio/netbird/proxy/internal/middleware"
|
||||
)
|
||||
|
||||
@@ -163,15 +162,6 @@ func (m *Middleware) modelInAllowlist(model string) bool {
|
||||
if allowed == normalised {
|
||||
return true
|
||||
}
|
||||
// Vertex model ids are often stored with their "@version" suffix (e.g.
|
||||
// "claude-opus-4-6@20250514") — the id the Google console documents —
|
||||
// while the request parser strips the suffix before the model reaches
|
||||
// the guardrail. Accept an entry whose version-stripped form matches so
|
||||
// both spellings of the same model pass one allowlist. Entries without
|
||||
// an "@" are untouched, so non-Vertex matching stays exact.
|
||||
if v := llm.NormalizeVertexModel(allowed); v != "" && v != allowed && v == normalised {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -70,27 +70,6 @@ func TestAllowlistMatchAllows(t *testing.T) {
|
||||
assert.Equal(t, middleware.DecisionAllow, out.Decision, "model in allowlist must be allowed")
|
||||
}
|
||||
|
||||
// A Vertex allowlist entry stored with its "@version" suffix must match the
|
||||
// request model, which reaches the guardrail with the suffix already stripped
|
||||
// by the request parser.
|
||||
func TestAllowlistVertexVersionedEntryMatchesStrippedModel(t *testing.T) {
|
||||
mw := New(Config{ModelAllowlist: []string{"claude-opus-4-6@20250514"}})
|
||||
out, err := mw.Invoke(context.Background(), newInput(
|
||||
middleware.KV{Key: middleware.KeyLLMModel, Value: "claude-opus-4-6"},
|
||||
))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, middleware.DecisionAllow, out.Decision,
|
||||
"@version allowlist entry must match the version-stripped request model")
|
||||
|
||||
// The stripped form must not over-match a different model.
|
||||
out, err = mw.Invoke(context.Background(), newInput(
|
||||
middleware.KV{Key: middleware.KeyLLMModel, Value: "claude-opus-4-8"},
|
||||
))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, middleware.DecisionDeny, out.Decision,
|
||||
"a different model must stay denied")
|
||||
}
|
||||
|
||||
func TestAllowlistMissDenies(t *testing.T) {
|
||||
mw := New(Config{ModelAllowlist: []string{"gpt-4o"}})
|
||||
out, err := mw.Invoke(context.Background(), newInput(
|
||||
|
||||
@@ -104,96 +104,3 @@ func TestModelAllowlist_URLRoutedProviders(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelAllowlist_VertexRequestShapes replays the Vertex request shapes an
|
||||
// Anthropic SDK client (Claude Code) actually sends — the model travels in the
|
||||
// URL path, optionally without an "@version" suffix, at the global location —
|
||||
// against allowlists holding either the bare catalog id or the raw versioned
|
||||
// id. The URLs mirror the shape of a customer-reported request (unversioned
|
||||
// model, global location, a project id that itself contains "claude").
|
||||
func TestModelAllowlist_VertexRequestShapes(t *testing.T) {
|
||||
const (
|
||||
opusBare = "/v1/projects/corp-gcp-it-all-claude/locations/global/publishers/anthropic/models/claude-opus-4-6:rawPredict"
|
||||
opusBareSSE = "/v1/projects/corp-gcp-it-all-claude/locations/global/publishers/anthropic/models/claude-opus-4-6:streamRawPredict"
|
||||
countTokens = "/v1/projects/corp-gcp-it-all-claude/locations/global/publishers/anthropic/models/count-tokens:rawPredict"
|
||||
messagesBody = `{"anthropic_version":"vertex-2023-10-16","messages":[{"role":"user","content":"hi"}]}`
|
||||
countOpusBody = `{"model":"claude-opus-4-6","messages":[{"role":"user","content":"hi"}]}`
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
body string
|
||||
allowlist []string
|
||||
decision middleware.Decision
|
||||
denyCode string
|
||||
}{
|
||||
{
|
||||
name: "unversioned model allowed by bare catalog entry",
|
||||
url: opusBare,
|
||||
body: messagesBody,
|
||||
allowlist: []string{"claude-opus-4-6"},
|
||||
decision: middleware.DecisionAllow,
|
||||
},
|
||||
{
|
||||
name: "unversioned model allowed by @version allowlist entry",
|
||||
url: opusBare,
|
||||
body: messagesBody,
|
||||
allowlist: []string{"claude-opus-4-6@20250514"},
|
||||
decision: middleware.DecisionAllow,
|
||||
},
|
||||
{
|
||||
name: "streaming action allowed the same as rawPredict",
|
||||
url: opusBareSSE,
|
||||
body: messagesBody,
|
||||
allowlist: []string{"claude-opus-4-6"},
|
||||
decision: middleware.DecisionAllow,
|
||||
},
|
||||
{
|
||||
// The original customer report: a Sonnet-only allowlist must block
|
||||
// an Opus request regardless of the id carrying a version suffix.
|
||||
name: "unversioned model outside the allowlist denied",
|
||||
url: opusBare,
|
||||
body: messagesBody,
|
||||
allowlist: []string{"claude-sonnet-4-5"},
|
||||
decision: middleware.DecisionDeny,
|
||||
denyCode: "llm_policy.model_blocked",
|
||||
},
|
||||
{
|
||||
name: "count-tokens resolves the body model and passes when allowed",
|
||||
url: countTokens,
|
||||
body: countOpusBody,
|
||||
allowlist: []string{"claude-opus-4-6"},
|
||||
decision: middleware.DecisionAllow,
|
||||
},
|
||||
{
|
||||
name: "count-tokens with a disallowed body model denied",
|
||||
url: countTokens,
|
||||
body: countOpusBody,
|
||||
allowlist: []string{"claude-sonnet-4-5"},
|
||||
decision: middleware.DecisionDeny,
|
||||
denyCode: "llm_policy.model_blocked",
|
||||
},
|
||||
{
|
||||
// No model in the body: the pseudo-model stays and fails closed.
|
||||
name: "count-tokens without a body model fails closed",
|
||||
url: countTokens,
|
||||
body: messagesBody,
|
||||
allowlist: []string{"claude-opus-4-6"},
|
||||
decision: middleware.DecisionDeny,
|
||||
denyCode: "llm_policy.model_blocked",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
out := runParserGuardrail(t, tt.url, []byte(tt.body), tt.allowlist)
|
||||
assert.Equal(t, tt.decision, out.Decision, "unexpected decision for %s", tt.name)
|
||||
if tt.decision == middleware.DecisionDeny {
|
||||
require.NotNil(t, out.DenyReason, "deny reason must be set for %s", tt.name)
|
||||
assert.Equal(t, 403, out.DenyStatus, "deny status must be 403 for %s", tt.name)
|
||||
assert.Equal(t, tt.denyCode, out.DenyReason.Code, "deny code for %s", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,7 +253,9 @@ func parseVertexPath(reqPath string) (vertexRequest, bool) {
|
||||
if c := strings.LastIndex(rest, ":"); c >= 0 {
|
||||
model, action = rest[:c], rest[c+1:]
|
||||
}
|
||||
model = llm.NormalizeVertexModel(model)
|
||||
if at := strings.Index(model, "@"); at >= 0 {
|
||||
model = model[:at]
|
||||
}
|
||||
if model == "" {
|
||||
return vertexRequest{}, false
|
||||
}
|
||||
@@ -274,44 +276,24 @@ func vertexPublisherVendor(publisher string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// vertexCountTokensModel is the pseudo-model id of the Vertex token-count
|
||||
// endpoint (…/models/count-tokens:rawPredict). It is the one Vertex request
|
||||
// shape whose real model travels in the JSON body rather than the URL path.
|
||||
const vertexCountTokensModel = "count-tokens"
|
||||
|
||||
// invokeVertex emits the model/vendor/session/prompt for a Vertex publisher
|
||||
// request, using the publisher's parser to read the (vendor-native) body.
|
||||
func (m middlewareImpl) invokeVertex(in *middleware.Input, vx vertexRequest) *middleware.Output {
|
||||
out := &middleware.Output{Decision: middleware.DecisionAllow}
|
||||
vendor := vertexPublisherVendor(vx.publisher)
|
||||
|
||||
var parser llm.Parser
|
||||
if vendor != "" {
|
||||
parser, _ = llm.ParserByName(vendor)
|
||||
}
|
||||
|
||||
model := vx.model
|
||||
// Token counting carries the literal "count-tokens" pseudo-model in the URL
|
||||
// and the real model in the body (Claude Code issues one such call per
|
||||
// request). Resolve the body model so the guardrail allowlist and the
|
||||
// router evaluate the actual model — otherwise every count-tokens call
|
||||
// under an allowlist denies as model_blocked. A body the parser cannot
|
||||
// read keeps the pseudo-model, which fails closed downstream.
|
||||
if model == vertexCountTokensModel && parser != nil {
|
||||
if facts, err := parser.ParseRequest(in.Body); err == nil && facts.Model != "" {
|
||||
if bodyModel := llm.NormalizeVertexModel(facts.Model); bodyModel != "" {
|
||||
model = bodyModel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
md := []middleware.KV{}
|
||||
if vendor != "" {
|
||||
md = append(md, middleware.KV{Key: middleware.KeyLLMProvider, Value: vendor})
|
||||
}
|
||||
md = append(md, middleware.KV{Key: middleware.KeyLLMModel, Value: model})
|
||||
md = append(md, middleware.KV{Key: middleware.KeyLLMModel, Value: vx.model})
|
||||
md = append(md, middleware.KV{Key: middleware.KeyLLMStream, Value: strconv.FormatBool(vx.stream)})
|
||||
|
||||
var parser llm.Parser
|
||||
if vendor != "" {
|
||||
parser, _ = llm.ParserByName(vendor)
|
||||
}
|
||||
|
||||
sessionID := sessionIDFromHeaders(in.Headers)
|
||||
if sessionID == "" && parser != nil {
|
||||
sessionID = parser.ExtractSessionID(in.Body)
|
||||
|
||||
@@ -564,14 +564,6 @@ func routeClaimsModel(route ProviderRoute, model string) bool {
|
||||
if route.Bedrock && llm.NormalizeBedrockModel(candidate) == model {
|
||||
return true
|
||||
}
|
||||
// Vertex request models likewise reach the router with the "@version"
|
||||
// suffix already stripped by the parser, while the operator may register
|
||||
// the raw versioned id the Google console documents (e.g.
|
||||
// "claude-opus-4-6@20250514"). Normalize the candidate so both spellings
|
||||
// of the same model match.
|
||||
if route.Vertex && llm.NormalizeVertexModel(candidate) == model {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
package llm_router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/proxy/internal/middleware"
|
||||
)
|
||||
|
||||
// TestRouteClaimsModel_VertexNormalizesCandidate guards the Vertex analog of
|
||||
// the native-Bedrock routing gap: the request model reaches the router already
|
||||
// normalized (the parser strips the "@version" suffix from the URL path), so a
|
||||
// provider registered with the raw versioned id must still match.
|
||||
func TestRouteClaimsModel_VertexNormalizesCandidate(t *testing.T) {
|
||||
route := ProviderRoute{Vertex: true, Models: []string{"claude-opus-4-6@20250514"}}
|
||||
assert.True(t, routeClaimsModel(route, "claude-opus-4-6"),
|
||||
"raw @version Vertex model must match the normalized request model")
|
||||
assert.False(t, routeClaimsModel(route, "claude-haiku-4-5"),
|
||||
"a model outside the provider's list must not match")
|
||||
|
||||
bare := ProviderRoute{Vertex: true, Models: []string{"claude-opus-4-6"}}
|
||||
assert.True(t, routeClaimsModel(bare, "claude-opus-4-6"),
|
||||
"a bare catalog id keeps matching exactly")
|
||||
|
||||
// Non-Vertex routes keep exact matching for "@"-suffixed candidates.
|
||||
direct := ProviderRoute{Models: []string{"claude-opus-4-6@20250514"}}
|
||||
assert.False(t, routeClaimsModel(direct, "claude-opus-4-6"),
|
||||
"non-Vertex routes must not normalize @version candidates")
|
||||
}
|
||||
|
||||
// TestRouter_VertexUnversionedModelRoutes replays the customer-reported request
|
||||
// end to end through the router: an unversioned model id at the global location
|
||||
// (…/models/claude-opus-4-6:rawPredict) must route on a Vertex provider whose
|
||||
// models were registered with the raw "@version" ids.
|
||||
func TestRouter_VertexUnversionedModelRoutes(t *testing.T) {
|
||||
route := vertexRoute()
|
||||
route.Models = []string{"claude-opus-4-6@20250514", "claude-sonnet-4-5@20250929"}
|
||||
mw := New(Config{Providers: []ProviderRoute{route}})
|
||||
|
||||
in := pathRoutedInput(
|
||||
"/v1/projects/corp-gcp-it-all-claude/locations/global/publishers/anthropic/models/claude-opus-4-6:rawPredict",
|
||||
"anthropic",
|
||||
"claude-opus-4-6",
|
||||
)
|
||||
out, err := mw.Invoke(context.Background(), in)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, middleware.DecisionAllow, out.Decision,
|
||||
"unversioned request model must route on a provider registered with @version ids")
|
||||
|
||||
denied := pathRoutedInput(
|
||||
"/v1/projects/corp-gcp-it-all-claude/locations/global/publishers/anthropic/models/claude-haiku-4-5:rawPredict",
|
||||
"anthropic",
|
||||
"claude-haiku-4-5",
|
||||
)
|
||||
out, err = mw.Invoke(context.Background(), denied)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, middleware.DecisionDeny, out.Decision,
|
||||
"a model outside the provider's registered list must still deny")
|
||||
require.NotNil(t, out.DenyReason)
|
||||
assert.Equal(t, denyCodeNotRoutable, out.DenyReason.Code)
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
package proxy_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/test/bufconn"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
||||
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
mgmtgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/proxy/internal/middleware"
|
||||
"github.com/netbirdio/netbird/proxy/internal/middleware/bodytap"
|
||||
mwbuiltin "github.com/netbirdio/netbird/proxy/internal/middleware/builtin"
|
||||
_ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/cost_meter"
|
||||
_ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_guardrail"
|
||||
_ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_identity_inject"
|
||||
_ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_limit_check"
|
||||
_ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_limit_record"
|
||||
_ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_request_parser"
|
||||
_ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_response_parser"
|
||||
_ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_router"
|
||||
"github.com/netbirdio/netbird/proxy/internal/proxy"
|
||||
nbproxytypes "github.com/netbirdio/netbird/proxy/internal/types"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// TestReverseProxy_VertexGuardrail_ModelAllowlist drives Anthropic-on-Vertex
|
||||
// requests (model in the URL path, not the body — the shape Claude Code sends)
|
||||
// through the full synthesized middleware chain against an in-process
|
||||
// management stack and a fake Vertex upstream. It reproduces the customer
|
||||
// report end to end: with a guardrail allowlisting only Sonnet, an Opus request
|
||||
// must be denied by the guardrail (model_blocked) before it reaches the
|
||||
// upstream, and the allowed Sonnet request must reach it.
|
||||
//
|
||||
// Two provider shapes are covered because they exercise different code:
|
||||
//
|
||||
// - "catch_all": provider registered with no models (the historical Vertex
|
||||
// default). The router waves every model through, so the guardrail is the
|
||||
// only thing that can block Opus — the direct regression guard for "the
|
||||
// disallowed model is never blocked".
|
||||
// - "versioned_models": provider registered with the raw "@version" model ids
|
||||
// the Google console documents. The router must normalize the registered id
|
||||
// to route the version-stripped request model (otherwise even Sonnet denies
|
||||
// as model_not_routable), and the guardrail must still block Opus.
|
||||
//
|
||||
// The upstream-hit counter proves whether a denied request actually
|
||||
// short-circuited or leaked through to Google.
|
||||
func TestReverseProxy_VertexGuardrail_ModelAllowlist(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("sqlite store not supported on Windows")
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
providerModels []agentNetworkTypes.ProviderModel
|
||||
}{
|
||||
{name: "catch_all", providerModels: nil},
|
||||
{name: "versioned_models", providerModels: []agentNetworkTypes.ProviderModel{
|
||||
{ID: "claude-sonnet-4-5@20250929"},
|
||||
{ID: "claude-opus-4-6@20250514"},
|
||||
}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
runVertexGuardrailCase(t, tc.providerModels)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func runVertexGuardrailCase(t *testing.T, providerModels []agentNetworkTypes.ProviderModel) {
|
||||
t.Helper()
|
||||
|
||||
const (
|
||||
testAccountID = "acct-vertex-guard-1"
|
||||
testAdminUser = "user-admin-1"
|
||||
adminGroupID = "grp-admins"
|
||||
providerID = "prov-vertex-test"
|
||||
guardrailID = "ainguard-sonnet-only"
|
||||
cluster = "test.proxy.local"
|
||||
subdomain = "vertexguard"
|
||||
)
|
||||
testLogger := log.New()
|
||||
testLogger.SetLevel(log.PanicLevel)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Fake Vertex upstream: any request that reaches it is a guardrail miss for
|
||||
// the disallowed model.
|
||||
var upstreamHits atomic.Int64
|
||||
upstreamBody := []byte(`{"id":"msg_x","type":"message","role":"assistant","model":"claude","content":[{"type":"text","text":"pong"}],"usage":{"input_tokens":5,"output_tokens":2}}`)
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
upstreamHits.Add(1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(upstreamBody)
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
|
||||
// In-process management gRPC (bufconn) over a real sqlite store + manager.
|
||||
st, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
|
||||
require.NoError(t, err, "real sqlite test store must come up")
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
anMgr := agentnetwork.NewManager(st, nil, nil, nil)
|
||||
server := &mgmtgrpc.ProxyServiceServer{}
|
||||
server.SetAgentNetworkLimitsService(anMgr)
|
||||
|
||||
lis := bufconn.Listen(1024 * 1024)
|
||||
srv := grpc.NewServer()
|
||||
proto.RegisterProxyServiceServer(srv, server)
|
||||
go func() { _ = srv.Serve(lis) }()
|
||||
t.Cleanup(srv.Stop)
|
||||
|
||||
conn, err := grpc.NewClient("passthrough:///bufnet",
|
||||
grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { return lis.Dial() }),
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
mgmtClient := proto.NewProxyServiceClient(conn)
|
||||
|
||||
require.NoError(t, st.SaveAgentNetworkSettings(ctx, &agentNetworkTypes.Settings{
|
||||
AccountID: testAccountID,
|
||||
Cluster: cluster,
|
||||
Subdomain: subdomain,
|
||||
}))
|
||||
require.NoError(t, st.SaveAgentNetworkProvider(ctx, &agentNetworkTypes.Provider{
|
||||
ID: providerID,
|
||||
AccountID: testAccountID,
|
||||
ProviderID: "vertex_ai_api",
|
||||
Name: "vertex-guard-test",
|
||||
UpstreamURL: upstream.URL,
|
||||
// A static bearer (not "keyfile::…") so the router injects a static auth
|
||||
// header instead of minting a GCP OAuth token at request time — the mint
|
||||
// needs network egress and would deny before the guardrail runs, masking
|
||||
// the guardrail decision this test exists to observe.
|
||||
APIKey: "static-vertex-token",
|
||||
Enabled: true,
|
||||
Models: providerModels,
|
||||
SessionPrivateKey: "priv",
|
||||
SessionPublicKey: "pub",
|
||||
}))
|
||||
// Guardrail allowlisting ONLY Sonnet.
|
||||
require.NoError(t, st.SaveAgentNetworkGuardrail(ctx, &agentNetworkTypes.Guardrail{
|
||||
ID: guardrailID,
|
||||
AccountID: testAccountID,
|
||||
Name: "sonnet-only",
|
||||
Checks: agentNetworkTypes.GuardrailChecks{
|
||||
ModelAllowlist: agentNetworkTypes.GuardrailModelAllowlist{
|
||||
Enabled: true,
|
||||
Models: []string{"claude-sonnet-4-5"},
|
||||
},
|
||||
},
|
||||
}))
|
||||
require.NoError(t, st.SaveAgentNetworkPolicy(ctx, &agentNetworkTypes.Policy{
|
||||
ID: "ainpol-vertex-guard",
|
||||
AccountID: testAccountID,
|
||||
Name: "admins-vertex",
|
||||
Enabled: true,
|
||||
SourceGroups: []string{adminGroupID},
|
||||
DestinationProviderIDs: []string{providerID},
|
||||
GuardrailIDs: []string{guardrailID},
|
||||
}))
|
||||
|
||||
services, err := agentnetwork.SynthesizeServices(ctx, st, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, services, 1, "exactly one synth service expected")
|
||||
synthSvc := services[0]
|
||||
require.NotEmpty(t, synthSvc.Targets, "synth target must exist")
|
||||
|
||||
mwbuiltin.Configure(ctx, t.TempDir(), nil, testLogger, mgmtClient)
|
||||
registry := mwbuiltin.DefaultRegistry()
|
||||
mwMetrics, err := middleware.NewMetrics(nil)
|
||||
require.NoError(t, err)
|
||||
mwMgr := middleware.NewManager(0, mwMetrics, testLogger)
|
||||
mwMgr.SetResolver(middleware.NewResolver(registry))
|
||||
|
||||
specs := make([]middleware.Spec, 0, len(synthSvc.Targets[0].Options.Middlewares))
|
||||
for _, mw := range synthSvc.Targets[0].Options.Middlewares {
|
||||
var slot middleware.Slot
|
||||
switch mw.Slot {
|
||||
case rpservice.MiddlewareSlotOnRequest:
|
||||
slot = middleware.SlotOnRequest
|
||||
case rpservice.MiddlewareSlotOnResponse:
|
||||
slot = middleware.SlotOnResponse
|
||||
case rpservice.MiddlewareSlotTerminal:
|
||||
slot = middleware.SlotTerminal
|
||||
default:
|
||||
t.Fatalf("unknown middleware slot %q on %s", mw.Slot, mw.ID)
|
||||
}
|
||||
specs = append(specs, middleware.Spec{
|
||||
ID: mw.ID,
|
||||
Slot: slot,
|
||||
Enabled: mw.Enabled,
|
||||
FailMode: middleware.FailOpen,
|
||||
Timeout: middleware.DefaultTimeout,
|
||||
RawConfig: append([]byte(nil), mw.ConfigJSON...),
|
||||
CanMutate: mw.CanMutate,
|
||||
})
|
||||
}
|
||||
|
||||
serviceIDStr := synthSvc.ID
|
||||
require.NoError(t, mwMgr.Rebuild(serviceIDStr, []middleware.PathTargetBinding{{
|
||||
ServiceID: serviceIDStr,
|
||||
PathID: "/",
|
||||
Specs: specs,
|
||||
}}))
|
||||
|
||||
upstreamURL, err := url.Parse(upstream.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
rp := proxy.NewReverseProxy(http.DefaultTransport, "auto", nil, testLogger, proxy.WithMiddlewareManager(mwMgr))
|
||||
rp.AddMapping(proxy.Mapping{
|
||||
ID: nbproxytypes.ServiceID(serviceIDStr),
|
||||
AccountID: nbproxytypes.AccountID(testAccountID),
|
||||
Host: synthSvc.Domain,
|
||||
Paths: map[string]*proxy.PathTarget{
|
||||
"/": {
|
||||
URL: upstreamURL,
|
||||
DirectUpstream: true,
|
||||
AgentNetwork: true,
|
||||
Middlewares: specs,
|
||||
CaptureConfig: &bodytap.Config{
|
||||
MaxRequestBytes: 1 << 20,
|
||||
MaxResponseBytes: 1 << 20,
|
||||
ContentTypes: []string{"application/json", "text/event-stream"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// vertexBody carries no "model" field — the model lives in the URL path,
|
||||
// exactly as an Anthropic SDK client (Claude Code) posts to Vertex.
|
||||
const vertexBody = `{"anthropic_version":"vertex-2023-10-16","max_tokens":64,"messages":[{"role":"user","content":"Reply with exactly: pong"}]}`
|
||||
|
||||
send := func(t *testing.T, model string) (int, int64, string) {
|
||||
t.Helper()
|
||||
before := upstreamHits.Load()
|
||||
path := "/v1/projects/corp-gcp-it-all-claude/locations/global/publishers/anthropic/models/" + model + ":rawPredict"
|
||||
req := httptest.NewRequest("POST", "https://"+synthSvc.Domain+path, strings.NewReader(vertexBody))
|
||||
req.Host = synthSvc.Domain
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
cd := proxy.NewCapturedData("req-" + model)
|
||||
cd.SetServiceID(nbproxytypes.ServiceID(serviceIDStr))
|
||||
cd.SetAccountID(nbproxytypes.AccountID(testAccountID))
|
||||
cd.SetUserID(testAdminUser)
|
||||
cd.SetUserGroups([]string{adminGroupID})
|
||||
cd.SetAuthMethod("tunnel_peer")
|
||||
req = req.WithContext(proxy.WithCapturedData(req.Context(), cd))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
rp.ServeHTTP(w, req)
|
||||
return w.Code, upstreamHits.Load() - before, w.Body.String()
|
||||
}
|
||||
|
||||
// The disallowed model (Opus) MUST be denied by the guardrail (model_blocked)
|
||||
// before the upstream — this is the customer-reported bug (Opus let through
|
||||
// under a Sonnet-only allowlist). The deny code distinguishes a guardrail
|
||||
// block from a router model_not_routable, which would deny for the wrong
|
||||
// reason.
|
||||
t.Run("opus_denied_by_guardrail", func(t *testing.T) {
|
||||
code, hits, body := send(t, "claude-opus-4-6")
|
||||
assert.Equal(t, http.StatusForbidden, code, "Opus must be denied under a Sonnet-only allowlist; body=%s", body)
|
||||
assert.Contains(t, body, "llm_policy.model_blocked", "denial must come from the guardrail allowlist, not routing; body=%s", body)
|
||||
assert.Equal(t, int64(0), hits, "a denied request must never reach the Vertex upstream")
|
||||
})
|
||||
|
||||
// The allowed model (Sonnet) passes the guardrail and reaches the upstream.
|
||||
t.Run("sonnet_allowed", func(t *testing.T) {
|
||||
code, hits, body := send(t, "claude-sonnet-4-5")
|
||||
assert.Equal(t, http.StatusOK, code, "Sonnet is allowlisted and must be served; body=%s", body)
|
||||
assert.Equal(t, int64(1), hits, "the allowed request must reach the Vertex upstream exactly once")
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user