From df3619e1e53529d021e45f9838465655d47b905e Mon Sep 17 00:00:00 2001 From: Brad Ison Date: Mon, 3 Aug 2026 17:03:04 +0200 Subject: [PATCH] feat(agentnetwork): add a placement-free Zone to settings A tenant's endpoint is ., 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 . 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. --- .../modules/agentnetwork/types/settings.go | 20 ++++++++++-- .../agentnetwork/types/settings_test.go | 31 +++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 management/internals/modules/agentnetwork/types/settings_test.go 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) +}