Compare commits

...

8 Commits

Author SHA1 Message Date
Brad Ison
e493efd532 chore(agentnetwork): fix config grouping, dead code, comments, coverage
- Move the new zone config key into the existing AgentNetwork config
  group (management/internals/server/config/config.go) instead of a
  sibling top-level field, wire modules.go to the new path, add the
  matching field to combined/cmd/config.go's AgentNetworkConfig and its
  mapping (it was previously unreachable in the combined binary), and
  document the key in infrastructure_files/management.json.tmpl and
  combined/config.yaml.example.

- Delete PickUnique and its three tests: Task 5 removed its last
  production caller, leaving it dead exported code with a stale
  words.go comment pointing at it.

- Reword two test comments that referenced our private review process
  instead of stating what the test locks down / why TargetId stays
  pinned to Cluster.

- Add TestBootstrapSettings_NonRetryableErrorFailsImmediately: a
  regression that dropped the isUniqueConstraintError gate and retried
  on every error would leave every existing allocator test green.

- Fix TestSynthesizeServiceForDomain_DegenerateInput's docstring: the
  early-return guard is an optimisation, not what makes "" and
  "localhost" resolve to no service.

- Replace manager.go's allocation-comment archaeology (a deleted
  per-cluster "taken" set, an `accountID[:4]` suffix, "~68 minutes") with
  the actual invariant, and note why each retry attempt gets its own
  transaction (a failed statement poisons the enclosing transaction on
  postgres).

- Collapse the per-service "no matching zone apex" debug log in
  account.go into a single line per call instead of one per skipped
  service.

- Document why idx_agent_network_settings_cluster_subdomain must stay
  on mysql: its index tag is what sizes subdomain as varchar(191)
  rather than longtext, which the new unique index requires.
2026-08-03 23:42:25 +02:00
Brad Ison
91dd9fa239 feat(dns): derive the mesh DNS apex for zone-based endpoints
The zone a private service's synthesized A record hangs under was derived
from the serving proxy's address or from a validated custom domain. A
placement-free endpoint matches neither, so the apex came out empty, the
service was skipped, and the tenant's hostname resolved to nothing -- with no
error logged.

Synthesized services now carry their zone explicitly and it is preferred when
deriving the apex. The field is in-memory only: these services are built per
read and never persisted, and the zone cannot be supplied as a parameter
instead because it is captured per account at allocation time, so a single
current-config value would misclassify any tenant allocated under a previous
one.

A blanket "use the parent of the hostname" fallback was rejected: the same
empty apex also occurs for a service whose domain has no validated entry for
its cluster, and those resolve to nothing deliberately, so a blanket fallback
would turn domain validation into a no-op.
2026-08-03 23:36:12 +02:00
Brad Ison
58d0793870 perf(agentnetwork): resolve endpoints by indexed subdomain lookup
Reverse resolution -- hostname to owning account -- prefiltered candidates by
"strip the first label and treat the rest as a cluster address". For an
endpoint whose parent is a DNS zone that matches no cluster, the prefilter
found nothing and resolution failed for every zone-based tenant.

Both endpoint shapes put the account's label in the first DNS label, and the
label is now globally unique, so one indexed point lookup resolves either
shape. This replaces the prefilter outright rather than adding a fallback
scan, which matters because the lookup runs per request from the
authentication path. A label match is not sufficient on its own -- owning
"brave-otter" does not mean owning "brave-otter.example.com" -- so the
resolved row's endpoint is still compared against the requested hostname.

Only a not-found is translated to "no such endpoint"; a genuine store failure
surfaces, so a database outage cannot be mistaken for a miss.
2026-08-03 23:36:10 +02:00
Brad Ison
29ad3fad43 feat(agentnetwork): allocate subdomains transactionally, retrying on conflict
Allocation read a per-cluster set of taken labels, picked one, and wrote it
later. That had three defects: the set was per-cluster, which is wrong once
labels must be unique across a shared zone; the read and the write were not
atomic; and on pool exhaustion it appended the first four characters of the
account ID with no retry and no uniqueness check -- and those four characters
are constant for accounts created within roughly the same hour, so two such
accounts could be handed the same label.

Allocation now picks a label and inserts it inside a transaction, retrying
with a fresh label when the database rejects a duplicate, and failing loudly
when the attempt budget is exhausted. A fresh transaction per attempt is
required rather than incidental: on PostgreSQL a failed statement poisons the
enclosing transaction, so a single transaction wrapping the loop would fail
every attempt after the first.

Because the settings primary key is the account ID, a concurrent bootstrap
for the same account fails on the primary key rather than the subdomain
index. That is indistinguishable from a label collision by message, so the
loop re-reads by account before retrying and returns the winner's row -- the
same answer the sequential path gives.
2026-08-03 23:36:08 +02:00
Brad Ison
6203528f3a feat(agentnetwork): thread the zone config through to the manager
Adds AgentNetwork.Zone to the management config and passes it to the
agent-network manager, alongside the existing plumbing in the combined
binary. Nothing reads it yet -- the allocator that stamps it onto new rows
comes next -- so this commit is inert on its own.
2026-08-03 23:36:06 +02:00
Brad Ison
fabfacee55 feat(store): globally unique subdomains and an insert that surfaces conflicts
Once an endpoint hangs off a shared zone rather than a per-cluster address,
subdomain labels must be unique across the whole zone rather than within one
cluster. Uniqueness was previously advisory -- a pre-read "taken" set with no
database constraint -- so this adds a unique index on the column and makes
the database the arbiter.

CreateAgentNetworkSettings is a plain INSERT that returns the driver error
unwrapped, both of which the allocator depends on: SaveAgentNetworkSettings
is an upsert (which cannot conflict) and wraps failures in a generic internal
error, discarding the message that unique-violation detection needs.

Note for operators: the index is created by a migration that fails, and
therefore blocks startup, on a deployment that already holds two rows with
the same subdomain on different clusters -- which was legal under the old
per-cluster scheme. Audit for duplicates before upgrading.
2026-08-03 23:36:04 +02:00
Brad Ison
df3619e1e5 feat(agentnetwork): add a placement-free Zone to settings
A tenant's endpoint is <subdomain>.<cluster>, where the cluster half is the
address of the proxy serving them. That couples the hostname to placement:
the tenant cannot be served by a different proxy without their address
changing.

Zone is a parent DNS zone captured onto the settings row when the row is
created, making the endpoint <subdomain>.<zone> instead. It is persisted per
row rather than read from config at call time for two reasons: Endpoint()
must keep its no-argument signature, because a synthesizer is registered
against a pinned signature at init() time; and persisting makes a tenant's
address immutable, so editing server config never silently moves an existing
tenant.

Zone is empty for every existing row and for any deployment that configures
none, in which case Endpoint() falls back to the previous behaviour exactly.
2026-08-03 23:36:02 +02:00
Brad Ison
b2d72534c5 feat(agentnetwork): adjective-noun subdomain label generation
Adds PickTuple, which draws an adjective and a noun to form a single DNS
label such as "brave-otter". The existing single-word generator kept a
per-cluster "taken" set and guessed uniqueness up front; a later commit
replaces that with a database constraint and a retry, so PickTuple
deliberately takes no taken set and has no fallback suffix.

The adjectives live in their own pool rather than reusing the noun list,
which is almost entirely nouns -- drawing twice from it produces
"millet-hammock", which reads as noise rather than a name. Tests assert the
curation contract the pool depends on: duplicate-free, DNS-safe, and
disjoint from the nouns.
2026-08-03 23:36:00 +02:00
29 changed files with 994 additions and 182 deletions

View File

@@ -83,6 +83,7 @@ type ServerConfig struct {
// AgentNetworkConfig contains agent-network (LLM gateway) configuration.
type AgentNetworkConfig struct {
PricingDefaultsFile string `yaml:"pricingDefaultsFile"`
Zone string `yaml:"zone"`
}
// TLSConfig contains TLS/HTTPS settings
@@ -732,6 +733,7 @@ func (c *CombinedConfig) ToManagementConfig() (*nbconfig.Config, error) {
PerAccountHighestSupportedSyncMessageVersion: c.Server.PerAccountSupportedSyncMessageVersions,
AgentNetwork: nbconfig.AgentNetwork{
PricingDefaultsFile: c.Server.AgentNetwork.PricingDefaultsFile,
Zone: c.Server.AgentNetwork.Zone,
},
}, nil
}

View File

@@ -147,3 +147,11 @@ server:
# # is re-read periodically (mtime poll). An explicitly configured path that
# # fails to load fails startup; runtime reload errors keep the previous table.
# pricingDefaultsFile: "pricing.yaml"
#
# # Parent DNS zone that Agent Network gateway endpoints are allocated
# # under, producing <subdomain>.<zone>. Empty (the default) preserves the
# # legacy behaviour of deriving the endpoint from the serving cluster, so
# # self-hosted deployments are unaffected. Captured onto each settings row
# # when that row is created; changing it later does not move existing
# # tenants.
# zone: "gateway.example.com"

View File

@@ -39,6 +39,9 @@
]
},
"DisableDefaultPolicy": $NETBIRD_MGMT_DISABLE_DEFAULT_POLICY,
"AgentNetwork": {
"Zone": "$NETBIRD_AGENT_NETWORK_ZONE"
},
"Datadir": "",
"DataStoreEncryptionKey": "$NETBIRD_DATASTORE_ENC_KEY",
"StoreConfig": {

View File

@@ -0,0 +1,296 @@
package agentnetwork
import (
"context"
"errors"
"fmt"
"math/rand"
"runtime"
"testing"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/labelgen"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
"github.com/netbirdio/netbird/management/server/store"
nbtypes "github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/shared/management/status"
)
// TestIsUniqueConstraintError_RecognisesAllThreeDialects — the allocator's
// retry loop hinges on this. A missed dialect turns a retryable collision into
// a hard provider-create failure.
func TestIsUniqueConstraintError_RecognisesAllThreeDialects(t *testing.T) {
for name, err := range map[string]error{
"postgres": errors.New(`ERROR: duplicate key value violates unique constraint (SQLSTATE 23505)`),
"mysql": errors.New(`Error 1062 (23000): Duplicate entry 'brave-otter'`),
"sqlite": errors.New(`UNIQUE constraint failed: agent_network_settings.subdomain`),
} {
assert.True(t, isUniqueConstraintError(err), "%s violation must be recognised", name)
}
assert.False(t, isUniqueConstraintError(errors.New("connection refused")),
"unrelated errors must not be treated as retryable collisions")
}
// newAllocatorTestStore wires a real sqlite store, mirroring the pattern in
// provider_bootstrap_test.go's bootstrapFixture. The allocator tests exercise
// bootstrapSettingsIfNeeded directly against a managerImpl built in-package,
// so no permissions manager or account manager is needed.
func newAllocatorTestStore(t *testing.T) store.Store {
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)
return st
}
// TestBootstrapSettings_StampsZoneAndTupleLabel — new rows must carry the
// configured zone and a tuple label, which together give the tenant a
// placement-independent address.
func TestBootstrapSettings_StampsZoneAndTupleLabel(t *testing.T) {
ctx := context.Background()
st := newAllocatorTestStore(t)
m := &managerImpl{
store: st,
zone: "gateway.example",
labelRng: rand.New(rand.NewSource(1)),
}
settings, err := m.bootstrapSettingsIfNeeded(ctx, "account1", "cluster1.example.com")
require.NoError(t, err, "bootstrap must succeed")
require.NotNil(t, settings)
assert.Equal(t, "gateway.example", settings.Zone, "new row must carry the configured zone")
assert.Equal(t, "cluster1.example.com", settings.Cluster)
assert.Contains(t, settings.Subdomain, "-", "subdomain must be an adjective-noun tuple label")
assert.Equal(t, "account1", settings.AccountID)
assert.Equal(t, settings.Subdomain+".gateway.example", settings.Endpoint(),
"endpoint must be placement-independent, hanging off the zone rather than the cluster")
persisted, err := st.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
require.NoError(t, err)
assert.Equal(t, settings.Subdomain, persisted.Subdomain, "returned settings must match the persisted row")
assert.Equal(t, "gateway.example", persisted.Zone)
}
// TestBootstrapSettings_RetriesOnCollision forces a duplicate by pre-inserting
// a row whose subdomain matches the next label the seeded rng will draw, then
// asserts allocation still succeeds with a different label and that no error
// escapes.
func TestBootstrapSettings_RetriesOnCollision(t *testing.T) {
ctx := context.Background()
st := newAllocatorTestStore(t)
const seed = 7
// Precompute the label a freshly seeded rng will draw first, without
// disturbing the rng the manager will actually use.
predictor := rand.New(rand.NewSource(seed))
firstDraw := labelgen.PickTuple(predictor)
require.NotEmpty(t, firstDraw, "test precondition: label pools must be non-empty")
// Pre-insert a colliding row on a different account so the allocator's
// first attempt hits the unique index and must retry.
require.NoError(t, st.CreateAgentNetworkSettings(ctx, &types.Settings{
AccountID: "other-account",
Cluster: "cluster1.example.com",
Subdomain: firstDraw,
}), "seeding the colliding row must succeed")
m := &managerImpl{
store: st,
labelRng: rand.New(rand.NewSource(seed)),
}
settings, err := m.bootstrapSettingsIfNeeded(ctx, "account1", "cluster1.example.com")
require.NoError(t, err, "allocation must succeed after retrying past the collision")
require.NotNil(t, settings)
assert.NotEqual(t, firstDraw, settings.Subdomain,
"the retried allocation must not reuse the already-taken label")
}
// TestBootstrapSettings_IsIdempotent — calling twice for one account returns
// the existing row unchanged (the early-return path), and does NOT
// re-allocate.
func TestBootstrapSettings_IsIdempotent(t *testing.T) {
ctx := context.Background()
st := newAllocatorTestStore(t)
m := &managerImpl{
store: st,
labelRng: rand.New(rand.NewSource(3)),
}
first, err := m.bootstrapSettingsIfNeeded(ctx, "account1", "cluster1.example.com")
require.NoError(t, err)
require.NotNil(t, first)
second, err := m.bootstrapSettingsIfNeeded(ctx, "account1", "cluster2.example.com")
require.NoError(t, err, "second call must not error")
require.NotNil(t, second)
assert.Equal(t, first.Subdomain, second.Subdomain, "second call must return the existing subdomain unchanged")
assert.Equal(t, first.Cluster, second.Cluster, "second call must not repin the cluster to the new hint")
all, err := st.GetAllAgentNetworkSettings(ctx, store.LockingStrengthNone)
require.NoError(t, err)
var forAccount int
for _, s := range all {
if s.AccountID == "account1" {
forAccount++
}
}
assert.Equal(t, 1, forAccount, "exactly one row must exist for the account; no re-allocation")
}
// TestBootstrapSettings_FailsAfterExhaustingAttempts — the retry loop's
// failure mode. maxSubdomainAllocationAttempts consecutive collisions must
// surface an error rather than inserting a duplicate, silently succeeding, or
// looping forever.
//
// Seed 11 was checked to produce maxSubdomainAllocationAttempts distinct
// labels from labelgen.PickTuple; a seed that repeated a label would leave
// fewer than maxAttempts rows pre-inserted and the allocator would succeed on
// the repeat instead of exhausting.
func TestBootstrapSettings_FailsAfterExhaustingAttempts(t *testing.T) {
ctx := context.Background()
st := newAllocatorTestStore(t)
const seed = 11
predictor := rand.New(rand.NewSource(seed))
seen := make(map[string]struct{}, maxSubdomainAllocationAttempts)
for i := 0; i < maxSubdomainAllocationAttempts; i++ {
label := labelgen.PickTuple(predictor)
_, dup := seen[label]
require.False(t, dup, "test precondition: seed %d must draw %d distinct labels, got a repeat %q at draw %d", seed, maxSubdomainAllocationAttempts, label, i)
seen[label] = struct{}{}
require.NoError(t, st.CreateAgentNetworkSettings(ctx, &types.Settings{
AccountID: fmt.Sprintf("squatter-%d", i),
Cluster: "cluster1.example.com",
Subdomain: label,
}), "seeding colliding row %d must succeed", i)
}
m := &managerImpl{
store: st,
labelRng: rand.New(rand.NewSource(seed)),
}
settings, err := m.bootstrapSettingsIfNeeded(ctx, "account1", "cluster1.example.com")
require.Error(t, err, "exhausting every attempt to a collision must not silently succeed")
assert.Nil(t, settings, "no settings row may be returned on failure")
assert.Contains(t, err.Error(), "attempts exhausted")
_, err = st.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
assert.Error(t, err, "no settings row must be persisted for the account when allocation fails")
}
// TestBootstrapSettings_ConcurrentBootstrapReturnsWinnersRow covers the
// same-account race: Settings' primary key is AccountID, and the
// existence pre-check in bootstrapSettingsIfNeeded runs outside the
// transaction, so two concurrent first-provider creates for the same
// account can both observe NotFound and both proceed to allocate. The
// loser's INSERT then fails on the primary key rather than the subdomain
// unique index — a string isUniqueConstraintError still recognises — and
// must not be treated as a label collision to retry past; it must
// re-read and return the winner's row.
//
// This is scripted against a gomock store rather than driven by real
// goroutines against the sqlite test store: NewTestStoreFromSQL caps the
// pool at a single open connection (see its startup log,
// "max open db connections to 1"), which serialises statement execution
// enough that reliably forcing the exact interleaving this test needs —
// both pre-checks observing NotFound before either INSERT lands — would
// depend on goroutine scheduling rather than the store, making a
// real-goroutine version flaky rather than deterministic. Scripting the
// exact sequence (pre-check miss, PK-shaped insert failure, re-read hit)
// through a MockStore exercises the same re-read branch precisely and
// deterministically.
func TestBootstrapSettings_ConcurrentBootstrapReturnsWinnersRow(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
mockStore := store.NewMockStore(ctrl)
winner := &types.Settings{
AccountID: "account1",
Cluster: "cluster1.example.com",
Subdomain: "brave-otter",
}
gomock.InOrder(
// The pre-check: no row yet, so this bootstrap proceeds to allocate.
mockStore.EXPECT().
GetAgentNetworkSettings(gomock.Any(), store.LockingStrengthNone, "account1").
Return(nil, status.Errorf(status.NotFound, "agent network settings not found")),
// The insert loses the race. The message shape is the sqlite wording
// for a primary-key violation on account_id (not the subdomain
// index); this test locks down that the retry path recognizes that
// shape as a race loss and re-reads the winner's row, rather than
// misclassifying it as a subdomain conflict.
mockStore.EXPECT().
ExecuteInTransaction(gomock.Any(), gomock.Any()).
DoAndReturn(func(_ context.Context, f func(store.Store) error) error {
return f(mockStore)
}),
// The re-read after the PK conflict finds the concurrent winner's row.
mockStore.EXPECT().
GetAgentNetworkSettings(gomock.Any(), store.LockingStrengthNone, "account1").
Return(winner, nil),
)
mockStore.EXPECT().
CreateAgentNetworkSettings(gomock.Any(), gomock.Any()).
Return(errors.New("UNIQUE constraint failed: agent_network_settings.account_id"))
m := &managerImpl{
store: mockStore,
labelRng: rand.New(rand.NewSource(9)),
}
settings, err := m.bootstrapSettingsIfNeeded(ctx, "account1", "cluster1.example.com")
require.NoError(t, err, "losing the same-account race must not surface as an error")
require.NotNil(t, settings)
assert.Same(t, winner, settings, "the loser must return the concurrent winner's row, not retry past it")
}
// TestBootstrapSettings_NonRetryableErrorFailsImmediately guards the
// isUniqueConstraintError branch itself: a regression that dropped that check
// and retried on every ExecuteInTransaction error would leave every other test
// in this file green, because none of them feed the loop a non-collision
// failure. A generic store error must surface immediately, wrapped, and must
// not be retried — asserting ExecuteInTransaction was called exactly once is
// what proves the loop didn't retry.
func TestBootstrapSettings_NonRetryableErrorFailsImmediately(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
mockStore := store.NewMockStore(ctrl)
mockStore.EXPECT().
GetAgentNetworkSettings(gomock.Any(), store.LockingStrengthNone, "account1").
Return(nil, status.Errorf(status.NotFound, "agent network settings not found"))
mockStore.EXPECT().
ExecuteInTransaction(gomock.Any(), gomock.Any()).
Return(errors.New("connection refused")).
Times(1)
m := &managerImpl{
store: mockStore,
labelRng: rand.New(rand.NewSource(5)),
}
settings, err := m.bootstrapSettingsIfNeeded(ctx, "account1", "cluster1.example.com")
require.Error(t, err, "a non-collision store error must surface, not be swallowed")
assert.Nil(t, settings)
assert.Contains(t, err.Error(), "create agent network settings",
"the non-retryable error must be wrapped and returned, not retried past")
}

View File

@@ -0,0 +1,120 @@
package agentnetwork
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/server/store"
)
// TestSynthesizeServiceForDomain_ResolvesZoneBasedEndpoint — with a Zone the
// hostname's parent is the zone, not the cluster, so the old "strip the first
// label and match a cluster" prefilter found nothing and every zone-based
// tenant failed to resolve on the auth path.
func TestSynthesizeServiceForDomain_ResolvesZoneBasedEndpoint(t *testing.T) {
ctx := context.Background()
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
require.NoError(t, err, "real sqlite test store must come up")
defer cleanup()
settings := newSynthTestSettings()
settings.Cluster = "eu.proxy.netbird.io"
settings.Zone = "gateway.netbird.ai"
settings.Subdomain = "brave-otter"
require.NoError(t, s.SaveAgentNetworkSettings(ctx, settings))
provider := newSynthTestProvider()
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
domain := "brave-otter.gateway.netbird.ai"
svc, err := SynthesizeServiceForDomain(ctx, s, domain)
require.NoError(t, err)
require.NotNil(t, svc, "zone-based endpoint must resolve to the owning account's service")
assert.Equal(t, domain, svc.Domain)
}
// TestSynthesizeServiceForDomain_ResolvesLegacyClusterEndpoint — the
// non-breaking guarantee. A row with no Zone still resolves at
// <subdomain>.<cluster>, because the subdomain is the first label either way.
func TestSynthesizeServiceForDomain_ResolvesLegacyClusterEndpoint(t *testing.T) {
ctx := context.Background()
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
require.NoError(t, err, "real sqlite test store must come up")
defer cleanup()
settings := newSynthTestSettings()
settings.Cluster = "eu.proxy.netbird.io"
settings.Zone = ""
settings.Subdomain = "swift-heron"
require.NoError(t, s.SaveAgentNetworkSettings(ctx, settings))
provider := newSynthTestProvider()
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
domain := "swift-heron.eu.proxy.netbird.io"
svc, err := SynthesizeServiceForDomain(ctx, s, domain)
require.NoError(t, err)
require.NotNil(t, svc, "legacy cluster-based endpoint must still resolve")
assert.Equal(t, domain, svc.Domain)
}
// TestSynthesizeServiceForDomain_LabelMatchesButParentDoesNot — the label is
// globally unique, so a lookup by first label can hit a row that does NOT own
// the queried hostname. That must resolve to nothing rather than to the wrong
// account's service.
func TestSynthesizeServiceForDomain_LabelMatchesButParentDoesNot(t *testing.T) {
ctx := context.Background()
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
require.NoError(t, err, "real sqlite test store must come up")
defer cleanup()
settings := newSynthTestSettings()
settings.Cluster = "eu.proxy.netbird.io"
settings.Zone = "gateway.netbird.ai"
settings.Subdomain = "brave-otter"
require.NoError(t, s.SaveAgentNetworkSettings(ctx, settings))
provider := newSynthTestProvider()
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
svc, err := SynthesizeServiceForDomain(ctx, s, "brave-otter.someone-elses.zone")
require.NoError(t, err)
assert.Nil(t, svc, "label matched a different endpoint's parent; must not resolve to the wrong account")
}
// TestSynthesizeServiceForDomain_UnknownLabel — a hostname whose first label
// belongs to no account is a miss, not an error: the caller falls back to the
// persisted-service lookup and a returned error would mask that.
func TestSynthesizeServiceForDomain_UnknownLabel(t *testing.T) {
ctx := context.Background()
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
require.NoError(t, err, "real sqlite test store must come up")
defer cleanup()
svc, err := SynthesizeServiceForDomain(ctx, s, "nobody-home.gateway.netbird.ai")
require.NoError(t, err)
assert.Nil(t, svc, "unknown label must be a miss, not an error")
}
// TestSynthesizeServiceForDomain_DegenerateInput — empty and single-label
// hostnames have no dot to cut a subdomain label from, so they resolve to no
// service, same as any other unowned hostname. The early-return guard that
// catches them is an optimisation (it skips a store round trip that would
// only miss anyway), not what makes this case correct — "" and "localhost"
// would still come back nil, nil even without it, via the same not-found
// fallthrough TestSynthesizeServiceForDomain_UnknownLabel exercises.
func TestSynthesizeServiceForDomain_DegenerateInput(t *testing.T) {
ctx := context.Background()
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
require.NoError(t, err, "real sqlite test store must come up")
defer cleanup()
for _, domain := range []string{"", "localhost"} {
svc, err := SynthesizeServiceForDomain(ctx, s, domain)
require.NoError(t, err, "domain %q", domain)
assert.Nil(t, svc, "domain %q has no subdomain label to look up", domain)
}
}

View File

@@ -61,7 +61,7 @@ func newAgentNetworkHandlerFixture(t *testing.T) *agentNetworkHandlerFixture {
Return(true, context.Background(), nil).
AnyTimes()
manager := agentnetwork.NewManager(st, perms, nil, nil)
manager := agentnetwork.NewManager(st, perms, nil, nil, "")
h := &handler{manager: manager}
router := mux.NewRouter()

View File

@@ -0,0 +1,39 @@
// Package labelgen produces DNS-safe Agent Network subdomain labels.
//
// The adjective pool below pairs with the noun pool in words.go to form
// `<adjective>-<noun>` labels. It is kept separate because words.go is almost
// entirely nouns — drawing both halves from it produced unreadable pairs like
// "millet-hammock". Entries are lowercase ASCII, 4-12 chars, free of hyphens
// and digits, screened for offensive/brand/region-specific terms, and disjoint
// from the noun pool (enforced by TestAdjectives_AreDisjointFromNouns).
package labelgen
// adjectives is the descriptor half of a generated label.
var adjectives = []string{
"able", "active", "adept", "agile", "airy", "alert", "amiable", "ample",
"ancient", "ardent", "artful", "astute", "balmy", "blithe", "bold", "bonny",
"brave", "breezy", "brisk", "bubbly", "buoyant", "bushy", "candid", "canny",
"cheery", "chilly", "chipper", "chunky", "civil", "classic", "clever", "comely",
"compact", "cordial", "cosmic", "courtly", "crafty", "creamy", "crisp", "cuddly",
"curious", "dainty", "dapper", "daring", "dashing", "deft", "dewy", "diligent",
"downy", "dreamy", "dulcet", "durable", "dusky", "eager", "earnest", "earthy",
"easy", "elated", "elegant", "epic", "fabled", "faithful", "fancy", "fearless",
"feisty", "fervent", "fleet", "fluffy", "fond", "frisky", "frosty", "gallant",
"genial", "genteel", "gentle", "giddy", "gilded", "glad", "glassy", "gleaming",
"glossy", "graceful", "grand", "grainy", "hale", "hardy", "hearty", "hefty",
"honest", "hopeful", "humble", "hushed", "immense", "jaunty", "jolly", "jovial",
"joyful", "jubilant", "keen", "kindly", "kindred", "lanky", "leafy", "limber",
"lively", "lofty", "loyal", "lucent", "lucid", "luminous", "lush", "maroon",
"mellow", "merry", "mighty", "mindful", "mirthful", "misty", "modest", "muted",
"nifty", "nimble", "noble", "patient", "peaceful", "pearly", "peppy", "perky",
"petite", "placid", "playful", "pleasant", "plucky", "plush", "polite", "posh",
"prancing", "pristine", "prompt", "proud", "prudent", "quaint", "quick", "quirky",
"radiant", "ready", "regal", "restful", "robust", "rosy", "ruddy", "rugged",
"sandy", "satin", "saucy", "savvy", "sedate", "serene", "shady", "shiny",
"silken", "silky", "sincere", "sleek", "slender", "smart", "smooth", "snappy",
"snug", "soaring", "sparkly", "spiffy", "spirited", "sprightly", "spry", "stalwart",
"stately", "steady", "sterling", "stoic", "stormy", "stout", "sturdy", "sunlit",
"supple", "svelte", "tawny", "tender", "tidy", "timeless", "trusty", "upbeat",
"urbane", "valiant", "vast", "vernal", "vibrant", "vintage", "whimsy", "willing",
"windy", "winsome", "wintry", "witty", "worthy", "zesty", "zippy",
}

View File

@@ -2,18 +2,11 @@
package labelgen
import (
"fmt"
"math/rand"
"sort"
"sync"
)
// pickAttempts caps the random retries before falling back to the
// suffixed form. Eight is a soft compromise: with a near-empty taken
// set the very first pick almost always succeeds; when the wordlist is
// densely populated the fallback eventually fires anyway.
const pickAttempts = 8
var (
dedupOnce sync.Once
uniqWords []string
@@ -37,30 +30,19 @@ func uniqueWords() []string {
return uniqWords
}
// PickUnique selects a label not already in `taken`. It tries up to
// pickAttempts random picks; on exhaustion it scans the deduplicated
// wordlist for any remaining free entry, and if none is left appends
// `-<fallbackSuffix>` to a deterministic word and returns. The caller
// is responsible for seeding rng (math/rand).
func PickUnique(rng *rand.Rand, taken map[string]struct{}, fallbackSuffix string) string {
pool := uniqueWords()
if len(pool) == 0 {
return fallbackSuffix
// PickTuple returns an adjective-noun label such as "brave-otter". It is still
// a single DNS label.
//
// It takes no `taken` set and has no fallback suffix. The noun pool holds 857
// entries, which is ample per cluster but a hard ceiling once labels must be
// unique across one shared zone; pairing an adjective with a noun spans
// len(adjectives) * 857 instead. Uniqueness is enforced by a database
// constraint and retried by the caller, rather than guessed from a pre-read
// set that a concurrent allocation can invalidate.
func PickTuple(rng *rand.Rand) string {
nouns := uniqueWords()
if len(nouns) == 0 || len(adjectives) == 0 {
return ""
}
for i := 0; i < pickAttempts; i++ {
w := pool[rng.Intn(len(pool))]
if _, ok := taken[w]; !ok {
return w
}
}
for _, w := range pool {
if _, ok := taken[w]; !ok {
return w
}
}
w := pool[rng.Intn(len(pool))]
return fmt.Sprintf("%s-%s", w, fallbackSuffix)
return adjectives[rng.Intn(len(adjectives))] + "-" + nouns[rng.Intn(len(nouns))]
}

View File

@@ -9,78 +9,6 @@ import (
"github.com/stretchr/testify/require"
)
// TestPickUnique_DeterministicWithSeededRng locks the property the
// caller relies on: same seed + same taken set → same pick. Without
// that, the bootstrap flow can't reproduce a label across retries.
func TestPickUnique_DeterministicWithSeededRng(t *testing.T) {
taken := map[string]struct{}{}
rngA := rand.New(rand.NewSource(42))
rngB := rand.New(rand.NewSource(42))
a := PickUnique(rngA, taken, "abcd")
b := PickUnique(rngB, taken, "abcd")
assert.Equal(t, a, b, "Same seed and taken set must produce identical pick")
}
// TestPickUnique_AvoidsTakenWordsWhenMostAreReserved seeds taken with
// every word in the pool except a handful and confirms PickUnique
// finds one of the remaining free entries instead of returning the
// fallback form.
func TestPickUnique_AvoidsTakenWordsWhenMostAreReserved(t *testing.T) {
pool := uniqueWords()
require.NotEmpty(t, pool, "wordlist must be populated for the test to mean anything")
free := map[string]struct{}{
pool[0]: {},
pool[len(pool)/2]: {},
pool[len(pool)-1]: {},
}
taken := make(map[string]struct{}, len(pool))
for _, w := range pool {
if _, ok := free[w]; ok {
continue
}
taken[w] = struct{}{}
}
rng := rand.New(rand.NewSource(7))
got := PickUnique(rng, taken, "abcd")
_, isFree := free[got]
assert.True(t, isFree, "PickUnique must return one of the free words; got %q", got)
assert.NotContains(t, got, "-", "Free pick must not be the suffix fallback form")
}
// TestPickUnique_FallsBackWhenAllReserved exhausts the pool and
// confirms PickUnique appends the supplied suffix instead of
// returning a duplicate.
func TestPickUnique_FallsBackWhenAllReserved(t *testing.T) {
pool := uniqueWords()
taken := make(map[string]struct{}, len(pool))
for _, w := range pool {
taken[w] = struct{}{}
}
rng := rand.New(rand.NewSource(99))
got := PickUnique(rng, taken, "abcd")
assert.True(t, strings.HasSuffix(got, "-abcd"), "Exhausted pool must produce <word>-<suffix>; got %q", got)
prefix := strings.TrimSuffix(got, "-abcd")
found := false
for _, w := range pool {
if w == prefix {
found = true
break
}
}
assert.True(t, found, "Fallback prefix must be drawn from the wordlist; got %q", prefix)
}
// TestUniqueWords_DropsDuplicates guards against authoring slips in
// words.go: every entry must be unique and DNS-safe.
func TestUniqueWords_DropsDuplicates(t *testing.T) {
@@ -99,3 +27,82 @@ func TestUniqueWords_DropsDuplicates(t *testing.T) {
}
assert.GreaterOrEqual(t, len(pool), 500, "Pool must contain at least 500 unique words")
}
// TestPickTuple_ShapeAndPoolMembership locks the wire-visible shape: an
// adjective and a noun, each from its own pool, joined by a single hyphen so
// the result stays one DNS label.
func TestPickTuple_ShapeAndPoolMembership(t *testing.T) {
nouns := uniqueWords()
inNouns := make(map[string]struct{}, len(nouns))
for _, w := range nouns {
inNouns[w] = struct{}{}
}
inAdjectives := make(map[string]struct{}, len(adjectives))
for _, a := range adjectives {
inAdjectives[a] = struct{}{}
}
rng := rand.New(rand.NewSource(7))
for i := 0; i < 200; i++ {
got := PickTuple(rng)
parts := strings.Split(got, "-")
require.Len(t, parts, 2, "PickTuple must produce exactly two hyphen-joined words; got %q", got)
_, adjOK := inAdjectives[parts[0]]
assert.True(t, adjOK, "First half must be an adjective; %q not in adjectives (from %q)", parts[0], got)
_, nounOK := inNouns[parts[1]]
assert.True(t, nounOK, "Second half must be a noun; %q not in words (from %q)", parts[1], got)
assert.LessOrEqual(t, len(got), 63, "Label must fit a DNS label; got %q (%d chars)", got, len(got))
}
}
// TestAdjectives_AreDisjointFromNouns keeps the namespace a clean product and
// prevents nonsense like "azure-azure": a handful of the noun pool's entries
// are adjectival, and any overlap would let the same word land on both sides.
func TestAdjectives_AreDisjointFromNouns(t *testing.T) {
nouns := make(map[string]struct{}, len(uniqueWords()))
for _, w := range uniqueWords() {
nouns[w] = struct{}{}
}
for _, a := range adjectives {
_, clash := nouns[a]
assert.False(t, clash, "Adjective %q also appears in the noun pool; remove it from one list", a)
}
}
// TestAdjectives_AreDNSSafeAndDeduplicated mirrors the curation contract stated
// in words.go: lowercase ASCII, 4-12 chars, no digits or hyphens, no repeats.
func TestAdjectives_AreDNSSafeAndDeduplicated(t *testing.T) {
seen := make(map[string]struct{}, len(adjectives))
for _, a := range adjectives {
_, dup := seen[a]
assert.False(t, dup, "Duplicate adjective %q", a)
seen[a] = struct{}{}
assert.Regexp(t, `^[a-z]{4,12}$`, a, "Adjective %q must be 4-12 lowercase ASCII letters", a)
}
assert.Greater(t, len(adjectives), 150, "Adjective pool too small to give a useful namespace")
}
// TestPickTuple_DeterministicWithSeededRng documents that generation is a pure
// function of the rng, which is what makes allocation retries reproducible in tests.
func TestPickTuple_DeterministicWithSeededRng(t *testing.T) {
a := PickTuple(rand.New(rand.NewSource(42)))
b := PickTuple(rand.New(rand.NewSource(42)))
assert.Equal(t, a, b, "Same seed must yield the same tuple")
}
// TestPickTuple_SpansALargeNamespace guards the reason we moved to tuples: a
// single-word pool caps the GLOBAL namespace at 857. Drawing many tuples must
// yield overwhelmingly distinct values.
func TestPickTuple_SpansALargeNamespace(t *testing.T) {
rng := rand.New(rand.NewSource(11))
seen := make(map[string]struct{}, 2000)
for i := 0; i < 2000; i++ {
seen[PickTuple(rng)] = struct{}{}
}
assert.Greater(t, len(seen), 1900,
"2000 draws should be nearly all distinct across a ~200k namespace; got %d unique", len(seen))
}

View File

@@ -6,7 +6,7 @@
// hand-checked to avoid offensive, brand, or region-specific terms.
package labelgen
// words is the pool PickUnique selects from. The slice is intentionally
// words is the pool PickTuple draws its noun from. The slice is intentionally
// not sorted — random picks distribute across the list naturally.
var words = []string{
"acorn", "adobe", "agate", "alder", "almond", "alpine", "amber", "amethyst",

View File

@@ -122,6 +122,10 @@ type managerImpl struct {
permissionsManager permissions.Manager
proxyController proxy.Controller
// zone is the parent DNS zone stamped onto newly allocated settings rows.
// Empty keeps the legacy <subdomain>.<cluster> endpoint form.
zone string
// reconcileCache holds the last set of synthesised proxy mappings
// per account so reconcile can emit precise Create/Update/Delete
// updates instead of a full re-push on every mutation. Keyed by
@@ -129,7 +133,7 @@ type managerImpl struct {
reconcileMu sync.Mutex
reconcileCache map[string]map[string]*proto.ProxyMapping
// labelRngMu guards labelRng. PickUnique consumes math/rand.Source
// labelRngMu guards labelRng. PickTuple consumes math/rand.Source
// state; concurrent provider creates would otherwise race.
labelRngMu sync.Mutex
labelRng *rand.Rand
@@ -145,12 +149,14 @@ func NewManager(
permissionsManager permissions.Manager,
accountManager account.Manager,
proxyController proxy.Controller,
zone string,
) Manager {
return &managerImpl{
store: store,
accountManager: accountManager,
permissionsManager: permissionsManager,
proxyController: proxyController,
zone: zone,
reconcileCache: make(map[string]map[string]*proto.ProxyMapping),
labelRng: rand.New(rand.NewSource(time.Now().UnixNano())),
}
@@ -303,6 +309,22 @@ func (m *managerImpl) DeleteProvider(ctx context.Context, accountID, userID, pro
return nil
}
// isUniqueConstraintError reports whether err is a duplicate-key rejection.
//
// The equivalent helper in management/server is unexported, so it cannot be
// reused from here; this is a deliberate duplicate rather than a new dependency
// on that package for a single three-line matcher. Keep the two in sync if a
// dialect is added.
func isUniqueConstraintError(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "(SQLSTATE 23505)") || // postgres
strings.Contains(msg, "Error 1062 (23000)") || // mysql
strings.Contains(msg, "UNIQUE constraint failed") // sqlite
}
func pluralize(n int, singular, plural string) string {
if n == 1 {
return singular
@@ -626,12 +648,6 @@ func (m *managerImpl) GetSettings(ctx context.Context, accountID, userID string)
return m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
}
// bootstrapSettingsIfNeeded creates the per-account agent-network
// settings row when missing. The cluster comes from the create-time
// hint the dashboard sends (auto-picked from the active cluster list);
// the subdomain is picked from the curated wordlist avoiding
// collisions on the same cluster. Idempotent: if a row already exists
// it is returned untouched and the hint is ignored.
// requireSettingsBootstrapPermission gates the one-time settings bootstrap a
// first provider create performs. Pinning the account's cluster and subdomain
// is a settings write, so it needs the settings permission on top of the
@@ -648,6 +664,15 @@ func (m *managerImpl) requireSettingsBootstrapPermission(ctx context.Context, ac
return m.requirePermission(ctx, accountID, userID, modules.AgentNetworkSettings, operations.Create)
}
// maxSubdomainAllocationAttempts bounds the allocate-and-insert retry loop in
// bootstrapSettingsIfNeeded. Package-level (rather than function-local) so
// tests can assert on the exhaustion path without duplicating the literal.
const maxSubdomainAllocationAttempts = 10
// bootstrapSettingsIfNeeded creates the per-account agent-network settings
// row when missing, allocating a subdomain unique across the whole zone.
// Idempotent: if a row already exists it is returned untouched and the
// cluster hint is ignored.
func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, accountID, providerCluster string) (*types.Settings, error) {
if accountID == "" {
return nil, fmt.Errorf("bootstrap settings: account id is required")
@@ -665,40 +690,66 @@ func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, accountID,
return nil, fmt.Errorf("get agent network settings: %w", err)
}
siblings, err := m.store.GetAgentNetworkSettingsByCluster(ctx, store.LockingStrengthNone, providerCluster)
if err != nil {
return nil, fmt.Errorf("list agent network settings on cluster: %w", err)
}
taken := make(map[string]struct{}, len(siblings))
for _, s := range siblings {
taken[s.Subdomain] = struct{}{}
}
suffix := accountID
if len(suffix) > 4 {
suffix = suffix[:4]
}
m.labelRngMu.Lock()
subdomain := labelgen.PickUnique(m.labelRng, taken, suffix)
m.labelRngMu.Unlock()
// Labels must be unique across the whole zone; the database's unique index
// enforces that, and the loop below retries with a fresh label whenever an
// attempt is rejected.
now := time.Now().UTC()
settings := &types.Settings{
AccountID: accountID,
Cluster: providerCluster,
Subdomain: subdomain,
// Logs on by default; usage is collected regardless. Retention bounds
// how long full log rows are kept.
AccountID: accountID,
Cluster: providerCluster,
Zone: m.zone,
EnableLogCollection: true,
AccessLogRetentionDays: types.DefaultAccessLogRetentionDays,
CreatedAt: now,
UpdatedAt: now,
}
if err := m.store.SaveAgentNetworkSettings(ctx, settings); err != nil {
return nil, fmt.Errorf("save agent network settings: %w", err)
for attempt := 1; attempt <= maxSubdomainAllocationAttempts; attempt++ {
m.labelRngMu.Lock()
settings.Subdomain = labelgen.PickTuple(m.labelRng)
m.labelRngMu.Unlock()
if settings.Subdomain == "" {
// Only reachable if either word pool were emptied; a database
// insert of an empty subdomain would collide with the unique
// index in a confusing way and produce a broken endpoint like
// ".gateway.example". Fail loudly instead of looping or inserting.
return nil, fmt.Errorf(
"allocate agent network subdomain for account %s: label generator returned an empty label",
accountID)
}
// Each attempt gets its own transaction wrapping a single INSERT: on
// postgres a failed statement poisons the enclosing transaction, so a
// fresh transaction per attempt is what makes the retry loop work on
// that dialect at all.
err := m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
return transaction.CreateAgentNetworkSettings(ctx, settings)
})
if err == nil {
return settings, nil
}
if isUniqueConstraintError(err) {
// A concurrent bootstrap for this account may have won the race: the
// pre-check above is outside the transaction, and the settings PK is
// account_id, so the loser's insert fails on the primary key rather
// than the subdomain index. Re-read before assuming the label was
// taken, so a same-account race resolves immediately instead of
// burning every remaining attempt on the same primary-key conflict.
if existing, getErr := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID); getErr == nil {
return existing, nil
}
log.WithContext(ctx).Tracef(
"agent-network subdomain %q taken, retrying (attempt %d/%d)",
settings.Subdomain, attempt, maxSubdomainAllocationAttempts)
continue
}
return nil, fmt.Errorf("create agent network settings: %w", err)
}
return settings, nil
return nil, fmt.Errorf(
"allocate agent network subdomain for account %s: %d attempts exhausted",
accountID, maxSubdomainAllocationAttempts)
}
// ListConsumption returns every consumption row recorded for the

View File

@@ -48,7 +48,7 @@ func newBootstrapFixture(t *testing.T) *bootstrapFixture {
accounts.EXPECT().BufferUpdateAccountPeers(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
return &bootstrapFixture{
manager: NewManager(st, perms, accounts, nil),
manager: NewManager(st, perms, accounts, nil, ""),
store: st,
perms: perms,
}

View File

@@ -116,45 +116,46 @@ func SynthesizeServicesForCluster(ctx context.Context, s store.Store, clusterAdd
}
// SynthesizeServiceForDomain resolves a single agent-network service by its
// public endpoint domain. It lists the (few) settings rows on the domain's
// cluster, matches the one whose endpoint equals the domain, and synthesises
// only that account — avoiding full per-account synthesis for every tenant on
// the cluster, which is what auth/session paths previously paid. Returns nil
// (no error) when no account owns the domain.
// endpoint hostname. Both endpoint shapes put the account's label in the first
// DNS label — <subdomain>.<cluster> and <subdomain>.<zone> — and the label is
// globally unique, so this is a single indexed lookup for either shape. It
// synthesises only the owning account rather than every tenant on a cluster,
// which is what auth/session paths previously paid. Returns nil (no error) when
// no account owns the hostname.
func SynthesizeServiceForDomain(ctx context.Context, s store.Store, domain string) (*rpservice.Service, error) {
domain = strings.TrimSpace(domain)
cluster := clusterFromDomain(domain)
if domain != "" && cluster != "" {
settingsRows, err := s.GetAgentNetworkSettingsByCluster(ctx, store.LockingStrengthNone, cluster)
if err != nil {
return nil, fmt.Errorf("list agent network settings on cluster: %w", err)
}
for _, settings := range settingsRows {
if settings == nil || settings.Endpoint() != domain {
continue
}
services, serr := SynthesizeServices(ctx, s, settings.AccountID)
if serr != nil {
return nil, serr
}
for _, svc := range services {
if svc != nil && svc.Domain == domain {
return svc, nil
}
}
break
}
subdomain, _, found := strings.Cut(domain, ".")
if !found || subdomain == "" {
return nil, nil //nolint:nilnil // no label to resolve: not an owned endpoint
}
return nil, nil //nolint:nilnil // optional lookup: no account owns the domain
}
// clusterFromDomain returns the cluster portion of an endpoint domain (every
// label after the first).
func clusterFromDomain(domain string) string {
if i := strings.IndexByte(domain, '.'); i >= 0 {
return domain[i+1:]
settings, err := s.GetAgentNetworkSettingsBySubdomain(ctx, store.LockingStrengthNone, subdomain)
if err != nil {
var sErr *status.Error
if errors.As(err, &sErr) && sErr.Type() == status.NotFound {
return nil, nil //nolint:nilnil // no account owns the label
}
// A real store failure must surface: the caller treats nil as "not an
// agent-network endpoint" and would silently mask a database error.
return nil, fmt.Errorf("get agent network settings by subdomain: %w", err)
}
return ""
// The label is unique but the parent is not implied by it: a row owning
// "brave-otter" does not own "brave-otter.some-other.zone".
if settings.Endpoint() != domain {
return nil, nil //nolint:nilnil // label matched a different endpoint
}
services, err := SynthesizeServices(ctx, s, settings.AccountID)
if err != nil {
return nil, err
}
for _, svc := range services {
if svc != nil && svc.Domain == domain {
return svc, nil
}
}
return nil, nil //nolint:nilnil // owner found but it emits no service
}
// SynthesizeServices builds the in-memory reverse-proxy service that
@@ -944,6 +945,7 @@ func buildAccountService(
Name: "agent-network-" + accountID,
Domain: domain,
ProxyCluster: cluster,
DNSZone: settings.Zone, // empty for legacy rows → unchanged behavior
Mode: rpservice.ModeHTTP,
Enabled: true,
Private: true,

View File

@@ -18,6 +18,14 @@ type Settings struct {
AccountID string `gorm:"primaryKey"`
Cluster string
Subdomain string `gorm:"index:idx_agent_network_settings_cluster_subdomain"`
// Zone is the placement-independent parent zone the endpoint lives under,
// captured from server config when the row is allocated. Immutable, like
// Cluster and Subdomain.
//
// Empty means "legacy": the endpoint falls back to <subdomain>.<cluster>,
// which embeds the serving proxy. Existing rows and any deployment that
// configures no zone keep that behaviour unchanged.
Zone string
// Account-level collection controls sourced by the synthesizer.
// EnableLogCollection gates the per-request access-log trail and defaults
@@ -42,9 +50,17 @@ type Settings struct {
// schema cohesive.
func (Settings) TableName() string { return "agent_network_settings" }
// Endpoint returns the bare hostname agents reach this account at:
// `<subdomain>.<cluster>`.
// Endpoint returns the bare hostname agents reach this account at.
//
// With a Zone set this is `<subdomain>.<zone>` — deliberately independent of
// which proxy serves the account, so moving between a shared and a private
// proxy (or between clusters) is a DNS change only and never alters the
// tenant's address. With no Zone it falls back to the legacy
// `<subdomain>.<cluster>` form.
func (s *Settings) Endpoint() string {
if s.Zone != "" {
return s.Subdomain + "." + s.Zone
}
return s.Subdomain + "." + s.Cluster
}

View File

@@ -0,0 +1,31 @@
package types
import (
"testing"
"github.com/stretchr/testify/assert"
)
// TestEndpoint_PrefersZoneOverCluster locks the decoupling: when a Zone is set
// the hostname must NOT embed the serving cluster, so moving a tenant between
// proxies never changes their address.
func TestEndpoint_PrefersZoneOverCluster(t *testing.T) {
s := &Settings{Subdomain: "brave-otter", Cluster: "eu.proxy.netbird.io", Zone: "gateway.netbird.ai"}
assert.Equal(t, "brave-otter.gateway.netbird.ai", s.Endpoint())
}
// TestEndpoint_FallsBackToClusterWhenZoneEmpty is the compatibility guarantee:
// existing rows (and every self-hosted deployment, which sets no zone) keep
// exactly the address they have today.
func TestEndpoint_FallsBackToClusterWhenZoneEmpty(t *testing.T) {
s := &Settings{Subdomain: "otter", Cluster: "eu.proxy.netbird.io"}
assert.Equal(t, "otter.eu.proxy.netbird.io", s.Endpoint())
}
// TestToAPIResponse_ExposesZoneAndDerivedEndpoint — the dashboard renders
// Endpoint verbatim, so it must reflect the zone.
func TestToAPIResponse_ExposesZoneAndDerivedEndpoint(t *testing.T) {
s := &Settings{Subdomain: "brave-otter", Cluster: "eu.proxy.netbird.io", Zone: "gateway.netbird.ai"}
resp := s.ToAPIResponse()
assert.Equal(t, "brave-otter.gateway.netbird.ai", resp.Endpoint)
}

View File

@@ -255,6 +255,13 @@ type Service struct {
Private bool
// AccessGroups is the group ID allowlist for inbound peers on private services. Mutually exclusive with bearer SSO.
AccessGroups []string `json:"access_groups,omitempty" gorm:"serializer:json"`
// DNSZone is the parent zone a private service's synthesized mesh A record
// hangs under, for the case where that zone cannot be derived from
// ProxyCluster or a validated custom domain — i.e. placement-free
// agent-network endpoints, which are <subdomain>.<zone>. In-memory only:
// set by the agent-network synthesizer on services it builds per read,
// never stored and never exposed on the API or the proxy wire.
DNSZone string `gorm:"-" json:"-"`
}
// InitNewRecord generates a new unique ID and resets metadata for a newly created
@@ -1412,6 +1419,7 @@ func (s *Service) Copy() *Service {
PortAutoAssigned: s.PortAutoAssigned,
Private: s.Private,
AccessGroups: accessGroups,
DNSZone: s.DNSZone,
}
}

View File

@@ -1215,6 +1215,17 @@ func TestService_Copy_RoundtripsPrivate(t *testing.T) {
assert.Equal(t, []string{"grp-admins", "grp-ops"}, svc.AccessGroups)
}
// TestServiceCopy_PreservesDNSZone — DNSZone is in-memory only, so it is easy
// to omit from Copy()'s explicit field list; if it is dropped, a copied
// account silently loses its zone apex and the tenant's endpoint resolves to
// nothing.
func TestServiceCopy_PreservesDNSZone(t *testing.T) {
svc := &Service{Domain: "brave-otter.gateway.netbird.ai", DNSZone: "gateway.netbird.ai"}
cp := svc.Copy()
require.NotNil(t, cp)
assert.Equal(t, "gateway.netbird.ai", cp.DNSZone)
}
func TestService_APIRoundtrip_Private(t *testing.T) {
enabled := true
private := true

View File

@@ -204,6 +204,15 @@ type AgentNetwork struct {
// prefill with). An explicitly configured path that fails to load
// fails startup; runtime reload errors keep the previous table.
PricingDefaultsFile string
// Zone is the parent DNS zone that Agent Network gateway endpoints are
// allocated under, producing <subdomain>.<zone>.
//
// Empty (the default) preserves the legacy behaviour of deriving the
// endpoint from the serving cluster, so self-hosted deployments are
// unaffected. It is captured onto each settings row when that row is
// created; changing it later does not move existing tenants.
Zone string
}
// ReverseProxy contains reverse proxy configuration in front of management.

View File

@@ -202,6 +202,7 @@ func (s *BaseServer) AgentNetworkManager() agentnetwork.Manager {
s.PermissionsManager(),
s.AccountManager(),
s.ServiceProxyController(),
s.Config.AgentNetwork.Zone,
)
// Sweep expired agent-network access logs per account retention,
// reusing the reverse-proxy cleanup interval config.

View File

@@ -30,7 +30,7 @@ func TestAgentNetwork_BudgetRuleCRUD_RealManager(t *testing.T) {
account := newAccountWithId(ctx, accountID, adminUserID, "agent-net.test", "", "", false)
require.NoError(t, am.Store.SaveAccount(ctx, account), "SaveAccount must succeed")
mgr := agentnetwork.NewManager(am.Store, permissions.NewManager(am.Store), am, nil)
mgr := agentnetwork.NewManager(am.Store, permissions.NewManager(am.Store), am, nil, "")
created, err := mgr.CreateBudgetRule(ctx, adminUserID, &agenttypes.AccountBudgetRule{
AccountID: accountID,
@@ -82,7 +82,7 @@ func TestAgentNetwork_UpdateSettings_PreservesImmutableAndTogglesCollection(t *t
account := newAccountWithId(ctx, accountID, adminUserID, "agent-net.test", "", "", false)
require.NoError(t, am.Store.SaveAccount(ctx, account), "SaveAccount must succeed")
mgr := agentnetwork.NewManager(am.Store, permissions.NewManager(am.Store), am, nil)
mgr := agentnetwork.NewManager(am.Store, permissions.NewManager(am.Store), am, nil, "")
// Creating a provider bootstraps the settings row (cluster + subdomain).
_, err = mgr.CreateProvider(ctx, adminUserID, &agenttypes.Provider{

View File

@@ -90,7 +90,7 @@ func TestAgentNetwork_ProviderCRUD_FansOutToProxyAndClientPeers(t *testing.T) {
// Real agentnetwork manager wired to the real account manager. proxyController
// is nil (no gRPC cluster fan-out here) — the reconcile still fires
// UpdateAccountPeers, which is the path under test.
agentMgr := agentnetwork.NewManager(am.Store, permissions.NewManager(am.Store), am, nil)
agentMgr := agentnetwork.NewManager(am.Store, permissions.NewManager(am.Store), am, nil, "")
provider, err := agentMgr.CreateProvider(ctx, adminUserID, &agenttypes.Provider{
AccountID: accountID,

View File

@@ -334,6 +334,30 @@ func (s *SqlStore) GetAgentNetworkSettingsByCluster(ctx context.Context, lockStr
return settings, nil
}
// GetAgentNetworkSettingsBySubdomain returns the settings row that owns the
// given subdomain label. The label is globally unique (enforced by
// idx_agent_network_settings_subdomain_unique), so at most one row can match,
// which makes this an indexed point lookup rather than a scan.
func (s *SqlStore) GetAgentNetworkSettingsBySubdomain(ctx context.Context, lockStrength LockingStrength, subdomain string) (*agentNetworkTypes.Settings, error) {
tx := s.db
if lockStrength != LockingStrengthNone {
tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
}
var settings agentNetworkTypes.Settings
result := tx.Take(&settings, "subdomain = ?", subdomain)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, status.Errorf(status.NotFound, "agent network settings for subdomain %s not found", subdomain)
}
log.WithContext(ctx).Errorf("failed to get agent network settings by subdomain from store: %v", result.Error)
return nil, status.Errorf(status.Internal, "failed to get agent network settings by subdomain from store")
}
return &settings, nil
}
// SaveAgentNetworkSettings upserts the per-account Agent Network
// settings row.
func (s *SqlStore) SaveAgentNetworkSettings(ctx context.Context, settings *agentNetworkTypes.Settings) error {
@@ -346,6 +370,21 @@ func (s *SqlStore) SaveAgentNetworkSettings(ctx context.Context, settings *agent
return nil
}
// CreateAgentNetworkSettings inserts a new settings row.
//
// Unlike SaveAgentNetworkSettings (an upsert) this is a plain INSERT, and it
// returns the driver error unwrapped. Both properties are required by the
// subdomain allocator: it relies on the unique index rejecting a duplicate
// label, and on being able to recognise that rejection so it can retry with a
// fresh label instead of surfacing an error.
func (s *SqlStore) CreateAgentNetworkSettings(ctx context.Context, settings *agentNetworkTypes.Settings) error {
if err := s.db.Create(settings).Error; err != nil {
log.WithContext(ctx).Debugf("failed to create agent network settings: %v", err)
return err
}
return nil
}
// IncrementAgentNetworkConsumption atomically upserts the consumption
// row keyed on (account, dim_kind, dim_id, window_seconds, window_start)
// and adds the supplied deltas. Concurrent calls from multiple proxy

View File

@@ -0,0 +1,77 @@
package store
import (
"context"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
)
// TestAgentNetworkSettings_SubdomainIsGloballyUnique is the guard for the whole
// allocation scheme: the label is now globally unique rather than per-cluster,
// and the allocator depends on the DATABASE saying no. Two different accounts on
// two different clusters must not be able to hold the same subdomain.
func TestAgentNetworkSettings_SubdomainIsGloballyUnique(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()
first := &agentNetworkTypes.Settings{
AccountID: "acc-unique-1",
Cluster: "eu.proxy.example",
Subdomain: "brave-otter",
Zone: "gateway.example",
}
require.NoError(t, s.CreateAgentNetworkSettings(ctx, first), "first insert must succeed")
// Deliberately a different account AND a different cluster: under the old
// per-cluster scheme this was legal, and it is exactly what must now fail.
second := &agentNetworkTypes.Settings{
AccountID: "acc-unique-2",
Cluster: "us.proxy.example",
Subdomain: "brave-otter",
Zone: "gateway.example",
}
err = s.CreateAgentNetworkSettings(ctx, second)
require.Error(t, err, "duplicate subdomain must be rejected by the unique index")
// The allocator recognises conflicts by matching the driver's message, so an
// error that does not carry a unique-violation signature is useless to it
// even though it is non-nil. These are the three signatures management's
// isUniqueConstraintError matches (postgres / mysql / sqlite).
msg := err.Error()
assert.True(t,
strings.Contains(msg, "(SQLSTATE 23505)") ||
strings.Contains(msg, "Error 1062 (23000)") ||
strings.Contains(msg, "UNIQUE constraint failed"),
"error must be the raw driver error, recognisable as a unique violation; got %q", msg)
}
// TestAgentNetworkSettings_CreateThenReadBack keeps CreateAgentNetworkSettings
// honest as an insert path: the row it writes must be fully readable, including
// the new Zone column.
func TestAgentNetworkSettings_CreateThenReadBack(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()
want := &agentNetworkTypes.Settings{
AccountID: "acc-readback-1",
Cluster: "eu.proxy.example",
Subdomain: "swift-heron",
Zone: "gateway.example",
}
require.NoError(t, s.CreateAgentNetworkSettings(ctx, want))
got, err := s.GetAgentNetworkSettings(ctx, LockingStrengthNone, "acc-readback-1")
require.NoError(t, err, "the inserted row must be readable")
assert.Equal(t, "swift-heron", got.Subdomain)
assert.Equal(t, "gateway.example", got.Zone, "the Zone column must round-trip")
assert.Equal(t, "swift-heron.gateway.example", got.Endpoint(), "endpoint derives from zone")
}

View File

@@ -361,7 +361,9 @@ type Store interface {
GetAgentNetworkSettings(ctx context.Context, lockStrength LockingStrength, accountID string) (*agentNetworkTypes.Settings, error)
GetAllAgentNetworkSettings(ctx context.Context, lockStrength LockingStrength) ([]*agentNetworkTypes.Settings, error)
GetAgentNetworkSettingsByCluster(ctx context.Context, lockStrength LockingStrength, cluster string) ([]*agentNetworkTypes.Settings, error)
GetAgentNetworkSettingsBySubdomain(ctx context.Context, lockStrength LockingStrength, subdomain string) (*agentNetworkTypes.Settings, error)
SaveAgentNetworkSettings(ctx context.Context, settings *agentNetworkTypes.Settings) error
CreateAgentNetworkSettings(ctx context.Context, settings *agentNetworkTypes.Settings) error
IncrementAgentNetworkConsumption(ctx context.Context, accountID string, kind agentNetworkTypes.ConsumptionDimension, dimID string, windowSeconds int64, windowStart time.Time, tokensIn, tokensOut int64, costUSD float64) error
IncrementAgentNetworkConsumptionBatch(ctx context.Context, accountID string, keys []agentNetworkTypes.ConsumptionKey, tokensIn, tokensOut int64, costUSD float64) error
GetAgentNetworkConsumption(ctx context.Context, lockStrength LockingStrength, accountID string, kind agentNetworkTypes.ConsumptionDimension, dimID string, windowSeconds int64, windowStart time.Time) (*agentNetworkTypes.Consumption, error)
@@ -658,6 +660,28 @@ func getMigrationsPostAuto(ctx context.Context) []migrationFunc {
func(db *gorm.DB) error {
return migration.FoldCostAggregatesIntoBuckets[agentNetworkTypes.AgentNetworkUsage](ctx, db)
},
func(db *gorm.DB) error {
// Enforce globally-unique agent-network subdomains.
//
// Uniqueness used to be per-cluster and advisory (a pre-read
// "taken" set with no DB constraint). Once the endpoint hangs off a
// shared zone the label must be unique across that whole zone, and
// the allocator depends on the database rejecting duplicates so it
// can retry with a fresh label.
//
// The pre-existing idx_agent_network_settings_cluster_subdomain is
// left in place: it is non-unique and indexes subdomain alone
// (Cluster carries no tag), so it neither conflicts nor suffices.
// It must also stay for a second, load-bearing reason on mysql:
// its gorm:"index:" tag on the Subdomain field is what makes gorm
// size that column as varchar(191) instead of longtext. mysql
// cannot put a longtext column in a unique index at all, so
// dropping this "redundant" index as unneeded would silently
// break the migration above on that dialect.
return migration.CreateIndexIfNotExists[agentNetworkTypes.Settings](
ctx, db, "idx_agent_network_settings_subdomain_unique", "subdomain",
)
},
}
}

View File

@@ -268,6 +268,20 @@ func (mr *MockStoreMockRecorder) CreateAgentNetworkAccessLog(ctx, entry, groups
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAgentNetworkAccessLog", reflect.TypeOf((*MockStore)(nil).CreateAgentNetworkAccessLog), ctx, entry, groups)
}
// CreateAgentNetworkSettings mocks base method.
func (m *MockStore) CreateAgentNetworkSettings(ctx context.Context, settings *types.Settings) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CreateAgentNetworkSettings", ctx, settings)
ret0, _ := ret[0].(error)
return ret0
}
// CreateAgentNetworkSettings indicates an expected call of CreateAgentNetworkSettings.
func (mr *MockStoreMockRecorder) CreateAgentNetworkSettings(ctx, settings interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAgentNetworkSettings", reflect.TypeOf((*MockStore)(nil).CreateAgentNetworkSettings), ctx, settings)
}
// CreateAgentNetworkUsage mocks base method.
func (m *MockStore) CreateAgentNetworkUsage(ctx context.Context, usage *types.AgentNetworkUsage, groups []types.AgentNetworkUsageGroup) error {
m.ctrl.T.Helper()
@@ -1702,6 +1716,21 @@ func (mr *MockStoreMockRecorder) GetAgentNetworkSettingsByCluster(ctx, lockStren
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkSettingsByCluster", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkSettingsByCluster), ctx, lockStrength, cluster)
}
// GetAgentNetworkSettingsBySubdomain mocks base method.
func (m *MockStore) GetAgentNetworkSettingsBySubdomain(ctx context.Context, lockStrength LockingStrength, subdomain string) (*types.Settings, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetAgentNetworkSettingsBySubdomain", ctx, lockStrength, subdomain)
ret0, _ := ret[0].(*types.Settings)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetAgentNetworkSettingsBySubdomain indicates an expected call of GetAgentNetworkSettingsBySubdomain.
func (mr *MockStoreMockRecorder) GetAgentNetworkSettingsBySubdomain(ctx, lockStrength, subdomain interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkSettingsBySubdomain", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkSettingsBySubdomain), ctx, lockStrength, subdomain)
}
// GetAgentNetworkUsageRows mocks base method.
func (m *MockStore) GetAgentNetworkUsageRows(ctx context.Context, lockStrength LockingStrength, accountID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkUsage, error) {
m.ctrl.T.Helper()

View File

@@ -254,6 +254,7 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon
peerGroups := a.GetPeerGroups(peerID)
zonesByApex := map[string]*nbdns.CustomZone{}
var skippedNoZoneApex []string
for _, svc := range a.Services {
if svc == nil || !svc.Enabled || !svc.Private {
@@ -272,6 +273,15 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon
serviceDomainZone := a.privateServiceDomainZone(svc)
if serviceDomainZone == "" {
// This service passed every gate above (enabled, private,
// AccessGroups, connected proxy peers) and would otherwise have
// emitted a record, but its domain matches neither its DNSZone,
// its ProxyCluster, nor any validated custom-domain row. Collected
// rather than logged here — this runs per peer x per service, and
// logging inline here would reintroduce the per-peer noise the
// "0 zones" diagnostic below deliberately avoids.
skippedNoZoneApex = append(skippedNoZoneApex,
fmt.Sprintf("%s(domain=%s cluster=%s dns_zone=%q)", svc.ID, svc.Domain, svc.ProxyCluster, svc.DNSZone))
continue
}
@@ -325,6 +335,10 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon
svc.ID, svc.Domain, svc.ProxyCluster, len(proxyPeers), skippedDisconnected)
}
}
if len(skippedNoZoneApex) > 0 {
log.Debugf("private-zone synth: peer %s account %s skipped %d service(s) with no matching zone apex: %s",
peerID, a.Id, len(skippedNoZoneApex), strings.Join(skippedNoZoneApex, ", "))
}
out := make([]nbdns.CustomZone, 0, len(zonesByApex))
for _, zone := range zonesByApex {
@@ -344,8 +358,18 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon
}
// privateServiceDomainZone returns the DNS zone name for the given private service domain by
// looking at the proxy cluster domain then the custom domains.
// checking its DNSZone, then the proxy cluster domain, then the custom domains.
func (a *Account) privateServiceDomainZone(svc *service.Service) string {
// Placement-free endpoints (<subdomain>.<zone>) carry their zone
// explicitly: it is server config, so it matches neither the serving
// proxy's address nor any per-account custom-domain row. Checked first so
// the apex stays the zone even once ProxyCluster becomes the tenant
// hostname itself (a private managed proxy), which would otherwise make the
// apex the full hostname and churn the client's zone set on cutover.
if svc.DNSZone != "" && domainFromSuffix(svc.Domain, svc.DNSZone) {
return svc.DNSZone
}
if domainFromSuffix(svc.Domain, svc.ProxyCluster) {
return svc.ProxyCluster
}

View File

@@ -423,6 +423,39 @@ func TestSynthesizePrivateServiceZones_MixedClusterCustomAndPublic(t *testing.T)
"only the 4 private custom services surface in the custom zone (public one excluded)")
}
// TestSynthesizePrivateServiceZones_ZoneBasedEndpoint_UsesZoneApex — a
// zone-based tenant still served by the SHARED proxy has a hostname whose
// parent is the zone, matching neither ProxyCluster nor any validated
// custom-domain row. Without DNSZone the apex resolves to "" and the service is
// skipped entirely, so the tenant's endpoint resolves to nothing.
func TestSynthesizePrivateServiceZones_ZoneBasedEndpoint_UsesZoneApex(t *testing.T) {
account := privateZoneTestAccount(t)
svc := account.Services[0]
svc.Domain = "brave-otter.gateway.netbird.ai"
svc.DNSZone = "gateway.netbird.ai"
// ProxyCluster stays the shared cluster address — the pre-private cohort.
zones := account.SynthesizePrivateServiceZones("user-peer")
require.Len(t, zones, 1, "a zone-based endpoint must still produce one zone")
assert.Equal(t, "gateway.netbird.ai.", zones[0].Domain, "apex must be the placement-free zone, not the cluster")
require.Len(t, zones[0].Records, 1)
assert.Equal(t, "brave-otter.gateway.netbird.ai.", zones[0].Records[0].Name)
assert.Equal(t, "100.64.0.99", zones[0].Records[0].RData, "still points at the serving proxy peer")
}
// TestSynthesizePrivateServiceZones_UnvalidatedDomain_StillSkipped locks the
// scope of the fix: a service matching no cluster suffix, no validated custom
// domain, AND carrying no DNSZone must keep resolving to nothing. A blanket
// "use the parent domain" fallback would hand it mesh DNS and bypass domain
// validation.
func TestSynthesizePrivateServiceZones_UnvalidatedDomain_StillSkipped(t *testing.T) {
account := privateZoneTestAccount(t)
account.Services[0].Domain = "api.unvalidated.example.com"
zones := account.SynthesizePrivateServiceZones("user-peer")
assert.Empty(t, zones, "no cluster suffix, no validated Domains row, no DNSZone → no records")
}
// recordNames returns the record names of a zone for order-independent assertions.
func recordNames(zone nbdns.CustomZone) []string {
names := make([]string, 0, len(zone.Records))

View File

@@ -53,7 +53,7 @@ func newChainIntegration(t *testing.T) *chainIntegrationFixture {
require.NoError(t, err)
t.Cleanup(cleanUp)
manager := agentnetwork.NewManager(st, nil, nil, nil)
manager := agentnetwork.NewManager(st, nil, nil, nil, "")
server := &mgmtgrpc.ProxyServiceServer{}
server.SetAgentNetworkLimitsService(manager)

View File

@@ -102,7 +102,7 @@ func TestReverseProxy_AgentNetworkRequest_FullChain(t *testing.T) {
require.NoError(t, err, "real sqlite test store must come up")
t.Cleanup(cleanup)
anMgr := agentnetwork.NewManager(st, nil, nil, nil)
anMgr := agentnetwork.NewManager(st, nil, nil, nil, "")
server := &mgmtgrpc.ProxyServiceServer{}
server.SetAgentNetworkLimitsService(anMgr)