[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:
mlsmaycon
2026-09-12 16:08:07 +00:00
co-authored by Claude Fable 5.1
parent 4ed71f8987
commit 5502ea08ac
17 changed files with 41 additions and 1323 deletions
@@ -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, ", "),
)
}
+3 -100
View File
@@ -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")
}