mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-27 00:59:07 +02:00
Merge branch 'main' into embedded-vnc
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.25-bookworm AS builder
|
||||
FROM golang:1.26.7-bookworm AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
|
||||
@@ -511,6 +511,7 @@ func (c *Controller) fetchNetworkMapData(ctx context.Context, accountID string)
|
||||
}
|
||||
|
||||
nmData.Services = c.proxyServicesFromRepo(ctx, accountID)
|
||||
nmData.BuildPrivateServiceCandidates()
|
||||
nmData.InjectProxyPolicies()
|
||||
nmData.PrecomputePostureValidation()
|
||||
|
||||
|
||||
@@ -183,6 +183,7 @@ func RunCase(t *testing.T, c Case) {
|
||||
ctx := context.Background()
|
||||
nmData := c.Data
|
||||
applyFixtureDefaults(nmData)
|
||||
nmData.BuildPrivateServiceCandidates()
|
||||
nmData.PrecomputePostureValidation()
|
||||
|
||||
dnsDomain := c.DNSDomain
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
)
|
||||
|
||||
// GetAgentConfigForUser returns the Agent Network setup the calling user's
|
||||
// groups authorize. It deliberately performs no role permission check:
|
||||
// the result is scoped to the caller's own groups, which is strictly
|
||||
// tighter than any role gate, so every authenticated user (any role) may
|
||||
// read it. The group source matches enforcement: the proxy authorizes
|
||||
// each Agent Network request against the calling user's groups as well —
|
||||
// session validation resolves them from the same user record's
|
||||
// auto-groups — so this answer and the proxy's verdict are computed from
|
||||
// the same memberships.
|
||||
func (m *managerImpl) GetAgentConfigForUser(ctx context.Context, accountID, userID string) (*types.AgentConfig, error) {
|
||||
user, err := m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get user: %w", err)
|
||||
}
|
||||
return m.agentConfigForGroups(ctx, accountID, user.AutoGroups)
|
||||
}
|
||||
|
||||
// agentConfigForGroups computes the effective Agent Network setup for
|
||||
// a set of caller groups: the account endpoint plus, per authorized
|
||||
// provider, the effective model set. It mirrors what the proxy enforces
|
||||
// at request time — the policy filter matches filterApplicablePolicies,
|
||||
// the model logic matches policyPermitsModel, and orphan providers
|
||||
// (enabled but referenced by no applicable policy) are omitted just like
|
||||
// the router synthesizer omits them — so the answer never advertises
|
||||
// anything the proxy would refuse.
|
||||
//
|
||||
// Configured tracks the account, not the caller: once the account has an
|
||||
// endpoint every member gets it, with Providers empty for those no policy
|
||||
// covers yet. The dashboard shows each user the same connection config
|
||||
// regardless of role, and an empty provider list tells them to ask for
|
||||
// access. Only the account having no Agent Network at all reads as not
|
||||
// configured. Providers stays caller-scoped either way — the endpoint on
|
||||
// its own authorizes nothing, and the proxy still refuses every request
|
||||
// no policy permits.
|
||||
func (m *managerImpl) agentConfigForGroups(ctx context.Context, accountID string, groupIDs []string) (*types.AgentConfig, error) {
|
||||
notConfigured := &types.AgentConfig{Providers: []types.AgentConfigProvider{}}
|
||||
|
||||
settings, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
|
||||
switch {
|
||||
case err == nil:
|
||||
case isNotFound(err):
|
||||
return notConfigured, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("get agent network settings: %w", err)
|
||||
}
|
||||
if settings.Endpoint() == "" {
|
||||
return notConfigured, nil
|
||||
}
|
||||
|
||||
authorized, applicable, err := m.authorizedProvidersForGroups(ctx, accountID, groupIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := &types.AgentConfig{
|
||||
Configured: true,
|
||||
Endpoint: "https://" + settings.Endpoint(),
|
||||
Providers: make([]types.AgentConfigProvider, 0, len(authorized)),
|
||||
}
|
||||
if len(authorized) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var guardrailsByID map[string]*types.Guardrail
|
||||
if anyPolicyHasGuardrails(applicable) {
|
||||
guardrailsByID, err = m.loadGuardrailsByID(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, p := range authorized {
|
||||
allAllowed, models := effectiveModelsForProvider(p, policiesForProvider(applicable, p.ID), guardrailsByID)
|
||||
flavor := ""
|
||||
if entry, ok := catalog.Lookup(p.ProviderID); ok {
|
||||
flavor = entry.ParserID
|
||||
}
|
||||
out.Providers = append(out.Providers, types.AgentConfigProvider{
|
||||
Name: p.Name,
|
||||
CatalogID: p.ProviderID,
|
||||
APIFlavor: flavor,
|
||||
AllModelsAllowed: allAllowed,
|
||||
Models: models,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// authorizedProvidersForGroups returns the enabled providers referenced
|
||||
// by at least one enabled policy whose source groups intersect groupIDs —
|
||||
// the providers the caller's own policies authorize — in created_at order
|
||||
// with ID tiebreak, the same deterministic order the router synthesizer
|
||||
// presents. The applicable policies come back alongside so callers that
|
||||
// need per-provider policy context (the setup's model computation) don't
|
||||
// re-filter. Both the self-service setup answer and the caller-scoped
|
||||
// provider list are built from this selection, so what the dashboard
|
||||
// offers and what the proxy enforces never diverge.
|
||||
func (m *managerImpl) authorizedProvidersForGroups(ctx context.Context, accountID string, groupIDs []string) ([]*types.Provider, []*types.Policy, error) {
|
||||
policies, err := m.store.GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, accountID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("list account policies: %w", err)
|
||||
}
|
||||
applicable := filterPoliciesByGroups(policies, groupIDs)
|
||||
if len(applicable) == 0 {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
providers, err := m.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, accountID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("list account providers: %w", err)
|
||||
}
|
||||
|
||||
// filterEnabledProviders carries the enabled filter and the
|
||||
// created_at/ID order shared with the router synthesizer.
|
||||
enabled := filterEnabledProviders(providers)
|
||||
authorized := make([]*types.Provider, 0, len(enabled))
|
||||
for _, p := range enabled {
|
||||
if len(policiesForProvider(applicable, p.ID)) == 0 {
|
||||
continue
|
||||
}
|
||||
authorized = append(authorized, p)
|
||||
}
|
||||
return authorized, applicable, nil
|
||||
}
|
||||
|
||||
// filterPoliciesByGroups returns the enabled policies whose SourceGroups
|
||||
// intersect the caller's groups. Same group matching as
|
||||
// filterApplicablePolicies, without the per-provider filter — the setup
|
||||
// answer spans every provider the caller can reach.
|
||||
func filterPoliciesByGroups(policies []*types.Policy, groupIDs []string) []*types.Policy {
|
||||
groupSet := make(map[string]struct{}, len(groupIDs))
|
||||
for _, g := range groupIDs {
|
||||
if g != "" {
|
||||
groupSet[g] = struct{}{}
|
||||
}
|
||||
}
|
||||
out := make([]*types.Policy, 0, len(policies))
|
||||
for _, p := range policies {
|
||||
if p == nil || !p.Enabled {
|
||||
continue
|
||||
}
|
||||
if !anyGroupMatches(p.SourceGroups, groupSet) {
|
||||
continue
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// policiesForProvider returns the subset of policies targeting the
|
||||
// provider, order preserved.
|
||||
func policiesForProvider(policies []*types.Policy, providerID string) []*types.Policy {
|
||||
out := make([]*types.Policy, 0, len(policies))
|
||||
for _, p := range policies {
|
||||
if sliceContains(p.DestinationProviderIDs, providerID) {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// effectiveModelsForProvider derives the caller's effective model set for
|
||||
// one provider from the applicable policies that target it, mirroring
|
||||
// policyPermitsModel: a policy with no allowlist-enabled guardrail is
|
||||
// unrestricted, and one unrestricted policy makes the whole provider
|
||||
// unrestricted (the proxy would admit any model through it). Otherwise
|
||||
// the union of the policies' allowlists applies, intersected with the
|
||||
// provider's declared models when the operator declared any — the router
|
||||
// only claims declared models, so an allowlisted-but-undeclared model is
|
||||
// unreachable and must not be advertised. With no declared models the
|
||||
// router claims every model, so the allowlist union stands alone.
|
||||
func effectiveModelsForProvider(provider *types.Provider, policies []*types.Policy, guardrailsByID map[string]*types.Guardrail) (bool, []string) {
|
||||
restricted := true
|
||||
union := make([]string, 0)
|
||||
seen := make(map[string]struct{})
|
||||
for _, p := range policies {
|
||||
policyRestricted := false
|
||||
for _, gID := range p.GuardrailIDs {
|
||||
g, ok := guardrailsByID[gID]
|
||||
if !ok || g == nil || !g.Checks.ModelAllowlist.Enabled {
|
||||
continue
|
||||
}
|
||||
policyRestricted = true
|
||||
for _, model := range g.Checks.ModelAllowlist.Models {
|
||||
key := normaliseModelID(model)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[key]; dup {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
union = append(union, key)
|
||||
}
|
||||
}
|
||||
if !policyRestricted {
|
||||
restricted = false
|
||||
}
|
||||
}
|
||||
|
||||
declared := declaredModelIDs(provider)
|
||||
if !restricted {
|
||||
return true, declared
|
||||
}
|
||||
if len(provider.Models) == 0 {
|
||||
// No operator declaration: the router claims every model, so the
|
||||
// allowlist union is the effective set as-is.
|
||||
return false, union
|
||||
}
|
||||
out := make([]string, 0, len(declared))
|
||||
for _, id := range declared {
|
||||
// Compare through the canonical id the proxy's parser emits — a
|
||||
// Bedrock declaration may carry the region/version form
|
||||
// ("eu.anthropic.claude-...-v1:0") while the allowlist holds the
|
||||
// stripped id the parser matches at request time, and the raw
|
||||
// forms would never intersect. The declared id itself is what
|
||||
// gets advertised, matching the router's route claim.
|
||||
if _, ok := seen[normaliseModelID(normalizePricingModelID(provider.ProviderID, id))]; ok {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return false, out
|
||||
}
|
||||
|
||||
// providerModelsByID maps effective model ids (as effectiveModelsForProvider
|
||||
// returns them) back onto the operator's declared entries, keeping the
|
||||
// declared casing and prices. With no operator declaration the ids are the
|
||||
// allowlist union and have no declared entry to map to, so bare entries are
|
||||
// synthesized — the router claims every model in that case, so those ids are
|
||||
// reachable and belong in the answer.
|
||||
func providerModelsByID(provider *types.Provider, ids []string) []types.ProviderModel {
|
||||
if len(provider.Models) == 0 {
|
||||
out := make([]types.ProviderModel, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
out = append(out, types.ProviderModel{ID: id})
|
||||
}
|
||||
return out
|
||||
}
|
||||
keep := make(map[string]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
keep[normaliseModelID(id)] = struct{}{}
|
||||
}
|
||||
out := make([]types.ProviderModel, 0, len(ids))
|
||||
for _, m := range provider.Models {
|
||||
if _, ok := keep[normaliseModelID(m.ID)]; ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// declaredModelIDs returns the models a provider exposes: the operator's
|
||||
// curated list when present, otherwise the catalog entry's models (an
|
||||
// empty operator list means "all catalog models"). Gateway/custom catalog
|
||||
// entries declare no models, so the result may be empty.
|
||||
func declaredModelIDs(provider *types.Provider) []string {
|
||||
if ids := providerModelIDs(provider); len(ids) > 0 {
|
||||
return ids
|
||||
}
|
||||
entry, ok := catalog.Lookup(provider.ProviderID)
|
||||
if !ok {
|
||||
return []string{}
|
||||
}
|
||||
out := make([]string, 0, len(entry.Models))
|
||||
for _, m := range entry.Models {
|
||||
if m.ID != "" {
|
||||
out = append(out, m.ID)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetAgentConfigForUser on the mock manager reports "not configured" so tests
|
||||
// that don't care about setup still compile.
|
||||
func (*mockManager) GetAgentConfigForUser(_ context.Context, _, _ string) (*types.AgentConfig, error) {
|
||||
return &types.AgentConfig{Providers: []types.AgentConfigProvider{}}, nil
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
nbtypes "github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
// These tests drive the effective-setup computation through the real
|
||||
// sqlite store, mirroring the policyselect realstore suite: assert on
|
||||
// observable answers (configured / providers / models), not on which
|
||||
// store methods get called. The computation must agree with what the
|
||||
// proxy enforces — policy filtering matches filterApplicablePolicies,
|
||||
// model logic matches policyPermitsModel, and orphan providers are
|
||||
// omitted like the router synthesizer omits them.
|
||||
|
||||
func newAgentConfigTestMgr(t *testing.T) (*managerImpl, store.Store) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
|
||||
require.NoError(t, err, "real sqlite test store must come up")
|
||||
t.Cleanup(cleanup)
|
||||
return &managerImpl{store: s}, s
|
||||
}
|
||||
|
||||
// newSetupTestGuardrail returns an allowlist-enabled guardrail.
|
||||
func newSetupTestGuardrail(id string, models ...string) *types.Guardrail {
|
||||
return &types.Guardrail{
|
||||
ID: id,
|
||||
AccountID: testAccountID,
|
||||
Name: "allowlist " + id,
|
||||
Checks: types.GuardrailChecks{
|
||||
ModelAllowlist: types.GuardrailModelAllowlist{Enabled: true, Models: models},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentConfig_RealStore_NoSettingsRow(t *testing.T) {
|
||||
mgr, _ := newAgentConfigTestMgr(t)
|
||||
|
||||
setup, err := mgr.agentConfigForGroups(context.Background(), testAccountID, []string{"grp-eng"})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, setup.Configured, "account without settings must read as not configured")
|
||||
assert.Empty(t, setup.Endpoint)
|
||||
assert.Empty(t, setup.Providers)
|
||||
}
|
||||
|
||||
func TestAgentConfig_RealStore_NoApplicablePolicy(t *testing.T) {
|
||||
mgr, s := newAgentConfigTestMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
provider := newSynthTestProvider()
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
|
||||
|
||||
setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-other"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, setup.Configured, "the account is set up, so every member reads as configured")
|
||||
assert.Equal(t, "https://"+testEndpoint, setup.Endpoint, "every member gets the same connection config")
|
||||
assert.Empty(t, setup.Providers, "a caller no policy covers is authorized for nothing")
|
||||
}
|
||||
|
||||
func TestAgentConfig_RealStore_UnrestrictedPolicyListsDeclaredModels(t *testing.T) {
|
||||
mgr, s := newAgentConfigTestMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
provider := newSynthTestProvider()
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
|
||||
|
||||
setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, setup.Configured)
|
||||
assert.Equal(t, "https://"+testEndpoint, setup.Endpoint)
|
||||
require.Len(t, setup.Providers, 1)
|
||||
p := setup.Providers[0]
|
||||
assert.Equal(t, "OpenAI", p.Name)
|
||||
assert.Equal(t, "openai_api", p.CatalogID)
|
||||
assert.Equal(t, "openai", p.APIFlavor)
|
||||
assert.True(t, p.AllModelsAllowed, "policy without allowlist guardrail is unrestricted")
|
||||
assert.Equal(t, []string{"gpt-5.4"}, p.Models, "declared models listed as a courtesy")
|
||||
}
|
||||
|
||||
func TestAgentConfig_RealStore_AllowlistIntersectsDeclaredModels(t *testing.T) {
|
||||
mgr, s := newAgentConfigTestMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
provider := newSynthTestProvider()
|
||||
provider.Models = []types.ProviderModel{{ID: "gpt-5.4"}, {ID: "gpt-4o"}}
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
// Allowlist admits gpt-5.4 (declared, odd casing/spacing) and gpt-4.1
|
||||
// (NOT declared — the router would never route it, so it must not be
|
||||
// advertised).
|
||||
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", " GPT-5.4 ", "gpt-4.1")))
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "guard-1")))
|
||||
|
||||
setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, setup.Providers, 1)
|
||||
p := setup.Providers[0]
|
||||
assert.False(t, p.AllModelsAllowed)
|
||||
assert.Equal(t, []string{"gpt-5.4"}, p.Models, "allowlist ∩ declared, in declared order and casing")
|
||||
}
|
||||
|
||||
func TestAgentConfig_RealStore_AllowlistMatchesBedrockDeclaredIDsCanonically(t *testing.T) {
|
||||
mgr, s := newAgentConfigTestMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
// A Bedrock operator typically declares the region/version form the
|
||||
// vendor lists, while the allowlist holds the canonical id the proxy's
|
||||
// parser emits at request time. The intersection must compare through
|
||||
// the same normalization the parser applies, and the declared (raw)
|
||||
// id is what gets advertised — it is what the router claims.
|
||||
provider := newSynthTestProvider()
|
||||
provider.ProviderID = "bedrock_api"
|
||||
provider.Name = "Bedrock"
|
||||
provider.Models = []types.ProviderModel{
|
||||
{ID: "eu.anthropic.claude-sonnet-4-5-20250929-v1:0"},
|
||||
{ID: "eu.amazon.nova-pro-v1:0"},
|
||||
}
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", "anthropic.claude-sonnet-4-5")))
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "guard-1")))
|
||||
|
||||
setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, setup.Providers, 1)
|
||||
p := setup.Providers[0]
|
||||
assert.False(t, p.AllModelsAllowed)
|
||||
assert.Equal(t, []string{"eu.anthropic.claude-sonnet-4-5-20250929-v1:0"}, p.Models,
|
||||
"the allowlisted canonical id must admit the declared region/version form, and only it")
|
||||
}
|
||||
|
||||
func TestAgentConfig_RealStore_UnrestrictedPolicyWinsOverRestricted(t *testing.T) {
|
||||
mgr, s := newAgentConfigTestMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
provider := newSynthTestProvider()
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", "gpt-5.4")))
|
||||
restricted := newSynthTestPolicy(provider.ID, "grp-eng", "guard-1")
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, restricted))
|
||||
open := newSynthTestPolicy(provider.ID, "grp-eng", "")
|
||||
open.ID = "pol-2"
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, open))
|
||||
|
||||
setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, setup.Providers, 1)
|
||||
assert.True(t, setup.Providers[0].AllModelsAllowed,
|
||||
"one applicable policy without an allowlist makes the provider unrestricted — the proxy would admit any model through it")
|
||||
}
|
||||
|
||||
func TestAgentConfig_RealStore_AllowlistUnionAcrossPolicies(t *testing.T) {
|
||||
mgr, s := newAgentConfigTestMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
provider := newSynthTestProvider()
|
||||
provider.Models = []types.ProviderModel{{ID: "gpt-5.4"}, {ID: "gpt-4o"}, {ID: "o4-mini"}}
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", "gpt-5.4")))
|
||||
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-2", "gpt-4o")))
|
||||
p1 := newSynthTestPolicy(provider.ID, "grp-eng", "guard-1")
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, p1))
|
||||
p2 := newSynthTestPolicy(provider.ID, "grp-eng", "guard-2")
|
||||
p2.ID = "pol-2"
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, p2))
|
||||
|
||||
setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, setup.Providers, 1)
|
||||
p := setup.Providers[0]
|
||||
assert.False(t, p.AllModelsAllowed)
|
||||
assert.ElementsMatch(t, []string{"gpt-5.4", "gpt-4o"}, p.Models, "union of allowlists across applicable policies")
|
||||
}
|
||||
|
||||
func TestAgentConfig_RealStore_OrphanAndDisabledProvidersOmitted(t *testing.T) {
|
||||
mgr, s := newAgentConfigTestMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
// Orphan: enabled but referenced by no policy.
|
||||
orphan := newSynthTestProvider()
|
||||
orphan.ID = "prov-orphan"
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, orphan))
|
||||
// Disabled but referenced by an applicable policy.
|
||||
disabled := newSynthTestProvider()
|
||||
disabled.ID = "prov-disabled"
|
||||
disabled.Enabled = false
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, disabled))
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(disabled.ID, "grp-eng", "")))
|
||||
|
||||
setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, setup.Configured)
|
||||
assert.Empty(t, setup.Providers, "neither an orphan nor a disabled provider is reachable for the caller")
|
||||
}
|
||||
|
||||
func TestAgentConfig_RealStore_DisabledPolicyIgnored(t *testing.T) {
|
||||
mgr, s := newAgentConfigTestMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
provider := newSynthTestProvider()
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
policy := newSynthTestPolicy(provider.ID, "grp-eng", "")
|
||||
policy.Enabled = false
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy))
|
||||
|
||||
setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, setup.Configured)
|
||||
assert.Empty(t, setup.Providers, "a disabled policy authorizes nothing")
|
||||
}
|
||||
|
||||
func TestAgentConfig_RealStore_UndeclaredModelsUseAllowlistAsIs(t *testing.T) {
|
||||
mgr, s := newAgentConfigTestMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
// Gateway-style provider: no declared models — the router claims every
|
||||
// model, so the allowlist union is the effective set on its own.
|
||||
provider := newSynthTestProvider()
|
||||
provider.ProviderID = "litellm_proxy"
|
||||
provider.Name = "LiteLLM"
|
||||
provider.Models = nil
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", "claude-sonnet-4-5")))
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "guard-1")))
|
||||
|
||||
setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, setup.Providers, 1)
|
||||
p := setup.Providers[0]
|
||||
assert.False(t, p.AllModelsAllowed)
|
||||
assert.Equal(t, []string{"claude-sonnet-4-5"}, p.Models)
|
||||
}
|
||||
|
||||
func TestAgentConfig_RealStore_ProvidersInCreatedAtOrder(t *testing.T) {
|
||||
mgr, s := newAgentConfigTestMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
newer := newSynthTestProvider()
|
||||
newer.ID = "prov-newer"
|
||||
newer.Name = "Newer"
|
||||
newer.CreatedAt = time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, newer))
|
||||
older := newSynthTestProvider()
|
||||
older.ID = "prov-older"
|
||||
older.Name = "Older"
|
||||
older.CreatedAt = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, older))
|
||||
|
||||
policy := newSynthTestPolicy(newer.ID, "grp-eng", "")
|
||||
policy.DestinationProviderIDs = []string{newer.ID, older.ID}
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy))
|
||||
|
||||
setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, setup.Providers, 2)
|
||||
assert.Equal(t, "Older", setup.Providers[0].Name)
|
||||
assert.Equal(t, "Newer", setup.Providers[1].Name)
|
||||
}
|
||||
|
||||
// TestGetAgentConfigForUser_RealStore pins the self-service entry point: the
|
||||
// user's group memberships (AutoGroups — the same groups the user's peers
|
||||
// carry) scope the providers, while the account's endpoint reaches every
|
||||
// member — a user outside every policy gets the config with nothing
|
||||
// authorized in it.
|
||||
func TestGetAgentConfigForUser_RealStore(t *testing.T) {
|
||||
mgr, s := newAgentConfigTestMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
provider := newSynthTestProvider()
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
|
||||
|
||||
// users.account_id is a foreign key into accounts, enforced on
|
||||
// MySQL/Postgres, so the account row must exist before its users.
|
||||
require.NoError(t, s.SaveAccount(ctx, &nbtypes.Account{Id: testAccountID}))
|
||||
require.NoError(t, s.SaveUser(ctx, &nbtypes.User{
|
||||
Id: "user-in", AccountID: testAccountID, Role: nbtypes.UserRoleUser, AutoGroups: []string{"grp-eng"},
|
||||
}))
|
||||
require.NoError(t, s.SaveUser(ctx, &nbtypes.User{
|
||||
Id: "user-out", AccountID: testAccountID, Role: nbtypes.UserRoleUser, AutoGroups: []string{"grp-other"},
|
||||
}))
|
||||
|
||||
setupIn, err := mgr.GetAgentConfigForUser(ctx, testAccountID, "user-in")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, setupIn.Configured)
|
||||
require.Len(t, setupIn.Providers, 1)
|
||||
|
||||
setupOut, err := mgr.GetAgentConfigForUser(ctx, testAccountID, "user-out")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, setupOut.Configured, "the account is set up, so the user reads as configured")
|
||||
assert.Equal(t, "https://"+testEndpoint, setupOut.Endpoint)
|
||||
assert.Empty(t, setupOut.Providers, "user outside the policy's source groups is authorized for nothing")
|
||||
}
|
||||
|
||||
// TestGetUsageOverview_RealStore_SelfScoped pins the self-scope fallback:
|
||||
// a caller without the account-wide usage grant gets the same aggregation
|
||||
// the admin overview serves, but only ever their own rows — a user_id
|
||||
// filter for someone else must be overridden, not honored, and never
|
||||
// denied. A caller holding the grant keeps the account-wide view.
|
||||
func TestGetUsageOverview_RealStore_SelfScoped(t *testing.T) {
|
||||
mgr, s := newAgentConfigTestMgr(t)
|
||||
mgr.permissionsManager = permissions.NewManager(s)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
require.NoError(t, s.SaveAccount(ctx, &nbtypes.Account{Id: testAccountID}))
|
||||
require.NoError(t, s.SaveUser(ctx, &nbtypes.User{
|
||||
Id: "user-a", AccountID: testAccountID, Role: nbtypes.UserRoleUser,
|
||||
}))
|
||||
require.NoError(t, s.SaveUser(ctx, &nbtypes.User{
|
||||
Id: "admin", AccountID: testAccountID, Role: nbtypes.UserRoleAdmin,
|
||||
}))
|
||||
|
||||
own1 := newIngestTestEntry()
|
||||
own1.ID, own1.UserId = "log-own-1", "user-a"
|
||||
own2 := newIngestTestEntry()
|
||||
own2.ID, own2.UserId = "log-own-2", "user-a"
|
||||
other := newIngestTestEntry()
|
||||
other.ID, other.UserId = "log-other", "user-b"
|
||||
for _, e := range []*accesslogs.AccessLogEntry{own1, own2, other} {
|
||||
require.NoError(t, IngestAccessLog(ctx, s, e))
|
||||
}
|
||||
|
||||
otherID := "user-b"
|
||||
filter := types.AgentNetworkAccessLogFilter{UserID: &otherID}
|
||||
buckets, err := mgr.GetUsageOverview(ctx, testAccountID, "user-a", filter, types.ParseUsageGranularity(""))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, buckets, 1, "same-day rows aggregate into one daily bucket")
|
||||
assert.Equal(t, int64(200), buckets[0].InputTokens, "only the caller's two rows count — the foreign user_id filter is overridden")
|
||||
assert.Equal(t, int64(100), buckets[0].OutputTokens)
|
||||
|
||||
adminBuckets, err := mgr.GetUsageOverview(ctx, testAccountID, "admin", types.AgentNetworkAccessLogFilter{}, types.ParseUsageGranularity(""))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, adminBuckets, 1)
|
||||
assert.Equal(t, int64(300), adminBuckets[0].InputTokens, "the account-wide grant keeps the unscoped view")
|
||||
}
|
||||
@@ -81,6 +81,10 @@ type Provider struct {
|
||||
// surface — the proxy middleware then falls back to URL sniffing
|
||||
// or skips request-side enrichment.
|
||||
ParserID string
|
||||
// RouterVendors declares every parser surface a gateway route can serve.
|
||||
// Leave empty for single-surface providers, where ParserID remains the
|
||||
// router discriminator for backward compatibility.
|
||||
RouterVendors []string
|
||||
// PricingSurfaces names the cost-meter pricing surfaces this
|
||||
// provider's Models are priced under ("openai", "anthropic",
|
||||
// "bedrock" — the llm.Parser surface the request parser stamps as
|
||||
@@ -116,8 +120,7 @@ type Provider struct {
|
||||
// Discovery, when non-nil, describes how to ask this vendor which
|
||||
// models the operator's own credential can actually reach, so the
|
||||
// provider form can offer a live list instead of only the hand-curated
|
||||
// Models above. Nil for entries with no listing endpoint (gateways
|
||||
// vary too much) — those keep free-text entry.
|
||||
// Models above. Nil entries keep free-text entry.
|
||||
Discovery *Discovery
|
||||
}
|
||||
|
||||
@@ -154,10 +157,13 @@ const (
|
||||
// one from the caller is also what keeps this from being an open proxy: the
|
||||
// only hosts management will dial are the ones written here.
|
||||
type Discovery struct {
|
||||
Host string
|
||||
Path string
|
||||
Query string
|
||||
Shape ListingShape
|
||||
Host string
|
||||
Path string
|
||||
Query string
|
||||
Shape ListingShape
|
||||
// ExactModelsOnly omits wildcard patterns from listings when NetBird's
|
||||
// provider model rows cannot represent the vendor's matching semantics.
|
||||
ExactModelsOnly bool
|
||||
// Headers are static headers the vendor requires beyond the credential
|
||||
// (Anthropic versions its API through one and rejects a request without
|
||||
// it). The auth header itself comes from AuthHeaderName/Template.
|
||||
@@ -635,6 +641,34 @@ var providers = []Provider{
|
||||
},
|
||||
Models: []Model{},
|
||||
},
|
||||
{
|
||||
ID: "agentgateway",
|
||||
Kind: KindGateway,
|
||||
Name: "agentgateway",
|
||||
Description: "Bring your own agentgateway with trusted NetBird identity stamped on every request",
|
||||
DefaultHost: "",
|
||||
AuthHeaderName: "Authorization",
|
||||
AuthHeaderTemplate: "Bearer ${API_KEY}",
|
||||
DefaultContentType: "application/json",
|
||||
BrandColor: "#8023C3",
|
||||
// Agentgateway accepts both OpenAI and Anthropic request shapes.
|
||||
// Leave ParserID empty so the proxy detects the shape from the URL.
|
||||
ParserID: "",
|
||||
RouterVendors: []string{"openai", "anthropic"},
|
||||
PricingSurfaces: []string{"openai", "anthropic"},
|
||||
Discovery: &Discovery{
|
||||
Path: "/v1/models",
|
||||
Shape: ShapeOpenAIData,
|
||||
ExactModelsOnly: true,
|
||||
},
|
||||
IdentityInjection: &IdentityInjection{
|
||||
HeaderPair: &HeaderPairInjection{
|
||||
EndUserIDHeader: "x-netbird-user-id",
|
||||
TagsHeader: "x-netbird-groups",
|
||||
},
|
||||
},
|
||||
Models: []Model{},
|
||||
},
|
||||
{
|
||||
ID: "portkey",
|
||||
Kind: KindGateway,
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
// TestClaudeLineupSelectable pins the models Claude Code resolves to by
|
||||
@@ -34,3 +36,51 @@ func TestClaudeLineupSelectable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentgatewayCatalogEntry(t *testing.T) {
|
||||
entry, ok := Lookup("agentgateway")
|
||||
require.True(t, ok, "agentgateway must be available in the provider catalog")
|
||||
|
||||
assert.Equal(t, KindGateway, entry.Kind, "agentgateway must be grouped with AI gateways")
|
||||
assert.Empty(t, entry.DefaultHost, "operators must provide their agentgateway proxy URL")
|
||||
assert.Equal(t, "Authorization", entry.AuthHeaderName)
|
||||
assert.Equal(t, "Bearer ${API_KEY}", entry.AuthHeaderTemplate)
|
||||
assert.Equal(t, "application/json", entry.DefaultContentType)
|
||||
assert.Empty(t, entry.ParserID, "URL detection must select the OpenAI or Anthropic parser")
|
||||
assert.Equal(t, []string{"openai", "anthropic"}, entry.RouterVendors,
|
||||
"agentgateway must accept both parser surfaces")
|
||||
assert.Equal(t, []string{"openai", "anthropic"}, entry.PricingSurfaces,
|
||||
"agentgateway models can use either pricing surface")
|
||||
assert.Empty(t, entry.Models, "an empty model list makes agentgateway a catch-all route")
|
||||
require.NotNil(t, entry.Discovery)
|
||||
assert.Empty(t, entry.Discovery.Host, "discovery must use the configured proxy URL")
|
||||
assert.Equal(t, "/v1/models", entry.Discovery.Path)
|
||||
assert.Equal(t, ShapeOpenAIData, entry.Discovery.Shape)
|
||||
assert.True(t, entry.Discovery.ExactModelsOnly,
|
||||
"wildcard model semantics are not supported by NetBird")
|
||||
|
||||
require.NotNil(t, entry.IdentityInjection)
|
||||
require.NotNil(t, entry.IdentityInjection.HeaderPair)
|
||||
assert.Nil(t, entry.IdentityInjection.JSONMetadata)
|
||||
assert.False(t, entry.IdentityInjection.HeaderPair.Customizable,
|
||||
"NetBird identity header names are part of the integration contract")
|
||||
assert.Equal(t, "x-netbird-user-id", entry.IdentityInjection.HeaderPair.EndUserIDHeader)
|
||||
assert.Equal(t, "x-netbird-groups", entry.IdentityInjection.HeaderPair.TagsHeader)
|
||||
assert.False(t, entry.IdentityInjection.HeaderPair.EndUserIDInBody)
|
||||
assert.False(t, entry.IdentityInjection.HeaderPair.TagsInBody)
|
||||
}
|
||||
|
||||
func TestAgentgatewayCatalogAPIResponse(t *testing.T) {
|
||||
entry, ok := Lookup("agentgateway")
|
||||
require.True(t, ok)
|
||||
|
||||
resp := entry.ToAPIResponse()
|
||||
assert.Equal(t, "agentgateway", resp.Id)
|
||||
assert.Equal(t, api.AgentNetworkCatalogProviderKindGateway, resp.Kind)
|
||||
assert.Empty(t, resp.Models)
|
||||
require.NotNil(t, resp.IdentityInjection)
|
||||
require.NotNil(t, resp.IdentityInjection.HeaderPair)
|
||||
assert.False(t, resp.IdentityInjection.HeaderPair.Customizable)
|
||||
assert.Equal(t, "x-netbird-user-id", resp.IdentityInjection.HeaderPair.EndUserIdHeader)
|
||||
assert.Equal(t, "x-netbird-groups", resp.IdentityInjection.HeaderPair.TagsHeader)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
"github.com/netbirdio/netbird/shared/management/http/util"
|
||||
)
|
||||
|
||||
// addAgentConfigEndpoints registers the self-service agent-config route.
|
||||
// It is available to every authenticated user regardless of role: the
|
||||
// providers in the response are scoped strictly to the caller, which is
|
||||
// tighter than any role gate could be. The caller's own usage and requests are served by
|
||||
// the regular usage/logs endpoints, which self-scope for callers without
|
||||
// the account-wide grants.
|
||||
func (h *handler) addAgentConfigEndpoints(router *mux.Router) {
|
||||
router.HandleFunc("/agent-network/agent-config", h.getAgentConfig).Methods("GET", "OPTIONS")
|
||||
}
|
||||
|
||||
func (h *handler) getAgentConfig(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
setup, err := h.manager.GetAgentConfigForUser(r.Context(), userAuth.AccountId, userAuth.UserId)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, agentConfigToAPI(setup))
|
||||
}
|
||||
|
||||
func agentConfigToAPI(setup *types.AgentConfig) api.AgentNetworkAgentConfig {
|
||||
providers := make([]api.AgentNetworkAgentConfigProvider, 0, len(setup.Providers))
|
||||
for _, p := range setup.Providers {
|
||||
providers = append(providers, api.AgentNetworkAgentConfigProvider{
|
||||
Name: p.Name,
|
||||
CatalogId: p.CatalogID,
|
||||
ApiFlavor: p.APIFlavor,
|
||||
AllModelsAllowed: p.AllModelsAllowed,
|
||||
Models: p.Models,
|
||||
})
|
||||
}
|
||||
return api.AgentNetworkAgentConfig{
|
||||
Configured: setup.Configured,
|
||||
Endpoint: setup.Endpoint,
|
||||
Providers: providers,
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ func RegisterEndpoints(manager agentnetwork.Manager, router *mux.Router) {
|
||||
h.addConsumptionEndpoints(router)
|
||||
h.addAccessLogEndpoints(router)
|
||||
h.addBudgetRuleEndpoints(router)
|
||||
h.addAgentConfigEndpoints(router)
|
||||
}
|
||||
|
||||
func (h *handler) getCatalogProviders(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -3,9 +3,10 @@ package labelgen
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/util"
|
||||
)
|
||||
|
||||
// pickAttempts caps the random retries before falling back to the
|
||||
@@ -40,16 +41,15 @@ func uniqueWords() []string {
|
||||
// PickUnique selects a label not already in `taken`. It tries up to
|
||||
// pickAttempts random picks; on exhaustion it scans the deduplicated
|
||||
// wordlist for any remaining free entry, and if none is left appends
|
||||
// `-<fallbackSuffix>` to a deterministic word and returns. The caller
|
||||
// is responsible for seeding rng (math/rand).
|
||||
func PickUnique(rng *rand.Rand, taken map[string]struct{}, fallbackSuffix string) string {
|
||||
// `-<fallbackSuffix>` to a random word and returns.
|
||||
func PickUnique(taken map[string]struct{}, fallbackSuffix string) string {
|
||||
pool := uniqueWords()
|
||||
if len(pool) == 0 {
|
||||
return fallbackSuffix
|
||||
}
|
||||
|
||||
for i := 0; i < pickAttempts; i++ {
|
||||
w := pool[rng.Intn(len(pool))]
|
||||
w := pool[util.RandIntn(len(pool))]
|
||||
if _, ok := taken[w]; !ok {
|
||||
return w
|
||||
}
|
||||
@@ -61,7 +61,7 @@ func PickUnique(rng *rand.Rand, taken map[string]struct{}, fallbackSuffix string
|
||||
}
|
||||
}
|
||||
|
||||
w := pool[rng.Intn(len(pool))]
|
||||
w := pool[util.RandIntn(len(pool))]
|
||||
return fmt.Sprintf("%s-%s", w, fallbackSuffix)
|
||||
}
|
||||
|
||||
@@ -74,10 +74,10 @@ func PickUnique(rng *rand.Rand, taken map[string]struct{}, fallbackSuffix string
|
||||
// a noun spans len(adjectives) * 857 instead. Uniqueness is enforced by a
|
||||
// database constraint and retried by the caller, rather than guessed from a
|
||||
// pre-read set that a concurrent allocation can invalidate.
|
||||
func PickTuple(rng *rand.Rand) string {
|
||||
func PickTuple() string {
|
||||
nouns := uniqueWords()
|
||||
if len(nouns) == 0 || len(adjectives) == 0 {
|
||||
return ""
|
||||
}
|
||||
return adjectives[rng.Intn(len(adjectives))] + "-" + nouns[rng.Intn(len(nouns))]
|
||||
return adjectives[util.RandIntn(len(adjectives))] + "-" + nouns[util.RandIntn(len(nouns))]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package labelgen
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -9,19 +9,12 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestPickUnique_DeterministicWithSeededRng locks the property the
|
||||
// caller relies on: same seed + same taken set → same pick. Without
|
||||
// that, the bootstrap flow can't reproduce a label across retries.
|
||||
func TestPickUnique_DeterministicWithSeededRng(t *testing.T) {
|
||||
taken := map[string]struct{}{}
|
||||
// TestPickUnique_ReturnsWordFromPool confirms a pick against an empty
|
||||
// taken set is always drawn verbatim from the wordlist.
|
||||
func TestPickUnique_ReturnsWordFromPool(t *testing.T) {
|
||||
got := PickUnique(map[string]struct{}{}, "abcd")
|
||||
|
||||
rngA := rand.New(rand.NewSource(42))
|
||||
rngB := rand.New(rand.NewSource(42))
|
||||
|
||||
a := PickUnique(rngA, taken, "abcd")
|
||||
b := PickUnique(rngB, taken, "abcd")
|
||||
|
||||
assert.Equal(t, a, b, "Same seed and taken set must produce identical pick")
|
||||
assert.True(t, slices.Contains(uniqueWords(), got), "Pick %q must be drawn from the wordlist", got)
|
||||
}
|
||||
|
||||
// TestPickUnique_AvoidsTakenWordsWhenMostAreReserved seeds taken with
|
||||
@@ -46,8 +39,7 @@ func TestPickUnique_AvoidsTakenWordsWhenMostAreReserved(t *testing.T) {
|
||||
taken[w] = struct{}{}
|
||||
}
|
||||
|
||||
rng := rand.New(rand.NewSource(7))
|
||||
got := PickUnique(rng, taken, "abcd")
|
||||
got := PickUnique(taken, "abcd")
|
||||
|
||||
_, isFree := free[got]
|
||||
assert.True(t, isFree, "PickUnique must return one of the free words; got %q", got)
|
||||
@@ -65,8 +57,7 @@ func TestPickUnique_FallsBackWhenAllReserved(t *testing.T) {
|
||||
taken[w] = struct{}{}
|
||||
}
|
||||
|
||||
rng := rand.New(rand.NewSource(99))
|
||||
got := PickUnique(rng, taken, "abcd")
|
||||
got := PickUnique(taken, "abcd")
|
||||
|
||||
assert.True(t, strings.HasSuffix(got, "-abcd"), "Exhausted pool must produce <word>-<suffix>; got %q", got)
|
||||
|
||||
@@ -114,9 +105,8 @@ func TestPickTuple_ShapeAndPoolMembership(t *testing.T) {
|
||||
inAdjectives[a] = struct{}{}
|
||||
}
|
||||
|
||||
rng := rand.New(rand.NewSource(7))
|
||||
for i := 0; i < 200; i++ {
|
||||
got := PickTuple(rng)
|
||||
got := PickTuple()
|
||||
|
||||
parts := strings.Split(got, "-")
|
||||
require.Len(t, parts, 2, "PickTuple must produce exactly two hyphen-joined words; got %q", got)
|
||||
@@ -158,22 +148,13 @@ func TestAdjectives_AreDNSSafeAndDeduplicated(t *testing.T) {
|
||||
assert.Greater(t, len(adjectives), 150, "Adjective pool too small to give a useful namespace")
|
||||
}
|
||||
|
||||
// TestPickTuple_DeterministicWithSeededRng documents that generation is a pure
|
||||
// function of the rng, which is what makes allocation retries reproducible in tests.
|
||||
func TestPickTuple_DeterministicWithSeededRng(t *testing.T) {
|
||||
a := PickTuple(rand.New(rand.NewSource(42)))
|
||||
b := PickTuple(rand.New(rand.NewSource(42)))
|
||||
assert.Equal(t, a, b, "Same seed must yield the same tuple")
|
||||
}
|
||||
|
||||
// TestPickTuple_SpansALargeNamespace guards the reason we moved to tuples: a
|
||||
// single-word pool caps the GLOBAL namespace at 857. Drawing many tuples must
|
||||
// yield overwhelmingly distinct values.
|
||||
func TestPickTuple_SpansALargeNamespace(t *testing.T) {
|
||||
rng := rand.New(rand.NewSource(11))
|
||||
seen := make(map[string]struct{}, 2000)
|
||||
for i := 0; i < 2000; i++ {
|
||||
seen[PickTuple(rng)] = struct{}{}
|
||||
seen[PickTuple()] = struct{}{}
|
||||
}
|
||||
assert.Greater(t, len(seen), 1900,
|
||||
"2000 draws should be nearly all distinct across a ~200k namespace; got %d unique", len(seen))
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -85,6 +84,13 @@ type Manager interface {
|
||||
RecordAccountBudgetUsage(ctx context.Context, accountID, userID string, groupIDs []string, tokensIn, tokensOut int64, costUSD float64) error
|
||||
RecordUsage(ctx context.Context, in RecordUsageInput) error
|
||||
SelectPolicyForRequest(ctx context.Context, in PolicySelectionInput) (*PolicySelectionResult, error)
|
||||
|
||||
// GetAgentConfigForUser backs the self-service agent-config endpoint.
|
||||
// Caller-scoped, so it skips the role permission gate; see
|
||||
// the implementation. The caller's own usage and requests come
|
||||
// through GetUsageOverview / ListAccessLogs, which self-scope when
|
||||
// the account-wide grant is missing.
|
||||
GetAgentConfigForUser(ctx context.Context, accountID, userID string) (*types.AgentConfig, error)
|
||||
}
|
||||
|
||||
// PolicySelectionInput is the per-request selection envelope. The
|
||||
@@ -139,11 +145,6 @@ type managerImpl struct {
|
||||
// of serving proxy can be diffed without re-deriving it.
|
||||
reconcileMu sync.Mutex
|
||||
reconcileCache map[string]map[string]syntheticMapping
|
||||
|
||||
// labelRngMu guards labelRng. PickUnique consumes math/rand.Source
|
||||
// state; concurrent provider creates would otherwise race.
|
||||
labelRngMu sync.Mutex
|
||||
labelRng *rand.Rand
|
||||
}
|
||||
|
||||
// NewManager constructs the persistent Agent Network manager. The
|
||||
@@ -164,22 +165,127 @@ func NewManager(
|
||||
proxyController: proxyController,
|
||||
modelDiscovery: &modeldiscovery.Client{},
|
||||
reconcileCache: make(map[string]map[string]syntheticMapping),
|
||||
labelRng: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||
}
|
||||
}
|
||||
|
||||
// GetAllProviders returns the account's providers for callers holding the
|
||||
// providers read grant (connection config redacted unless they can also
|
||||
// update). A caller without the grant self-scopes instead of being denied
|
||||
// — mirroring the usage and log endpoints: they get the providers their
|
||||
// own policies authorize, redacted to the display surface, which is what
|
||||
// feeds the dashboard's provider filter for plain users.
|
||||
func (m *managerImpl) GetAllProviders(ctx context.Context, accountID, userID string) ([]*types.Provider, error) {
|
||||
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Read); err != nil {
|
||||
ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Read)
|
||||
if err != nil {
|
||||
return nil, status.NewPermissionValidationError(err)
|
||||
}
|
||||
if !ok {
|
||||
return m.callerScopedProviders(ctx, accountID, userID)
|
||||
}
|
||||
providers, err := m.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, accountID)
|
||||
return m.redactProvidersForViewer(ctx, accountID, userID, providers)
|
||||
}
|
||||
|
||||
// GetProvider self-scopes like GetAllProviders: a caller without the read
|
||||
// grant may fetch a provider their own policies authorize (redacted), and
|
||||
// gets the same not-found answer for any other id — an out-of-scope
|
||||
// provider must be indistinguishable from a nonexistent one.
|
||||
func (m *managerImpl) GetProvider(ctx context.Context, accountID, userID, providerID string) (*types.Provider, error) {
|
||||
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Read); err != nil {
|
||||
ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Read)
|
||||
if err != nil {
|
||||
return nil, status.NewPermissionValidationError(err)
|
||||
}
|
||||
if !ok {
|
||||
scoped, err := m.callerScopedProviders(ctx, accountID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range scoped {
|
||||
if p.ID == providerID {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
return nil, status.NewAgentNetworkProviderNotFoundError(providerID)
|
||||
}
|
||||
provider, err := m.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, accountID, providerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, accountID, providerID)
|
||||
redacted, err := m.redactProvidersForViewer(ctx, accountID, userID, []*types.Provider{provider})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return redacted[0], nil
|
||||
}
|
||||
|
||||
// callerScopedProviders returns the providers the caller's own policies
|
||||
// authorize — the same selection the self-service setup answer and the
|
||||
// proxy's routing derive from — each reduced to the display surface. No
|
||||
// role permission is needed: the answer is scoped strictly to the caller,
|
||||
// and a caller outside every policy gets an empty list, indistinguishable
|
||||
// from an account with nothing configured.
|
||||
func (m *managerImpl) callerScopedProviders(ctx context.Context, accountID, userID string) ([]*types.Provider, error) {
|
||||
user, err := m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get user: %w", err)
|
||||
}
|
||||
authorized, applicable, err := m.authorizedProvidersForGroups(ctx, accountID, user.AutoGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var guardrailsByID map[string]*types.Guardrail
|
||||
if anyPolicyHasGuardrails(applicable) {
|
||||
guardrailsByID, err = m.loadGuardrailsByID(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
out := make([]*types.Provider, 0, len(authorized))
|
||||
for _, p := range authorized {
|
||||
r := p.RedactedForViewer()
|
||||
// The model list follows the same effective computation the setup
|
||||
// answer and the proxy use: allowlist-restricted callers see only
|
||||
// the models their guardrails permit, and an unrestricted policy
|
||||
// on a provider without an operator declaration surfaces the
|
||||
// catalog models, matching the setup response — so the dashboard's
|
||||
// model filter never offers a model the caller's own requests
|
||||
// could not use, and never comes up empty when the setup page
|
||||
// lists models. Grant holders keep the full declared lists —
|
||||
// their usage view spans everyone's requests.
|
||||
_, effective := effectiveModelsForProvider(p, policiesForProvider(applicable, p.ID), guardrailsByID)
|
||||
r.Models = providerModelsByID(p, effective)
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// redactProvidersForViewer strips the connection configuration from
|
||||
// providers handed to a caller who holds only the read grant on
|
||||
// agent_network.providers. Update is the managing signal: a role that can
|
||||
// edit a provider sees its config in the edit form anyway, while a
|
||||
// read-only role (usage_viewer) only needs the display surface the usage
|
||||
// filters resolve against — upstream URLs and operator-supplied header
|
||||
// values are not part of that. Validation errors fail closed.
|
||||
func (m *managerImpl) redactProvidersForViewer(ctx context.Context, accountID, userID string, providers []*types.Provider) ([]*types.Provider, error) {
|
||||
canManage, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Update)
|
||||
if err != nil {
|
||||
return nil, status.NewPermissionValidationError(err)
|
||||
}
|
||||
if canManage {
|
||||
return providers, nil
|
||||
}
|
||||
out := make([]*types.Provider, 0, len(providers))
|
||||
for _, p := range providers {
|
||||
if p == nil {
|
||||
out = append(out, nil)
|
||||
continue
|
||||
}
|
||||
out = append(out, p.RedactedForViewer())
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DiscoverProviderModels asks the vendor which models a credential can reach.
|
||||
@@ -873,9 +979,7 @@ func (m *managerImpl) bootstrapLabeled(ctx context.Context, settings *types.Sett
|
||||
}
|
||||
|
||||
for attempt := 1; attempt <= maxDomainAllocationAttempts; attempt++ {
|
||||
m.labelRngMu.Lock()
|
||||
label := labelgen.PickTuple(m.labelRng)
|
||||
m.labelRngMu.Unlock()
|
||||
label := labelgen.PickTuple()
|
||||
if label == "" {
|
||||
// Only reachable if either word pool were emptied. An empty label
|
||||
// would produce a broken endpoint like ".example.com", so fail
|
||||
@@ -945,8 +1049,11 @@ func (m *managerImpl) ListConsumption(ctx context.Context, accountID, userID str
|
||||
|
||||
// ListAccessLogs returns a paginated, server-side-filtered page of
|
||||
// agent-network access logs plus the total count matching the filter.
|
||||
// Callers without the account-wide logs grant get a self-scoped page —
|
||||
// only their own requests — instead of a denial.
|
||||
func (m *managerImpl) ListAccessLogs(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLog, int64, error) {
|
||||
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkLogs, operations.Read); err != nil {
|
||||
filter, err := m.scopeFilterToCaller(ctx, accountID, userID, modules.AgentNetworkLogs, filter)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return m.store.GetAgentNetworkAccessLogs(ctx, store.LockingStrengthNone, accountID, filter)
|
||||
@@ -954,18 +1061,23 @@ func (m *managerImpl) ListAccessLogs(ctx context.Context, accountID, userID stri
|
||||
|
||||
// ListAccessLogSessions returns a paginated, server-side-filtered page of
|
||||
// agent-network access logs grouped by session, plus the total number of
|
||||
// sessions matching the filter.
|
||||
// sessions matching the filter. Self-scoped like ListAccessLogs for
|
||||
// callers without the account-wide logs grant.
|
||||
func (m *managerImpl) ListAccessLogSessions(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLogSession, int64, error) {
|
||||
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkLogs, operations.Read); err != nil {
|
||||
filter, err := m.scopeFilterToCaller(ctx, accountID, userID, modules.AgentNetworkLogs, filter)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return m.store.GetAgentNetworkAccessLogSessions(ctx, store.LockingStrengthNone, accountID, filter)
|
||||
}
|
||||
|
||||
// GetUsageOverview returns the filtered usage rows aggregated into time buckets
|
||||
// at the requested granularity, oldest-first.
|
||||
// at the requested granularity, oldest-first. Callers without the
|
||||
// account-wide usage grant get their own rows aggregated instead of a
|
||||
// denial, so the dashboard serves "my usage" from the same endpoint.
|
||||
func (m *managerImpl) GetUsageOverview(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter, granularity types.UsageGranularity) ([]*types.AgentNetworkUsageBucket, error) {
|
||||
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkUsage, operations.Read); err != nil {
|
||||
filter, err := m.scopeFilterToCaller(ctx, accountID, userID, modules.AgentNetworkUsage, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := m.store.GetAgentNetworkUsageRows(ctx, store.LockingStrengthNone, accountID, filter)
|
||||
@@ -975,6 +1087,25 @@ func (m *managerImpl) GetUsageOverview(ctx context.Context, accountID, userID st
|
||||
return types.AggregateUsageByGranularity(rows, granularity), nil
|
||||
}
|
||||
|
||||
// scopeFilterToCaller applies the account-wide read gate for module and,
|
||||
// when the caller lacks the grant, pins the filter to the caller instead
|
||||
// of denying: their own user id replaces any requested one and group
|
||||
// filters are dropped. A caller may always see their own rows — strictly
|
||||
// tighter than any role gate — which is what lets every authenticated
|
||||
// user read their usage and requests through the regular endpoints.
|
||||
// Validation errors (not denials) still fail closed.
|
||||
func (m *managerImpl) scopeFilterToCaller(ctx context.Context, accountID, userID string, module modules.Module, filter types.AgentNetworkAccessLogFilter) (types.AgentNetworkAccessLogFilter, error) {
|
||||
ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, module, operations.Read)
|
||||
if err != nil {
|
||||
return filter, status.NewPermissionValidationError(err)
|
||||
}
|
||||
if !ok {
|
||||
filter.UserID = &userID
|
||||
filter.GroupIDs = nil
|
||||
}
|
||||
return filter, nil
|
||||
}
|
||||
|
||||
// StartAccessLogCleanup launches a background sweep that periodically deletes
|
||||
// each account's agent-network access-log rows older than that account's
|
||||
// AccessLogRetentionDays. Usage records are never swept. A non-positive
|
||||
|
||||
@@ -52,9 +52,8 @@ const (
|
||||
)
|
||||
|
||||
// ErrNoDiscovery is returned for a catalog entry that declares no listing
|
||||
// endpoint. Gateways vary too much to have one, and the caller should fall
|
||||
// back to the catalog list plus free-text entry rather than treating this as
|
||||
// a failure.
|
||||
// endpoint. The caller should fall back to the catalog list plus free-text
|
||||
// entry rather than treating this as a failure.
|
||||
var ErrNoDiscovery = errors.New("provider has no model-discovery endpoint")
|
||||
|
||||
// ErrInvalidRequest marks a discovery failure caused by the caller's own input
|
||||
@@ -356,6 +355,9 @@ func decorate(entry catalog.Provider, ids []listedModel) []Model {
|
||||
if listed.id == "" {
|
||||
continue
|
||||
}
|
||||
if entry.Discovery.ExactModelsOnly && strings.Contains(listed.id, "*") {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[listed.id]; dup {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -59,6 +59,13 @@ const openAIListing = `{"object":"list","data":[
|
||||
{"id":"gpt-4o","object":"model","created":1715367049,"owned_by":"system"}
|
||||
]}`
|
||||
|
||||
const agentgatewayListing = `{"object":"list","data":[
|
||||
{"id":"gpt-4o-mini","object":"model","created":1785166485,"owned_by":"openai"},
|
||||
{"id":"claude-haiku-4-5","object":"model","created":1785166485,"owned_by":"anthropic"},
|
||||
{"id":"openai/*","object":"model","created":1785166485,"owned_by":"openai"},
|
||||
{"id":"*-latest","object":"model","created":1785166485,"owned_by":"openai"}
|
||||
]}`
|
||||
|
||||
const anthropicListing = `{"data":[
|
||||
{"type":"model","id":"claude-haiku-4-5-20251001","display_name":"Claude Haiku 4.5"},
|
||||
{"type":"model","id":"claude-sonnet-4-6","display_name":"Claude Sonnet 4.6"}
|
||||
@@ -97,6 +104,26 @@ func TestFetchOpenAIListing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAgentgatewayListing(t *testing.T) {
|
||||
cl, tr := newStubClient(http.StatusOK, agentgatewayListing)
|
||||
|
||||
models, err := cl.Fetch(context.Background(), Request{
|
||||
CatalogID: "agentgateway",
|
||||
UpstreamURL: "https://gateway.example.com",
|
||||
APIKey: "virtual-key",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "https://gateway.example.com/v1/models", tr.got.URL.String())
|
||||
assert.Equal(t, "Bearer virtual-key", tr.got.Header.Get("Authorization"),
|
||||
"agentgateway model discovery must use the configured virtual key")
|
||||
assert.Equal(t, []string{"gpt-4o-mini", "claude-haiku-4-5"}, ids(models),
|
||||
"model patterns must not be offered as exact NetBird authorization rows")
|
||||
for _, m := range models {
|
||||
assert.True(t, m.PricingKnown, "known upstream model must use NetBird catalog pricing: %s", m.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAnthropicSendsTheVersionHeader(t *testing.T) {
|
||||
cl, tr := newStubClient(http.StatusOK, anthropicListing)
|
||||
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/modules"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/operations"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
nbtypes "github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
// These tests pin the provider read surface per grant: a caller holding
|
||||
// providers read together with update (managers) gets the full record,
|
||||
// while read-only viewers (usage_viewer) get the display surface only —
|
||||
// connection configuration is redacted before it reaches the wire layer.
|
||||
|
||||
func TestGetAllProviders_RedactsConnectionConfigForReadOnlyViewer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
|
||||
saved := newSynthTestProvider()
|
||||
saved.ExtraValues = map[string]string{"x-portkey-config": "cfg-123"}
|
||||
saved.IdentityHeaderUserID = "X-User"
|
||||
saved.IdentityHeaderGroups = "X-Groups"
|
||||
saved.SkipTLSVerification = true
|
||||
require.NoError(t, f.store.SaveAgentNetworkProvider(ctx, saved))
|
||||
|
||||
f.expectPermission(testAccountID, "viewer", modules.AgentNetworkProviders, operations.Read, true)
|
||||
f.expectPermission(testAccountID, "viewer", modules.AgentNetworkProviders, operations.Update, false)
|
||||
|
||||
providers, err := f.manager.GetAllProviders(ctx, testAccountID, "viewer")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, providers, 1)
|
||||
p := providers[0]
|
||||
assert.Equal(t, saved.ID, p.ID, "identity survives redaction")
|
||||
assert.Equal(t, saved.Name, p.Name)
|
||||
assert.Equal(t, saved.ProviderID, p.ProviderID)
|
||||
assert.Equal(t, saved.Models, p.Models, "the model list backs the usage filters and stays")
|
||||
assert.True(t, p.Enabled)
|
||||
assert.Empty(t, p.UpstreamURL, "upstream URL is connection config")
|
||||
assert.Empty(t, p.ExtraValues, "operator-typed header values are connection config")
|
||||
assert.Empty(t, p.IdentityHeaderUserID)
|
||||
assert.Empty(t, p.IdentityHeaderGroups)
|
||||
assert.False(t, p.SkipTLSVerification)
|
||||
assert.Empty(t, p.APIKey)
|
||||
assert.Empty(t, p.SessionPrivateKey)
|
||||
|
||||
stored, err := f.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, testAccountID, saved.ID)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, stored.UpstreamURL, "redaction must not write back to the store")
|
||||
}
|
||||
|
||||
func TestGetProvider_FullConfigForManagingCaller(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
|
||||
saved := newSynthTestProvider()
|
||||
saved.ExtraValues = map[string]string{"x-portkey-config": "cfg-123"}
|
||||
require.NoError(t, f.store.SaveAgentNetworkProvider(ctx, saved))
|
||||
|
||||
f.expectPermission(testAccountID, "admin", modules.AgentNetworkProviders, operations.Read, true)
|
||||
f.expectPermission(testAccountID, "admin", modules.AgentNetworkProviders, operations.Update, true)
|
||||
|
||||
p, err := f.manager.GetProvider(ctx, testAccountID, "admin", saved.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, saved.UpstreamURL, p.UpstreamURL, "a caller who can edit the provider sees its config")
|
||||
assert.Equal(t, saved.ExtraValues, p.ExtraValues)
|
||||
}
|
||||
|
||||
func TestGetProvider_RedactsForReadOnlyViewer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
|
||||
saved := newSynthTestProvider()
|
||||
require.NoError(t, f.store.SaveAgentNetworkProvider(ctx, saved))
|
||||
|
||||
f.expectPermission(testAccountID, "viewer", modules.AgentNetworkProviders, operations.Read, true)
|
||||
f.expectPermission(testAccountID, "viewer", modules.AgentNetworkProviders, operations.Update, false)
|
||||
|
||||
p, err := f.manager.GetProvider(ctx, testAccountID, "viewer", saved.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, saved.ID, p.ID)
|
||||
assert.Empty(t, p.UpstreamURL)
|
||||
}
|
||||
|
||||
// The self-scope tests drive the real permissions manager over the real
|
||||
// store, so role resolution is the production one: a plain user holds no
|
||||
// providers grant and must fall back to the caller-scoped list — the same
|
||||
// selection the self-service setup answer derives from — while an admin
|
||||
// keeps the account-wide view with full config.
|
||||
|
||||
// newSelfScopeStore seeds the account and its users only, so each test
|
||||
// declares exactly the providers and policies it asserts on — the store
|
||||
// rejects re-saving a policy id on MySQL, so tests never overwrite each
|
||||
// other's rows.
|
||||
func newSelfScopeStore(t *testing.T) (*managerImpl, store.Store) {
|
||||
t.Helper()
|
||||
mgr, s := newAgentConfigTestMgr(t)
|
||||
mgr.permissionsManager = permissions.NewManager(s)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, s.SaveAccount(ctx, &nbtypes.Account{Id: testAccountID}))
|
||||
require.NoError(t, s.SaveUser(ctx, &nbtypes.User{
|
||||
Id: "user-a", AccountID: testAccountID, Role: nbtypes.UserRoleUser, AutoGroups: []string{"grp-eng"},
|
||||
}))
|
||||
require.NoError(t, s.SaveUser(ctx, &nbtypes.User{
|
||||
Id: "user-out", AccountID: testAccountID, Role: nbtypes.UserRoleUser,
|
||||
}))
|
||||
require.NoError(t, s.SaveUser(ctx, &nbtypes.User{
|
||||
Id: "admin", AccountID: testAccountID, Role: nbtypes.UserRoleAdmin,
|
||||
}))
|
||||
return mgr, s
|
||||
}
|
||||
|
||||
func newSelfScopeProvidersFixture(t *testing.T) (*managerImpl, store.Store) {
|
||||
t.Helper()
|
||||
mgr, s := newSelfScopeStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
granted := newSynthTestProvider()
|
||||
granted.ID = "prov-granted"
|
||||
granted.Name = "Granted"
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, granted))
|
||||
|
||||
other := newSynthTestProvider()
|
||||
other.ID = "prov-other"
|
||||
other.Name = "Other"
|
||||
other.CreatedAt = granted.CreatedAt.Add(time.Hour)
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, other))
|
||||
|
||||
disabled := newSynthTestProvider()
|
||||
disabled.ID = "prov-disabled"
|
||||
disabled.Name = "Disabled"
|
||||
disabled.Enabled = false
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, disabled))
|
||||
|
||||
// user-a's group authorizes the granted and the disabled provider; the
|
||||
// disabled one must still not surface (the proxy never routes it).
|
||||
policy := newSynthTestPolicy(granted.ID, "grp-eng", "")
|
||||
policy.DestinationProviderIDs = []string{granted.ID, disabled.ID}
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy))
|
||||
|
||||
return mgr, s
|
||||
}
|
||||
|
||||
func TestGetAllProviders_SelfScopedForPlainUser(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr, _ := newSelfScopeProvidersFixture(t)
|
||||
|
||||
scoped, err := mgr.GetAllProviders(ctx, testAccountID, "user-a")
|
||||
require.NoError(t, err, "a caller without the read grant self-scopes instead of being denied")
|
||||
require.Len(t, scoped, 1)
|
||||
assert.Equal(t, "prov-granted", scoped[0].ID)
|
||||
assert.Empty(t, scoped[0].UpstreamURL, "the caller-scoped list is the redacted display surface")
|
||||
assert.NotEmpty(t, scoped[0].Models, "model list backs the dashboard filters")
|
||||
|
||||
empty, err := mgr.GetAllProviders(ctx, testAccountID, "user-out")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, empty, "a caller outside every policy gets an empty list, not an error")
|
||||
|
||||
all, err := mgr.GetAllProviders(ctx, testAccountID, "admin")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, all, 3, "grant holders keep the account-wide list, disabled providers included")
|
||||
for _, p := range all {
|
||||
if p.ID == "prov-granted" {
|
||||
assert.NotEmpty(t, p.UpstreamURL, "a managing caller sees the connection config")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProvider_SelfScopedForPlainUser(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr, _ := newSelfScopeProvidersFixture(t)
|
||||
|
||||
p, err := mgr.GetProvider(ctx, testAccountID, "user-a", "prov-granted")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "prov-granted", p.ID)
|
||||
assert.Empty(t, p.UpstreamURL)
|
||||
|
||||
assertNotFound := func(id string) {
|
||||
t.Helper()
|
||||
_, err := mgr.GetProvider(ctx, testAccountID, "user-a", id)
|
||||
require.Error(t, err)
|
||||
var sErr *status.Error
|
||||
require.ErrorAs(t, err, &sErr)
|
||||
assert.Equal(t, status.NotFound, sErr.Type(),
|
||||
"out-of-scope and nonexistent providers must be indistinguishable")
|
||||
}
|
||||
assertNotFound("prov-other")
|
||||
assertNotFound("prov-disabled")
|
||||
assertNotFound("prov-does-not-exist")
|
||||
}
|
||||
|
||||
func TestGetAllProviders_SelfScopedModelsFollowGuardrails(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr, s := newSelfScopeStore(t)
|
||||
|
||||
// A provider declaring two models, restricted by an allowlist admitting
|
||||
// one declared model plus one the operator never declared (unreachable —
|
||||
// the router only claims declared models, so it must not surface).
|
||||
granted := newSynthTestProvider()
|
||||
granted.ID = "prov-models"
|
||||
granted.Name = "Granted"
|
||||
granted.Models = []types.ProviderModel{
|
||||
{ID: "gpt-5.4", InputPer1k: 0.004, OutputPer1k: 0.02},
|
||||
{ID: "gpt-4o", InputPer1k: 0.0025, OutputPer1k: 0.01},
|
||||
}
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, granted))
|
||||
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-models", "gpt-5.4", "gpt-undeclared")))
|
||||
policy := newSynthTestPolicy(granted.ID, "grp-eng", "guard-models")
|
||||
policy.ID = "pol-guard-models"
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy))
|
||||
|
||||
scoped, err := mgr.GetAllProviders(ctx, testAccountID, "user-a")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, scoped, 1)
|
||||
require.Len(t, scoped[0].Models, 1,
|
||||
"the self-scoped model list is the effective set: allowlist ∩ declared")
|
||||
assert.Equal(t, "gpt-5.4", scoped[0].Models[0].ID)
|
||||
assert.Equal(t, 0.004, scoped[0].Models[0].InputPer1k, "declared entry survives, prices included")
|
||||
|
||||
all, err := mgr.GetAllProviders(ctx, testAccountID, "admin")
|
||||
require.NoError(t, err)
|
||||
for _, p := range all {
|
||||
if p.ID == granted.ID {
|
||||
assert.Len(t, p.Models, 2,
|
||||
"grant holders keep the full declared list — their usage view spans everyone's requests")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAllProviders_SelfScopedAllowlistWithoutDeclaredModels(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr, s := newSelfScopeStore(t)
|
||||
|
||||
// No operator declaration: the router claims every model, so the
|
||||
// allowlist union is the effective set and comes back as bare entries.
|
||||
granted := newSynthTestProvider()
|
||||
granted.ID = "prov-bare"
|
||||
granted.Name = "Granted"
|
||||
granted.Models = nil
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, granted))
|
||||
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-bare", "gpt-5.4")))
|
||||
policy := newSynthTestPolicy(granted.ID, "grp-eng", "guard-bare")
|
||||
policy.ID = "pol-guard-bare"
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy))
|
||||
|
||||
scoped, err := mgr.GetAllProviders(ctx, testAccountID, "user-a")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, scoped, 1)
|
||||
require.Len(t, scoped[0].Models, 1)
|
||||
assert.Equal(t, "gpt-5.4", scoped[0].Models[0].ID)
|
||||
}
|
||||
|
||||
func TestGetAllProviders_SelfScopedUnrestrictedFallsBackToCatalogModels(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr, s := newSelfScopeStore(t)
|
||||
|
||||
// Unrestricted policy on a provider without an operator declaration:
|
||||
// the setup answer advertises the catalog models, and the scoped
|
||||
// provider list must match so the model filter is never emptier than
|
||||
// the setup page.
|
||||
granted := newSynthTestProvider()
|
||||
granted.ID = "prov-catalog"
|
||||
granted.Name = "Granted"
|
||||
granted.Models = nil
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, granted))
|
||||
policy := newSynthTestPolicy(granted.ID, "grp-eng", "")
|
||||
policy.ID = "pol-catalog"
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy))
|
||||
|
||||
scoped, err := mgr.GetAllProviders(ctx, testAccountID, "user-a")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, scoped, 1)
|
||||
require.NotEmpty(t, scoped[0].Models, "catalog models back the filter when the operator declared none")
|
||||
ids := make([]string, 0, len(scoped[0].Models))
|
||||
for _, m := range scoped[0].Models {
|
||||
ids = append(ids, m.ID)
|
||||
}
|
||||
assert.Equal(t, declaredModelIDs(granted), ids, "the scoped list mirrors the setup answer's declared/catalog set")
|
||||
}
|
||||
@@ -352,6 +352,7 @@ type routerConfig struct {
|
||||
type routerProviderRoute struct {
|
||||
ID string `json:"id"`
|
||||
Vendor string `json:"vendor,omitempty"`
|
||||
Vendors []string `json:"vendors,omitempty"`
|
||||
Models []string `json:"models"`
|
||||
UpstreamScheme string `json:"upstream_scheme"`
|
||||
UpstreamHost string `json:"upstream_host"`
|
||||
@@ -461,6 +462,7 @@ func buildRouterConfigJSON(providers []*types.Provider, groupIndex map[string][]
|
||||
cfg.Providers = append(cfg.Providers, routerProviderRoute{
|
||||
ID: p.ID,
|
||||
Vendor: providerVendor(p),
|
||||
Vendors: providerVendors(p),
|
||||
Models: providerModelIDs(p),
|
||||
UpstreamScheme: scheme,
|
||||
UpstreamHost: host,
|
||||
@@ -525,6 +527,17 @@ func providerVendor(p *types.Provider) string {
|
||||
return entry.ParserID
|
||||
}
|
||||
|
||||
// providerVendors returns the parser surfaces a multi-surface gateway route
|
||||
// accepts. Single-surface providers keep using the singular vendor field so
|
||||
// existing proxy versions and configurations retain their wire shape.
|
||||
func providerVendors(p *types.Provider) []string {
|
||||
entry, ok := catalog.Lookup(p.ProviderID)
|
||||
if !ok || len(entry.RouterVendors) == 0 {
|
||||
return nil
|
||||
}
|
||||
return append([]string(nil), entry.RouterVendors...)
|
||||
}
|
||||
|
||||
// providerModelIDs returns the model identifiers exposed by the
|
||||
// provider, deduplicated and in the operator's declared order. Empty
|
||||
// slice when no models are configured — the router treats that as
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
@@ -497,6 +497,55 @@ func TestSynthesizeServices_IdentityInject_LiteLLM(t *testing.T) {
|
||||
assert.Equal(t, "x-litellm-tags", entry.HeaderPair.TagsHeader)
|
||||
}
|
||||
|
||||
func TestBuildIdentityInjectConfigJSON_Agentgateway(t *testing.T) {
|
||||
provider := &types.Provider{
|
||||
ID: "prov-agentgateway",
|
||||
ProviderID: "agentgateway",
|
||||
}
|
||||
|
||||
raw, err := buildIdentityInjectConfigJSON(
|
||||
[]*types.Provider{provider},
|
||||
map[string][]string{provider.ID: []string{"grp-eng"}},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
var cfg identityInjectConfig
|
||||
require.NoError(t, json.Unmarshal(raw, &cfg))
|
||||
require.Len(t, cfg.Providers, 1)
|
||||
|
||||
rule := cfg.Providers[0]
|
||||
assert.Equal(t, provider.ID, rule.ProviderID)
|
||||
require.NotNil(t, rule.HeaderPair)
|
||||
assert.Nil(t, rule.JSONMetadata)
|
||||
assert.Equal(t, "x-netbird-user-id", rule.HeaderPair.EndUserIDHeader)
|
||||
assert.Equal(t, "x-netbird-groups", rule.HeaderPair.TagsHeader)
|
||||
assert.False(t, rule.HeaderPair.EndUserIDInBody)
|
||||
assert.False(t, rule.HeaderPair.TagsInBody)
|
||||
}
|
||||
|
||||
func TestBuildRouterConfigJSON_AgentgatewayVendors(t *testing.T) {
|
||||
provider := &types.Provider{
|
||||
ID: "prov-agentgateway",
|
||||
ProviderID: "agentgateway",
|
||||
UpstreamURL: "https://gateway.example.com",
|
||||
APIKey: "virtual-key",
|
||||
}
|
||||
|
||||
raw, err := buildRouterConfigJSON(
|
||||
[]*types.Provider{provider},
|
||||
map[string][]string{provider.ID: {"grp-eng"}},
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
var cfg routerConfig
|
||||
require.NoError(t, json.Unmarshal(raw, &cfg))
|
||||
require.Len(t, cfg.Providers, 1)
|
||||
assert.Empty(t, cfg.Providers[0].Vendor,
|
||||
"the singular vendor remains empty for a multi-surface gateway")
|
||||
assert.Equal(t, []string{"openai", "anthropic"}, cfg.Providers[0].Vendors)
|
||||
}
|
||||
|
||||
// TestSynthesizeServices_IdentityInject_Bifrost_OperatorOverrides
|
||||
// covers the customizable HeaderPair contract. The Bifrost catalog
|
||||
// entry sets HeaderPair.Customizable=true with x-bf-dim-* defaults
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package types
|
||||
|
||||
// AgentConfig is the caller-scoped answer to "what may this caller
|
||||
// use on the Agent Network?" — the account's proxy endpoint plus the
|
||||
// providers and models the caller's groups authorize. It intentionally
|
||||
// carries display metadata only: no keys, no upstream URLs, no policy or
|
||||
// guardrail structure, and no hint of providers the caller cannot reach.
|
||||
type AgentConfig struct {
|
||||
// Configured is false only when the account has no Agent Network set
|
||||
// up. A caller no policy covers yet still reads as configured, with an
|
||||
// empty Providers list: every member gets the same connection config,
|
||||
// and the empty list is what tells them to ask for access.
|
||||
Configured bool
|
||||
// Endpoint is the account's proxy base URL
|
||||
// ("https://<subdomain>.<cluster>"), reachable over the NetBird tunnel
|
||||
// only. Empty when Configured is false. Handing it to a member the
|
||||
// policies do not cover authorizes nothing on its own — the proxy
|
||||
// still refuses every request no policy permits.
|
||||
Endpoint string
|
||||
// Providers lists the providers at least one applicable policy
|
||||
// authorizes for the caller, in the account's created_at order.
|
||||
Providers []AgentConfigProvider
|
||||
}
|
||||
|
||||
// AgentConfigProvider is one authorized provider in an AgentConfig.
|
||||
type AgentConfigProvider struct {
|
||||
// Name is the operator-assigned label, e.g. "Bedrock prod".
|
||||
Name string
|
||||
// CatalogID names the catalog entry, e.g. "anthropic_api".
|
||||
CatalogID string
|
||||
// APIFlavor is the request-body shape the provider speaks — the
|
||||
// catalog entry's parser id ("anthropic", "openai"); empty when the
|
||||
// proxy dispatches the provider by URL path instead.
|
||||
APIFlavor string
|
||||
// AllModelsAllowed is true when no model allowlist restricts this
|
||||
// provider for the caller. Models then lists the declared/catalog
|
||||
// models as a courtesy (possibly none for gateway-style providers).
|
||||
AllModelsAllowed bool
|
||||
// Models is the effective model allowlist for the caller, or the
|
||||
// declared/catalog models when AllModelsAllowed is true.
|
||||
Models []string
|
||||
}
|
||||
@@ -175,6 +175,26 @@ func (p *Provider) FromAPIRequest(req *api.AgentNetworkProviderRequest) {
|
||||
|
||||
// ToAPIResponse renders the provider as the API representation. The API
|
||||
// key is intentionally never surfaced.
|
||||
// RedactedForViewer returns a copy with the connection configuration
|
||||
// blanked: upstream URL, operator-typed extra header values, identity
|
||||
// header names, the TLS-verification override, and (defence in depth —
|
||||
// they never reach the wire anyway) the sealed credentials. Read-only
|
||||
// viewers such as usage_viewer only need the display surface — id,
|
||||
// catalog id, name, enabled state, and the model list the usage filters
|
||||
// resolve against — so their responses carry nothing about how the
|
||||
// operator connects to the vendor.
|
||||
func (p *Provider) RedactedForViewer() *Provider {
|
||||
c := *p
|
||||
c.UpstreamURL = ""
|
||||
c.APIKey = ""
|
||||
c.ExtraValues = nil
|
||||
c.IdentityHeaderUserID = ""
|
||||
c.IdentityHeaderGroups = ""
|
||||
c.SkipTLSVerification = false
|
||||
c.SessionPrivateKey = ""
|
||||
return &c
|
||||
}
|
||||
|
||||
func (p *Provider) ToAPIResponse() *api.AgentNetworkProvider {
|
||||
models := make([]api.AgentNetworkProviderModel, 0, len(p.Models))
|
||||
for _, m := range p.Models {
|
||||
|
||||
@@ -3,10 +3,7 @@ package networkmapdb
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/exp/maps"
|
||||
|
||||
@@ -48,7 +45,7 @@ func (s *NetworkMapDBStoreImpl) GetNetworkMapData(ctx context.Context, accountId
|
||||
if err != nil {
|
||||
return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get network: %w", err))
|
||||
}
|
||||
peers, proxyPeers, err := tx.GetPeers(ctx, accountId)
|
||||
peers, _, err := tx.GetPeers(ctx, accountId)
|
||||
if err != nil {
|
||||
return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get peers: %w", err))
|
||||
}
|
||||
@@ -80,10 +77,6 @@ func (s *NetworkMapDBStoreImpl) GetNetworkMapData(ctx context.Context, accountId
|
||||
if err != nil {
|
||||
return rollbackAndReturnError(ctx, tx, err)
|
||||
}
|
||||
services, err := tx.GetPrivateServices(ctx, accountId)
|
||||
if err != nil {
|
||||
return rollbackAndReturnError(ctx, tx, err)
|
||||
}
|
||||
proxyTargetedDomainResourceIDs, err := tx.GetProxyTargetedDomainResourceIDs(ctx, accountId)
|
||||
if err != nil {
|
||||
return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get proxy targeted domain resources: %w", err))
|
||||
@@ -113,7 +106,7 @@ func (s *NetworkMapDBStoreImpl) GetNetworkMapData(ctx context.Context, accountId
|
||||
GroupIDToUserIDs: groupsToUserIds,
|
||||
NetworkXIDToPublicID: networkXIDToPublicID, // TODO (dmitri) maybe we can switch to public ids everywhere?
|
||||
AppliedZoneCandidates: dnsZones,
|
||||
PrivateServiceCandidates: buildPrivateServiceCandidates(services, domains, proxyPeers),
|
||||
Domains: TwinProxyDomains(domains),
|
||||
PostureCheckXIDToPublicID: postureCheckXIDToPublicID,
|
||||
ProxyTargetedDomainResourceIDs: proxyTargetedDomainResourceIDs,
|
||||
}
|
||||
@@ -154,94 +147,6 @@ func toSliceOfPtrs[T any](all []T) []*T {
|
||||
return toret
|
||||
}
|
||||
|
||||
func serviceDomainZone(svc Service, ds []Domain) string {
|
||||
if domainFromSuffix(svc.Domain.String, svc.ProxyCluster.String) {
|
||||
return svc.ProxyCluster.String
|
||||
}
|
||||
|
||||
var zoneName string
|
||||
for _, domain := range ds {
|
||||
if domain.TargetCluster.String != svc.ProxyCluster.String {
|
||||
continue
|
||||
}
|
||||
if domainFromSuffix(svc.Domain.String, domain.Domain.String) && len(domain.Domain.String) > len(zoneName) {
|
||||
zoneName = domain.Domain.String
|
||||
}
|
||||
}
|
||||
|
||||
return zoneName
|
||||
}
|
||||
|
||||
func domainFromSuffix(domain, suffix string) bool {
|
||||
if suffix == "" {
|
||||
return false
|
||||
}
|
||||
return domain == suffix || strings.HasSuffix(domain, "."+suffix)
|
||||
}
|
||||
|
||||
func buildPrivateServiceCandidates(svcs []Service, domains []Domain, proxyPeersByCluster map[string][]*nmdata.Peer) []networkmap.PrivateServiceCandidate {
|
||||
var out []networkmap.PrivateServiceCandidate
|
||||
|
||||
if len(proxyPeersByCluster) == 0 {
|
||||
return out
|
||||
}
|
||||
|
||||
for _, svc := range svcs {
|
||||
if !svc.Enabled.Bool || !svc.Private.Bool {
|
||||
continue
|
||||
}
|
||||
if len(svc.AccessGroups) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
domainZone := serviceDomainZone(svc, domains)
|
||||
if domainZone == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// this is implied when domainZone != "", but for maintainability's sake the check is explicit
|
||||
// TODO (dmitri) make this an invariant
|
||||
if svc.Domain.String == "" {
|
||||
continue
|
||||
}
|
||||
var records []nmdata.SimpleRecord
|
||||
for _, proxyPeer := range proxyPeersByCluster[svc.ProxyCluster.String] {
|
||||
if record, ok := recordForProxyPeer(svc.Domain.String, proxyPeer.IP); ok {
|
||||
records = append(records, record)
|
||||
}
|
||||
}
|
||||
if len(records) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, networkmap.PrivateServiceCandidate{
|
||||
AccessGroups: svc.AccessGroups,
|
||||
Zone: nmdata.CustomZone{
|
||||
Domain: dns.Fqdn(domainZone),
|
||||
Records: records,
|
||||
NonAuthoritative: true,
|
||||
SearchDomainDisabled: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func recordForProxyPeer(fqdn string, ip netip.Addr) (nmdata.SimpleRecord, bool) {
|
||||
if !ip.IsValid() {
|
||||
return nmdata.SimpleRecord{}, false
|
||||
}
|
||||
|
||||
return nmdata.SimpleRecord{
|
||||
Name: dns.Fqdn(fqdn),
|
||||
Type: int(dns.TypeA),
|
||||
Class: "IN",
|
||||
TTL: 5,
|
||||
RData: ip.String(),
|
||||
}, true
|
||||
}
|
||||
|
||||
func buildResourcePolicies(networkResources []nmdata.NetworkResource,
|
||||
policies []nmdata.Policy,
|
||||
resourceToGroupIdx map[string]map[string]any,
|
||||
|
||||
@@ -1,253 +1,12 @@
|
||||
package networkmapdb
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDomainFromSuffix(t *testing.T) {
|
||||
assert.False(t, domainFromSuffix("test", ""))
|
||||
assert.False(t, domainFromSuffix("test", "suffix")) // domain != suffix
|
||||
assert.True(t, domainFromSuffix("test", "test")) // domain == suffix
|
||||
assert.False(t, domainFromSuffix("test.anothersuffix", "suffix")) // domain doesn't contain suffix
|
||||
assert.True(t, domainFromSuffix("test.suffix", "suffix")) // domain contains suffix
|
||||
}
|
||||
|
||||
func TestServiceDomainZone(t *testing.T) {
|
||||
// shortcut -- service's domain is a subomain of proxy cluster
|
||||
assert.Equal(t, "cluster",
|
||||
serviceDomainZone(
|
||||
Service{
|
||||
Domain: sql.NullString{Valid: true, String: "test.cluster"},
|
||||
ProxyCluster: sql.NullString{Valid: true, String: "cluster"}},
|
||||
[]Domain{}))
|
||||
assert.Equal(t, "a.b", serviceDomainZone(
|
||||
Service{
|
||||
Domain: sql.NullString{Valid: true, String: "test.a.b"},
|
||||
ProxyCluster: sql.NullString{Valid: true, String: "cluster"}},
|
||||
[]Domain{
|
||||
{TargetCluster: sql.NullString{Valid: true, String: "a-cluster"}},
|
||||
{TargetCluster: sql.NullString{Valid: true, String: "cluster"},
|
||||
Domain: sql.NullString{Valid: true, String: "b"}},
|
||||
{TargetCluster: sql.NullString{Valid: true, String: "cluster"},
|
||||
Domain: sql.NullString{Valid: true, String: "a.b"}}, // should return this domain, as it's the longest match
|
||||
{TargetCluster: sql.NullString{Valid: true, String: "b-cluster"}},
|
||||
}))
|
||||
// service and domain clusters don't match
|
||||
assert.Empty(t, serviceDomainZone(
|
||||
Service{
|
||||
Domain: sql.NullString{Valid: true, String: "test.a.b"},
|
||||
ProxyCluster: sql.NullString{Valid: true, String: "c-cluster"}},
|
||||
[]Domain{
|
||||
{TargetCluster: sql.NullString{Valid: true, String: "cluster"},
|
||||
Domain: sql.NullString{Valid: true, String: "a.b"}},
|
||||
}))
|
||||
// service domain is empty
|
||||
assert.Empty(t, serviceDomainZone(
|
||||
Service{
|
||||
Domain: sql.NullString{Valid: false, String: ""},
|
||||
ProxyCluster: sql.NullString{Valid: true, String: "cluster"}},
|
||||
[]Domain{
|
||||
{TargetCluster: sql.NullString{Valid: true, String: "cluster"},
|
||||
Domain: sql.NullString{Valid: true, String: "a.b"}},
|
||||
}))
|
||||
}
|
||||
|
||||
func TestRecordForProxyPeer(t *testing.T) {
|
||||
record, ok := recordForProxyPeer("test.cluster", netip.MustParseAddr("127.0.0.1"))
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, nmdata.SimpleRecord{
|
||||
Name: "test.cluster.",
|
||||
Type: 1,
|
||||
Class: "IN",
|
||||
TTL: 5,
|
||||
RData: "127.0.0.1",
|
||||
}, record)
|
||||
|
||||
// invalid address
|
||||
var addr netip.Addr
|
||||
_, ok = recordForProxyPeer("test.cluster", addr)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
var empty []networkmap.PrivateServiceCandidate
|
||||
|
||||
// empty proxyPeersByCluster results in empty []PrivateServiceCandidates
|
||||
func TestBuildPrivateServiceCandidates_EmptyProxyPeers(t *testing.T) {
|
||||
assert.Equal(t, empty, buildPrivateServiceCandidates([]Service{}, []Domain{}, nil))
|
||||
}
|
||||
|
||||
// disabled service returns an empty result
|
||||
func TestBuildPrivateServiceCandidates_DisabledService(t *testing.T) {
|
||||
assert.Equal(t, empty,
|
||||
buildPrivateServiceCandidates([]Service{
|
||||
{Enabled: sql.NullBool{Valid: true, Bool: false},
|
||||
Private: sql.NullBool{Valid: true, Bool: true},
|
||||
AccessGroups: []string{"group-1", "group-2"},
|
||||
Domain: sql.NullString{Valid: true, String: "test.a.b"},
|
||||
ProxyCluster: sql.NullString{Valid: true, String: "cluster"}},
|
||||
}, []Domain{
|
||||
{TargetCluster: sql.NullString{Valid: true, String: "cluster"},
|
||||
Domain: sql.NullString{Valid: true, String: "a.b"}},
|
||||
},
|
||||
map[string][]*nmdata.Peer{
|
||||
"cluster": {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.1")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.2")}},
|
||||
"a-cluster": {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.3")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.4")}},
|
||||
}))
|
||||
}
|
||||
|
||||
// non-private service results in empty []PrivateServiceCandidates
|
||||
func TestBuildPrivateServiceCandidates_PublicService(t *testing.T) {
|
||||
assert.Equal(t, empty,
|
||||
buildPrivateServiceCandidates([]Service{
|
||||
{Enabled: sql.NullBool{Valid: true, Bool: true},
|
||||
Private: sql.NullBool{Valid: true, Bool: false},
|
||||
AccessGroups: []string{"group-1", "group-2"},
|
||||
Domain: sql.NullString{Valid: true, String: "test.a.b"},
|
||||
ProxyCluster: sql.NullString{Valid: true, String: "cluster"}},
|
||||
}, []Domain{
|
||||
{TargetCluster: sql.NullString{Valid: true, String: "cluster"},
|
||||
Domain: sql.NullString{Valid: true, String: "a.b"}},
|
||||
},
|
||||
map[string][]*nmdata.Peer{
|
||||
"cluster": {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.1")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.2")}},
|
||||
"a-cluster": {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.3")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.4")}},
|
||||
}))
|
||||
}
|
||||
|
||||
// empty AccessList results in empty []PrivateServiceCandidates
|
||||
func TestBuildPrivateServiceCandidates_EmptyAccessList(t *testing.T) {
|
||||
assert.Equal(t, empty,
|
||||
buildPrivateServiceCandidates([]Service{
|
||||
{Enabled: sql.NullBool{Valid: true, Bool: true},
|
||||
Private: sql.NullBool{Valid: true, Bool: true},
|
||||
Domain: sql.NullString{Valid: true, String: "test.a.b"},
|
||||
ProxyCluster: sql.NullString{Valid: true, String: "cluster"}},
|
||||
}, []Domain{
|
||||
{TargetCluster: sql.NullString{Valid: true, String: "cluster"},
|
||||
Domain: sql.NullString{Valid: true, String: "a.b"}},
|
||||
},
|
||||
map[string][]*nmdata.Peer{
|
||||
"cluster": {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.1")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.2")}},
|
||||
"a-cluster": {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.3")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.4")}},
|
||||
}))
|
||||
}
|
||||
|
||||
// empty TragetCluster results in empty []PrivateServiceCandidates
|
||||
func TestBuildPrivateServiceCandidates_EmptyTargetCluster(t *testing.T) {
|
||||
assert.Equal(t, empty,
|
||||
buildPrivateServiceCandidates([]Service{
|
||||
{Enabled: sql.NullBool{Valid: true, Bool: true},
|
||||
Private: sql.NullBool{Valid: true, Bool: true},
|
||||
AccessGroups: []string{"group-1", "group-2"},
|
||||
Domain: sql.NullString{Valid: true, String: "test.a.b"},
|
||||
ProxyCluster: sql.NullString{Valid: true, String: "cluster"}},
|
||||
}, []Domain{
|
||||
{TargetCluster: sql.NullString{Valid: true, String: ""},
|
||||
Domain: sql.NullString{Valid: true, String: "a.b"}},
|
||||
},
|
||||
map[string][]*nmdata.Peer{
|
||||
"cluster": {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.1")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.2")}},
|
||||
"a-cluster": {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.3")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.4")}},
|
||||
}))
|
||||
}
|
||||
|
||||
func TestBuildPrivateServiceCandidates_EmptyServiceDomain(t *testing.T) {
|
||||
assert.Equal(t, empty,
|
||||
buildPrivateServiceCandidates([]Service{
|
||||
{Enabled: sql.NullBool{Valid: true, Bool: true},
|
||||
Private: sql.NullBool{Valid: true, Bool: true},
|
||||
Domain: sql.NullString{Valid: true, String: ""},
|
||||
ProxyCluster: sql.NullString{Valid: true, String: "cluster"}},
|
||||
}, []Domain{
|
||||
{TargetCluster: sql.NullString{Valid: true, String: "cluster"},
|
||||
Domain: sql.NullString{Valid: true, String: "a.b"}},
|
||||
},
|
||||
map[string][]*nmdata.Peer{
|
||||
"cluster": {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.1")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.2")}},
|
||||
"a-cluster": {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.3")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.4")}},
|
||||
}))
|
||||
}
|
||||
|
||||
func TestBuildPrivateServiceCandidates_HappyPath(t *testing.T) {
|
||||
assert.Equal(t, []networkmap.PrivateServiceCandidate{
|
||||
{
|
||||
AccessGroups: []string{"group-1", "group-2"},
|
||||
Zone: nmdata.CustomZone{
|
||||
Domain: "a.b.",
|
||||
SearchDomainDisabled: true,
|
||||
NonAuthoritative: true,
|
||||
Records: []nmdata.SimpleRecord{
|
||||
{
|
||||
Name: "test.a.b.",
|
||||
Type: 1,
|
||||
Class: "IN",
|
||||
TTL: 5,
|
||||
RData: "127.0.0.1",
|
||||
},
|
||||
{
|
||||
Name: "test.a.b.",
|
||||
Type: 1,
|
||||
Class: "IN",
|
||||
TTL: 5,
|
||||
RData: "127.0.0.2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
AccessGroups: []string{"group-1", "group-2"},
|
||||
Zone: nmdata.CustomZone{
|
||||
Domain: "c.d.",
|
||||
SearchDomainDisabled: true,
|
||||
NonAuthoritative: true,
|
||||
Records: []nmdata.SimpleRecord{
|
||||
{
|
||||
Name: "test.c.d.",
|
||||
Type: 1,
|
||||
Class: "IN",
|
||||
TTL: 5,
|
||||
RData: "127.0.0.3",
|
||||
},
|
||||
{
|
||||
Name: "test.c.d.",
|
||||
Type: 1,
|
||||
Class: "IN",
|
||||
TTL: 5,
|
||||
RData: "127.0.0.4",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
buildPrivateServiceCandidates([]Service{
|
||||
{Enabled: sql.NullBool{Valid: true, Bool: true},
|
||||
Private: sql.NullBool{Valid: true, Bool: true},
|
||||
AccessGroups: []string{"group-1", "group-2"},
|
||||
Domain: sql.NullString{Valid: true, String: "test.a.b"},
|
||||
ProxyCluster: sql.NullString{Valid: true, String: "cluster"}},
|
||||
{Enabled: sql.NullBool{Valid: true, Bool: true},
|
||||
Private: sql.NullBool{Valid: true, Bool: true},
|
||||
AccessGroups: []string{"group-1", "group-2"},
|
||||
Domain: sql.NullString{Valid: true, String: "test.c.d"},
|
||||
ProxyCluster: sql.NullString{Valid: true, String: "a-cluster"}},
|
||||
}, []Domain{
|
||||
{TargetCluster: sql.NullString{Valid: true, String: "cluster"},
|
||||
Domain: sql.NullString{Valid: true, String: "a.b"}},
|
||||
{TargetCluster: sql.NullString{Valid: true, String: "a-cluster"},
|
||||
Domain: sql.NullString{Valid: true, String: "c.d"}},
|
||||
},
|
||||
map[string][]*nmdata.Peer{
|
||||
"cluster": {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.1")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.2")}},
|
||||
"a-cluster": {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.3")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.4")}},
|
||||
}))
|
||||
}
|
||||
|
||||
// disabled network resource shouldn't be in the resulting map
|
||||
func TestBuildResourcePolicies_DisabledNetworkResource(t *testing.T) {
|
||||
networkResources := []nmdata.NetworkResource{
|
||||
|
||||
@@ -304,6 +304,7 @@ func ConvertToNmdataPeers(peers []Peer) ([]nmdata.Peer, map[string][]*nmdata.Pee
|
||||
}
|
||||
dp.ProxyMeta.Cluster = p.ProxyMetaCluster.String
|
||||
// This is only used to build private service candidates, not connected peers are skipped
|
||||
dp.Connected = p.PeerStatusConnected.Bool
|
||||
if dp.ProxyMeta.Embedded && p.PeerStatusConnected.Bool {
|
||||
clusterToPeerIdx[p.ProxyMetaCluster.String] = append(clusterToPeerIdx[p.ProxyMetaCluster.String], &dp)
|
||||
}
|
||||
@@ -481,3 +482,16 @@ func decodePolicyRuleColumns(p Policy, pr func() *nmdata.PolicyRule, resourceIdx
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TwinProxyDomains converts registered reverse-proxy domain rows to their slim
|
||||
// twins, so private-service zone apex resolution runs on the twin.
|
||||
func TwinProxyDomains(domains []Domain) []nmdata.ProxyDomain {
|
||||
if len(domains) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]nmdata.ProxyDomain, 0, len(domains))
|
||||
for _, d := range domains {
|
||||
out = append(out, nmdata.ProxyDomain{Domain: d.Domain.String, TargetCluster: d.TargetCluster.String})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -676,6 +676,7 @@ func extractPeerMeta(ctx context.Context, meta *proto.PeerSystemMeta) nbpeer.Pee
|
||||
RosenpassEnabled: meta.GetFlags().GetRosenpassEnabled(),
|
||||
RosenpassPermissive: meta.GetFlags().GetRosenpassPermissive(),
|
||||
ServerSSHAllowed: meta.GetFlags().GetServerSSHAllowed(),
|
||||
RemoteJobsAllowed: meta.GetFlags().GetRemoteJobsAllowed(),
|
||||
ServerVNCAllowed: meta.GetFlags().GetServerVNCAllowed(),
|
||||
DisableClientRoutes: meta.GetFlags().GetDisableClientRoutes(),
|
||||
DisableServerRoutes: meta.GetFlags().GetDisableServerRoutes(),
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
@@ -65,7 +64,7 @@ const (
|
||||
type userLoggedInOnce bool
|
||||
|
||||
func cacheEntryExpiration() time.Duration {
|
||||
r := rand.Intn(int(nbcache.DefaultIDPCacheExpirationMax.Milliseconds()-nbcache.DefaultIDPCacheExpirationMin.Milliseconds())) + int(nbcache.DefaultIDPCacheExpirationMin.Milliseconds())
|
||||
r := util.RandIntn(int(nbcache.DefaultIDPCacheExpirationMax.Milliseconds()-nbcache.DefaultIDPCacheExpirationMin.Milliseconds())) + int(nbcache.DefaultIDPCacheExpirationMin.Milliseconds())
|
||||
return time.Duration(r) * time.Millisecond
|
||||
}
|
||||
|
||||
@@ -2470,8 +2469,7 @@ func (am *DefaultAccountManager) ensureIPv6Subnet(ctx context.Context, transacti
|
||||
return transaction.UpdateAccountNetworkV6(ctx, accountID, network.NetV6)
|
||||
}
|
||||
if network.NetV6.IP == nil {
|
||||
r := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
network.NetV6 = types.AllocateIPv6Subnet(r)
|
||||
network.NetV6 = types.AllocateIPv6Subnet()
|
||||
|
||||
// Sync settings to match the allocated subnet so SaveAccountSettings persists it.
|
||||
ones, _ := network.NetV6.Mask.Size()
|
||||
|
||||
@@ -3,6 +3,7 @@ package geolocation
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
@@ -21,6 +22,8 @@ const (
|
||||
geoLiteCitySha256ZipURL = "https://pkgs.netbird.io/geolocation-dbs/GeoLite2-City-CSV/download?suffix=zip.sha256"
|
||||
geoLiteCityMMDB = "GeoLite2-City.mmdb"
|
||||
geoLiteCityCSV = "GeoLite2-City-Locations-en.csv"
|
||||
|
||||
geonamesCsvFields = 14
|
||||
)
|
||||
|
||||
// loadGeolocationDatabases loads the MaxMind databases.
|
||||
@@ -160,6 +163,10 @@ func loadGeonamesCsv(filepath string) ([]GeoNames, error) {
|
||||
if index == 0 {
|
||||
continue
|
||||
}
|
||||
if len(record) < geonamesCsvFields {
|
||||
return nil, fmt.Errorf("geonames csv record %d has %d fields, want at least %d", index, len(record), geonamesCsvFields)
|
||||
}
|
||||
|
||||
geoNameID, err := strconv.Atoi(record[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -242,7 +242,11 @@ func getDatabaseFilename(ctx context.Context, databaseURL string, filenamePatter
|
||||
// strip suffixes that may be nested, such as .tar.gz
|
||||
basename := strings.SplitN(filename, ".", 2)[0]
|
||||
// get date version from basename
|
||||
date := strings.SplitN(basename, "_", 2)[1]
|
||||
parts := strings.SplitN(basename, "_", 2)
|
||||
if len(parts) < 2 || parts[1] == "" {
|
||||
return "", fmt.Errorf("unexpected database filename %q: missing date suffix", filename)
|
||||
}
|
||||
date := parts[1]
|
||||
// format db as "GeoLite2-Cities-{maxmind|geonames}_{DATE}.{mmdb|db}"
|
||||
databaseFilename := filepath.Base(strings.Replace(filenamePattern, "*", date, 1))
|
||||
|
||||
|
||||
@@ -184,7 +184,12 @@ func getFilenameFromURL(url string) (string, error) {
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
_, params, err := mime.ParseMediaType(resp.Header["Content-Disposition"][0])
|
||||
contentDisposition := resp.Header.Get("Content-Disposition")
|
||||
if contentDisposition == "" {
|
||||
return "", fmt.Errorf("no Content-Disposition header in response from %s", url)
|
||||
}
|
||||
|
||||
_, params, err := mime.ParseMediaType(contentDisposition)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/rand"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -28,7 +27,7 @@ func TestGroupIPv6Assignment(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Allocate IPv6 subnet for the account
|
||||
account.Network.NetV6 = types.AllocateIPv6Subnet(rand.New(rand.NewSource(time.Now().UnixNano())))
|
||||
account.Network.NetV6 = types.AllocateIPv6Subnet()
|
||||
require.NoError(t, am.Store.SaveAccount(ctx, account))
|
||||
|
||||
// Create setup key
|
||||
|
||||
@@ -711,6 +711,7 @@ func toSinglePeerResponse(peer *nbpeer.Peer, groupsInfo []api.GroupMinimum, dnsD
|
||||
RosenpassEnabled: &peer.Meta.Flags.RosenpassEnabled,
|
||||
RosenpassPermissive: &peer.Meta.Flags.RosenpassPermissive,
|
||||
ServerSshAllowed: &peer.Meta.Flags.ServerSSHAllowed,
|
||||
RemoteJobsAllowed: &peer.Meta.Flags.RemoteJobsAllowed,
|
||||
ServerVncAllowed: &peer.Meta.Flags.ServerVNCAllowed,
|
||||
},
|
||||
}
|
||||
@@ -767,6 +768,7 @@ func toPeerListItemResponse(peer *nbpeer.Peer, groupsInfo []api.GroupMinimum, dn
|
||||
RosenpassEnabled: &peer.Meta.Flags.RosenpassEnabled,
|
||||
RosenpassPermissive: &peer.Meta.Flags.RosenpassPermissive,
|
||||
ServerSshAllowed: &peer.Meta.Flags.ServerSSHAllowed,
|
||||
RemoteJobsAllowed: &peer.Meta.Flags.RemoteJobsAllowed,
|
||||
ServerVncAllowed: &peer.Meta.Flags.ServerVNCAllowed,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -59,13 +59,21 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee
|
||||
}
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
|
||||
// Bound the background loops these managers start (account request buffer,
|
||||
// telemetry P95 flushers, PAT usage tracker, API rate limiter, proxy service
|
||||
// cleanup, cache janitors, DB connection pools) to the test's lifetime. On
|
||||
// context.Background() they never stop and accumulate across the package,
|
||||
// exhausting DB connections until the suite hits the 20m test timeout.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
metrics, err := telemetry.NewDefaultAppMetrics(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create metrics: %v", err)
|
||||
}
|
||||
|
||||
peersUpdateManager := update_channel.NewPeersUpdateManager(nil)
|
||||
updMsg := peersUpdateManager.CreateChannel(context.Background(), testing_tools.TestPeerId)
|
||||
updMsg := peersUpdateManager.CreateChannel(ctx, testing_tools.TestPeerId)
|
||||
done := make(chan struct{})
|
||||
if validateUpdate {
|
||||
go func() {
|
||||
@@ -88,8 +96,6 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee
|
||||
|
||||
jobManager := job.NewJobManager(nil, store, peersManager)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
cacheStore, err := nbcache.NewStore(ctx, 100*time.Millisecond, 300*time.Millisecond, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create cache store: %v", err)
|
||||
@@ -111,6 +117,10 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee
|
||||
t.Fatalf("Failed to create proxy manager: %v", err)
|
||||
}
|
||||
proxyServiceServer := nbgrpc.NewProxyServiceServer(accessLogsManager, proxyTokenStore, pkceverifierStore, nbgrpc.ProxyOIDCConfig{}, peersManager, userManager, nil, proxyMgr, nil)
|
||||
// NewProxyServiceServer starts cleanupStaleProxies on a context it derives
|
||||
// from context.Background(), independent of the cancellable ctx above;
|
||||
// Close() cancels it so the goroutine does not outlive the test.
|
||||
t.Cleanup(proxyServiceServer.Close)
|
||||
domainManager := manager.NewManager(store, proxyMgr, permissionsManager, am)
|
||||
serviceProxyController, err := proxymanager.NewGRPCController(proxyServiceServer, noopMeter)
|
||||
if err != nil {
|
||||
@@ -137,7 +147,7 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee
|
||||
zoneRecordsManager := recordsManager.NewManager(store, am, permissionsManager)
|
||||
|
||||
apiRouter := mux.NewRouter().PathPrefix("/api").Subrouter()
|
||||
apiHandler, err := http2.NewAPIHandler(context.Background(), apiRouter, am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil, nil)
|
||||
apiHandler, err := http2.NewAPIHandler(ctx, apiRouter, am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create API handler: %v", err)
|
||||
}
|
||||
@@ -200,13 +210,21 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin
|
||||
}
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
|
||||
// Bound the background loops these managers start (account request buffer,
|
||||
// telemetry P95 flushers, PAT usage tracker, API rate limiter, proxy service
|
||||
// cleanup, cache janitors, DB connection pools) to the test's lifetime. On
|
||||
// context.Background() they never stop and accumulate across the package,
|
||||
// exhausting DB connections until the suite hits the 20m test timeout.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
metrics, err := telemetry.NewDefaultAppMetrics(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create metrics: %v", err)
|
||||
}
|
||||
|
||||
peersUpdateManager := update_channel.NewPeersUpdateManager(nil)
|
||||
updMsg := peersUpdateManager.CreateChannel(context.Background(), testing_tools.TestPeerId)
|
||||
updMsg := peersUpdateManager.CreateChannel(ctx, testing_tools.TestPeerId)
|
||||
|
||||
geoMock := &geolocation.Mock{}
|
||||
validatorMock := server.MockIntegratedValidator{}
|
||||
@@ -218,8 +236,6 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin
|
||||
|
||||
jobManager := job.NewJobManager(nil, store, peersManager)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
cacheStore, err := nbcache.NewStore(ctx, 100*time.Millisecond, 300*time.Millisecond, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create cache store: %v", err)
|
||||
@@ -241,6 +257,10 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin
|
||||
t.Fatalf("Failed to create proxy manager: %v", err)
|
||||
}
|
||||
proxyServiceServer := nbgrpc.NewProxyServiceServer(accessLogsManager, proxyTokenStore, pkceverifierStore, nbgrpc.ProxyOIDCConfig{}, peersManager, userManager, nil, proxyMgr, nil)
|
||||
// NewProxyServiceServer starts cleanupStaleProxies on a context it derives
|
||||
// from context.Background(), independent of the cancellable ctx above;
|
||||
// Close() cancels it so the goroutine does not outlive the test.
|
||||
t.Cleanup(proxyServiceServer.Close)
|
||||
domainManager := manager.NewManager(store, proxyMgr, permissionsManager, am)
|
||||
serviceProxyController, err := proxymanager.NewGRPCController(proxyServiceServer, noopMeter)
|
||||
if err != nil {
|
||||
@@ -267,7 +287,7 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin
|
||||
zoneRecordsManager := recordsManager.NewManager(store, am, permissionsManager)
|
||||
|
||||
apiRouter := mux.NewRouter().PathPrefix("/api").Subrouter()
|
||||
apiHandler, err := http2.NewAPIHandler(context.Background(), apiRouter, am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil, nil)
|
||||
apiHandler, err := http2.NewAPIHandler(ctx, apiRouter, am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create API handler: %v", err)
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@ package idp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math/rand"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/util"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -33,31 +34,32 @@ func GeneratePassword(passwordLength, minSpecialChar, minNum, minUpperCase int)
|
||||
|
||||
//Set special character
|
||||
for i := 0; i < minSpecialChar; i++ {
|
||||
random := rand.Intn(len(specialCharSet))
|
||||
random := util.RandIntn(len(specialCharSet))
|
||||
password.WriteString(string(specialCharSet[random]))
|
||||
}
|
||||
|
||||
//Set numeric
|
||||
for i := 0; i < minNum; i++ {
|
||||
random := rand.Intn(len(numberSet))
|
||||
random := util.RandIntn(len(numberSet))
|
||||
password.WriteString(string(numberSet[random]))
|
||||
}
|
||||
|
||||
//Set uppercase
|
||||
for i := 0; i < minUpperCase; i++ {
|
||||
random := rand.Intn(len(upperCharSet))
|
||||
random := util.RandIntn(len(upperCharSet))
|
||||
password.WriteString(string(upperCharSet[random]))
|
||||
}
|
||||
|
||||
remainingLength := passwordLength - minSpecialChar - minNum - minUpperCase
|
||||
for i := 0; i < remainingLength; i++ {
|
||||
random := rand.Intn(len(allCharSet))
|
||||
random := util.RandIntn(len(allCharSet))
|
||||
password.WriteString(string(allCharSet[random]))
|
||||
}
|
||||
inRune := []rune(password.String())
|
||||
rand.Shuffle(len(inRune), func(i, j int) {
|
||||
for i := len(inRune) - 1; i > 0; i-- {
|
||||
j := util.RandIntn(i + 1)
|
||||
inRune[i], inRune[j] = inRune[j], inRune[i]
|
||||
})
|
||||
}
|
||||
return string(inRune)
|
||||
}
|
||||
|
||||
|
||||
@@ -142,6 +142,7 @@ type Flags struct {
|
||||
RosenpassEnabled bool
|
||||
RosenpassPermissive bool
|
||||
ServerSSHAllowed bool
|
||||
RemoteJobsAllowed bool
|
||||
ServerVNCAllowed bool
|
||||
|
||||
DisableClientRoutes bool
|
||||
@@ -574,6 +575,7 @@ func (f Flags) isEqual(other Flags) bool {
|
||||
return f.RosenpassEnabled == other.RosenpassEnabled &&
|
||||
f.RosenpassPermissive == other.RosenpassPermissive &&
|
||||
f.ServerSSHAllowed == other.ServerSSHAllowed &&
|
||||
f.RemoteJobsAllowed == other.RemoteJobsAllowed &&
|
||||
f.ServerVNCAllowed == other.ServerVNCAllowed &&
|
||||
f.DisableClientRoutes == other.DisableClientRoutes &&
|
||||
f.DisableServerRoutes == other.DisableServerRoutes &&
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package permissions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/permissions/modules"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/operations"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/roles"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
var allOps = []operations.Operation{operations.Read, operations.Create, operations.Update, operations.Delete}
|
||||
|
||||
// TestAgentNetworkAdminRole pins the delegated-admin contract: full control
|
||||
// over the whole agent_network area (parent grant cascades to every
|
||||
// submodule), read-only on the account objects needed to build policies,
|
||||
// and nothing else in the account.
|
||||
func TestAgentNetworkAdminRole(t *testing.T) {
|
||||
manager := NewManager(nil)
|
||||
ctx := context.Background()
|
||||
|
||||
role, ok := roles.RolesMap[types.UserRoleAgentNetworkAdmin]
|
||||
require.True(t, ok, "agent_network_admin must exist in RolesMap")
|
||||
|
||||
agentNetworkModules := []modules.Module{
|
||||
modules.AgentNetwork,
|
||||
modules.AgentNetworkProviders,
|
||||
modules.AgentNetworkPolicies,
|
||||
modules.AgentNetworkGuardrails,
|
||||
modules.AgentNetworkBudgets,
|
||||
modules.AgentNetworkUsage,
|
||||
modules.AgentNetworkLogs,
|
||||
modules.AgentNetworkSettings,
|
||||
}
|
||||
for _, m := range agentNetworkModules {
|
||||
for _, op := range allOps {
|
||||
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, op),
|
||||
"agent_network_admin must have %s on %s", op, m)
|
||||
}
|
||||
}
|
||||
|
||||
// Settings read rides along because GET /api/accounts (which the
|
||||
// dashboard needs to boot) validates it, like network_admin.
|
||||
for _, m := range []modules.Module{modules.Users, modules.Groups, modules.Peers, modules.Accounts, modules.Settings} {
|
||||
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, operations.Read),
|
||||
"agent_network_admin must read %s to build policies and load the dashboard", m)
|
||||
for _, op := range []operations.Operation{operations.Create, operations.Update, operations.Delete} {
|
||||
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, op),
|
||||
"agent_network_admin must not have %s on %s", op, m)
|
||||
}
|
||||
}
|
||||
|
||||
for _, m := range []modules.Module{modules.Networks, modules.Dns, modules.SetupKeys, modules.Routes} {
|
||||
for _, op := range allOps {
|
||||
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, op),
|
||||
"agent_network_admin must not have %s on %s", op, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUsageViewerRole pins the least-privilege cost role: read on the
|
||||
// aggregated usage overview plus read-only on the resources its filters
|
||||
// and display columns resolve against (users, groups, peers, the provider
|
||||
// list) — no policies, no request-level logs (which can contain captured
|
||||
// prompts), nothing else in the account.
|
||||
func TestUsageViewerRole(t *testing.T) {
|
||||
manager := NewManager(nil)
|
||||
ctx := context.Background()
|
||||
|
||||
role, ok := roles.RolesMap[types.UserRoleUsageViewer]
|
||||
require.True(t, ok, "usage_viewer must exist in RolesMap")
|
||||
|
||||
readOnly := []modules.Module{
|
||||
modules.AgentNetworkUsage,
|
||||
modules.AgentNetworkProviders,
|
||||
modules.Users,
|
||||
modules.Groups,
|
||||
modules.Peers,
|
||||
}
|
||||
for _, m := range readOnly {
|
||||
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, operations.Read),
|
||||
"usage_viewer must read %s for the usage view and its filters", m)
|
||||
for _, op := range []operations.Operation{operations.Create, operations.Update, operations.Delete} {
|
||||
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, op),
|
||||
"usage_viewer must not have %s on %s", op, m)
|
||||
}
|
||||
}
|
||||
|
||||
denied := []modules.Module{
|
||||
modules.AgentNetwork,
|
||||
modules.AgentNetworkPolicies,
|
||||
modules.AgentNetworkGuardrails,
|
||||
modules.AgentNetworkBudgets,
|
||||
modules.AgentNetworkLogs,
|
||||
modules.AgentNetworkSettings,
|
||||
modules.Networks,
|
||||
modules.SetupKeys,
|
||||
}
|
||||
for _, m := range denied {
|
||||
for _, op := range allOps {
|
||||
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, op),
|
||||
"usage_viewer must not have %s on %s", op, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingAdminRoleResolves pins that billing_admin has a proper entry
|
||||
// in the permission map. Its plan/seat/invoice permissions are enforced
|
||||
// outside this map; management-side it carries the regular User baseline
|
||||
// instead of failing role resolution.
|
||||
func TestBillingAdminRoleResolves(t *testing.T) {
|
||||
manager := NewManager(nil)
|
||||
ctx := context.Background()
|
||||
|
||||
role, ok := roles.RolesMap[types.UserRoleBillingAdmin]
|
||||
require.True(t, ok, "billing_admin must exist in RolesMap")
|
||||
|
||||
permissions, err := manager.GetPermissionsByRole(ctx, types.UserRoleBillingAdmin)
|
||||
require.NoError(t, err, "billing_admin role must resolve")
|
||||
require.NotEmpty(t, permissions)
|
||||
|
||||
for _, m := range []modules.Module{modules.AgentNetwork, modules.Networks, modules.Users, modules.Peers} {
|
||||
for _, op := range allOps {
|
||||
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, op),
|
||||
"billing_admin must not have %s on %s", op, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewRolesParse pins the API role strings, which are permanent once
|
||||
// released.
|
||||
func TestNewRolesParse(t *testing.T) {
|
||||
assert.Equal(t, types.UserRoleAgentNetworkAdmin, types.StrRoleToUserRole("agent_network_admin"))
|
||||
assert.Equal(t, types.UserRoleUsageViewer, types.StrRoleToUserRole("usage_viewer"))
|
||||
assert.Equal(t, types.UserRoleBillingAdmin, types.StrRoleToUserRole("billing_admin"))
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package roles
|
||||
|
||||
import (
|
||||
"github.com/netbirdio/netbird/management/server/permissions/modules"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/operations"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
// AgentNetworkAdmin is the delegated administrator for the Agent Network
|
||||
// area: full control over providers, policies, guardrails, budgets, usage,
|
||||
// logs, and its settings, plus read-only visibility into the account
|
||||
// objects needed to build policies (users, groups, peers) and the account
|
||||
// settings/meta read the dashboard needs to boot (GET /api/accounts
|
||||
// validates Settings read, same as network_admin). Nothing else in the
|
||||
// account is visible.
|
||||
var AgentNetworkAdmin = RolePermissions{
|
||||
Role: types.UserRoleAgentNetworkAdmin,
|
||||
AutoAllowNew: map[operations.Operation]bool{
|
||||
operations.Read: false,
|
||||
operations.Create: false,
|
||||
operations.Update: false,
|
||||
operations.Delete: false,
|
||||
},
|
||||
Permissions: Permissions{
|
||||
modules.AgentNetwork: {
|
||||
operations.Read: true,
|
||||
operations.Create: true,
|
||||
operations.Update: true,
|
||||
operations.Delete: true,
|
||||
},
|
||||
modules.Users: {
|
||||
operations.Read: true,
|
||||
operations.Create: false,
|
||||
operations.Update: false,
|
||||
operations.Delete: false,
|
||||
},
|
||||
modules.Groups: {
|
||||
operations.Read: true,
|
||||
operations.Create: false,
|
||||
operations.Update: false,
|
||||
operations.Delete: false,
|
||||
},
|
||||
modules.Peers: {
|
||||
operations.Read: true,
|
||||
operations.Create: false,
|
||||
operations.Update: false,
|
||||
operations.Delete: false,
|
||||
},
|
||||
modules.Accounts: {
|
||||
operations.Read: true,
|
||||
operations.Create: false,
|
||||
operations.Update: false,
|
||||
operations.Delete: false,
|
||||
},
|
||||
modules.Settings: {
|
||||
operations.Read: true,
|
||||
operations.Create: false,
|
||||
operations.Update: false,
|
||||
operations.Delete: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package roles
|
||||
|
||||
import (
|
||||
"github.com/netbirdio/netbird/management/server/permissions/operations"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
// BillingAdmin manages plans, seats, and invoices, which are enforced
|
||||
// outside this permission map (NetBird Cloud). Management-side it carries
|
||||
// the regular User baseline; the explicit entry keeps role resolution from
|
||||
// failing with a role-not-found error.
|
||||
var BillingAdmin = RolePermissions{
|
||||
Role: types.UserRoleBillingAdmin,
|
||||
AutoAllowNew: map[operations.Operation]bool{
|
||||
operations.Read: false,
|
||||
operations.Create: false,
|
||||
operations.Update: false,
|
||||
operations.Delete: false,
|
||||
},
|
||||
}
|
||||
@@ -15,9 +15,12 @@ type RolePermissions struct {
|
||||
type Permissions map[modules.Module]map[operations.Operation]bool
|
||||
|
||||
var RolesMap = map[types.UserRole]RolePermissions{
|
||||
types.UserRoleOwner: Owner,
|
||||
types.UserRoleAdmin: Admin,
|
||||
types.UserRoleUser: User,
|
||||
types.UserRoleAuditor: Auditor,
|
||||
types.UserRoleNetworkAdmin: NetworkAdmin,
|
||||
types.UserRoleOwner: Owner,
|
||||
types.UserRoleAdmin: Admin,
|
||||
types.UserRoleUser: User,
|
||||
types.UserRoleAuditor: Auditor,
|
||||
types.UserRoleNetworkAdmin: NetworkAdmin,
|
||||
types.UserRoleAgentNetworkAdmin: AgentNetworkAdmin,
|
||||
types.UserRoleUsageViewer: UsageViewer,
|
||||
types.UserRoleBillingAdmin: BillingAdmin,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package roles
|
||||
|
||||
import (
|
||||
"github.com/netbirdio/netbird/management/server/permissions/modules"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/operations"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
// UsageViewer is the regular User baseline plus read access to the
|
||||
// aggregated Agent Network usage and cost overview, and read-only access
|
||||
// to the resources the usage filters and display columns resolve against:
|
||||
// users and groups (identity filters and name resolution), peers (agent
|
||||
// principals in the caller column), and the provider list (provider and
|
||||
// model filter options — the manager redacts connection config such as
|
||||
// upstream URLs and operator-supplied header values for callers holding
|
||||
// read without update). It sees no policies and no account-wide
|
||||
// request-level access logs (which can contain captured prompts); its own
|
||||
// requests remain readable through the self-scoped endpoints, like any
|
||||
// caller's.
|
||||
var UsageViewer = RolePermissions{
|
||||
Role: types.UserRoleUsageViewer,
|
||||
AutoAllowNew: map[operations.Operation]bool{
|
||||
operations.Read: false,
|
||||
operations.Create: false,
|
||||
operations.Update: false,
|
||||
operations.Delete: false,
|
||||
},
|
||||
Permissions: Permissions{
|
||||
modules.AgentNetworkUsage: {
|
||||
operations.Read: true,
|
||||
operations.Create: false,
|
||||
operations.Update: false,
|
||||
operations.Delete: false,
|
||||
},
|
||||
modules.AgentNetworkProviders: {
|
||||
operations.Read: true,
|
||||
operations.Create: false,
|
||||
operations.Update: false,
|
||||
operations.Delete: false,
|
||||
},
|
||||
modules.Users: {
|
||||
operations.Read: true,
|
||||
operations.Create: false,
|
||||
operations.Update: false,
|
||||
operations.Delete: false,
|
||||
},
|
||||
modules.Groups: {
|
||||
operations.Read: true,
|
||||
operations.Create: false,
|
||||
operations.Update: false,
|
||||
operations.Delete: false,
|
||||
},
|
||||
modules.Peers: {
|
||||
operations.Read: true,
|
||||
operations.Create: false,
|
||||
operations.Update: false,
|
||||
operations.Delete: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -573,10 +573,10 @@ func TestSqlStore_SavePeer(t *testing.T) {
|
||||
|
||||
numOfFields, err := populateFields.PopulateAll(reflectedMetadata)
|
||||
assert.NoError(t, err)
|
||||
// 33 rather than upstream's 32: Flags carries ServerVNCAllowed here. Flags
|
||||
// round-trips as the meta_flags blob on both the gorm and pgx paths, so a
|
||||
// new flag needs no query change.
|
||||
assert.Equal(t, 33, numOfFields)
|
||||
// The count includes nested struct fields, so every flag added to Flags
|
||||
// moves it. Flags round-trips as the meta_flags blob on both the gorm and
|
||||
// pgx paths, so a new flag needs no query change.
|
||||
assert.Equal(t, 34, numOfFields)
|
||||
|
||||
// save status of non-existing peer
|
||||
peer := &nbpeer.Peer{
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"github.com/miekg/dns"
|
||||
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
proxydomain "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/zones"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/zones/records"
|
||||
@@ -114,6 +115,7 @@ func (a *Account) toNetworkMapData(
|
||||
nmd.AppliedZoneCandidates = buildAppliedZoneCandidates(accountZones)
|
||||
nmd.PrivateServiceCandidates = a.buildPrivateServiceCandidates()
|
||||
nmd.Services = TwinServices(a.Services)
|
||||
nmd.Domains = twinProxyDomains(a.Domains)
|
||||
|
||||
return nmd
|
||||
}
|
||||
@@ -153,6 +155,7 @@ func TwinServices(services []*service.Service) []*nmdata.Service {
|
||||
Enabled: svc.Enabled,
|
||||
Private: svc.Private,
|
||||
Mode: svc.Mode,
|
||||
Domain: svc.Domain,
|
||||
ProxyCluster: svc.ProxyCluster,
|
||||
AccessGroups: svc.AccessGroups,
|
||||
Targets: targets,
|
||||
@@ -185,6 +188,7 @@ func twinPeer(p *nbpeer.Peer) *nmdata.Peer {
|
||||
IP: p.IP,
|
||||
IPv6: p.IPv6,
|
||||
RequiresApproval: p.Status != nil && p.Status.RequiresApproval,
|
||||
Connected: p.Status != nil && p.Status.Connected,
|
||||
ExtraDNSLabels: p.ExtraDNSLabels,
|
||||
ProxyMeta: nmdata.ProxyMeta{Embedded: p.ProxyMeta.Embedded, Cluster: p.ProxyMeta.Cluster},
|
||||
Meta: nmdata.PeerSystemMeta{
|
||||
@@ -623,3 +627,19 @@ func TwinCustomZone(z nbdns.CustomZone) nmdata.CustomZone {
|
||||
NonAuthoritative: z.NonAuthoritative,
|
||||
}
|
||||
}
|
||||
|
||||
// twinProxyDomains converts the account's registered reverse-proxy domains to
|
||||
// their slim twins, so private-service zone apex resolution runs on the twin.
|
||||
func twinProxyDomains(domains []*proxydomain.Domain) []nmdata.ProxyDomain {
|
||||
if len(domains) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]nmdata.ProxyDomain, 0, len(domains))
|
||||
for _, d := range domains {
|
||||
if d == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, nmdata.ProxyDomain{Domain: d.Domain, TargetCluster: d.TargetCluster})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ package types
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/netbirdio/netbird/client/anonymize"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
@@ -150,6 +152,21 @@ func validateAndBuildBundleParams(req api.WorkloadRequest, workload *Workload) e
|
||||
if bundle.Parameters.LogFileCount < 1 || bundle.Parameters.LogFileCount > 1000 {
|
||||
return fmt.Errorf("log-file-count must be between 1 and 1000, got %d", bundle.Parameters.LogFileCount)
|
||||
}
|
||||
// validate anonymize_level: omitted or empty defaults on the client;
|
||||
// otherwise it must name a known level. An unknown value is rejected here
|
||||
// rather than silently escalated, so a typo surfaces at job creation. The
|
||||
// normalized (trimmed, lowercased) value is persisted so it matches what
|
||||
// the client parses — the client only lowercases, so a stored " default "
|
||||
// would otherwise resolve to strict.
|
||||
if lvl := bundle.Parameters.AnonymizeLevel; lvl != nil {
|
||||
normalized := strings.ToLower(strings.TrimSpace(*lvl))
|
||||
switch normalized {
|
||||
case "", anonymize.LevelDefaultString, anonymize.LevelStrictString:
|
||||
default:
|
||||
return fmt.Errorf("anonymize_level must be %q or %q, got %q", anonymize.LevelDefaultString, anonymize.LevelStrictString, *lvl)
|
||||
}
|
||||
bundle.Parameters.AnonymizeLevel = &normalized
|
||||
}
|
||||
|
||||
workload.Parameters, err = json.Marshal(bundle.Parameters)
|
||||
if err != nil {
|
||||
@@ -209,6 +226,17 @@ func (j *Job) ToStreamJobRequest() (*proto.JobRequest, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// derefString returns the pointed-to string, or "" when the pointer is nil.
|
||||
// The bundle parameters carry anonymize_level and upload_url as optional
|
||||
// fields; an absent value maps to the empty proto string, which the client
|
||||
// resolves to its default.
|
||||
func derefString(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
func (j *Job) buildStreamBundleResponse() (*proto.JobRequest, error) {
|
||||
var p api.BundleParameters
|
||||
if err := json.Unmarshal(j.Workload.Parameters, &p); err != nil {
|
||||
@@ -218,10 +246,12 @@ func (j *Job) buildStreamBundleResponse() (*proto.JobRequest, error) {
|
||||
ID: []byte(j.ID),
|
||||
WorkloadParameters: &proto.JobRequest_Bundle{
|
||||
Bundle: &proto.BundleParameters{
|
||||
BundleFor: p.BundleFor,
|
||||
BundleForTime: int64(p.BundleForTime),
|
||||
LogFileCount: int32(p.LogFileCount),
|
||||
Anonymize: p.Anonymize,
|
||||
BundleFor: p.BundleFor,
|
||||
BundleForTime: int64(p.BundleForTime),
|
||||
LogFileCount: int32(p.LogFileCount),
|
||||
Anonymize: p.Anonymize,
|
||||
AnonymizeLevel: derefString(p.AnonymizeLevel),
|
||||
UploadUrl: derefString(p.UploadUrl),
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
|
||||
// bundleJobFromParams builds a bundle Job whose stored workload parameters are
|
||||
// the marshalled REST BundleParameters, mirroring what NewJob persists.
|
||||
func bundleJobFromParams(t *testing.T, p api.BundleParameters) *Job {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(p)
|
||||
require.NoError(t, err, "marshal bundle parameters")
|
||||
return &Job{
|
||||
ID: "job-1",
|
||||
Workload: Workload{
|
||||
Type: JobTypeBundle,
|
||||
Parameters: raw,
|
||||
Result: []byte("{}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildStreamBundleResponse_CarriesIdentityAndUploadFields verifies the
|
||||
// anonymize_level and upload_url REST fields are mapped onto the proto request
|
||||
// the client receives.
|
||||
func TestBuildStreamBundleResponse_CarriesIdentityAndUploadFields(t *testing.T) {
|
||||
job := bundleJobFromParams(t, api.BundleParameters{
|
||||
BundleFor: true,
|
||||
BundleForTime: 2,
|
||||
LogFileCount: 100,
|
||||
Anonymize: true,
|
||||
AnonymizeLevel: strPtr("strict"),
|
||||
UploadUrl: strPtr("https://upload.example.com"),
|
||||
})
|
||||
|
||||
req, err := job.ToStreamJobRequest()
|
||||
require.NoError(t, err, "ToStreamJobRequest must succeed")
|
||||
|
||||
bundle := req.GetBundle()
|
||||
require.NotNil(t, bundle, "the request must carry bundle parameters")
|
||||
assert.Equal(t, "strict", bundle.GetAnonymizeLevel(), "anonymize_level must reach the client")
|
||||
assert.Equal(t, "https://upload.example.com", bundle.GetUploadUrl(), "upload_url must reach the client")
|
||||
assert.True(t, bundle.GetAnonymize(), "existing fields must still map")
|
||||
assert.Equal(t, int32(100), bundle.GetLogFileCount(), "existing fields must still map")
|
||||
}
|
||||
|
||||
// newBundleJobRequest builds an api.JobRequest carrying a bundle workload with
|
||||
// the given parameters, mirroring what the REST handler decodes.
|
||||
func newBundleJobRequest(t *testing.T, p api.BundleParameters) *api.JobRequest {
|
||||
t.Helper()
|
||||
var wr api.WorkloadRequest
|
||||
require.NoError(t, wr.FromBundleWorkloadRequest(api.BundleWorkloadRequest{
|
||||
Type: api.WorkloadTypeBundle,
|
||||
Parameters: p,
|
||||
}), "build bundle workload request")
|
||||
return &api.JobRequest{Workload: wr}
|
||||
}
|
||||
|
||||
// TestNewJob_AnonymizeLevelValidation verifies the management API accepts only
|
||||
// known anonymization levels (empty defaults on the client) and rejects an
|
||||
// unknown value instead of silently escalating it.
|
||||
func TestNewJob_AnonymizeLevelValidation(t *testing.T) {
|
||||
base := api.BundleParameters{BundleFor: false, LogFileCount: 100, Anonymize: true}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
level *string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "omitted", level: nil},
|
||||
{name: "empty", level: strPtr("")},
|
||||
{name: "default", level: strPtr("default")},
|
||||
{name: "strict", level: strPtr("strict")},
|
||||
{name: "mixed case", level: strPtr("Strict")},
|
||||
{name: "padded", level: strPtr(" default ")},
|
||||
{name: "unknown", level: strPtr("verbose"), wantErr: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p := base
|
||||
p.AnonymizeLevel = tc.level
|
||||
_, err := NewJob("user-1", "acc-1", "peer-1", newBundleJobRequest(t, p))
|
||||
if tc.wantErr {
|
||||
require.Error(t, err, "an unknown anonymize_level must be rejected")
|
||||
assert.Contains(t, err.Error(), "anonymize_level", "the error must name the offending field")
|
||||
return
|
||||
}
|
||||
require.NoError(t, err, "a known anonymize_level must be accepted")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewJob_AnonymizeLevelNormalized verifies an accepted level is persisted
|
||||
// trimmed and lowercased, so it reaches the client as a value the client's
|
||||
// lowercase-only parser resolves correctly rather than escalating to strict.
|
||||
func TestNewJob_AnonymizeLevelNormalized(t *testing.T) {
|
||||
job, err := NewJob("user-1", "acc-1", "peer-1", newBundleJobRequest(t, api.BundleParameters{
|
||||
BundleFor: false,
|
||||
LogFileCount: 100,
|
||||
Anonymize: true,
|
||||
AnonymizeLevel: strPtr(" Default "),
|
||||
}))
|
||||
require.NoError(t, err, "a padded known level must be accepted")
|
||||
|
||||
req, err := job.ToStreamJobRequest()
|
||||
require.NoError(t, err, "ToStreamJobRequest must succeed")
|
||||
assert.Equal(t, "default", req.GetBundle().GetAnonymizeLevel(),
|
||||
"the persisted level must be normalized so the client does not resolve it to strict")
|
||||
}
|
||||
|
||||
// TestBuildStreamBundleResponse_OmittedFieldsMapToEmpty verifies that omitted
|
||||
// optional fields map to the empty proto string, which the client resolves to
|
||||
// its defaults (default anonymization level, default upload server).
|
||||
func TestBuildStreamBundleResponse_OmittedFieldsMapToEmpty(t *testing.T) {
|
||||
job := bundleJobFromParams(t, api.BundleParameters{
|
||||
BundleFor: false,
|
||||
BundleForTime: 1,
|
||||
LogFileCount: 50,
|
||||
Anonymize: false,
|
||||
// AnonymizeLevel and UploadUrl intentionally nil.
|
||||
})
|
||||
|
||||
req, err := job.ToStreamJobRequest()
|
||||
require.NoError(t, err, "ToStreamJobRequest must succeed")
|
||||
|
||||
bundle := req.GetBundle()
|
||||
require.NotNil(t, bundle, "the request must carry bundle parameters")
|
||||
assert.Empty(t, bundle.GetAnonymizeLevel(), "an omitted anonymize_level must map to empty so the client defaults it")
|
||||
assert.Empty(t, bundle.GetUploadUrl(), "an omitted upload_url must map to empty so the client defaults it")
|
||||
}
|
||||
@@ -1,18 +1,18 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/c-robinson/iplib"
|
||||
"github.com/rs/xid"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/util"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
@@ -47,14 +47,12 @@ func NewNetwork() *Network {
|
||||
n := iplib.NewNet4(net.ParseIP("100.64.0.0"), NetSize)
|
||||
sub, _ := n.Subnet(SubnetSize)
|
||||
|
||||
s := rand.NewSource(time.Now().UnixNano())
|
||||
r := rand.New(s)
|
||||
intn := r.Intn(len(sub))
|
||||
intn := util.RandIntn(len(sub))
|
||||
|
||||
return &Network{
|
||||
Identifier: xid.New().String(),
|
||||
Net: sub[intn].IPNet,
|
||||
NetV6: AllocateIPv6Subnet(r),
|
||||
NetV6: AllocateIPv6Subnet(),
|
||||
Dns: "",
|
||||
Serial: 0,
|
||||
}
|
||||
@@ -64,18 +62,13 @@ func NewNetwork() *Network {
|
||||
// The format follows RFC 4193 section 3.1: fd + 40-bit Global ID + 16-bit Subnet ID.
|
||||
// The Global ID and Subnet ID are randomized (simplified from the SHA-1 algorithm
|
||||
// in section 3.2.2), giving 2^56 possible /64 subnets across all accounts.
|
||||
func AllocateIPv6Subnet(r *rand.Rand) net.IPNet {
|
||||
func AllocateIPv6Subnet() net.IPNet {
|
||||
ip := make(net.IP, 16)
|
||||
ip[0] = 0xfd
|
||||
// Bytes 1-5: 40-bit random Global ID
|
||||
ip[1] = byte(r.Intn(256))
|
||||
ip[2] = byte(r.Intn(256))
|
||||
ip[3] = byte(r.Intn(256))
|
||||
ip[4] = byte(r.Intn(256))
|
||||
ip[5] = byte(r.Intn(256))
|
||||
// Bytes 6-7: 16-bit random Subnet ID
|
||||
ip[6] = byte(r.Intn(256))
|
||||
ip[7] = byte(r.Intn(256))
|
||||
// Bytes 1-5: 40-bit random Global ID, bytes 6-7: 16-bit random Subnet ID
|
||||
if _, err := rand.Read(ip[1:8]); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return net.IPNet{
|
||||
IP: ip,
|
||||
@@ -109,10 +102,22 @@ func (n *Network) Copy() *Network {
|
||||
}
|
||||
}
|
||||
|
||||
// validateIPv4Prefix ensures the prefix is an IPv4 network with assignable host addresses.
|
||||
func validateIPv4Prefix(prefix netip.Prefix) error {
|
||||
if !prefix.IsValid() || !prefix.Addr().Is4() || prefix.Bits() < 1 || prefix.Bits() >= 31 {
|
||||
return fmt.Errorf("invalid IPv4 subnet: %s", prefix.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AllocatePeerIP picks an available IP from a netip.Prefix.
|
||||
// This method considers already taken IPs and reuses IPs if there are gaps in takenIps.
|
||||
// E.g. if prefix=100.30.0.0/16 and takenIps=[100.30.0.1, 100.30.0.4] then the result would be 100.30.0.2 or 100.30.0.3.
|
||||
func AllocatePeerIP(prefix netip.Prefix, takenIps []netip.Addr) (netip.Addr, error) {
|
||||
if err := validateIPv4Prefix(prefix); err != nil {
|
||||
return netip.Addr{}, err
|
||||
}
|
||||
|
||||
b := prefix.Masked().Addr().As4()
|
||||
baseIP := binary.BigEndian.Uint32(b[:])
|
||||
hostBits := 32 - prefix.Bits()
|
||||
@@ -123,15 +128,17 @@ func AllocatePeerIP(prefix netip.Prefix, takenIps []netip.Addr) (netip.Addr, err
|
||||
taken[baseIP+totalIPs-1] = struct{}{} // reserve broadcast IP
|
||||
|
||||
for _, ip := range takenIps {
|
||||
if !ip.Is4() {
|
||||
continue
|
||||
}
|
||||
ab := ip.As4()
|
||||
taken[binary.BigEndian.Uint32(ab[:])] = struct{}{}
|
||||
}
|
||||
|
||||
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
maxAttempts := (int(totalIPs) - len(taken)) / 100
|
||||
|
||||
for i := 0; i < maxAttempts; i++ {
|
||||
offset := uint32(rng.Intn(int(totalIPs-2))) + 1
|
||||
offset := uint32(util.RandIntn(int(totalIPs-2))) + 1
|
||||
candidate := baseIP + offset
|
||||
if _, exists := taken[candidate]; !exists {
|
||||
return uint32ToIP(candidate), nil
|
||||
@@ -150,13 +157,16 @@ func AllocatePeerIP(prefix netip.Prefix, takenIps []netip.Addr) (netip.Addr, err
|
||||
|
||||
// AllocateRandomPeerIP picks a random available IP from a netip.Prefix.
|
||||
func AllocateRandomPeerIP(prefix netip.Prefix) (netip.Addr, error) {
|
||||
if err := validateIPv4Prefix(prefix); err != nil {
|
||||
return netip.Addr{}, err
|
||||
}
|
||||
|
||||
b := prefix.Masked().Addr().As4()
|
||||
baseIP := binary.BigEndian.Uint32(b[:])
|
||||
hostBits := 32 - prefix.Bits()
|
||||
totalIPs := uint32(1 << hostBits)
|
||||
|
||||
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
offset := uint32(rng.Intn(int(totalIPs-2))) + 1
|
||||
offset := uint32(util.RandIntn(int(totalIPs-2))) + 1
|
||||
|
||||
candidate := baseIP + offset
|
||||
return uint32ToIP(candidate), nil
|
||||
@@ -172,23 +182,26 @@ func AllocateRandomPeerIPv6(prefix netip.Prefix) (netip.Addr, error) {
|
||||
|
||||
ip := prefix.Addr().As16()
|
||||
|
||||
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
|
||||
// Determine which byte the host bits start in
|
||||
firstHostByte := ones / 8
|
||||
// If the prefix doesn't end on a byte boundary, handle the partial byte
|
||||
partialBits := ones % 8
|
||||
|
||||
var rnd [16]byte
|
||||
if _, err := rand.Read(rnd[firstHostByte:]); err != nil {
|
||||
return netip.Addr{}, err
|
||||
}
|
||||
|
||||
if partialBits > 0 {
|
||||
// Keep the network bits in the partial byte, randomize the rest
|
||||
hostMask := byte(0xff >> partialBits)
|
||||
ip[firstHostByte] = (ip[firstHostByte] & ^hostMask) | (byte(rng.Intn(256)) & hostMask)
|
||||
ip[firstHostByte] = (ip[firstHostByte] & ^hostMask) | (rnd[firstHostByte] & hostMask)
|
||||
firstHostByte++
|
||||
}
|
||||
|
||||
// Randomize remaining full host bytes
|
||||
for i := firstHostByte; i < 16; i++ {
|
||||
ip[i] = byte(rng.Intn(256))
|
||||
ip[i] = rnd[i]
|
||||
}
|
||||
|
||||
// Avoid all-zeros and all-ones host parts by checking only host bits.
|
||||
|
||||
@@ -143,6 +143,34 @@ func TestAllocatePeerIPVariousCIDRs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllocateIPv4InvalidPrefixes(t *testing.T) {
|
||||
prefixes := []netip.Prefix{
|
||||
{},
|
||||
netip.MustParsePrefix("0.0.0.0/0"),
|
||||
netip.MustParsePrefix("192.168.1.0/31"),
|
||||
netip.MustParsePrefix("192.168.1.1/32"),
|
||||
netip.MustParsePrefix("fd12:3456:7890:abcd::/64"),
|
||||
}
|
||||
|
||||
for _, prefix := range prefixes {
|
||||
t.Run(prefix.String(), func(t *testing.T) {
|
||||
_, err := AllocatePeerIP(prefix, nil)
|
||||
assert.Error(t, err)
|
||||
|
||||
_, err = AllocateRandomPeerIP(prefix)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllocatePeerIPIgnoresNonIPv4TakenIPs(t *testing.T) {
|
||||
prefix := netip.MustParsePrefix("192.168.1.0/29")
|
||||
|
||||
ip, err := AllocatePeerIP(prefix, []netip.Addr{netip.MustParseAddr("fd12:3456:7890:abcd::1")})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, prefix.Contains(ip))
|
||||
}
|
||||
|
||||
func TestGenerateIPs(t *testing.T) {
|
||||
ipNet := net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.IPMask{255, 255, 255, 0}}
|
||||
ips, ipsLen := generateIPs(&ipNet, map[string]struct{}{"100.64.0.0": {}})
|
||||
|
||||
@@ -11,13 +11,15 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
UserRoleOwner UserRole = "owner"
|
||||
UserRoleAdmin UserRole = "admin"
|
||||
UserRoleUser UserRole = "user"
|
||||
UserRoleUnknown UserRole = "unknown"
|
||||
UserRoleBillingAdmin UserRole = "billing_admin"
|
||||
UserRoleAuditor UserRole = "auditor"
|
||||
UserRoleNetworkAdmin UserRole = "network_admin"
|
||||
UserRoleOwner UserRole = "owner"
|
||||
UserRoleAdmin UserRole = "admin"
|
||||
UserRoleUser UserRole = "user"
|
||||
UserRoleUnknown UserRole = "unknown"
|
||||
UserRoleBillingAdmin UserRole = "billing_admin"
|
||||
UserRoleAuditor UserRole = "auditor"
|
||||
UserRoleNetworkAdmin UserRole = "network_admin"
|
||||
UserRoleAgentNetworkAdmin UserRole = "agent_network_admin"
|
||||
UserRoleUsageViewer UserRole = "usage_viewer"
|
||||
|
||||
UserStatusActive UserStatus = "active"
|
||||
UserStatusDisabled UserStatus = "disabled"
|
||||
@@ -42,6 +44,10 @@ func StrRoleToUserRole(strRole string) UserRole {
|
||||
return UserRoleAuditor
|
||||
case "network_admin":
|
||||
return UserRoleNetworkAdmin
|
||||
case "agent_network_admin":
|
||||
return UserRoleAgentNetworkAdmin
|
||||
case "usage_viewer":
|
||||
return UserRoleUsageViewer
|
||||
default:
|
||||
return UserRoleUnknown
|
||||
}
|
||||
@@ -140,7 +146,7 @@ func (u *User) IsRegularUser() bool {
|
||||
|
||||
// IsRestrictable checks whether a user is in a restrictable role.
|
||||
func (u *User) IsRestrictable() bool {
|
||||
return u.Role == UserRoleUser || u.Role == UserRoleBillingAdmin
|
||||
return u.Role == UserRoleUser || u.Role == UserRoleBillingAdmin || u.Role == UserRoleUsageViewer
|
||||
}
|
||||
|
||||
// ToUserInfo converts a User object to a UserInfo object.
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"math/big"
|
||||
)
|
||||
|
||||
// RandIntn returns a uniformly distributed int in [0, n) sourced from
|
||||
// crypto/rand. It panics if n <= 0 or the platform randomness source fails.
|
||||
func RandIntn(n int) int {
|
||||
v, err := rand.Int(rand.Reader, big.NewInt(int64(n)))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return int(v.Int64())
|
||||
}
|
||||
|
||||
// Difference returns the elements in `a` that aren't in `b`.
|
||||
func Difference(a, b []string) []string {
|
||||
mb := make(map[string]struct{}, len(b))
|
||||
|
||||
Reference in New Issue
Block a user