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.
This commit is contained in:
Brad Ison
2026-08-03 17:03:04 +02:00
parent b2d72534c5
commit df3619e1e5
2 changed files with 49 additions and 2 deletions

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)
}