mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-03 20:19:07 +02:00
[management] Scope the change to the private-capability check
The PR grew past its purpose. What it needs to do is refuse to bootstrap an agent network endpoint onto a cluster that cannot serve it, which is the private capability check on the picked cluster. Everything that accreted around it — canonicalising proxy addresses at connect, refusing another account's cluster or a host another account pinned, withdrawing a claim lost to a concurrent one, folding casing on migrated settings rows — is security work in its own right and moves to follow-up PRs, where each can be reviewed against its own threat rather than as a rider on this one. This restores main's version of every file outside that purpose and reduces the validation to: a cluster the account can see must have a live embedded proxy, and a cluster management holds no row for stays pinnable (address-first). The e2e test and the fixture seeds are unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sa3DsBDP3VciAi4PPG17L6
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
4ed71f8987
commit
5502ea08ac
@@ -3,7 +3,6 @@ package migration
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
@@ -67,19 +66,12 @@ func MigrateAgentNetworkSettingsToDomain(ctx context.Context, db *gorm.DB) error
|
||||
}
|
||||
|
||||
if hasCluster {
|
||||
// The legacy bootstrap stored the cluster as the caller spelled
|
||||
// it, trimmed but never folded, while every reader of these
|
||||
// columns matches exactly against canonical lowercase: proxy
|
||||
// addresses are canonicalised at connect, and the proxy's host
|
||||
// map is keyed by the domain verbatim. Fold here so the reshaped
|
||||
// row is addressable, rather than copying a spelling nothing
|
||||
// will match.
|
||||
concat := "LOWER(subdomain || '.' || cluster)"
|
||||
concat := "subdomain || '.' || cluster"
|
||||
if tx.Name() == "mysql" {
|
||||
concat = "LOWER(CONCAT(subdomain, '.', cluster))"
|
||||
concat = "CONCAT(subdomain, '.', cluster)"
|
||||
}
|
||||
res := tx.Exec(fmt.Sprintf(
|
||||
"UPDATE agent_network_settings SET domain = %s, proxy_address = LOWER(cluster) WHERE (domain IS NULL OR domain = '') AND cluster <> '' AND subdomain <> ''",
|
||||
"UPDATE agent_network_settings SET domain = %s, proxy_address = cluster WHERE (domain IS NULL OR domain = '') AND cluster <> '' AND subdomain <> ''",
|
||||
concat,
|
||||
))
|
||||
if res.Error != nil {
|
||||
@@ -96,9 +88,6 @@ func MigrateAgentNetworkSettingsToDomain(ctx context.Context, db *gorm.DB) error
|
||||
unmigratable,
|
||||
)
|
||||
}
|
||||
if err := failOnDuplicateAgentNetworkDomains(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if res.RowsAffected > 0 {
|
||||
log.WithContext(ctx).Infof("migrated %d agent_network_settings row(s) to domain/proxy_address", res.RowsAffected)
|
||||
@@ -121,83 +110,3 @@ func MigrateAgentNetworkSettingsToDomain(ctx context.Context, db *gorm.DB) error
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// agentNetworkSettingsIdentity is the post-reshape view of the two identity
|
||||
// columns, enough for the normaliser to address the table without importing
|
||||
// the current model.
|
||||
type agentNetworkSettingsIdentity struct {
|
||||
AccountID string `gorm:"primaryKey"`
|
||||
Domain string `gorm:"type:varchar(255)"`
|
||||
ProxyAddress string `gorm:"type:varchar(255)"`
|
||||
}
|
||||
|
||||
func (agentNetworkSettingsIdentity) TableName() string { return "agent_network_settings" }
|
||||
|
||||
// NormalizeAgentNetworkSettingsIdentity lowercases domain and proxy_address on
|
||||
// rows already reshaped by a release whose backfill copied the legacy cluster
|
||||
// spelling verbatim.
|
||||
//
|
||||
// Every reader of these columns matches exactly against canonical lowercase:
|
||||
// the gateway-pin check a proxy registration runs and cluster-scoped mapping
|
||||
// synthesis look proxy_address up by the canonical address, the domain lookup
|
||||
// is followed by an exact Go compare, and the proxy's host map is keyed by the
|
||||
// domain verbatim. A row that kept capitals is invisible to all of them, so
|
||||
// the value is repaired where it is stored rather than folded on every read.
|
||||
//
|
||||
// MySQL needs the predicate spelled byte-wise: under its default
|
||||
// case-insensitive collation `domain <> LOWER(domain)` is false for every row,
|
||||
// which would leave the rows unrepaired while the Go-side compares still miss
|
||||
// them. Idempotent: the predicate selects only rows that would change, one
|
||||
// pass over a table holding one row per account. Runs after the reshape, so
|
||||
// the columns exist whenever the table does.
|
||||
func NormalizeAgentNetworkSettingsIdentity(ctx context.Context, db *gorm.DB) error {
|
||||
model := &agentNetworkSettingsIdentity{}
|
||||
migrator := db.Migrator()
|
||||
|
||||
if !migrator.HasTable(model) || !migrator.HasColumn(model, "Domain") || !migrator.HasColumn(model, "ProxyAddress") {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := failOnDuplicateAgentNetworkDomains(db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
predicate := "domain <> LOWER(domain) OR proxy_address <> LOWER(proxy_address)"
|
||||
if db.Name() == "mysql" {
|
||||
predicate = "BINARY domain <> BINARY LOWER(domain) OR BINARY proxy_address <> BINARY LOWER(proxy_address)"
|
||||
}
|
||||
res := db.Exec("UPDATE agent_network_settings SET domain = LOWER(domain), proxy_address = LOWER(proxy_address) WHERE " + predicate)
|
||||
if res.Error != nil {
|
||||
return fmt.Errorf("normalize agent_network_settings identity casing: %w", res.Error)
|
||||
}
|
||||
if res.RowsAffected > 0 {
|
||||
log.WithContext(ctx).Infof("normalized casing on %d agent_network_settings row(s)", res.RowsAffected)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// failOnDuplicateAgentNetworkDomains refuses to continue when two settings
|
||||
// rows would fold onto one endpoint hostname. Two accounts cannot share an
|
||||
// endpoint, the unique index would refuse the fold with a driver message that
|
||||
// names no row, and there is no right answer as to which account keeps the
|
||||
// name, so the migration stops and says which hostname needs a human.
|
||||
func failOnDuplicateAgentNetworkDomains(db *gorm.DB) error {
|
||||
var rows []struct{ Domain string }
|
||||
err := db.Raw("SELECT LOWER(domain) AS domain FROM agent_network_settings GROUP BY LOWER(domain) HAVING COUNT(*) > 1").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("check agent_network_settings for endpoints differing only by case: %w", err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
duplicates := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
duplicates = append(duplicates, row.Domain)
|
||||
}
|
||||
return fmt.Errorf(
|
||||
"agent_network_settings holds endpoints that differ only by case (%s); resolve them manually before upgrading",
|
||||
strings.Join(duplicates, ", "),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -757,11 +757,8 @@ func TestMigrateAgentNetworkSettingsToDomain_BackfillsAndDropsLegacyColumns(t *t
|
||||
db := setupDatabase(t)
|
||||
require.NoError(t, db.Migrator().DropTable(&legacyAgentNetworkSettings{}))
|
||||
require.NoError(t, db.AutoMigrate(&legacyAgentNetworkSettings{}))
|
||||
// The cluster is spelled the way the legacy bootstrap kept it: as the
|
||||
// caller typed it, trimmed but never folded. The subdomain was always
|
||||
// server-assigned lowercase.
|
||||
require.NoError(t, db.Create(&legacyAgentNetworkSettings{
|
||||
AccountID: "acct-1", Cluster: "EU.Proxy.NetBird.io", Subdomain: "violet", EnableLogCollection: true,
|
||||
AccountID: "acct-1", Cluster: "eu.proxy.netbird.io", Subdomain: "violet", EnableLogCollection: true,
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&legacyAgentNetworkSettings{
|
||||
AccountID: "acct-2", Cluster: "us.proxy.netbird.io", Subdomain: "violet",
|
||||
@@ -773,10 +770,8 @@ func TestMigrateAgentNetworkSettingsToDomain_BackfillsAndDropsLegacyColumns(t *t
|
||||
|
||||
var one, two agentNetworkTypes.Settings
|
||||
require.NoError(t, db.First(&one, "account_id = ?", "acct-1").Error)
|
||||
assert.Equal(t, "violet.eu.proxy.netbird.io", one.Domain,
|
||||
"domain must combine subdomain and cluster, folded to the canonical lowercase every reader compares against")
|
||||
assert.Equal(t, "eu.proxy.netbird.io", one.ProxyAddress,
|
||||
"proxy address must carry the cluster in canonical lowercase, matching what proxies register under")
|
||||
assert.Equal(t, "violet.eu.proxy.netbird.io", one.Domain, "domain must combine subdomain and cluster")
|
||||
assert.Equal(t, "eu.proxy.netbird.io", one.ProxyAddress, "proxy address must carry the cluster")
|
||||
assert.True(t, one.EnableLogCollection, "non-identity fields must ride through")
|
||||
require.NoError(t, db.First(&two, "account_id = ?", "acct-2").Error)
|
||||
assert.Equal(t, "violet.us.proxy.netbird.io", two.Domain,
|
||||
@@ -863,95 +858,3 @@ func TestMigrateAgentNetworkSettingsToDomain_ResumesAfterPartialDrop(t *testing.
|
||||
assert.Equal(t, "violet.eu.proxy.netbird.io", row.Domain, "migrated values must be untouched")
|
||||
assert.Equal(t, "eu.proxy.netbird.io", row.ProxyAddress, "migrated values must be untouched")
|
||||
}
|
||||
|
||||
// TestNormalizeAgentNetworkSettingsIdentity_LowercasesReshapedRows covers rows
|
||||
// a released reshape already copied verbatim: capitals kept from the legacy
|
||||
// cluster spelling are folded in place, canonical rows are left alone, and
|
||||
// non-identity fields ride through.
|
||||
func TestNormalizeAgentNetworkSettingsIdentity_LowercasesReshapedRows(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := setupDatabase(t)
|
||||
require.NoError(t, db.Migrator().DropTable(&agentNetworkTypes.Settings{}))
|
||||
require.NoError(t, db.AutoMigrate(&agentNetworkTypes.Settings{}))
|
||||
require.NoError(t, db.Create(&agentNetworkTypes.Settings{
|
||||
AccountID: "acct-legacy", Domain: "Violet.EU.Proxy.NetBird.io", ProxyAddress: "EU.Proxy.NetBird.io", EnableLogCollection: true,
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&agentNetworkTypes.Settings{
|
||||
AccountID: "acct-canonical", Domain: "amber.us.proxy.netbird.io", ProxyAddress: "us.proxy.netbird.io",
|
||||
}).Error)
|
||||
|
||||
require.NoError(t, migration.NormalizeAgentNetworkSettingsIdentity(ctx, db))
|
||||
|
||||
var legacy, canonical agentNetworkTypes.Settings
|
||||
require.NoError(t, db.First(&legacy, "account_id = ?", "acct-legacy").Error)
|
||||
assert.Equal(t, "violet.eu.proxy.netbird.io", legacy.Domain, "a mixed-case endpoint must be folded where it is stored")
|
||||
assert.Equal(t, "eu.proxy.netbird.io", legacy.ProxyAddress, "a mixed-case pin must be folded so exact lookups find it")
|
||||
assert.True(t, legacy.EnableLogCollection, "non-identity fields must ride through")
|
||||
require.NoError(t, db.First(&canonical, "account_id = ?", "acct-canonical").Error)
|
||||
assert.Equal(t, "amber.us.proxy.netbird.io", canonical.Domain, "a canonical row must be left as it is")
|
||||
assert.Equal(t, "us.proxy.netbird.io", canonical.ProxyAddress)
|
||||
|
||||
require.NoError(t, migration.NormalizeAgentNetworkSettingsIdentity(ctx, db),
|
||||
"a second run over a normalised table must be a no-op, not an error")
|
||||
}
|
||||
|
||||
// TestNormalizeAgentNetworkSettingsIdentity_SkipsMissingTable pins that a
|
||||
// store which never had agent network settings is left untouched.
|
||||
func TestNormalizeAgentNetworkSettingsIdentity_SkipsMissingTable(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := setupDatabase(t)
|
||||
require.NoError(t, db.Migrator().DropTable(&agentNetworkTypes.Settings{}))
|
||||
|
||||
require.NoError(t, migration.NormalizeAgentNetworkSettingsIdentity(ctx, db),
|
||||
"no table must be a no-op, not an error")
|
||||
assert.False(t, db.Migrator().HasTable(&agentNetworkTypes.Settings{}), "the normaliser must not create the table")
|
||||
}
|
||||
|
||||
// TestNormalizeAgentNetworkSettingsIdentity_RefusesCaseOnlyCollision pins the
|
||||
// loud failure: two rows that would fold onto one endpoint stop the migration
|
||||
// with the hostname named, and neither row is touched, rather than letting the
|
||||
// unique index refuse the fold with a driver message that names no row.
|
||||
func TestNormalizeAgentNetworkSettingsIdentity_RefusesCaseOnlyCollision(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := setupDatabase(t)
|
||||
if db.Name() == "mysql" {
|
||||
t.Skip("MySQL's default collation refuses two rows differing only by case at insert; the collision cannot exist there")
|
||||
}
|
||||
require.NoError(t, db.Migrator().DropTable(&agentNetworkTypes.Settings{}))
|
||||
require.NoError(t, db.AutoMigrate(&agentNetworkTypes.Settings{}))
|
||||
require.NoError(t, db.Create(&agentNetworkTypes.Settings{
|
||||
AccountID: "acct-1", Domain: "Violet.eu.proxy.netbird.io", ProxyAddress: "eu.proxy.netbird.io",
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&agentNetworkTypes.Settings{
|
||||
AccountID: "acct-2", Domain: "violet.eu.proxy.netbird.io", ProxyAddress: "eu.proxy.netbird.io",
|
||||
}).Error)
|
||||
|
||||
err := migration.NormalizeAgentNetworkSettingsIdentity(ctx, db)
|
||||
require.Error(t, err, "two rows folding onto one endpoint must stop the migration")
|
||||
assert.Contains(t, err.Error(), "violet.eu.proxy.netbird.io", "the failure must name the colliding hostname")
|
||||
|
||||
var one agentNetworkTypes.Settings
|
||||
require.NoError(t, db.First(&one, "account_id = ?", "acct-1").Error)
|
||||
assert.Equal(t, "Violet.eu.proxy.netbird.io", one.Domain, "a refused normalisation must leave every row as it was")
|
||||
}
|
||||
|
||||
// TestMigrateAgentNetworkSettingsToDomain_RefusesCaseOnlyCollision pins the
|
||||
// same loud failure on the reshape: legacy rows whose identities differ only
|
||||
// by case would fold onto one endpoint, and the reshape must say so rather
|
||||
// than leave AutoMigrate to fail on the unique index.
|
||||
func TestMigrateAgentNetworkSettingsToDomain_RefusesCaseOnlyCollision(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := setupDatabase(t)
|
||||
require.NoError(t, db.Migrator().DropTable(&legacyAgentNetworkSettings{}))
|
||||
require.NoError(t, db.AutoMigrate(&legacyAgentNetworkSettings{}))
|
||||
require.NoError(t, db.Create(&legacyAgentNetworkSettings{
|
||||
AccountID: "acct-1", Cluster: "EU.proxy.netbird.io", Subdomain: "violet",
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&legacyAgentNetworkSettings{
|
||||
AccountID: "acct-2", Cluster: "eu.proxy.netbird.io", Subdomain: "violet",
|
||||
}).Error)
|
||||
|
||||
err := migration.MigrateAgentNetworkSettingsToDomain(ctx, db)
|
||||
require.Error(t, err, "legacy rows folding onto one endpoint must stop the reshape")
|
||||
assert.Contains(t, err.Error(), "violet.eu.proxy.netbird.io", "the failure must name the colliding hostname")
|
||||
}
|
||||
|
||||
@@ -6292,25 +6292,6 @@ func (s *SqlStore) DisconnectProxy(ctx context.Context, proxyID, sessionID strin
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteProxy removes the proxy's row, but only while it still carries the
|
||||
// given session: a registration withdrawing its own claim must not take out a
|
||||
// newer session's row for the same proxy. A row already superseded or gone is
|
||||
// not an error — the claim it would have withdrawn is no longer this session's
|
||||
// to withdraw.
|
||||
func (s *SqlStore) DeleteProxy(ctx context.Context, proxyID, sessionID string) error {
|
||||
result := s.db.
|
||||
Where("id = ? AND session_id = ?", proxyID, sessionID).
|
||||
Delete(&proxy.Proxy{})
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to delete proxy %s session %s: %v", proxyID, sessionID, result.Error)
|
||||
return status.Errorf(status.Internal, "failed to delete proxy")
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
log.WithContext(ctx).Debugf("proxy %s session %s: no row deleted (already gone or superseded by a newer session)", proxyID, sessionID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAllProxies returns all reverse proxy instance rows.
|
||||
func (s *SqlStore) GetAllProxies(ctx context.Context) ([]*proxy.Proxy, error) {
|
||||
var proxies []*proxy.Proxy
|
||||
@@ -6435,13 +6416,11 @@ func (s *SqlStore) CountProxiesByAccountID(ctx context.Context, accountID string
|
||||
// queries. Backs the agent-network settings delete guard: settings cannot be
|
||||
// deleted while a proxy declares the endpoint hostname as its address.
|
||||
//
|
||||
// The comparison folds case on both sides. Addresses are canonicalized where
|
||||
// they are written now (canonicalProxyAddress on the proxy-connect path), so
|
||||
// this mostly matters for a row written before that: hostnames are
|
||||
// case-insensitive per RFC 4343, and on a case-sensitive collation a proxy
|
||||
// stored as "GW.Example.com" would otherwise slip past the guard. This runs
|
||||
// only on the settings delete path, so folding costs nothing worth indexing
|
||||
// around.
|
||||
// The comparison folds case on both sides: the caller passes a normalized
|
||||
// (lowercase) hostname, but proxies declare their cluster address verbatim
|
||||
// and Connect stores it unchanged, so on case-sensitive collations a proxy
|
||||
// declaring "GW.Example.com" would otherwise slip past the guard. Hostnames
|
||||
// are case-insensitive per RFC 4343; the guard must be too.
|
||||
func (s *SqlStore) HasActiveProxyAtClusterAddress(ctx context.Context, clusterAddress string) (bool, error) {
|
||||
var count int64
|
||||
result := s.db.
|
||||
@@ -6455,39 +6434,6 @@ func (s *SqlStore) HasActiveProxyAtClusterAddress(ctx context.Context, clusterAd
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// IsClusterAddressConflicting reports whether the address is already declared
|
||||
// by a proxy outside the account — a shared proxy or another account's. The
|
||||
// match is exact, and stays exact so it uses the cluster_address index:
|
||||
// addresses are canonicalised where they are written (canonicalProxyAddress on
|
||||
// the proxy-connect path), so one host has one spelling in this column.
|
||||
// HasForeignAccountProxyAtHost reports whether a proxy owned by another
|
||||
// account declares this host, folding case on both sides.
|
||||
//
|
||||
// Shared proxies (account_id IS NULL) are deliberately not foreign: they are
|
||||
// what most accounts pin their gateway to. What this catches is two accounts
|
||||
// claiming one hostname, which IsClusterAddressConflicting prevents going
|
||||
// forward but cannot see for a row written before addresses were
|
||||
// canonicalized.
|
||||
//
|
||||
// It folds case where IsClusterAddressConflicting stays exact because the
|
||||
// callers differ in cost and in what they can assume. That one runs on every
|
||||
// account-scoped proxy connect, where both sides are canonical and the match
|
||||
// must stay exact to use the cluster_address index. This one runs once per
|
||||
// account, when an agent network bootstraps, and is the only thing standing
|
||||
// between that account and pinning its immutable endpoint to a cluster
|
||||
// somebody else runs — worth a scan on a path taken once.
|
||||
func (s *SqlStore) HasForeignAccountProxyAtHost(ctx context.Context, host, accountID string) (bool, error) {
|
||||
var count int64
|
||||
result := s.db.
|
||||
Model(&proxy.Proxy{}).
|
||||
Where("LOWER(cluster_address) = LOWER(?) AND account_id IS NOT NULL AND account_id != ?", host, accountID).
|
||||
Count(&count)
|
||||
if result.Error != nil {
|
||||
return false, status.Errorf(status.Internal, "check proxy host ownership: %v", result.Error)
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) IsClusterAddressConflicting(ctx context.Context, clusterAddress, accountID string) (bool, error) {
|
||||
var count int64
|
||||
result := s.db.
|
||||
|
||||
@@ -315,32 +315,6 @@ func (s *SqlStore) GetAllAgentNetworkSettings(ctx context.Context, lockStrength
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// HasGatewayPinnedByOtherAccount reports whether an account other than the
|
||||
// given one has its agent network gateway pinned to this host.
|
||||
//
|
||||
// A pin is a claim on the host, the same way a proxy row is: the pinned
|
||||
// endpoint is served by whichever proxy declares that address, and an
|
||||
// account-scoped proxy only ever receives its own account's mappings. A proxy
|
||||
// from a different account taking the address therefore cannot serve the pin
|
||||
// and silently strands it. The pin is immutable, so the account that holds it
|
||||
// cannot move out of the way — the later claimant is the one to refuse.
|
||||
//
|
||||
// Both sides are canonical (settings normalize on write, proxy addresses
|
||||
// canonicalize at connect), so the match is exact and uses the proxy_address
|
||||
// index.
|
||||
func (s *SqlStore) HasGatewayPinnedByOtherAccount(ctx context.Context, host, accountID string) (bool, error) {
|
||||
var count int64
|
||||
result := s.db.
|
||||
Model(&agentNetworkTypes.Settings{}).
|
||||
Where("proxy_address = ? AND account_id != ?", host, accountID).
|
||||
Count(&count)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to check agent network gateway pins by proxy address: %v", result.Error)
|
||||
return false, status.Errorf(status.Internal, "check agent network gateway pins")
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// GetAgentNetworkSettingsByProxyAddress returns every Settings row whose
|
||||
// gateway is served by the proxy declaring the given cluster address. Used by
|
||||
// cluster-scoped synthesis to find the accounts a shared proxy serves.
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
)
|
||||
|
||||
// TestHasGatewayPinnedByOtherAccount_RealStore drives the query a proxy
|
||||
// registration asks before claiming a cluster address, against a real sqlite
|
||||
// store.
|
||||
//
|
||||
// A gateway pin is a claim on the host: it is immutable, it is served by
|
||||
// whichever proxy declares that address, and an account-scoped proxy only ever
|
||||
// receives its own account's mappings — so a proxy from a different account
|
||||
// taking the address strands the pin. The account's own pin is the opposite
|
||||
// case and must stay claimable, because pinning first and deploying the proxy
|
||||
// after is the documented order.
|
||||
func TestHasGatewayPinnedByOtherAccount_RealStore(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, cleanup, err := NewTestStoreFromSQL(ctx, "", t.TempDir())
|
||||
require.NoError(t, err, "real sqlite test store must come up")
|
||||
defer cleanup()
|
||||
|
||||
const (
|
||||
pinnedHost = "gw.account1.example.com"
|
||||
freeHost = "nobody.example.com"
|
||||
)
|
||||
|
||||
settings := agentNetworkTypes.DefaultSettings("account1")
|
||||
settings.Domain = pinnedHost
|
||||
settings.ProxyAddress = pinnedHost
|
||||
require.NoError(t, s.CreateAgentNetworkSettings(ctx, settings), "seeding the pin must succeed")
|
||||
|
||||
t.Run("another account is refused the host", func(t *testing.T) {
|
||||
pinned, err := s.HasGatewayPinnedByOtherAccount(ctx, pinnedHost, "account2")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, pinned, "a host another account pinned its gateway to is claimed")
|
||||
})
|
||||
|
||||
t.Run("the pinning account may still claim it", func(t *testing.T) {
|
||||
pinned, err := s.HasGatewayPinnedByOtherAccount(ctx, pinnedHost, "account1")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, pinned, "an account must be able to deploy the proxy for its own pin")
|
||||
})
|
||||
|
||||
t.Run("an unpinned host is free", func(t *testing.T) {
|
||||
pinned, err := s.HasGatewayPinnedByOtherAccount(ctx, freeHost, "account2")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, pinned, "a host no gateway is pinned to stays claimable")
|
||||
})
|
||||
|
||||
t.Run("a labeled pin claims the cluster, not just the endpoint", func(t *testing.T) {
|
||||
// A labeled bootstrap hangs <label>.<cluster> beneath the address while
|
||||
// pinning the cluster itself, so the claim follows proxy_address.
|
||||
labeled := agentNetworkTypes.DefaultSettings("account3")
|
||||
labeled.ProxyAddress = "byop.account3.example.com"
|
||||
labeled.Domain = "violet." + labeled.ProxyAddress
|
||||
require.NoError(t, s.CreateAgentNetworkSettings(ctx, labeled))
|
||||
|
||||
pinned, err := s.HasGatewayPinnedByOtherAccount(ctx, labeled.ProxyAddress, "account2")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, pinned, "the pinned cluster address is the claim, not the labeled endpoint")
|
||||
|
||||
pinned, err = s.HasGatewayPinnedByOtherAccount(ctx, labeled.Domain, "account2")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, pinned, "the labeled endpoint itself is not a cluster claim")
|
||||
})
|
||||
}
|
||||
@@ -154,47 +154,3 @@ func TestSqlStore_GetAllProxies_Empty(t *testing.T) {
|
||||
assert.Empty(t, all)
|
||||
})
|
||||
}
|
||||
|
||||
// TestSqlStore_DeleteProxy guards the withdrawal a registration makes when
|
||||
// its claim on a cluster address is lost after the row was written:
|
||||
//
|
||||
// 1. The delete is session-guarded, like DisconnectProxy — a stale session
|
||||
// withdrawing itself must not take out the row a newer session of the
|
||||
// same proxy has since written.
|
||||
// 2. A row that is already gone, or already superseded, is not an error;
|
||||
// the claim it would have withdrawn is no longer this session's.
|
||||
// 3. Other proxies at the same address are untouched: only the one row is
|
||||
// withdrawn, not the cluster.
|
||||
func TestSqlStore_DeleteProxy(t *testing.T) {
|
||||
if (os.Getenv("CI") == "true" && runtime.GOOS == "darwin") || runtime.GOOS == "windows" {
|
||||
t.Skip("skip CI tests on darwin and windows")
|
||||
}
|
||||
|
||||
runTestForAllEngines(t, "", func(t *testing.T, store Store) {
|
||||
ctx := context.Background()
|
||||
accountID := "acct-withdraw"
|
||||
now := time.Now()
|
||||
|
||||
for _, p := range []*rpproxy.Proxy{
|
||||
{ID: "p-withdrawn", SessionID: "sess-new", ClusterAddress: "byop.example.com", LastSeen: now, Status: rpproxy.StatusConnected, AccountID: &accountID},
|
||||
{ID: "p-neighbour", SessionID: "sess-1", ClusterAddress: "byop.example.com", LastSeen: now, Status: rpproxy.StatusConnected, AccountID: &accountID},
|
||||
} {
|
||||
require.NoError(t, store.SaveProxy(ctx, p))
|
||||
}
|
||||
|
||||
require.NoError(t, store.DeleteProxy(ctx, "p-withdrawn", "sess-old"),
|
||||
"a delete under a superseded session must be a no-op, not an error")
|
||||
remaining, err := store.GetAllProxies(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, remaining, 2, "a superseded session must not withdraw the newer session's row")
|
||||
|
||||
require.NoError(t, store.DeleteProxy(ctx, "p-withdrawn", "sess-new"))
|
||||
remaining, err = store.GetAllProxies(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, remaining, 1, "the withdrawing session's own row must be gone")
|
||||
assert.Equal(t, "p-neighbour", remaining[0].ID, "the other proxy at the address must be untouched")
|
||||
|
||||
require.NoError(t, store.DeleteProxy(ctx, "p-withdrawn", "sess-new"),
|
||||
"withdrawing a row that is already gone must be a no-op, not an error")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -325,7 +325,6 @@ type Store interface {
|
||||
|
||||
SaveProxy(ctx context.Context, proxy *proxy.Proxy) error
|
||||
DisconnectProxy(ctx context.Context, proxyID, sessionID string) error
|
||||
DeleteProxy(ctx context.Context, proxyID, sessionID string) error
|
||||
UpdateProxyHeartbeat(ctx context.Context, p *proxy.Proxy) error
|
||||
GetActiveProxyClusterAddresses(ctx context.Context) ([]string, error)
|
||||
GetActiveProxyClusterAddressesForAccount(ctx context.Context, accountID string) ([]string, error)
|
||||
@@ -341,7 +340,6 @@ type Store interface {
|
||||
CountProxiesByAccountID(ctx context.Context, accountID string) (int64, error)
|
||||
IsClusterAddressConflicting(ctx context.Context, clusterAddress, accountID string) (bool, error)
|
||||
HasActiveProxyAtClusterAddress(ctx context.Context, clusterAddress string) (bool, error)
|
||||
HasForeignAccountProxyAtHost(ctx context.Context, host, accountID string) (bool, error)
|
||||
DeleteAccountCluster(ctx context.Context, clusterAddress, accountID string) error
|
||||
|
||||
GetCustomDomainsCounts(ctx context.Context) (total int64, validated int64, err error)
|
||||
@@ -375,7 +373,6 @@ type Store interface {
|
||||
GetAgentNetworkSettings(ctx context.Context, lockStrength LockingStrength, accountID string) (*agentNetworkTypes.Settings, error)
|
||||
GetAllAgentNetworkSettings(ctx context.Context, lockStrength LockingStrength) ([]*agentNetworkTypes.Settings, error)
|
||||
GetAgentNetworkSettingsByProxyAddress(ctx context.Context, lockStrength LockingStrength, proxyAddress string) ([]*agentNetworkTypes.Settings, error)
|
||||
HasGatewayPinnedByOtherAccount(ctx context.Context, host, accountID string) (bool, error)
|
||||
GetAgentNetworkSettingsByDomain(ctx context.Context, lockStrength LockingStrength, domain string) (*agentNetworkTypes.Settings, error)
|
||||
CreateAgentNetworkSettings(ctx context.Context, settings *agentNetworkTypes.Settings) error
|
||||
SaveAgentNetworkSettings(ctx context.Context, settings *agentNetworkTypes.Settings) error
|
||||
@@ -629,9 +626,6 @@ func getMigrationsPreAuto(ctx context.Context) []migrationFunc {
|
||||
func(db *gorm.DB) error {
|
||||
return migration.MigrateAgentNetworkSettingsToDomain(ctx, db)
|
||||
},
|
||||
func(db *gorm.DB) error {
|
||||
return migration.NormalizeAgentNetworkSettingsIdentity(ctx, db)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -754,20 +754,6 @@ func (mr *MockStoreMockRecorder) DeletePostureChecks(ctx, accountID, postureChec
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePostureChecks", reflect.TypeOf((*MockStore)(nil).DeletePostureChecks), ctx, accountID, postureChecksID)
|
||||
}
|
||||
|
||||
// DeleteProxy mocks base method.
|
||||
func (m *MockStore) DeleteProxy(ctx context.Context, proxyID, sessionID string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "DeleteProxy", ctx, proxyID, sessionID)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// DeleteProxy indicates an expected call of DeleteProxy.
|
||||
func (mr *MockStoreMockRecorder) DeleteProxy(ctx, proxyID, sessionID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteProxy", reflect.TypeOf((*MockStore)(nil).DeleteProxy), ctx, proxyID, sessionID)
|
||||
}
|
||||
|
||||
// DeleteRoute mocks base method.
|
||||
func (m *MockStore) DeleteRoute(ctx context.Context, accountID, routeID string) error {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -3079,36 +3065,6 @@ func (mr *MockStoreMockRecorder) HasActiveProxyAtClusterAddress(ctx, clusterAddr
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasActiveProxyAtClusterAddress", reflect.TypeOf((*MockStore)(nil).HasActiveProxyAtClusterAddress), ctx, clusterAddress)
|
||||
}
|
||||
|
||||
// HasForeignAccountProxyAtHost mocks base method.
|
||||
func (m *MockStore) HasForeignAccountProxyAtHost(ctx context.Context, host, accountID string) (bool, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "HasForeignAccountProxyAtHost", ctx, host, accountID)
|
||||
ret0, _ := ret[0].(bool)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// HasForeignAccountProxyAtHost indicates an expected call of HasForeignAccountProxyAtHost.
|
||||
func (mr *MockStoreMockRecorder) HasForeignAccountProxyAtHost(ctx, host, accountID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasForeignAccountProxyAtHost", reflect.TypeOf((*MockStore)(nil).HasForeignAccountProxyAtHost), ctx, host, accountID)
|
||||
}
|
||||
|
||||
// HasGatewayPinnedByOtherAccount mocks base method.
|
||||
func (m *MockStore) HasGatewayPinnedByOtherAccount(ctx context.Context, host, accountID string) (bool, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "HasGatewayPinnedByOtherAccount", ctx, host, accountID)
|
||||
ret0, _ := ret[0].(bool)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// HasGatewayPinnedByOtherAccount indicates an expected call of HasGatewayPinnedByOtherAccount.
|
||||
func (mr *MockStoreMockRecorder) HasGatewayPinnedByOtherAccount(ctx, host, accountID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasGatewayPinnedByOtherAccount", reflect.TypeOf((*MockStore)(nil).HasGatewayPinnedByOtherAccount), ctx, host, accountID)
|
||||
}
|
||||
|
||||
// IncrementAgentNetworkConsumption mocks base method.
|
||||
func (m *MockStore) IncrementAgentNetworkConsumption(ctx context.Context, accountID string, kind types.ConsumptionDimension, dimID string, windowSeconds int64, windowStart time.Time, tokensIn, tokensOut int64, costUSD float64) error {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
Reference in New Issue
Block a user