diff --git a/management/internals/modules/agentnetwork/types/settings.go b/management/internals/modules/agentnetwork/types/settings.go index d61d9deff..ba707bb08 100644 --- a/management/internals/modules/agentnetwork/types/settings.go +++ b/management/internals/modules/agentnetwork/types/settings.go @@ -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 ., + // 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: -// `.`. +// Endpoint returns the bare hostname agents reach this account at. +// +// With a Zone set this is `.` — 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 +// `.` form. func (s *Settings) Endpoint() string { + if s.Zone != "" { + return s.Subdomain + "." + s.Zone + } return s.Subdomain + "." + s.Cluster } diff --git a/management/internals/modules/agentnetwork/types/settings_test.go b/management/internals/modules/agentnetwork/types/settings_test.go new file mode 100644 index 000000000..8837cb7d5 --- /dev/null +++ b/management/internals/modules/agentnetwork/types/settings_test.go @@ -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) +}