[management] fix private services calc on new db path (#7383)

This commit is contained in:
Pascal Fischer
2026-09-01 20:20:30 +02:00
committed by GitHub
parent e5c0cdf958
commit e3d6c3d0eb
13 changed files with 337 additions and 339 deletions

View File

@@ -12,6 +12,7 @@
"IP": "10.10.10.1",
"IPv6": "fdf4:ba80:6aa5:89f1:44d7:8701:8699:4940",
"RequiresApproval": false,
"Connected": true,
"ExtraDNSLabels": [
"extra-peer-1"
],
@@ -66,6 +67,7 @@
"IP": "10.10.100.1",
"IPv6": "fdf5:ba80:6aa5:89f1:44d7:8701:8699:4940",
"RequiresApproval": false,
"Connected": true,
"ExtraDNSLabels": [
"extra-peer-2"
],
@@ -120,6 +122,7 @@
"IP": "10.10.200.1",
"IPv6": "fdf6:ba80:6aa5:89f1:44d7:8701:8699:4940",
"RequiresApproval": false,
"Connected": true,
"ExtraDNSLabels": [
"extra-peer-3"
],
@@ -542,5 +545,11 @@
}
],
"PrivateServiceCandidates": null,
"Services": null
"Services": null,
"Domains": [
{
"Domain": "test-331.com",
"TargetCluster": "target-1.cluster.local"
}
]
}

View File

@@ -40,6 +40,7 @@ func TestGetPeers(t *testing.T) {
IP: netip.MustParseAddr("10.10.10.1"),
IPv6: netip.MustParseAddr("fdf4:ba80:6aa5:89f1:44d7:8701:8699:4940"),
RequiresApproval: false,
Connected: true,
Meta: nmdata.PeerSystemMeta{
WtVersion: "0.76.0",
GoOS: "linux",
@@ -82,6 +83,7 @@ func TestGetPeers(t *testing.T) {
IP: netip.MustParseAddr("10.10.100.1"),
IPv6: netip.MustParseAddr("fdf5:ba80:6aa5:89f1:44d7:8701:8699:4940"),
RequiresApproval: false,
Connected: true,
Meta: nmdata.PeerSystemMeta{
WtVersion: "0.76.1",
GoOS: "linux",
@@ -124,6 +126,7 @@ func TestGetPeers(t *testing.T) {
IP: netip.MustParseAddr("10.10.200.1"),
IPv6: netip.MustParseAddr("fdf6:ba80:6aa5:89f1:44d7:8701:8699:4940"),
RequiresApproval: false,
Connected: true,
Meta: nmdata.PeerSystemMeta{
WtVersion: "0.76.2",
GoOS: "linux",

View File

@@ -511,6 +511,7 @@ func (c *Controller) fetchNetworkMapData(ctx context.Context, accountID string)
}
nmData.Services = c.proxyServicesFromRepo(ctx, accountID)
nmData.BuildPrivateServiceCandidates()
nmData.InjectProxyPolicies()
nmData.PrecomputePostureValidation()

View File

@@ -183,6 +183,7 @@ func RunCase(t *testing.T, c Case) {
ctx := context.Background()
nmData := c.Data
applyFixtureDefaults(nmData)
nmData.BuildPrivateServiceCandidates()
nmData.PrecomputePostureValidation()
dnsDomain := c.DNSDomain

View File

@@ -3,10 +3,7 @@ package networkmapdb
import (
"context"
"fmt"
"net/netip"
"strings"
"github.com/miekg/dns"
log "github.com/sirupsen/logrus"
"golang.org/x/exp/maps"
@@ -48,7 +45,7 @@ func (s *NetworkMapDBStoreImpl) GetNetworkMapData(ctx context.Context, accountId
if err != nil {
return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get network: %w", err))
}
peers, proxyPeers, err := tx.GetPeers(ctx, accountId)
peers, _, err := tx.GetPeers(ctx, accountId)
if err != nil {
return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get peers: %w", err))
}
@@ -80,10 +77,6 @@ func (s *NetworkMapDBStoreImpl) GetNetworkMapData(ctx context.Context, accountId
if err != nil {
return rollbackAndReturnError(ctx, tx, err)
}
services, err := tx.GetPrivateServices(ctx, accountId)
if err != nil {
return rollbackAndReturnError(ctx, tx, err)
}
proxyTargetedDomainResourceIDs, err := tx.GetProxyTargetedDomainResourceIDs(ctx, accountId)
if err != nil {
return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get proxy targeted domain resources: %w", err))
@@ -113,7 +106,7 @@ func (s *NetworkMapDBStoreImpl) GetNetworkMapData(ctx context.Context, accountId
GroupIDToUserIDs: groupsToUserIds,
NetworkXIDToPublicID: networkXIDToPublicID, // TODO (dmitri) maybe we can switch to public ids everywhere?
AppliedZoneCandidates: dnsZones,
PrivateServiceCandidates: buildPrivateServiceCandidates(services, domains, proxyPeers),
Domains: TwinProxyDomains(domains),
PostureCheckXIDToPublicID: postureCheckXIDToPublicID,
ProxyTargetedDomainResourceIDs: proxyTargetedDomainResourceIDs,
}
@@ -154,94 +147,6 @@ func toSliceOfPtrs[T any](all []T) []*T {
return toret
}
func serviceDomainZone(svc Service, ds []Domain) string {
if domainFromSuffix(svc.Domain.String, svc.ProxyCluster.String) {
return svc.ProxyCluster.String
}
var zoneName string
for _, domain := range ds {
if domain.TargetCluster.String != svc.ProxyCluster.String {
continue
}
if domainFromSuffix(svc.Domain.String, domain.Domain.String) && len(domain.Domain.String) > len(zoneName) {
zoneName = domain.Domain.String
}
}
return zoneName
}
func domainFromSuffix(domain, suffix string) bool {
if suffix == "" {
return false
}
return domain == suffix || strings.HasSuffix(domain, "."+suffix)
}
func buildPrivateServiceCandidates(svcs []Service, domains []Domain, proxyPeersByCluster map[string][]*nmdata.Peer) []networkmap.PrivateServiceCandidate {
var out []networkmap.PrivateServiceCandidate
if len(proxyPeersByCluster) == 0 {
return out
}
for _, svc := range svcs {
if !svc.Enabled.Bool || !svc.Private.Bool {
continue
}
if len(svc.AccessGroups) == 0 {
continue
}
domainZone := serviceDomainZone(svc, domains)
if domainZone == "" {
continue
}
// this is implied when domainZone != "", but for maintainability's sake the check is explicit
// TODO (dmitri) make this an invariant
if svc.Domain.String == "" {
continue
}
var records []nmdata.SimpleRecord
for _, proxyPeer := range proxyPeersByCluster[svc.ProxyCluster.String] {
if record, ok := recordForProxyPeer(svc.Domain.String, proxyPeer.IP); ok {
records = append(records, record)
}
}
if len(records) == 0 {
continue
}
out = append(out, networkmap.PrivateServiceCandidate{
AccessGroups: svc.AccessGroups,
Zone: nmdata.CustomZone{
Domain: dns.Fqdn(domainZone),
Records: records,
NonAuthoritative: true,
SearchDomainDisabled: true,
},
})
}
return out
}
func recordForProxyPeer(fqdn string, ip netip.Addr) (nmdata.SimpleRecord, bool) {
if !ip.IsValid() {
return nmdata.SimpleRecord{}, false
}
return nmdata.SimpleRecord{
Name: dns.Fqdn(fqdn),
Type: int(dns.TypeA),
Class: "IN",
TTL: 5,
RData: ip.String(),
}, true
}
func buildResourcePolicies(networkResources []nmdata.NetworkResource,
policies []nmdata.Policy,
resourceToGroupIdx map[string]map[string]any,

View File

@@ -1,253 +1,12 @@
package networkmapdb
import (
"database/sql"
"net/netip"
"testing"
"github.com/netbirdio/netbird/shared/management/networkmap"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/stretchr/testify/assert"
)
func TestDomainFromSuffix(t *testing.T) {
assert.False(t, domainFromSuffix("test", ""))
assert.False(t, domainFromSuffix("test", "suffix")) // domain != suffix
assert.True(t, domainFromSuffix("test", "test")) // domain == suffix
assert.False(t, domainFromSuffix("test.anothersuffix", "suffix")) // domain doesn't contain suffix
assert.True(t, domainFromSuffix("test.suffix", "suffix")) // domain contains suffix
}
func TestServiceDomainZone(t *testing.T) {
// shortcut -- service's domain is a subomain of proxy cluster
assert.Equal(t, "cluster",
serviceDomainZone(
Service{
Domain: sql.NullString{Valid: true, String: "test.cluster"},
ProxyCluster: sql.NullString{Valid: true, String: "cluster"}},
[]Domain{}))
assert.Equal(t, "a.b", serviceDomainZone(
Service{
Domain: sql.NullString{Valid: true, String: "test.a.b"},
ProxyCluster: sql.NullString{Valid: true, String: "cluster"}},
[]Domain{
{TargetCluster: sql.NullString{Valid: true, String: "a-cluster"}},
{TargetCluster: sql.NullString{Valid: true, String: "cluster"},
Domain: sql.NullString{Valid: true, String: "b"}},
{TargetCluster: sql.NullString{Valid: true, String: "cluster"},
Domain: sql.NullString{Valid: true, String: "a.b"}}, // should return this domain, as it's the longest match
{TargetCluster: sql.NullString{Valid: true, String: "b-cluster"}},
}))
// service and domain clusters don't match
assert.Empty(t, serviceDomainZone(
Service{
Domain: sql.NullString{Valid: true, String: "test.a.b"},
ProxyCluster: sql.NullString{Valid: true, String: "c-cluster"}},
[]Domain{
{TargetCluster: sql.NullString{Valid: true, String: "cluster"},
Domain: sql.NullString{Valid: true, String: "a.b"}},
}))
// service domain is empty
assert.Empty(t, serviceDomainZone(
Service{
Domain: sql.NullString{Valid: false, String: ""},
ProxyCluster: sql.NullString{Valid: true, String: "cluster"}},
[]Domain{
{TargetCluster: sql.NullString{Valid: true, String: "cluster"},
Domain: sql.NullString{Valid: true, String: "a.b"}},
}))
}
func TestRecordForProxyPeer(t *testing.T) {
record, ok := recordForProxyPeer("test.cluster", netip.MustParseAddr("127.0.0.1"))
assert.True(t, ok)
assert.Equal(t, nmdata.SimpleRecord{
Name: "test.cluster.",
Type: 1,
Class: "IN",
TTL: 5,
RData: "127.0.0.1",
}, record)
// invalid address
var addr netip.Addr
_, ok = recordForProxyPeer("test.cluster", addr)
assert.False(t, ok)
}
var empty []networkmap.PrivateServiceCandidate
// empty proxyPeersByCluster results in empty []PrivateServiceCandidates
func TestBuildPrivateServiceCandidates_EmptyProxyPeers(t *testing.T) {
assert.Equal(t, empty, buildPrivateServiceCandidates([]Service{}, []Domain{}, nil))
}
// disabled service returns an empty result
func TestBuildPrivateServiceCandidates_DisabledService(t *testing.T) {
assert.Equal(t, empty,
buildPrivateServiceCandidates([]Service{
{Enabled: sql.NullBool{Valid: true, Bool: false},
Private: sql.NullBool{Valid: true, Bool: true},
AccessGroups: []string{"group-1", "group-2"},
Domain: sql.NullString{Valid: true, String: "test.a.b"},
ProxyCluster: sql.NullString{Valid: true, String: "cluster"}},
}, []Domain{
{TargetCluster: sql.NullString{Valid: true, String: "cluster"},
Domain: sql.NullString{Valid: true, String: "a.b"}},
},
map[string][]*nmdata.Peer{
"cluster": {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.1")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.2")}},
"a-cluster": {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.3")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.4")}},
}))
}
// non-private service results in empty []PrivateServiceCandidates
func TestBuildPrivateServiceCandidates_PublicService(t *testing.T) {
assert.Equal(t, empty,
buildPrivateServiceCandidates([]Service{
{Enabled: sql.NullBool{Valid: true, Bool: true},
Private: sql.NullBool{Valid: true, Bool: false},
AccessGroups: []string{"group-1", "group-2"},
Domain: sql.NullString{Valid: true, String: "test.a.b"},
ProxyCluster: sql.NullString{Valid: true, String: "cluster"}},
}, []Domain{
{TargetCluster: sql.NullString{Valid: true, String: "cluster"},
Domain: sql.NullString{Valid: true, String: "a.b"}},
},
map[string][]*nmdata.Peer{
"cluster": {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.1")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.2")}},
"a-cluster": {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.3")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.4")}},
}))
}
// empty AccessList results in empty []PrivateServiceCandidates
func TestBuildPrivateServiceCandidates_EmptyAccessList(t *testing.T) {
assert.Equal(t, empty,
buildPrivateServiceCandidates([]Service{
{Enabled: sql.NullBool{Valid: true, Bool: true},
Private: sql.NullBool{Valid: true, Bool: true},
Domain: sql.NullString{Valid: true, String: "test.a.b"},
ProxyCluster: sql.NullString{Valid: true, String: "cluster"}},
}, []Domain{
{TargetCluster: sql.NullString{Valid: true, String: "cluster"},
Domain: sql.NullString{Valid: true, String: "a.b"}},
},
map[string][]*nmdata.Peer{
"cluster": {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.1")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.2")}},
"a-cluster": {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.3")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.4")}},
}))
}
// empty TragetCluster results in empty []PrivateServiceCandidates
func TestBuildPrivateServiceCandidates_EmptyTargetCluster(t *testing.T) {
assert.Equal(t, empty,
buildPrivateServiceCandidates([]Service{
{Enabled: sql.NullBool{Valid: true, Bool: true},
Private: sql.NullBool{Valid: true, Bool: true},
AccessGroups: []string{"group-1", "group-2"},
Domain: sql.NullString{Valid: true, String: "test.a.b"},
ProxyCluster: sql.NullString{Valid: true, String: "cluster"}},
}, []Domain{
{TargetCluster: sql.NullString{Valid: true, String: ""},
Domain: sql.NullString{Valid: true, String: "a.b"}},
},
map[string][]*nmdata.Peer{
"cluster": {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.1")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.2")}},
"a-cluster": {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.3")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.4")}},
}))
}
func TestBuildPrivateServiceCandidates_EmptyServiceDomain(t *testing.T) {
assert.Equal(t, empty,
buildPrivateServiceCandidates([]Service{
{Enabled: sql.NullBool{Valid: true, Bool: true},
Private: sql.NullBool{Valid: true, Bool: true},
Domain: sql.NullString{Valid: true, String: ""},
ProxyCluster: sql.NullString{Valid: true, String: "cluster"}},
}, []Domain{
{TargetCluster: sql.NullString{Valid: true, String: "cluster"},
Domain: sql.NullString{Valid: true, String: "a.b"}},
},
map[string][]*nmdata.Peer{
"cluster": {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.1")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.2")}},
"a-cluster": {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.3")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.4")}},
}))
}
func TestBuildPrivateServiceCandidates_HappyPath(t *testing.T) {
assert.Equal(t, []networkmap.PrivateServiceCandidate{
{
AccessGroups: []string{"group-1", "group-2"},
Zone: nmdata.CustomZone{
Domain: "a.b.",
SearchDomainDisabled: true,
NonAuthoritative: true,
Records: []nmdata.SimpleRecord{
{
Name: "test.a.b.",
Type: 1,
Class: "IN",
TTL: 5,
RData: "127.0.0.1",
},
{
Name: "test.a.b.",
Type: 1,
Class: "IN",
TTL: 5,
RData: "127.0.0.2",
},
},
},
},
{
AccessGroups: []string{"group-1", "group-2"},
Zone: nmdata.CustomZone{
Domain: "c.d.",
SearchDomainDisabled: true,
NonAuthoritative: true,
Records: []nmdata.SimpleRecord{
{
Name: "test.c.d.",
Type: 1,
Class: "IN",
TTL: 5,
RData: "127.0.0.3",
},
{
Name: "test.c.d.",
Type: 1,
Class: "IN",
TTL: 5,
RData: "127.0.0.4",
},
},
},
},
},
buildPrivateServiceCandidates([]Service{
{Enabled: sql.NullBool{Valid: true, Bool: true},
Private: sql.NullBool{Valid: true, Bool: true},
AccessGroups: []string{"group-1", "group-2"},
Domain: sql.NullString{Valid: true, String: "test.a.b"},
ProxyCluster: sql.NullString{Valid: true, String: "cluster"}},
{Enabled: sql.NullBool{Valid: true, Bool: true},
Private: sql.NullBool{Valid: true, Bool: true},
AccessGroups: []string{"group-1", "group-2"},
Domain: sql.NullString{Valid: true, String: "test.c.d"},
ProxyCluster: sql.NullString{Valid: true, String: "a-cluster"}},
}, []Domain{
{TargetCluster: sql.NullString{Valid: true, String: "cluster"},
Domain: sql.NullString{Valid: true, String: "a.b"}},
{TargetCluster: sql.NullString{Valid: true, String: "a-cluster"},
Domain: sql.NullString{Valid: true, String: "c.d"}},
},
map[string][]*nmdata.Peer{
"cluster": {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.1")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.2")}},
"a-cluster": {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.3")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.4")}},
}))
}
// disabled network resource shouldn't be in the resulting map
func TestBuildResourcePolicies_DisabledNetworkResource(t *testing.T) {
networkResources := []nmdata.NetworkResource{

View File

@@ -302,6 +302,7 @@ func ConvertToNmdataPeers(peers []Peer) ([]nmdata.Peer, map[string][]*nmdata.Pee
}
dp.ProxyMeta.Cluster = p.ProxyMetaCluster.String
// This is only used to build private service candidates, not connected peers are skipped
dp.Connected = p.PeerStatusConnected.Bool
if dp.ProxyMeta.Embedded && p.PeerStatusConnected.Bool {
clusterToPeerIdx[p.ProxyMetaCluster.String] = append(clusterToPeerIdx[p.ProxyMetaCluster.String], &dp)
}
@@ -470,3 +471,16 @@ func ConvertToNmdataPolicy(policies []Policy) ([]nmdata.Policy, map[string]map[s
return toret, policyToDestinationResourceIdx, policyToDestinationGroupIdx, nil
}
// TwinProxyDomains converts registered reverse-proxy domain rows to their slim
// twins, so private-service zone apex resolution runs on the twin.
func TwinProxyDomains(domains []Domain) []nmdata.ProxyDomain {
if len(domains) == 0 {
return nil
}
out := make([]nmdata.ProxyDomain, 0, len(domains))
for _, d := range domains {
out = append(out, nmdata.ProxyDomain{Domain: d.Domain.String, TargetCluster: d.TargetCluster.String})
}
return out
}

View File

@@ -4,6 +4,7 @@ import (
"github.com/miekg/dns"
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"
"github.com/netbirdio/netbird/management/internals/modules/zones"
"github.com/netbirdio/netbird/management/internals/modules/zones/records"
@@ -114,6 +115,7 @@ func (a *Account) toNetworkMapData(
nmd.AppliedZoneCandidates = buildAppliedZoneCandidates(accountZones)
nmd.PrivateServiceCandidates = a.buildPrivateServiceCandidates()
nmd.Services = TwinServices(a.Services)
nmd.Domains = twinProxyDomains(a.Domains)
return nmd
}
@@ -153,6 +155,7 @@ func TwinServices(services []*service.Service) []*nmdata.Service {
Enabled: svc.Enabled,
Private: svc.Private,
Mode: svc.Mode,
Domain: svc.Domain,
ProxyCluster: svc.ProxyCluster,
AccessGroups: svc.AccessGroups,
Targets: targets,
@@ -185,6 +188,7 @@ func twinPeer(p *nbpeer.Peer) *nmdata.Peer {
IP: p.IP,
IPv6: p.IPv6,
RequiresApproval: p.Status != nil && p.Status.RequiresApproval,
Connected: p.Status != nil && p.Status.Connected,
ExtraDNSLabels: p.ExtraDNSLabels,
ProxyMeta: nmdata.ProxyMeta{Embedded: p.ProxyMeta.Embedded, Cluster: p.ProxyMeta.Cluster},
Meta: nmdata.PeerSystemMeta{
@@ -621,3 +625,19 @@ func TwinCustomZone(z nbdns.CustomZone) nmdata.CustomZone {
NonAuthoritative: z.NonAuthoritative,
}
}
// twinProxyDomains converts the account's registered reverse-proxy domains to
// their slim twins, so private-service zone apex resolution runs on the twin.
func twinProxyDomains(domains []*proxydomain.Domain) []nmdata.ProxyDomain {
if len(domains) == 0 {
return nil
}
out := make([]nmdata.ProxyDomain, 0, len(domains))
for _, d := range domains {
if d == nil {
continue
}
out = append(out, nmdata.ProxyDomain{Domain: d.Domain, TargetCluster: d.TargetCluster})
}
return out
}

View File

@@ -54,6 +54,10 @@ type NetworkMapData struct { //nolint:revive // established name across the code
// builder can load because they are never written to the database.
Services []*nmdata.Service
// Domains are the account's registered reverse-proxy domains, used to
// resolve the zone apex a private service's records hang under.
Domains []nmdata.ProxyDomain
peerGroupsOnce sync.Once
peerGroupsIdx map[string]map[string]struct{}

View File

@@ -27,6 +27,7 @@ type Peer struct {
IP netip.Addr
IPv6 netip.Addr
RequiresApproval bool
Connected bool
ExtraDNSLabels []string
Meta PeerSystemMeta
ProxyMeta ProxyMeta
@@ -39,6 +40,14 @@ type ProxyMeta struct {
Cluster string
}
// ProxyDomain is the slim twin of a registered reverse-proxy domain, carrying
// what private-service zone resolution needs: the apex a service domain can sit
// under, and the cluster it is registered against.
type ProxyDomain struct {
Domain string
TargetCluster string
}
// PeerSystemMeta is the slim twin of peer.PeerSystemMeta.
type PeerSystemMeta struct {
WtVersion string

View File

@@ -9,6 +9,7 @@ type Service struct {
Enabled bool
Private bool
Mode string
Domain string
ProxyCluster string
AccessGroups []string
Targets []*ServiceTarget

View File

@@ -0,0 +1,124 @@
package networkmap
import (
"slices"
"strings"
"github.com/miekg/dns"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
// privateServiceDNSRecordTTL is short so proxy-peer changes propagate quickly.
const privateServiceDNSRecordTTL = 5
// BuildPrivateServiceCandidates derives the per-service DNS records a private
// service publishes, from the twin's own services. It is the counterpart of
// InjectProxyPolicies: that one synthesises the ACL half of a private service,
// this one the DNS half, and both read nmd.Services so a service added to the
// twin after it was loaded — an agent-network service is synthesised in memory
// and never persisted — reaches the peer with both halves rather than one.
//
// The per-peer access-group gate and the merge by apex stay in the components
// calculation; this only precomputes what is account-wide.
func (nmd *NetworkMapData) BuildPrivateServiceCandidates() {
if len(nmd.Services) == 0 {
nmd.PrivateServiceCandidates = nil
return
}
proxyPeersByCluster := nmd.connectedProxyPeersByCluster()
if len(proxyPeersByCluster) == 0 {
nmd.PrivateServiceCandidates = nil
return
}
var out []PrivateServiceCandidate
for _, svc := range nmd.Services {
if svc == nil || !svc.Enabled || !svc.Private || len(svc.AccessGroups) == 0 || svc.Domain == "" {
continue
}
proxyPeers := proxyPeersByCluster[svc.ProxyCluster]
if len(proxyPeers) == 0 {
continue
}
apex := nmd.privateServiceApex(svc)
if apex == "" {
continue
}
records := make([]nmdata.SimpleRecord, 0, len(proxyPeers))
for _, p := range proxyPeers {
records = append(records, nmdata.SimpleRecord{
Name: dns.Fqdn(svc.Domain),
Type: int(dns.TypeA),
Class: "IN",
TTL: privateServiceDNSRecordTTL,
RData: p.IP.String(),
})
}
out = append(out, PrivateServiceCandidate{
AccessGroups: svc.AccessGroups,
Zone: nmdata.CustomZone{
// NonAuthoritative keeps the zone match-only, so names without
// an explicit record fall through to the upstream resolver
// instead of returning NXDOMAIN for the whole apex.
Domain: dns.Fqdn(apex),
Records: records,
NonAuthoritative: true,
SearchDomainDisabled: true,
},
})
}
nmd.PrivateServiceCandidates = out
}
// privateServiceApex resolves the zone a service's record hangs under: the
// cluster when the service sits directly beneath it, otherwise the longest
// registered custom domain pointing at that same cluster. A service whose
// domain matches no registered apex publishes nothing, since a zone the client
// never intercepts cannot answer the query.
func (nmd *NetworkMapData) privateServiceApex(svc *nmdata.Service) string {
if domainUnderSuffix(svc.Domain, svc.ProxyCluster) {
return svc.ProxyCluster
}
apex := ""
for _, d := range nmd.Domains {
if d.TargetCluster != svc.ProxyCluster {
continue
}
if domainUnderSuffix(svc.Domain, d.Domain) && len(d.Domain) > len(apex) {
apex = d.Domain
}
}
return apex
}
func domainUnderSuffix(domain, suffix string) bool {
if suffix == "" {
return false
}
return domain == suffix || strings.HasSuffix(domain, "."+suffix)
}
// connectedProxyPeersByCluster groups the account's embedded proxy peers by the
// cluster they serve, keeping only connected ones.
func (nmd *NetworkMapData) connectedProxyPeersByCluster() map[string][]*nmdata.Peer {
var out map[string][]*nmdata.Peer
for _, peer := range nmd.Peers {
if peer == nil || !peer.ProxyMeta.Embedded || !peer.Connected || !peer.IP.IsValid() {
continue
}
if out == nil {
out = make(map[string][]*nmdata.Peer)
}
out[peer.ProxyMeta.Cluster] = append(out[peer.ProxyMeta.Cluster], peer)
}
for _, peers := range out {
slices.SortFunc(peers, func(a, b *nmdata.Peer) int { return strings.Compare(a.ID, b.ID) })
}
return out
}

View File

@@ -0,0 +1,148 @@
package networkmap
import (
"net/netip"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
func proxyPeer(id, ip, cluster string, connected bool) *nmdata.Peer {
return &nmdata.Peer{
ID: id, Key: id + "-key", IP: netip.MustParseAddr(ip), Connected: connected,
ProxyMeta: nmdata.ProxyMeta{Embedded: true, Cluster: cluster},
}
}
func privateService(id, domain, cluster string, groups ...string) *nmdata.Service {
return &nmdata.Service{
ID: id, Enabled: true, Private: true, Mode: "http",
Domain: domain, ProxyCluster: cluster, AccessGroups: groups,
}
}
func twinWithProxy(services ...*nmdata.Service) *NetworkMapData {
return &NetworkMapData{
Peers: map[string]*nmdata.Peer{
"proxy-1": proxyPeer("proxy-1", "100.64.0.99", "eu.proxy.netbird.io", true),
},
Services: services,
}
}
// An agent-network service is synthesised in memory and never persisted, so it
// only ever reaches the twin through nmd.Services. Deriving the zone from that
// same field is what stops it from arriving with an ACL and no name.
func TestBuildPrivateServiceCandidates_SynthesisedServiceGetsAZone(t *testing.T) {
nmd := twinWithProxy(privateService(
"agent-network-acct-1", "acct-1.agent.netbird.io", "eu.proxy.netbird.io", "grp-admins"))
nmd.BuildPrivateServiceCandidates()
require.Len(t, nmd.PrivateServiceCandidates, 0,
"a service whose domain sits under no registered apex publishes nothing")
nmd.Domains = []nmdata.ProxyDomain{
{Domain: "agent.netbird.io", TargetCluster: "eu.proxy.netbird.io"},
}
nmd.BuildPrivateServiceCandidates()
require.Len(t, nmd.PrivateServiceCandidates, 1)
got := nmd.PrivateServiceCandidates[0]
assert.Equal(t, []string{"grp-admins"}, got.AccessGroups)
assert.Equal(t, "agent.netbird.io.", got.Zone.Domain, "apex is the registered domain, not the service FQDN")
assert.True(t, got.Zone.NonAuthoritative, "zone stays match-only")
assert.True(t, got.Zone.SearchDomainDisabled)
require.Len(t, got.Zone.Records, 1)
assert.Equal(t, nmdata.SimpleRecord{
Name: "acct-1.agent.netbird.io.", Type: 1, Class: "IN", TTL: 5, RData: "100.64.0.99",
}, got.Zone.Records[0])
}
func TestBuildPrivateServiceCandidates_ClusterApexNeedsNoRegisteredDomain(t *testing.T) {
nmd := twinWithProxy(privateService("svc-1", "myapp.eu.proxy.netbird.io", "eu.proxy.netbird.io", "grp-admins"))
nmd.BuildPrivateServiceCandidates()
require.Len(t, nmd.PrivateServiceCandidates, 1)
assert.Equal(t, "eu.proxy.netbird.io.", nmd.PrivateServiceCandidates[0].Zone.Domain)
}
func TestBuildPrivateServiceCandidates_LongestRegisteredApexWins(t *testing.T) {
nmd := twinWithProxy(privateService("svc-1", "app.sub.example.com", "eu.proxy.netbird.io", "grp-admins"))
nmd.Domains = []nmdata.ProxyDomain{
{Domain: "example.com", TargetCluster: "eu.proxy.netbird.io"},
{Domain: "sub.example.com", TargetCluster: "eu.proxy.netbird.io"},
{Domain: "other.com", TargetCluster: "eu.proxy.netbird.io"},
}
nmd.BuildPrivateServiceCandidates()
require.Len(t, nmd.PrivateServiceCandidates, 1)
assert.Equal(t, "sub.example.com.", nmd.PrivateServiceCandidates[0].Zone.Domain)
}
func TestBuildPrivateServiceCandidates_RegisteredApexOfAnotherClusterIsIgnored(t *testing.T) {
nmd := twinWithProxy(privateService("svc-1", "app.example.com", "eu.proxy.netbird.io", "grp-admins"))
nmd.Domains = []nmdata.ProxyDomain{
{Domain: "example.com", TargetCluster: "us.proxy.netbird.io"},
}
nmd.BuildPrivateServiceCandidates()
assert.Empty(t, nmd.PrivateServiceCandidates)
}
// A disconnected proxy peer's tunnel IP does not answer, so publishing it
// black-holes the name for as long as a client caches the record.
func TestBuildPrivateServiceCandidates_OnlyConnectedProxyPeersSurface(t *testing.T) {
nmd := twinWithProxy(privateService("svc-1", "myapp.eu.proxy.netbird.io", "eu.proxy.netbird.io", "grp-admins"))
nmd.Peers["proxy-2"] = proxyPeer("proxy-2", "100.64.0.100", "eu.proxy.netbird.io", false)
nmd.BuildPrivateServiceCandidates()
require.Len(t, nmd.PrivateServiceCandidates, 1)
require.Len(t, nmd.PrivateServiceCandidates[0].Zone.Records, 1)
assert.Equal(t, "100.64.0.99", nmd.PrivateServiceCandidates[0].Zone.Records[0].RData)
nmd.Peers["proxy-1"].Connected = false
nmd.BuildPrivateServiceCandidates()
assert.Empty(t, nmd.PrivateServiceCandidates, "no connected proxy peer means no zone at all")
}
func TestBuildPrivateServiceCandidates_SkipsServicesThatGrantNothing(t *testing.T) {
cases := map[string]func(*nmdata.Service){
"disabled": func(s *nmdata.Service) { s.Enabled = false },
"not private": func(s *nmdata.Service) { s.Private = false },
"no access groups": func(s *nmdata.Service) { s.AccessGroups = nil },
"no domain": func(s *nmdata.Service) { s.Domain = "" },
"other cluster": func(s *nmdata.Service) { s.ProxyCluster = "us.proxy.netbird.io" },
}
for name, mutate := range cases {
t.Run(name, func(t *testing.T) {
svc := privateService("svc-1", "myapp.eu.proxy.netbird.io", "eu.proxy.netbird.io", "grp-admins")
mutate(svc)
nmd := twinWithProxy(svc)
nmd.BuildPrivateServiceCandidates()
assert.Empty(t, nmd.PrivateServiceCandidates)
})
}
}
func TestBuildPrivateServiceCandidates_MultipleConnectedProxyPeersEachGetARecord(t *testing.T) {
nmd := twinWithProxy(privateService("svc-1", "myapp.eu.proxy.netbird.io", "eu.proxy.netbird.io", "grp-admins"))
nmd.Peers["proxy-2"] = proxyPeer("proxy-2", "100.64.0.100", "eu.proxy.netbird.io", true)
nmd.BuildPrivateServiceCandidates()
require.Len(t, nmd.PrivateServiceCandidates, 1)
records := nmd.PrivateServiceCandidates[0].Zone.Records
require.Len(t, records, 2)
assert.Equal(t, "100.64.0.99", records[0].RData, "records are ordered by proxy peer id")
assert.Equal(t, "100.64.0.100", records[1].RData)
}