mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-13 02:09:08 +02:00
An agent network bootstrap stores the cluster it pins to as proxy_address, and that value selects the proxy that serves the endpoint. An account-scoped proxy only ever receives its own account's mappings, so a pin onto a host another account's proxy declares can never be served — and the endpoint it assigns is immutable, so the account is left with a dead gateway until it deletes its settings and starts over. Nothing refused that pin: the domain unique index arbitrates between pins only, and knows nothing about proxies. Both bootstrap paths now ask, before the insert, whether a proxy owned by a different account declares the host. Shared proxies are not foreign — a shared cluster is what most accounts pin to, and any number of them may — and a host no proxy has declared stays pinnable, since claiming the address before the proxy's first connection is the documented order. Ownership is decided on the proxy rows, not on heartbeat freshness, and on the folded spelling, since proxies declare their address as the operator typed it. Registration is deliberately not changed: refusing a proxy because another account pinned its host would let a pin lock a tenant's proxy out once the stale-proxy reaper has dropped its rows. Left as is, the worst a race or a reaping window can produce is a dead pin for the account that made it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sa3DsBDP3VciAi4PPG17L6
390 lines
18 KiB
Go
390 lines
18 KiB
Go
package agentnetwork
|
|
|
|
import (
|
|
"context"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/mock/gomock"
|
|
|
|
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
|
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
|
"github.com/netbirdio/netbird/management/server/account"
|
|
"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"
|
|
)
|
|
|
|
// bootstrapFixture wires a real sqlite store to a gomock permissions manager
|
|
// so tests can grant or deny the settings permission per case.
|
|
type bootstrapFixture struct {
|
|
manager Manager
|
|
store store.Store
|
|
perms *permissions.MockManager
|
|
// vendor stands in for the provider credential check's vendor call, which
|
|
// runs on every provider write. Without it these tests would reach a real
|
|
// vendor to save a record.
|
|
vendor *stubLister
|
|
}
|
|
|
|
func newBootstrapFixture(t *testing.T) *bootstrapFixture {
|
|
t.Helper()
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("sqlite store not properly supported on Windows yet")
|
|
}
|
|
t.Setenv("NETBIRD_STORE_ENGINE", string(nbtypes.SqliteStoreEngine))
|
|
|
|
st, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir())
|
|
require.NoError(t, err, "test store setup must succeed")
|
|
t.Cleanup(cleanUp)
|
|
|
|
ctrl := gomock.NewController(t)
|
|
perms := permissions.NewMockManager(ctrl)
|
|
|
|
accounts := account.NewMockManager(ctrl)
|
|
accounts.EXPECT().StoreEvent(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
|
|
accounts.EXPECT().UpdateAccountPeers(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
|
|
accounts.EXPECT().BufferUpdateAccountPeers(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
|
|
|
|
vendor := &stubLister{}
|
|
return &bootstrapFixture{
|
|
manager: NewManager(st, perms, accounts, nil, WithModelLister(vendor)),
|
|
store: st,
|
|
perms: perms,
|
|
vendor: vendor,
|
|
}
|
|
}
|
|
|
|
func (f *bootstrapFixture) expectPermission(accountID, userID string, module modules.Module, op operations.Operation, allowed bool) {
|
|
f.perms.EXPECT().
|
|
ValidateUserPermissions(gomock.Any(), accountID, userID, module, op).
|
|
Return(allowed, context.Background(), nil)
|
|
}
|
|
|
|
func (f *bootstrapFixture) createSettings(ctx context.Context, accountID, userID, proxyAddress, endpoint string) (*types.Settings, error) {
|
|
return f.manager.CreateSettings(ctx, userID, types.DefaultSettings(accountID), proxyAddress, endpoint)
|
|
}
|
|
|
|
func ptrTo[T any](v T) *T { return &v }
|
|
|
|
// seedProxy registers a proxy in clusterAddr, heartbeating now, so the labeled
|
|
// bootstrap path has a real cluster to validate against. accountID empty makes
|
|
// it a shared (NetBird-operated) cluster; private mirrors the capability an
|
|
// embedded `netbird proxy` reports, nil an unreported one.
|
|
func (f *bootstrapFixture) seedProxy(t *testing.T, proxyID, accountID, clusterAddr string, private *bool) {
|
|
t.Helper()
|
|
f.seedProxyAt(t, proxyID, accountID, clusterAddr, private, time.Now().UTC())
|
|
}
|
|
|
|
// seedProxyAt is seedProxy with an explicit last-seen, for cases that need a
|
|
// proxy whose heartbeat has aged past the active window while its row (and so
|
|
// its cluster) is still on record.
|
|
func (f *bootstrapFixture) seedProxyAt(t *testing.T, proxyID, accountID, clusterAddr string, private *bool, lastSeen time.Time) {
|
|
t.Helper()
|
|
p := &proxy.Proxy{
|
|
ID: proxyID,
|
|
ClusterAddress: clusterAddr,
|
|
Status: proxy.StatusConnected,
|
|
LastSeen: lastSeen,
|
|
Capabilities: proxy.Capabilities{Private: private},
|
|
}
|
|
if accountID != "" {
|
|
p.AccountID = &accountID
|
|
}
|
|
require.NoError(t, f.store.SaveProxy(context.Background(), p), "seeding a proxy must succeed")
|
|
}
|
|
|
|
// seedEmbeddedCluster is the common case: a shared cluster with a connected
|
|
// embedded proxy, which is what the labeled bootstrap requires.
|
|
func (f *bootstrapFixture) seedEmbeddedCluster(t *testing.T, clusterAddr string) {
|
|
t.Helper()
|
|
f.seedProxy(t, "proxy-"+clusterAddr, "", clusterAddr, ptrTo(true))
|
|
}
|
|
|
|
// TestCreateSettingsRequiresPermission pins the gate: bootstrap assigns the
|
|
// account's immutable endpoint, a settings write requiring the settings
|
|
// Create permission — and a denial leaves no row behind.
|
|
func TestCreateSettingsRequiresPermission(t *testing.T) {
|
|
ctx := context.Background()
|
|
f := newBootstrapFixture(t)
|
|
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, false)
|
|
|
|
_, err := f.createSettings(ctx, "account1", "user1", "cluster1.example.com", "")
|
|
require.Error(t, err, "bootstrap without the settings permission must fail")
|
|
var sErr *status.Error
|
|
require.ErrorAs(t, err, &sErr)
|
|
assert.Equal(t, status.PermissionDenied, sErr.Type(), "denial should surface as permission denied")
|
|
|
|
_, err = f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
|
assert.Error(t, err, "settings row must not be created when bootstrap is denied")
|
|
}
|
|
|
|
// TestCreateSettingsLabeled pins the labeled shape: the server allocates an
|
|
// adjective-noun label beneath the proxy address, the pin is not dedicated,
|
|
// and the domain records the full endpoint hostname.
|
|
func TestCreateSettingsLabeled(t *testing.T) {
|
|
ctx := context.Background()
|
|
f := newBootstrapFixture(t)
|
|
f.seedEmbeddedCluster(t, "cluster1.example.com")
|
|
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
|
|
|
created, err := f.createSettings(ctx, "account1", "user1", "Cluster1.Example.com", "")
|
|
require.NoError(t, err, "labeled bootstrap must succeed")
|
|
assert.Equal(t, "cluster1.example.com", created.ProxyAddress, "proxy address must be pinned lowercased")
|
|
require.True(t, strings.HasSuffix(created.Domain, ".cluster1.example.com"),
|
|
"domain must hang one label beneath the proxy address: %s", created.Domain)
|
|
label := strings.TrimSuffix(created.Domain, ".cluster1.example.com")
|
|
assert.NotContains(t, label, ".", "the allocated label must be a single DNS label: %s", label)
|
|
assert.False(t, created.Dedicated(), "a labeled pin is not dedicated")
|
|
assert.Equal(t, created.Domain, created.Endpoint(), "the endpoint is the domain column")
|
|
|
|
stored, err := f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
|
require.NoError(t, err, "bootstrap must persist the row")
|
|
assert.Equal(t, created.Domain, stored.Domain)
|
|
assert.Equal(t, created.ProxyAddress, stored.ProxyAddress)
|
|
}
|
|
|
|
// TestCreateSettingsSelfAddressed pins the dedicated shape: the endpoint is
|
|
// claimed verbatim (normalized), Domain == ProxyAddress, and the claim
|
|
// succeeds with no proxy declaring the address yet (address-first).
|
|
func TestCreateSettingsSelfAddressed(t *testing.T) {
|
|
ctx := context.Background()
|
|
f := newBootstrapFixture(t)
|
|
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
|
|
|
created, err := f.createSettings(ctx, "account1", "user1", "", "Brave-Otter.GW.Example.com")
|
|
require.NoError(t, err, "self-addressed bootstrap must succeed")
|
|
assert.Equal(t, "brave-otter.gw.example.com", created.Domain, "endpoint must be claimed lowercased")
|
|
assert.Equal(t, created.Domain, created.ProxyAddress, "self-addressed: proxy address is the endpoint")
|
|
assert.True(t, created.Dedicated(), "a self-addressed pin is dedicated")
|
|
}
|
|
|
|
// TestCreateSettingsIdentityFieldValidation pins the request contract: exactly
|
|
// one of proxyAddress and endpoint, and both must be well-formed hostnames.
|
|
func TestCreateSettingsIdentityFieldValidation(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
cases := map[string]struct {
|
|
proxyAddress string
|
|
endpoint string
|
|
}{
|
|
"neither": {"", ""},
|
|
"both": {"cluster1.example.com", "gw.example.com"},
|
|
"trailing dot endpoint": {"", "gw.example.com."},
|
|
"leading dot endpoint": {"", ".gw.example.com"},
|
|
"whitespace inside": {"", "g w.example.com"},
|
|
"empty label in parent": {"eu..example.com", ""},
|
|
"hyphen-edged label": {"", "-gw.example.com"},
|
|
}
|
|
for name, tc := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
f := newBootstrapFixture(t)
|
|
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
|
|
|
_, err := f.createSettings(ctx, "account1", "user1", tc.proxyAddress, tc.endpoint)
|
|
require.Error(t, err, "invalid identity input must be rejected")
|
|
var sErr *status.Error
|
|
require.ErrorAs(t, err, &sErr)
|
|
assert.Equal(t, status.InvalidArgument, sErr.Type(), "rejection must be a validation error")
|
|
|
|
_, err = f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
|
assert.Error(t, err, "no row may be left behind by a rejected bootstrap")
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCreateSettingsConflictsOnSecondBootstrap pins that bootstrap is a
|
|
// one-time create per account: a second call is a conflict, whatever shape it
|
|
// asks for, and the original row survives untouched.
|
|
func TestCreateSettingsConflictsOnSecondBootstrap(t *testing.T) {
|
|
ctx := context.Background()
|
|
f := newBootstrapFixture(t)
|
|
f.seedEmbeddedCluster(t, "cluster1.example.com")
|
|
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
|
|
|
first, err := f.createSettings(ctx, "account1", "user1", "cluster1.example.com", "")
|
|
require.NoError(t, err)
|
|
|
|
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
|
_, err = f.createSettings(ctx, "account1", "user1", "", "other.example.com")
|
|
require.Error(t, err, "second bootstrap must fail")
|
|
var sErr *status.Error
|
|
require.ErrorAs(t, err, &sErr)
|
|
assert.Equal(t, status.AlreadyExists, sErr.Type(), "second bootstrap must surface as a conflict")
|
|
|
|
stored, err := f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, first.Domain, stored.Domain, "the original endpoint must survive the rejected bootstrap")
|
|
}
|
|
|
|
// TestCreateSettingsEndpointTaken pins global hostname uniqueness: a hostname
|
|
// held by one account cannot be claimed by another, in either direction —
|
|
// self-addressed onto self-addressed, or self-addressed onto an allocated
|
|
// labeled endpoint.
|
|
func TestCreateSettingsEndpointTaken(t *testing.T) {
|
|
ctx := context.Background()
|
|
f := newBootstrapFixture(t)
|
|
|
|
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
|
first, err := f.createSettings(ctx, "account1", "user1", "", "gw.example.com")
|
|
require.NoError(t, err)
|
|
|
|
f.expectPermission("account2", "user2", modules.AgentNetworkSettings, operations.Create, true)
|
|
_, err = f.createSettings(ctx, "account2", "user2", "", "gw.example.com")
|
|
require.Error(t, err, "a taken hostname must be refused")
|
|
var sErr *status.Error
|
|
require.ErrorAs(t, err, &sErr)
|
|
assert.Equal(t, status.AlreadyExists, sErr.Type(), "the refusal must surface as a conflict")
|
|
|
|
f.expectPermission("account3", "user3", modules.AgentNetworkSettings, operations.Create, true)
|
|
_, err = f.createSettings(ctx, "account3", "user3", "", first.Domain)
|
|
require.Error(t, err, "claiming another account's endpoint must be refused")
|
|
}
|
|
|
|
// TestCreateProviderHasNoSettingsSideEffects pins the decoupling: provider
|
|
// create needs only the providers permission (gomock fails the test on any
|
|
// settings-permission call) and never creates a settings row.
|
|
func TestCreateProviderHasNoSettingsSideEffects(t *testing.T) {
|
|
ctx := context.Background()
|
|
f := newBootstrapFixture(t)
|
|
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
|
|
|
|
provider := types.NewProvider("account1")
|
|
provider.ProviderID = "openai_api"
|
|
provider.Name = "openai"
|
|
provider.UpstreamURL = "https://api.openai.com"
|
|
provider.APIKey = "sk-test"
|
|
provider.Enabled = true
|
|
|
|
created, err := f.manager.CreateProvider(ctx, "user1", provider)
|
|
require.NoError(t, err, "provider create must succeed on the providers permission alone")
|
|
require.NotNil(t, created)
|
|
|
|
_, err = f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
|
assert.Error(t, err, "provider create must not conjure a settings row")
|
|
}
|
|
|
|
// TestCreateSettingsAllowsUnknownCluster pins the one opening left: a cluster
|
|
// management holds no proxy row for cannot be judged, so the pin is allowed —
|
|
// the same order the dedicated path documents (claim the address, connect the
|
|
// proxy after).
|
|
func TestCreateSettingsAllowsUnknownCluster(t *testing.T) {
|
|
ctx := context.Background()
|
|
f := newBootstrapFixture(t)
|
|
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
|
|
|
created, err := f.createSettings(ctx, "account1", "user1", "future.example.com", "")
|
|
require.NoError(t, err, "a cluster no proxy has ever declared must stay pinnable")
|
|
assert.Equal(t, "future.example.com", created.ProxyAddress)
|
|
}
|
|
|
|
// TestCreateSettingsRejectsOfflineCluster is the guard against deciding on
|
|
// heartbeat freshness. A centralised cluster is refused while its proxies are
|
|
// live; the same cluster must stay refused once they stop heartbeating, which
|
|
// takes only a couple of minutes (proxyActiveThreshold). Judging on liveness
|
|
// would turn "wait for the proxy to go quiet" into a way to pin the account's
|
|
// immutable endpoint to a cluster that can never serve it.
|
|
func TestCreateSettingsRejectsOfflineCluster(t *testing.T) {
|
|
ctx := context.Background()
|
|
notPrivate := false
|
|
|
|
cases := map[string]*bool{
|
|
"centralised proxy gone quiet": ¬Private,
|
|
// A cluster that could serve the gateway still has to have something
|
|
// live in it to prove so at bootstrap: refusing is the safe direction
|
|
// (reconnect the proxy and retry) where accepting is permanent.
|
|
"embedded proxy gone quiet": ptrTo(true),
|
|
}
|
|
for name, private := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
f := newBootstrapFixture(t)
|
|
f.seedProxyAt(t, "proxy1", "", "offline.example.com", private,
|
|
time.Now().UTC().Add(-time.Hour))
|
|
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
|
|
|
_, err := f.createSettings(ctx, "account1", "user1", "offline.example.com", "")
|
|
require.Error(t, err, "a known cluster with nothing live in it must be rejected")
|
|
var sErr *status.Error
|
|
require.ErrorAs(t, err, &sErr)
|
|
assert.Equal(t, status.InvalidArgument, sErr.Type(), "rejection must be a validation error")
|
|
assert.Contains(t, err.Error(), "connected embedded proxy",
|
|
"the error must say a live embedded proxy is what is missing")
|
|
|
|
_, err = f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
|
assert.Error(t, err, "no row may be left behind by a rejected bootstrap")
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCreateSettingsRequiresPrivateCluster pins the capability gate: the
|
|
// synthesised gateway service is always private, so a live cluster whose
|
|
// proxies are not embedded in a netbird client cannot serve it and must not
|
|
// become the account's immutable endpoint.
|
|
func TestCreateSettingsRequiresPrivateCluster(t *testing.T) {
|
|
ctx := context.Background()
|
|
f := newBootstrapFixture(t)
|
|
notPrivate := false
|
|
f.seedProxy(t, "proxy1", "", "central.example.com", ¬Private)
|
|
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
|
|
|
_, err := f.createSettings(ctx, "account1", "user1", "central.example.com", "")
|
|
require.Error(t, err, "a cluster without an embedded proxy must be rejected")
|
|
var sErr *status.Error
|
|
require.ErrorAs(t, err, &sErr)
|
|
assert.Equal(t, status.InvalidArgument, sErr.Type(), "rejection must be a validation error")
|
|
assert.Contains(t, err.Error(), "embedded proxy", "the error must name what the cluster is missing")
|
|
|
|
_, err = f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
|
assert.Error(t, err, "no row may be left behind by a rejected bootstrap")
|
|
}
|
|
|
|
// TestCreateSettingsAcceptsOwnPrivateCluster pins the BYOP happy path: the
|
|
// account's own cluster with a connected embedded proxy is a valid pin.
|
|
func TestCreateSettingsAcceptsOwnPrivateCluster(t *testing.T) {
|
|
ctx := context.Background()
|
|
f := newBootstrapFixture(t)
|
|
f.seedProxy(t, "proxy1", "account1", "byop.account1.example.com", ptrTo(true))
|
|
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
|
|
|
created, err := f.createSettings(ctx, "account1", "user1", "byop.account1.example.com", "")
|
|
require.NoError(t, err, "the account's own private cluster must be accepted")
|
|
assert.Equal(t, "byop.account1.example.com", created.ProxyAddress)
|
|
}
|
|
|
|
// TestCreateSettingsMatchesClusterCasing pins that a cluster spelled with
|
|
// capitals in the store is still recognised as the same cluster the normalised
|
|
// proxy_address names, in both directions: a private cluster is accepted and a
|
|
// centralised one is refused, whatever the casing. The comparison is in memory
|
|
// over the account's cluster list; the capability lookup is still asked under
|
|
// the spelling the store actually holds, which is what an exact match needs.
|
|
func TestCreateSettingsMatchesClusterCasing(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
t.Run("own private cluster is found", func(t *testing.T) {
|
|
f := newBootstrapFixture(t)
|
|
f.seedProxy(t, "proxy1", "", "EU.Proxy.Example.com", ptrTo(true))
|
|
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
|
|
|
created, err := f.createSettings(ctx, "account1", "user1", "eu.proxy.example.com", "")
|
|
require.NoError(t, err, "a private cluster declared with capitals must still be accepted")
|
|
assert.Equal(t, "eu.proxy.example.com", created.ProxyAddress)
|
|
})
|
|
|
|
t.Run("non-private cluster is still refused", func(t *testing.T) {
|
|
f := newBootstrapFixture(t)
|
|
f.seedProxy(t, "proxy1", "", "Central.Example.com", ptrTo(false))
|
|
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
|
|
|
_, err := f.createSettings(ctx, "account1", "user1", "central.example.com", "")
|
|
require.Error(t, err, "casing must not become a way past the capability check")
|
|
assert.Contains(t, err.Error(), "embedded proxy")
|
|
})
|
|
}
|