mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-12 17:59:06 +02:00
A self-addressed bootstrap stores the hostname as proxy_address, which is exactly what a proxy registration is refused on when another account holds it there. It asked nobody whether it could: the domain unique index arbitrated between pins, and a foreign proxy already declaring the host was never consulted, before or after the insert. Any account could therefore pin an endpoint onto a host another account's proxy serves — owning nothing — and lock that proxy out on its next reconnect, and a proxy racing such a pin could end with both claims standing, since only the labeled path re-read ownership after its write. The self-addressed path now asks HasForeignAccountProxyAtHost before the insert and confirmGatewayClusterOwnership after it, the same as the labeled one. Address-first stays intact: only a row owned by a different account refuses, so pinning ahead of any proxy, or onto the account's own, is unchanged. Also pins the bootstrap side's failure paths — an ownership re-read that cannot answer leaves no pin behind and surfaces the store's error, and a withdrawal that fails still reports the claim as lost — and shortens the helper's comment to point at the shared argument on proxy.ErrClusterAddressUnavailable rather than restate it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sa3DsBDP3VciAi4PPG17L6
690 lines
32 KiB
Go
690 lines
32 KiB
Go
package agentnetwork
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"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()
|
|
return newBootstrapFixtureWith(t, func(st store.Store) store.Store { return st })
|
|
}
|
|
|
|
// newBootstrapFixtureWith hands the manager the real store as seen through
|
|
// wrap, while the fixture keeps the unwrapped store for seeding and
|
|
// assertions. It exists for cases that need something to happen between two
|
|
// of the manager's store calls — a competing claim landing mid-bootstrap —
|
|
// which a real store cannot be made to do on cue.
|
|
func newBootstrapFixtureWith(t *testing.T, wrap func(store.Store) store.Store) *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(wrap(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")
|
|
}
|
|
|
|
// TestCreateSettingsRejectsForeignCluster pins tenant isolation on the pin: an
|
|
// account-owned (BYOP) cluster belongs to the account that runs it and is not
|
|
// one another account may hang its gateway beneath, even though it is
|
|
// private-capable. Ownership does not lapse with the heartbeat either, so the
|
|
// refusal holds while the foreign cluster is offline.
|
|
func TestCreateSettingsRejectsForeignCluster(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
cases := map[string]time.Time{
|
|
"live": time.Now().UTC(),
|
|
"offline": time.Now().UTC().Add(-time.Hour),
|
|
}
|
|
for name, lastSeen := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
f := newBootstrapFixture(t)
|
|
f.seedProxyAt(t, "proxy1", "account2", "byop.account2.example.com", ptrTo(true), lastSeen)
|
|
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
|
|
|
_, err := f.createSettings(ctx, "account1", "user1", "byop.account2.example.com", "")
|
|
require.Error(t, err, "another account's BYOP cluster 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(), "not available to this account",
|
|
"the error must say the cluster is not the account's to use")
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCreateSettingsRejectsHostAnotherAccountClaims pins that ownership is
|
|
// decided before the account's own view, not after it.
|
|
//
|
|
// Two accounts holding rows for one hostname is the ambiguity the connect-time
|
|
// conflict check prevents going forward and cannot see for a row written
|
|
// before addresses were canonicalized. Deciding on the account's own view
|
|
// first would skip the ownership question exactly when the account has a row
|
|
// of its own — which is when a collision is worth catching — and the endpoint
|
|
// pinned here cannot be moved afterwards.
|
|
func TestCreateSettingsRejectsHostAnotherAccountClaims(t *testing.T) {
|
|
ctx := context.Background()
|
|
f := newBootstrapFixture(t)
|
|
// account1's own row is canonical and perfectly serviceable on its own.
|
|
f.seedProxy(t, "own", "account1", "shared.example.com", ptrTo(true))
|
|
// account2 holds a legacy, non-canonical spelling of the same host.
|
|
f.seedProxy(t, "foreign", "account2", "Shared.Example.com", ptrTo(true))
|
|
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
|
|
|
_, err := f.createSettings(ctx, "account1", "user1", "shared.example.com", "")
|
|
require.Error(t, err, "a host another account also claims must be refused")
|
|
var sErr *status.Error
|
|
require.ErrorAs(t, err, &sErr)
|
|
assert.Equal(t, status.InvalidArgument, sErr.Type())
|
|
assert.Contains(t, err.Error(), "not available to this account")
|
|
|
|
_, err = f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
|
assert.Error(t, err, "no row may be left behind by a rejected bootstrap")
|
|
}
|
|
|
|
// claimingStore is a store.Store on which another account's proxy registers
|
|
// at the host being pinned in the moment the settings row is written — the
|
|
// interleaving a concurrent proxy connect produces when it passes its own
|
|
// availability check before this bootstrap's row exists, so neither side's
|
|
// pre-write check sees the other.
|
|
type claimingStore struct {
|
|
store.Store
|
|
t *testing.T
|
|
claim *proxy.Proxy
|
|
claimed bool
|
|
}
|
|
|
|
func (s *claimingStore) CreateAgentNetworkSettings(ctx context.Context, settings *types.Settings) error {
|
|
if !s.claimed {
|
|
s.claimed = true
|
|
require.NoError(s.t, s.Store.SaveProxy(ctx, s.claim), "the competing claim must land")
|
|
}
|
|
return s.Store.CreateAgentNetworkSettings(ctx, settings)
|
|
}
|
|
|
|
// foreignClaim is the competing claim the race tests let land: another
|
|
// account's embedded proxy at host.
|
|
func foreignClaim(host string) *proxy.Proxy {
|
|
return &proxy.Proxy{
|
|
ID: "foreign",
|
|
ClusterAddress: host,
|
|
Status: proxy.StatusConnected,
|
|
LastSeen: time.Now().UTC(),
|
|
AccountID: ptrTo("account2"),
|
|
Capabilities: proxy.Capabilities{Private: ptrTo(true)},
|
|
}
|
|
}
|
|
|
|
// failingStore is a store.Store that fails a named call on its nth invocation,
|
|
// for the paths where the bootstrap's own bookkeeping cannot be completed:
|
|
// an ownership re-read that cannot answer, or a withdrawal that does not go
|
|
// through.
|
|
type failingStore struct {
|
|
store.Store
|
|
failOwnershipOn int
|
|
failDelete bool
|
|
ownershipCalls int
|
|
}
|
|
|
|
func (s *failingStore) HasForeignAccountProxyAtHost(ctx context.Context, host, accountID string) (bool, error) {
|
|
s.ownershipCalls++
|
|
if s.ownershipCalls == s.failOwnershipOn {
|
|
return false, errors.New("store unavailable")
|
|
}
|
|
return s.Store.HasForeignAccountProxyAtHost(ctx, host, accountID)
|
|
}
|
|
|
|
func (s *failingStore) DeleteAgentNetworkSettings(ctx context.Context, accountID string) error {
|
|
if s.failDelete {
|
|
return errors.New("delete failed")
|
|
}
|
|
return s.Store.DeleteAgentNetworkSettings(ctx, accountID)
|
|
}
|
|
|
|
// TestCreateSettingsWithdrawsPinClaimedDuringBootstrap covers the window
|
|
// between validateGatewayCluster and the insert: a foreign proxy that claims
|
|
// the host in that window is seen by the ownership re-read after the write,
|
|
// and the pin is withdrawn rather than left standing on a cluster that will
|
|
// never serve it. The refusal reads exactly as it would have had the
|
|
// pre-write check caught the claim.
|
|
func TestCreateSettingsWithdrawsPinClaimedDuringBootstrap(t *testing.T) {
|
|
ctx := context.Background()
|
|
const host = "shared.example.com"
|
|
|
|
f := newBootstrapFixtureWith(t, func(st store.Store) store.Store {
|
|
return &claimingStore{Store: st, t: t, claim: foreignClaim(host)}
|
|
})
|
|
// A shared embedded cluster, so the pre-write validation passes on its
|
|
// own merits and only the claim landing mid-bootstrap can refuse it.
|
|
f.seedEmbeddedCluster(t, host)
|
|
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
|
|
|
_, err := f.createSettings(ctx, "account1", "user1", host, "")
|
|
require.Error(t, err, "a host claimed by another account mid-bootstrap must be refused")
|
|
var sErr *status.Error
|
|
require.ErrorAs(t, err, &sErr)
|
|
assert.Equal(t, status.InvalidArgument, sErr.Type())
|
|
assert.Contains(t, err.Error(), "not available to this account")
|
|
|
|
_, err = f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
|
assert.Error(t, err, "the pin written before the claim was seen must be withdrawn")
|
|
|
|
foreign, err := f.store.HasForeignAccountProxyAtHost(ctx, host, "account1")
|
|
require.NoError(t, err)
|
|
assert.True(t, foreign, "the competing claim, having landed first, keeps the host")
|
|
}
|
|
|
|
// TestCreateSettingsSelfAddressedRejectsForeignHost pins that a self-addressed
|
|
// pin is subject to the same ownership rule as a labeled one. The hostname is
|
|
// stored as proxy_address, which is exactly what a proxy registration is
|
|
// refused on when another account holds it there — so without this check any
|
|
// account could pin an endpoint onto a host another account's proxy already
|
|
// declares and lock that proxy out on its next reconnect, owning nothing.
|
|
func TestCreateSettingsSelfAddressedRejectsForeignHost(t *testing.T) {
|
|
ctx := context.Background()
|
|
f := newBootstrapFixture(t)
|
|
f.seedProxy(t, "foreign", "account2", "gw.example.com", ptrTo(true))
|
|
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
|
|
|
_, err := f.createSettings(ctx, "account1", "user1", "", "gw.example.com")
|
|
require.Error(t, err, "a hostname another account's proxy declares must be refused")
|
|
var sErr *status.Error
|
|
require.ErrorAs(t, err, &sErr)
|
|
assert.Equal(t, status.InvalidArgument, sErr.Type())
|
|
assert.Contains(t, err.Error(), "not available to this account")
|
|
|
|
_, err = f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
|
assert.Error(t, err, "no row may be left behind by a rejected bootstrap")
|
|
}
|
|
|
|
// TestCreateSettingsSelfAddressedAcceptsOwnHost is the address-first order the
|
|
// self-addressed path exists for, in both directions: a hostname no proxy has
|
|
// declared, and one the account's own proxy already declares.
|
|
func TestCreateSettingsSelfAddressedAcceptsOwnHost(t *testing.T) {
|
|
ctx := context.Background()
|
|
f := newBootstrapFixture(t)
|
|
f.seedProxy(t, "own", "account1", "gw.example.com", ptrTo(true))
|
|
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
|
|
|
created, err := f.createSettings(ctx, "account1", "user1", "", "gw.example.com")
|
|
require.NoError(t, err, "the account's own proxy is not a competing claim")
|
|
assert.Equal(t, "gw.example.com", created.ProxyAddress)
|
|
}
|
|
|
|
// TestCreateSettingsSelfAddressedWithdrawsPinClaimedDuringBootstrap is the
|
|
// self-addressed twin of the labeled race: the competing proxy lands as the
|
|
// row is written, the re-read sees it, and the pin is withdrawn.
|
|
func TestCreateSettingsSelfAddressedWithdrawsPinClaimedDuringBootstrap(t *testing.T) {
|
|
ctx := context.Background()
|
|
const host = "gw.example.com"
|
|
|
|
f := newBootstrapFixtureWith(t, func(st store.Store) store.Store {
|
|
return &claimingStore{Store: st, t: t, claim: foreignClaim(host)}
|
|
})
|
|
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
|
|
|
_, err := f.createSettings(ctx, "account1", "user1", "", host)
|
|
require.Error(t, err, "a host claimed by another account mid-bootstrap must be refused")
|
|
var sErr *status.Error
|
|
require.ErrorAs(t, err, &sErr)
|
|
assert.Equal(t, status.InvalidArgument, sErr.Type())
|
|
|
|
_, err = f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
|
assert.Error(t, err, "the pin written before the claim was seen must be withdrawn")
|
|
}
|
|
|
|
// TestCreateSettingsWithdrawsPinWhenOwnershipRecheckFails pins fail-closed on
|
|
// the bootstrap side: a re-read that cannot answer leaves no pin behind and
|
|
// surfaces the store's error rather than a validation refusal, since nothing
|
|
// established that the cluster is somebody else's.
|
|
func TestCreateSettingsWithdrawsPinWhenOwnershipRecheckFails(t *testing.T) {
|
|
ctx := context.Background()
|
|
const host = "shared.example.com"
|
|
|
|
// The first ownership call is the pre-write check and must pass; the
|
|
// second is the re-read.
|
|
f := newBootstrapFixtureWith(t, func(st store.Store) store.Store {
|
|
return &failingStore{Store: st, failOwnershipOn: 2}
|
|
})
|
|
f.seedEmbeddedCluster(t, host)
|
|
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
|
|
|
_, err := f.createSettings(ctx, "account1", "user1", host, "")
|
|
require.Error(t, err)
|
|
var sErr *status.Error
|
|
assert.False(t, errors.As(err, &sErr) && sErr.Type() == status.InvalidArgument,
|
|
"an inconclusive re-read is not a validation refusal: %v", err)
|
|
assert.ErrorContains(t, err, "store unavailable", "the store's error must be the one surfaced")
|
|
|
|
_, err = f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
|
assert.Error(t, err, "a pin that could not be confirmed must not stand")
|
|
}
|
|
|
|
// TestCreateSettingsRefusesEvenWhenWithdrawalFails pins that a lost claim is
|
|
// reported as lost whatever happens to the compensating delete: the caller
|
|
// must not be told it holds a cluster another account's proxy declares.
|
|
func TestCreateSettingsRefusesEvenWhenWithdrawalFails(t *testing.T) {
|
|
ctx := context.Background()
|
|
const host = "shared.example.com"
|
|
|
|
f := newBootstrapFixtureWith(t, func(st store.Store) store.Store {
|
|
return &failingStore{Store: &claimingStore{Store: st, t: t, claim: foreignClaim(host)}, failDelete: true}
|
|
})
|
|
f.seedEmbeddedCluster(t, host)
|
|
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
|
|
|
_, err := f.createSettings(ctx, "account1", "user1", host, "")
|
|
require.Error(t, err)
|
|
var sErr *status.Error
|
|
require.ErrorAs(t, err, &sErr)
|
|
assert.Equal(t, status.InvalidArgument, sErr.Type(), "a failed withdrawal must not turn a lost claim into a held one")
|
|
assert.Contains(t, err.Error(), "not available to this account")
|
|
}
|
|
|
|
// TestCreateSettingsAcceptsSharedClusterAlongsideOwnProxy pins the other side
|
|
// of that ordering: a shared (NetBird-operated) proxy is not foreign, so
|
|
// asking the ownership question first must not refuse the cluster most
|
|
// accounts pin to.
|
|
func TestCreateSettingsAcceptsSharedClusterAlongsideOwnProxy(t *testing.T) {
|
|
ctx := context.Background()
|
|
f := newBootstrapFixture(t)
|
|
f.seedProxy(t, "shared", "", "eu.proxy.example.com", ptrTo(true))
|
|
f.seedProxy(t, "own", "account1", "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 shared cluster must stay pinnable")
|
|
assert.Equal(t, "eu.proxy.example.com", created.ProxyAddress)
|
|
}
|
|
|
|
// 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. Addresses are canonicalised where they are written
|
|
// (canonicalProxyAddress on the proxy-connect path), so this is the belt to
|
|
// that braces: it covers a row written before that landed, and any future
|
|
// writer that skips it. The comparison is in memory over the account's cluster
|
|
// list, so it costs nothing at the query — the capability lookup is still
|
|
// asked under the spelling the store actually holds, which is what an exact,
|
|
// indexed 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("foreign cluster is still foreign", func(t *testing.T) {
|
|
f := newBootstrapFixture(t)
|
|
f.seedProxy(t, "proxy1", "account2", "BYOP.Account2.Example.com", ptrTo(true))
|
|
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
|
|
|
_, err := f.createSettings(ctx, "account1", "user1", "byop.account2.example.com", "")
|
|
require.Error(t, err, "another account's cluster must be refused whatever its casing")
|
|
var sErr *status.Error
|
|
require.ErrorAs(t, err, &sErr)
|
|
assert.Equal(t, status.InvalidArgument, sErr.Type())
|
|
assert.Contains(t, err.Error(), "not available to this account")
|
|
})
|
|
|
|
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")
|
|
})
|
|
}
|