diff --git a/integration_tests/management/network_map_db/main_test.go b/integration_tests/management/network_map_db/main_test.go index 4fd4f226c..ee96cf1b5 100644 --- a/integration_tests/management/network_map_db/main_test.go +++ b/integration_tests/management/network_map_db/main_test.go @@ -40,6 +40,7 @@ func TestMain(m *testing.M) { case string(types.PostgresStoreEngine): engine = string(types.PostgresStoreEngine) pgstore, cleanup = createPGTestStore(baseData, pgData) + pgstore.UsingTimeZone(time.UTC) case "", string(types.SqliteStoreEngine): engine = string(types.SqliteStoreEngine) sqlitestore, cleanup = createSqliteTestStore(baseData, sqliteData) @@ -67,6 +68,18 @@ func conn(t *testing.T, ctx context.Context) networkmapdb.NetworkMapDBStoreConn return nil } +func store(t *testing.T) networkmapdb.NetworkMapDBStore { + t.Helper() + switch engine { + case string(types.PostgresStoreEngine): + return pgstore + case string(types.SqliteStoreEngine): + return sqlitestore + } + log.Fatalf("unknown db engine kind %s", engine) + return nil +} + func execQuery(t *testing.T, ctx context.Context, q string) { t.Helper() switch engine { @@ -80,20 +93,13 @@ func execQuery(t *testing.T, ctx context.Context, q string) { } // use to parse time in time.RFC3339Nano format -// returns the time in the local time zone, as that's what being returned from sql queries -// pgx returns time in the "local" timezone -// sql with sqlite driver returns time in UTC timezone +// returns the time in the UTC time zone func mustParseTime(t string) *time.Time { tt, err := time.Parse(time.RFC3339Nano, t) if err != nil { panic(err) } - if engine == string(types.SqliteStoreEngine) { - utc := tt.UTC() - return &utc - } - - local := tt.Local() - return &local + utc := tt.UTC() + return &utc } 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 new file mode 100644 index 000000000..e78982850 --- /dev/null +++ b/integration_tests/management/network_map_db/network_map_data_golden.json @@ -0,0 +1,372 @@ +{ + "Peers": { + "peer-id-1": { + "ID": "peer-id-1", + "Key": "key-1", + "SSHKey": "ssh-key-1", + "DNSLabel": "peer-1", + "UserID": "user-id-1", + "SSHEnabled": true, + "LoginExpirationEnabled": true, + "LastLogin": "2026-08-06T13:25:59.12999Z", + "IP": "10.10.10.1", + "IPv6": "fdf4:ba80:6aa5:89f1:44d7:8701:8699:4940", + "RequiresApproval": false, + "ExtraDNSLabels": [ + "extra-peer-1" + ], + "Meta": { + "WtVersion": "0.76.0", + "GoOS": "linux", + "OSVersion": "26.4.1", + "KernelVersion": "6.8.0-134-generic", + "NetworkAddresses": [ + { + "NetIP": "fe80::8b4c:973f:a76b:3771/64" + }, + { + "NetIP": "192.168.16.1/20" + } + ], + "Files": [ + { + "Path": "/usr/bin/netbird", + "ProcessIsRunning": false + } + ], + "Capabilities": [ + 1, + 2 + ], + "Flags": { + "ServerSSHAllowed": true, + "DisableIPv6": false + }, + "SyncMessageVersion": 1 + }, + "ProxyMeta": { + "Embedded": true + }, + "Location": { + "CountryCode": "DE", + "CityName": "Berlin", + "ConnectionIP": "46.201.148.187" + } + }, + "peer-id-2": { + "ID": "peer-id-2", + "Key": "key-2", + "SSHKey": "ssh-key-2", + "DNSLabel": "peer-2", + "UserID": "user-id-2", + "SSHEnabled": true, + "LoginExpirationEnabled": true, + "LastLogin": "2026-08-06T14:25:59.12999Z", + "IP": "10.10.100.1", + "IPv6": "fdf5:ba80:6aa5:89f1:44d7:8701:8699:4940", + "RequiresApproval": false, + "ExtraDNSLabels": [ + "extra-peer-2" + ], + "Meta": { + "WtVersion": "0.76.1", + "GoOS": "linux", + "OSVersion": "26.4.2", + "KernelVersion": "6.8.0-135-generic", + "NetworkAddresses": [ + { + "NetIP": "fe81::8b4c:973f:a76b:3771/64" + }, + { + "NetIP": "192.168.17.1/20" + } + ], + "Files": [ + { + "Path": "/usr/bin/netbird", + "ProcessIsRunning": false + } + ], + "Capabilities": [ + 1, + 2 + ], + "Flags": { + "ServerSSHAllowed": true, + "DisableIPv6": false + }, + "SyncMessageVersion": 0 + }, + "ProxyMeta": { + "Embedded": true + }, + "Location": { + "CountryCode": "DE", + "CityName": "Berlin", + "ConnectionIP": "46.201.149.187" + } + }, + "peer-id-3": { + "ID": "peer-id-3", + "Key": "key-3", + "SSHKey": "ssh-key-3", + "DNSLabel": "peer-3", + "UserID": "user-id-3", + "SSHEnabled": true, + "LoginExpirationEnabled": true, + "LastLogin": "2026-08-06T12:25:59.12999Z", + "IP": "10.10.200.1", + "IPv6": "fdf6:ba80:6aa5:89f1:44d7:8701:8699:4940", + "RequiresApproval": false, + "ExtraDNSLabels": [ + "extra-peer-3" + ], + "Meta": { + "WtVersion": "0.76.2", + "GoOS": "linux", + "OSVersion": "26.4.3", + "KernelVersion": "6.8.0-136-generic", + "NetworkAddresses": [ + { + "NetIP": "fe82::8b4c:973f:a76b:3771/64" + }, + { + "NetIP": "192.168.18.1/20" + } + ], + "Files": [ + { + "Path": "/usr/bin/netbird", + "ProcessIsRunning": false + } + ], + "Capabilities": [ + 1, + 2 + ], + "Flags": { + "ServerSSHAllowed": true, + "DisableIPv6": false + }, + "SyncMessageVersion": 1 + }, + "ProxyMeta": { + "Embedded": true + }, + "Location": { + "CountryCode": "DE", + "CityName": "Berlin", + "ConnectionIP": "46.201.150.187" + } + } + }, + "Groups": { + "group-no-resources-id": { + "ID": "group-no-resources-id", + "Name": "group-3-name", + "PublicID": "group-no-resources-id-public", + "Peers": null, + "Resources": null + }, + "group-one-resource-id": { + "ID": "group-one-resource-id", + "Name": "group-1-name", + "PublicID": "group-one-resource-id-public", + "Peers": [ + "peer-id-1" + ], + "Resources": [ + { + "ID": "host-id-1", + "Type": "host" + } + ] + }, + "group-two-resources-id": { + "ID": "group-two-resources-id", + "Name": "group-2-name", + "PublicID": "group-two-resources-id-public", + "Peers": [ + "peer-id-2", + "peer-id-3" + ], + "Resources": [ + { + "ID": "subnet-id-1", + "Type": "subnet" + }, + { + "ID": "host-id-2", + "Type": "host" + } + ] + } + }, + "Policies": [], + "Routes": [], + "NameServerGroups": [ + { + "ID": "nsgroup-1", + "PublicID": "nsgroup-1-public", + "Name": "nsgroup-1", + "Description": "nsgroup-1", + "NameServers": [ + { + "IP": "192.168.31.2", + "NSType": 1, + "Port": 53 + } + ], + "Groups": [ + "group-one-resource-id" + ], + "Primary": true, + "Domains": [ + "test-1.com" + ], + "Enabled": true, + "SearchDomainsEnabled": false + }, + { + "ID": "nsgroup-2", + "PublicID": "nsgroup-2-public", + "Name": "nsgroup-2", + "Description": "nsgroup-2", + "NameServers": [ + { + "IP": "192.168.32.3", + "NSType": 1, + "Port": 53 + } + ], + "Groups": [ + "group-one-resource-id", + "group-no-resources-id" + ], + "Primary": true, + "Domains": [ + "test-1.com", + "test-2.com" + ], + "Enabled": true, + "SearchDomainsEnabled": false + }, + { + "ID": "nsgroup-3", + "PublicID": "nsgroup-3-public", + "Name": "", + "Description": "", + "NameServers": null, + "Groups": null, + "Primary": false, + "Domains": null, + "Enabled": true, + "SearchDomainsEnabled": false + } + ], + "NetworkResources": [], + "Network": { + "Identifier": "network-1", + "Net": { + "IP": "100.103.0.0", + "Mask": "//8AAA==" + }, + "NetV6": { + "IP": "fdde:e995:fd38:a465::", + "Mask": "//////////8AAAAAAAAAAA==" + }, + "Dns": "", + "Serial": 1 + }, + "DNSSettings": { + "DisabledManagementGroups": [ + "disabled-group-1", + "disabled-group-2" + ] + }, + "AccountSettings": { + "PeerLoginExpirationEnabled": true, + "PeerLoginExpiration": 86400000000000, + "PeerInactivityExpirationEnabled": false, + "PeerInactivityExpiration": 86400000000000, + "DNSDomain": "", + "IPv6EnabledGroups": [ + "group-one-resource-id" + ], + "RoutingPeerDNSResolutionEnabled": false, + "LazyConnectionEnabled": false, + "AutoUpdateVersion": "disabled", + "AutoUpdateAlways": false, + "MetricsPushEnabled": false + }, + "PostureChecks": {}, + "PostureValidation": null, + "AllowedUserIDs": {}, + "NetworkXIDToPublicID": {}, + "PostureCheckXIDToPublicID": {}, + "ValidatedPeers": { + "peer-id-1": {}, + "peer-id-2": {}, + "peer-id-3": {} + }, + "ResourcePolicies": {}, + "Routers": {}, + "GroupIDToUserIDs": {}, + "DNSDomain": "", + "ProxyTargetedDomainResourceIDs": {}, + "AppliedZoneCandidates": [ + { + "DistributionGroups": [ + "group-one-resource-id" + ], + "Zone": { + "Domain": "test-1.com", + "Records": [ + { + "Name": "test.test-1.com", + "Type": 1, + "Class": "IN", + "TTL": 1800, + "RData": "1.1.1.1" + }, + { + "Name": "test2.test-1.com", + "Type": 1, + "Class": "IN", + "TTL": 1800, + "RData": "1.1.1.2" + }, + { + "Name": "test3.test-1.com", + "Type": 5, + "Class": "IN", + "TTL": 1800, + "RData": "test4.test-1.com." + } + ], + "SearchDomainDisabled": false, + "NonAuthoritative": false + } + }, + { + "DistributionGroups": [ + "group-two-resources-id" + ], + "Zone": { + "Domain": "test-2.com", + "Records": [ + { + "Name": "test2.test-2.com", + "Type": 5, + "Class": "IN", + "TTL": 1800, + "RData": "test3.test-2.com." + } + ], + "SearchDomainDisabled": true, + "NonAuthoritative": false + } + } + ], + "PrivateServiceCandidates": null +} \ No newline at end of file diff --git a/integration_tests/management/network_map_db/network_map_data_test.go b/integration_tests/management/network_map_db/network_map_data_test.go new file mode 100644 index 000000000..700fa064e --- /dev/null +++ b/integration_tests/management/network_map_db/network_map_data_test.go @@ -0,0 +1,103 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + _ "embed" + "encoding/json" + "net/netip" + "os" + "path/filepath" + "runtime" + "testing" + + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" + "github.com/netbirdio/netbird/management/server/integrations/extra_settings" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/netbirdio/netbird/shared/management/proto" + "github.com/stretchr/testify/assert" +) + +//go:embed network_map_data_golden.json +var goldenNMap string + +const EnvUpdateGoldenData = "NMAP_UPDATE_GOLDEN_DATA" + +func TestGetNetworkMapData(t *testing.T) { + ctx := context.TODO() + storeImpl := networkmapdb.NetworkMapDBStoreImpl{ + Store: store(t), + ExtraSettingsManager: &extraSettingsManagerForTesting{}, + IntegratedPeerValidator: &peerValidatorForTesting{}, + } + + nmap, err := storeImpl.GetNetworkMapData(ctx, "account-1") + assert.NoError(t, err) + + serializedNMap, err := json.MarshalIndent(nmap, "", " ") + assert.NoError(t, err) + + if _, ok := os.LookupEnv(EnvUpdateGoldenData); ok { + _, filename, _, _ := runtime.Caller(0) + tosavepath := filepath.Join(filepath.Dir(filename), "network_map_data_golden.json") + err = os.WriteFile(tosavepath, serializedNMap, 0644) + assert.NoError(t, err) + goldenNMap = string(serializedNMap) + } + assert.Equal(t, goldenNMap, string(serializedNMap)) +} + +// need these calls for the test +func (*extraSettingsManagerForTesting) GetExtraSettings(ctx context.Context, accountID string) (*types.ExtraSettings, error) { + return &types.ExtraSettings{}, nil +} +func (*peerValidatorForTesting) GetValidatedPeers(ctx context.Context, accountID string, groups []*nmdata.Group, peers []*nmdata.Peer, extraSettings *types.ExtraSettings) (map[string]struct{}, error) { + return map[string]struct{}{ + "peer-id-1": {}, + "peer-id-2": {}, + "peer-id-3": {}, + }, nil +} + +type extraSettingsManagerForTesting struct{} + +func (*extraSettingsManagerForTesting) GetExtraSettingsManager() extra_settings.Manager { return nil } +func (*extraSettingsManagerForTesting) GetSettings(ctx context.Context, accountID string, userID string) (*types.Settings, error) { + return nil, nil +} +func (*extraSettingsManagerForTesting) UpdateExtraSettings(ctx context.Context, accountID, userID string, extraSettings *types.ExtraSettings) (bool, error) { + return false, nil +} +func (*extraSettingsManagerForTesting) GetEffectiveNetworkRanges(ctx context.Context, accountID string) (v4, v6 netip.Prefix, err error) { + return netip.Prefix{}, netip.Prefix{}, nil +} + +type peerValidatorForTesting struct{} + +func (*peerValidatorForTesting) ValidateExtraSettings(ctx context.Context, newExtraSettings *types.ExtraSettings, oldExtraSettings *types.ExtraSettings, userID string, accountID string) error { + return nil +} +func (*peerValidatorForTesting) ValidatePeer(ctx context.Context, update *nbpeer.Peer, peer *nbpeer.Peer, userID string, accountID string, dnsDomain string, peersGroup []string, extraSettings *types.ExtraSettings) (*nbpeer.Peer, bool, error) { + return nil, false, nil +} +func (*peerValidatorForTesting) PreparePeer(ctx context.Context, accountID string, peer *nbpeer.Peer, peersGroup []string, extraSettings *types.ExtraSettings, temporary bool) *nbpeer.Peer { + return nil +} +func (*peerValidatorForTesting) IsNotValidPeer(ctx context.Context, accountID string, peer *nbpeer.Peer, peersGroup []string, extraSettings *types.ExtraSettings) (bool, bool, error) { + return false, false, nil +} +func (*peerValidatorForTesting) GetInvalidPeers(ctx context.Context, accountID string, extraSettings *types.ExtraSettings) (map[string]string, error) { + return nil, nil +} +func (*peerValidatorForTesting) PeerDeleted(ctx context.Context, accountID, peerID string, extraSettings *types.ExtraSettings) error { + return nil +} +func (*peerValidatorForTesting) SetPeerInvalidationListener(fn func(accountID string, peerIDs []string)) { +} +func (*peerValidatorForTesting) Stop(ctx context.Context) {} +func (*peerValidatorForTesting) ValidateFlowResponse(ctx context.Context, peerKey string, flowResponse *proto.PKCEAuthorizationFlow) *proto.PKCEAuthorizationFlow { + return nil +} diff --git a/magefiles/test.go b/magefiles/test.go index 9b638e96a..2d08e8e01 100644 --- a/magefiles/test.go +++ b/magefiles/test.go @@ -55,6 +55,15 @@ func (Integrationtest) NmapDbSqlite(gotestflags *string) error { return sh.RunWithV(map[string]string{"NETBIRD_STORE_ENGINE": "sqlite"}, "go", cli...) } +func (Integrationtest) RegenerateNmapGoldenData(gotestflags *string) error { + cli := defaultcli + if gotestflags != nil { + cli = append(cli, strings.Split(*gotestflags, " ")...) + } + cli = append(cli, "./integration_tests/management/network_map_db/...") + return sh.RunWithV(map[string]string{"NMAP_UPDATE_GOLDEN_DATA": "true", "NETBIRD_STORE_ENGINE": "sqlite"}, "go", cli...) +} + func (Integrationtest) Api(gotestflags *string) error { cli := defaultcli if gotestflags != nil { diff --git a/management/internals/network_map_db/network_map_data.go b/management/internals/network_map_db/network_map_data.go index 8d8c033b4..f18cc8650 100644 --- a/management/internals/network_map_db/network_map_data.go +++ b/management/internals/network_map_db/network_map_data.go @@ -3,6 +3,7 @@ package networkmapdb import ( "context" "fmt" + "net/netip" "strings" "github.com/miekg/dns" @@ -88,35 +89,13 @@ func (s *NetworkMapDBStoreImpl) GetNetworkMapData(ctx context.Context, accountId return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get proxy targeted domain resources: %w", err)) } - resourcePolicies := make(map[string][]*nmdata.Policy) - for _, resource := range networkResources { - if !resource.Enabled { - continue - } - networkResourceGroups := resourceToGroupIdx[resource.ID] - for _, policy := range policies { - if !policy.Enabled { - continue - } - if _, ok := policyToDestinationResourceIdx[policy.ID][resource.ID]; ok { - resourcePolicies[resource.ID] = append(resourcePolicies[resource.ID], &policy) // TODO (dmitri) maybe use public id? - continue - } - if groupIds, ok := policyToDestinationGroupIdx[policy.ID]; ok { - for networkResourceGroup := range networkResourceGroups { - if _, ok := groupIds[networkResourceGroup]; ok { - resourcePolicies[resource.ID] = append(resourcePolicies[resource.ID], &policy) - break - } - } - } - } - } - if err = tx.CommitTx(ctx); err != nil { log.WithContext(ctx).Warnf("failed to commit network map read transaction: %v", err) } + resourcePolicies := buildResourcePolicies( + networkResources, policies, resourceToGroupIdx, policyToDestinationResourceIdx, policyToDestinationGroupIdx) + toret := networkmap.NetworkMapData{ AccountSettings: &acctSettings, DNSSettings: &dnsSettings, @@ -220,18 +199,16 @@ func buildPrivateServiceCandidates(svcs []Service, domains []Domain, proxyPeersB 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 !proxyPeer.IP.IsValid() { - continue + if record, ok := recordForProxyPeer(svc.Domain.String, proxyPeer.IP); ok { + records = append(records, record) } - records = append(records, nmdata.SimpleRecord{ - Name: dns.Fqdn(svc.Domain.String), - Type: int(dns.TypeA), - Class: "IN", - TTL: 5, - RData: proxyPeer.IP.String(), - }) } if len(records) == 0 { continue @@ -250,3 +227,51 @@ func buildPrivateServiceCandidates(svcs []Service, domains []Domain, proxyPeersB 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, + policyToDestinationResourceIdx map[string]map[string]any, + policyToDestinationGroupIdx map[string]map[string]any) map[string][]*nmdata.Policy { + + resourcePolicies := make(map[string][]*nmdata.Policy) + for _, resource := range networkResources { + if !resource.Enabled { + continue + } + networkResourceGroups := resourceToGroupIdx[resource.ID] + for _, policy := range policies { + if !policy.Enabled { + continue + } + if _, ok := policyToDestinationResourceIdx[policy.ID][resource.ID]; ok { + resourcePolicies[resource.ID] = append(resourcePolicies[resource.ID], &policy) // TODO (dmitri) maybe use public id? + continue + } + if groupIds, ok := policyToDestinationGroupIdx[policy.ID]; ok { + for networkResourceGroup := range networkResourceGroups { + if _, ok := groupIds[networkResourceGroup]; ok { + resourcePolicies[resource.ID] = append(resourcePolicies[resource.ID], &policy) + break + } + } + } + } + } + + return resourcePolicies +} diff --git a/management/internals/network_map_db/network_map_data_test.go b/management/internals/network_map_db/network_map_data_test.go new file mode 100644 index 000000000..f93f02a4f --- /dev/null +++ b/management/internals/network_map_db/network_map_data_test.go @@ -0,0 +1,400 @@ +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 + record, 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{ + {ID: "net-res-1", Enabled: false}, + } + policies := []nmdata.Policy{ + {ID: "policy-1", Enabled: true}, + } + resourceToGroupIdx := map[string]map[string]any{} + policyToDestinationResourceIdx := map[string]map[string]any{ + "policy-1": { + "net-res-1": struct{}{}, + "net-res-3": struct{}{}, + }, + } + policyToDestinationGroupIdx := map[string]map[string]any{} + + assert.Empty(t, buildResourcePolicies( + networkResources, policies, resourceToGroupIdx, policyToDestinationResourceIdx, policyToDestinationGroupIdx)) +} + +// disabled policy shouldn't be in the resulting map +func TestBuildResourcePolicies_DisabledPolicy(t *testing.T) { + networkResources := []nmdata.NetworkResource{ + {ID: "net-res-1", Enabled: true}, + } + policies := []nmdata.Policy{ + {ID: "policy-1", Enabled: false}, + } + resourceToGroupIdx := map[string]map[string]any{} + policyToDestinationResourceIdx := map[string]map[string]any{ + "policy-1": { + "net-res-1": struct{}{}, + "net-res-3": struct{}{}, + }, + } + policyToDestinationGroupIdx := map[string]map[string]any{} + + assert.Empty(t, buildResourcePolicies( + networkResources, policies, resourceToGroupIdx, policyToDestinationResourceIdx, policyToDestinationGroupIdx)) +} + +// build ResourcePolicies via PolicyToDestinationResourceIdx only +func TestBuildResourcePolicies_ViaPolicyToDestinationResourceIdx(t *testing.T) { + networkResources := []nmdata.NetworkResource{ + {ID: "net-res-1", Enabled: true}, + {ID: "net-res-2", Enabled: true}, + {ID: "net-res-3", Enabled: true}, + } + policies := []nmdata.Policy{ + {ID: "policy-1", Enabled: true}, + {ID: "policy-2", Enabled: true}, + {ID: "policy-3", Enabled: true}, + } + resourceToGroupIdx := map[string]map[string]any{} + policyToDestinationResourceIdx := map[string]map[string]any{ + "policy-1": { + "net-res-1": struct{}{}, + "net-res-3": struct{}{}, + }, + "policy-2": { + "net-res-2": struct{}{}, + }, + "policy-3": { + "net-res-1": struct{}{}, + "net-res-2": struct{}{}, + }, + } + policyToDestinationGroupIdx := map[string]map[string]any{} + + resourceToPolicies := buildResourcePolicies( + networkResources, policies, resourceToGroupIdx, policyToDestinationResourceIdx, policyToDestinationGroupIdx) + + assert.Equal(t, map[string][]*nmdata.Policy{ + "net-res-1": { + {ID: "policy-1", Enabled: true}, + {ID: "policy-3", Enabled: true}, + }, + "net-res-2": { + {ID: "policy-2", Enabled: true}, + {ID: "policy-3", Enabled: true}, + }, + "net-res-3": { + {ID: "policy-1", Enabled: true}, + }, + }, resourceToPolicies) +} + +// build ResourcePolicies via PolicyToDestinationGroupIdx only +func TestBuildResourcePolicies_ViaPolicyToDestinationGroupIdx(t *testing.T) { + networkResources := []nmdata.NetworkResource{ + {ID: "net-res-1", Enabled: true}, + {ID: "net-res-2", Enabled: true}, + {ID: "net-res-3", Enabled: true}, + } + policies := []nmdata.Policy{ + {ID: "policy-1", Enabled: true}, + {ID: "policy-2", Enabled: true}, + {ID: "policy-3", Enabled: true}, + } + resourceToGroupIdx := map[string]map[string]any{ + "net-res-1": { + "group-1": struct{}{}, + "group-2": struct{}{}, + }, + "net-res-2": { + "group-2": struct{}{}, + "group-3": struct{}{}, + }, + "net-res-3": { + "group-3": struct{}{}, + "group-4": struct{}{}, + }, + } + policyToDestinationResourceIdx := map[string]map[string]any{} + policyToDestinationGroupIdx := map[string]map[string]any{ + "policy-1": { + "group-1": struct{}{}, + "group-2": struct{}{}, + }, + "policy-2": { + "group-1": struct{}{}, + "group-4": struct{}{}, + }, + "policy-3": { + "group-1": struct{}{}, + "group-3": struct{}{}, + }, + } + + resourceToPolicies := buildResourcePolicies( + networkResources, policies, resourceToGroupIdx, policyToDestinationResourceIdx, policyToDestinationGroupIdx) + + assert.Equal(t, map[string][]*nmdata.Policy{ + "net-res-1": { + {ID: "policy-1", Enabled: true}, + {ID: "policy-2", Enabled: true}, + {ID: "policy-3", Enabled: true}, + }, + "net-res-2": { + {ID: "policy-1", Enabled: true}, + {ID: "policy-3", Enabled: true}, + }, + "net-res-3": { + {ID: "policy-2", Enabled: true}, + {ID: "policy-3", Enabled: true}, + }, + }, resourceToPolicies) +} diff --git a/management/internals/network_map_db/pgsql/pg_store.go b/management/internals/network_map_db/pgsql/pg_store.go index 13b6f6c17..737424ab2 100644 --- a/management/internals/network_map_db/pgsql/pg_store.go +++ b/management/internals/network_map_db/pgsql/pg_store.go @@ -8,6 +8,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" ) @@ -22,7 +23,8 @@ const ( var _ networkmapdb.NetworkMapDBStore = &PgStore{} type PgStore struct { - Pool *pgxpool.Pool + Pool *pgxpool.Pool + Location *time.Location } type PgStoreConn struct { @@ -45,7 +47,23 @@ func NewPostgresqlStore(ctx context.Context, dsn string) (*PgStore, error) { return &PgStore{Pool: pool}, nil } +// This is used to control the timezone timestamps returned in. +// By default pgx returns timestamps in the local timezone, +// which may not be desirable. +// use .UsingTimeZone(time.UTC) to return timestamps in UTC TZ +func (p *PgStore) UsingTimeZone(location *time.Location) { + p.Location = location +} + func (p *PgStore) UsingConnection(c *pgx.Conn) networkmapdb.NetworkMapDBStoreConn { + if p.Location != nil { + c.TypeMap().RegisterType(&pgtype.Type{ + Name: "timestamptz", + OID: pgtype.TimestamptzOID, + Codec: &pgtype.TimestamptzCodec{ScanLocation: time.UTC}, + }) + } + return &PgStoreConn{Conn: c} } @@ -54,6 +72,13 @@ func (p *PgStore) BeginTx(ctx context.Context) (networkmapdb.NetworkMapDBStoreCo if err != nil { return nil, err } + if p.Location != nil { + tx.Conn().TypeMap().RegisterType(&pgtype.Type{ + Name: "timestamptz", + OID: pgtype.TimestamptzOID, + Codec: &pgtype.TimestamptzCodec{ScanLocation: time.UTC}, + }) + } return &PgStoreConn{Conn: tx}, nil }