diff --git a/management/server/types/account.go b/management/server/types/account.go index 0d0893e28..d658f605d 100644 --- a/management/server/types/account.go +++ b/management/server/types/account.go @@ -273,7 +273,7 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon } peerGroups := a.GetPeerGroups(peerID) - zonesByCluster := map[string]*nbdns.CustomZone{} + zonesByApex := map[string]*nbdns.CustomZone{} for _, svc := range a.Services { if svc == nil || !svc.Enabled || !svc.Private { @@ -290,19 +290,24 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon continue } - zone, exists := zonesByCluster[svc.ProxyCluster] + serviceDomainZone := a.privateServiceDomainZone(svc) + if serviceDomainZone == "" { + continue + } + + zone, exists := zonesByApex[serviceDomainZone] if !exists { // NonAuthoritative makes this a match-only zone: queries for // names without an explicit record fall through to the // upstream resolver instead of returning NXDOMAIN. Without // it, adding a single private service would black-hole every - // other name under the cluster apex. + // other name under the zone apex. zone = &nbdns.CustomZone{ - Domain: dns.Fqdn(svc.ProxyCluster), + Domain: dns.Fqdn(serviceDomainZone), Records: []nbdns.SimpleRecord{}, NonAuthoritative: true, } - zonesByCluster[svc.ProxyCluster] = zone + zonesByApex[serviceDomainZone] = zone } emitted := 0 @@ -340,8 +345,8 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon } } - out := make([]nbdns.CustomZone, 0, len(zonesByCluster)) - for _, zone := range zonesByCluster { + out := make([]nbdns.CustomZone, 0, len(zonesByApex)) + for _, zone := range zonesByApex { if len(zone.Records) == 0 { continue } @@ -357,6 +362,33 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon return out } +// privateServiceDomainZone returns the DNS zone name for the given private service domain by +// looking at the proxy cluster domain then the custom domains. +func (a *Account) privateServiceDomainZone(svc *service.Service) string { + if domainFromSuffix(svc.Domain, svc.ProxyCluster) { + return svc.ProxyCluster + } + + // Longest matching custom domain wins + zoneName := "" + for _, d := range a.Domains { + if d == nil || d.TargetCluster != svc.ProxyCluster { + continue + } + if domainFromSuffix(svc.Domain, d.Domain) && len(d.Domain) > len(zoneName) { + zoneName = d.Domain + } + } + return zoneName +} + +func domainFromSuffix(domain, suffix string) bool { + if suffix == "" { + return false + } + return domain == suffix || strings.HasSuffix(domain, "."+suffix) +} + // peerInDistributionGroups reports whether any of the peer's groups // matches the service's bearer-auth distribution_groups. func peerInDistributionGroups(peerGroups LookupMap, distributionGroups []string) bool { diff --git a/management/server/types/account_private_zones_test.go b/management/server/types/account_private_zones_test.go index 1d4f720b7..a2da34754 100644 --- a/management/server/types/account_private_zones_test.go +++ b/management/server/types/account_private_zones_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" nbdns "github.com/netbirdio/netbird/dns" + proxydomain "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" nbpeer "github.com/netbirdio/netbird/management/server/peer" ) @@ -234,6 +235,113 @@ func TestPrivateZone_GetPeerNetworkMap_PeerOutsideGroups_OmitsSynthZone(t *testi assert.False(t, ok, "peer outside the distribution_groups must not see the synth zone") } +func TestSynthesizePrivateServiceZones_CustomDomain_ZoneApexIsRegisteredDomain(t *testing.T) { + account := privateZoneTestAccount(t) + // A custom-domain service: Domain is the custom FQDN, ProxyCluster + // is the cluster serving it, and account.Domains holds the registered + // custom domain. The synth zone apex must be the registered domain, + // not the cluster, or the client's match-only zone never intercepts + // the query. + account.Services[0].Domain = "app.example.com" + account.Domains = []*proxydomain.Domain{ + {Domain: "example.com", AccountID: "acct-1", TargetCluster: "eu.proxy.netbird.io", Validated: true}, + } + + zones := account.SynthesizePrivateServiceZones("user-peer") + require.Len(t, zones, 1, "custom-domain service must still produce one zone") + zone := zones[0] + assert.Equal(t, "example.com.", zone.Domain, "zone apex must be the registered custom domain, not the cluster or the service FQDN") + assert.True(t, zone.NonAuthoritative, "synth zone must remain match-only") + require.Len(t, zone.Records, 1, "custom-domain service yields one A record") + rec := zone.Records[0] + assert.Equal(t, "app.example.com.", rec.Name, "record name is the custom service FQDN") + assert.Equal(t, "100.64.0.99", rec.RData, "record points at the embedded proxy peer's tunnel IP") +} + +func TestSynthesizePrivateServiceZones_CustomAndFreeDomain_SeparateZones(t *testing.T) { + account := privateZoneTestAccount(t) + account.Domains = []*proxydomain.Domain{ + {Domain: "example.com", AccountID: "acct-1", TargetCluster: "eu.proxy.netbird.io", Validated: true}, + } + account.Services = append(account.Services, &service.Service{ + ID: "svc-2", + AccountID: "acct-1", + Name: "custom", + Domain: "app.example.com", + ProxyCluster: "eu.proxy.netbird.io", + Enabled: true, + Private: true, + Mode: service.ModeHTTP, + AccessGroups: []string{"grp-admins"}, + }) + + zones := account.SynthesizePrivateServiceZones("user-peer") + require.Len(t, zones, 2, "a free-domain and a custom-domain service must not collapse into one zone") + + free, ok := findCustomZone(zones, "eu.proxy.netbird.io") + require.True(t, ok, "free-domain service keeps the shared cluster-apex zone") + require.Len(t, free.Records, 1, "cluster zone carries only the free-domain record") + assert.Equal(t, "myapp.eu.proxy.netbird.io.", free.Records[0].Name, "cluster zone record is the free-domain FQDN") + + custom, ok := findCustomZone(zones, "example.com") + require.True(t, ok, "custom-domain service gets its own zone at the registered custom domain apex") + require.Len(t, custom.Records, 1, "custom zone carries only the custom-domain record") + assert.Equal(t, "app.example.com.", custom.Records[0].Name, "custom zone record is the custom-domain FQDN") +} + +func TestSynthesizePrivateServiceZones_TwoServicesSameCustomDomain_OneZone(t *testing.T) { + account := privateZoneTestAccount(t) + account.Domains = []*proxydomain.Domain{ + {Domain: "example.com", AccountID: "acct-1", TargetCluster: "eu.proxy.netbird.io", Validated: true}, + } + account.Services[0].Domain = "a.example.com" + account.Services = append(account.Services, &service.Service{ + ID: "svc-2", + AccountID: "acct-1", + Name: "bapp", + Domain: "b.example.com", + ProxyCluster: "eu.proxy.netbird.io", + Enabled: true, + Private: true, + Mode: service.ModeHTTP, + AccessGroups: []string{"grp-admins"}, + }) + + zones := account.SynthesizePrivateServiceZones("user-peer") + require.Len(t, zones, 1, "two services under the same registered custom domain must share one zone") + assert.Equal(t, "example.com.", zones[0].Domain, "shared zone apex is the registered custom domain") + require.Len(t, zones[0].Records, 2, "both services surface as records in the shared custom-domain zone") + names := []string{zones[0].Records[0].Name, zones[0].Records[1].Name} + assert.ElementsMatch(t, []string{"a.example.com.", "b.example.com."}, names, "both custom-domain service FQDNs must surface") +} + +func TestSynthesizePrivateServiceZones_CustomDomainNotRegistered_NoZone(t *testing.T) { + account := privateZoneTestAccount(t) + // Service domain is outside the cluster and no account.Domains entry + // covers it: there is no apex that would intercept the query, so the + // service must be skipped rather than emit an unmatchable record. + account.Services[0].Domain = "app.example.com" + + zones := account.SynthesizePrivateServiceZones("user-peer") + assert.Empty(t, zones, "a custom-domain service with no registered domain apex must not produce a zone") +} + +func TestSynthesizePrivateServiceZones_CustomDomainClusterMismatch_NoZone(t *testing.T) { + account := privateZoneTestAccount(t) + // The registered custom domain matches the service FQDN by suffix but + // targets a different cluster than the service's ProxyCluster. It must + // be ignored, leaving no apex to intercept the query — otherwise the + // zone would point at this cluster's proxy peers under a domain owned + // by a different cluster. + account.Services[0].Domain = "app.example.com" + account.Domains = []*proxydomain.Domain{ + {Domain: "example.com", AccountID: "acct-1", TargetCluster: "us.proxy.netbird.io", Validated: true}, + } + + zones := account.SynthesizePrivateServiceZones("user-peer") + assert.Empty(t, zones, "a custom domain targeting a different cluster must not anchor the service zone") +} + func TestSynthesizePrivateServiceZones_TwoServicesSameCluster_OneZone(t *testing.T) { account := privateZoneTestAccount(t) account.Services = append(account.Services, &service.Service{