diff --git a/integration_tests/management/network_map_db/network_map_data_golden.json b/integration_tests/management/network_map_db/network_map_data_golden.json index bb0ccd30b..bf194851e 100644 --- a/integration_tests/management/network_map_db/network_map_data_golden.json +++ b/integration_tests/management/network_map_db/network_map_data_golden.json @@ -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" + } + ] } \ No newline at end of file diff --git a/integration_tests/management/network_map_db/peer_test.go b/integration_tests/management/network_map_db/peer_test.go index e33c3ea3a..cf2d7928f 100644 --- a/integration_tests/management/network_map_db/peer_test.go +++ b/integration_tests/management/network_map_db/peer_test.go @@ -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", diff --git a/management/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go index d72ba439d..9727ff958 100644 --- a/management/internals/controllers/network_map/controller/controller.go +++ b/management/internals/controllers/network_map/controller/controller.go @@ -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() diff --git a/management/internals/controllers/network_map/nmaptest/runner.go b/management/internals/controllers/network_map/nmaptest/runner.go index c70bd7298..0d6ac9c18 100644 --- a/management/internals/controllers/network_map/nmaptest/runner.go +++ b/management/internals/controllers/network_map/nmaptest/runner.go @@ -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 diff --git a/management/internals/network_map_db/network_map_data.go b/management/internals/network_map_db/network_map_data.go index f18cc8650..1e83bfb34 100644 --- a/management/internals/network_map_db/network_map_data.go +++ b/management/internals/network_map_db/network_map_data.go @@ -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, diff --git a/management/internals/network_map_db/network_map_data_test.go b/management/internals/network_map_db/network_map_data_test.go index 925a23d1c..2b694f5bc 100644 --- a/management/internals/network_map_db/network_map_data_test.go +++ b/management/internals/network_map_db/network_map_data_test.go @@ -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{ diff --git a/management/internals/network_map_db/shared_types.go b/management/internals/network_map_db/shared_types.go index bdd387877..e468ba36a 100644 --- a/management/internals/network_map_db/shared_types.go +++ b/management/internals/network_map_db/shared_types.go @@ -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 +} diff --git a/management/server/types/account_networkmapdata.go b/management/server/types/account_networkmapdata.go index d554bfe80..80052d393 100644 --- a/management/server/types/account_networkmapdata.go +++ b/management/server/types/account_networkmapdata.go @@ -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 +} diff --git a/shared/management/networkmap/networkmapdata.go b/shared/management/networkmap/networkmapdata.go index e27605d64..4e0e1c420 100644 --- a/shared/management/networkmap/networkmapdata.go +++ b/shared/management/networkmap/networkmapdata.go @@ -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{} diff --git a/shared/management/networkmap/nmdata/peer.go b/shared/management/networkmap/nmdata/peer.go index 3ceb1dbc1..78df77b64 100644 --- a/shared/management/networkmap/nmdata/peer.go +++ b/shared/management/networkmap/nmdata/peer.go @@ -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 diff --git a/shared/management/networkmap/nmdata/service.go b/shared/management/networkmap/nmdata/service.go index 63557c51e..a28e7a954 100644 --- a/shared/management/networkmap/nmdata/service.go +++ b/shared/management/networkmap/nmdata/service.go @@ -9,6 +9,7 @@ type Service struct { Enabled bool Private bool Mode string + Domain string ProxyCluster string AccessGroups []string Targets []*ServiceTarget diff --git a/shared/management/networkmap/privatezones.go b/shared/management/networkmap/privatezones.go new file mode 100644 index 000000000..07643c7af --- /dev/null +++ b/shared/management/networkmap/privatezones.go @@ -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 +} diff --git a/shared/management/networkmap/privatezones_test.go b/shared/management/networkmap/privatezones_test.go new file mode 100644 index 000000000..5700fcf79 --- /dev/null +++ b/shared/management/networkmap/privatezones_test.go @@ -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) +}