diff --git a/management/internals/modules/agentnetwork/synthesizer.go b/management/internals/modules/agentnetwork/synthesizer.go index 2a3002784..c7fab2241 100644 --- a/management/internals/modules/agentnetwork/synthesizer.go +++ b/management/internals/modules/agentnetwork/synthesizer.go @@ -945,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, diff --git a/management/internals/modules/reverseproxy/service/service.go b/management/internals/modules/reverseproxy/service/service.go index b6438abde..4fbc12102 100644 --- a/management/internals/modules/reverseproxy/service/service.go +++ b/management/internals/modules/reverseproxy/service/service.go @@ -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 .. 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, } } diff --git a/management/internals/modules/reverseproxy/service/service_test.go b/management/internals/modules/reverseproxy/service/service_test.go index a149ac609..e1e384252 100644 --- a/management/internals/modules/reverseproxy/service/service_test.go +++ b/management/internals/modules/reverseproxy/service/service_test.go @@ -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 diff --git a/management/server/types/account.go b/management/server/types/account.go index 1a3a30544..a0f5c9c6a 100644 --- a/management/server/types/account.go +++ b/management/server/types/account.go @@ -272,6 +272,12 @@ 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. + log.Debugf("private-zone synth: svc %s domain=%s cluster=%s dns_zone=%q has no matching zone apex, skipping", + svc.ID, svc.Domain, svc.ProxyCluster, svc.DNSZone) continue } @@ -344,8 +350,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 (.) 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 } diff --git a/management/server/types/account_private_zones_test.go b/management/server/types/account_private_zones_test.go index efbbbffaf..38cd5790a 100644 --- a/management/server/types/account_private_zones_test.go +++ b/management/server/types/account_private_zones_test.go @@ -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))