diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml index 004b78b3e..c93e36e4e 100644 --- a/.github/workflows/golang-test-linux.yml +++ b/.github/workflows/golang-test-linux.yml @@ -730,6 +730,11 @@ jobs: - name: Install modules run: go mod tidy + - name: Run Mage + uses: magefile/mage-action@a662bd8c29d8106879588cfff83b2faf6e6f59db # v4.0.0 + with: + install-only: true + - name: check git status run: git --no-pager diff --exit-code @@ -738,9 +743,7 @@ jobs: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \ NETBIRD_STORE_ENGINE=${{ matrix.store }} \ CI=true \ - go test -tags=integration -coverprofile=coverage.txt \ - -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' \ - -timeout 20m ./management/server/http/... + mage integrationtest:all -gotestflags="-coverprofile=coverage.txt" - name: Upload coverage reports to Codecov if: matrix.arch == 'amd64' diff --git a/client/cmd/testutil_test.go b/client/cmd/testutil_test.go index f40056f83..328a15454 100644 --- a/client/cmd/testutil_test.go +++ b/client/cmd/testutil_test.go @@ -124,7 +124,7 @@ func startManagement(t *testing.T, config *config.Config, testFile string) (*grp updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := mgmt.NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersmanager), config) + networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersmanager), config, nil) accountManager, err := mgmt.BuildManager(ctx, config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore) if err != nil { diff --git a/client/embed/embed_test.go b/client/embed/embed_test.go index 27beb8934..4ff5c9978 100644 --- a/client/embed/embed_test.go +++ b/client/embed/embed_test.go @@ -146,7 +146,7 @@ func startManagement(t *testing.T, signalAddr string) string { updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := mgmt.NewAccountRequestBuffer(context.Background(), testStore) - networkMapController := controller.NewController(context.Background(), testStore, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(testStore, peersManager), cfg) + networkMapController := controller.NewController(context.Background(), testStore, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(testStore, peersManager), cfg, nil) accountManager, err := mgmt.BuildManager(context.Background(), cfg, testStore, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) require.NoError(t, err) diff --git a/client/firewall/nftables/router_linux.go b/client/firewall/nftables/router_linux.go index d3e031c5f..c79f9b8c2 100644 --- a/client/firewall/nftables/router_linux.go +++ b/client/firewall/nftables/router_linux.go @@ -763,7 +763,7 @@ func (r *router) addNatRule(pair firewall.RouterPair) error { exprs = append(exprs, sourceExp...) exprs = append(exprs, destExp...) - var markValue uint32 = nbnet.PreroutingFwmarkMasquerade + markValue := nbnet.PreroutingFwmarkMasquerade if pair.Inverse { markValue = nbnet.PreroutingFwmarkMasqueradeReturn } diff --git a/client/iface/configurer/usp.go b/client/iface/configurer/usp.go index 0a25c55bc..2be1b861e 100644 --- a/client/iface/configurer/usp.go +++ b/client/iface/configurer/usp.go @@ -502,7 +502,7 @@ func toBytes(s string) (int64, error) { func getFwmark() int { if nbnet.AdvancedRouting() && runtime.GOOS == "linux" { - return nbnet.ControlPlaneMark + return int(nbnet.ControlPlaneMark) } return 0 } diff --git a/client/iface/wgproxy/rawsocket/rawsocket.go b/client/iface/wgproxy/rawsocket/rawsocket.go index bc785b43a..37aaa160f 100644 --- a/client/iface/wgproxy/rawsocket/rawsocket.go +++ b/client/iface/wgproxy/rawsocket/rawsocket.go @@ -10,8 +10,6 @@ import ( log "github.com/sirupsen/logrus" "golang.org/x/sys/unix" - - nbnet "github.com/netbirdio/netbird/client/net" ) // PrepareSenderRawSocketIPv4 creates and configures a raw socket for sending IPv4 packets @@ -60,14 +58,12 @@ func prepareSenderRawSocket(family int, isIPv4 bool) (net.PacketConn, error) { return nil, fmt.Errorf("binding to lo interface failed: %w", err) } - // Set the fwmark on the socket. - err = nbnet.SetSocketOpt(fd) - if err != nil { - if closeErr := syscall.Close(fd); closeErr != nil { - log.Warnf("failed to close raw socket fd: %v", closeErr) - } - return nil, fmt.Errorf("setting fwmark failed: %w", err) - } + // The socket is bound to lo and only ever sends to the local WireGuard + // instance, a destination the local routing table resolves without help, so + // it carries no fwmark. Staying unmarked also keeps these packets out of + // third-party NAT rules that match on marks: such a rule rewriting the + // source would make WireGuard adopt the rewritten address as the peer + // endpoint. // Convert the file descriptor to a PacketConn. file := os.NewFile(uintptr(fd), fmt.Sprintf("fd %d", fd)) diff --git a/client/iface/wgproxy/rawsocket/rawsocket_privileged_test.go b/client/iface/wgproxy/rawsocket/rawsocket_privileged_test.go new file mode 100644 index 000000000..03748c6f9 --- /dev/null +++ b/client/iface/wgproxy/rawsocket/rawsocket_privileged_test.go @@ -0,0 +1,77 @@ +//go:build linux && !android && privileged + +package rawsocket + +import ( + "net" + "syscall" + "testing" + + "golang.org/x/sys/unix" + + nbnet "github.com/netbirdio/netbird/client/net" +) + +// The sender sockets must stay unmarked: a NAT rule matching on fwmark that +// rewrites the source of an injected packet makes WireGuard adopt the rewritten +// address as the peer endpoint. +func TestSenderRawSocketsCarryNoFwmark(t *testing.T) { + // the mark is only ever applied when advanced routing is available, so + // without it the assertion below would hold for the wrong reason + nbnet.Init() + if !nbnet.AdvancedRouting() { + t.Skip("advanced routing unsupported, the sockets carry no mark either way") + } + + tests := []struct { + name string + prepare func() (net.PacketConn, error) + // the proxy treats the IPv6 socket as optional, so a host without IPv6 + // is a reason to skip rather than to fail + optional bool + }{ + {name: "IPv4", prepare: PrepareSenderRawSocketIPv4}, + {name: "IPv6", prepare: PrepareSenderRawSocketIPv6, optional: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + conn, err := tc.prepare() + if err != nil { + if tc.optional { + t.Skipf("prepare raw socket: %v", err) + } + t.Fatalf("prepare raw socket: %v", err) + } + defer func() { + if err := conn.Close(); err != nil { + t.Logf("close raw socket: %v", err) + } + }() + + syscallConn, ok := conn.(syscall.Conn) + if !ok { + t.Fatalf("raw socket %T does not expose a syscall conn", conn) + } + raw, err := syscallConn.SyscallConn() + if err != nil { + t.Fatalf("syscall conn: %v", err) + } + + var mark int + var markErr error + if err := raw.Control(func(fd uintptr) { + mark, markErr = unix.GetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_MARK) + }); err != nil { + t.Fatalf("control: %v", err) + } + if markErr != nil { + t.Fatalf("get SO_MARK: %v", markErr) + } + + if mark != 0 { + t.Errorf("SO_MARK = %#x, want 0", mark) + } + }) + } +} diff --git a/client/internal/dns_test.go b/client/internal/dns_test.go index e15cc8fb7..031431efe 100644 --- a/client/internal/dns_test.go +++ b/client/internal/dns_test.go @@ -8,7 +8,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/netbirdio/netbird/client/iface/wgaddr" nbdns "github.com/netbirdio/netbird/dns" + mgmProto "github.com/netbirdio/netbird/shared/management/proto" ) func TestCreatePTRRecord_IPv4(t *testing.T) { @@ -136,3 +138,88 @@ func TestAddReverseZone_IPv6(t *testing.T) { assert.Len(t, reverseZone.Records, 1) assert.Equal(t, int(dns.TypePTR), reverseZone.Records[0].Type) } + +// TestToDNSConfig_ZoneFlagsPreserved pins the per-zone NonAuthoritative flag +// through the legacy DNSConfig path. A non-authoritative zone is match-only: +// the local resolver falls through to the upstream for an in-zone name it does +// not define. The built-in peer zone is the authoritative one and must stay +// that way, so the flag has to travel per zone rather than be derived. +func TestToDNSConfig_ZoneFlagsPreserved(t *testing.T) { + config := toDNSConfig(&mgmProto.DNSConfig{ + ServiceEnable: true, + CustomZones: []*mgmProto.CustomZone{ + { + Domain: "netbird.cloud.", + Records: []*mgmProto.SimpleRecord{ + {Name: "peer1.netbird.cloud.", Type: int64(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.1"}, + }, + }, + { + Domain: "corp.internal.", + NonAuthoritative: true, + SearchDomainDisabled: true, + Records: []*mgmProto.SimpleRecord{ + {Name: "db.corp.internal.", Type: int64(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.10.0.5"}, + }, + }, + }, + }, wgaddr.Address{ + IP: netip.MustParseAddr("100.64.0.1"), + Network: netip.MustParsePrefix("100.64.0.0/16"), + }) + + zones := make(map[string]nbdns.CustomZone, len(config.CustomZones)) + for _, zone := range config.CustomZones { + zones[zone.Domain] = zone + } + + peerZone, ok := zones["netbird.cloud."] + require.True(t, ok, "peer zone must survive") + assert.False(t, peerZone.NonAuthoritative, "the built-in peer zone owns the account domain and stays authoritative") + + accountZone, ok := zones["corp.internal."] + require.True(t, ok, "account zone must survive") + assert.True(t, accountZone.NonAuthoritative, "an account zone stays match-only, else undefined in-zone names get black-holed") + assert.True(t, accountZone.SearchDomainDisabled) +} + +// TestToDNSConfig_SingleZoneForcedAuthoritative pins the compatibility clause +// in toDNSConfig: a config carrying exactly one zone is treated as +// authoritative no matter what the server said, because servers that predate +// the NonAuthoritative field send only the peer FQDN zone. +// +// The clause can only ever downgrade an explicit true to false, so a server +// that legitimately sends a single non-authoritative zone — an account whose +// only zone is a custom one, with no peer records to build the built-in zone +// from — gets that zone's whole apex black-holed on the client. Real accounts +// always carry the peer zone alongside, which is why this is latent. Narrowing +// it needs a way to tell "unset" from "false" on the wire, or the account +// domain passed down here; until then this test states the contract so a +// change to it is deliberate. +func TestToDNSConfig_SingleZoneForcedAuthoritative(t *testing.T) { + config := toDNSConfig(&mgmProto.DNSConfig{ + ServiceEnable: true, + CustomZones: []*mgmProto.CustomZone{ + { + Domain: "corp.internal.", + NonAuthoritative: true, + Records: []*mgmProto.SimpleRecord{ + {Name: "db.corp.internal.", Type: int64(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.10.0.5"}, + }, + }, + }, + }, wgaddr.Address{ + IP: netip.MustParseAddr("100.64.0.1"), + Network: netip.MustParsePrefix("100.64.0.0/16"), + }) + + require.NotEmpty(t, config.CustomZones) + assert.Equal(t, "corp.internal.", config.CustomZones[0].Domain) + assert.False(t, config.CustomZones[0].NonAuthoritative, + "a lone zone is forced authoritative for pre-NonAuthoritative servers") + + // The reverse zone the config gains afterwards must not feed back into the + // decision: the compat gate counts the zones the server sent. + require.Len(t, config.CustomZones, 2, "a reverse zone is appended for the overlay prefix") + assert.Equal(t, "64.100.in-addr.arpa.", config.CustomZones[1].Domain) +} diff --git a/client/internal/engine_privileged_test.go b/client/internal/engine_privileged_test.go index 032992464..1428b742c 100644 --- a/client/internal/engine_privileged_test.go +++ b/client/internal/engine_privileged_test.go @@ -519,7 +519,7 @@ func startManagement(t *testing.T, dataDir, testFile string) (*grpc.Server, stri updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := server.NewAccountRequestBuffer(context.Background(), store) - networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config) + networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config, nil) accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) if err != nil { return nil, "", err diff --git a/client/internal/updater/installer/doc.go b/client/internal/updater/installer/doc.go index 11b0512ac..aff0f24f7 100644 --- a/client/internal/updater/installer/doc.go +++ b/client/internal/updater/installer/doc.go @@ -109,6 +109,10 @@ // - Does NOT remove result.json (cleaned by ResultHandler after read) // - Does NOT remove msi.log (kept for debugging) // +// On Windows the updater copy is often still locked when the daemon it restarted +// runs cleanup, so removing it is retried briefly and otherwise left in place for +// the next update to overwrite rather than reported as a failure. +// // # Dry-Run Mode // // Dry-run mode allows testing the update process without actually installing: diff --git a/client/internal/updater/installer/installer_cleanup_windows_test.go b/client/internal/updater/installer/installer_cleanup_windows_test.go new file mode 100644 index 000000000..aab16dc93 --- /dev/null +++ b/client/internal/updater/installer/installer_cleanup_windows_test.go @@ -0,0 +1,67 @@ +package installer + +import ( + "os" + "path/filepath" + "testing" + "time" + + "golang.org/x/sys/windows" +) + +// lockFile opens path without FILE_SHARE_DELETE, so os.Remove fails the way it does +// while the updater process still holds its own image. +func lockFile(t *testing.T, path string) windows.Handle { + t.Helper() + + p, err := windows.UTF16PtrFromString(path) + if err != nil { + t.Fatalf("convert path: %v", err) + } + + handle, err := windows.CreateFile(p, windows.GENERIC_READ, windows.FILE_SHARE_READ, nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0) + if err != nil { + t.Fatalf("lock %s: %v", path, err) + } + return handle +} + +// releaseAfter closes the handle once the delay has passed, standing in for the +// updater process finally exiting. +func releaseAfter(t *testing.T, handle windows.Handle, delay time.Duration) { + t.Helper() + + released := make(chan struct{}) + t.Cleanup(func() { <-released }) + + go func() { + defer close(released) + time.Sleep(delay) + if err := windows.CloseHandle(handle); err != nil { + t.Errorf("close handle: %v", err) + } + }() +} + +// TestCleanUpInstallerFilesLockedUpdater covers the post-update cleanup race: the +// daemon cleans up at startup while the updater that restarted it is still exiting, +// so the updater image is locked and Windows refuses the delete. Cleanup must wait +// the lock out instead of reporting a failure and leaving the binary behind. +func TestCleanUpInstallerFilesLockedUpdater(t *testing.T) { + tempDir := t.TempDir() + path := filepath.Join(tempDir, updaterBinary) + if err := os.WriteFile(path, []byte("x"), 0o600); err != nil { + t.Fatalf("write updater: %v", err) + } + + releaseAfter(t, lockFile(t, path), 300*time.Millisecond) + + u := NewWithDir(tempDir) + if err := u.CleanUpInstallerFiles(); err != nil { + t.Fatalf("cleanup must tolerate a still-locked updater: %v", err) + } + + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("updater binary still present (stat err: %v)", err) + } +} diff --git a/client/internal/updater/installer/installer_common.go b/client/internal/updater/installer/installer_common.go index 17566f7de..f917424b8 100644 --- a/client/internal/updater/installer/installer_common.go +++ b/client/internal/updater/installer/installer_common.go @@ -152,8 +152,8 @@ func (u *Installer) CleanUpInstallerFiles() error { var merr *multierror.Error - if err := os.Remove(filepath.Join(u.tempDir, updaterBinary)); err != nil && !os.IsNotExist(err) { - merr = multierror.Append(merr, fmt.Errorf("failed to remove updater binary: %w", err)) + if err := removeUpdaterBinary(filepath.Join(u.tempDir, updaterBinary)); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove updater binary: %w", err)) } entries, err := os.ReadDir(u.tempDir) @@ -167,10 +167,16 @@ func (u *Installer) CleanUpInstallerFiles() error { } name := entry.Name() + // The updater copy is handled above; on Windows its name also matches the + // extension sweep, which would report the same file twice. + if strings.EqualFold(name, updaterBinary) { + continue + } + for _, ext := range binaryExtensions { if strings.HasSuffix(strings.ToLower(name), strings.ToLower(ext)) { if err := os.Remove(filepath.Join(u.tempDir, name)); err != nil { - merr = multierror.Append(merr, fmt.Errorf("failed to remove %s: %w", name, err)) + merr = multierror.Append(merr, fmt.Errorf("remove %s: %w", name, err)) } break } diff --git a/client/internal/updater/installer/installer_common_test.go b/client/internal/updater/installer/installer_common_test.go new file mode 100644 index 000000000..c1556c828 --- /dev/null +++ b/client/internal/updater/installer/installer_common_test.go @@ -0,0 +1,52 @@ +//go:build windows || darwin + +package installer + +import ( + "os" + "path/filepath" + "testing" +) + +// TestCleanUpInstallerFiles checks that cleanup removes the updater copy and the +// downloaded installer while leaving the logs and the result file for the daemon. +func TestCleanUpInstallerFiles(t *testing.T) { + tempDir := t.TempDir() + + installers := make([]string, 0, len(binaryExtensions)) + for _, ext := range binaryExtensions { + installers = append(installers, "netbird_installer."+ext) + } + + kept := []string{"installer.log", "result.json"} + + for _, name := range append(append([]string{updaterBinary}, installers...), kept...) { + if err := os.WriteFile(filepath.Join(tempDir, name), []byte("x"), 0o600); err != nil { + t.Fatalf("write %s: %v", name, err) + } + } + + u := NewWithDir(tempDir) + if err := u.CleanUpInstallerFiles(); err != nil { + t.Fatalf("CleanUpInstallerFiles: %v", err) + } + + for _, name := range append([]string{updaterBinary}, installers...) { + if _, err := os.Stat(filepath.Join(tempDir, name)); !os.IsNotExist(err) { + t.Errorf("%s was not removed (stat err: %v)", name, err) + } + } + + for _, name := range kept { + if _, err := os.Stat(filepath.Join(tempDir, name)); err != nil { + t.Errorf("%s should have been kept: %v", name, err) + } + } +} + +func TestCleanUpInstallerFilesMissingTempDir(t *testing.T) { + u := NewWithDir(filepath.Join(t.TempDir(), "does-not-exist")) + if err := u.CleanUpInstallerFiles(); err != nil { + t.Errorf("a missing temp dir is not a cleanup failure, got: %v", err) + } +} diff --git a/client/internal/updater/installer/remove_updater_darwin.go b/client/internal/updater/installer/remove_updater_darwin.go new file mode 100644 index 000000000..4d4a0be60 --- /dev/null +++ b/client/internal/updater/installer/remove_updater_darwin.go @@ -0,0 +1,12 @@ +package installer + +import "os" + +// removeUpdaterBinary deletes the updater copy left in the temp dir. On darwin a +// running binary can be unlinked, so no retry is needed. +func removeUpdaterBinary(path string) error { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} diff --git a/client/internal/updater/installer/remove_updater_windows.go b/client/internal/updater/installer/remove_updater_windows.go new file mode 100644 index 000000000..0e23b1644 --- /dev/null +++ b/client/internal/updater/installer/remove_updater_windows.go @@ -0,0 +1,45 @@ +package installer + +import ( + "errors" + "os" + "time" + + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" +) + +const ( + // The updater is the process that restarted the daemon, so when the daemon + // cleans up at startup the updater is often still exiting and Windows refuses + // to delete its locked image. These bound how long cleanup waits for it. + updaterRemoveAttempts = 5 + updaterRemoveDelay = 200 * time.Millisecond +) + +// removeUpdaterBinary deletes the updater copy left in the temp dir, retrying +// while the still-exiting updater process holds its image. A binary that stays +// locked for the whole window is left in place and reported at info level: the +// next update overwrites it, so it is not worth failing cleanup over. +func removeUpdaterBinary(path string) error { + for attempt := 0; attempt < updaterRemoveAttempts; attempt++ { + if attempt > 0 { + time.Sleep(updaterRemoveDelay) + } + + err := os.Remove(path) + if err == nil || os.IsNotExist(err) { + return nil + } + if !isFileLocked(err) { + return err + } + } + + log.Infof("updater binary %s is still locked, leaving it for the next update to overwrite", path) + return nil +} + +func isFileLocked(err error) bool { + return errors.Is(err, windows.ERROR_ACCESS_DENIED) || errors.Is(err, windows.ERROR_SHARING_VIOLATION) +} diff --git a/client/internal/updater/installer/remove_updater_windows_test.go b/client/internal/updater/installer/remove_updater_windows_test.go new file mode 100644 index 000000000..09910d034 --- /dev/null +++ b/client/internal/updater/installer/remove_updater_windows_test.go @@ -0,0 +1,59 @@ +package installer + +import ( + "os" + "path/filepath" + "testing" + "time" + + "golang.org/x/sys/windows" +) + +func TestRemoveUpdaterBinaryRetriesWhileLocked(t *testing.T) { + path := filepath.Join(t.TempDir(), updaterBinary) + if err := os.WriteFile(path, []byte("x"), 0o600); err != nil { + t.Fatalf("write updater: %v", err) + } + + releaseAfter(t, lockFile(t, path), updaterRemoveDelay+50*time.Millisecond) + + if err := removeUpdaterBinary(path); err != nil { + t.Fatalf("removeUpdaterBinary: %v", err) + } + + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("updater binary still present (stat err: %v)", err) + } +} + +// TestRemoveUpdaterBinaryStaysLocked covers an updater that never releases its +// image within the retry window. Cleanup gives up quietly and leaves the file +// behind rather than reporting a failure. +func TestRemoveUpdaterBinaryStaysLocked(t *testing.T) { + path := filepath.Join(t.TempDir(), updaterBinary) + if err := os.WriteFile(path, []byte("x"), 0o600); err != nil { + t.Fatalf("write updater: %v", err) + } + + handle := lockFile(t, path) + t.Cleanup(func() { + if err := windows.CloseHandle(handle); err != nil { + t.Errorf("close handle: %v", err) + } + }) + + if err := removeUpdaterBinary(path); err != nil { + t.Fatalf("a permanently locked updater is not a cleanup failure, got: %v", err) + } + + if _, err := os.Stat(path); err != nil { + t.Errorf("locked updater binary should be left in place, stat: %v", err) + } +} + +func TestRemoveUpdaterBinaryMissingFile(t *testing.T) { + path := filepath.Join(t.TempDir(), updaterBinary) + if err := removeUpdaterBinary(path); err != nil { + t.Errorf("a missing updater binary is not a failure, got: %v", err) + } +} diff --git a/client/net/fwmark.go b/client/net/fwmark.go new file mode 100644 index 000000000..b526feee4 --- /dev/null +++ b/client/net/fwmark.go @@ -0,0 +1,110 @@ +package net + +import ( + "fmt" + "os" + "strconv" + "strings" + + log "github.com/sirupsen/logrus" +) + +const ( + // envFwmarkBase overrides the base of the fwmark range. Container network + // plugins, CNIs and other VPNs claim bits of the mark space for themselves, + // and a rule of theirs matching one of our bits acts on our traffic, so + // hosts running such software may need to move the range out of the way. + envFwmarkBase = "NB_FWMARK_BASE" + + // defaultFwmarkBase is the base of the fwmark range used when the + // environment does not override it. + defaultFwmarkBase uint32 = 0x1BD00 + + // fwmarkOffsetMask is the part of a mark that identifies the individual mark + // within the range, so the base occupies everything above it. + fwmarkOffsetMask uint32 = 0xFF +) + +// Offsets of the individual marks within the range. +const ( + offsetControlPlane uint32 = 0x00 + offsetDataPlaneIn uint32 = 0x10 + offsetDataPlaneOut uint32 = 0x11 + offsetRedirected uint32 = 0x20 + offsetMasquerade uint32 = 0x21 + offsetMasqueradeReturn uint32 = 0x22 + offsetDataPlaneLower uint32 = 0x10 + offsetDataPlaneUpper uint32 = fwmarkOffsetMask +) + +var ( + fwmarkBase = loadFwmarkBase() + + // ControlPlaneMark is the fwmark value used to mark packets that should not be routed through the NetBird interface to + // avoid routing loops. + // This includes all control plane traffic (mgmt, signal, flows), relay, ICE/stun/turn and everything that is emitted by the wireguard socket. + // It doesn't collide with the other marks, as the others are used for data plane traffic only. + ControlPlaneMark = fwmarkBase | offsetControlPlane + + // DataPlaneMarkLower is the lowest value for the data plane range + DataPlaneMarkLower = fwmarkBase | offsetDataPlaneLower + // DataPlaneMarkUpper is the highest value for the data plane range + DataPlaneMarkUpper = fwmarkBase | offsetDataPlaneUpper + + // DataPlaneMarkIn is the mark for inbound data plane traffic. + DataPlaneMarkIn = fwmarkBase | offsetDataPlaneIn + + // DataPlaneMarkOut is the mark for outbound data plane traffic. + DataPlaneMarkOut = fwmarkBase | offsetDataPlaneOut + + // PreroutingFwmarkRedirected is applied to packets that were redirected (input -> forward, e.g. by Docker or Podman) for special handling. + PreroutingFwmarkRedirected = fwmarkBase | offsetRedirected + + // PreroutingFwmarkMasquerade is applied to packets that arrive from the NetBird interface and should be masqueraded. + PreroutingFwmarkMasquerade = fwmarkBase | offsetMasquerade + + // PreroutingFwmarkMasqueradeReturn is applied to packets that will leave through the NetBird interface and should be masqueraded. + PreroutingFwmarkMasqueradeReturn = fwmarkBase | offsetMasqueradeReturn +) + +// IsDataPlaneMark determines if a fwmark is in the data plane range. +func IsDataPlaneMark(fwmark uint32) bool { + return fwmark >= DataPlaneMarkLower && fwmark <= DataPlaneMarkUpper +} + +func loadFwmarkBase() uint32 { + val := os.Getenv(envFwmarkBase) + if val == "" { + return defaultFwmarkBase + } + + base, err := parseFwmarkBase(val) + if err != nil { + log.Warnf("failed to parse %s=%q, using the default range: %v", envFwmarkBase, val, err) + return defaultFwmarkBase + } + + log.Infof("using fwmark range %#x-%#x from %s", base, base|fwmarkOffsetMask, envFwmarkBase) + return base +} + +// parseFwmarkBase reads a mark range base. The low byte of a mark identifies the +// individual mark within the range, so a base has to leave it free. +func parseFwmarkBase(val string) (uint32, error) { + val = strings.TrimSpace(val) + + base, err := strconv.ParseUint(val, 0, 32) + if err != nil { + return 0, fmt.Errorf("not a 32 bit number: %w", err) + } + + if base == 0 { + return 0, fmt.Errorf("base must not be zero") + } + + if uint32(base)&fwmarkOffsetMask != 0 { + return 0, fmt.Errorf("base %#x must leave the low byte free", base) + } + + return uint32(base), nil +} diff --git a/client/net/fwmark_test.go b/client/net/fwmark_test.go new file mode 100644 index 000000000..2dbebec2a --- /dev/null +++ b/client/net/fwmark_test.go @@ -0,0 +1,111 @@ +package net + +import ( + "testing" +) + +func TestParseFwmarkBase(t *testing.T) { + tests := []struct { + name string + val string + want uint32 + wantErr bool + }{ + {name: "hex", val: "0x5A000", want: 0x5A000}, + {name: "hex upper case", val: "0X5A000", want: 0x5A000}, + {name: "decimal", val: "65536", want: 65536}, + {name: "octal", val: "0o400", want: 0o400}, + {name: "surrounding space", val: " 0x5A000 ", want: 0x5A000}, + {name: "highest usable base", val: "0xFFFFFF00", want: 0xFFFFFF00}, + {name: "low byte in use", val: "0x1BD01", wantErr: true}, + {name: "zero", val: "0", wantErr: true}, + {name: "not a number", val: "wireguard", wantErr: true}, + {name: "wider than 32 bit", val: "0x1FFFFFFFF", wantErr: true}, + {name: "negative", val: "-0x100", wantErr: true}, + {name: "empty", val: "", wantErr: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := parseFwmarkBase(tc.val) + if tc.wantErr { + if err == nil { + t.Fatalf("parseFwmarkBase(%q) = %#x, want an error", tc.val, got) + } + return + } + if err != nil { + t.Fatalf("parseFwmarkBase(%q): %v", tc.val, err) + } + if got != tc.want { + t.Errorf("parseFwmarkBase(%q) = %#x, want %#x", tc.val, got, tc.want) + } + }) + } +} + +// The marks have to stay inside the range the base defines, otherwise a host +// that moved the range to dodge a collision would still emit the old values. +func TestMarksStayWithinTheRange(t *testing.T) { + lower, upper := fwmarkBase, fwmarkBase|fwmarkOffsetMask + + marks := map[string]uint32{ + "ControlPlaneMark": ControlPlaneMark, + "DataPlaneMarkLower": DataPlaneMarkLower, + "DataPlaneMarkUpper": DataPlaneMarkUpper, + "DataPlaneMarkIn": DataPlaneMarkIn, + "DataPlaneMarkOut": DataPlaneMarkOut, + "PreroutingFwmarkRedirected": PreroutingFwmarkRedirected, + "PreroutingFwmarkMasquerade": PreroutingFwmarkMasquerade, + "PreroutingFwmarkMasqueradeReturn": PreroutingFwmarkMasqueradeReturn, + } + + for name, mark := range marks { + if mark < lower || mark > upper { + t.Errorf("%s = %#x, outside the range %#x-%#x", name, mark, lower, upper) + } + } + + // the control plane mark must stay out of the data plane range, the netflow + // conntrack path tells them apart by it + if IsDataPlaneMark(ControlPlaneMark) { + t.Errorf("ControlPlaneMark %#x is inside the data plane range", ControlPlaneMark) + } + for name, mark := range map[string]uint32{ + "DataPlaneMarkIn": DataPlaneMarkIn, + "DataPlaneMarkOut": DataPlaneMarkOut, + "PreroutingFwmarkRedirected": PreroutingFwmarkRedirected, + "PreroutingFwmarkMasquerade": PreroutingFwmarkMasquerade, + "PreroutingFwmarkMasqueradeReturn": PreroutingFwmarkMasqueradeReturn, + } { + if !IsDataPlaneMark(mark) { + t.Errorf("%s = %#x is outside the data plane range %#x-%#x", name, mark, DataPlaneMarkLower, DataPlaneMarkUpper) + } + } +} + +func TestDefaultMarksAreUnchanged(t *testing.T) { + tests := map[string]struct { + got uint32 + want uint32 + }{ + "ControlPlaneMark": {ControlPlaneMark, 0x1BD00}, + "DataPlaneMarkLower": {DataPlaneMarkLower, 0x1BD10}, + "DataPlaneMarkUpper": {DataPlaneMarkUpper, 0x1BDFF}, + "DataPlaneMarkIn": {DataPlaneMarkIn, 0x1BD10}, + "DataPlaneMarkOut": {DataPlaneMarkOut, 0x1BD11}, + "PreroutingFwmarkRedirected": {PreroutingFwmarkRedirected, 0x1BD20}, + "PreroutingFwmarkMasquerade": {PreroutingFwmarkMasquerade, 0x1BD21}, + "PreroutingFwmarkMasqueradeReturn": {PreroutingFwmarkMasqueradeReturn, 0x1BD22}, + } + + if fwmarkBase != defaultFwmarkBase { + t.Skipf("%s is set, the defaults do not apply", envFwmarkBase) + } + + for name, tc := range tests { + if tc.got != tc.want { + t.Errorf("%s = %#x, want %#x", name, tc.got, tc.want) + } + } +} diff --git a/client/net/net.go b/client/net/net.go index a97de9d59..77fba36d1 100644 --- a/client/net/net.go +++ b/client/net/net.go @@ -7,41 +7,6 @@ import ( "net/netip" ) -const ( - // ControlPlaneMark is the fwmark value used to mark packets that should not be routed through the NetBird interface to - // avoid routing loops. - // This includes all control plane traffic (mgmt, signal, flows), relay, ICE/stun/turn and everything that is emitted by the wireguard socket. - // It doesn't collide with the other marks, as the others are used for data plane traffic only. - ControlPlaneMark = 0x1BD00 - - // Data plane marks (0x1BD10 - 0x1BDFF) - - // DataPlaneMarkLower is the lowest value for the data plane range - DataPlaneMarkLower = 0x1BD10 - // DataPlaneMarkUpper is the highest value for the data plane range - DataPlaneMarkUpper = 0x1BDFF - - // DataPlaneMarkIn is the mark for inbound data plane traffic. - DataPlaneMarkIn = 0x1BD10 - - // DataPlaneMarkOut is the mark for outbound data plane traffic. - DataPlaneMarkOut = 0x1BD11 - - // PreroutingFwmarkRedirected is applied to packets that are were redirected (input -> forward, e.g. by Docker or Podman) for special handling. - PreroutingFwmarkRedirected = 0x1BD20 - - // PreroutingFwmarkMasquerade is applied to packets that arrive from the NetBird interface and should be masqueraded. - PreroutingFwmarkMasquerade = 0x1BD21 - - // PreroutingFwmarkMasqueradeReturn is applied to packets that will leave through the NetBird interface and should be masqueraded. - PreroutingFwmarkMasqueradeReturn = 0x1BD22 -) - -// IsDataPlaneMark determines if a fwmark is in the data plane range (0x1BD10-0x1BDFF) -func IsDataPlaneMark(fwmark uint32) bool { - return fwmark >= DataPlaneMarkLower && fwmark <= DataPlaneMarkUpper -} - func GetLastIPFromNetwork(network netip.Prefix, fromEnd int) (netip.Addr, error) { var endIP net.IP addr := network.Addr().AsSlice() diff --git a/client/net/net_linux.go b/client/net/net_linux.go index 9e7d13702..8ed8a1944 100644 --- a/client/net/net_linux.go +++ b/client/net/net_linux.go @@ -21,15 +21,6 @@ func SetSocketMark(conn syscall.Conn) error { return setRawSocketMark(sysconn) } -// SetSocketOpt sets the SO_MARK option on the given file descriptor -func SetSocketOpt(fd int) error { - if !AdvancedRouting() { - return nil - } - - return setSocketOptInt(fd) -} - func setRawSocketMark(conn syscall.RawConn) error { var setErr error @@ -51,5 +42,5 @@ func setRawSocketMark(conn syscall.RawConn) error { } func setSocketOptInt(fd int) error { - return syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_MARK, ControlPlaneMark) + return syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_MARK, int(ControlPlaneMark)) } diff --git a/client/server/network.go b/client/server/network.go index c390b8180..69eaabf8a 100644 --- a/client/server/network.go +++ b/client/server/network.go @@ -232,4 +232,3 @@ func toNetIDs(routes []string) []route.NetID { } return netIDs } - diff --git a/client/server/server_privileged_test.go b/client/server/server_privileged_test.go index 0366ccb31..aa6e99026 100644 --- a/client/server/server_privileged_test.go +++ b/client/server/server_privileged_test.go @@ -200,7 +200,7 @@ func startManagement(t *testing.T, signalAddr string, counter *int) (*grpc.Serve requestBuffer := server.NewAccountRequestBuffer(context.Background(), store) peersUpdateManager := update_channel.NewPeersUpdateManager(metrics) - networkMapController := controller.NewController(context.Background(), store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config) + networkMapController := controller.NewController(context.Background(), store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config, nil) accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore) if err != nil { return nil, "", err diff --git a/go.mod b/go.mod index efec8c94d..09c3df95b 100644 --- a/go.mod +++ b/go.mod @@ -71,17 +71,18 @@ require ( github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-secure-stdlib/base62 v0.1.2 github.com/hashicorp/go-version v1.7.0 - github.com/jackc/pgx/v5 v5.5.5 + github.com/jackc/pgx/v5 v5.10.0 github.com/libdns/route53 v1.5.0 github.com/libp2p/go-netroute v0.4.0 github.com/lrh3321/ipset-go v0.0.0-20250619021614-54a0a98ace81 + github.com/magefile/mage v1.17.2 github.com/mdlayher/socket v0.5.1 github.com/mdp/qrterminal/v3 v3.2.1 github.com/miekg/dns v1.1.72 github.com/mitchellh/hashstructure/v2 v2.0.2 github.com/moby/moby/api v1.54.1 github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8 - github.com/netbirdio/management-integrations/integrations v0.0.0-20260416123949-2355d972be42 + github.com/netbirdio/management-integrations/integrations v0.0.0-20260803100840-78e79ba20f87 github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 github.com/oapi-codegen/runtime v1.1.2 github.com/okta/okta-sdk-golang/v2 v2.18.0 @@ -236,8 +237,8 @@ require ( github.com/huin/goupnp v1.2.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect - github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect - github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackpal/go-nat-pmp v1.0.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect diff --git a/go.sum b/go.sum index da68b6458..e5bf6248d 100644 --- a/go.sum +++ b/go.sum @@ -341,12 +341,12 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= -github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= -github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= -github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= @@ -413,6 +413,8 @@ github.com/lrh3321/ipset-go v0.0.0-20250619021614-54a0a98ace81 h1:J56rFEfUTFT9j9 github.com/lrh3321/ipset-go v0.0.0-20250619021614-54a0a98ace81/go.mod h1:RD8ML/YdXctQ7qbcizZkw5mZ6l8Ogrl1dodBzVJduwI= github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae h1:dIZY4ULFcto4tAFlj1FYZl8ztUZ13bdq+PLY+NOfbyI= github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= +github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40= +github.com/magefile/mage v1.17.2/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= @@ -482,8 +484,8 @@ github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8 h1:pBxXEsxcsO3qVU github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8/go.mod h1:mFViabv4PpnoDw9w7W21a7xux6APA4q7KQZRsv4BCl8= github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51 h1:Ov4qdafATOgGMB1wbSuh+0aAHcwz9hdvB6VZjh1mVMI= github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51/go.mod h1:ZSIbPdBn5hePO8CpF1PekH2SfpTxg1PDhEwtbqZS7R8= -github.com/netbirdio/management-integrations/integrations v0.0.0-20260416123949-2355d972be42 h1:F3zS5fT9xzD1OFLfcdAE+3FfyiwjGukF1hvj0jErgs8= -github.com/netbirdio/management-integrations/integrations v0.0.0-20260416123949-2355d972be42/go.mod h1:n47r67ZSPgwSmT/Z1o48JjZQW9YJ6m/6Bd/uAXkL3Pg= +github.com/netbirdio/management-integrations/integrations v0.0.0-20260803100840-78e79ba20f87 h1:iJeUvSMC0BTpkw7u4JyWcY4/3dl7fEL9DR/TpKf2+1w= +github.com/netbirdio/management-integrations/integrations v0.0.0-20260803100840-78e79ba20f87/go.mod h1:pmsCPx1S0nuZRxCextGpc9AV4hLgGSuTsc4NMuwGeCo= github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 h1:3tHlFmhTdX9axERMVN63dqyFqnvuD+EMJHzM7mNGON8= github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM= github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ= diff --git a/integration_tests/management/network_map_db/account_settings_test.go b/integration_tests/management/network_map_db/account_settings_test.go new file mode 100644 index 000000000..d7927aaf1 --- /dev/null +++ b/integration_tests/management/network_map_db/account_settings_test.go @@ -0,0 +1,58 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "testing" + "time" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/stretchr/testify/assert" +) + +func TestGetAccountSettings(t *testing.T) { + ctx := context.TODO() + + execQuery(t, ctx, + `insert into accounts (id, settings_peer_login_expiration_enabled, settings_peer_login_expiration, settings_peer_inactivity_expiration_enabled, + settings_peer_inactivity_expiration, settings_dns_domain, settings_ipv6_enabled_groups, settings_routing_peer_dns_resolution_enabled, + settings_lazy_connection_enabled, settings_auto_update_version, settings_auto_update_always, settings_metrics_push_enabled) + values('account-3',null,null,null,null,null,null,null,null,null,null,null)`) + + accountSettings, err := conn(t, ctx).GetAccountSettings(ctx, "account-1") + assert.NoError(t, err) + assert.Equal(t, accountSettings, nmdata.AccountSettingsInfo{ + PeerLoginExpirationEnabled: true, + PeerLoginExpiration: 86400000000000 * time.Nanosecond, + PeerInactivityExpirationEnabled: false, + PeerInactivityExpiration: 86400000000000 * time.Nanosecond, + DNSDomain: "", + IPv6EnabledGroups: []string{"group-one-resource-id"}, + RoutingPeerDNSResolutionEnabled: false, + LazyConnectionEnabled: false, + AutoUpdateVersion: "disabled", + AutoUpdateAlways: false, + MetricsPushEnabled: false, + }) + + accountSettings, err = conn(t, ctx).GetAccountSettings(ctx, "account-2") + assert.NoError(t, err) + assert.Equal(t, accountSettings, nmdata.AccountSettingsInfo{ + PeerLoginExpirationEnabled: true, + PeerLoginExpiration: 86400000000000 * time.Nanosecond, + PeerInactivityExpirationEnabled: false, + PeerInactivityExpiration: 86400000000000 * time.Nanosecond, + DNSDomain: "", + IPv6EnabledGroups: []string{"group-two-resources-id"}, + RoutingPeerDNSResolutionEnabled: false, + LazyConnectionEnabled: false, + AutoUpdateVersion: "disabled", + AutoUpdateAlways: false, + MetricsPushEnabled: false, + }) + + accountSettings, err = conn(t, ctx).GetAccountSettings(ctx, "account-3") + assert.NoError(t, err) + assert.Equal(t, accountSettings, nmdata.AccountSettingsInfo{}) +} diff --git a/integration_tests/management/network_map_db/base_data.sql b/integration_tests/management/network_map_db/base_data.sql new file mode 100644 index 000000000..136df00ac --- /dev/null +++ b/integration_tests/management/network_map_db/base_data.sql @@ -0,0 +1,53 @@ +insert into accounts (id, network_identifier, network_net, network_net_v6, network_dns, network_serial,dns_settings_disabled_management_groups, + settings_peer_login_expiration_enabled, settings_peer_login_expiration, settings_peer_inactivity_expiration_enabled, + settings_peer_inactivity_expiration, settings_dns_domain, settings_ipv6_enabled_groups, settings_routing_peer_dns_resolution_enabled, + settings_lazy_connection_enabled, settings_auto_update_version, settings_auto_update_always, settings_metrics_push_enabled) +VALUES('account-1','network-1','{"IP":"100.103.0.0","Mask":"//8AAA=="}','{"IP":"fdde:e995:fd38:a465::","Mask":"//////////8AAAAAAAAAAA=="}','',1,'["disabled-group-1","disabled-group-2"]', + true, 86400000000000, false, + 86400000000000, null, '["group-one-resource-id"]', false, + false, 'disabled', false, false); +insert into accounts (id, network_identifier, network_net, network_net_v6, network_dns, network_serial,dns_settings_disabled_management_groups, + settings_peer_login_expiration_enabled, settings_peer_login_expiration, settings_peer_inactivity_expiration_enabled, + settings_peer_inactivity_expiration, settings_dns_domain, settings_ipv6_enabled_groups, settings_routing_peer_dns_resolution_enabled, + settings_lazy_connection_enabled, settings_auto_update_version, settings_auto_update_always, settings_metrics_push_enabled) +VALUES('account-2','network-2','{"IP":"110.0.0.0","Mask":"//8AAA=="}','{"IP":"fddf:e995:fd38:a465::","Mask":"//////////8AAAAAAAAAAA=="}','',2,null, + true, 86400000000000, false, + 86400000000000, null, '["group-two-resources-id"]', false, + false, 'disabled', false, false); +insert into groups (id, account_id, name, resources, public_id) VALUES('group-one-resource-id','account-1','group-1-name', '[{"ID":"host-id-1","Type":"host"}]','group-one-resource-id-public'); +insert into groups (id, account_id, name, resources, public_id) VALUES('group-two-resources-id','account-1','group-2-name', '[{"ID":"subnet-id-1","Type":"subnet"}, {"ID":"host-id-2","Type":"host"}]','group-two-resources-id-public'); +insert into groups (id, account_id, name, resources, public_id) VALUES('group-no-resources-id','account-1','group-3-name', null,'group-no-resources-id-public'); +insert into group_peers (account_id, peer_id, group_id) VALUES('account-1','peer-id-1','group-one-resource-id'); +insert into group_peers (account_id, peer_id, group_id) VALUES('account-1','peer-id-2','group-two-resources-id'); +insert into group_peers (account_id, peer_id, group_id) VALUES('account-1','peer-id-3','group-two-resources-id'); +insert into peers (id, account_id, "key", ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6, + peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster, + meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, + meta_capabilities, meta_flags, meta_sync_message_version, + location_country_code, location_city_name, location_connection_ip) + values('peer-id-1','account-1','key-1','ssh-key-1','peer-1','["extra-peer-1"]','user-id-1',true,true,'2026-08-06 13:25:59.12999','"10.10.10.1"','"fdf4:ba80:6aa5:89f1:44d7:8701:8699:4940"', + false,true,true,'cluster-1.netbird.services', + '0.76.0','linux','26.4.1','6.8.0-134-generic','[{"NetIP":"fe80::8b4c:973f:a76b:3771/64","Mac":"00:15:5d:24:0c:ac"},{"NetIP":"192.168.16.1/20","Mac":"00:15:5d:24:0c:ac"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]', + '[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',1, + 'DE','Berlin','"46.201.148.187"'); +insert into peers (id,account_id,"key", ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6, + peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster, + meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, + meta_capabilities, meta_flags, meta_sync_message_version, + location_country_code, location_city_name, location_connection_ip) + values('peer-id-2','account-1','key-2','ssh-key-2','peer-2','["extra-peer-2"]','user-id-2',true,true,'2026-08-06 14:25:59.12999','"10.10.100.1"','"fdf5:ba80:6aa5:89f1:44d7:8701:8699:4940"', + false,true,true,'cluster-2.netbird.services', + '0.76.1','linux','26.4.2','6.8.0-135-generic','[{"NetIP":"fe81::8b4c:973f:a76b:3771/64","Mac":"00:15:5d:24:0c:ad"},{"NetIP":"192.168.17.1/20","Mac":"00:15:5d:24:0c:ad"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]', + '[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',0, + 'DE','Berlin','"46.201.149.187"'); +insert into peers (id,account_id,"key", ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6, + peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster, + meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, + meta_capabilities, meta_flags, meta_sync_message_version, + location_country_code, location_city_name, location_connection_ip) + values('peer-id-3','account-1','key-3','ssh-key-3','peer-3','["extra-peer-3"]','user-id-3',true,true,'2026-08-06 12:25:59.12999','"10.10.200.1"','"fdf6:ba80:6aa5:89f1:44d7:8701:8699:4940"', + false,true,true,'cluster-3.netbird.services', + '0.76.2','linux','26.4.3','6.8.0-136-generic','[{"NetIP":"fe82::8b4c:973f:a76b:3771/64","Mac":"00:15:5d:24:0c:ae"},{"NetIP":"192.168.18.1/20","Mac":"00:15:5d:24:0c:ae"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]', + '[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',1, + 'DE','Berlin','"46.201.150.187"'); + diff --git a/integration_tests/management/network_map_db/dns_settings_test.go b/integration_tests/management/network_map_db/dns_settings_test.go new file mode 100644 index 000000000..95ac84aed --- /dev/null +++ b/integration_tests/management/network_map_db/dns_settings_test.go @@ -0,0 +1,25 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "testing" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/stretchr/testify/assert" +) + +func TestGetDnsSettings(t *testing.T) { + ctx := context.TODO() + + settings, err := conn(t, ctx).GetDnsSettings(ctx, "account-1") + assert.NoError(t, err) + assert.Equal(t, settings, nmdata.DNSSettings{ + DisabledManagementGroups: []string{"disabled-group-1", "disabled-group-2"}, + }) + + settings, err = conn(t, ctx).GetDnsSettings(ctx, "account-2") + assert.NoError(t, err) + assert.Equal(t, settings, nmdata.DNSSettings{}) +} diff --git a/integration_tests/management/network_map_db/dns_test.go b/integration_tests/management/network_map_db/dns_test.go new file mode 100644 index 000000000..33023061d --- /dev/null +++ b/integration_tests/management/network_map_db/dns_test.go @@ -0,0 +1,80 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "testing" + + "github.com/miekg/dns" + "github.com/netbirdio/netbird/shared/management/networkmap" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/stretchr/testify/assert" +) + +func TestGetAppliedZoneCandidatesViaPgxConnection(t *testing.T) { + ctx := context.TODO() + + execQuery(t, ctx, + `insert into zones (id, account_id, domain, enabled, enable_search_domain, distribution_groups) + VALUES('zone-1','account-1','test-1.com',true,true,'["group-one-resource-id"]')`) + execQuery(t, ctx, + `insert into zones (id, account_id, domain, enabled, enable_search_domain, distribution_groups) + VALUES('zone-2','account-1','test-2.com',true,false,'["group-two-resources-id"]')`) + execQuery(t, ctx, + `insert into zones (id, account_id, domain, enabled, enable_search_domain, distribution_groups) + VALUES('zone-3','account-1','test-3.com',false,true,'["group-one-resource-id"]')`) + execQuery(t, ctx, + `insert into records (id, account_id, zone_id, name, type, ttl, content) + VALUES('record-1','account-1','zone-1','test.test-1.com','A',1800,'1.1.1.1')`) + execQuery(t, ctx, + `insert into records (id, account_id, zone_id, name, type, ttl, content) + VALUES('record-2','account-1','zone-1','test2.test-1.com','A',1800,'1.1.1.2')`) + execQuery(t, ctx, + `insert into records (id, account_id, zone_id, name, type, ttl, content) + VALUES('record-3','account-1','zone-1','test3.test-1.com','CNAME',1800,'test4.test-1.com')`) + execQuery(t, ctx, + `insert into records (id, account_id, zone_id, name, type, ttl, content) + VALUES('record-4','account-1','zone-2','test2.test-2.com','CNAME',1800,'test3.test-2.com')`) + execQuery(t, ctx, + `insert into records (id, account_id, zone_id, name, type, ttl, content) + VALUES('record-5','account-1','zone-3','test.test-3.com','A',1800,'1.1.1.3')`) + + zoneCandidates, err := conn(t, ctx).GetAppliedZoneCandidates(ctx, "account-1") + assert.NoError(t, err) + + // Zone domains and record names are fully qualified, and the zone is served + // non-authoritatively — the account-side builder + // (types.buildAppliedZoneCandidates) states the same shape, and both feed the + // one client-facing map, so the two have to agree. + assert.Contains(t, zoneCandidates, networkmap.AppliedZoneCandidate{ + DistributionGroups: []string{"group-one-resource-id"}, + Zone: nmdata.CustomZone{ + Domain: "test-1.com.", + SearchDomainDisabled: false, + NonAuthoritative: true, + Records: []nmdata.SimpleRecord{ + {Name: "test.test-1.com.", Type: int(dns.TypeA), Class: "IN", TTL: 1800, RData: "1.1.1.1"}, + {Name: "test2.test-1.com.", Type: int(dns.TypeA), Class: "IN", TTL: 1800, RData: "1.1.1.2"}, + {Name: "test3.test-1.com.", Type: int(dns.TypeCNAME), Class: "IN", TTL: 1800, RData: "test4.test-1.com."}, + }, + }, + }) + assert.Contains(t, zoneCandidates, networkmap.AppliedZoneCandidate{ + DistributionGroups: []string{"group-two-resources-id"}, + Zone: nmdata.CustomZone{ + Domain: "test-2.com.", + SearchDomainDisabled: true, + NonAuthoritative: true, + Records: []nmdata.SimpleRecord{ + {Name: "test2.test-2.com.", Type: int(dns.TypeCNAME), Class: "IN", TTL: 1800, RData: "test3.test-2.com."}, + }, + }, + }) + + // A zone an admin switched off reaches no peer. + for _, candidate := range zoneCandidates { + assert.NotEqual(t, "test-3.com.", candidate.Zone.Domain, "disabled zone must not be a candidate") + assert.NotEqual(t, "test-3.com", candidate.Zone.Domain, "disabled zone must not be a candidate") + } +} diff --git a/integration_tests/management/network_map_db/domain_test.go b/integration_tests/management/network_map_db/domain_test.go new file mode 100644 index 000000000..8434a76c3 --- /dev/null +++ b/integration_tests/management/network_map_db/domain_test.go @@ -0,0 +1,39 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "database/sql" + "testing" + + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" + "github.com/stretchr/testify/assert" +) + +func TestGetDomains(t *testing.T) { + ctx := context.TODO() + + execQuery(t, ctx, + `insert into domains (id, account_id, domain, target_cluster) + VALUES('domain-1','account-1','test-1.com','target-1.cluster.local')`) + execQuery(t, ctx, + `insert into domains (id, account_id, domain, target_cluster) + VALUES('domain-2','account-1','test-2.com','target-2.cluster.local')`) + execQuery(t, ctx, + `insert into domains (id, account_id, domain, target_cluster) + VALUES('domain-3','account-1',null,null)`) + + domains, err := conn(t, ctx).GetDomains(ctx, "account-1") + assert.NoError(t, err) + assert.Len(t, domains, 2) + + assert.Contains(t, domains, networkmapdb.Domain{ + Domain: sql.NullString{String: "test-1.com", Valid: true}, + TargetCluster: sql.NullString{String: "target-1.cluster.local", Valid: true}, + }) + assert.Contains(t, domains, networkmapdb.Domain{ + Domain: sql.NullString{String: "test-2.com", Valid: true}, + TargetCluster: sql.NullString{String: "target-2.cluster.local", Valid: true}, + }) +} diff --git a/integration_tests/management/network_map_db/group_test.go b/integration_tests/management/network_map_db/group_test.go new file mode 100644 index 000000000..3ccf96eb0 --- /dev/null +++ b/integration_tests/management/network_map_db/group_test.go @@ -0,0 +1,54 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "testing" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetGroups(t *testing.T) { + ctx := context.TODO() + + groups, resourceToGroupIdx, err := conn(t, ctx).GetGroups(ctx, "account-1") + assert.NoError(t, err) + assert.Contains(t, + groups, + nmdata.Group{ID: "group-one-resource-id", Name: "group-1-name", PublicID: "group-one-resource-id-public", Resources: []nmdata.Resource{{ID: "host-id-1", Type: "host"}}, Peers: []string{"peer-id-1"}}, + ) + assert.NotNil(t, resourceToGroupIdx["host-id-1"]["group-one-resource-id"]) + assert.Contains(t, + groups, + nmdata.Group{ID: "group-two-resources-id", Name: "group-2-name", PublicID: "group-two-resources-id-public", + Resources: []nmdata.Resource{{ID: "subnet-id-1", Type: "subnet"}, {ID: "host-id-2", Type: "host"}}, + Peers: []string{"peer-id-2", "peer-id-3"}}, + ) + assert.NotNil(t, resourceToGroupIdx["host-id-2"]["group-two-resources-id"]) + assert.NotNil(t, resourceToGroupIdx["subnet-id-1"]["group-two-resources-id"]) + assert.Contains(t, + groups, + nmdata.Group{ID: "group-no-resources-id", Name: "group-3-name", PublicID: "group-no-resources-id-public"}) +} + +// Verify handling of empty fields in groups table +// Verify that group's PublicID gets populated on retrieval +// TODO (dmitri) PublicID should not be populated with delta updates, +// which require stable PublicIDs +func TestGetGroupsWithoutExpectedFields(t *testing.T) { + ctx := context.TODO() + + execQuery(t, ctx, + "insert into accounts (id) VALUES('random-id')") + + execQuery(t, ctx, + "insert into groups (id, account_id) VALUES('g2-test-group-id-1','random-id')") + + groups, _, err := conn(t, ctx).GetGroups(ctx, "random-id") + assert.NoError(t, err) + require.Len(t, groups, 1) + assert.NotEmpty(t, groups[0].PublicID) +} diff --git a/integration_tests/management/network_map_db/main_test.go b/integration_tests/management/network_map_db/main_test.go new file mode 100644 index 000000000..78c8c8ec8 --- /dev/null +++ b/integration_tests/management/network_map_db/main_test.go @@ -0,0 +1,99 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + _ "embed" + "os" + "testing" + "time" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" + networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql" + networkmap_sqlite "github.com/netbirdio/netbird/management/internals/network_map_db/sqlite" + "github.com/netbirdio/netbird/management/server/types" +) + +//go:embed base_data.sql +var baseData string + +var ( + pgstore *networkmap_pgsql.PgStore + sqlitestore *networkmap_sqlite.SqliteStore + engine string +) + +func TestMain(m *testing.M) { + var cleanup func() + kind, _ := os.LookupEnv("NETBIRD_STORE_ENGINE") + switch kind { + case string(types.PostgresStoreEngine): + engine = string(types.PostgresStoreEngine) + pgstore, cleanup = createPGTestStore(baseData) + pgstore.UsingTimeZone(time.UTC) + case "", string(types.SqliteStoreEngine): + engine = string(types.SqliteStoreEngine) + sqlitestore, cleanup = createSqliteTestStore(baseData) + default: + log.Fatalf("unsupported db '%s' in NETBIRD_STORE_ENGINE env var", kind) + } + + code := m.Run() + + cleanup() + os.Exit(code) +} + +func conn(t *testing.T, ctx context.Context) networkmapdb.NetworkMapDBStoreConn { + t.Helper() + switch engine { + case string(types.PostgresStoreEngine): + c, err := pgstore.Pool.Acquire(ctx) + assert.NoError(t, err) + return pgstore.UsingConnection(c.Conn()) + case string(types.SqliteStoreEngine): + return sqlitestore.UsingConn() + } + log.Fatalf("unknown db engine kind %s", engine) + 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 { + case string(types.PostgresStoreEngine): + _, err := pgstore.Pool.Exec(ctx, q) + assert.NoError(t, err) + case string(types.SqliteStoreEngine): + _, err := sqlitestore.Db.ExecContext(ctx, q) + assert.NoError(t, err) + } +} + +// use to parse time in time.RFC3339Nano format +// 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) + } + + utc := tt.UTC() + return &utc +} diff --git a/integration_tests/management/network_map_db/nameserver_test.go b/integration_tests/management/network_map_db/nameserver_test.go new file mode 100644 index 000000000..d6243a6e3 --- /dev/null +++ b/integration_tests/management/network_map_db/nameserver_test.go @@ -0,0 +1,61 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "net/netip" + "testing" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/stretchr/testify/assert" +) + +func TestGetNameServerGroups(t *testing.T) { + ctx := context.TODO() + + execQuery(t, ctx, + `insert into name_server_groups (id, public_id, name, description, name_servers, groups, domains, enabled, search_domains_enabled, "primary", account_id) + VALUES('nsgroup-1','nsgroup-1-public','nsgroup-1','nsgroup-1','[{"IP":"192.168.31.2","NSType":1,"Port":53}]','["group-one-resource-id"]','["test-1.com"]',TRUE,FALSE,TRUE,'account-1')`) + execQuery(t, ctx, + `insert into name_server_groups (id, public_id, name, description, name_servers, groups, domains, enabled, search_domains_enabled,"primary",account_id) + VALUES('nsgroup-2','nsgroup-2-public','nsgroup-2','nsgroup-2','[{"IP":"192.168.32.3","NSType":1,"Port":53}]','["group-one-resource-id","group-no-resources-id"]','["test-1.com","test-2.com"]',TRUE,FALSE,TRUE,'account-1')`) + execQuery(t, ctx, + `insert into name_server_groups (id, public_id, name, description, name_servers, groups, domains, enabled, search_domains_enabled,"primary",account_id) + VALUES('nsgroup-3','nsgroup-3-public',null,null,null,null,null,TRUE,FALSE,FALSE,'account-1')`) + + nsgroups, err := conn(t, ctx).GetNameServerGroups(ctx, "account-1") + assert.NoError(t, err) + + assert.Contains(t, nsgroups, nmdata.NameServerGroup{ + ID: "nsgroup-1", + PublicID: "nsgroup-1-public", + Name: "nsgroup-1", + Description: "nsgroup-1", + NameServers: []nmdata.NameServer{{IP: netip.MustParseAddr("192.168.31.2"), NSType: 1, Port: 53}}, + Groups: []string{"group-one-resource-id"}, + Domains: []string{"test-1.com"}, + Primary: true, + SearchDomainsEnabled: false, + Enabled: true, + }) + assert.Contains(t, nsgroups, nmdata.NameServerGroup{ + ID: "nsgroup-2", + PublicID: "nsgroup-2-public", + Name: "nsgroup-2", + Description: "nsgroup-2", + NameServers: []nmdata.NameServer{{IP: netip.MustParseAddr("192.168.32.3"), NSType: 1, Port: 53}}, + Groups: []string{"group-one-resource-id", "group-no-resources-id"}, + Domains: []string{"test-1.com", "test-2.com"}, + Primary: true, + SearchDomainsEnabled: false, + Enabled: true, + }) + assert.Contains(t, nsgroups, nmdata.NameServerGroup{ + ID: "nsgroup-3", + PublicID: "nsgroup-3-public", + Primary: false, + SearchDomainsEnabled: false, + Enabled: true, + }) +} diff --git a/integration_tests/management/network_map_db/network_map_data.sql b/integration_tests/management/network_map_db/network_map_data.sql new file mode 100644 index 000000000..d94e2f4aa --- /dev/null +++ b/integration_tests/management/network_map_db/network_map_data.sql @@ -0,0 +1,108 @@ +insert into accounts (id, network_identifier, network_net, network_net_v6, network_dns, network_serial,dns_settings_disabled_management_groups, + settings_peer_login_expiration_enabled, settings_peer_login_expiration, settings_peer_inactivity_expiration_enabled, + settings_peer_inactivity_expiration, settings_dns_domain, settings_ipv6_enabled_groups, settings_routing_peer_dns_resolution_enabled, + settings_lazy_connection_enabled, settings_auto_update_version, settings_auto_update_always, settings_metrics_push_enabled) +VALUES('account-33','network-331','{"IP":"100.103.0.0","Mask":"//8AAA=="}','{"IP":"fdde:e995:fd38:a465::","Mask":"//////////8AAAAAAAAAAA=="}','',1,'["disabled-group-1","disabled-group-2"]', + true, 86400000000000, false, + 86400000000000, null, '["33-group-one-resource-id"]', false, + false, 'disabled', false, false); +insert into groups (id, account_id, name, resources, public_id) VALUES('33-group-one-resource-id','account-33','group-1-name', '[{"ID":"host-id-1","Type":"host"}]','group-one-resource-id-public'); +insert into groups (id, account_id, name, resources, public_id) VALUES('33-group-two-resources-id','account-33','group-2-name', '[{"ID":"subnet-id-1","Type":"subnet"}, {"ID":"host-id-2","Type":"host"}]','33-group-two-resources-id-public'); +insert into groups (id, account_id, name, resources, public_id) VALUES('33-group-no-resources-id','account-33','group-3-name', null,'33-group-no-resources-id-public'); +insert into group_peers (account_id, peer_id, group_id) VALUES('account-33','peer-id-331','33-group-one-resource-id'); +insert into group_peers (account_id, peer_id, group_id) VALUES('account-33','peer-id-332','33-group-two-resources-id'); +insert into group_peers (account_id, peer_id, group_id) VALUES('account-33','peer-id-333','33-group-two-resources-id'); +insert into peers (id, account_id, "key", ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6, + peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster, + meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, + meta_capabilities, meta_flags, meta_sync_message_version, + location_country_code, location_city_name, location_connection_ip) + values('peer-id-331','account-33','key-331','ssh-key-1','peer-1','["extra-peer-1"]','user-id-1',true,true,'2026-08-06 13:25:59.12999','"10.10.10.1"','"fdf4:ba80:6aa5:89f1:44d7:8701:8699:4940"', + false,true,true,'cluster-1.netbird.services', + '0.76.0','linux','26.4.1','6.8.0-134-generic','[{"NetIP":"fe80::8b4c:973f:a76b:3771/64","Mac":"00:15:5d:24:0c:ac"},{"NetIP":"192.168.16.1/20","Mac":"00:15:5d:24:0c:ac"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]', + '[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',1, + 'DE','Berlin','"46.201.148.187"'); +insert into peers (id,account_id,"key", ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6, + peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster, + meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, + meta_capabilities, meta_flags, meta_sync_message_version, + location_country_code, location_city_name, location_connection_ip) + values('peer-id-332','account-33','key-332','ssh-key-2','peer-2','["extra-peer-2"]','user-id-2',true,true,'2026-08-06 14:25:59.12999','"10.10.100.1"','"fdf5:ba80:6aa5:89f1:44d7:8701:8699:4940"', + false,true,true,'cluster-2.netbird.services', + '0.76.1','linux','26.4.2','6.8.0-135-generic','[{"NetIP":"fe81::8b4c:973f:a76b:3771/64","Mac":"00:15:5d:24:0c:ad"},{"NetIP":"192.168.17.1/20","Mac":"00:15:5d:24:0c:ad"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]', + '[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',0, + 'DE','Berlin','"46.201.149.187"'); +insert into peers (id,account_id,"key", ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6, + peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster, + meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, + meta_capabilities, meta_flags, meta_sync_message_version, + location_country_code, location_city_name, location_connection_ip) + values('peer-id-333','account-33','key-333','ssh-key-3','peer-3','["extra-peer-3"]','user-id-3',true,true,'2026-08-06 12:25:59.12999','"10.10.200.1"','"fdf6:ba80:6aa5:89f1:44d7:8701:8699:4940"', + false,true,true,'cluster-3.netbird.services', + '0.76.2','linux','26.4.3','6.8.0-136-generic','[{"NetIP":"fe82::8b4c:973f:a76b:3771/64","Mac":"00:15:5d:24:0c:ae"},{"NetIP":"192.168.18.1/20","Mac":"00:15:5d:24:0c:ae"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]', + '[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',1, + 'DE','Berlin','"46.201.150.187"'); + +insert into zones (id, account_id, domain, enabled, enable_search_domain, distribution_groups) + VALUES('zone-331','account-33','test-331.com',true,true,'["33-group-one-resource-id"]'); +insert into zones (id, account_id, domain, enabled, enable_search_domain, distribution_groups) + VALUES('zone-332','account-33','disabled-331.com',false,true,'["33-group-one-resource-id"]'); +insert into zones (id, account_id, domain, enabled, enable_search_domain, distribution_groups) + VALUES('zone-333','account-33','search-off-331.com',true,false,'["33-group-two-resources-id"]'); +insert into records (id, account_id, zone_id, name, type, ttl, content) + VALUES('record-333','account-33','zone-332','test.disabled-331.com','A',1800,'1.1.1.9'); +insert into records (id, account_id, zone_id, name, type, ttl, content) + VALUES('record-334','account-33','zone-333','test.search-off-331.com','A',1800,'1.1.1.3'); +insert into records (id, account_id, zone_id, name, type, ttl, content) + VALUES('record-335','account-33','zone-333','alias.search-off-331.com','CNAME',1800,'test.search-off-331.com'); +insert into records (id, account_id, zone_id, name, type, ttl, content) + VALUES('record-331','account-33','zone-331','test.test-331.com','A',1800,'1.1.1.1'); +insert into records (id, account_id, zone_id, name, type, ttl, content) + VALUES('record-332','account-33','zone-331','test2.test-331.com','A',1800,'1.1.1.2'); + +insert into domains (id, account_id, domain, target_cluster) + VALUES('domain-331','account-33','test-331.com','target-1.cluster.local'); + +insert into name_server_groups (id, public_id, name, description, name_servers, groups, domains, enabled, search_domains_enabled, "primary", account_id) + VALUES('nsgroup-331','nsgroup-1-public','nsgroup-1','nsgroup-1','[{"IP":"192.168.31.2","NSType":1,"Port":53}]','["33-group-one-resource-id"]','["test-1.com"]',TRUE,FALSE,TRUE,'account-33'); +insert into name_server_groups (id, public_id, name, description, name_servers, groups, domains, enabled, search_domains_enabled,"primary",account_id) + VALUES('nsgroup-332','nsgroup-2-public','nsgroup-2','nsgroup-2','[{"IP":"192.168.32.3","NSType":1,"Port":53}]','["33-group-one-resource-id","33-group-no-resources-id"]','["test-1.com","test-2.com"]',TRUE,FALSE,TRUE,'account-33'); + +insert into network_resources (id, account_id, network_id, public_id, name, description, type, domain, prefix, enabled) + VALUES('net-resource-331','account-33','network-331','net-resource-public-1','network-resource-1','network-resource-1','subnet','','"10.0.0.0/16"',TRUE); +insert into network_resources (id, account_id, network_id, public_id, name, description, type, domain, prefix, enabled) + VALUES('net-resource-332','account-33','network-332','net-resource-public-2','network-resource-2','network-resource-2','domain','test.com','',TRUE); + +insert into network_routers (id, account_id, public_id, peer, network_id, masquerade, metric, enabled, peer_groups) + VALUES('test-nr-id-331','account-33','public-id-1','peer-id-331','network-id-1',TRUE,999,TRUE,'["33-group-one-resource-id"]'); +insert into network_routers (id, account_id, public_id, peer, network_id, masquerade, metric, enabled, peer_groups) + VALUES('test-nr-id-332','account-33','public-id-2','','network-id-2',TRUE,333,TRUE,'["33-group-two-resources-id","33-group-no-resources-id"]'); + +insert into networks (id, account_id, public_id) VALUES('network-331','account-33','network-1-public'); +insert into networks (id, account_id, public_id) VALUES('network-332','account-33','network-2-public'); + +insert into policies (id, public_id, account_id, enabled, source_posture_checks) + values('policy-331','policy-1-public','account-33',true,'["posture-checks-1","posture-checks-2"]'); +insert into policy_rules (id, policy_id, enabled, action, protocol, bidirectional, sources, destinations, + source_resource, destination_resource, ports, port_ranges, + authorized_groups, authorized_user) + values('policy-331-rule-1','policy-331',true,'accept','tcp',true,'["33-group-one-resource-id","33-group-two-resources-id"]','["33-group-one-resource-id","33-group-two-resources-id"]', + '{"ID":"host-id-1","Type":"host"}','{"ID":"domain-331","Type":"domain"}','["8080","8443"]', '[{"Start":8080,"End":8090}]', + '{"33-group-one-resource-id":["user-1", "user-2"]}','user-3'); + +insert into posture_checks (id, account_id, public_id, checks) + VALUES('posturecheck-331','account-33','posturecheck-1-public', + '{"NBVersionCheck":{"MinVersion":"0.25.0"}, + "OSVersionCheck":{"Darwin":{"MinVersion":"12.0"}}, + "GeoLocationCheck":{"Locations":[{"CountryCode":"FI","CityName":""}],"Action":"allow"}, + "PeerNetworkRangeCheck":{"Action":"deny","Ranges":["192.168.0.1/24"]}}'); + +insert into routes (id, account_id, public_id, network, domains, keep_route, net_id, description, + peer, peer_groups, network_type, masquerade, metric, enabled, + groups, access_control_groups, skip_auto_apply) + VALUES('route-331','account-33','route-1-public','"172.0.0.0/16"','["test-1.com"]',true,'route-331-net-id','route-1', + 'peer-id-331','["33-group-one-resource-id"]',1,true,9999,true, + '["33-group-one-resource-id"]','["33-group-one-resource-id"]',false); + +insert into services (id, account_id, enabled, private, access_groups, proxy_cluster, domain) + values('service-331','account-33',true,true,'["33-group-one-resource-id"]','test-1.com','test-332.com'); 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..bb0ccd30b --- /dev/null +++ b/integration_tests/management/network_map_db/network_map_data_golden.json @@ -0,0 +1,546 @@ +{ + "Peers": { + "peer-id-331": { + "ID": "peer-id-331", + "Key": "key-331", + "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, + "Cluster": "cluster-1.netbird.services" + }, + "Location": { + "CountryCode": "DE", + "CityName": "Berlin", + "ConnectionIP": "46.201.148.187" + } + }, + "peer-id-332": { + "ID": "peer-id-332", + "Key": "key-332", + "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, + "Cluster": "cluster-2.netbird.services" + }, + "Location": { + "CountryCode": "DE", + "CityName": "Berlin", + "ConnectionIP": "46.201.149.187" + } + }, + "peer-id-333": { + "ID": "peer-id-333", + "Key": "key-333", + "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, + "Cluster": "cluster-3.netbird.services" + }, + "Location": { + "CountryCode": "DE", + "CityName": "Berlin", + "ConnectionIP": "46.201.150.187" + } + } + }, + "Groups": { + "33-group-no-resources-id": { + "ID": "33-group-no-resources-id", + "Name": "group-3-name", + "PublicID": "33-group-no-resources-id-public", + "Peers": null, + "Resources": null + }, + "33-group-one-resource-id": { + "ID": "33-group-one-resource-id", + "Name": "group-1-name", + "PublicID": "group-one-resource-id-public", + "Peers": [ + "peer-id-331" + ], + "Resources": [ + { + "ID": "host-id-1", + "Type": "host" + } + ] + }, + "33-group-two-resources-id": { + "ID": "33-group-two-resources-id", + "Name": "group-2-name", + "PublicID": "33-group-two-resources-id-public", + "Peers": [ + "peer-id-332", + "peer-id-333" + ], + "Resources": [ + { + "ID": "subnet-id-1", + "Type": "subnet" + }, + { + "ID": "host-id-2", + "Type": "host" + } + ] + } + }, + "Policies": [ + { + "ID": "policy-331", + "PublicID": "policy-1-public", + "Enabled": true, + "SourcePostureChecks": [ + "posture-checks-1", + "posture-checks-2" + ], + "Rules": [ + { + "ID": "policy-331", + "PolicyID": "policy-331", + "Enabled": true, + "Action": "accept", + "Protocol": "tcp", + "Bidirectional": true, + "Sources": [ + "33-group-one-resource-id", + "33-group-two-resources-id" + ], + "Destinations": [ + "33-group-one-resource-id", + "33-group-two-resources-id" + ], + "SourceResource": { + "ID": "host-id-1", + "Type": "host" + }, + "DestinationResource": { + "ID": "domain-331", + "Type": "domain" + }, + "Ports": [ + "8080", + "8443" + ], + "PortRanges": [ + { + "Start": 8080, + "End": 8090 + } + ], + "AuthorizedGroups": { + "33-group-one-resource-id": [ + "user-1", + "user-2" + ] + }, + "AuthorizedUser": "user-3" + } + ] + } + ], + "Routes": [ + { + "ID": "route-331", + "AccountID": "account-33", + "PublicID": "route-1-public", + "Network": "172.0.0.0/16", + "Domains": [ + "test-1.com" + ], + "KeepRoute": true, + "NetID": "route-331-net-id", + "Description": "route-1", + "Peer": "peer-id-331", + "PeerID": "peer-id-331", + "PeerGroups": [ + "33-group-one-resource-id" + ], + "NetworkType": 1, + "Masquerade": true, + "Metric": 9999, + "Enabled": true, + "Groups": [ + "33-group-one-resource-id" + ], + "AccessControlGroups": [ + "33-group-one-resource-id" + ], + "SkipAutoApply": false + } + ], + "NameServerGroups": [ + { + "ID": "nsgroup-331", + "PublicID": "nsgroup-1-public", + "Name": "nsgroup-1", + "Description": "nsgroup-1", + "NameServers": [ + { + "IP": "192.168.31.2", + "NSType": 1, + "Port": 53 + } + ], + "Groups": [ + "33-group-one-resource-id" + ], + "Primary": true, + "Domains": [ + "test-1.com" + ], + "Enabled": true, + "SearchDomainsEnabled": false + }, + { + "ID": "nsgroup-332", + "PublicID": "nsgroup-2-public", + "Name": "nsgroup-2", + "Description": "nsgroup-2", + "NameServers": [ + { + "IP": "192.168.32.3", + "NSType": 1, + "Port": 53 + } + ], + "Groups": [ + "33-group-one-resource-id", + "33-group-no-resources-id" + ], + "Primary": true, + "Domains": [ + "test-1.com", + "test-2.com" + ], + "Enabled": true, + "SearchDomainsEnabled": false + } + ], + "NetworkResources": [ + { + "ID": "net-resource-331", + "NetworkID": "network-331", + "AccountID": "account-33", + "PublicID": "net-resource-public-1", + "Name": "network-resource-1", + "Description": "network-resource-1", + "Type": "subnet", + "Address": "", + "Domain": "", + "Prefix": "10.0.0.0/16", + "Enabled": true + }, + { + "ID": "net-resource-332", + "NetworkID": "network-332", + "AccountID": "account-33", + "PublicID": "net-resource-public-2", + "Name": "network-resource-2", + "Description": "network-resource-2", + "Type": "domain", + "Address": "", + "Domain": "test.com", + "Prefix": "", + "Enabled": true + } + ], + "Network": { + "Identifier": "network-331", + "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": [ + "33-group-one-resource-id" + ], + "RoutingPeerDNSResolutionEnabled": false, + "LazyConnectionEnabled": false, + "AutoUpdateVersion": "disabled", + "AutoUpdateAlways": false, + "MetricsPushEnabled": false + }, + "PostureChecks": { + "posturecheck-331": { + "ID": "posturecheck-331", + "Checks": { + "NBVersionCheck": { + "MinVersion": "0.25.0" + }, + "OSVersionCheck": { + "Android": null, + "Darwin": { + "MinVersion": "12.0" + }, + "Ios": null, + "Linux": null, + "Windows": null + }, + "GeoLocationCheck": { + "Locations": [ + { + "CountryCode": "FI", + "CityName": "" + } + ], + "Action": "allow" + }, + "PeerNetworkRangeCheck": { + "Action": "deny", + "Ranges": [ + "192.168.0.1/24" + ] + }, + "ProcessCheck": null + } + } + }, + "PostureValidation": null, + "AllowedUserIDs": {}, + "NetworkXIDToPublicID": { + "network-331": "network-1-public", + "network-332": "network-2-public" + }, + "PostureCheckXIDToPublicID": { + "posturecheck-331": "posturecheck-1-public" + }, + "ValidatedPeers": { + "peer-id-1": {}, + "peer-id-2": {}, + "peer-id-3": {} + }, + "ResourcePolicies": {}, + "Routers": { + "network-id-1": { + "peer-id-331": { + "PublicID": "public-id-1", + "PeerGroups": [ + "33-group-one-resource-id" + ], + "Masquerade": true, + "Metric": 999, + "Enabled": true + } + }, + "network-id-2": { + "peer-id-332": { + "PublicID": "public-id-2", + "PeerGroups": [ + "33-group-two-resources-id", + "33-group-no-resources-id" + ], + "Masquerade": true, + "Metric": 333, + "Enabled": true + }, + "peer-id-333": { + "PublicID": "public-id-2", + "PeerGroups": [ + "33-group-two-resources-id", + "33-group-no-resources-id" + ], + "Masquerade": true, + "Metric": 333, + "Enabled": true + } + } + }, + "GroupIDToUserIDs": {}, + "DNSDomain": "", + "ProxyTargetedDomainResourceIDs": {}, + "AppliedZoneCandidates": [ + { + "DistributionGroups": [ + "33-group-one-resource-id" + ], + "Zone": { + "Domain": "test-331.com.", + "Records": [ + { + "Name": "test.test-331.com.", + "Type": 1, + "Class": "IN", + "TTL": 1800, + "RData": "1.1.1.1" + }, + { + "Name": "test2.test-331.com.", + "Type": 1, + "Class": "IN", + "TTL": 1800, + "RData": "1.1.1.2" + } + ], + "SearchDomainDisabled": false, + "NonAuthoritative": true + } + }, + { + "DistributionGroups": [ + "33-group-two-resources-id" + ], + "Zone": { + "Domain": "search-off-331.com.", + "Records": [ + { + "Name": "test.search-off-331.com.", + "Type": 1, + "Class": "IN", + "TTL": 1800, + "RData": "1.1.1.3" + }, + { + "Name": "alias.search-off-331.com.", + "Type": 5, + "Class": "IN", + "TTL": 1800, + "RData": "test.search-off-331.com." + } + ], + "SearchDomainDisabled": true, + "NonAuthoritative": true + } + } + ], + "PrivateServiceCandidates": null, + "Services": 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..00c0ec03f --- /dev/null +++ b/integration_tests/management/network_map_db/network_map_data_test.go @@ -0,0 +1,74 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + _ "embed" + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" + "github.com/netbirdio/netbird/management/server/integrations/integrated_validator" + "github.com/netbirdio/netbird/management/server/settings" + "github.com/netbirdio/netbird/management/server/types" + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +//go:embed network_map_data.sql +var nmapData string + +//go:embed network_map_data_golden.json +var goldenNMap string + +const EnvUpdateGoldenData = "NMAP_UPDATE_GOLDEN_DATA" + +func TestGetNetworkMapData(t *testing.T) { + ctx := context.TODO() + + // The two mocks are generated by different mock frameworks, so each needs a + // controller of its own kind. + extraSettingsManager := settings.NewMockManager(gomock.NewController(t)) + extraSettingsManager.EXPECT().GetExtraSettings(gomock.Any(), gomock.Any()).Return(&types.ExtraSettings{}, nil) + + peerValidators := integrated_validator.NewMockIntegratedValidator(gomock.NewController(t)) + peerValidators.EXPECT().GetValidatedPeers(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return( + map[string]struct{}{ + "peer-id-1": {}, + "peer-id-2": {}, + "peer-id-3": {}, + }, nil) + + storeImpl := networkmapdb.NetworkMapDBStoreImpl{ + Store: store(t), + ExtraSettingsManager: extraSettingsManager, + IntegratedPeerValidator: peerValidators, + } + + for _, query := range strings.Split(nmapData, ";") { + if err := store(t).Exec(ctx, query); err != nil { + log.Fatalf("error initializing nmap test: %s", err.Error()) + } + } + + nmap, err := storeImpl.GetNetworkMapData(ctx, "account-33") + 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)) +} diff --git a/integration_tests/management/network_map_db/network_resource_test.go b/integration_tests/management/network_map_db/network_resource_test.go new file mode 100644 index 000000000..4325ed3ba --- /dev/null +++ b/integration_tests/management/network_map_db/network_resource_test.go @@ -0,0 +1,65 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "net/netip" + "testing" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/stretchr/testify/assert" +) + +func TestGetNetworkResources(t *testing.T) { + ctx := context.TODO() + + execQuery(t, ctx, + `insert into network_resources (id, account_id, network_id, public_id, name, description, type, domain, prefix, enabled) + VALUES('net-resource-1','account-1','network-1','net-resource-public-1','network-resource-1','network-resource-1','subnet','','"10.0.0.0/16"',TRUE)`) + execQuery(t, ctx, + `insert into network_resources (id, account_id, network_id, public_id, name, description, type, domain, prefix, enabled) + VALUES('net-resource-2','account-1','network-2','net-resource-public-2','network-resource-2','network-resource-2','domain','test.com','',TRUE)`) + execQuery(t, ctx, + `insert into network_resources (id, account_id, network_id, public_id, name, description, type, domain, prefix, enabled) + VALUES('net-resource-3','account-1','network-3','net-resource-public-3','network-resource-3','network-resource-3','host','','"10.0.0.1/32"',TRUE)`) + + resources, err := conn(t, ctx).GetNetworkResources(ctx, "account-1") + assert.NoError(t, err) + + assert.Contains(t, resources, nmdata.NetworkResource{ + ID: "net-resource-1", + AccountID: "account-1", + NetworkID: "network-1", + PublicID: "net-resource-public-1", + Name: "network-resource-1", + Description: "network-resource-1", + Type: "subnet", + Domain: "", + Prefix: netip.MustParsePrefix("10.0.0.0/16"), + Enabled: true, + }) + assert.Contains(t, resources, nmdata.NetworkResource{ + ID: "net-resource-2", + AccountID: "account-1", + NetworkID: "network-2", + PublicID: "net-resource-public-2", + Name: "network-resource-2", + Description: "network-resource-2", + Type: "domain", + Domain: "test.com", + Enabled: true, + }) + assert.Contains(t, resources, nmdata.NetworkResource{ + ID: "net-resource-3", + AccountID: "account-1", + NetworkID: "network-3", + PublicID: "net-resource-public-3", + Name: "network-resource-3", + Description: "network-resource-3", + Type: "host", + Domain: "", + Prefix: netip.MustParsePrefix("10.0.0.1/32"), + Enabled: true, + }) +} diff --git a/integration_tests/management/network_map_db/network_router_test.go b/integration_tests/management/network_map_db/network_router_test.go new file mode 100644 index 000000000..fa7ea2a04 --- /dev/null +++ b/integration_tests/management/network_map_db/network_router_test.go @@ -0,0 +1,33 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "testing" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/stretchr/testify/assert" +) + +func TestGetNetworkRouters(t *testing.T) { + ctx := context.TODO() + + execQuery(t, ctx, + `insert into network_routers (id, account_id, public_id, peer, network_id, masquerade, metric, enabled, peer_groups) + VALUES('test-nr-id-1','account-1','public-id-1','peer-id-1','network-id-1',TRUE,999,TRUE,'["group-one-resource-id"]')`) + execQuery(t, ctx, + `insert into network_routers (id, account_id, public_id, peer, network_id, masquerade, metric, enabled, peer_groups) + VALUES('test-nr-id-2','account-1','public-id-2','','network-id-2',TRUE,333,TRUE,'["group-two-resources-id","group-no-resources-id"]')`) + + routers, err := conn(t, ctx).GetNetworkRouters(ctx, "account-1") + assert.NoError(t, err) + assert.NotEmpty(t, routers) + + assert.Equal(t, routers["network-id-1"], + map[string]*nmdata.NetworkRouter{"peer-id-1": {PublicID: "public-id-1", Masquerade: true, Metric: 999, Enabled: true, PeerGroups: []string{"group-one-resource-id"}}}) + assert.Equal(t, routers["network-id-2"], + map[string]*nmdata.NetworkRouter{ + "peer-id-2": {PublicID: "public-id-2", Masquerade: true, Metric: 333, Enabled: true, PeerGroups: []string{"group-two-resources-id", "group-no-resources-id"}}, + "peer-id-3": {PublicID: "public-id-2", Masquerade: true, Metric: 333, Enabled: true, PeerGroups: []string{"group-two-resources-id", "group-no-resources-id"}}}) +} diff --git a/integration_tests/management/network_map_db/network_test.go b/integration_tests/management/network_map_db/network_test.go new file mode 100644 index 000000000..fbccee504 --- /dev/null +++ b/integration_tests/management/network_map_db/network_test.go @@ -0,0 +1,56 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "encoding/json" + "net" + "testing" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/stretchr/testify/assert" +) + +func TestGetNetwork(t *testing.T) { + ctx := context.TODO() + + network, err := conn(t, ctx).GetNetwork(ctx, "account-1") + assert.NoError(t, err) + assert.Equal(t, network, nmdata.Network{ + Identifier: "network-1", + Net: mustParseCIDR("100.103.0.0/16"), + NetV6: mustParseCIDR("fdde:e995:fd38:a465::/64"), + Serial: 1, + }) + + network, err = conn(t, ctx).GetNetwork(ctx, "account-2") + assert.NoError(t, err) + assert.Equal(t, network, nmdata.Network{ + Identifier: "network-2", + Net: mustParseCIDR("110.0.0.0/16"), + NetV6: mustParseCIDR("fddf:e995:fd38:a465::/64"), + Serial: 2, + }) +} + +func mustParseCIDR(s string) net.IPNet { + var toret net.IPNet + + _, net, err := net.ParseCIDR(s) + if err != nil { + panic(err) + } + + jn, err := json.Marshal(net) + if err != nil { + panic(err) + } + + err = json.Unmarshal(jn, &toret) + if err != nil { + panic(err) + } + + return toret +} diff --git a/integration_tests/management/network_map_db/networks_test.go b/integration_tests/management/network_map_db/networks_test.go new file mode 100644 index 000000000..5af771522 --- /dev/null +++ b/integration_tests/management/network_map_db/networks_test.go @@ -0,0 +1,26 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetNetworks(t *testing.T) { + ctx := context.TODO() + + execQuery(t, ctx, + `insert into networks (id, account_id, public_id) VALUES('network-1','account-1','network-1-public')`) + execQuery(t, ctx, + `insert into networks (id, account_id, public_id) VALUES('network-2','account-1','network-2-public')`) + + networksIdx, err := conn(t, ctx).GetNetworkXIDToPublicIdMap(ctx, "account-1") + assert.NoError(t, err) + assert.Equal(t, networksIdx, map[string]string{ + "network-1": "network-1-public", + "network-2": "network-2-public", + }) +} diff --git a/integration_tests/management/network_map_db/peer_test.go b/integration_tests/management/network_map_db/peer_test.go new file mode 100644 index 000000000..e33c3ea3a --- /dev/null +++ b/integration_tests/management/network_map_db/peer_test.go @@ -0,0 +1,166 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "net" + "net/netip" + "testing" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/stretchr/testify/assert" +) + +func TestGetPeers(t *testing.T) { + ctx := context.TODO() + + peers, clusterToPeersIdx, err := conn(t, ctx).GetPeers(ctx, "account-1") + assert.NoError(t, err) + + // shouldn't be returned in the index, as it's not connected + execQuery(t, ctx, + `insert into peers (id,account_id,"key",ssh_key,proxy_meta_embedded,peer_status_connected) + values('peer-4','account-1','key-4','ssh-key-4',true,false)`) + // shouldn't be returned in the index as it doesn't have cluster set + execQuery(t, ctx, + `insert into peers (id,account_id,"key",ssh_key,proxy_meta_embedded,peer_status_connected) + values('peer-5','account-1','key-5','ssh-key-5',false,true)`) + + peer1 := nmdata.Peer{ + ID: "peer-id-1", + Key: "key-1", + SSHKey: "ssh-key-1", + DNSLabel: "peer-1", + ExtraDNSLabels: []string{"extra-peer-1"}, + UserID: "user-id-1", + SSHEnabled: true, + LoginExpirationEnabled: true, + LastLogin: mustParseTime("2026-08-06T13:25:59.12999+00:00"), + IP: netip.MustParseAddr("10.10.10.1"), + IPv6: netip.MustParseAddr("fdf4:ba80:6aa5:89f1:44d7:8701:8699:4940"), + RequiresApproval: false, + Meta: nmdata.PeerSystemMeta{ + WtVersion: "0.76.0", + GoOS: "linux", + OSVersion: "26.4.1", + KernelVersion: "6.8.0-134-generic", + NetworkAddresses: []nmdata.NetworkAddress{ + {NetIP: netip.MustParsePrefix("fe80::8b4c:973f:a76b:3771/64")}, + {NetIP: netip.MustParsePrefix("192.168.16.1/20")}, + }, + Files: []nmdata.File{ + {Path: "/usr/bin/netbird", ProcessIsRunning: false}, + }, + Capabilities: []int32{1, 2}, + Flags: nmdata.Flags{ + ServerSSHAllowed: true, + DisableIPv6: false, + }, + SyncMessageVersion: 1, + }, + ProxyMeta: nmdata.ProxyMeta{ + Embedded: true, + Cluster: "cluster-1.netbird.services", + }, + Location: nmdata.PeerLocation{ + CountryCode: "DE", + CityName: "Berlin", + ConnectionIP: net.ParseIP("46.201.148.187"), + }, + } + peer2 := nmdata.Peer{ + ID: "peer-id-2", + Key: "key-2", + SSHKey: "ssh-key-2", + DNSLabel: "peer-2", + ExtraDNSLabels: []string{"extra-peer-2"}, + UserID: "user-id-2", + SSHEnabled: true, + LoginExpirationEnabled: true, + LastLogin: mustParseTime("2026-08-06T14:25:59.12999+00:00"), + IP: netip.MustParseAddr("10.10.100.1"), + IPv6: netip.MustParseAddr("fdf5:ba80:6aa5:89f1:44d7:8701:8699:4940"), + RequiresApproval: false, + Meta: nmdata.PeerSystemMeta{ + WtVersion: "0.76.1", + GoOS: "linux", + OSVersion: "26.4.2", + KernelVersion: "6.8.0-135-generic", + NetworkAddresses: []nmdata.NetworkAddress{ + {NetIP: netip.MustParsePrefix("fe81::8b4c:973f:a76b:3771/64")}, + {NetIP: netip.MustParsePrefix("192.168.17.1/20")}, + }, + Files: []nmdata.File{ + {Path: "/usr/bin/netbird", ProcessIsRunning: false}, + }, + Capabilities: []int32{1, 2}, + Flags: nmdata.Flags{ + ServerSSHAllowed: true, + DisableIPv6: false, + }, + SyncMessageVersion: 0, + }, + ProxyMeta: nmdata.ProxyMeta{ + Embedded: true, + Cluster: "cluster-2.netbird.services", + }, + Location: nmdata.PeerLocation{ + CountryCode: "DE", + CityName: "Berlin", + ConnectionIP: net.ParseIP("46.201.149.187"), + }, + } + peer3 := nmdata.Peer{ + ID: "peer-id-3", + Key: "key-3", + SSHKey: "ssh-key-3", + DNSLabel: "peer-3", + ExtraDNSLabels: []string{"extra-peer-3"}, + UserID: "user-id-3", + SSHEnabled: true, + LoginExpirationEnabled: true, + LastLogin: mustParseTime("2026-08-06T12:25:59.12999+00:00"), + IP: netip.MustParseAddr("10.10.200.1"), + IPv6: netip.MustParseAddr("fdf6:ba80:6aa5:89f1:44d7:8701:8699:4940"), + RequiresApproval: false, + Meta: nmdata.PeerSystemMeta{ + WtVersion: "0.76.2", + GoOS: "linux", + OSVersion: "26.4.3", + KernelVersion: "6.8.0-136-generic", + NetworkAddresses: []nmdata.NetworkAddress{ + {NetIP: netip.MustParsePrefix("fe82::8b4c:973f:a76b:3771/64")}, + {NetIP: netip.MustParsePrefix("192.168.18.1/20")}, + }, + Files: []nmdata.File{ + {Path: "/usr/bin/netbird", ProcessIsRunning: false}, + }, + Capabilities: []int32{1, 2}, + Flags: nmdata.Flags{ + ServerSSHAllowed: true, + DisableIPv6: false, + }, + SyncMessageVersion: 1, + }, + ProxyMeta: nmdata.ProxyMeta{ + Embedded: true, + Cluster: "cluster-3.netbird.services", + }, + Location: nmdata.PeerLocation{ + CountryCode: "DE", + CityName: "Berlin", + ConnectionIP: net.ParseIP("46.201.150.187"), + }, + } + + assert.Contains(t, peers, peer1) + assert.Contains(t, peers, peer2) + assert.Contains(t, peers, peer3) + + assert.Equal(t, clusterToPeersIdx, map[string][]*nmdata.Peer{ + "cluster-1.netbird.services": {&peer1}, + "cluster-2.netbird.services": {&peer2}, + "cluster-3.netbird.services": {&peer3}, + }) +} diff --git a/integration_tests/management/network_map_db/pg_test_store.go b/integration_tests/management/network_map_db/pg_test_store.go new file mode 100644 index 000000000..1710747b5 --- /dev/null +++ b/integration_tests/management/network_map_db/pg_test_store.go @@ -0,0 +1,121 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "fmt" + "regexp" + "strings" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/google/uuid" + networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql" + gormstore "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/testutil" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +func createPGTestStore(baseData string) (*networkmap_pgsql.PgStore, func()) { + _, tmpdsn, err := testutil.CreatePostgresTestContainer() + if err != nil { + log.Fatalf("error starting postres container %v", err) + } + + var db *gorm.DB + for i := range 5 { + db, err = gorm.Open(postgres.Open(tmpdsn), &gorm.Config{}) + + if err == nil { + break + } + + if i < 5 { + waitTime := time.Duration(100*(i+1)) * time.Millisecond + time.Sleep(waitTime) + continue + } + + log.Fatalf("error connecting to postres db %v", err) + } + + var cleanup func() + dsn, cleanup, err := createRandomDB(tmpdsn, db) + sqlDB, _ := db.DB() + if sqlDB != nil { + sqlDB.Close() + } + if err != nil { + log.Fatalf("error creating postres db %v", err) + } + + _, err = gormstore.NewPostgresqlStoreForTests(context.TODO(), dsn, nil, false) + if err != nil { + log.Fatalf("error running migrations %v", err) + } + + ctx := context.TODO() + pgstore, err := networkmap_pgsql.NewPostgresqlStore(ctx, dsn) + if err != nil { + log.Fatal("error creating postgres store %w", err) + } + + for _, query := range strings.Split(baseData, ";") { + if _, err := pgstore.Pool.Exec(ctx, query); err != nil { + log.Fatalf("error initializing db: %s", err.Error()) + } + } + + return pgstore, cleanup +} + +func createRandomDB(dsn string, db *gorm.DB) (string, func(), error) { + dbName := fmt.Sprintf("test_db_%s", strings.ReplaceAll(uuid.New().String(), "-", "_")) + + if err := db.Exec(fmt.Sprintf("CREATE DATABASE %s", dbName)).Error; err != nil { + return "", nil, fmt.Errorf("failed to create database: %v", err) + } + + originalDSN := dsn + + cleanup := func() { + var dropDB *gorm.DB + var err error + + dropDB, err = gorm.Open(postgres.Open(originalDSN), &gorm.Config{ + SkipDefaultTransaction: true, + PrepareStmt: false, + }) + if err != nil { + log.Errorf("failed to connect for dropping database %s: %v", dbName, err) + return + } + defer func() { + if sqlDB, _ := dropDB.DB(); sqlDB != nil { + sqlDB.Close() + } + }() + + if sqlDB, _ := dropDB.DB(); sqlDB != nil { + sqlDB.SetMaxOpenConns(1) + sqlDB.SetMaxIdleConns(0) + sqlDB.SetConnMaxLifetime(time.Second) + } + + err = dropDB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s WITH (FORCE)", dbName)).Error + + if err != nil { + log.Errorf("failed to drop database %s: %v", dbName, err) + } + } + + return replaceDBName(dsn, dbName), cleanup, nil +} + +func replaceDBName(dsn, newDBName string) string { + re := regexp.MustCompile(`(?P
[:/@])(?P[^/?]+)(?P \?|$)`) + return re.ReplaceAllString(dsn, `${pre}`+newDBName+`${post}`) +} diff --git a/integration_tests/management/network_map_db/policy_test.go b/integration_tests/management/network_map_db/policy_test.go new file mode 100644 index 000000000..1f4c543da --- /dev/null +++ b/integration_tests/management/network_map_db/policy_test.go @@ -0,0 +1,146 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "testing" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/stretchr/testify/assert" +) + +func TestGetPolicies(t *testing.T) { + ctx := context.TODO() + + execQuery(t, ctx, + `insert into policies (id, public_id, account_id, enabled, source_posture_checks) + values('policy-1','policy-1-public','account-1',true,'["posture-checks-1","posture-checks-2"]')`) + execQuery(t, ctx, + `insert into policy_rules (id, policy_id, enabled, action, protocol, bidirectional, sources, destinations, + source_resource, destination_resource, ports, port_ranges, + authorized_groups, authorized_user) + values('policy-1-rule-1','policy-1',true,'accept','tcp',true,'["group-one-resource-id","group-two-resources-id"]','["group-one-resource-id","group-two-resources-id"]', + '{"ID":"host-id-1","Type":"host"}','{"ID":"domain-1","Type":"domain"}','["8080","8443"]', '[{"Start":8080,"End":8090}]', + '{"group-one-resource-id":["user-1", "user-2"]}','user-3')`) + execQuery(t, ctx, + `insert into policies (id, public_id, account_id, enabled, source_posture_checks) + values('policy-2','policy-2-public','account-1',true,'["posture-checks-3","posture-checks-4"]')`) + execQuery(t, ctx, + `insert into policy_rules (id, policy_id, enabled, action, protocol, bidirectional, sources, destinations, + source_resource, destination_resource, ports, port_ranges, + authorized_groups, authorized_user) + values('policy-2-rule-1','policy-2',true,'accept','tcp',true,'["group-one-resource-id"]','["group-two-resources-id"]', + '{"ID":"host-id-3","Type":"host"}','{"ID":"domain-3","Type":"domain"}','["8080","8443"]', '[{"Start":8080,"End":8090}]', + '{"group-one-resource-id":["user-6", "user-7"]}','user-8')`) + // policy with a rule with null fields + execQuery(t, ctx, + `insert into policies (id, public_id, account_id, enabled, source_posture_checks) + values('policy-3','policy-3-public','account-1',true,null)`) + execQuery(t, ctx, + `insert into policy_rules (id, policy_id, enabled, action, protocol, bidirectional, sources, destinations, + source_resource, destination_resource, ports, port_ranges, + authorized_groups, authorized_user) + values('policy-3-rule-1','policy-3',true,null,null,null,null,null,null,null,null,null,null,null)`) + // policy with a disabled rule, destination resource and groups should not be in indexes + execQuery(t, ctx, + `insert into policies (id, public_id, account_id, enabled, source_posture_checks) + values('policy-4','policy-4-public','account-1',true,null)`) + execQuery(t, ctx, + `insert into policy_rules (id, policy_id, enabled, action, protocol, bidirectional, sources, destinations, + source_resource, destination_resource, ports, port_ranges, + authorized_groups, authorized_user) + values('policy-4-rule-1','policy-4',false,null,null,null,null,'["group-two-resources-id"]', + null,'{"ID":"domain-3","Type":"domain"}',null,null,null,null)`) + + policies, policyToDestinationResourceIdx, policyToDestinationGroupIdx, err := conn(t, ctx).GetPolicies(ctx, "account-1") + assert.NoError(t, err) + + assert.Contains(t, policies, nmdata.Policy{ + ID: "policy-1", + PublicID: "policy-1-public", + Enabled: true, + SourcePostureChecks: []string{"posture-checks-1", "posture-checks-2"}, + Rules: []*nmdata.PolicyRule{ + { + ID: "policy-1", + PolicyID: "policy-1", + Enabled: true, + Action: "accept", + Protocol: "tcp", + Bidirectional: true, + Sources: []string{"group-one-resource-id", "group-two-resources-id"}, + Destinations: []string{"group-one-resource-id", "group-two-resources-id"}, + SourceResource: nmdata.Resource{ID: "host-id-1", Type: "host"}, + DestinationResource: nmdata.Resource{ID: "domain-1", Type: "domain"}, + Ports: []string{"8080", "8443"}, + PortRanges: []nmdata.RulePortRange{{Start: 8080, End: 8090}}, + AuthorizedGroups: map[string][]string{"group-one-resource-id": {"user-1", "user-2"}}, + AuthorizedUser: "user-3", + }, + }, + }) + + assert.Contains(t, policies, nmdata.Policy{ + ID: "policy-2", + PublicID: "policy-2-public", + Enabled: true, + SourcePostureChecks: []string{"posture-checks-3", "posture-checks-4"}, + Rules: []*nmdata.PolicyRule{ + { + ID: "policy-2", + PolicyID: "policy-2", + Enabled: true, + Action: "accept", + Protocol: "tcp", + Bidirectional: true, + Sources: []string{"group-one-resource-id"}, + Destinations: []string{"group-two-resources-id"}, + SourceResource: nmdata.Resource{ID: "host-id-3", Type: "host"}, + DestinationResource: nmdata.Resource{ID: "domain-3", Type: "domain"}, + Ports: []string{"8080", "8443"}, + PortRanges: []nmdata.RulePortRange{{Start: 8080, End: 8090}}, + AuthorizedGroups: map[string][]string{"group-one-resource-id": {"user-6", "user-7"}}, + AuthorizedUser: "user-8", + }, + }, + }) + + assert.Contains(t, policies, nmdata.Policy{ + ID: "policy-3", + PublicID: "policy-3-public", + Enabled: true, + SourcePostureChecks: nil, + Rules: []*nmdata.PolicyRule{ + { + ID: "policy-3", + PolicyID: "policy-3", + Enabled: true, + }, + }, + }) + assert.Contains(t, policies, nmdata.Policy{ + ID: "policy-4", + PublicID: "policy-4-public", + Enabled: true, + SourcePostureChecks: nil, + Rules: []*nmdata.PolicyRule{ + { + ID: "policy-4", + PolicyID: "policy-4", + Enabled: false, + Destinations: []string{"group-two-resources-id"}, + DestinationResource: nmdata.Resource{ID: "domain-3", Type: "domain"}, + }, + }, + }) + + assert.Equal(t, policyToDestinationGroupIdx, map[string]map[string]any{ + "policy-1": {"group-one-resource-id": struct{}{}, "group-two-resources-id": struct{}{}}, + "policy-2": {"group-two-resources-id": struct{}{}}, + }) + assert.Equal(t, policyToDestinationResourceIdx, map[string]map[string]any{ + "policy-1": {"domain-1": struct{}{}}, + "policy-2": {"domain-3": struct{}{}}, + }) +} diff --git a/integration_tests/management/network_map_db/posture_test.go b/integration_tests/management/network_map_db/posture_test.go new file mode 100644 index 000000000..2b4bb3f3d --- /dev/null +++ b/integration_tests/management/network_map_db/posture_test.go @@ -0,0 +1,61 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "net/netip" + "testing" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/stretchr/testify/assert" +) + +func TestGetPostureChecks(t *testing.T) { + ctx := context.TODO() + + execQuery(t, ctx, + `insert into posture_checks (id, account_id, public_id, checks) + VALUES('posturecheck-1','account-1','posturecheck-1-public', + '{"NBVersionCheck":{"MinVersion":"0.25.0"}, + "OSVersionCheck":{"Darwin":{"MinVersion":"12.0"}}, + "GeoLocationCheck":{"Locations":[{"CountryCode":"FI","CityName":""}],"Action":"allow"}, + "PeerNetworkRangeCheck":{"Action":"deny","Ranges":["192.168.0.1/24"]}}')`) + + execQuery(t, ctx, + `insert into posture_checks (id, account_id, public_id, checks) + VALUES('posturecheck-2','account-1','posturecheck-2-public', + '{"NBVersionCheck":{"MinVersion":"0.25.0"}, + "OSVersionCheck":{"Android":{"MinVersion":"0"}}, + "GeoLocationCheck":{"Locations":[{"CountryCode":"US","CityName":"Harker Heights"}],"Action":"allow"}, + "PeerNetworkRangeCheck":{"Action":"allow","Ranges":["0.0.0.0/0"]}}')`) + execQuery(t, ctx, + `insert into posture_checks (id, account_id, public_id, checks) + VALUES('posturecheck-3','account-1','posturecheck-3-public', null)`) + + postureChecks, idToPublicIDIdx, err := conn(t, ctx).GetPostureChecks(ctx, "account-1") + assert.NoError(t, err) + assert.Equal(t, idToPublicIDIdx, map[string]string{ + "posturecheck-1": "posturecheck-1-public", + "posturecheck-2": "posturecheck-2-public", + "posturecheck-3": "posturecheck-3-public", + }) + assert.Contains(t, postureChecks, nmdata.PostureChecks{ + ID: "posturecheck-1", + Checks: nmdata.ChecksDefinition{ + NBVersionCheck: &nmdata.NBVersionCheck{MinVersion: "0.25.0"}, + OSVersionCheck: &nmdata.OSVersionCheck{Darwin: &nmdata.MinVersionCheck{MinVersion: "12.0"}}, + GeoLocationCheck: &nmdata.GeoLocationCheck{Locations: []nmdata.GeoLocation{{CountryCode: "FI"}}, Action: "allow"}, + PeerNetworkRangeCheck: &nmdata.PeerNetworkRangeCheck{Action: "deny", Ranges: []netip.Prefix{netip.MustParsePrefix("192.168.0.1/24")}}, + }}) + assert.Contains(t, postureChecks, nmdata.PostureChecks{ + ID: "posturecheck-2", + Checks: nmdata.ChecksDefinition{ + NBVersionCheck: &nmdata.NBVersionCheck{MinVersion: "0.25.0"}, + OSVersionCheck: &nmdata.OSVersionCheck{Android: &nmdata.MinVersionCheck{MinVersion: "0"}}, + GeoLocationCheck: &nmdata.GeoLocationCheck{Locations: []nmdata.GeoLocation{{CountryCode: "US", CityName: "Harker Heights"}}, Action: "allow"}, + PeerNetworkRangeCheck: &nmdata.PeerNetworkRangeCheck{Action: "allow", Ranges: []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")}}, + }}) + assert.Contains(t, postureChecks, nmdata.PostureChecks{ + ID: "posturecheck-3"}) +} diff --git a/integration_tests/management/network_map_db/route_test.go b/integration_tests/management/network_map_db/route_test.go new file mode 100644 index 000000000..12e9302f9 --- /dev/null +++ b/integration_tests/management/network_map_db/route_test.go @@ -0,0 +1,87 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "net/netip" + "testing" + + "github.com/netbirdio/netbird/shared/management/domain" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/stretchr/testify/assert" +) + +func TestGetRoutes(t *testing.T) { + ctx := context.TODO() + + execQuery(t, ctx, + `insert into routes (id, account_id, public_id, network, domains, keep_route, net_id, description, + peer, peer_groups, network_type, masquerade, metric, enabled, + groups, access_control_groups, skip_auto_apply) + VALUES('route-1','account-1','route-1-public','"172.0.0.0/16"','["test-1.com"]',true,'route-1-net-id','route-1', + 'peer-id-1','["group-one-resource-id"]',1,true,9999,true, + '["group-one-resource-id"]','["group-one-resource-id"]',false)`) + execQuery(t, ctx, + `insert into routes (id, account_id, public_id, network, domains, keep_route, net_id, description, + peer, peer_groups, network_type, masquerade, metric, enabled, + groups, access_control_groups, skip_auto_apply) + VALUES('route-2','account-1','route-2-public','"172.10.0.0/16"','["test-1.com","test-2.com"]',true,'route-2-net-id','route-2', + 'peer-id-2','["group-two-resources-id"]',1,true,9999,true, + '["group-two-resources-id"]','["group-two-resources-id"]',false)`) + execQuery(t, ctx, + `insert into routes (id, account_id, public_id, network, domains, keep_route, net_id, description, + peer, peer_groups, network_type, masquerade, metric, enabled, + groups, access_control_groups, skip_auto_apply) + VALUES('route-3','account-1','route-3-public',null,null,null,null,'route-3', + null,null,null,null,null,null,null,null,null)`) + + routes, err := conn(t, ctx).GetRoutes(ctx, "account-1") + assert.NoError(t, err) + assert.Contains(t, routes, nmdata.Route{ + ID: "route-1", + AccountID: "account-1", + PublicID: "route-1-public", + Network: netip.MustParsePrefix("172.0.0.0/16"), + Domains: domain.List{"test-1.com"}, + KeepRoute: true, + NetID: "route-1-net-id", + Description: "route-1", + Peer: "peer-id-1", + PeerID: "peer-id-1", + PeerGroups: []string{"group-one-resource-id"}, + NetworkType: 1, + Masquerade: true, + Metric: 9999, + Enabled: true, + Groups: []string{"group-one-resource-id"}, + AccessControlGroups: []string{"group-one-resource-id"}, + SkipAutoApply: false, + }) + assert.Contains(t, routes, nmdata.Route{ + ID: "route-2", + AccountID: "account-1", + PublicID: "route-2-public", + Network: netip.MustParsePrefix("172.10.0.0/16"), + Domains: domain.List{"test-1.com", "test-2.com"}, + KeepRoute: true, + NetID: "route-2-net-id", + Description: "route-2", + Peer: "peer-id-2", + PeerID: "peer-id-2", + PeerGroups: []string{"group-two-resources-id"}, + NetworkType: 1, + Masquerade: true, + Metric: 9999, + Enabled: true, + Groups: []string{"group-two-resources-id"}, + AccessControlGroups: []string{"group-two-resources-id"}, + SkipAutoApply: false, + }) + assert.Contains(t, routes, nmdata.Route{ + ID: "route-3", + AccountID: "account-1", + PublicID: "route-3-public", + Description: "route-3", + }) +} diff --git a/integration_tests/management/network_map_db/service_test.go b/integration_tests/management/network_map_db/service_test.go new file mode 100644 index 000000000..effc7a707 --- /dev/null +++ b/integration_tests/management/network_map_db/service_test.go @@ -0,0 +1,109 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "database/sql" + "testing" + + "github.com/stretchr/testify/assert" + + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" +) + +func TestGetPrivateServices(t *testing.T) { + ctx := context.TODO() + + execQuery(t, ctx, + `insert into services (id, account_id, enabled, private, access_groups, proxy_cluster, domain) + values('service-1','account-1',true,true,'["group-one-resource-id"]','test-1.com','test-2.com')`) + execQuery(t, ctx, + `insert into services (id, account_id, enabled, private, access_groups, proxy_cluster, domain) + values('service-2','account-1',true,true,'["group-one-resource-id","group-two-resources-id"]','test-3.com','test-4.com')`) + execQuery(t, ctx, + `insert into services (id, account_id, enabled, private, access_groups, proxy_cluster, domain) + values('service-3','account-1',null,null,null,null,null)`) + + services, err := conn(t, ctx).GetPrivateServices(ctx, "account-1") + assert.NoError(t, err) + assert.Contains(t, services, networkmapdb.Service{ + Enabled: sql.NullBool{Bool: true, Valid: true}, + Private: sql.NullBool{Bool: true, Valid: true}, + AccessGroups: []string{"group-one-resource-id"}, + ProxyCluster: sql.NullString{String: "test-1.com", Valid: true}, + Domain: sql.NullString{String: "test-2.com", Valid: true}, + }) + assert.Contains(t, services, networkmapdb.Service{ + Enabled: sql.NullBool{Bool: true, Valid: true}, + Private: sql.NullBool{Bool: true, Valid: true}, + AccessGroups: []string{"group-one-resource-id", "group-two-resources-id"}, + ProxyCluster: sql.NullString{String: "test-3.com", Valid: true}, + Domain: sql.NullString{String: "test-4.com", Valid: true}, + }) + assert.Contains(t, services, networkmapdb.Service{ + Enabled: sql.NullBool{Bool: false, Valid: false}, + Private: sql.NullBool{Bool: false, Valid: false}, + AccessGroups: []string{}, + ProxyCluster: sql.NullString{String: "", Valid: false}, + Domain: sql.NullString{String: "", Valid: false}, + }) +} + +func TestGetProxyTargetedDomainResourceIDs(t *testing.T) { + ctx := context.TODO() + + execQuery(t, ctx, + `insert into services (id, account_id, enabled, terminated) + values('service-4','account-1',true,false)`) + execQuery(t, ctx, + `insert into targets (target_id, account_id, service_id, enabled, target_type) + values('target-1','account-1','service-4',true,'domain')`) + // id shouldn't be returned as the taget_type is not "domain" + execQuery(t, ctx, + `insert into targets (target_id, account_id, service_id, enabled, target_type) + values('target-2','account-1','service-4',true,'cluster')`) + // id shouldn't be included as the target is disabled + execQuery(t, ctx, + `insert into targets (target_id, account_id, service_id, enabled, target_type) + values('target-3','account-1','service-4',false,'domain')`) + // id shouldn't be included as the service is disabled + execQuery(t, ctx, + `insert into services (id, account_id, enabled, terminated) + values('service-5','account-1',false,false)`) + execQuery(t, ctx, + `insert into targets (target_id, account_id, service_id, enabled, target_type) + values('target-4','account-1','service-5',false,'domain')`) + // id shouldn't be included as the service is terminated (explicitly) + execQuery(t, ctx, + `insert into services (id, account_id, enabled, terminated) + values('service-6','account-1',true,true)`) + execQuery(t, ctx, + `insert into targets (target_id, account_id, service_id, enabled, target_type) + values('target-5','account-1','service-6',true,'domain')`) + // id shouldn't be included as the service is terminated (implicitly) + execQuery(t, ctx, + `insert into services (id, account_id, enabled, terminated) + values('service-7','account-1',true,null)`) + execQuery(t, ctx, + `insert into targets (target_id, account_id, service_id, enabled, target_type) + values('target-6','account-1','service-7',true,'domain')`) + execQuery(t, ctx, + `insert into services (id, account_id, enabled, terminated) + values('service-8','account-1',true,false)`) + execQuery(t, ctx, + `insert into targets (target_id, account_id, service_id, enabled, target_type) + values('target-7','account-1','service-8',true,'domain')`) + // id shouldn't be returned as the taget_id is null + execQuery(t, ctx, + `insert into targets (target_id, account_id, service_id, enabled, target_type) + values(null,'account-1','service-4',true,'cluster')`) + + servtargetedDomains, err := conn(t, ctx).GetProxyTargetedDomainResourceIDs(ctx, "account-1") + assert.NoError(t, err) + assert.Equal(t, servtargetedDomains, map[string]struct{}{ + "target-1": {}, + "target-6": {}, + "target-7": {}, + }) +} diff --git a/integration_tests/management/network_map_db/sqlite_test_store.go b/integration_tests/management/network_map_db/sqlite_test_store.go new file mode 100644 index 000000000..1c70c93d4 --- /dev/null +++ b/integration_tests/management/network_map_db/sqlite_test_store.go @@ -0,0 +1,48 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "fmt" + "runtime" + "strings" + + networkmap_sqlite "github.com/netbirdio/netbird/management/internals/network_map_db/sqlite" + gormstore "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + log "github.com/sirupsen/logrus" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func createSqliteTestStore(baseData string) (*networkmap_sqlite.SqliteStore, func()) { + storeSqliteFileName := ":memory:" + storeStr := fmt.Sprintf("%s?cache=shared", storeSqliteFileName) + if runtime.GOOS == "windows" { + // Vo avoid `The process cannot access the file because it is being used by another process` on Windows + storeStr = storeSqliteFileName + } + + db, err := gorm.Open(sqlite.Open(storeStr), &gorm.Config{}) + if err != nil { + log.Fatalf("error initializing db: %s", err.Error()) + } + _, err = gormstore.NewSqlStore(context.TODO(), db, types.SqliteStoreEngine, nil, false) + if err != nil { + log.Fatalf("error initializing db: %s", err.Error()) + } + + sqldb, err := db.DB() + if err != nil { + log.Fatalf("error initializing db: %s", err.Error()) + + } + for _, query := range strings.Split(baseData, ";") { + if _, err := sqldb.Exec(query); err != nil { + log.Fatalf("error initializing db: %s", err.Error()) + } + } + + return &networkmap_sqlite.SqliteStore{Db: sqldb}, func() {} +} diff --git a/integration_tests/management/network_map_db/user_test.go b/integration_tests/management/network_map_db/user_test.go new file mode 100644 index 000000000..132f749e2 --- /dev/null +++ b/integration_tests/management/network_map_db/user_test.go @@ -0,0 +1,57 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetAllowedUsers(t *testing.T) { + ctx := context.TODO() + + execQuery(t, ctx, + `insert into users (id, name, account_id, auto_groups, blocked, is_service_user) + VALUES('user-1','user-1','account-1','["group-one-resource-id"]',false,false)`) + execQuery(t, ctx, + `insert into users (id, name, account_id, auto_groups, blocked, is_service_user) + VALUES('user-2','user-2','account-1','["group-one-resource-id","group-two-resources-id"]',false,false)`) + execQuery(t, ctx, + `insert into users (id, name, account_id, auto_groups, blocked, is_service_user) + VALUES('user-3','user-3','account-1','["group-two-resources-id"]',false,false)`) + // shouldn't be included as it's blocked + execQuery(t, ctx, + `insert into users (id, name, account_id, auto_groups, blocked, is_service_user) + VALUES('user-4','user-4','account-1','["group-two-resources-id"]',true,false)`) + // shouldn't be included as it's a service_user + execQuery(t, ctx, + `insert into users (id, name, account_id, auto_groups, blocked, is_service_user) + VALUES('user-5','user-5','account-1','["group-two-resources-id"]',false,true)`) + execQuery(t, ctx, + `insert into groups (id, name, account_id) + VALUES('all-group-1','All','account-1')`) + execQuery(t, ctx, + `insert into groups (id, name, account_id) + VALUES('all-group-2','All','account-1')`) + execQuery(t, ctx, + `insert into groups (id, name, account_id) + VALUES('all-group-3','All','account-1')`) + + userIdx, groupIdToUserIds, err := conn(t, ctx).GetAllowedUsers(ctx, "account-1") + assert.NoError(t, err) + + assert.Equal(t, userIdx, map[string]struct{}{ + "user-1": {}, + "user-2": {}, + "user-3": {}, + }) + assert.Equal(t, groupIdToUserIds, map[string][]string{ + "group-one-resource-id": {"user-1", "user-2"}, + "group-two-resources-id": {"user-2", "user-3"}, + "all-group-1": {"user-1", "user-2", "user-3"}, + "all-group-2": {"user-1", "user-2", "user-3"}, + "all-group-3": {"user-1", "user-2", "user-3"}, + }) +} diff --git a/magefiles/magefile.go b/magefiles/magefile.go new file mode 100644 index 000000000..34ab3c08f --- /dev/null +++ b/magefiles/magefile.go @@ -0,0 +1,10 @@ +//mage:multiline + +// Set the general description you want to have displayed with mage -l here. +package main + +// mg contains helpful utility functions, like Deps + +// Default target to run when none is specified +// If not set, running mage will list available targets +//var Default = Integrationtest.All diff --git a/magefiles/test.go b/magefiles/test.go new file mode 100644 index 000000000..2d08e8e01 --- /dev/null +++ b/magefiles/test.go @@ -0,0 +1,74 @@ +package main + +import ( + "errors" + "strings" + + "github.com/magefile/mage/mg" + "github.com/magefile/mage/sh" +) + +var defaultcli = []string{"test", "-tags=integration", "-timeout=20m"} + +type Integrationtest mg.Namespace + +func (i Integrationtest) All(gotestflags *string) error { + var errs []error + if err := i.Api(gotestflags); err != nil { + errs = append(errs, err) + } + if err := i.NmapDb(gotestflags); err != nil { + errs = append(errs, err) + } + if len(errs) > 0 { + return errors.Join(errs...) + } + return nil +} + +func (Integrationtest) NmapDb(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.RunV("go", cli...) +} + +func (Integrationtest) NmapDbPostgres(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{"NETBIRD_STORE_ENGINE": "postgres"}, "go", cli...) +} + +func (Integrationtest) NmapDbSqlite(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{"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 { + cli = append(cli, strings.Split(*gotestflags, " ")...) + } + cli = append(cli, "./management/server/http/...") + return sh.RunV("go", cli...) +} diff --git a/management/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go index 30de974a1..e74b17638 100644 --- a/management/internals/controllers/network_map/controller/controller.go +++ b/management/internals/controllers/network_map/controller/controller.go @@ -18,8 +18,10 @@ import ( "github.com/netbirdio/netbird/management/internals/controllers/network_map" "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache" "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral" + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" "github.com/netbirdio/netbird/management/internals/server/config" "github.com/netbirdio/netbird/management/internals/shared/grpc" + "github.com/netbirdio/netbird/management/internals/shared/requestbuffer" "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/integrations/integrated_validator" "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" @@ -30,12 +32,16 @@ import ( "github.com/netbirdio/netbird/management/server/telemetry" "github.com/netbirdio/netbird/management/server/types" sharedgrpc "github.com/netbirdio/netbird/shared/management/grpc" + "github.com/netbirdio/netbird/shared/management/networkmap" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" "github.com/netbirdio/netbird/shared/management/proto" "github.com/netbirdio/netbird/shared/management/status" "github.com/netbirdio/netbird/util" "github.com/netbirdio/netbird/version" ) +const defaultNetworkMapDataBufferInterval = 100 * time.Millisecond + type Controller struct { repo Repository metrics *metrics @@ -61,6 +67,9 @@ type Controller struct { serverSupportedSyncMessageVersion sharedgrpc.SyncMessageVersion perAccountServerSupportedSyncMessageVersions map[string]sharedgrpc.SyncMessageVersion + + nmdataStore *networkmapdb.NetworkMapDBStoreImpl + nmdataBuffer *requestbuffer.Buffer[*networkmap.NetworkMapData] } type bufferUpdate struct { @@ -78,13 +87,13 @@ type bufferAffectedUpdate struct { var _ network_map.Controller = (*Controller)(nil) -func NewController(ctx context.Context, store store.Store, metrics telemetry.AppMetrics, peersUpdateManager network_map.PeersUpdateManager, requestBuffer account.RequestBuffer, integratedPeerValidator integrated_validator.IntegratedValidator, settingsManager settings.Manager, dnsDomain string, proxyController port_forwarding.Controller, ephemeralPeersManager ephemeral.Manager, config *config.Config) *Controller { +func NewController(ctx context.Context, store store.Store, metrics telemetry.AppMetrics, peersUpdateManager network_map.PeersUpdateManager, requestBuffer account.RequestBuffer, integratedPeerValidator integrated_validator.IntegratedValidator, settingsManager settings.Manager, dnsDomain string, proxyController port_forwarding.Controller, ephemeralPeersManager ephemeral.Manager, config *config.Config, nmdataStore *networkmapdb.NetworkMapDBStoreImpl) *Controller { nMetrics, err := newMetrics(metrics.UpdateChannelMetrics()) if err != nil { log.Fatal(fmt.Errorf("error creating metrics: %w", err)) } - return &Controller{ + c := &Controller{ repo: newRepository(store), metrics: nMetrics, accountManagerMetrics: metrics.AccountManagerMetrics(), @@ -99,7 +108,16 @@ func NewController(ctx context.Context, store store.Store, metrics telemetry.App EphemeralPeersManager: ephemeralPeersManager, serverSupportedSyncMessageVersion: sharedgrpc.SyncMessageVersionFromConfig(config.HighestSupportedSyncMessageVersion), perAccountServerSupportedSyncMessageVersions: sharedgrpc.SyncMessageVersionsFromMap(config.PerAccountHighestSupportedSyncMessageVersion), + nmdataStore: nmdataStore, } + + if nmdataStore != nil { + interval := requestbuffer.Interval(ctx, "NB_NETWORK_MAP_DATA_BUFFER_INTERVAL", defaultNetworkMapDataBufferInterval) + log.WithContext(ctx).Infof("set network map data request buffer interval to %s", interval) + c.nmdataBuffer = requestbuffer.New(ctx, "network map data request buffer", interval, c.fetchNetworkMapData) + } + + return c } func (c *Controller) OnPeerConnected(ctx context.Context, accountID string, peerID string) (chan *network_map.UpdateMessage, error) { @@ -125,12 +143,12 @@ func (c *Controller) OnPeerDisconnected(ctx context.Context, accountID string, p // injectAllProxyPolicies prepares an account for the per-peer network-map // computation. It prepends the in-memory agent-network services synthesised -// from the account's current provider/policy state to account.Services so -// the existing InjectProxyPolicies + injectPrivateServicePolicies walks pick -// them up alongside persisted reverse-proxy services. Synthesised services -// are never persisted; the account is loaded fresh per cycle so re-prepending -// is safe and idempotent. Accounts without agent-network providers get an -// empty synth slice — no behaviour change. +// from the account's current provider/policy state to account.Services, so the +// twin store built from the account carries them alongside the persisted +// reverse-proxy services and synthesises their ACLs. Synthesised services are +// never persisted; the account is loaded fresh per cycle so re-prepending is +// safe and idempotent. Accounts without agent-network providers get an empty +// synth slice — no behaviour change. func (c *Controller) injectAllProxyPolicies(ctx context.Context, account *types.Account) { synth, err := c.repo.SynthesizeAgentNetworkServices(ctx, account.Id) if err != nil { @@ -138,7 +156,26 @@ func (c *Controller) injectAllProxyPolicies(ctx context.Context, account *types. } else if len(synth) > 0 { account.Services = append(synth, account.Services...) } - account.InjectProxyPolicies(ctx) +} + +// proxyServicesFromRepo is the store-path counterpart of +// injectAllProxyPolicies: the network-map store reads the policies table, which +// never holds the proxy ACLs, so the twin gets the services they are +// synthesised from — the synthesised agent-network ones first, exactly as the +// account path orders them. +func (c *Controller) proxyServicesFromRepo(ctx context.Context, accountID string) []*nmdata.Service { + persisted, err := c.repo.GetAccountServices(ctx, accountID) + if err != nil { + log.WithContext(ctx).Errorf("failed to get services for account %s: %v", accountID, err) + return nil + } + + synth, err := c.repo.SynthesizeAgentNetworkServices(ctx, accountID) + if err != nil { + log.WithContext(ctx).Warnf("synthesise agent-network services for account %s: %v", accountID, err) + } + + return types.TwinServices(append(synth, persisted...)) } func (c *Controller) CountStreams() int { @@ -147,6 +184,11 @@ func (c *Controller) CountStreams() int { func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error { log.WithContext(ctx).Tracef("updating peers for account %s from %s", accountID, util.GetCallerName()) + + if nmData := c.getNetworkMapData(ctx, accountID); nmData != nil { + return c.sendUpdateAccountPeersFromData(ctx, accountID, reason, nmData) + } + account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID) if err != nil { return fmt.Errorf("failed to get account: %v", err) @@ -167,7 +209,7 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin return nil } - approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra) + approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra) if err != nil { return fmt.Errorf("failed to get validate peers: %v", err) } @@ -255,7 +297,7 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin // proxyNetworkMap rides the envelope as a ProxyPatch sidecar; // the client merges it into Calculate()'s output the same // way the legacy server did via NetworkMap.Merge. - update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort) + update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort) c.metrics.CountToComponentSyncResponseDuration(time.Since(start)) c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{ @@ -276,7 +318,7 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin } start = time.Now() - update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, nmap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort) + update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, nmap, dnsDomain, postureChecks, dnsCache, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort) c.metrics.CountToSyncResponseDuration(time.Since(start)) c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{ @@ -294,6 +336,290 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin return nil } +// sendUpdateAccountPeersFromData is the account-free variant of +// sendUpdateAccountPeers: everything is computed from the network-map DB +// store's twin data; only extra settings and validated peers are resolved at +// runtime. Proxy network maps and policy injection, private-service zones, +// group-to-user SSH mappings and forced routing-peer DNS resolution have no +// DB-backed source yet and are omitted. +func (c *Controller) sendUpdateAccountPeersFromData(ctx context.Context, accountID string, reason types.UpdateReason, nmData *networkmap.NetworkMapData) error { + peersToUpdate := c.connectedPeersFromData(nmData, nil) + if len(peersToUpdate) == 0 { + return nil + } + return c.sendUpdatesFromData(ctx, accountID, nmData, peersToUpdate, &reason) +} + +// sendUpdateForAffectedPeersFromData is the account-free variant of +// sendUpdateForAffectedPeers. +func (c *Controller) sendUpdateForAffectedPeersFromData(ctx context.Context, accountID string, peerIDs []string, nmData *networkmap.NetworkMapData) error { + if len(peerIDs) == 0 { + log.WithContext(ctx).Tracef("sendUpdateForAffectedPeersFromData: no affected peers") + return nil + } + + peersToUpdate := c.connectedPeersFromData(nmData, peerIDs) + if len(peersToUpdate) == 0 { + log.WithContext(ctx).Tracef("sendUpdateForAffectedPeersFromData: no peers to update (affected peers not found in data or no channels)") + return nil + } + + log.WithContext(ctx).Tracef("sendUpdateForAffectedPeersFromData: sending network map to %d connected peers", len(peersToUpdate)) + + return c.sendUpdatesFromData(ctx, accountID, nmData, peersToUpdate, nil) +} + +// connectedPeersFromData returns the peers with an open update channel. An +// empty affected list means all peers; a non-empty list restricts the result +// to those peer IDs. +func (c *Controller) connectedPeersFromData(nmData *networkmap.NetworkMapData, affected []string) []*nmdata.Peer { + if len(affected) == 0 { + result := make([]*nmdata.Peer, 0, len(nmData.Peers)) + for _, peer := range nmData.Peers { + if c.peersUpdateManager.HasChannel(peer.ID) { + result = append(result, peer) + } + } + return result + } + + result := make([]*nmdata.Peer, 0, len(affected)) + for _, peerID := range affected { + peer := nmData.Peers[peerID] + if peer == nil { + continue + } + if c.peersUpdateManager.HasChannel(peerID) { + result = append(result, peer) + } + } + return result +} + +func (c *Controller) sendUpdatesFromData(ctx context.Context, accountID string, nmData *networkmap.NetworkMapData, peersToUpdate []*nmdata.Peer, reason *types.UpdateReason) error { + globalStart := time.Now() + + extraSettings, err := c.settingsManager.GetExtraSettings(ctx, accountID) + if err != nil { + return fmt.Errorf("failed to get flow enabled status: %v", err) + } + + dnsCache := &cache.DNSConfigCache{} + dnsDomain := c.getDNSDomainFromData(nmData.AccountSettings) + peersCustomZone := networkmap.PeersCustomZone(ctx, accountID, dnsDomain, nmData.Peers, IPv6AllowedPeersFromData(nmData)) + + dnsFwdPort := ComputeForwarderPortFromData(nmData.Peers, network_map.DnsForwarderPortMinVersion) + + var wg sync.WaitGroup + semaphore := make(chan struct{}, 10) + + for _, peer := range peersToUpdate { + if reason != nil && c.accountManagerMetrics != nil { + c.accountManagerMetrics.CountNmapTriggered(string(reason.Resource), string(reason.Operation)) + } + + wg.Add(1) + semaphore <- struct{}{} + go func(p *nmdata.Peer) { + defer wg.Done() + defer func() { <-semaphore }() + + start := time.Now() + + postureChecks := peerPostureChecksFromData(nmData, p.ID) + + c.metrics.CountCalcPostureChecksDuration(time.Since(start)) + start = time.Now() + + peerGroups := maps.Keys(nmData.GetPeerGroups(p.ID)) + var update *proto.SyncResponse + + commonSyncMessageVersion := sharedgrpc.HighestCommonSyncMessageVersion( + c.perAccountOrGlobalSupportedSyncMessageVersions(accountID), + sharedgrpc.SyncMessageVersionFromConfig(&p.Meta.SyncMessageVersion)) + + log.WithContext(ctx). + WithFields(log.Fields{ + "sync_message_version": commonSyncMessageVersion, + "server_sync_message_version": c.perAccountOrGlobalSupportedSyncMessageVersions(accountID), + "peer_sync_message_version": sharedgrpc.SyncMessageVersionFromConfig(&p.Meta.SyncMessageVersion), + }).Debug("common highest sync message version") + + if commonSyncMessageVersion == sharedgrpc.ComponentNetworkMap { + components := nmData.GetPeerNetworkMapComponents(p.ID, peersCustomZone) + + c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start)) + + start = time.Now() + update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, components, nil, dnsDomain, postureChecks, nmData.AccountSettings, extraSettings, peerGroups, dnsFwdPort) + c.metrics.CountToComponentSyncResponseDuration(time.Since(start)) + + c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{ + Update: update, + MessageType: network_map.MessageTypeNetworkMap, + }) + + return + } + + nmap := NetworkMapFromData(ctx, nmData, p.ID, peersCustomZone, c.accountManagerMetrics) + + c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start)) + + start = time.Now() + update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, nmap, dnsDomain, postureChecks, dnsCache, nmData.AccountSettings, extraSettings, peerGroups, dnsFwdPort) + c.metrics.CountToSyncResponseDuration(time.Since(start)) + + c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{ + Update: update, + MessageType: network_map.MessageTypeNetworkMap, + }) + }(peer) + } + + wg.Wait() + if c.accountManagerMetrics != nil { + c.accountManagerMetrics.CountUpdateAccountPeersDuration(time.Since(globalStart)) + } + + return nil +} + +func (c *Controller) getNetworkMapData(ctx context.Context, accountID string) *networkmap.NetworkMapData { + if c.nmdataBuffer == nil { + return nil + } + + nmData, err := c.nmdataBuffer.Get(ctx, accountID) + if err != nil { + log.WithContext(ctx).Errorf("failed to get network map data for account %s, falling back to account-based computation: %v", accountID, err) + return nil + } + + return nmData +} + +// fetchNetworkMapData reads the twin once per buffer window. Its result is +// shared by every waiter of that window, so the mutating steps run here, before +// it is handed out: the twin the callers see is read-only. Injected proxy +// policies carry no posture checks, so precomputing after the injection yields +// the same validation as precomputing before it. +func (c *Controller) fetchNetworkMapData(ctx context.Context, accountID string) (*networkmap.NetworkMapData, error) { + nmData, err := c.nmdataStore.GetNetworkMapData(ctx, accountID) + if err != nil { + return nil, err + } + + nmData.Services = c.proxyServicesFromRepo(ctx, accountID) + nmData.InjectProxyPolicies() + nmData.PrecomputePostureValidation() + + return nmData, nil +} + +func (c *Controller) getDNSDomainFromData(settings *nmdata.AccountSettingsInfo) string { + if settings == nil || settings.DNSDomain == "" { + return c.dnsDomain + } + return settings.DNSDomain +} + +func IPv6AllowedPeersFromData(nmData *networkmap.NetworkMapData) map[string]struct{} { + result := make(map[string]struct{}) + // An account with no IPv6-enabled group runs no overlay at all, so the + // embedded-proxy carve-out below has nothing to reach and stays shut. + if nmData.AccountSettings == nil || len(nmData.AccountSettings.IPv6EnabledGroups) == 0 { + return result + } + for _, groupID := range nmData.AccountSettings.IPv6EnabledGroups { + group := nmData.Groups[groupID] + if group == nil { + continue + } + for _, peerID := range group.Peers { + result[peerID] = struct{}{} + } + } + for id, p := range nmData.Peers { + if p != nil && p.ProxyMeta.Embedded { + result[id] = struct{}{} + } + } + return result +} + +func NetworkMapFromData(ctx context.Context, nmData *networkmap.NetworkMapData, peerID string, peersCustomZone nmdata.CustomZone, metrics *telemetry.AccountManagerMetrics) *types.NetworkMap { + start := time.Now() + + components := nmData.GetPeerNetworkMapComponents(peerID, peersCustomZone) + if components.IsEmpty() { + return &types.NetworkMap{Network: components.Network} + } + nm := types.CalculateNetworkMapFromComponents(ctx, components) + + if metrics != nil { + objectCount := int64(len(nm.Peers) + len(nm.OfflinePeers) + len(nm.Routes) + len(nm.FirewallRules) + len(nm.RoutesFirewallRules)) + metrics.CountNetworkMapObjects(objectCount) + metrics.CountGetPeerNetworkMapDuration(time.Since(start)) + } + + return nm +} + +// peerPostureChecksFromData mirrors getPeerPostureChecks on the twin store. The +// sync response only encodes process-check file paths, so only ProcessCheck is +// converted back to the server posture type. +func peerPostureChecksFromData(nmData *networkmap.NetworkMapData, peerID string) []*posture.Checks { + if len(nmData.PostureChecks) == 0 { + return nil + } + + peerPostureChecks := make(map[string]*posture.Checks) + for _, policy := range nmData.Policies { + if policy == nil || !policy.Enabled || len(policy.SourcePostureChecks) == 0 { + continue + } + if !isPeerInPolicySourceGroupsFromData(nmData, peerID, policy) { + continue + } + for _, checkID := range policy.SourcePostureChecks { + twin := nmData.PostureChecks[checkID] + if twin == nil { + continue + } + peerPostureChecks[checkID] = postureChecksFromTwin(twin) + } + } + + return maps.Values(peerPostureChecks) +} + +func isPeerInPolicySourceGroupsFromData(nmData *networkmap.NetworkMapData, peerID string, policy *nmdata.Policy) bool { + for _, rule := range policy.Rules { + if rule == nil || !rule.Enabled { + continue + } + for _, groupID := range rule.Sources { + if group := nmData.Groups[groupID]; group != nil && slices.Contains(group.Peers, peerID) { + return true + } + } + } + return false +} + +func postureChecksFromTwin(twin *nmdata.PostureChecks) *posture.Checks { + checks := &posture.Checks{ID: twin.ID} + if twin.Checks.ProcessCheck != nil { + processes := make([]posture.Process, 0, len(twin.Checks.ProcessCheck.Processes)) + for _, p := range twin.Checks.ProcessCheck.Processes { + processes = append(processes, posture.Process{LinuxPath: p.LinuxPath, MacPath: p.MacPath, WindowsPath: p.WindowsPath}) + } + checks.Checks.ProcessCheck = &posture.ProcessCheck{Processes: processes} + } + return checks +} + func (c *Controller) perAccountOrGlobalSupportedSyncMessageVersions(accountId string) sharedgrpc.SyncMessageVersion { if perAccount, ok := c.perAccountServerSupportedSyncMessageVersions[accountId]; ok { return perAccount @@ -326,6 +652,10 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s return nil } + if nmData := c.getNetworkMapData(ctx, accountID); nmData != nil { + return c.sendUpdateForAffectedPeersFromData(ctx, accountID, peerIDs, nmData) + } + account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID) if err != nil { return fmt.Errorf("failed to get account: %v", err) @@ -341,7 +671,7 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s log.WithContext(ctx).Tracef("sendUpdateForAffectedPeers: sending network map to %d connected peers", len(peersToUpdate)) - approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra) + approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra) if err != nil { return fmt.Errorf("failed to get validate peers: %v", err) } @@ -428,7 +758,7 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s // proxyNetworkMap rides the envelope as a ProxyPatch sidecar; // the client merges it into Calculate()'s output the same // way the legacy server did via NetworkMap.Merge. - update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort) + update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort) c.metrics.CountToComponentSyncResponseDuration(time.Since(start)) c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{ @@ -449,7 +779,7 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s } start = time.Now() - update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, nmap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort) + update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, nmap, dnsDomain, postureChecks, dnsCache, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort) c.metrics.CountToSyncResponseDuration(time.Since(start)) c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{ @@ -506,7 +836,7 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe return fmt.Errorf("peer %s doesn't exists in account %s", peerId, accountId) } - approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra) + approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra) if err != nil { return fmt.Errorf("failed to get validated peers: %v", err) } @@ -566,7 +896,7 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe // proxyNetworkMap rides the envelope as a ProxyPatch sidecar; // the client merges it into Calculate()'s output the same // way the legacy server did via NetworkMap.Merge. - update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, peer, nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, account.Settings, extraSettings, maps.Keys(peerGroups), dnsFwdPort) + update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(peer), nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSettings, maps.Keys(peerGroups), dnsFwdPort) c.peersUpdateManager.SendUpdate(ctx, peer.ID, &network_map.UpdateMessage{ Update: update, @@ -583,7 +913,7 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe nmap.Merge(proxyNetworkMap) } - update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, peer, nil, nil, nmap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSettings, maps.Keys(peerGroups), dnsFwdPort) + update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(peer), nil, nil, nmap, dnsDomain, postureChecks, dnsCache, types.TwinAccountSettings(account.Settings), extraSettings, maps.Keys(peerGroups), dnsFwdPort) c.peersUpdateManager.SendUpdate(ctx, peer.ID, &network_map.UpdateMessage{ Update: update, @@ -643,7 +973,11 @@ func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequi if err != nil { return nil, nil, nil, nil, 0, err } - return peer, &types.NetworkMapComponents{Network: network.Copy()}, nil, nil, 0, nil + return peer, &types.NetworkMapComponents{Network: types.TwinNetwork(network)}, nil, nil, 0, nil + } + + if nmData := c.getNetworkMapData(ctx, accountID); nmData != nil { + return c.getValidatedPeerWithComponentsFromData(ctx, accountID, peer, nmData) } account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID) @@ -658,7 +992,7 @@ func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequi c.injectAllProxyPolicies(ctx, account) - approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra) + approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra) if err != nil { return nil, nil, nil, nil, 0, err } @@ -695,6 +1029,21 @@ func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequi return peer, components, proxyNetworkMaps[peer.ID], postureChecks, dnsFwdPort, nil } +// getValidatedPeerWithComponentsFromData is the account-free variant of +// GetValidatedPeerWithComponents. The proxy network map fragment is omitted +// like on the other nmdata paths. +func (c *Controller) getValidatedPeerWithComponentsFromData(ctx context.Context, accountID string, peer *nbpeer.Peer, nmData *networkmap.NetworkMapData) (*nbpeer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*posture.Checks, int64, error) { + postureChecks := peerPostureChecksFromData(nmData, peer.ID) + + dnsDomain := c.getDNSDomainFromData(nmData.AccountSettings) + peersCustomZone := networkmap.PeersCustomZone(ctx, accountID, dnsDomain, nmData.Peers, IPv6AllowedPeersFromData(nmData)) + + components := nmData.GetPeerNetworkMapComponents(peer.ID, peersCustomZone) + dnsFwdPort := ComputeForwarderPortFromData(nmData.Peers, network_map.DnsForwarderPortMinVersion) + + return peer, components, nil, postureChecks, dnsFwdPort, nil +} + // BufferUpdateAffectedPeers accumulates peer IDs and flushes them after the buffer interval. func (c *Controller) BufferUpdateAffectedPeers(ctx context.Context, accountID string, peerIDs []string, reason types.UpdateReason) error { if len(peerIDs) == 0 { @@ -801,11 +1150,15 @@ func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresAppr } emptyMap := &types.NetworkMap{ - Network: network.Copy(), + Network: types.TwinNetwork(network), } return emptyMap, nil, 0, nil } + if nmData := c.getNetworkMapData(ctx, accountID); nmData != nil { + return c.getValidatedPeerWithMapFromData(ctx, accountID, peerID, nmData) + } + account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID) if err != nil { return nil, nil, 0, err @@ -813,7 +1166,7 @@ func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresAppr c.injectAllProxyPolicies(ctx, account) - approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra) + approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra) if err != nil { return nil, nil, 0, err } @@ -853,6 +1206,21 @@ func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresAppr return networkMap, postureChecks, dnsFwdPort, nil } +// getValidatedPeerWithMapFromData is the account-free variant of +// GetValidatedPeerWithMap. The proxy network map fragment is omitted like on +// the other nmdata paths. +func (c *Controller) getValidatedPeerWithMapFromData(ctx context.Context, accountID string, peerID string, nmData *networkmap.NetworkMapData) (*types.NetworkMap, []*posture.Checks, int64, error) { + postureChecks := peerPostureChecksFromData(nmData, peerID) + + dnsDomain := c.getDNSDomainFromData(nmData.AccountSettings) + peersCustomZone := networkmap.PeersCustomZone(ctx, accountID, dnsDomain, nmData.Peers, IPv6AllowedPeersFromData(nmData)) + + networkMap := NetworkMapFromData(ctx, nmData, peerID, peersCustomZone, c.accountManagerMetrics) + dnsFwdPort := ComputeForwarderPortFromData(nmData.Peers, network_map.DnsForwarderPortMinVersion) + + return networkMap, postureChecks, dnsFwdPort, nil +} + // GetDNSDomain returns the configured dnsDomain func (c *Controller) GetDNSDomain(settings *types.Settings) string { if settings == nil { @@ -915,20 +1283,36 @@ func (c *Controller) StartWarmup(ctx context.Context) { // computeForwarderPort checks if all peers in the account have updated to a specific version or newer. // If all peers have the required version, it returns the new well-known port (22054), otherwise returns 0. func computeForwarderPort(peers []*nbpeer.Peer, requiredVersion string) int64 { - if len(peers) == 0 { + versions := make([]string, 0, len(peers)) + for _, peer := range peers { + versions = append(versions, peer.Meta.WtVersion) + } + return computeForwarderPortFromVersions(versions, requiredVersion) +} + +func ComputeForwarderPortFromData(peers map[string]*nmdata.Peer, requiredVersion string) int64 { + versions := make([]string, 0, len(peers)) + for _, peer := range peers { + versions = append(versions, peer.Meta.WtVersion) + } + return computeForwarderPortFromVersions(versions, requiredVersion) +} + +func computeForwarderPortFromVersions(wtVersions []string, requiredVersion string) int64 { + if len(wtVersions) == 0 { return int64(network_map.OldForwarderPort) } reqVer := semver.Canonical(requiredVersion) // Check if all peers have the required version or newer - for _, peer := range peers { + for _, wtVersion := range wtVersions { // Development version is always supported - if version.IsDevelopmentVersion(peer.Meta.WtVersion) { + if version.IsDevelopmentVersion(wtVersion) { continue } - peerVersion := semver.Canonical("v" + peer.Meta.WtVersion) + peerVersion := semver.Canonical("v" + wtVersion) if peerVersion == "" { // If any peer doesn't have version info, return 0 return int64(network_map.OldForwarderPort) @@ -1062,7 +1446,12 @@ func (c *Controller) GetNetworkMap(ctx context.Context, peerID string) (*types.N groups[groupID] = group.Peers } - validatedPeers, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra) + extraSettings, err := c.settingsManager.GetExtraSettings(ctx, account.Id) + if err != nil { + return nil, err + } + + validatedPeers, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), extraSettings) if err != nil { return nil, err } diff --git a/management/internals/controllers/network_map/controller/ipv6_allowed_test.go b/management/internals/controllers/network_map/controller/ipv6_allowed_test.go new file mode 100644 index 000000000..c80f3b734 --- /dev/null +++ b/management/internals/controllers/network_map/controller/ipv6_allowed_test.go @@ -0,0 +1,47 @@ +package controller + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/shared/management/networkmap" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" +) + +// The account-side builder (types.Account.peerIPv6AllowedSet) is the reference: +// an account with no IPv6-enabled group runs no IPv6 overlay at all, embedded +// proxy peers included — see TestPeerIPv6AllowedEmbeddedProxy. Both builders +// gate the same AAAA records, so the store-backed one has to agree. +func TestIPv6AllowedPeersFromData(t *testing.T) { + data := func(enabledGroups []string) *networkmap.NetworkMapData { + return &networkmap.NetworkMapData{ + AccountSettings: &nmdata.AccountSettingsInfo{IPv6EnabledGroups: enabledGroups}, + Peers: map[string]*nmdata.Peer{ + "peer1": {ID: "peer1"}, + "lonely": {ID: "lonely"}, + "proxy": {ID: "proxy", ProxyMeta: nmdata.ProxyMeta{Embedded: true, Cluster: "netbird.test"}}, + }, + Groups: map[string]*nmdata.Group{ + "group-devs": {ID: "group-devs", Peers: []string{"peer1"}}, + }, + } + } + + t.Run("embedded proxy allowed when any v6 group exists, without group membership", func(t *testing.T) { + allowed := IPv6AllowedPeersFromData(data([]string{"group-devs"})) + assert.Contains(t, allowed, "proxy", "embedded proxy participates in v6 overlay") + assert.Contains(t, allowed, "peer1", "regular peer in enabled group still allowed") + }) + + t.Run("embedded proxy denied when no v6 group enabled", func(t *testing.T) { + allowed := IPv6AllowedPeersFromData(data(nil)) + assert.NotContains(t, allowed, "proxy", "v6 disabled account-wide denies embedded proxies too") + assert.Empty(t, allowed, "no peer participates in the v6 overlay") + }) + + t.Run("non-embedded peer outside any enabled group is not pulled in", func(t *testing.T) { + allowed := IPv6AllowedPeersFromData(data([]string{"group-devs"})) + assert.NotContains(t, allowed, "lonely", "embedded-proxy bypass must not leak to regular peers") + }) +} diff --git a/management/internals/controllers/network_map/controller/repository.go b/management/internals/controllers/network_map/controller/repository.go index bd8ed4e80..5c3195f16 100644 --- a/management/internals/controllers/network_map/controller/repository.go +++ b/management/internals/controllers/network_map/controller/repository.go @@ -24,6 +24,7 @@ type Repository interface { // services synthesised from the account's agent-network provider/policy // state. Empty for accounts without agent-network providers. SynthesizeAgentNetworkServices(ctx context.Context, accountID string) ([]*service.Service, error) + GetAccountServices(ctx context.Context, accountID string) ([]*service.Service, error) } type repository struct { @@ -62,6 +63,10 @@ func (r *repository) SynthesizeAgentNetworkServices(ctx context.Context, account return agentnetwork.SynthesizeServices(ctx, r.store, accountID) } +func (r *repository) GetAccountServices(ctx context.Context, accountID string) ([]*service.Service, error) { + return r.store.GetAccountServices(ctx, store.LockingStrengthNone, accountID) +} + func (r *repository) GetAccountZones(ctx context.Context, accountID string) ([]*zones.Zone, error) { return r.store.GetAccountZones(ctx, store.LockingStrengthNone, accountID) } diff --git a/management/internals/controllers/network_map/nmaptest/canonicalize.go b/management/internals/controllers/network_map/nmaptest/canonicalize.go new file mode 100644 index 000000000..ec6614d81 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/canonicalize.go @@ -0,0 +1,380 @@ +package nmaptest + +import ( + "bytes" + "cmp" + "fmt" + "slices" + "sort" + "strconv" + "strings" + + "github.com/netbirdio/netbird/shared/management/proto" +) + +// normalizeIDSpace replaces policy and route identifiers with positional +// placeholders so a comparison can reach everything else. +// +// This exists only because the envelope round-trip currently substitutes each +// internal xid with the object's public id, which is a tracked defect and not a +// licence to differ: those identifiers reach the server again inside flow +// events, which resolve them by internal id, so the substitution silently +// breaks flow attribution for component-format peers. TestIDSpaceMatches +// asserts the equality that must eventually hold; this erasure keeps the other +// 40-odd cases reporting on semantics meanwhile. When the id space is unified, +// delete this and the calls to it — every case should still pass. +// +// Cardinality and cross-references survive the erasure: two rules under one +// policy still share a token and a route firewall rule still points at its +// route, so a path that drops a policy, merges two policies, or misattributes a +// rule to the wrong route still fails. +func normalizeIDSpace(nm *proto.NetworkMap) { + if nm == nil { + return + } + policies := newTokenizer("policy") + routes := newTokenizer("route") + + for _, i := range orderBy(nm.Routes, routeKeyWithoutID) { + nm.Routes[i].ID = routes.get(nm.Routes[i].ID) + } + for _, i := range orderBy(nm.FirewallRules, firewallKeyWithoutPolicy) { + r := nm.FirewallRules[i] + if len(r.PolicyID) > 0 { + r.PolicyID = []byte(policies.get(string(r.PolicyID))) + } + } + for _, i := range orderBy(nm.RoutesFirewallRules, routeFirewallKeyWithoutIDs) { + r := nm.RoutesFirewallRules[i] + if len(r.PolicyID) > 0 { + r.PolicyID = []byte(policies.get(string(r.PolicyID))) + } + r.RouteID = routes.get(r.RouteID) + } +} + +// tokenizer maps identifiers to positional placeholders in order of first use. +type tokenizer struct { + prefix string + seen map[string]string +} + +func newTokenizer(prefix string) *tokenizer { + return &tokenizer{prefix: prefix, seen: make(map[string]string)} +} + +func (t *tokenizer) get(id string) string { + if id == "" { + return "" + } + if tok, ok := t.seen[id]; ok { + return tok + } + tok := fmt.Sprintf("%s#%d", t.prefix, len(t.seen)) + t.seen[id] = tok + return tok +} + +// orderBy returns indices sorted by key, so placeholder numbering does not +// depend on the identifiers being erased. +func orderBy[T any](items []T, key func(T) string) []int { + idx := make([]int, len(items)) + for i := range idx { + idx[i] = i + } + sort.SliceStable(idx, func(a, b int) bool { return key(items[idx[a]]) < key(items[idx[b]]) }) + return idx +} + +func routeKeyWithoutID(r *proto.Route) string { + if r == nil { + return "" + } + return fmt.Sprintf("%s|%s|%s|%d|%d|%t|%t|%v", + r.Network, r.NetID, r.Peer, r.Metric, r.NetworkType, r.Masquerade, r.KeepRoute, r.Domains) +} + +func firewallKeyWithoutPolicy(r *proto.FirewallRule) string { + if r == nil { + return "" + } + return fmt.Sprintf("%s|%d|%d|%d|%s|%s|%v", + r.PeerIP, r.Direction, r.Action, r.Protocol, r.Port, portInfoKey(r.PortInfo), r.SourcePrefixes) //nolint:staticcheck +} + +func routeFirewallKeyWithoutIDs(r *proto.RouteFirewallRule) string { + if r == nil { + return "" + } + return fmt.Sprintf("%s|%d|%d|%s|%v|%v|%t|%d", + r.Destination, r.Protocol, r.Action, portInfoKey(r.PortInfo), r.Domains, r.SourceRanges, r.IsDynamic, r.CustomProtocol) +} + +// canonicalize sorts every repeated field of the NetworkMap by a stable key. +// The producing paths iterate Go maps while building these slices, so order +// can differ between runs even when the content is identical; comparing +// without this reports noise. +func canonicalize(nm *proto.NetworkMap) { + if nm == nil { + return + } + slices.SortFunc(nm.RemotePeers, cmpRemotePeer) + slices.SortFunc(nm.OfflinePeers, cmpRemotePeer) + slices.SortFunc(nm.Routes, cmpRoute) + slices.SortFunc(nm.FirewallRules, cmpFirewallRule) + slices.SortFunc(nm.RoutesFirewallRules, cmpRouteFirewallRule) + slices.SortFunc(nm.ForwardingRules, cmpForwardingRule) + + for _, r := range nm.FirewallRules { + slices.SortFunc(r.SourcePrefixes, bytes.Compare) + } + for _, r := range nm.RoutesFirewallRules { + slices.Sort(r.SourceRanges) + } + canonicalizeDNSConfig(nm.DNSConfig) + canonicalizeSSHAuth(nm.SshAuth) +} + +func canonicalizeDNSConfig(d *proto.DNSConfig) { + if d == nil { + return + } + for _, g := range d.NameServerGroups { + if g == nil { + continue + } + slices.Sort(g.Domains) + slices.SortFunc(g.NameServers, func(a, b *proto.NameServer) int { + if a == nil || b == nil { + return boolCmp(a == nil, b == nil) + } + if c := cmp.Compare(a.IP, b.IP); c != 0 { + return c + } + if c := cmp.Compare(a.Port, b.Port); c != 0 { + return c + } + return cmp.Compare(a.NSType, b.NSType) + }) + } + slices.SortFunc(d.NameServerGroups, func(a, b *proto.NameServerGroup) int { + return cmp.Compare(nsgKey(a), nsgKey(b)) + }) + for _, z := range d.CustomZones { + if z == nil { + continue + } + slices.SortFunc(z.Records, cmpSimpleRecord) + } + slices.SortFunc(d.CustomZones, func(a, b *proto.CustomZone) int { + if a == nil || b == nil { + return boolCmp(a == nil, b == nil) + } + return cmp.Compare(a.Domain, b.Domain) + }) +} + +// canonicalizeSSHAuth sorts AuthorizedUsers and re-keys MachineUsers.Indexes +// against the new ordering, preserving which machine user maps to which hashes. +func canonicalizeSSHAuth(s *proto.SSHAuth) { + if s == nil || len(s.AuthorizedUsers) == 0 { + return + } + type hashed struct { + bytes []byte + old uint32 + } + entries := make([]hashed, len(s.AuthorizedUsers)) + for i, b := range s.AuthorizedUsers { + entries[i] = hashed{bytes: b, old: uint32(i)} + } + slices.SortFunc(entries, func(a, b hashed) int { return bytes.Compare(a.bytes, b.bytes) }) + + remap := make(map[uint32]uint32, len(entries)) + sorted := make([][]byte, len(entries)) + for newIdx, e := range entries { + remap[e.old] = uint32(newIdx) + sorted[newIdx] = e.bytes + } + s.AuthorizedUsers = sorted + + for _, mu := range s.MachineUsers { + if mu == nil { + continue + } + for i, oldIdx := range mu.Indexes { + if newIdx, ok := remap[oldIdx]; ok { + mu.Indexes[i] = newIdx + } + } + slices.Sort(mu.Indexes) + } +} + +func boolCmp(a, b bool) int { + if a == b { + return 0 + } + if a { + return 1 + } + return -1 +} + +func nsgKey(g *proto.NameServerGroup) string { + if g == nil { + return "" + } + var parts []string + for _, ns := range g.NameServers { + if ns == nil { + continue + } + parts = append(parts, ns.IP+":"+strconv.FormatInt(ns.Port, 10)+":"+strconv.FormatInt(ns.NSType, 10)) + } + slices.Sort(parts) + key := strings.Join(parts, ",") + domains := append([]string(nil), g.Domains...) + slices.Sort(domains) + key += "|" + strings.Join(domains, "|") + if g.Primary { + key += "|P" + } + if g.SearchDomainsEnabled { + key += "|S" + } + return key +} + +func cmpSimpleRecord(a, b *proto.SimpleRecord) int { + if a == nil || b == nil { + return boolCmp(a == nil, b == nil) + } + if c := cmp.Compare(a.Name, b.Name); c != 0 { + return c + } + if c := cmp.Compare(a.Type, b.Type); c != 0 { + return c + } + if c := cmp.Compare(a.Class, b.Class); c != 0 { + return c + } + if c := cmp.Compare(a.RData, b.RData); c != 0 { + return c + } + return cmp.Compare(a.TTL, b.TTL) +} + +func cmpRemotePeer(a, b *proto.RemotePeerConfig) int { + if a == nil || b == nil { + return boolCmp(a == nil, b == nil) + } + return cmp.Compare(a.WgPubKey, b.WgPubKey) +} + +func cmpRoute(a, b *proto.Route) int { + if a == nil || b == nil { + return boolCmp(a == nil, b == nil) + } + if c := cmp.Compare(a.ID, b.ID); c != 0 { + return c + } + if c := cmp.Compare(a.NetID, b.NetID); c != 0 { + return c + } + if c := cmp.Compare(a.Network, b.Network); c != 0 { + return c + } + if c := cmp.Compare(a.Peer, b.Peer); c != 0 { + return c + } + if c := cmp.Compare(a.Metric, b.Metric); c != 0 { + return c + } + return slices.Compare(a.Domains, b.Domains) +} + +func cmpFirewallRule(a, b *proto.FirewallRule) int { + if a == nil || b == nil { + return boolCmp(a == nil, b == nil) + } + if c := bytes.Compare(a.PolicyID, b.PolicyID); c != 0 { + return c + } + if c := cmp.Compare(a.PeerIP, b.PeerIP); c != 0 { //nolint:staticcheck + return c + } + if c := cmp.Compare(int32(a.Direction), int32(b.Direction)); c != 0 { + return c + } + if c := cmp.Compare(int32(a.Action), int32(b.Action)); c != 0 { + return c + } + if c := cmp.Compare(int32(a.Protocol), int32(b.Protocol)); c != 0 { + return c + } + if c := cmp.Compare(a.Port, b.Port); c != 0 { + return c + } + return cmp.Compare(portInfoKey(a.PortInfo), portInfoKey(b.PortInfo)) +} + +func cmpRouteFirewallRule(a, b *proto.RouteFirewallRule) int { + if a == nil || b == nil { + return boolCmp(a == nil, b == nil) + } + if c := bytes.Compare(a.PolicyID, b.PolicyID); c != 0 { + return c + } + if c := cmp.Compare(a.RouteID, b.RouteID); c != 0 { + return c + } + if c := cmp.Compare(a.Destination, b.Destination); c != 0 { + return c + } + if c := cmp.Compare(int32(a.Protocol), int32(b.Protocol)); c != 0 { + return c + } + if c := cmp.Compare(portInfoKey(a.PortInfo), portInfoKey(b.PortInfo)); c != 0 { + return c + } + if c := cmp.Compare(int32(a.Action), int32(b.Action)); c != 0 { + return c + } + if c := slices.Compare(a.Domains, b.Domains); c != 0 { + return c + } + if c := slices.Compare(a.SourceRanges, b.SourceRanges); c != 0 { + return c + } + if c := cmp.Compare(a.CustomProtocol, b.CustomProtocol); c != 0 { + return c + } + return boolCmp(a.IsDynamic, b.IsDynamic) +} + +func cmpForwardingRule(a, b *proto.ForwardingRule) int { + if a == nil || b == nil { + return boolCmp(a == nil, b == nil) + } + if c := cmp.Compare(int32(a.Protocol), int32(b.Protocol)); c != 0 { + return c + } + return bytes.Compare(a.TranslatedAddress, b.TranslatedAddress) +} + +func portInfoKey(pi *proto.PortInfo) string { + if pi == nil { + return "" + } + switch sel := pi.PortSelection.(type) { + case *proto.PortInfo_Port: + return "P" + strconv.FormatUint(uint64(sel.Port), 10) + case *proto.PortInfo_Range_: + if sel.Range == nil { + return "R" + } + return "R" + strconv.FormatUint(uint64(sel.Range.Start), 10) + "-" + strconv.FormatUint(uint64(sel.Range.End), 10) + } + return "" +} diff --git a/management/internals/controllers/network_map/nmaptest/fixture.go b/management/internals/controllers/network_map/nmaptest/fixture.go new file mode 100644 index 000000000..d56285f95 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/fixture.go @@ -0,0 +1,218 @@ +package nmaptest + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net" + "os" + + "github.com/netbirdio/netbird/shared/management/networkmap" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" +) + +// LoadNetworkMapData reads a fixture holding the NetworkMapData the store +// would return for one account. Unknown fields are rejected so fixture typos +// fail loudly instead of silently testing a default. +func LoadNetworkMapData(path string) (*networkmap.NetworkMapData, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open fixture: %w", err) + } + defer f.Close() + + dec := json.NewDecoder(f) + dec.DisallowUnknownFields() + var nmData networkmap.NetworkMapData + if err := dec.Decode(&nmData); err != nil { + return nil, fmt.Errorf("decode fixture %s: %w", path, err) + } + return &nmData, nil +} + +var defaultNetworkNet = func() net.IPNet { + _, ipnet, err := net.ParseCIDR("100.64.0.0/10") + if err != nil { + panic(err) + } + return *ipnet +}() + +// applyFixtureDefaults fills the boilerplate a fixture may omit. Map-keyed +// objects inherit their key as ID, peers get a deterministic WG-shaped key +// and their ID as DNS label, PublicIDs default to the internal ID (the +// envelope encoder puts public IDs on the wire and silently degrades on +// empty ones), and a nil ValidatedPeers validates every peer — production +// fills it through the integrated validator, not the store. +func applyFixtureDefaults(nmData *networkmap.NetworkMapData) { + if nmData.Network == nil { + nmData.Network = &nmdata.Network{} + } + if nmData.Network.Identifier == "" { + nmData.Network.Identifier = "network" + } + if nmData.Network.Net.IP == nil { + nmData.Network.Net = defaultNetworkNet + } + if nmData.AccountSettings == nil { + nmData.AccountSettings = &nmdata.AccountSettingsInfo{} + } + if nmData.DNSSettings == nil { + nmData.DNSSettings = &nmdata.DNSSettings{} + } + + for id, p := range nmData.Peers { + if p == nil { + continue + } + if p.ID == "" { + p.ID = id + } + if p.Key == "" { + p.Key = derivedWgKey(p.ID) + } + if p.DNSLabel == "" { + p.DNSLabel = p.ID + } + } + + for id, g := range nmData.Groups { + if g == nil { + continue + } + if g.ID == "" { + g.ID = id + } + if g.Name == "" { + g.Name = g.ID + } + if g.PublicID == "" { + g.PublicID = g.ID + } + } + + for _, policy := range nmData.Policies { + defaultPolicyIDs(policy) + } + resolveResourcePolicyRefs(nmData) + + for _, r := range nmData.Routes { + if r != nil && r.PublicID == "" { + r.PublicID = r.ID + } + } + for _, nsg := range nmData.NameServerGroups { + if nsg != nil && nsg.PublicID == "" { + nsg.PublicID = nsg.ID + } + } + for _, res := range nmData.NetworkResources { + if res == nil { + continue + } + if res.PublicID == "" { + res.PublicID = res.ID + } + defaultXIDMapping(&nmData.NetworkXIDToPublicID, res.NetworkID) + } + for networkID, routers := range nmData.Routers { + defaultXIDMapping(&nmData.NetworkXIDToPublicID, networkID) + for _, router := range routers { + if router != nil && router.PublicID == "" { + router.PublicID = networkID + } + } + } + + for id, pc := range nmData.PostureChecks { + if pc == nil { + continue + } + if pc.ID == "" { + pc.ID = id + } + defaultXIDMapping(&nmData.PostureCheckXIDToPublicID, pc.ID) + } + + if nmData.ValidatedPeers == nil { + nmData.ValidatedPeers = make(map[string]struct{}, len(nmData.Peers)) + for id := range nmData.Peers { + nmData.ValidatedPeers[id] = struct{}{} + } + } +} + +// resolveResourcePolicyRefs lets a fixture name an account policy by ID in +// ResourcePolicies — {"ID": "pol-x"} with no rules — instead of repeating it. +// The real store puts the same policy pointer in both places, which is what +// resolving the reference reproduces. +func resolveResourcePolicyRefs(nmData *networkmap.NetworkMapData) { + byID := make(map[string]*nmdata.Policy, len(nmData.Policies)) + for _, policy := range nmData.Policies { + if policy != nil && policy.ID != "" { + byID[policy.ID] = policy + } + } + + for _, policies := range nmData.ResourcePolicies { + for i, policy := range policies { + if policy == nil { + continue + } + if len(policy.Rules) == 0 { + if full, ok := byID[policy.ID]; ok { + policies[i] = full + continue + } + } + defaultPolicyIDs(policy) + } + } +} + +func defaultPolicyIDs(policy *nmdata.Policy) { + if policy == nil { + return + } + if policy.PublicID == "" { + policy.PublicID = policy.ID + } + for i, rule := range policy.Rules { + if rule == nil { + continue + } + if rule.PolicyID == "" { + rule.PolicyID = policy.ID + } + if rule.ID == "" { + // Production gives a rule its policy's id (management/server/policy.go:205, + // "when policy can contain multiple rules, need refactor"), so a + // single-rule policy — the only shape the product can create today — + // must be modelled that way or the wire ids come out unrealistic. + rule.ID = policy.ID + if len(policy.Rules) > 1 { + rule.ID = fmt.Sprintf("%s-rule-%d", policy.ID, i) + } + } + } +} + +func defaultXIDMapping(m *map[string]string, id string) { + if id == "" { + return + } + if *m == nil { + *m = make(map[string]string) + } + if _, ok := (*m)[id]; !ok { + (*m)[id] = id + } +} + +// derivedWgKey returns a deterministic base64 key of 32 bytes, valid for the +// envelope decoder's WG-key identity. +func derivedWgKey(peerID string) string { + sum := sha256.Sum256([]byte(peerID)) + return base64.StdEncoding.EncodeToString(sum[:]) +} diff --git a/management/internals/controllers/network_map/nmaptest/golden_test.go b/management/internals/controllers/network_map/nmaptest/golden_test.go new file mode 100644 index 000000000..75c0d57d2 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/golden_test.go @@ -0,0 +1,12 @@ +package nmaptest_test + +import ( + "path/filepath" + "testing" + + "github.com/netbirdio/netbird/management/internals/controllers/network_map/nmaptest" +) + +func TestNetworkMapGolden(t *testing.T) { + nmaptest.RunGoldenDir(t, filepath.Join("testdata", "cases")) +} diff --git a/management/internals/controllers/network_map/nmaptest/legacyaccount.go b/management/internals/controllers/network_map/nmaptest/legacyaccount.go new file mode 100644 index 000000000..d6a653f7a --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/legacyaccount.go @@ -0,0 +1,543 @@ +package nmaptest + +import ( + "context" + "strings" + "testing" + + "github.com/miekg/dns" + "github.com/stretchr/testify/require" + + nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache" + "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" + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" + routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" + networkTypes "github.com/netbirdio/netbird/management/server/networks/types" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/posture" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/management/server/types/legacynmap" + nbroute "github.com/netbirdio/netbird/route" + "github.com/netbirdio/netbird/shared/management/networkmap" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/netbirdio/netbird/shared/management/proto" + sharedtypes "github.com/netbirdio/netbird/shared/management/types" +) + +// legacyInput is the account and the four derived arguments main's computation +// took alongside it. The controller resolved them from the account before +// calling; the twin carries them as fields, so the fixture is the source for +// both halves. +type legacyInput struct { + account *types.Account + accountZones []*zones.Zone + validatedPeers map[string]struct{} + resourcePolicies map[string][]*types.Policy + routers map[string]map[string]*routerTypes.NetworkRouter + groupIDToUserIDs map[string][]string +} + +// legacyInputFromData rebuilds the Account the fixture stands for. A fixture is +// the value the store returns, and the store's twins carry exactly the state +// the computation reads, so inverting them reproduces the account main would +// have loaded — which is what lets one expectation measure all three paths. +// +// The inverse is only defined for what a twin carries: fields the builders drop +// (peer names, policy descriptions, user records behind AllowedUserIDs) come +// back as the zero value or a minimal stand-in, because no path reads them. +func legacyInputFromData(accountID string, nmData *networkmap.NetworkMapData) legacyInput { + account := &types.Account{ + Id: accountID, + Network: accountNetwork(nmData.Network), + Settings: accountSettings(nmData.AccountSettings), + DNSSettings: types.DNSSettings{DisabledManagementGroups: nmData.DNSSettings.DisabledManagementGroups}, + Peers: make(map[string]*nbpeer.Peer, len(nmData.Peers)), + Groups: make(map[string]*types.Group, len(nmData.Groups)), + Policies: make([]*types.Policy, 0, len(nmData.Policies)), + Routes: make(map[nbroute.ID]*nbroute.Route, len(nmData.Routes)), + NameServerGroups: make(map[string]*nbdns.NameServerGroup, len(nmData.NameServerGroups)), + NetworkResources: make([]*resourceTypes.NetworkResource, 0, len(nmData.NetworkResources)), + PostureChecks: make([]*posture.Checks, 0, len(nmData.PostureChecks)), + Users: make(map[string]*types.User, len(nmData.AllowedUserIDs)), + Services: accountServices(nmData.Services), + } + + for id, p := range nmData.Peers { + account.Peers[id] = accountPeer(id, p) + } + for id, g := range nmData.Groups { + account.Groups[id] = accountGroup(id, g) + } + + policiesByID := make(map[string]*types.Policy, len(nmData.Policies)) + for _, p := range nmData.Policies { + policy := accountPolicy(p) + if policy == nil { + continue + } + account.Policies = append(account.Policies, policy) + policiesByID[policy.ID] = policy + } + + for _, r := range nmData.Routes { + route := accountRoute(r) + if route != nil { + account.Routes[route.ID] = route + } + } + for _, nsg := range nmData.NameServerGroups { + group := accountNSG(nsg) + if group != nil { + account.NameServerGroups[group.ID] = group + } + } + for _, res := range nmData.NetworkResources { + if resource := accountNetworkResource(res); resource != nil { + account.NetworkResources = append(account.NetworkResources, resource) + } + } + for id, pc := range nmData.PostureChecks { + if check := accountPostureChecks(id, pc, nmData.PostureCheckXIDToPublicID[id]); check != nil { + account.PostureChecks = append(account.PostureChecks, check) + } + } + for xid, publicID := range nmData.NetworkXIDToPublicID { + account.Networks = append(account.Networks, &networkTypes.Network{ID: xid, PublicID: publicID}) + } + // The twin keeps only the ids of the users a peer may be shared with; the + // legacy side derives the same set from the account's user records, so a + // bare non-blocked regular user per id is enough. + for userID := range nmData.AllowedUserIDs { + account.Users[userID] = &types.User{Id: userID} + } + + // Main's network-map controller synthesised the reverse-proxy ACLs onto the + // account and only then derived the resource-policy map, so the frozen copy + // has to be fed in that order to stand for what main produced. + account.Policies = append(account.Policies, legacynmap.SynthesizeProxyPolicies(account)...) + + return legacyInput{ + account: account, + accountZones: accountZones(nmData.AppliedZoneCandidates), + validatedPeers: nmData.ValidatedPeers, + resourcePolicies: account.GetResourcePoliciesMap(), + routers: accountRouters(nmData.Routers), + groupIDToUserIDs: nmData.GroupIDToUserIDs, + } +} + +// computeLegacy runs the fixture through main's frozen path and its own proto +// encoder, the one comparison surface the three modes share. +func computeLegacy(t *testing.T, ctx context.Context, legacy legacyInput, peerID string, zone nmdata.CustomZone, dnsDomain string, dnsFwdPort int64) *proto.NetworkMap { + t.Helper() + + require.NotNil(t, legacy.account, "legacy mode needs an account rebuilt from the fixture") + peer := legacy.account.Peers[peerID] + require.NotNil(t, peer, "target peer %q not in rebuilt account", peerID) + + nm := legacynmap.GetPeerNetworkMapFromComponents( + legacy.account, ctx, peerID, legacyCustomZone(zone), legacy.accountZones, legacy.validatedPeers, + legacy.resourcePolicies, legacy.routers, nil, legacy.groupIDToUserIDs, + ) + require.NotNil(t, nm, "legacy path returned no network map for peer %q", peerID) + + return legacynmap.ToProtoNetworkMap( + ctx, peer, nm, dnsDomain, legacy.account.Settings, nil, &cache.DNSConfigCache{}, dnsFwdPort, + ) +} + +// legacyCustomZone converts the peers custom zone the runner computes once for +// every mode into the shape main's path took. +func legacyCustomZone(z nmdata.CustomZone) nbdns.CustomZone { + zoneRecords := make([]nbdns.SimpleRecord, 0, len(z.Records)) + for _, r := range z.Records { + zoneRecords = append(zoneRecords, nbdns.SimpleRecord{ + Name: r.Name, + Type: r.Type, + Class: r.Class, + TTL: r.TTL, + RData: r.RData, + }) + } + return nbdns.CustomZone{ + Domain: z.Domain, + Records: zoneRecords, + SearchDomainDisabled: z.SearchDomainDisabled, + NonAuthoritative: z.NonAuthoritative, + } +} + +func accountNetwork(n *nmdata.Network) *types.Network { + if n == nil { + return nil + } + return &types.Network{ + Identifier: n.Identifier, + Net: n.Net, + NetV6: n.NetV6, + Dns: n.Dns, + Serial: uint64(n.Serial), + } +} + +func accountSettings(s *nmdata.AccountSettingsInfo) *types.Settings { + if s == nil { + return nil + } + return &types.Settings{ + PeerLoginExpirationEnabled: s.PeerLoginExpirationEnabled, + PeerLoginExpiration: s.PeerLoginExpiration, + PeerInactivityExpirationEnabled: s.PeerInactivityExpirationEnabled, + PeerInactivityExpiration: s.PeerInactivityExpiration, + DNSDomain: s.DNSDomain, + IPv6EnabledGroups: s.IPv6EnabledGroups, + RoutingPeerDNSResolutionEnabled: s.RoutingPeerDNSResolutionEnabled, + LazyConnectionEnabled: s.LazyConnectionEnabled, + AutoUpdateVersion: s.AutoUpdateVersion, + AutoUpdateAlways: s.AutoUpdateAlways, + MetricsPushEnabled: s.MetricsPushEnabled, + } +} + +func accountPeer(id string, p *nmdata.Peer) *nbpeer.Peer { + if p == nil { + return nil + } + networkAddresses := make([]nbpeer.NetworkAddress, 0, len(p.Meta.NetworkAddresses)) + for _, na := range p.Meta.NetworkAddresses { + networkAddresses = append(networkAddresses, nbpeer.NetworkAddress{NetIP: na.NetIP}) + } + files := make([]nbpeer.File, 0, len(p.Meta.Files)) + for _, f := range p.Meta.Files { + files = append(files, nbpeer.File{Path: f.Path, ProcessIsRunning: f.ProcessIsRunning}) + } + return &nbpeer.Peer{ + ID: id, + Key: p.Key, + SSHKey: p.SSHKey, + DNSLabel: p.DNSLabel, + UserID: p.UserID, + SSHEnabled: p.SSHEnabled, + LoginExpirationEnabled: p.LoginExpirationEnabled, + LastLogin: p.LastLogin, + IP: p.IP, + IPv6: p.IPv6, + ExtraDNSLabels: p.ExtraDNSLabels, + ProxyMeta: nbpeer.ProxyMeta{Embedded: p.ProxyMeta.Embedded, Cluster: p.ProxyMeta.Cluster}, + // Connected is what SynthesizePrivateServiceZones gates its records on, + // and a fixture peer stands for a peer the store returned, so it is one + // the account would have reported connected. + Status: &nbpeer.PeerStatus{RequiresApproval: p.RequiresApproval, Connected: true}, + Meta: nbpeer.PeerSystemMeta{ + WtVersion: p.Meta.WtVersion, + GoOS: p.Meta.GoOS, + OSVersion: p.Meta.OSVersion, + KernelVersion: p.Meta.KernelVersion, + NetworkAddresses: networkAddresses, + Files: files, + Capabilities: p.Meta.Capabilities, + SyncMessageVersion: p.Meta.SyncMessageVersion, + Flags: nbpeer.Flags{ + ServerSSHAllowed: p.Meta.Flags.ServerSSHAllowed, + DisableIPv6: p.Meta.Flags.DisableIPv6, + }, + }, + Location: nbpeer.Location{ + CountryCode: p.Location.CountryCode, + CityName: p.Location.CityName, + ConnectionIP: p.Location.ConnectionIP, + }, + } +} + +func accountGroup(id string, g *nmdata.Group) *types.Group { + if g == nil { + return nil + } + return &types.Group{ + ID: id, + Name: g.Name, + PublicID: g.PublicID, + Peers: g.Peers, + } +} + +func accountPolicy(p *nmdata.Policy) *types.Policy { + if p == nil { + return nil + } + rules := make([]*types.PolicyRule, 0, len(p.Rules)) + for _, r := range p.Rules { + if r == nil { + continue + } + var portRanges []sharedtypes.RulePortRange + if r.PortRanges != nil { + portRanges = make([]sharedtypes.RulePortRange, len(r.PortRanges)) + for i, pr := range r.PortRanges { + portRanges[i] = sharedtypes.RulePortRange{Start: pr.Start, End: pr.End} + } + } + rules = append(rules, &types.PolicyRule{ + ID: r.ID, + PolicyID: r.PolicyID, + Enabled: r.Enabled, + Action: sharedtypes.PolicyTrafficActionType(r.Action), + Protocol: sharedtypes.PolicyRuleProtocolType(r.Protocol), + Bidirectional: r.Bidirectional, + Sources: r.Sources, + Destinations: r.Destinations, + SourceResource: types.Resource{ID: r.SourceResource.ID, Type: sharedtypes.ResourceType(r.SourceResource.Type)}, + DestinationResource: types.Resource{ID: r.DestinationResource.ID, Type: sharedtypes.ResourceType(r.DestinationResource.Type)}, + Ports: r.Ports, + PortRanges: portRanges, + AuthorizedGroups: r.AuthorizedGroups, + AuthorizedUser: r.AuthorizedUser, + }) + } + return &types.Policy{ + ID: p.ID, + PublicID: p.PublicID, + Enabled: p.Enabled, + SourcePostureChecks: p.SourcePostureChecks, + Rules: rules, + } +} + +func accountRoute(r *nmdata.Route) *nbroute.Route { + if r == nil { + return nil + } + return &nbroute.Route{ + ID: nbroute.ID(r.ID), + AccountID: r.AccountID, + PublicID: r.PublicID, + Network: r.Network, + Domains: r.Domains, + KeepRoute: r.KeepRoute, + NetID: nbroute.NetID(r.NetID), + Description: r.Description, + Peer: r.Peer, + PeerID: r.PeerID, + PeerGroups: r.PeerGroups, + NetworkType: nbroute.NetworkType(r.NetworkType), + Masquerade: r.Masquerade, + Metric: r.Metric, + Enabled: r.Enabled, + Groups: r.Groups, + AccessControlGroups: r.AccessControlGroups, + SkipAutoApply: r.SkipAutoApply, + } +} + +func accountNSG(n *nmdata.NameServerGroup) *nbdns.NameServerGroup { + if n == nil { + return nil + } + nameServers := make([]nbdns.NameServer, 0, len(n.NameServers)) + for _, ns := range n.NameServers { + nameServers = append(nameServers, nbdns.NameServer{ + IP: ns.IP, + NSType: nbdns.NameServerType(ns.NSType), + Port: ns.Port, + }) + } + return &nbdns.NameServerGroup{ + ID: n.ID, + PublicID: n.PublicID, + Name: n.Name, + Description: n.Description, + NameServers: nameServers, + Groups: n.Groups, + Primary: n.Primary, + Domains: n.Domains, + Enabled: n.Enabled, + SearchDomainsEnabled: n.SearchDomainsEnabled, + } +} + +func accountNetworkResource(r *nmdata.NetworkResource) *resourceTypes.NetworkResource { + if r == nil { + return nil + } + return &resourceTypes.NetworkResource{ + ID: r.ID, + NetworkID: r.NetworkID, + AccountID: r.AccountID, + PublicID: r.PublicID, + Name: r.Name, + Description: r.Description, + Type: resourceTypes.NetworkResourceType(r.Type), + Address: r.Address, + Domain: r.Domain, + Prefix: r.Prefix, + Enabled: r.Enabled, + } +} + +func accountPostureChecks(id string, pc *nmdata.PostureChecks, publicID string) *posture.Checks { + if pc == nil { + return nil + } + out := &posture.Checks{ID: id, PublicID: publicID} + def := pc.Checks + if def.NBVersionCheck != nil { + out.Checks.NBVersionCheck = &posture.NBVersionCheck{MinVersion: def.NBVersionCheck.MinVersion} + } + if def.OSVersionCheck != nil { + oc := &posture.OSVersionCheck{} + if def.OSVersionCheck.Android != nil { + oc.Android = &posture.MinVersionCheck{MinVersion: def.OSVersionCheck.Android.MinVersion} + } + if def.OSVersionCheck.Darwin != nil { + oc.Darwin = &posture.MinVersionCheck{MinVersion: def.OSVersionCheck.Darwin.MinVersion} + } + if def.OSVersionCheck.Ios != nil { + oc.Ios = &posture.MinVersionCheck{MinVersion: def.OSVersionCheck.Ios.MinVersion} + } + if def.OSVersionCheck.Linux != nil { + oc.Linux = &posture.MinKernelVersionCheck{MinKernelVersion: def.OSVersionCheck.Linux.MinKernelVersion} + } + if def.OSVersionCheck.Windows != nil { + oc.Windows = &posture.MinKernelVersionCheck{MinKernelVersion: def.OSVersionCheck.Windows.MinKernelVersion} + } + out.Checks.OSVersionCheck = oc + } + if def.GeoLocationCheck != nil { + gc := &posture.GeoLocationCheck{Action: def.GeoLocationCheck.Action} + for _, loc := range def.GeoLocationCheck.Locations { + gc.Locations = append(gc.Locations, posture.Location{CountryCode: loc.CountryCode, CityName: loc.CityName}) + } + out.Checks.GeoLocationCheck = gc + } + if def.PeerNetworkRangeCheck != nil { + out.Checks.PeerNetworkRangeCheck = &posture.PeerNetworkRangeCheck{ + Action: def.PeerNetworkRangeCheck.Action, + Ranges: def.PeerNetworkRangeCheck.Ranges, + } + } + if def.ProcessCheck != nil { + procs := make([]posture.Process, 0, len(def.ProcessCheck.Processes)) + for _, p := range def.ProcessCheck.Processes { + procs = append(procs, posture.Process{LinuxPath: p.LinuxPath, MacPath: p.MacPath, WindowsPath: p.WindowsPath}) + } + out.Checks.ProcessCheck = &posture.ProcessCheck{Processes: procs} + } + return out +} + +func accountServices(services []*nmdata.Service) []*service.Service { + if len(services) == 0 { + return nil + } + out := make([]*service.Service, 0, len(services)) + for _, svc := range services { + if svc == nil { + continue + } + targets := make([]*service.Target, 0, len(svc.Targets)) + for _, t := range svc.Targets { + if t == nil { + continue + } + target := &service.Target{ + Enabled: t.Enabled, + Port: t.Port, + Protocol: t.Protocol, + TargetId: t.TargetID, + TargetType: service.TargetType(t.TargetType), + } + if t.Path != "" { + path := t.Path + target.Path = &path + } + targets = append(targets, target) + } + out = append(out, &service.Service{ + ID: svc.ID, + Enabled: svc.Enabled, + Private: svc.Private, + Mode: svc.Mode, + ProxyCluster: svc.ProxyCluster, + AccessGroups: svc.AccessGroups, + Targets: targets, + }) + } + return out +} + +// accountZones inverts buildAppliedZoneCandidates. Records come back with the +// record type the builder mapped them from; a candidate only ever carries the +// three types it converts. +func accountZones(candidates []networkmap.AppliedZoneCandidate) []*zones.Zone { + if len(candidates) == 0 { + return nil + } + out := make([]*zones.Zone, 0, len(candidates)) + for _, candidate := range candidates { + zoneRecords := make([]*records.Record, 0, len(candidate.Zone.Records)) + for _, r := range candidate.Zone.Records { + recordType, ok := zoneRecordType(r.Type) + if !ok { + continue + } + zoneRecords = append(zoneRecords, &records.Record{ + Name: strings.TrimSuffix(r.Name, "."), + Type: recordType, + Content: r.RData, + TTL: r.TTL, + }) + } + out = append(out, &zones.Zone{ + ID: candidate.Zone.Domain, + Domain: strings.TrimSuffix(candidate.Zone.Domain, "."), + Enabled: true, + EnableSearchDomain: !candidate.Zone.SearchDomainDisabled, + DistributionGroups: candidate.DistributionGroups, + Records: zoneRecords, + }) + } + return out +} + +func zoneRecordType(recordType int) (records.RecordType, bool) { + switch uint16(recordType) { + case dns.TypeA: + return records.RecordTypeA, true + case dns.TypeAAAA: + return records.RecordTypeAAAA, true + case dns.TypeCNAME: + return records.RecordTypeCNAME, true + default: + return "", false + } +} + +func accountRouters(routers map[string]map[string]*nmdata.NetworkRouter) map[string]map[string]*routerTypes.NetworkRouter { + if len(routers) == 0 { + return nil + } + out := make(map[string]map[string]*routerTypes.NetworkRouter, len(routers)) + for networkID, inner := range routers { + converted := make(map[string]*routerTypes.NetworkRouter, len(inner)) + for peerID, router := range inner { + if router == nil { + continue + } + converted[peerID] = &routerTypes.NetworkRouter{ + NetworkID: networkID, + PublicID: router.PublicID, + Peer: peerID, + PeerGroups: router.PeerGroups, + Masquerade: router.Masquerade, + Metric: router.Metric, + Enabled: router.Enabled, + } + } + out[networkID] = converted + } + return out +} diff --git a/management/internals/controllers/network_map/nmaptest/runner.go b/management/internals/controllers/network_map/nmaptest/runner.go new file mode 100644 index 000000000..c70bd7298 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/runner.go @@ -0,0 +1,332 @@ +// Package nmaptest measures network map generation on the dedicated store +// path against committed expectations. A case stands in for the store load +// with a NetworkMapData fixture — the value NetworkMapDBStoreImpl returns for +// one account — then runs the production per-peer pipeline the controller +// uses, PeersCustomZone → GetPeerNetworkMapComponents → proto conversion, in +// both wire shapes: the full map (grpc.ToSyncResponse) and the component +// envelope expanded client-side (grpc.ToComponentSyncResponse → +// networkmap.EnvelopeToNetworkMap). A third mode inverts the fixture back into +// the Account it stands for and runs main's frozen path over it (legacynmap), +// so every case is pinned to what main shipped as well. +// +// The expectation files are the point of the framework. They state what the +// output should be, so a failing case means the code disagrees with the +// expectation and the answer is normally to fix the code; an expectation +// changes only through a deliberate reviewed edit. Nothing in this package +// writes to testdata — there is no flag that records current behaviour into an +// expectation, because that is how a defect becomes the baseline. Cases whose +// expectation encodes correct behaviour the code does not yet deliver stay red +// on purpose. +// +// A case lives in testdata/cases/ / as case.json (manifest: description, +// peers, optional accountID, dnsDomain, modes), nmdata.json (the fixture the +// mocked store returns, using Go field names; zero values may be omitted and +// applyFixtureDefaults fills the boilerplate) and golden/ .json. +// +// There is ONE expectation per peer, shared by every mode, because all three +// must arrive at the same client-facing map. Full and envelope are not even +// different computations — CalculateNetworkMapFromComponents is +// components.Calculate and both assemble the proto with the same encode +// helpers — so the only variable between them is what the envelope round-trip +// did in transit, and a difference there is a round-trip fidelity defect. +// Legacy is a different computation, main's, reached from a rebuilt account; +// a difference there is this tree having drifted from what main shipped. +// Results are canonicalized before comparison, since repeated proto fields +// come from map iteration. +package nmaptest + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/require" + "golang.org/x/exp/maps" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/testing/protocmp" + + "github.com/netbirdio/netbird/management/internals/controllers/network_map" + "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller" + "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache" + mgmtgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" + "github.com/netbirdio/netbird/shared/management/networkmap" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// Mode selects the wire shape a case is verified through. Both end in a +// *proto.NetworkMap, the one comparison surface shared by every path. +type Mode string + +const ( + // ModeFull is the legacy wire shape: the server runs Calculate and sends + // the expanded map (grpc.ToSyncResponse). + ModeFull Mode = "full" + // ModeEnvelope is the component wire shape: the server encodes components + // into a NetworkMapEnvelope (grpc.ToComponentSyncResponse) and the map is + // expanded the way the client engine does (networkmap.EnvelopeToNetworkMap). + ModeEnvelope Mode = "envelope" + // ModeLegacy is main's frozen path: the fixture is inverted back into the + // Account it stands for and run through legacynmap, the copy of what main + // shipped. It is the outside measurement — the other two modes share this + // tree's computation, so only this one can catch the whole tree drifting. + ModeLegacy Mode = "legacy" + + defaultAccountID = "account" + defaultDNSDomain = "netbird.test" +) + +var defaultModes = []Mode{ModeFull, ModeEnvelope, ModeLegacy} + +// Case is one nmap-generation scenario: store data for a single account, the +// peers whose network maps are computed, and the directory holding one expected +// *proto.NetworkMap per peer — shared by every mode. +type Case struct { + Name string + AccountID string + DNSDomain string + Peers []string + Modes []Mode + Data *networkmap.NetworkMapData + GoldenDir string +} + +type manifest struct { + Description string + AccountID string + DNSDomain string + Peers []string + Modes []Mode +} + +// RunGoldenDir discovers and runs every fixture case under dir. A case is a +// directory containing case.json (manifest), nmdata.json (store fixture) and +// golden/ .json (expected proto.NetworkMap, protojson). +func RunGoldenDir(t *testing.T, dir string) { + t.Helper() + + entries, err := os.ReadDir(dir) + require.NoError(t, err, "read cases dir") + + ran := 0 + for _, entry := range entries { + if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { + continue + } + caseDir := filepath.Join(dir, entry.Name()) + c, err := loadCase(caseDir) + require.NoError(t, err, "load case %s", entry.Name()) + ran++ + t.Run(entry.Name(), func(t *testing.T) { + RunCase(t, c) + }) + } + require.NotZero(t, ran, "no cases found under %s", dir) +} + +func loadCase(caseDir string) (Case, error) { + raw, err := os.ReadFile(filepath.Join(caseDir, "case.json")) + if err != nil { + return Case{}, fmt.Errorf("read manifest: %w", err) + } + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + var m manifest + if err := dec.Decode(&m); err != nil { + return Case{}, fmt.Errorf("decode manifest: %w", err) + } + + data, err := LoadNetworkMapData(filepath.Join(caseDir, "nmdata.json")) + if err != nil { + return Case{}, err + } + + return Case{ + Name: filepath.Base(caseDir), + AccountID: m.AccountID, + DNSDomain: m.DNSDomain, + Peers: m.Peers, + Modes: m.Modes, + Data: data, + GoldenDir: filepath.Join(caseDir, "golden"), + }, nil +} + +// RunCase computes each target peer's network map through every enabled mode +// and compares the canonicalized result against the peer's expectation file. +// It mirrors the controller's store path: fill fixture defaults, precompute +// posture validation once, then run the per-peer pipeline. +func RunCase(t *testing.T, c Case) { + t.Helper() + + require.NotNil(t, c.Data, "case %s: Data is required", c.Name) + require.NotEmpty(t, c.Peers, "case %s: Peers is required", c.Name) + require.NotEmpty(t, c.GoldenDir, "case %s: GoldenDir is required", c.Name) + if c.AccountID == "" { + c.AccountID = defaultAccountID + } + if c.DNSDomain == "" { + c.DNSDomain = defaultDNSDomain + } + if len(c.Modes) == 0 { + c.Modes = defaultModes + } + + ctx := context.Background() + nmData := c.Data + applyFixtureDefaults(nmData) + nmData.PrecomputePostureValidation() + + dnsDomain := c.DNSDomain + if nmData.AccountSettings.DNSDomain != "" { + dnsDomain = nmData.AccountSettings.DNSDomain + } + + zone := networkmap.PeersCustomZone(ctx, c.AccountID, dnsDomain, nmData.Peers, controller.IPv6AllowedPeersFromData(nmData)) + dnsFwdPort := controller.ComputeForwarderPortFromData(nmData.Peers, network_map.DnsForwarderPortMinVersion) + + for _, mode := range c.Modes { + if mode == ModeEnvelope { + requireEnvelopeSafeKeys(t, nmData, c.Name) + break + } + } + + // Built before any mode runs: the first per-peer computation injects the + // synthesised proxy ACLs into the twin's policies, and the legacy side + // synthesises its own, so inverting a twin that already carries them would + // hand the legacy path each ACL twice. + var legacy legacyInput + if slices.Contains(c.Modes, ModeLegacy) { + legacy = legacyInputFromData(c.AccountID, nmData) + } + + for _, peerID := range c.Peers { + peer := nmData.Peers[peerID] + require.NotNil(t, peer, "case %s: target peer %q not in fixture", c.Name, peerID) + + for _, mode := range c.Modes { + t.Run(peerID+"/"+string(mode), func(t *testing.T) { + got := computeMode(t, ctx, mode, nmData, peerID, zone, dnsDomain, dnsFwdPort, legacy) + canonicalize(got) + compareGolden(t, filepath.Join(c.GoldenDir, peerID+".json"), got, mode) + }) + } + } +} + +// computeMode produces the peer's proto.NetworkMap the way the controller does +// for that wire shape. +func computeMode(t *testing.T, ctx context.Context, mode Mode, nmData *networkmap.NetworkMapData, + peerID string, zone nmdata.CustomZone, dnsDomain string, dnsFwdPort int64, legacy legacyInput) *proto.NetworkMap { + t.Helper() + + peer := nmData.Peers[peerID] + require.NotNil(t, peer, "target peer %q not in fixture", peerID) + + switch mode { + case ModeLegacy: + return computeLegacy(t, ctx, legacy, peerID, zone, dnsDomain, dnsFwdPort) + case ModeFull: + nmap := controller.NetworkMapFromData(ctx, nmData, peerID, zone, nil) + return mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, peer, nil, nil, nmap, dnsDomain, nil, + &cache.DNSConfigCache{}, nmData.AccountSettings, nil, nil, dnsFwdPort).NetworkMap + case ModeEnvelope: + components := nmData.GetPeerNetworkMapComponents(peerID, zone) + peerGroups := maps.Keys(nmData.GetPeerGroups(peerID)) + resp := mgmtgrpc.ToComponentSyncResponse(ctx, nil, nil, nil, peer, nil, nil, components, nil, + dnsDomain, nil, nmData.AccountSettings, nil, peerGroups, dnsFwdPort) + res, err := networkmap.EnvelopeToNetworkMap(ctx, resp.NetworkMapEnvelope, peer.Key, dnsDomain) + require.NoError(t, err, "expand envelope") + return res.NetworkMap + default: + t.Fatalf("unknown mode %q", mode) + return nil + } +} + +// requireEnvelopeSafeKeys fails fast on peer keys the envelope decoder would +// silently drop: it re-keys peers by base64 of the raw 32-byte WG public key. +func requireEnvelopeSafeKeys(t *testing.T, nmData *networkmap.NetworkMapData, caseName string) { + t.Helper() + for id, p := range nmData.Peers { + if p == nil { + continue + } + raw, err := base64.StdEncoding.DecodeString(p.Key) + if err != nil || len(raw) != 32 { + t.Fatalf("case %s: peer %q Key must be base64 of 32 bytes for mode %q (the envelope decoder drops it otherwise); use a real WireGuard public key or restrict the case to mode %q", + caseName, id, ModeEnvelope, ModeFull) + } + } +} + +// compareGolden measures got against the committed expectation file. One +// expectation serves every mode, because the modes run the same computation and +// must therefore agree. The expectation is the authority: a mismatch means the +// code does not produce what this case says it should, so it is reported as a +// failure and not quietly absorbed. +// +// The full and legacy modes are compared verbatim, identifiers included, so the +// expectation pins real ids and stays readable. The envelope mode has +// identifiers erased on both sides first, because it currently rewrites them — +// a tracked defect that TestIDSpaceMatches asserts against on its own, so it +// does not have to drown out every other case here. +// Nothing here writes to testdata. Expectation files are authored by hand and +// only ever change through a reviewed edit, so there is no mode in which a run +// can create or replace one. When a file is missing the computed map is printed +// for the author to read and, if it is genuinely correct, save deliberately. +func compareGolden(t *testing.T, path string, got *proto.NetworkMap, mode Mode) { + t.Helper() + + if mode == ModeEnvelope { + normalizeIDSpace(got) + canonicalize(got) + } + + raw, err := os.ReadFile(path) + if err != nil { + rendered, mErr := renderNetworkMap(got) + require.NoError(t, mErr) + t.Fatalf("no expectation file %s: %v\nThis case has nothing to measure against — write the "+ + "proto.NetworkMap this peer should receive. Mode %s currently produces:\n%s\nRead it before "+ + "saving any of it: if the code is wrong, so is this.", path, err, mode, rendered) + } + want := &proto.NetworkMap{} + require.NoError(t, protojson.Unmarshal(raw, want), "parse expectation %s", path) + canonicalize(want) + if mode == ModeEnvelope { + normalizeIDSpace(want) + canonicalize(want) + } + + if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" { + t.Errorf("mode %s does not produce what %s expects (-want +got):\n%s\n"+ + "Every mode has to deliver the same client-facing map for the same account state. "+ + "The expectation file is the committed statement of correct output — fix the code, or change the "+ + "expectation deliberately if the intended behaviour really moved.", mode, path, diff) + } +} + +// renderNetworkMap renders stable protojson: protojson output whitespace is +// deliberately unstable, so it is reformatted through json.Indent. +func renderNetworkMap(nm *proto.NetworkMap) ([]byte, error) { + raw, err := protojson.Marshal(nm) + if err != nil { + return nil, err + } + var buf bytes.Buffer + if err := json.Indent(&buf, raw, "", " "); err != nil { + return nil, err + } + buf.WriteByte('\n') + return buf.Bytes(), nil +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/case.json new file mode 100644 index 000000000..4747e9640 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/case.json @@ -0,0 +1,7 @@ +{ + "description": "Two groups joined by one allow-all policy; peer-c has SSH enabled so the legacy-SSH path fills SshAuth from AllowedUserIDs.", + "peers": [ + "peer-a", + "peer-c" + ] +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/golden/peer-a.json new file mode 100644 index 000000000..e2b69276c --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/golden/peer-a.json @@ -0,0 +1,65 @@ +{ + "Serial": "5", + "peerConfig": { + "address": "100.64.0.1/10", + "sshConfig": {}, + "fqdn": "peer-a.netbird.test", + "RoutingPeerDnsResolutionEnabled": true, + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "4deEImv8zGvsyBmmfC2G0eQkbyMzyGuz/YK7pcYETwM=", + "allowedIps": [ + "100.64.0.3/32" + ], + "sshConfig": { + "sshPubKey": "c3NoLXBlZXItYw==" + }, + "fqdn": "peer-c.netbird.test", + "agentVersion": "0.60.0" + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-a.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + }, + { + "Name": "peer-c.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.3" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "FirewallRules": [ + { + "PeerIP": "100.64.0.3", + "Protocol": "ALL", + "PolicyID": "cG9sLWFsbA==" + }, + { + "PeerIP": "100.64.0.3", + "Direction": "OUT", + "Protocol": "ALL", + "PolicyID": "cG9sLWFsbA==" + } + ], + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/golden/peer-c.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/golden/peer-c.json new file mode 100644 index 000000000..4c358b163 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/golden/peer-c.json @@ -0,0 +1,102 @@ +{ + "Serial": "5", + "peerConfig": { + "address": "100.64.0.3/10", + "sshConfig": { + "sshEnabled": true + }, + "fqdn": "peer-c.netbird.test", + "RoutingPeerDnsResolutionEnabled": true, + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "AvldyrZ12Pf90jzf3AXmhPwg3UcI+jtJHfbpBlupvko=", + "allowedIps": [ + "100.64.0.2/32" + ], + "sshConfig": {}, + "fqdn": "peer-b.netbird.test", + "agentVersion": "0.60.0" + }, + { + "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=", + "allowedIps": [ + "100.64.0.1/32" + ], + "sshConfig": {}, + "fqdn": "peer-a.netbird.test", + "agentVersion": "0.60.0" + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-a.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + }, + { + "Name": "peer-b.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.2" + }, + { + "Name": "peer-c.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.3" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "FirewallRules": [ + { + "PeerIP": "100.64.0.1", + "Protocol": "ALL", + "PolicyID": "cG9sLWFsbA==" + }, + { + "PeerIP": "100.64.0.1", + "Direction": "OUT", + "Protocol": "ALL", + "PolicyID": "cG9sLWFsbA==" + }, + { + "PeerIP": "100.64.0.2", + "Protocol": "ALL", + "PolicyID": "cG9sLWFsbA==" + }, + { + "PeerIP": "100.64.0.2", + "Direction": "OUT", + "Protocol": "ALL", + "PolicyID": "cG9sLWFsbA==" + } + ], + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub", + "AuthorizedUsers": [ + "u9dHvAXZJKiXITuwP9jD/A==" + ], + "machineUsers": { + "*": { + "indexes": [ + 0 + ] + } + } + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/nmdata.json new file mode 100644 index 000000000..7d78e5c61 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/nmdata.json @@ -0,0 +1,31 @@ +{ + "Network": {"Serial": 5}, + "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true}, + "Peers": { + "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}}, + "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}}, + "peer-c": {"IP": "100.64.0.3", "SSHEnabled": true, "SSHKey": "ssh-peer-c", "Meta": {"WtVersion": "0.60.0"}} + }, + "Groups": { + "grp-dev": {"Peers": ["peer-a", "peer-b"]}, + "grp-ops": {"Peers": ["peer-c"]} + }, + "Policies": [ + { + "ID": "pol-all", + "PublicID": "pol-all-pub", + "Enabled": true, + "Rules": [ + { + "Enabled": true, + "Action": "accept", + "Protocol": "all", + "Bidirectional": true, + "Sources": ["grp-dev"], + "Destinations": ["grp-ops"] + } + ] + } + ], + "AllowedUserIDs": {"user-ops": {}} +} \ No newline at end of file diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/case.json new file mode 100644 index 000000000..e4c46c63d --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/case.json @@ -0,0 +1,7 @@ +{ + "description": "Nameserver group and applied custom zones distributed to grp-dev; peer-a (with an extra DNS label) receives them, peer-c is outside that group and receives only the zone distributed to grp-ops. Zone flags travel per zone: both grp-dev zones are match-only (NonAuthoritative), only search-off.internal. disables the search domain, and the built-in peer zone stays authoritative.", + "peers": [ + "peer-a", + "peer-c" + ] +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/golden/peer-a.json new file mode 100644 index 000000000..f06a19d9d --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/golden/peer-a.json @@ -0,0 +1,115 @@ +{ + "Serial": "8", + "peerConfig": { + "address": "100.64.0.1/10", + "sshConfig": {}, + "fqdn": "peer-a.netbird.test", + "RoutingPeerDnsResolutionEnabled": true, + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "AvldyrZ12Pf90jzf3AXmhPwg3UcI+jtJHfbpBlupvko=", + "allowedIps": [ + "100.64.0.2/32" + ], + "sshConfig": {}, + "fqdn": "peer-b.netbird.test", + "agentVersion": "0.60.0" + } + ], + "DNSConfig": { + "ServiceEnable": true, + "NameServerGroups": [ + { + "NameServers": [ + { + "IP": "8.8.8.8", + "Port": "53" + } + ], + "Primary": true + } + ], + "CustomZones": [ + { + "Domain": "corp.internal.", + "NonAuthoritative": true, + "Records": [ + { + "Name": "db.corp.internal.", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "10.10.0.5" + } + ] + }, + { + "Domain": "search-off.internal.", + "SearchDomainDisabled": true, + "NonAuthoritative": true, + "Records": [ + { + "Name": "alias.search-off.internal.", + "Type": "5", + "Class": "IN", + "TTL": "300", + "RData": "app.search-off.internal." + }, + { + "Name": "app.search-off.internal.", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "10.10.0.6" + } + ] + }, + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-a.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + }, + { + "Name": "peer-b.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.2" + }, + { + "Name": "www.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "FirewallRules": [ + { + "PeerIP": "100.64.0.2", + "Protocol": "ALL", + "PolicyID": "cG9sLW1lc2g=" + }, + { + "PeerIP": "100.64.0.2", + "Direction": "OUT", + "Protocol": "ALL", + "PolicyID": "cG9sLW1lc2g=" + } + ], + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/golden/peer-c.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/golden/peer-c.json new file mode 100644 index 000000000..7e04dca40 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/golden/peer-c.json @@ -0,0 +1,47 @@ +{ + "Serial": "8", + "peerConfig": { + "address": "100.64.0.3/10", + "sshConfig": {}, + "fqdn": "peer-c.netbird.test", + "RoutingPeerDnsResolutionEnabled": true, + "autoUpdate": {} + }, + "remotePeersIsEmpty": true, + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-c.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.3" + } + ] + }, + { + "Domain": "ops-only.internal.", + "NonAuthoritative": true, + "Records": [ + { + "Name": "tool.ops-only.internal.", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "10.10.0.7" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "firewallRulesIsEmpty": true, + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/nmdata.json new file mode 100644 index 000000000..b9741ef16 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/nmdata.json @@ -0,0 +1,74 @@ +{ + "Network": {"Serial": 8}, + "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true}, + "Peers": { + "peer-a": {"IP": "100.64.0.1", "ExtraDNSLabels": ["www"], "Meta": {"WtVersion": "0.60.0"}}, + "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}}, + "peer-c": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.60.0"}} + }, + "Groups": { + "grp-dev": {"Peers": ["peer-a", "peer-b"]}, + "grp-ops": {"Peers": ["peer-c"]} + }, + "Policies": [ + { + "ID": "pol-mesh", + "PublicID": "pol-mesh-pub", + "Enabled": true, + "Rules": [ + { + "Enabled": true, + "Action": "accept", + "Protocol": "all", + "Bidirectional": true, + "Sources": ["grp-dev"], + "Destinations": ["grp-dev"] + } + ] + } + ], + "NameServerGroups": [ + { + "ID": "nsg-1", + "Name": "dns-primary", + "NameServers": [{"IP": "8.8.8.8", "Port": 53}], + "Groups": ["grp-dev"], + "Primary": true, + "Enabled": true + } + ], + "AppliedZoneCandidates": [ + { + "DistributionGroups": ["grp-dev"], + "Zone": { + "Domain": "corp.internal.", + "NonAuthoritative": true, + "Records": [ + {"Name": "db.corp.internal.", "Type": 1, "Class": "IN", "TTL": 300, "RData": "10.10.0.5"} + ] + } + }, + { + "DistributionGroups": ["grp-dev"], + "Zone": { + "Domain": "search-off.internal.", + "NonAuthoritative": true, + "SearchDomainDisabled": true, + "Records": [ + {"Name": "app.search-off.internal.", "Type": 1, "Class": "IN", "TTL": 300, "RData": "10.10.0.6"}, + {"Name": "alias.search-off.internal.", "Type": 5, "Class": "IN", "TTL": 300, "RData": "app.search-off.internal."} + ] + } + }, + { + "DistributionGroups": ["grp-ops"], + "Zone": { + "Domain": "ops-only.internal.", + "NonAuthoritative": true, + "Records": [ + {"Name": "tool.ops-only.internal.", "Type": 1, "Class": "IN", "TTL": 300, "RData": "10.10.0.7"} + ] + } + } + ] +} \ No newline at end of file diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/case.json new file mode 100644 index 000000000..a5776d0e7 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/case.json @@ -0,0 +1,4 @@ +{ + "description": "Domain network resource: the route carries the domain list and the 192.0.2.0/32 placeholder network with NetworkType 3 (dynamic), and peer-r's route firewall rules must be marked dynamic and repeat the domain. Two ports on the policy must produce one rule per port. A domain resource contributes no DNS custom zone of its own — resolution happens through the routing peer's forwarder.", + "peers": ["peer-a", "peer-r"] +} \ No newline at end of file diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/golden/peer-a.json new file mode 100644 index 000000000..f83e7a2f2 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/golden/peer-a.json @@ -0,0 +1,59 @@ +{ + "Serial": "22", + "peerConfig": { + "address": "100.64.0.1/10", + "sshConfig": {}, + "fqdn": "peer-a.netbird.test", + "RoutingPeerDnsResolutionEnabled": true, + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=", + "allowedIps": [ + "100.64.0.9/32" + ], + "sshConfig": {}, + "fqdn": "peer-r.netbird.test", + "agentVersion": "0.60.0" + } + ], + "Routes": [ + { + "ID": "res-domain:peer-r", + "Network": "192.0.2.0/32", + "NetworkType": "3", + "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=", + "Metric": "9999", + "Masquerade": true, + "NetID": "app-domain", + "Domains": [ + "app.internal" + ], + "keepRoute": true + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-a.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "firewallRulesIsEmpty": true, + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/golden/peer-r.json new file mode 100644 index 000000000..41ae3dd33 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/golden/peer-r.json @@ -0,0 +1,92 @@ +{ + "Serial": "22", + "peerConfig": { + "address": "100.64.0.9/10", + "sshConfig": {}, + "fqdn": "peer-r.netbird.test", + "RoutingPeerDnsResolutionEnabled": true, + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=", + "allowedIps": [ + "100.64.0.1/32" + ], + "sshConfig": {}, + "fqdn": "peer-a.netbird.test", + "agentVersion": "0.60.0" + } + ], + "Routes": [ + { + "ID": "res-domain:peer-r", + "Network": "192.0.2.0/32", + "NetworkType": "3", + "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=", + "Metric": "9999", + "Masquerade": true, + "NetID": "app-domain", + "Domains": [ + "app.internal" + ], + "keepRoute": true + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-r.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.9" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "firewallRulesIsEmpty": true, + "routesFirewallRules": [ + { + "sourceRanges": [ + "100.64.0.1/32" + ], + "destination": "192.0.2.0/32", + "protocol": "TCP", + "portInfo": { + "port": 443 + }, + "isDynamic": true, + "domains": [ + "app.internal" + ], + "PolicyID": "cG9sLWFwcA==", + "RouteID": "res-domain:peer-r" + }, + { + "sourceRanges": [ + "100.64.0.1/32" + ], + "destination": "192.0.2.0/32", + "protocol": "TCP", + "portInfo": { + "port": 80 + }, + "isDynamic": true, + "domains": [ + "app.internal" + ], + "PolicyID": "cG9sLWFwcA==", + "RouteID": "res-domain:peer-r" + } + ], + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/nmdata.json new file mode 100644 index 000000000..db6dc8eda --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/nmdata.json @@ -0,0 +1,43 @@ +{ + "Network": {"Serial": 22}, + "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true}, + "Peers": { + "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}}, + "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}} + }, + "Groups": { + "grp-dev": {"Peers": ["peer-a"]} + }, + "Policies": [ + { + "ID": "pol-app", + "Enabled": true, + "Rules": [ + { + "Enabled": true, + "Action": "accept", + "Protocol": "tcp", + "Ports": ["80", "443"], + "Sources": ["grp-dev"], + "DestinationResource": {"ID": "res-domain", "Type": "domain"} + } + ] + } + ], + "ResourcePolicies": {"res-domain": [{"ID": "pol-app"}]}, + "NetworkResources": [ + { + "ID": "res-domain", + "NetworkID": "net-1", + "Name": "app-domain", + "Type": "domain", + "Domain": "app.internal", + "Enabled": true + } + ], + "Routers": { + "net-1": { + "peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true} + } + } +} \ No newline at end of file diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/case.json new file mode 100644 index 000000000..fa1e5c24b --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/case.json @@ -0,0 +1,4 @@ +{ + "description": "Host network resource (single /32) behind one directly-assigned router. peer-a is in the resource policy's source group and must receive one route to 10.10.0.7/32 via peer-r with KeepRoute set and NetID taken from the resource name; peer-r as the router must receive the same route plus a route firewall rule whose SourceRanges are the policy's source peers. A client never gets route firewall rules.", + "peers": ["peer-a", "peer-r"] +} \ No newline at end of file diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/golden/peer-a.json new file mode 100644 index 000000000..8bf83f20e --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/golden/peer-a.json @@ -0,0 +1,56 @@ +{ + "Serial": "20", + "peerConfig": { + "address": "100.64.0.1/10", + "sshConfig": {}, + "fqdn": "peer-a.netbird.test", + "RoutingPeerDnsResolutionEnabled": true, + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=", + "allowedIps": [ + "100.64.0.9/32" + ], + "sshConfig": {}, + "fqdn": "peer-r.netbird.test", + "agentVersion": "0.60.0" + } + ], + "Routes": [ + { + "ID": "res-host:peer-r", + "Network": "10.10.0.7/32", + "NetworkType": "1", + "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=", + "Metric": "9999", + "Masquerade": true, + "NetID": "web-host", + "keepRoute": true + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-a.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "firewallRulesIsEmpty": true, + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/golden/peer-r.json new file mode 100644 index 000000000..ef3b5a6c8 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/golden/peer-r.json @@ -0,0 +1,69 @@ +{ + "Serial": "20", + "peerConfig": { + "address": "100.64.0.9/10", + "sshConfig": {}, + "fqdn": "peer-r.netbird.test", + "RoutingPeerDnsResolutionEnabled": true, + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=", + "allowedIps": [ + "100.64.0.1/32" + ], + "sshConfig": {}, + "fqdn": "peer-a.netbird.test", + "agentVersion": "0.60.0" + } + ], + "Routes": [ + { + "ID": "res-host:peer-r", + "Network": "10.10.0.7/32", + "NetworkType": "1", + "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=", + "Metric": "9999", + "Masquerade": true, + "NetID": "web-host", + "keepRoute": true + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-r.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.9" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "firewallRulesIsEmpty": true, + "routesFirewallRules": [ + { + "sourceRanges": [ + "100.64.0.1/32" + ], + "destination": "10.10.0.7/32", + "protocol": "TCP", + "portInfo": { + "port": 443 + }, + "PolicyID": "cG9sLXdlYg==", + "RouteID": "res-host:peer-r" + } + ], + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/nmdata.json new file mode 100644 index 000000000..fdd35a439 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/nmdata.json @@ -0,0 +1,43 @@ +{ + "Network": {"Serial": 20}, + "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true}, + "Peers": { + "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}}, + "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}} + }, + "Groups": { + "grp-dev": {"Peers": ["peer-a"]} + }, + "Policies": [ + { + "ID": "pol-web", + "Enabled": true, + "Rules": [ + { + "Enabled": true, + "Action": "accept", + "Protocol": "tcp", + "Ports": ["443"], + "Sources": ["grp-dev"], + "DestinationResource": {"ID": "res-host", "Type": "host"} + } + ] + } + ], + "ResourcePolicies": {"res-host": [{"ID": "pol-web"}]}, + "NetworkResources": [ + { + "ID": "res-host", + "NetworkID": "net-1", + "Name": "web-host", + "Type": "host", + "Prefix": "10.10.0.7/32", + "Enabled": true + } + ], + "Routers": { + "net-1": { + "peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true} + } + } +} \ No newline at end of file diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/case.json new file mode 100644 index 000000000..ca54a4b81 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/case.json @@ -0,0 +1,4 @@ +{ + "description": "A disabled resource with a valid policy and router must leave no trace: no routes and no route firewall rules for either the client or the router. Disabling a resource is the switch that revokes access without deleting the policy.", + "peers": ["peer-a", "peer-r"] +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/golden/peer-a.json new file mode 100644 index 000000000..a4f5a92bb --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/golden/peer-a.json @@ -0,0 +1,34 @@ +{ + "Serial": "25", + "peerConfig": { + "address": "100.64.0.1/10", + "sshConfig": {}, + "fqdn": "peer-a.netbird.test", + "RoutingPeerDnsResolutionEnabled": true, + "autoUpdate": {} + }, + "remotePeersIsEmpty": true, + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-a.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "firewallRulesIsEmpty": true, + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/golden/peer-r.json new file mode 100644 index 000000000..b83cfdff6 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/golden/peer-r.json @@ -0,0 +1,34 @@ +{ + "Serial": "25", + "peerConfig": { + "address": "100.64.0.9/10", + "sshConfig": {}, + "fqdn": "peer-r.netbird.test", + "RoutingPeerDnsResolutionEnabled": true, + "autoUpdate": {} + }, + "remotePeersIsEmpty": true, + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-r.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.9" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "firewallRulesIsEmpty": true, + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/nmdata.json new file mode 100644 index 000000000..43e00a2db --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/nmdata.json @@ -0,0 +1,42 @@ +{ + "Network": {"Serial": 25}, + "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true}, + "Peers": { + "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}}, + "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}} + }, + "Groups": { + "grp-dev": {"Peers": ["peer-a"]} + }, + "Policies": [ + { + "ID": "pol-off-resource", + "Enabled": true, + "Rules": [ + { + "Enabled": true, + "Action": "accept", + "Protocol": "tcp", + "Ports": ["443"], + "Sources": ["grp-dev"], + "DestinationResource": {"ID": "res-disabled", "Type": "subnet"} + } + ] + } + ], + "ResourcePolicies": {"res-disabled": [{"ID": "pol-off-resource"}]}, + "NetworkResources": [ + { + "ID": "res-disabled", + "NetworkID": "net-1", + "Name": "disabled-subnet", + "Type": "subnet", + "Prefix": "10.50.0.0/24" + } + ], + "Routers": { + "net-1": { + "peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true} + } + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/case.json new file mode 100644 index 000000000..494ac0fce --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/case.json @@ -0,0 +1,4 @@ +{ + "description": "An enabled resource with a healthy router but no policy granting access to it must produce nothing anywhere: no route for the client and none for the router either, since access to a resource is only ever created by a policy. The router also gets no route firewall rules despite being a routing peer for the network.", + "peers": ["peer-a", "peer-r"] +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/golden/peer-a.json new file mode 100644 index 000000000..32f9cf35e --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/golden/peer-a.json @@ -0,0 +1,34 @@ +{ + "Serial": "24", + "peerConfig": { + "address": "100.64.0.1/10", + "sshConfig": {}, + "fqdn": "peer-a.netbird.test", + "RoutingPeerDnsResolutionEnabled": true, + "autoUpdate": {} + }, + "remotePeersIsEmpty": true, + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-a.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "firewallRulesIsEmpty": true, + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/golden/peer-r.json new file mode 100644 index 000000000..a97eac9a3 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/golden/peer-r.json @@ -0,0 +1,34 @@ +{ + "Serial": "24", + "peerConfig": { + "address": "100.64.0.9/10", + "sshConfig": {}, + "fqdn": "peer-r.netbird.test", + "RoutingPeerDnsResolutionEnabled": true, + "autoUpdate": {} + }, + "remotePeersIsEmpty": true, + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-r.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.9" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "firewallRulesIsEmpty": true, + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/nmdata.json new file mode 100644 index 000000000..a3bbf299a --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/nmdata.json @@ -0,0 +1,26 @@ +{ + "Network": {"Serial": 24}, + "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true}, + "Peers": { + "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}}, + "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}} + }, + "Groups": { + "grp-dev": {"Peers": ["peer-a"]} + }, + "NetworkResources": [ + { + "ID": "res-orphan", + "NetworkID": "net-1", + "Name": "orphan-subnet", + "Type": "subnet", + "Prefix": "10.40.0.0/24", + "Enabled": true + } + ], + "Routers": { + "net-1": { + "peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true} + } + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/case.json new file mode 100644 index 000000000..2922a5deb --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/case.json @@ -0,0 +1,4 @@ +{ + "description": "A DISABLED policy granting access to a network resource must grant nothing: no route to 10.90.0.0/24 for peer-a and none for the router either, exactly as if the policy were absent. THE FULL EXPECTATION CURRENTLY FAILS, and should: resource-policy selection never checks policy.Enabled (networkmapcompute.go and networkmap_components.go both test only nil/len(Rules)/Rules[0]), so the legacy path still hands out the route — access survives disabling the policy. The envelope path happens to be correct because the encoder drops disabled policies from the wire. Fix the compute path, do not weaken this expectation.", + "peers": ["peer-a", "peer-r"] +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/golden/peer-a.json new file mode 100644 index 000000000..92ba75b1e --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/golden/peer-a.json @@ -0,0 +1,34 @@ +{ + "Serial": "39", + "peerConfig": { + "address": "100.64.0.1/10", + "sshConfig": {}, + "fqdn": "peer-a.netbird.test", + "RoutingPeerDnsResolutionEnabled": true, + "autoUpdate": {} + }, + "remotePeersIsEmpty": true, + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-a.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "firewallRulesIsEmpty": true, + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/golden/peer-r.json new file mode 100644 index 000000000..8c27320ee --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/golden/peer-r.json @@ -0,0 +1,34 @@ +{ + "Serial": "39", + "peerConfig": { + "address": "100.64.0.9/10", + "sshConfig": {}, + "fqdn": "peer-r.netbird.test", + "RoutingPeerDnsResolutionEnabled": true, + "autoUpdate": {} + }, + "remotePeersIsEmpty": true, + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-r.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.9" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "firewallRulesIsEmpty": true, + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/nmdata.json new file mode 100644 index 000000000..be7251712 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/nmdata.json @@ -0,0 +1,42 @@ +{ + "Network": {"Serial": 39}, + "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true}, + "Peers": { + "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}}, + "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}} + }, + "Groups": { + "grp-dev": {"Peers": ["peer-a"]} + }, + "Policies": [ + { + "ID": "pol-revoked", + "Rules": [ + { + "Enabled": true, + "Action": "accept", + "Protocol": "tcp", + "Ports": ["5432"], + "Sources": ["grp-dev"], + "DestinationResource": {"ID": "res-db", "Type": "subnet"} + } + ] + } + ], + "ResourcePolicies": {"res-db": [{"ID": "pol-revoked"}]}, + "NetworkResources": [ + { + "ID": "res-db", + "NetworkID": "net-1", + "Name": "db-subnet", + "Type": "subnet", + "Prefix": "10.90.0.0/24", + "Enabled": true + } + ], + "Routers": { + "net-1": { + "peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true} + } + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/case.json new file mode 100644 index 000000000..de1027d48 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/case.json @@ -0,0 +1,4 @@ +{ + "description": "The routing peer for the resource is not in ValidatedPeers — an unapproved peer, which the integrated validator withholds. peer-a must therefore receive no route through it and must not see it as a peer at all: traffic may not be routed through a peer the account has not approved. THE ENVELOPE EXPECTATION CURRENTLY FAILS, and should: component selection puts every routing peer into RouterPeers without checking validation, the encoder indexes them into the envelope's peer table, and the client decoder puts every peer it finds back into its peer map, so the unapproved router reappears client-side with a working route. The full path drops it correctly. Fix the component/encoder path, do not weaken this expectation.", + "peers": ["peer-a"] +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/golden/peer-a.json new file mode 100644 index 000000000..2554fc08c --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/golden/peer-a.json @@ -0,0 +1,34 @@ +{ + "Serial": "40", + "peerConfig": { + "address": "100.64.0.1/10", + "sshConfig": {}, + "fqdn": "peer-a.netbird.test", + "RoutingPeerDnsResolutionEnabled": true, + "autoUpdate": {} + }, + "remotePeersIsEmpty": true, + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-a.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "firewallRulesIsEmpty": true, + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/nmdata.json new file mode 100644 index 000000000..98ba94471 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/nmdata.json @@ -0,0 +1,44 @@ +{ + "Network": {"Serial": 40}, + "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true}, + "Peers": { + "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}}, + "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}} + }, + "ValidatedPeers": {"peer-a": {}}, + "Groups": { + "grp-dev": {"Peers": ["peer-a"]} + }, + "Policies": [ + { + "ID": "pol-db", + "Enabled": true, + "Rules": [ + { + "Enabled": true, + "Action": "accept", + "Protocol": "tcp", + "Ports": ["5432"], + "Sources": ["grp-dev"], + "DestinationResource": {"ID": "res-db", "Type": "subnet"} + } + ] + } + ], + "ResourcePolicies": {"res-db": [{"ID": "pol-db"}]}, + "NetworkResources": [ + { + "ID": "res-db", + "NetworkID": "net-1", + "Name": "db-subnet", + "Type": "subnet", + "Prefix": "10.100.0.0/24", + "Enabled": true + } + ], + "Routers": { + "net-1": { + "peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true} + } + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/case.json new file mode 100644 index 000000000..8ec63c816 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/case.json @@ -0,0 +1,4 @@ +{ + "description": "Routing peer group: one router record assigned to a peer group, which the store expands into one entry per member peer sharing the router's settings. peer-a must receive one route per routing peer — same NetID and destination, different route ID and peer — which is what gives the client an HA pair to choose between. Each router must receive only its own route, never its sibling's, plus its own route firewall rule.", + "peers": ["peer-a", "peer-r1", "peer-r2"] +} \ No newline at end of file diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-a.json new file mode 100644 index 000000000..020c34835 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-a.json @@ -0,0 +1,75 @@ +{ + "Serial": "23", + "peerConfig": { + "address": "100.64.0.1/10", + "sshConfig": {}, + "fqdn": "peer-a.netbird.test", + "RoutingPeerDnsResolutionEnabled": true, + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "Iu6Lj1HqDXfgDh5RppHCRI6RO3lMlZBCndRNrdS7QUI=", + "allowedIps": [ + "100.64.0.11/32" + ], + "sshConfig": {}, + "fqdn": "peer-r1.netbird.test", + "agentVersion": "0.60.0" + }, + { + "wgPubKey": "YC6sbWtvpB2d6W/XqsNIV9crsrXBoVDdwmhCoszjCps=", + "allowedIps": [ + "100.64.0.12/32" + ], + "sshConfig": {}, + "fqdn": "peer-r2.netbird.test", + "agentVersion": "0.60.0" + } + ], + "Routes": [ + { + "ID": "res-ha:peer-r1", + "Network": "10.30.0.0/24", + "NetworkType": "1", + "Peer": "Iu6Lj1HqDXfgDh5RppHCRI6RO3lMlZBCndRNrdS7QUI=", + "Metric": "9999", + "Masquerade": true, + "NetID": "ha-subnet", + "keepRoute": true + }, + { + "ID": "res-ha:peer-r2", + "Network": "10.30.0.0/24", + "NetworkType": "1", + "Peer": "YC6sbWtvpB2d6W/XqsNIV9crsrXBoVDdwmhCoszjCps=", + "Metric": "9999", + "Masquerade": true, + "NetID": "ha-subnet", + "keepRoute": true + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-a.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "firewallRulesIsEmpty": true, + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-r1.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-r1.json new file mode 100644 index 000000000..e42214ca8 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-r1.json @@ -0,0 +1,69 @@ +{ + "Serial": "23", + "peerConfig": { + "address": "100.64.0.11/10", + "sshConfig": {}, + "fqdn": "peer-r1.netbird.test", + "RoutingPeerDnsResolutionEnabled": true, + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=", + "allowedIps": [ + "100.64.0.1/32" + ], + "sshConfig": {}, + "fqdn": "peer-a.netbird.test", + "agentVersion": "0.60.0" + } + ], + "Routes": [ + { + "ID": "res-ha:peer-r1", + "Network": "10.30.0.0/24", + "NetworkType": "1", + "Peer": "Iu6Lj1HqDXfgDh5RppHCRI6RO3lMlZBCndRNrdS7QUI=", + "Metric": "9999", + "Masquerade": true, + "NetID": "ha-subnet", + "keepRoute": true + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-r1.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.11" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "firewallRulesIsEmpty": true, + "routesFirewallRules": [ + { + "sourceRanges": [ + "100.64.0.1/32" + ], + "destination": "10.30.0.0/24", + "protocol": "TCP", + "portInfo": { + "port": 5432 + }, + "PolicyID": "cG9sLWhh", + "RouteID": "res-ha:peer-r1" + } + ], + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-r2.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-r2.json new file mode 100644 index 000000000..2560742fb --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-r2.json @@ -0,0 +1,69 @@ +{ + "Serial": "23", + "peerConfig": { + "address": "100.64.0.12/10", + "sshConfig": {}, + "fqdn": "peer-r2.netbird.test", + "RoutingPeerDnsResolutionEnabled": true, + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=", + "allowedIps": [ + "100.64.0.1/32" + ], + "sshConfig": {}, + "fqdn": "peer-a.netbird.test", + "agentVersion": "0.60.0" + } + ], + "Routes": [ + { + "ID": "res-ha:peer-r2", + "Network": "10.30.0.0/24", + "NetworkType": "1", + "Peer": "YC6sbWtvpB2d6W/XqsNIV9crsrXBoVDdwmhCoszjCps=", + "Metric": "9999", + "Masquerade": true, + "NetID": "ha-subnet", + "keepRoute": true + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-r2.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.12" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "firewallRulesIsEmpty": true, + "routesFirewallRules": [ + { + "sourceRanges": [ + "100.64.0.1/32" + ], + "destination": "10.30.0.0/24", + "protocol": "TCP", + "portInfo": { + "port": 5432 + }, + "PolicyID": "cG9sLWhh", + "RouteID": "res-ha:peer-r2" + } + ], + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/nmdata.json new file mode 100644 index 000000000..03937cb14 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/nmdata.json @@ -0,0 +1,46 @@ +{ + "Network": {"Serial": 23}, + "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true}, + "Peers": { + "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}}, + "peer-r1": {"IP": "100.64.0.11", "Meta": {"WtVersion": "0.60.0"}}, + "peer-r2": {"IP": "100.64.0.12", "Meta": {"WtVersion": "0.60.0"}} + }, + "Groups": { + "grp-dev": {"Peers": ["peer-a"]}, + "grp-routers": {"Peers": ["peer-r1", "peer-r2"]} + }, + "Policies": [ + { + "ID": "pol-ha", + "Enabled": true, + "Rules": [ + { + "Enabled": true, + "Action": "accept", + "Protocol": "tcp", + "Ports": ["5432"], + "Sources": ["grp-dev"], + "DestinationResource": {"ID": "res-ha", "Type": "subnet"} + } + ] + } + ], + "ResourcePolicies": {"res-ha": [{"ID": "pol-ha"}]}, + "NetworkResources": [ + { + "ID": "res-ha", + "NetworkID": "net-ha", + "Name": "ha-subnet", + "Type": "subnet", + "Prefix": "10.30.0.0/24", + "Enabled": true + } + ], + "Routers": { + "net-ha": { + "peer-r1": {"PublicID": "router-ha", "PeerGroups": ["grp-routers"], "Masquerade": true, "Metric": 9999, "Enabled": true}, + "peer-r2": {"PublicID": "router-ha", "PeerGroups": ["grp-routers"], "Masquerade": true, "Metric": 9999, "Enabled": true} + } + } +} \ No newline at end of file diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/case.json new file mode 100644 index 000000000..16cfedf34 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/case.json @@ -0,0 +1,4 @@ +{ + "description": "Subnet network resource behind one directly-assigned router, with masquerade off and a non-default metric so both reach the wire verbatim, and an all-protocol policy from a two-peer source group. peer-r's route firewall rule must list both source peers; peer-b confirms a second client in the same group gets its own identical route.", + "peers": ["peer-a", "peer-b", "peer-r"] +} \ No newline at end of file diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-a.json new file mode 100644 index 000000000..a5bc8f880 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-a.json @@ -0,0 +1,55 @@ +{ + "Serial": "21", + "peerConfig": { + "address": "100.64.0.1/10", + "sshConfig": {}, + "fqdn": "peer-a.netbird.test", + "RoutingPeerDnsResolutionEnabled": true, + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=", + "allowedIps": [ + "100.64.0.9/32" + ], + "sshConfig": {}, + "fqdn": "peer-r.netbird.test", + "agentVersion": "0.60.0" + } + ], + "Routes": [ + { + "ID": "res-subnet:peer-r", + "Network": "10.20.0.0/24", + "NetworkType": "1", + "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=", + "Metric": "500", + "NetID": "office-subnet", + "keepRoute": true + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-a.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "firewallRulesIsEmpty": true, + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-b.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-b.json new file mode 100644 index 000000000..01c31edf6 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-b.json @@ -0,0 +1,55 @@ +{ + "Serial": "21", + "peerConfig": { + "address": "100.64.0.2/10", + "sshConfig": {}, + "fqdn": "peer-b.netbird.test", + "RoutingPeerDnsResolutionEnabled": true, + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=", + "allowedIps": [ + "100.64.0.9/32" + ], + "sshConfig": {}, + "fqdn": "peer-r.netbird.test", + "agentVersion": "0.60.0" + } + ], + "Routes": [ + { + "ID": "res-subnet:peer-r", + "Network": "10.20.0.0/24", + "NetworkType": "1", + "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=", + "Metric": "500", + "NetID": "office-subnet", + "keepRoute": true + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-b.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.2" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "firewallRulesIsEmpty": true, + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-r.json new file mode 100644 index 000000000..39a29125d --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-r.json @@ -0,0 +1,76 @@ +{ + "Serial": "21", + "peerConfig": { + "address": "100.64.0.9/10", + "sshConfig": {}, + "fqdn": "peer-r.netbird.test", + "RoutingPeerDnsResolutionEnabled": true, + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "AvldyrZ12Pf90jzf3AXmhPwg3UcI+jtJHfbpBlupvko=", + "allowedIps": [ + "100.64.0.2/32" + ], + "sshConfig": {}, + "fqdn": "peer-b.netbird.test", + "agentVersion": "0.60.0" + }, + { + "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=", + "allowedIps": [ + "100.64.0.1/32" + ], + "sshConfig": {}, + "fqdn": "peer-a.netbird.test", + "agentVersion": "0.60.0" + } + ], + "Routes": [ + { + "ID": "res-subnet:peer-r", + "Network": "10.20.0.0/24", + "NetworkType": "1", + "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=", + "Metric": "500", + "NetID": "office-subnet", + "keepRoute": true + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-r.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.9" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "firewallRulesIsEmpty": true, + "routesFirewallRules": [ + { + "sourceRanges": [ + "100.64.0.1/32", + "100.64.0.2/32" + ], + "destination": "10.20.0.0/24", + "protocol": "ALL", + "portInfo": {}, + "PolicyID": "cG9sLXN1Ym5ldA==", + "RouteID": "res-subnet:peer-r" + } + ], + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/nmdata.json new file mode 100644 index 000000000..ba27f494c --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/nmdata.json @@ -0,0 +1,43 @@ +{ + "Network": {"Serial": 21}, + "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true}, + "Peers": { + "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}}, + "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}}, + "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}} + }, + "Groups": { + "grp-dev": {"Peers": ["peer-a", "peer-b"]} + }, + "Policies": [ + { + "ID": "pol-subnet", + "Enabled": true, + "Rules": [ + { + "Enabled": true, + "Action": "accept", + "Protocol": "all", + "Sources": ["grp-dev"], + "DestinationResource": {"ID": "res-subnet", "Type": "subnet"} + } + ] + } + ], + "ResourcePolicies": {"res-subnet": [{"ID": "pol-subnet"}]}, + "NetworkResources": [ + { + "ID": "res-subnet", + "NetworkID": "net-1", + "Name": "office-subnet", + "Type": "subnet", + "Prefix": "10.20.0.0/24", + "Enabled": true + } + ], + "Routers": { + "net-1": { + "peer-r": {"PublicID": "router-direct", "Metric": 500, "Enabled": true} + } + } +} \ No newline at end of file diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/case.json new file mode 100644 index 000000000..39c477b9f --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/case.json @@ -0,0 +1,4 @@ +{ + "description": "Direct peer-to-peer policy via Source/DestinationResource of type peer, no groups involved; peer-a and peer-b see each other, bystander peer-c sees nobody.", + "peers": ["peer-a", "peer-b", "peer-c"] +} \ No newline at end of file diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-a.json new file mode 100644 index 000000000..4d59c33bb --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-a.json @@ -0,0 +1,64 @@ +{ + "Serial": "15", + "peerConfig": { + "address": "100.64.0.1/10", + "sshConfig": {}, + "fqdn": "peer-a.netbird.test", + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "AvldyrZ12Pf90jzf3AXmhPwg3UcI+jtJHfbpBlupvko=", + "allowedIps": [ + "100.64.0.2/32" + ], + "sshConfig": {}, + "fqdn": "peer-b.netbird.test", + "agentVersion": "0.60.0" + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-a.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + }, + { + "Name": "peer-b.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.2" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "FirewallRules": [ + { + "PeerIP": "100.64.0.2", + "Protocol": "TCP", + "Port": "443", + "PolicyID": "cG9sLWRpcmVjdA==" + }, + { + "PeerIP": "100.64.0.2", + "Direction": "OUT", + "Protocol": "TCP", + "Port": "443", + "PolicyID": "cG9sLWRpcmVjdA==" + } + ], + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-b.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-b.json new file mode 100644 index 000000000..59b4bd24c --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-b.json @@ -0,0 +1,64 @@ +{ + "Serial": "15", + "peerConfig": { + "address": "100.64.0.2/10", + "sshConfig": {}, + "fqdn": "peer-b.netbird.test", + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=", + "allowedIps": [ + "100.64.0.1/32" + ], + "sshConfig": {}, + "fqdn": "peer-a.netbird.test", + "agentVersion": "0.60.0" + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-a.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + }, + { + "Name": "peer-b.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.2" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "FirewallRules": [ + { + "PeerIP": "100.64.0.1", + "Protocol": "TCP", + "Port": "443", + "PolicyID": "cG9sLWRpcmVjdA==" + }, + { + "PeerIP": "100.64.0.1", + "Direction": "OUT", + "Protocol": "TCP", + "Port": "443", + "PolicyID": "cG9sLWRpcmVjdA==" + } + ], + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-c.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-c.json new file mode 100644 index 000000000..9ff24ce1a --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-c.json @@ -0,0 +1,33 @@ +{ + "Serial": "15", + "peerConfig": { + "address": "100.64.0.3/10", + "sshConfig": {}, + "fqdn": "peer-c.netbird.test", + "autoUpdate": {} + }, + "remotePeersIsEmpty": true, + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-c.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.3" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "firewallRulesIsEmpty": true, + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/nmdata.json new file mode 100644 index 000000000..f3ee4d163 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/nmdata.json @@ -0,0 +1,26 @@ +{ + "Network": {"Serial": 15}, + "Peers": { + "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}}, + "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}}, + "peer-c": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.60.0"}} + }, + "Policies": [ + { + "ID": "pol-direct", + "PublicID": "pol-direct-pub", + "Enabled": true, + "Rules": [ + { + "Enabled": true, + "Action": "accept", + "Protocol": "tcp", + "Ports": ["443"], + "Bidirectional": true, + "SourceResource": {"ID": "peer-a", "Type": "peer"}, + "DestinationResource": {"ID": "peer-b", "Type": "peer"} + } + ] + } + ] +} \ No newline at end of file diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/case.json new file mode 100644 index 000000000..a0eccba20 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/case.json @@ -0,0 +1,4 @@ +{ + "description": "One-way udp/514 plus bidirectional tcp port-range 1000-2000 between the same groups; a disabled policy and a policy whose only rule is disabled must leave no trace.", + "peers": ["peer-a", "peer-srv"] +} \ No newline at end of file diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/golden/peer-a.json new file mode 100644 index 000000000..5a2276d96 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/golden/peer-a.json @@ -0,0 +1,81 @@ +{ + "Serial": "14", + "peerConfig": { + "address": "100.64.0.1/10", + "sshConfig": {}, + "fqdn": "peer-a.netbird.test", + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "jEboa3bOv65XRbq8+8JuagiwWz+mM7Fc5MUfBQbOf6Y=", + "allowedIps": [ + "100.64.0.10/32" + ], + "sshConfig": {}, + "fqdn": "peer-srv.netbird.test", + "agentVersion": "0.60.0" + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-a.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + }, + { + "Name": "peer-srv.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.10" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "FirewallRules": [ + { + "PeerIP": "100.64.0.10", + "Protocol": "TCP", + "PortInfo": { + "range": { + "start": 1000, + "end": 2000 + } + }, + "PolicyID": "cG9sLXJhbmdl" + }, + { + "PeerIP": "100.64.0.10", + "Direction": "OUT", + "Protocol": "TCP", + "PortInfo": { + "range": { + "start": 1000, + "end": 2000 + } + }, + "PolicyID": "cG9sLXJhbmdl" + }, + { + "PeerIP": "100.64.0.10", + "Direction": "OUT", + "Protocol": "UDP", + "Port": "514", + "PolicyID": "cG9sLXN5c2xvZw==" + } + ], + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/golden/peer-srv.json new file mode 100644 index 000000000..6a89148f5 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/golden/peer-srv.json @@ -0,0 +1,80 @@ +{ + "Serial": "14", + "peerConfig": { + "address": "100.64.0.10/10", + "sshConfig": {}, + "fqdn": "peer-srv.netbird.test", + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=", + "allowedIps": [ + "100.64.0.1/32" + ], + "sshConfig": {}, + "fqdn": "peer-a.netbird.test", + "agentVersion": "0.60.0" + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-a.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + }, + { + "Name": "peer-srv.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.10" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "FirewallRules": [ + { + "PeerIP": "100.64.0.1", + "Protocol": "TCP", + "PortInfo": { + "range": { + "start": 1000, + "end": 2000 + } + }, + "PolicyID": "cG9sLXJhbmdl" + }, + { + "PeerIP": "100.64.0.1", + "Direction": "OUT", + "Protocol": "TCP", + "PortInfo": { + "range": { + "start": 1000, + "end": 2000 + } + }, + "PolicyID": "cG9sLXJhbmdl" + }, + { + "PeerIP": "100.64.0.1", + "Protocol": "UDP", + "Port": "514", + "PolicyID": "cG9sLXN5c2xvZw==" + } + ], + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/nmdata.json new file mode 100644 index 000000000..f1262a0d7 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/nmdata.json @@ -0,0 +1,74 @@ +{ + "Network": {"Serial": 14}, + "Peers": { + "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}}, + "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}} + }, + "Groups": { + "grp-dev": {"Peers": ["peer-a"]}, + "grp-svc": {"Peers": ["peer-srv"]} + }, + "Policies": [ + { + "ID": "pol-syslog", + "PublicID": "pol-syslog-pub", + "Enabled": true, + "Rules": [ + { + "Enabled": true, + "Action": "accept", + "Protocol": "udp", + "Ports": ["514"], + "Sources": ["grp-dev"], + "Destinations": ["grp-svc"] + } + ] + }, + { + "ID": "pol-range", + "PublicID": "pol-range-pub", + "Enabled": true, + "Rules": [ + { + "Enabled": true, + "Action": "accept", + "Protocol": "tcp", + "PortRanges": [{"Start": 1000, "End": 2000}], + "Bidirectional": true, + "Sources": ["grp-dev"], + "Destinations": ["grp-svc"] + } + ] + }, + { + "ID": "pol-off", + "PublicID": "pol-off-pub", + "Rules": [ + { + "Enabled": true, + "Action": "accept", + "Protocol": "tcp", + "Ports": ["9999"], + "Bidirectional": true, + "Sources": ["grp-dev"], + "Destinations": ["grp-svc"] + } + ] + }, + { + "ID": "pol-rule-off", + "PublicID": "pol-rule-off-pub", + "Enabled": true, + "Rules": [ + { + "Action": "accept", + "Protocol": "udp", + "Ports": ["1111"], + "Bidirectional": true, + "Sources": ["grp-dev"], + "Destinations": ["grp-svc"] + } + ] + } + ] +} \ No newline at end of file diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/case.json new file mode 100644 index 000000000..3307e0afe --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/case.json @@ -0,0 +1,4 @@ +{ + "description": "Posture checks gate a policy's sources only, never its destinations. peer-srv-old would fail the version check, but it sits in the destination group, so peer-client must still receive it alongside peer-srv-new, and peer-srv-old must still receive peer-client. This asymmetry is deliberate in the compute path — destination peers are resolved with no posture checks passed in — and it is worth pinning because it is easy to assume a posture check protects both ends.", + "peers": ["peer-client", "peer-srv-old"] +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/golden/peer-client.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/golden/peer-client.json new file mode 100644 index 000000000..73182932b --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/golden/peer-client.json @@ -0,0 +1,93 @@ +{ + "Serial": "37", + "peerConfig": { + "address": "100.64.0.1/10", + "sshConfig": {}, + "fqdn": "peer-client.netbird.test", + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "MdeD+cDSnurizeZ/Zd7rEdIhs9VZViEnutUwkodqb1s=", + "allowedIps": [ + "100.64.0.12/32" + ], + "sshConfig": {}, + "fqdn": "peer-srv-new.netbird.test", + "agentVersion": "1.0.0" + }, + { + "wgPubKey": "ph1eqUTlSeLQ6V9zLEUpck25m5K5sOQq+AHY879HZME=", + "allowedIps": [ + "100.64.0.11/32" + ], + "sshConfig": {}, + "fqdn": "peer-srv-old.netbird.test", + "agentVersion": "0.30.0" + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-client.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + }, + { + "Name": "peer-srv-new.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.12" + }, + { + "Name": "peer-srv-old.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.11" + } + ] + } + ], + "ForwarderPort": "5353" + }, + "FirewallRules": [ + { + "PeerIP": "100.64.0.11", + "Protocol": "TCP", + "Port": "443", + "PolicyID": "cG9sLWRlc3Q=" + }, + { + "PeerIP": "100.64.0.11", + "Direction": "OUT", + "Protocol": "TCP", + "Port": "443", + "PolicyID": "cG9sLWRlc3Q=" + }, + { + "PeerIP": "100.64.0.12", + "Protocol": "TCP", + "Port": "443", + "PolicyID": "cG9sLWRlc3Q=" + }, + { + "PeerIP": "100.64.0.12", + "Direction": "OUT", + "Protocol": "TCP", + "Port": "443", + "PolicyID": "cG9sLWRlc3Q=" + } + ], + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/golden/peer-srv-old.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/golden/peer-srv-old.json new file mode 100644 index 000000000..8d1ad4feb --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/golden/peer-srv-old.json @@ -0,0 +1,64 @@ +{ + "Serial": "37", + "peerConfig": { + "address": "100.64.0.11/10", + "sshConfig": {}, + "fqdn": "peer-srv-old.netbird.test", + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "tKxuKEYQFPR8lCpcfVWBKVX0vGFKYXtTtFjXhoiu5zc=", + "allowedIps": [ + "100.64.0.1/32" + ], + "sshConfig": {}, + "fqdn": "peer-client.netbird.test", + "agentVersion": "1.0.0" + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-client.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + }, + { + "Name": "peer-srv-old.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.11" + } + ] + } + ], + "ForwarderPort": "5353" + }, + "FirewallRules": [ + { + "PeerIP": "100.64.0.1", + "Protocol": "TCP", + "Port": "443", + "PolicyID": "cG9sLWRlc3Q=" + }, + { + "PeerIP": "100.64.0.1", + "Direction": "OUT", + "Protocol": "TCP", + "Port": "443", + "PolicyID": "cG9sLWRlc3Q=" + } + ], + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/nmdata.json new file mode 100644 index 000000000..ca5ece21a --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/nmdata.json @@ -0,0 +1,33 @@ +{ + "Network": {"Serial": 37}, + "Peers": { + "peer-client": {"IP": "100.64.0.1", "Meta": {"WtVersion": "1.0.0"}}, + "peer-srv-old": {"IP": "100.64.0.11", "Meta": {"WtVersion": "0.30.0"}}, + "peer-srv-new": {"IP": "100.64.0.12", "Meta": {"WtVersion": "1.0.0"}} + }, + "Groups": { + "grp-clients": {"Peers": ["peer-client"]}, + "grp-srv": {"Peers": ["peer-srv-old", "peer-srv-new"]} + }, + "PostureChecks": { + "chk-version": {"Checks": {"NBVersionCheck": {"MinVersion": "0.45.0"}}} + }, + "Policies": [ + { + "ID": "pol-dest", + "Enabled": true, + "SourcePostureChecks": ["chk-version"], + "Rules": [ + { + "Enabled": true, + "Action": "accept", + "Protocol": "tcp", + "Ports": ["443"], + "Bidirectional": true, + "Sources": ["grp-clients"], + "Destinations": ["grp-srv"] + } + ] + } + ] +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/case.json new file mode 100644 index 000000000..a661fa53e --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/case.json @@ -0,0 +1,7 @@ +{ + "description": "Source-side NB-version posture check: peer-b (0.40.0) fails the 0.45.0 minimum, so peer-c must not see it and peer-b itself gets no policy connectivity.", + "peers": [ + "peer-b", + "peer-c" + ] +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/golden/peer-b.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/golden/peer-b.json new file mode 100644 index 000000000..201be294f --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/golden/peer-b.json @@ -0,0 +1,34 @@ +{ + "Serial": "6", + "peerConfig": { + "address": "100.64.0.2/10", + "sshConfig": {}, + "fqdn": "peer-b.netbird.test", + "RoutingPeerDnsResolutionEnabled": true, + "autoUpdate": {} + }, + "remotePeersIsEmpty": true, + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-b.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.2" + } + ] + } + ], + "ForwarderPort": "5353" + }, + "firewallRulesIsEmpty": true, + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/golden/peer-c.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/golden/peer-c.json new file mode 100644 index 000000000..009d00490 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/golden/peer-c.json @@ -0,0 +1,65 @@ +{ + "Serial": "6", + "peerConfig": { + "address": "100.64.0.3/10", + "sshConfig": {}, + "fqdn": "peer-c.netbird.test", + "RoutingPeerDnsResolutionEnabled": true, + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=", + "allowedIps": [ + "100.64.0.1/32" + ], + "sshConfig": {}, + "fqdn": "peer-a.netbird.test", + "agentVersion": "0.60.0" + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-a.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + }, + { + "Name": "peer-c.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.3" + } + ] + } + ], + "ForwarderPort": "5353" + }, + "FirewallRules": [ + { + "PeerIP": "100.64.0.1", + "Protocol": "TCP", + "Port": "443", + "PolicyID": "cG9sLWdhdGVk" + }, + { + "PeerIP": "100.64.0.1", + "Direction": "OUT", + "Protocol": "TCP", + "Port": "443", + "PolicyID": "cG9sLWdhdGVk" + } + ], + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/nmdata.json new file mode 100644 index 000000000..4962e8b6a --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/nmdata.json @@ -0,0 +1,36 @@ +{ + "Network": {"Serial": 6}, + "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true}, + "Peers": { + "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}}, + "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.40.0"}}, + "peer-c": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.60.0"}} + }, + "Groups": { + "grp-dev": {"Peers": ["peer-a", "peer-b"]}, + "grp-ops": {"Peers": ["peer-c"]} + }, + "PostureChecks": { + "chk-ver": {"Checks": {"NBVersionCheck": {"MinVersion": "0.45.0"}}} + }, + "PostureCheckXIDToPublicID": {"chk-ver": "chk-ver-pub"}, + "Policies": [ + { + "ID": "pol-gated", + "PublicID": "pol-gated-pub", + "Enabled": true, + "SourcePostureChecks": ["chk-ver"], + "Rules": [ + { + "Enabled": true, + "Action": "accept", + "Protocol": "tcp", + "Bidirectional": true, + "Ports": ["443"], + "Sources": ["grp-dev"], + "Destinations": ["grp-ops"] + } + ] + } + ] +} \ No newline at end of file diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/case.json new file mode 100644 index 000000000..9f7f86860 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/case.json @@ -0,0 +1,4 @@ +{ + "description": "Geo location posture check in allow mode. An entry naming only a country matches the whole country, so peer-de passes; an entry naming a city must match that city exactly, so peer-us-ny passes while peer-us-bos does not. peer-fr matches nothing and fails. peer-nowhere has no location at all, which the check reports as an error, and an errored check denies — so it fails too.", + "peers": ["peer-srv", "peer-de", "peer-us-bos", "peer-nowhere"] +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-de.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-de.json new file mode 100644 index 000000000..9a07139fe --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-de.json @@ -0,0 +1,64 @@ +{ + "Serial": "31", + "peerConfig": { + "address": "100.64.0.1/10", + "sshConfig": {}, + "fqdn": "peer-de.netbird.test", + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "jEboa3bOv65XRbq8+8JuagiwWz+mM7Fc5MUfBQbOf6Y=", + "allowedIps": [ + "100.64.0.10/32" + ], + "sshConfig": {}, + "fqdn": "peer-srv.netbird.test", + "agentVersion": "0.60.0" + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-de.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + }, + { + "Name": "peer-srv.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.10" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "FirewallRules": [ + { + "PeerIP": "100.64.0.10", + "Protocol": "TCP", + "Port": "443", + "PolicyID": "cG9sLWdlbw==" + }, + { + "PeerIP": "100.64.0.10", + "Direction": "OUT", + "Protocol": "TCP", + "Port": "443", + "PolicyID": "cG9sLWdlbw==" + } + ], + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-nowhere.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-nowhere.json new file mode 100644 index 000000000..aed97a69f --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-nowhere.json @@ -0,0 +1,33 @@ +{ + "Serial": "31", + "peerConfig": { + "address": "100.64.0.5/10", + "sshConfig": {}, + "fqdn": "peer-nowhere.netbird.test", + "autoUpdate": {} + }, + "remotePeersIsEmpty": true, + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-nowhere.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.5" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "firewallRulesIsEmpty": true, + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-srv.json new file mode 100644 index 000000000..9f3fcb1ba --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-srv.json @@ -0,0 +1,93 @@ +{ + "Serial": "31", + "peerConfig": { + "address": "100.64.0.10/10", + "sshConfig": {}, + "fqdn": "peer-srv.netbird.test", + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "9nwvdE0wik6Fcs8Tw6WBnmOqGZmzdiTR4VZAdRBOJF4=", + "allowedIps": [ + "100.64.0.2/32" + ], + "sshConfig": {}, + "fqdn": "peer-us-ny.netbird.test", + "agentVersion": "0.60.0" + }, + { + "wgPubKey": "s/f5frZqT3DT1o9QCuhA14Pj5GUa4JUsF4M3twrprmk=", + "allowedIps": [ + "100.64.0.1/32" + ], + "sshConfig": {}, + "fqdn": "peer-de.netbird.test", + "agentVersion": "0.60.0" + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-de.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + }, + { + "Name": "peer-srv.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.10" + }, + { + "Name": "peer-us-ny.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.2" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "FirewallRules": [ + { + "PeerIP": "100.64.0.1", + "Protocol": "TCP", + "Port": "443", + "PolicyID": "cG9sLWdlbw==" + }, + { + "PeerIP": "100.64.0.1", + "Direction": "OUT", + "Protocol": "TCP", + "Port": "443", + "PolicyID": "cG9sLWdlbw==" + }, + { + "PeerIP": "100.64.0.2", + "Protocol": "TCP", + "Port": "443", + "PolicyID": "cG9sLWdlbw==" + }, + { + "PeerIP": "100.64.0.2", + "Direction": "OUT", + "Protocol": "TCP", + "Port": "443", + "PolicyID": "cG9sLWdlbw==" + } + ], + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-us-bos.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-us-bos.json new file mode 100644 index 000000000..624666c36 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-us-bos.json @@ -0,0 +1,33 @@ +{ + "Serial": "31", + "peerConfig": { + "address": "100.64.0.3/10", + "sshConfig": {}, + "fqdn": "peer-us-bos.netbird.test", + "autoUpdate": {} + }, + "remotePeersIsEmpty": true, + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-us-bos.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.3" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "firewallRulesIsEmpty": true, + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/nmdata.json new file mode 100644 index 000000000..b92840266 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/nmdata.json @@ -0,0 +1,46 @@ +{ + "Network": {"Serial": 31}, + "Peers": { + "peer-de": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}, "Location": {"CountryCode": "DE", "CityName": "Berlin"}}, + "peer-us-ny": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}, "Location": {"CountryCode": "US", "CityName": "New York"}}, + "peer-us-bos": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.60.0"}, "Location": {"CountryCode": "US", "CityName": "Boston"}}, + "peer-fr": {"IP": "100.64.0.4", "Meta": {"WtVersion": "0.60.0"}, "Location": {"CountryCode": "FR", "CityName": "Paris"}}, + "peer-nowhere": {"IP": "100.64.0.5", "Meta": {"WtVersion": "0.60.0"}}, + "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}} + }, + "Groups": { + "grp-clients": {"Peers": ["peer-de", "peer-us-ny", "peer-us-bos", "peer-fr", "peer-nowhere"]}, + "grp-srv": {"Peers": ["peer-srv"]} + }, + "PostureChecks": { + "chk-geo": { + "Checks": { + "GeoLocationCheck": { + "Action": "allow", + "Locations": [ + {"CountryCode": "DE"}, + {"CountryCode": "US", "CityName": "New York"} + ] + } + } + } + }, + "Policies": [ + { + "ID": "pol-geo", + "Enabled": true, + "SourcePostureChecks": ["chk-geo"], + "Rules": [ + { + "Enabled": true, + "Action": "accept", + "Protocol": "tcp", + "Ports": ["443"], + "Bidirectional": true, + "Sources": ["grp-clients"], + "Destinations": ["grp-srv"] + } + ] + } + ] +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/case.json new file mode 100644 index 000000000..f234b5280 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/case.json @@ -0,0 +1,4 @@ +{ + "description": "Geo location posture check in deny mode: matching the list rejects, not matching passes, so peer-ru is excluded and peer-de is admitted. peer-nowhere has no location and fails here as well — a missing location is an error and errors deny in both modes, so deny mode is not a way to admit peers whose location is unknown.", + "peers": ["peer-srv", "peer-ru", "peer-nowhere"] +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-nowhere.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-nowhere.json new file mode 100644 index 000000000..e73b77d9e --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-nowhere.json @@ -0,0 +1,33 @@ +{ + "Serial": "32", + "peerConfig": { + "address": "100.64.0.3/10", + "sshConfig": {}, + "fqdn": "peer-nowhere.netbird.test", + "autoUpdate": {} + }, + "remotePeersIsEmpty": true, + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-nowhere.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.3" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "firewallRulesIsEmpty": true, + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-ru.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-ru.json new file mode 100644 index 000000000..721267f37 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-ru.json @@ -0,0 +1,33 @@ +{ + "Serial": "32", + "peerConfig": { + "address": "100.64.0.1/10", + "sshConfig": {}, + "fqdn": "peer-ru.netbird.test", + "autoUpdate": {} + }, + "remotePeersIsEmpty": true, + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-ru.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "firewallRulesIsEmpty": true, + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-srv.json new file mode 100644 index 000000000..93883369e --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-srv.json @@ -0,0 +1,62 @@ +{ + "Serial": "32", + "peerConfig": { + "address": "100.64.0.10/10", + "sshConfig": {}, + "fqdn": "peer-srv.netbird.test", + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "s/f5frZqT3DT1o9QCuhA14Pj5GUa4JUsF4M3twrprmk=", + "allowedIps": [ + "100.64.0.2/32" + ], + "sshConfig": {}, + "fqdn": "peer-de.netbird.test", + "agentVersion": "0.60.0" + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-de.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.2" + }, + { + "Name": "peer-srv.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.10" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "FirewallRules": [ + { + "PeerIP": "100.64.0.2", + "Protocol": "ALL", + "PolicyID": "cG9sLWdlby1kZW55" + }, + { + "PeerIP": "100.64.0.2", + "Direction": "OUT", + "Protocol": "ALL", + "PolicyID": "cG9sLWdlby1kZW55" + } + ], + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/nmdata.json new file mode 100644 index 000000000..5edc50a38 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/nmdata.json @@ -0,0 +1,40 @@ +{ + "Network": {"Serial": 32}, + "Peers": { + "peer-ru": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}, "Location": {"CountryCode": "RU", "CityName": "Moscow"}}, + "peer-de": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}, "Location": {"CountryCode": "DE", "CityName": "Berlin"}}, + "peer-nowhere": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.60.0"}}, + "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}} + }, + "Groups": { + "grp-clients": {"Peers": ["peer-ru", "peer-de", "peer-nowhere"]}, + "grp-srv": {"Peers": ["peer-srv"]} + }, + "PostureChecks": { + "chk-geo-deny": { + "Checks": { + "GeoLocationCheck": { + "Action": "deny", + "Locations": [{"CountryCode": "RU"}] + } + } + } + }, + "Policies": [ + { + "ID": "pol-geo-deny", + "Enabled": true, + "SourcePostureChecks": ["chk-geo-deny"], + "Rules": [ + { + "Enabled": true, + "Action": "accept", + "Protocol": "all", + "Bidirectional": true, + "Sources": ["grp-clients"], + "Destinations": ["grp-srv"] + } + ] + } + ] +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/case.json new file mode 100644 index 000000000..c0eab5408 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/case.json @@ -0,0 +1,4 @@ +{ + "description": "One posture check bundle holding two different checks. All checks in a bundle must pass, so only peer-both is admitted: peer-badgeo satisfies the version rule and peer-badversion satisfies the location rule, and each is still rejected on the other. This pins the AND semantics of a bundle rather than any-of.", + "peers": ["peer-srv", "peer-both", "peer-badgeo"] +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-badgeo.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-badgeo.json new file mode 100644 index 000000000..6a6272909 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-badgeo.json @@ -0,0 +1,33 @@ +{ + "Serial": "35", + "peerConfig": { + "address": "100.64.0.2/10", + "sshConfig": {}, + "fqdn": "peer-badgeo.netbird.test", + "autoUpdate": {} + }, + "remotePeersIsEmpty": true, + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-badgeo.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.2" + } + ] + } + ], + "ForwarderPort": "5353" + }, + "firewallRulesIsEmpty": true, + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-both.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-both.json new file mode 100644 index 000000000..7e18e7cb8 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-both.json @@ -0,0 +1,64 @@ +{ + "Serial": "35", + "peerConfig": { + "address": "100.64.0.1/10", + "sshConfig": {}, + "fqdn": "peer-both.netbird.test", + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "jEboa3bOv65XRbq8+8JuagiwWz+mM7Fc5MUfBQbOf6Y=", + "allowedIps": [ + "100.64.0.10/32" + ], + "sshConfig": {}, + "fqdn": "peer-srv.netbird.test", + "agentVersion": "1.0.0" + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-both.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + }, + { + "Name": "peer-srv.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.10" + } + ] + } + ], + "ForwarderPort": "5353" + }, + "FirewallRules": [ + { + "PeerIP": "100.64.0.10", + "Protocol": "TCP", + "Port": "443", + "PolicyID": "cG9sLWNvbWJv" + }, + { + "PeerIP": "100.64.0.10", + "Direction": "OUT", + "Protocol": "TCP", + "Port": "443", + "PolicyID": "cG9sLWNvbWJv" + } + ], + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-srv.json new file mode 100644 index 000000000..e91e8f777 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-srv.json @@ -0,0 +1,64 @@ +{ + "Serial": "35", + "peerConfig": { + "address": "100.64.0.10/10", + "sshConfig": {}, + "fqdn": "peer-srv.netbird.test", + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "ilmSCJoVLTTY/Am7or8ES8R0hL/OdfE2FDK197pxc5o=", + "allowedIps": [ + "100.64.0.1/32" + ], + "sshConfig": {}, + "fqdn": "peer-both.netbird.test", + "agentVersion": "1.0.0" + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-both.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + }, + { + "Name": "peer-srv.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.10" + } + ] + } + ], + "ForwarderPort": "5353" + }, + "FirewallRules": [ + { + "PeerIP": "100.64.0.1", + "Protocol": "TCP", + "Port": "443", + "PolicyID": "cG9sLWNvbWJv" + }, + { + "PeerIP": "100.64.0.1", + "Direction": "OUT", + "Protocol": "TCP", + "Port": "443", + "PolicyID": "cG9sLWNvbWJv" + } + ], + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/nmdata.json new file mode 100644 index 000000000..fbf7a5f1e --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/nmdata.json @@ -0,0 +1,42 @@ +{ + "Network": {"Serial": 35}, + "Peers": { + "peer-both": {"IP": "100.64.0.1", "Meta": {"WtVersion": "1.0.0"}, "Location": {"CountryCode": "DE", "CityName": "Berlin"}}, + "peer-badgeo": {"IP": "100.64.0.2", "Meta": {"WtVersion": "1.0.0"}, "Location": {"CountryCode": "FR", "CityName": "Paris"}}, + "peer-badversion": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.30.0"}, "Location": {"CountryCode": "DE", "CityName": "Berlin"}}, + "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "1.0.0"}} + }, + "Groups": { + "grp-clients": {"Peers": ["peer-both", "peer-badgeo", "peer-badversion"]}, + "grp-srv": {"Peers": ["peer-srv"]} + }, + "PostureChecks": { + "chk-combo": { + "Checks": { + "NBVersionCheck": {"MinVersion": "0.45.0"}, + "GeoLocationCheck": { + "Action": "allow", + "Locations": [{"CountryCode": "DE"}] + } + } + } + }, + "Policies": [ + { + "ID": "pol-combo", + "Enabled": true, + "SourcePostureChecks": ["chk-combo"], + "Rules": [ + { + "Enabled": true, + "Action": "accept", + "Protocol": "tcp", + "Ports": ["443"], + "Bidirectional": true, + "Sources": ["grp-clients"], + "Destinations": ["grp-srv"] + } + ] + } + ] +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/case.json new file mode 100644 index 000000000..d10bfc9ae --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/case.json @@ -0,0 +1,4 @@ +{ + "description": "Peer network range posture check in allow mode over 192.168.0.0/16. peer-office passes on its reported interface network, and peer-by-connip passes on the address it connected from, which the check folds in as a single-host prefix — so either source of address information can satisfy it. peer-remote is outside the range and peer-noaddr reports no address at all, which errors and therefore denies. Note this policy is tcp/22 without the peer's SSH flag, so no authorized users appear.", + "peers": ["peer-srv", "peer-office", "peer-by-connip", "peer-remote"] +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-by-connip.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-by-connip.json new file mode 100644 index 000000000..c9cb3ed0a --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-by-connip.json @@ -0,0 +1,64 @@ +{ + "Serial": "33", + "peerConfig": { + "address": "100.64.0.3/10", + "sshConfig": {}, + "fqdn": "peer-by-connip.netbird.test", + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "jEboa3bOv65XRbq8+8JuagiwWz+mM7Fc5MUfBQbOf6Y=", + "allowedIps": [ + "100.64.0.10/32" + ], + "sshConfig": {}, + "fqdn": "peer-srv.netbird.test", + "agentVersion": "0.60.0" + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-by-connip.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.3" + }, + { + "Name": "peer-srv.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.10" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "FirewallRules": [ + { + "PeerIP": "100.64.0.10", + "Protocol": "TCP", + "Port": "22", + "PolicyID": "cG9sLXJhbmdl" + }, + { + "PeerIP": "100.64.0.10", + "Direction": "OUT", + "Protocol": "TCP", + "Port": "22", + "PolicyID": "cG9sLXJhbmdl" + } + ], + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-office.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-office.json new file mode 100644 index 000000000..1b9834d12 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-office.json @@ -0,0 +1,64 @@ +{ + "Serial": "33", + "peerConfig": { + "address": "100.64.0.1/10", + "sshConfig": {}, + "fqdn": "peer-office.netbird.test", + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "jEboa3bOv65XRbq8+8JuagiwWz+mM7Fc5MUfBQbOf6Y=", + "allowedIps": [ + "100.64.0.10/32" + ], + "sshConfig": {}, + "fqdn": "peer-srv.netbird.test", + "agentVersion": "0.60.0" + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-office.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + }, + { + "Name": "peer-srv.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.10" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "FirewallRules": [ + { + "PeerIP": "100.64.0.10", + "Protocol": "TCP", + "Port": "22", + "PolicyID": "cG9sLXJhbmdl" + }, + { + "PeerIP": "100.64.0.10", + "Direction": "OUT", + "Protocol": "TCP", + "Port": "22", + "PolicyID": "cG9sLXJhbmdl" + } + ], + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-remote.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-remote.json new file mode 100644 index 000000000..134a0aff2 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-remote.json @@ -0,0 +1,33 @@ +{ + "Serial": "33", + "peerConfig": { + "address": "100.64.0.2/10", + "sshConfig": {}, + "fqdn": "peer-remote.netbird.test", + "autoUpdate": {} + }, + "remotePeersIsEmpty": true, + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-remote.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.2" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "firewallRulesIsEmpty": true, + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-srv.json new file mode 100644 index 000000000..a8499e031 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-srv.json @@ -0,0 +1,93 @@ +{ + "Serial": "33", + "peerConfig": { + "address": "100.64.0.10/10", + "sshConfig": {}, + "fqdn": "peer-srv.netbird.test", + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "2Za6YHlJPJPv3hG/vDvdT0emXqIQADX9+wE8F1ZsgHU=", + "allowedIps": [ + "100.64.0.3/32" + ], + "sshConfig": {}, + "fqdn": "peer-by-connip.netbird.test", + "agentVersion": "0.60.0" + }, + { + "wgPubKey": "iVuRGJEIX4iqqF3zV01cxAEgyjXW3X4rrMoal6iA4fc=", + "allowedIps": [ + "100.64.0.1/32" + ], + "sshConfig": {}, + "fqdn": "peer-office.netbird.test", + "agentVersion": "0.60.0" + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-by-connip.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.3" + }, + { + "Name": "peer-office.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + }, + { + "Name": "peer-srv.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.10" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "FirewallRules": [ + { + "PeerIP": "100.64.0.1", + "Protocol": "TCP", + "Port": "22", + "PolicyID": "cG9sLXJhbmdl" + }, + { + "PeerIP": "100.64.0.1", + "Direction": "OUT", + "Protocol": "TCP", + "Port": "22", + "PolicyID": "cG9sLXJhbmdl" + }, + { + "PeerIP": "100.64.0.3", + "Protocol": "TCP", + "Port": "22", + "PolicyID": "cG9sLXJhbmdl" + }, + { + "PeerIP": "100.64.0.3", + "Direction": "OUT", + "Protocol": "TCP", + "Port": "22", + "PolicyID": "cG9sLXJhbmdl" + } + ], + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/nmdata.json new file mode 100644 index 000000000..a258e855d --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/nmdata.json @@ -0,0 +1,42 @@ +{ + "Network": {"Serial": 33}, + "Peers": { + "peer-office": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0", "NetworkAddresses": [{"NetIP": "192.168.1.10/24"}]}}, + "peer-remote": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0", "NetworkAddresses": [{"NetIP": "10.0.0.5/8"}]}}, + "peer-by-connip": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.60.0"}, "Location": {"ConnectionIP": "192.168.5.5"}}, + "peer-noaddr": {"IP": "100.64.0.4", "Meta": {"WtVersion": "0.60.0"}}, + "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}} + }, + "Groups": { + "grp-clients": {"Peers": ["peer-office", "peer-remote", "peer-by-connip", "peer-noaddr"]}, + "grp-srv": {"Peers": ["peer-srv"]} + }, + "PostureChecks": { + "chk-range": { + "Checks": { + "PeerNetworkRangeCheck": { + "Action": "allow", + "Ranges": ["192.168.0.0/16"] + } + } + } + }, + "Policies": [ + { + "ID": "pol-range", + "Enabled": true, + "SourcePostureChecks": ["chk-range"], + "Rules": [ + { + "Enabled": true, + "Action": "accept", + "Protocol": "tcp", + "Ports": ["22"], + "Bidirectional": true, + "Sources": ["grp-clients"], + "Destinations": ["grp-srv"] + } + ] + } + ] +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/case.json new file mode 100644 index 000000000..b23a187d4 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/case.json @@ -0,0 +1,4 @@ +{ + "description": "OS version posture check with per-OS minimums. peer-srv must see only the peers that satisfy their own platform's rule: the Linux peer on kernel 6.1 (the check compares the part before the first dash) and the macOS peer on 14.2. The old Linux and macOS peers fail. peer-win fails too even though its version looks modern, because the check defines no Windows minimum and a platform with no rule configured is treated as failing, not as unrestricted — a surprising rule worth freezing. Each rejected peer also loses its own view of peer-srv, since the policy is its only connectivity.", + "peers": ["peer-srv", "peer-lin-ok", "peer-lin-old", "peer-win"] +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-lin-ok.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-lin-ok.json new file mode 100644 index 000000000..f299b9fcf --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-lin-ok.json @@ -0,0 +1,64 @@ +{ + "Serial": "30", + "peerConfig": { + "address": "100.64.0.1/10", + "sshConfig": {}, + "fqdn": "peer-lin-ok.netbird.test", + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "jEboa3bOv65XRbq8+8JuagiwWz+mM7Fc5MUfBQbOf6Y=", + "allowedIps": [ + "100.64.0.10/32" + ], + "sshConfig": {}, + "fqdn": "peer-srv.netbird.test", + "agentVersion": "0.60.0" + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-lin-ok.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + }, + { + "Name": "peer-srv.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.10" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "FirewallRules": [ + { + "PeerIP": "100.64.0.10", + "Protocol": "TCP", + "Port": "443", + "PolicyID": "cG9sLW9z" + }, + { + "PeerIP": "100.64.0.10", + "Direction": "OUT", + "Protocol": "TCP", + "Port": "443", + "PolicyID": "cG9sLW9z" + } + ], + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-lin-old.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-lin-old.json new file mode 100644 index 000000000..c0a5412fa --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-lin-old.json @@ -0,0 +1,33 @@ +{ + "Serial": "30", + "peerConfig": { + "address": "100.64.0.2/10", + "sshConfig": {}, + "fqdn": "peer-lin-old.netbird.test", + "autoUpdate": {} + }, + "remotePeersIsEmpty": true, + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-lin-old.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.2" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "firewallRulesIsEmpty": true, + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-srv.json new file mode 100644 index 000000000..91883d20b --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-srv.json @@ -0,0 +1,93 @@ +{ + "Serial": "30", + "peerConfig": { + "address": "100.64.0.10/10", + "sshConfig": {}, + "fqdn": "peer-srv.netbird.test", + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "XpdB4aptfFjgsQOHfEO65dNozY8R7EIw3/alAnXjl+k=", + "allowedIps": [ + "100.64.0.1/32" + ], + "sshConfig": {}, + "fqdn": "peer-lin-ok.netbird.test", + "agentVersion": "0.60.0" + }, + { + "wgPubKey": "gRO02HiaHUKq2xTbYJhPsmGj06bK0HGU2tgL0pKB2yQ=", + "allowedIps": [ + "100.64.0.3/32" + ], + "sshConfig": {}, + "fqdn": "peer-mac-ok.netbird.test", + "agentVersion": "0.60.0" + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-lin-ok.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + }, + { + "Name": "peer-mac-ok.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.3" + }, + { + "Name": "peer-srv.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.10" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "FirewallRules": [ + { + "PeerIP": "100.64.0.1", + "Protocol": "TCP", + "Port": "443", + "PolicyID": "cG9sLW9z" + }, + { + "PeerIP": "100.64.0.1", + "Direction": "OUT", + "Protocol": "TCP", + "Port": "443", + "PolicyID": "cG9sLW9z" + }, + { + "PeerIP": "100.64.0.3", + "Protocol": "TCP", + "Port": "443", + "PolicyID": "cG9sLW9z" + }, + { + "PeerIP": "100.64.0.3", + "Direction": "OUT", + "Protocol": "TCP", + "Port": "443", + "PolicyID": "cG9sLW9z" + } + ], + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-win.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-win.json new file mode 100644 index 000000000..0b0cafb26 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-win.json @@ -0,0 +1,33 @@ +{ + "Serial": "30", + "peerConfig": { + "address": "100.64.0.5/10", + "sshConfig": {}, + "fqdn": "peer-win.netbird.test", + "autoUpdate": {} + }, + "remotePeersIsEmpty": true, + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-win.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.5" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "firewallRulesIsEmpty": true, + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/nmdata.json new file mode 100644 index 000000000..018c0cc21 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/nmdata.json @@ -0,0 +1,43 @@ +{ + "Network": {"Serial": 30}, + "Peers": { + "peer-lin-ok": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0", "GoOS": "linux", "KernelVersion": "6.1.0-arch1"}}, + "peer-lin-old": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0", "GoOS": "linux", "KernelVersion": "5.4.0-generic"}}, + "peer-mac-ok": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.60.0", "GoOS": "darwin", "OSVersion": "14.2"}}, + "peer-mac-old": {"IP": "100.64.0.4", "Meta": {"WtVersion": "0.60.0", "GoOS": "darwin", "OSVersion": "12.0"}}, + "peer-win": {"IP": "100.64.0.5", "Meta": {"WtVersion": "0.60.0", "GoOS": "windows", "KernelVersion": "10.0.19045"}}, + "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0", "GoOS": "linux", "KernelVersion": "6.1.0-arch1"}} + }, + "Groups": { + "grp-clients": {"Peers": ["peer-lin-ok", "peer-lin-old", "peer-mac-ok", "peer-mac-old", "peer-win"]}, + "grp-srv": {"Peers": ["peer-srv"]} + }, + "PostureChecks": { + "chk-os": { + "Checks": { + "OSVersionCheck": { + "Linux": {"MinKernelVersion": "6.0.0"}, + "Darwin": {"MinVersion": "13.0"} + } + } + } + }, + "Policies": [ + { + "ID": "pol-os", + "Enabled": true, + "SourcePostureChecks": ["chk-os"], + "Rules": [ + { + "Enabled": true, + "Action": "accept", + "Protocol": "tcp", + "Ports": ["443"], + "Bidirectional": true, + "Sources": ["grp-clients"], + "Destinations": ["grp-srv"] + } + ] + } + ] +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/case.json new file mode 100644 index 000000000..b730389a6 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/case.json @@ -0,0 +1,4 @@ +{ + "description": "Process posture check, which picks the path for the peer's own platform. peer-lin-running and peer-mac-running each have their platform's process running and pass. peer-lin-stopped reports the same file but not running, so it fails — presence of the binary is not enough. peer-bsd runs an unsupported operating system, which the check reports as an error, and errors deny.", + "peers": ["peer-srv", "peer-lin-running", "peer-lin-stopped", "peer-bsd"] +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-bsd.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-bsd.json new file mode 100644 index 000000000..f75459d39 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-bsd.json @@ -0,0 +1,33 @@ +{ + "Serial": "34", + "peerConfig": { + "address": "100.64.0.4/10", + "sshConfig": {}, + "fqdn": "peer-bsd.netbird.test", + "autoUpdate": {} + }, + "remotePeersIsEmpty": true, + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-bsd.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.4" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "firewallRulesIsEmpty": true, + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-lin-running.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-lin-running.json new file mode 100644 index 000000000..ddfa39472 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-lin-running.json @@ -0,0 +1,62 @@ +{ + "Serial": "34", + "peerConfig": { + "address": "100.64.0.1/10", + "sshConfig": {}, + "fqdn": "peer-lin-running.netbird.test", + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "jEboa3bOv65XRbq8+8JuagiwWz+mM7Fc5MUfBQbOf6Y=", + "allowedIps": [ + "100.64.0.10/32" + ], + "sshConfig": {}, + "fqdn": "peer-srv.netbird.test", + "agentVersion": "0.60.0" + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-lin-running.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + }, + { + "Name": "peer-srv.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.10" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "FirewallRules": [ + { + "PeerIP": "100.64.0.10", + "Protocol": "ALL", + "PolicyID": "cG9sLXByb2M=" + }, + { + "PeerIP": "100.64.0.10", + "Direction": "OUT", + "Protocol": "ALL", + "PolicyID": "cG9sLXByb2M=" + } + ], + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-lin-stopped.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-lin-stopped.json new file mode 100644 index 000000000..828a933f7 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-lin-stopped.json @@ -0,0 +1,33 @@ +{ + "Serial": "34", + "peerConfig": { + "address": "100.64.0.2/10", + "sshConfig": {}, + "fqdn": "peer-lin-stopped.netbird.test", + "autoUpdate": {} + }, + "remotePeersIsEmpty": true, + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-lin-stopped.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.2" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "firewallRulesIsEmpty": true, + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-srv.json new file mode 100644 index 000000000..6eb409623 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-srv.json @@ -0,0 +1,89 @@ +{ + "Serial": "34", + "peerConfig": { + "address": "100.64.0.10/10", + "sshConfig": {}, + "fqdn": "peer-srv.netbird.test", + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "d9LCTf7vwqctprOKyF95j17uPWpRjEeirHfB75RIGlk=", + "allowedIps": [ + "100.64.0.1/32" + ], + "sshConfig": {}, + "fqdn": "peer-lin-running.netbird.test", + "agentVersion": "0.60.0" + }, + { + "wgPubKey": "rgP4xt50GcHp7fFgBoSt8yz5bp5AnOAVHMmOd+rTbR4=", + "allowedIps": [ + "100.64.0.3/32" + ], + "sshConfig": {}, + "fqdn": "peer-mac-running.netbird.test", + "agentVersion": "0.60.0" + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-lin-running.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + }, + { + "Name": "peer-mac-running.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.3" + }, + { + "Name": "peer-srv.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.10" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "FirewallRules": [ + { + "PeerIP": "100.64.0.1", + "Protocol": "ALL", + "PolicyID": "cG9sLXByb2M=" + }, + { + "PeerIP": "100.64.0.1", + "Direction": "OUT", + "Protocol": "ALL", + "PolicyID": "cG9sLXByb2M=" + }, + { + "PeerIP": "100.64.0.3", + "Protocol": "ALL", + "PolicyID": "cG9sLXByb2M=" + }, + { + "PeerIP": "100.64.0.3", + "Direction": "OUT", + "Protocol": "ALL", + "PolicyID": "cG9sLXByb2M=" + } + ], + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/nmdata.json new file mode 100644 index 000000000..b112e2235 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/nmdata.json @@ -0,0 +1,70 @@ +{ + "Network": {"Serial": 34}, + "Peers": { + "peer-lin-running": { + "IP": "100.64.0.1", + "Meta": { + "WtVersion": "0.60.0", + "GoOS": "linux", + "Files": [{"Path": "/usr/bin/agent", "ProcessIsRunning": true}] + } + }, + "peer-lin-stopped": { + "IP": "100.64.0.2", + "Meta": { + "WtVersion": "0.60.0", + "GoOS": "linux", + "Files": [{"Path": "/usr/bin/agent"}] + } + }, + "peer-mac-running": { + "IP": "100.64.0.3", + "Meta": { + "WtVersion": "0.60.0", + "GoOS": "darwin", + "Files": [{"Path": "/Applications/Agent.app", "ProcessIsRunning": true}] + } + }, + "peer-bsd": { + "IP": "100.64.0.4", + "Meta": { + "WtVersion": "0.60.0", + "GoOS": "freebsd", + "Files": [{"Path": "/usr/bin/agent", "ProcessIsRunning": true}] + } + }, + "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0", "GoOS": "linux"}} + }, + "Groups": { + "grp-clients": {"Peers": ["peer-lin-running", "peer-lin-stopped", "peer-mac-running", "peer-bsd"]}, + "grp-srv": {"Peers": ["peer-srv"]} + }, + "PostureChecks": { + "chk-proc": { + "Checks": { + "ProcessCheck": { + "Processes": [ + {"LinuxPath": "/usr/bin/agent", "MacPath": "/Applications/Agent.app"} + ] + } + } + } + }, + "Policies": [ + { + "ID": "pol-proc", + "Enabled": true, + "SourcePostureChecks": ["chk-proc"], + "Rules": [ + { + "Enabled": true, + "Action": "accept", + "Protocol": "all", + "Bidirectional": true, + "Sources": ["grp-clients"], + "Destinations": ["grp-srv"] + } + ] + } + ] +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/case.json new file mode 100644 index 000000000..b30b06243 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/case.json @@ -0,0 +1,4 @@ +{ + "description": "Posture check on a policy granting access to a network resource. peer-ok must receive the route to the resource through peer-r, while peer-bad fails the version check and must receive no route at all. The router's route firewall rule must narrow its SourceRanges to peer-ok's address only — a peer rejected by posture must not be permitted through the routing peer either, which is the enforcement that actually matters.", + "peers": ["peer-ok", "peer-bad", "peer-r"] +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-bad.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-bad.json new file mode 100644 index 000000000..b1aa1a494 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-bad.json @@ -0,0 +1,34 @@ +{ + "Serial": "38", + "peerConfig": { + "address": "100.64.0.2/10", + "sshConfig": {}, + "fqdn": "peer-bad.netbird.test", + "RoutingPeerDnsResolutionEnabled": true, + "autoUpdate": {} + }, + "remotePeersIsEmpty": true, + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-bad.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.2" + } + ] + } + ], + "ForwarderPort": "5353" + }, + "firewallRulesIsEmpty": true, + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-ok.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-ok.json new file mode 100644 index 000000000..6aa688eef --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-ok.json @@ -0,0 +1,56 @@ +{ + "Serial": "38", + "peerConfig": { + "address": "100.64.0.1/10", + "sshConfig": {}, + "fqdn": "peer-ok.netbird.test", + "RoutingPeerDnsResolutionEnabled": true, + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=", + "allowedIps": [ + "100.64.0.9/32" + ], + "sshConfig": {}, + "fqdn": "peer-r.netbird.test", + "agentVersion": "1.0.0" + } + ], + "Routes": [ + { + "ID": "res-db:peer-r", + "Network": "10.80.0.0/24", + "NetworkType": "1", + "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=", + "Metric": "9999", + "Masquerade": true, + "NetID": "db-subnet", + "keepRoute": true + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-ok.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + } + ] + } + ], + "ForwarderPort": "5353" + }, + "firewallRulesIsEmpty": true, + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-r.json new file mode 100644 index 000000000..192304ca6 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-r.json @@ -0,0 +1,69 @@ +{ + "Serial": "38", + "peerConfig": { + "address": "100.64.0.9/10", + "sshConfig": {}, + "fqdn": "peer-r.netbird.test", + "RoutingPeerDnsResolutionEnabled": true, + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "XPXITqbev8jVtIkumbPt5vpohe2OHWvhNGO3z2mgcxM=", + "allowedIps": [ + "100.64.0.1/32" + ], + "sshConfig": {}, + "fqdn": "peer-ok.netbird.test", + "agentVersion": "1.0.0" + } + ], + "Routes": [ + { + "ID": "res-db:peer-r", + "Network": "10.80.0.0/24", + "NetworkType": "1", + "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=", + "Metric": "9999", + "Masquerade": true, + "NetID": "db-subnet", + "keepRoute": true + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-r.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.9" + } + ] + } + ], + "ForwarderPort": "5353" + }, + "firewallRulesIsEmpty": true, + "routesFirewallRules": [ + { + "sourceRanges": [ + "100.64.0.1/32" + ], + "destination": "10.80.0.0/24", + "protocol": "TCP", + "portInfo": { + "port": 5432 + }, + "PolicyID": "cG9sLXJlcw==", + "RouteID": "res-db:peer-r" + } + ], + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/nmdata.json new file mode 100644 index 000000000..840c5a7f8 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/nmdata.json @@ -0,0 +1,48 @@ +{ + "Network": {"Serial": 38}, + "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true}, + "Peers": { + "peer-ok": {"IP": "100.64.0.1", "Meta": {"WtVersion": "1.0.0"}}, + "peer-bad": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.30.0"}}, + "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "1.0.0"}} + }, + "Groups": { + "grp-dev": {"Peers": ["peer-ok", "peer-bad"]} + }, + "PostureChecks": { + "chk-version": {"Checks": {"NBVersionCheck": {"MinVersion": "0.45.0"}}} + }, + "Policies": [ + { + "ID": "pol-res", + "Enabled": true, + "SourcePostureChecks": ["chk-version"], + "Rules": [ + { + "Enabled": true, + "Action": "accept", + "Protocol": "tcp", + "Ports": ["5432"], + "Sources": ["grp-dev"], + "DestinationResource": {"ID": "res-db", "Type": "subnet"} + } + ] + } + ], + "ResourcePolicies": {"res-db": [{"ID": "pol-res"}]}, + "NetworkResources": [ + { + "ID": "res-db", + "NetworkID": "net-1", + "Name": "db-subnet", + "Type": "subnet", + "Prefix": "10.80.0.0/24", + "Enabled": true + } + ], + "Routers": { + "net-1": { + "peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true} + } + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/case.json new file mode 100644 index 000000000..203b89533 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/case.json @@ -0,0 +1,4 @@ +{ + "description": "The same peer under two policies carrying different posture checks. peer-x is on an old agent version but in an allowed country, so the version-gated policy rejects it while the location-gated one admits it: it must reach peer-srv-b on 8443 and not peer-srv-a at all. Failing one policy's check must not leak into another policy's decision. peer-srv-a correspondingly sees nobody, peer-srv-b sees peer-x.", + "peers": ["peer-x", "peer-srv-a", "peer-srv-b"] +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-srv-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-srv-a.json new file mode 100644 index 000000000..8008db39c --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-srv-a.json @@ -0,0 +1,33 @@ +{ + "Serial": "36", + "peerConfig": { + "address": "100.64.0.11/10", + "sshConfig": {}, + "fqdn": "peer-srv-a.netbird.test", + "autoUpdate": {} + }, + "remotePeersIsEmpty": true, + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-srv-a.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.11" + } + ] + } + ], + "ForwarderPort": "5353" + }, + "firewallRulesIsEmpty": true, + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-srv-b.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-srv-b.json new file mode 100644 index 000000000..c7bc45742 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-srv-b.json @@ -0,0 +1,64 @@ +{ + "Serial": "36", + "peerConfig": { + "address": "100.64.0.12/10", + "sshConfig": {}, + "fqdn": "peer-srv-b.netbird.test", + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "N1oKwtIwXdTDF0HdKDss0gPhxkqz+/Z/91734QZVCng=", + "allowedIps": [ + "100.64.0.1/32" + ], + "sshConfig": {}, + "fqdn": "peer-x.netbird.test", + "agentVersion": "0.40.0" + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-srv-b.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.12" + }, + { + "Name": "peer-x.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + } + ] + } + ], + "ForwarderPort": "5353" + }, + "FirewallRules": [ + { + "PeerIP": "100.64.0.1", + "Protocol": "TCP", + "Port": "8443", + "PolicyID": "cG9sLWxlbmllbnQ=" + }, + { + "PeerIP": "100.64.0.1", + "Direction": "OUT", + "Protocol": "TCP", + "Port": "8443", + "PolicyID": "cG9sLWxlbmllbnQ=" + } + ], + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-x.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-x.json new file mode 100644 index 000000000..461454233 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-x.json @@ -0,0 +1,64 @@ +{ + "Serial": "36", + "peerConfig": { + "address": "100.64.0.1/10", + "sshConfig": {}, + "fqdn": "peer-x.netbird.test", + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "fT/Mb0QBqXGx2q06gXqizvlXp5uz+ErGCaKmCEXVbMk=", + "allowedIps": [ + "100.64.0.12/32" + ], + "sshConfig": {}, + "fqdn": "peer-srv-b.netbird.test", + "agentVersion": "1.0.0" + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-srv-b.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.12" + }, + { + "Name": "peer-x.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + } + ] + } + ], + "ForwarderPort": "5353" + }, + "FirewallRules": [ + { + "PeerIP": "100.64.0.12", + "Protocol": "TCP", + "Port": "8443", + "PolicyID": "cG9sLWxlbmllbnQ=" + }, + { + "PeerIP": "100.64.0.12", + "Direction": "OUT", + "Protocol": "TCP", + "Port": "8443", + "PolicyID": "cG9sLWxlbmllbnQ=" + } + ], + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/nmdata.json new file mode 100644 index 000000000..2e0512194 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/nmdata.json @@ -0,0 +1,55 @@ +{ + "Network": {"Serial": 36}, + "Peers": { + "peer-x": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.40.0"}, "Location": {"CountryCode": "DE", "CityName": "Berlin"}}, + "peer-srv-a": {"IP": "100.64.0.11", "Meta": {"WtVersion": "1.0.0"}}, + "peer-srv-b": {"IP": "100.64.0.12", "Meta": {"WtVersion": "1.0.0"}} + }, + "Groups": { + "grp-clients": {"Peers": ["peer-x"]}, + "grp-srv-a": {"Peers": ["peer-srv-a"]}, + "grp-srv-b": {"Peers": ["peer-srv-b"]} + }, + "PostureChecks": { + "chk-version": {"Checks": {"NBVersionCheck": {"MinVersion": "0.45.0"}}}, + "chk-geo": { + "Checks": { + "GeoLocationCheck": {"Action": "allow", "Locations": [{"CountryCode": "DE"}]} + } + } + }, + "Policies": [ + { + "ID": "pol-strict", + "Enabled": true, + "SourcePostureChecks": ["chk-version"], + "Rules": [ + { + "Enabled": true, + "Action": "accept", + "Protocol": "tcp", + "Ports": ["443"], + "Bidirectional": true, + "Sources": ["grp-clients"], + "Destinations": ["grp-srv-a"] + } + ] + }, + { + "ID": "pol-lenient", + "Enabled": true, + "SourcePostureChecks": ["chk-geo"], + "Rules": [ + { + "Enabled": true, + "Action": "accept", + "Protocol": "tcp", + "Ports": ["8443"], + "Bidirectional": true, + "Sources": ["grp-clients"], + "Destinations": ["grp-srv-b"] + } + ] + } + ] +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/case.json new file mode 100644 index 000000000..5f9e98ea7 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/case.json @@ -0,0 +1,7 @@ +{ + "description": "A reverse-proxy service targeting a domain network resource. The synthesised proxy-access ACL is a resource policy too: on the account path the resource-policy map was built after injection, so the routing peer must carry a route firewall rule sourced from the proxy peer for the resource's domain. The store reads the policies table and ResourcePolicies never holds it, so only the synthesis puts it there.", + "peers": [ + "router-peer", + "proxy-peer" + ] +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/golden/proxy-peer.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/golden/proxy-peer.json new file mode 100644 index 000000000..a9e74061f --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/golden/proxy-peer.json @@ -0,0 +1,60 @@ +{ + "Serial": "32", + "peerConfig": { + "address": "100.64.0.99/10", + "sshConfig": {}, + "fqdn": "proxy-peer.netbird.test", + "RoutingPeerDnsResolutionEnabled": true, + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "4IuEBozN3DjkJXkJtBp/9Ekkr0zkfucwk9gqxWhV1hE=", + "allowedIps": [ + "100.64.0.9/32" + ], + "sshConfig": {}, + "fqdn": "router-peer.netbird.test", + "lazyState": "LazyStateLazy", + "agentVersion": "0.60.0" + } + ], + "Routes": [ + { + "ID": "res-domain:router-peer", + "Network": "192.0.2.0/32", + "NetworkType": "3", + "Peer": "4IuEBozN3DjkJXkJtBp/9Ekkr0zkfucwk9gqxWhV1hE=", + "Metric": "9999", + "Masquerade": true, + "NetID": "app-domain", + "Domains": [ + "app.internal" + ], + "keepRoute": true + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "proxy-peer.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.99" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "firewallRulesIsEmpty": true, + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/golden/router-peer.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/golden/router-peer.json new file mode 100644 index 000000000..d0bb48a3b --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/golden/router-peer.json @@ -0,0 +1,80 @@ +{ + "Serial": "32", + "peerConfig": { + "address": "100.64.0.9/10", + "sshConfig": {}, + "fqdn": "router-peer.netbird.test", + "RoutingPeerDnsResolutionEnabled": true, + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "MgrwmZOHFZ+i0SXrfbBOcATxBAQsWKllrGL/32GvlxY=", + "allowedIps": [ + "100.64.0.99/32" + ], + "sshConfig": {}, + "fqdn": "proxy-peer.netbird.test", + "lazyState": "LazyStateLazy", + "agentVersion": "0.60.0" + } + ], + "Routes": [ + { + "ID": "res-domain:router-peer", + "Network": "192.0.2.0/32", + "NetworkType": "3", + "Peer": "4IuEBozN3DjkJXkJtBp/9Ekkr0zkfucwk9gqxWhV1hE=", + "Metric": "9999", + "Masquerade": true, + "NetID": "app-domain", + "Domains": [ + "app.internal" + ], + "keepRoute": true + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "router-peer.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.9" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "firewallRulesIsEmpty": true, + "routesFirewallRules": [ + { + "sourceRanges": [ + "100.64.0.99/32" + ], + "destination": "192.0.2.0/32", + "protocol": "TCP", + "portInfo": { + "range": { + "start": 443, + "end": 443 + } + }, + "isDynamic": true, + "domains": [ + "app.internal" + ], + "PolicyID": "cHJveHktYWNjZXNzLXN2Yy0xLXByb3h5LXBlZXIt", + "RouteID": "res-domain:router-peer" + } + ], + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/nmdata.json new file mode 100644 index 000000000..cbf729d9f --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/nmdata.json @@ -0,0 +1,44 @@ +{ + "Network": {"Serial": 32}, + "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true}, + "Peers": { + "router-peer": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}, + "proxy-peer": { + "IP": "100.64.0.99", + "Meta": {"WtVersion": "0.60.0"}, + "ProxyMeta": {"Embedded": true, "Cluster": "eu.proxy.netbird.io"} + } + }, + "NetworkResources": [ + { + "ID": "res-domain", + "NetworkID": "net-1", + "Name": "app-domain", + "Type": "domain", + "Domain": "app.internal", + "Enabled": true + } + ], + "Routers": { + "net-1": { + "router-peer": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true} + } + }, + "ProxyTargetedDomainResourceIDs": {"res-domain": {}}, + "Services": [ + { + "ID": "svc-1", + "Enabled": true, + "Mode": "http", + "ProxyCluster": "eu.proxy.netbird.io", + "Targets": [ + { + "Enabled": true, + "Protocol": "https", + "TargetID": "res-domain", + "TargetType": "domain" + } + ] + } + ] +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/case.json new file mode 100644 index 000000000..d8450505f --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/case.json @@ -0,0 +1,7 @@ +{ + "description": "A reverse-proxy service targeting a peer. The proxy-access ACL is synthesised from Services, never loaded from the policies table, and is what lets the cluster's embedded proxy peer reach the target on the target's port: proxy-peer gets an OUT rule to app-peer on TCP 8080 and app-peer the matching IN rule. Without the synthesis both maps are empty of each other.", + "peers": [ + "proxy-peer", + "app-peer" + ] +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/golden/app-peer.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/golden/app-peer.json new file mode 100644 index 000000000..0e71a62ba --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/golden/app-peer.json @@ -0,0 +1,64 @@ +{ + "Serial": "30", + "peerConfig": { + "address": "100.64.0.10/10", + "sshConfig": {}, + "fqdn": "app-peer.netbird.test", + "RoutingPeerDnsResolutionEnabled": true, + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "MgrwmZOHFZ+i0SXrfbBOcATxBAQsWKllrGL/32GvlxY=", + "allowedIps": [ + "100.64.0.99/32" + ], + "sshConfig": {}, + "fqdn": "proxy-peer.netbird.test", + "lazyState": "LazyStateLazy", + "agentVersion": "0.60.0" + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "app-peer.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.10" + }, + { + "Name": "proxy-peer.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.99" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "FirewallRules": [ + { + "PeerIP": "100.64.0.99", + "Protocol": "TCP", + "PortInfo": { + "range": { + "start": 8080, + "end": 8080 + } + }, + "PolicyID": "cHJveHktYWNjZXNzLXN2Yy0xLXByb3h5LXBlZXIt" + } + ], + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/golden/proxy-peer.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/golden/proxy-peer.json new file mode 100644 index 000000000..4c6053319 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/golden/proxy-peer.json @@ -0,0 +1,65 @@ +{ + "Serial": "30", + "peerConfig": { + "address": "100.64.0.99/10", + "sshConfig": {}, + "fqdn": "proxy-peer.netbird.test", + "RoutingPeerDnsResolutionEnabled": true, + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "/wFxrqMtMwWNZak/f0UDddUkCZMTmxNuiuk4/RGGNcY=", + "allowedIps": [ + "100.64.0.10/32" + ], + "sshConfig": {}, + "fqdn": "app-peer.netbird.test", + "lazyState": "LazyStateLazy", + "agentVersion": "0.60.0" + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "app-peer.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.10" + }, + { + "Name": "proxy-peer.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.99" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "FirewallRules": [ + { + "PeerIP": "100.64.0.10", + "Direction": "OUT", + "Protocol": "TCP", + "PortInfo": { + "range": { + "start": 8080, + "end": 8080 + } + }, + "PolicyID": "cHJveHktYWNjZXNzLXN2Yy0xLXByb3h5LXBlZXIt" + } + ], + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/nmdata.json new file mode 100644 index 000000000..b4645fb90 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/nmdata.json @@ -0,0 +1,29 @@ +{ + "Network": {"Serial": 30}, + "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true}, + "Peers": { + "proxy-peer": { + "IP": "100.64.0.99", + "Meta": {"WtVersion": "0.60.0"}, + "ProxyMeta": {"Embedded": true, "Cluster": "eu.proxy.netbird.io"} + }, + "app-peer": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}} + }, + "Services": [ + { + "ID": "svc-1", + "Enabled": true, + "Mode": "http", + "ProxyCluster": "eu.proxy.netbird.io", + "Targets": [ + { + "Enabled": true, + "Port": 8080, + "Protocol": "http", + "TargetID": "app-peer", + "TargetType": "peer" + } + ] + } + ] +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/case.json new file mode 100644 index 000000000..8ef26f4cc --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/case.json @@ -0,0 +1,7 @@ +{ + "description": "A private reverse-proxy service. The private-access ACL is synthesised from Services, never loaded from the policies table, and is what lets the service's AccessGroups reach the cluster's embedded proxy peer on TCP 80 and 443: user-peer gets OUT rules on both ports and proxy-peer the matching IN rules. Without the synthesis both maps are empty of each other.", + "peers": [ + "user-peer", + "proxy-peer" + ] +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/golden/proxy-peer.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/golden/proxy-peer.json new file mode 100644 index 000000000..7022f0c70 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/golden/proxy-peer.json @@ -0,0 +1,75 @@ +{ + "Serial": "31", + "peerConfig": { + "address": "100.64.0.99/10", + "sshConfig": {}, + "fqdn": "proxy-peer.netbird.test", + "RoutingPeerDnsResolutionEnabled": true, + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "v19TN/CymWAs/WppcLjz3atM+t4ySNdImGtoPu4wnT8=", + "allowedIps": [ + "100.64.0.10/32" + ], + "sshConfig": {}, + "fqdn": "user-peer.netbird.test", + "lazyState": "LazyStateLazy", + "agentVersion": "0.60.0" + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "proxy-peer.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.99" + }, + { + "Name": "user-peer.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.10" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "FirewallRules": [ + { + "PeerIP": "100.64.0.10", + "Protocol": "TCP", + "PortInfo": { + "range": { + "start": 443, + "end": 443 + } + }, + "PolicyID": "cHJpdmF0ZS1hY2Nlc3Mtc3ZjLTEtcHJveHktcGVlcg==" + }, + { + "PeerIP": "100.64.0.10", + "Protocol": "TCP", + "PortInfo": { + "range": { + "start": 80, + "end": 80 + } + }, + "PolicyID": "cHJpdmF0ZS1hY2Nlc3Mtc3ZjLTEtcHJveHktcGVlcg==" + } + ], + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/golden/user-peer.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/golden/user-peer.json new file mode 100644 index 000000000..7197abf22 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/golden/user-peer.json @@ -0,0 +1,77 @@ +{ + "Serial": "31", + "peerConfig": { + "address": "100.64.0.10/10", + "sshConfig": {}, + "fqdn": "user-peer.netbird.test", + "RoutingPeerDnsResolutionEnabled": true, + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "MgrwmZOHFZ+i0SXrfbBOcATxBAQsWKllrGL/32GvlxY=", + "allowedIps": [ + "100.64.0.99/32" + ], + "sshConfig": {}, + "fqdn": "proxy-peer.netbird.test", + "lazyState": "LazyStateLazy", + "agentVersion": "0.60.0" + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "proxy-peer.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.99" + }, + { + "Name": "user-peer.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.10" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "FirewallRules": [ + { + "PeerIP": "100.64.0.99", + "Direction": "OUT", + "Protocol": "TCP", + "PortInfo": { + "range": { + "start": 443, + "end": 443 + } + }, + "PolicyID": "cHJpdmF0ZS1hY2Nlc3Mtc3ZjLTEtcHJveHktcGVlcg==" + }, + { + "PeerIP": "100.64.0.99", + "Direction": "OUT", + "Protocol": "TCP", + "PortInfo": { + "range": { + "start": 80, + "end": 80 + } + }, + "PolicyID": "cHJpdmF0ZS1hY2Nlc3Mtc3ZjLTEtcHJveHktcGVlcg==" + } + ], + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/nmdata.json new file mode 100644 index 000000000..066849bd4 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/nmdata.json @@ -0,0 +1,26 @@ +{ + "Network": {"Serial": 31}, + "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true}, + "Peers": { + "user-peer": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}}, + "other-peer": {"IP": "100.64.0.11", "Meta": {"WtVersion": "0.60.0"}}, + "proxy-peer": { + "IP": "100.64.0.99", + "Meta": {"WtVersion": "0.60.0"}, + "ProxyMeta": {"Embedded": true, "Cluster": "eu.proxy.netbird.io"} + } + }, + "Groups": { + "grp-admins": {"Peers": ["user-peer"]} + }, + "Services": [ + { + "ID": "svc-1", + "Enabled": true, + "Private": true, + "Mode": "http", + "ProxyCluster": "eu.proxy.netbird.io", + "AccessGroups": ["grp-admins", "grp-deleted"] + } + ] +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/case.json new file mode 100644 index 000000000..8d99a239c --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/case.json @@ -0,0 +1,4 @@ +{ + "description": "Classic route with access control groups: instead of the wide-open default permit, peer-r's route firewall rule must be narrowed to the policy that targets the ACL group — protocol and port from that rule, SourceRanges limited to the two source peers. peer-a still receives the route itself.", + "peers": ["peer-a", "peer-r"] +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/golden/peer-a.json new file mode 100644 index 000000000..c52aecce9 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/golden/peer-a.json @@ -0,0 +1,76 @@ +{ + "Serial": "27", + "peerConfig": { + "address": "100.64.0.1/10", + "sshConfig": {}, + "fqdn": "peer-a.netbird.test", + "RoutingPeerDnsResolutionEnabled": true, + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=", + "allowedIps": [ + "100.64.0.9/32" + ], + "sshConfig": {}, + "fqdn": "peer-r.netbird.test", + "agentVersion": "0.60.0" + } + ], + "Routes": [ + { + "ID": "rt-acl", + "Network": "10.70.0.0/24", + "NetworkType": "1", + "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=", + "Metric": "9999", + "Masquerade": true, + "NetID": "db-net" + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-a.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + }, + { + "Name": "peer-r.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.9" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "FirewallRules": [ + { + "PeerIP": "100.64.0.9", + "Protocol": "TCP", + "Port": "3306", + "PolicyID": "cG9sLWFjbA==" + }, + { + "PeerIP": "100.64.0.9", + "Direction": "OUT", + "Protocol": "TCP", + "Port": "3306", + "PolicyID": "cG9sLWFjbA==" + } + ], + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/golden/peer-r.json new file mode 100644 index 000000000..cc03ef08c --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/golden/peer-r.json @@ -0,0 +1,119 @@ +{ + "Serial": "27", + "peerConfig": { + "address": "100.64.0.9/10", + "sshConfig": {}, + "fqdn": "peer-r.netbird.test", + "RoutingPeerDnsResolutionEnabled": true, + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "AvldyrZ12Pf90jzf3AXmhPwg3UcI+jtJHfbpBlupvko=", + "allowedIps": [ + "100.64.0.2/32" + ], + "sshConfig": {}, + "fqdn": "peer-b.netbird.test", + "agentVersion": "0.60.0" + }, + { + "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=", + "allowedIps": [ + "100.64.0.1/32" + ], + "sshConfig": {}, + "fqdn": "peer-a.netbird.test", + "agentVersion": "0.60.0" + } + ], + "Routes": [ + { + "ID": "rt-acl", + "Network": "10.70.0.0/24", + "NetworkType": "1", + "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=", + "Metric": "9999", + "Masquerade": true, + "NetID": "db-net" + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-a.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + }, + { + "Name": "peer-b.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.2" + }, + { + "Name": "peer-r.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.9" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "FirewallRules": [ + { + "PeerIP": "100.64.0.1", + "Protocol": "TCP", + "Port": "3306", + "PolicyID": "cG9sLWFjbA==" + }, + { + "PeerIP": "100.64.0.1", + "Direction": "OUT", + "Protocol": "TCP", + "Port": "3306", + "PolicyID": "cG9sLWFjbA==" + }, + { + "PeerIP": "100.64.0.2", + "Protocol": "TCP", + "Port": "3306", + "PolicyID": "cG9sLWFjbA==" + }, + { + "PeerIP": "100.64.0.2", + "Direction": "OUT", + "Protocol": "TCP", + "Port": "3306", + "PolicyID": "cG9sLWFjbA==" + } + ], + "routesFirewallRules": [ + { + "sourceRanges": [ + "100.64.0.1/32", + "100.64.0.2/32" + ], + "destination": "10.70.0.0/24", + "protocol": "TCP", + "portInfo": { + "port": 3306 + }, + "PolicyID": "cG9sLWFjbA==", + "RouteID": "rt-acl" + } + ], + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/nmdata.json new file mode 100644 index 000000000..d7bfd0d08 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/nmdata.json @@ -0,0 +1,45 @@ +{ + "Network": {"Serial": 27}, + "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true}, + "Peers": { + "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}}, + "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}}, + "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}} + }, + "Groups": { + "grp-dev": {"Peers": ["peer-a", "peer-b"]}, + "grp-acl": {"Peers": ["peer-r"]} + }, + "Policies": [ + { + "ID": "pol-acl", + "Enabled": true, + "Rules": [ + { + "Enabled": true, + "Action": "accept", + "Protocol": "tcp", + "Ports": ["3306"], + "Bidirectional": true, + "Sources": ["grp-dev"], + "Destinations": ["grp-acl"] + } + ] + } + ], + "Routes": [ + { + "ID": "rt-acl", + "NetID": "db-net", + "Network": "10.70.0.0/24", + "NetworkType": 1, + "Peer": "peer-r", + "PeerID": "peer-r", + "Groups": ["grp-dev"], + "AccessControlGroups": ["grp-acl"], + "Metric": 9999, + "Masquerade": true, + "Enabled": true + } + ] +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/case.json new file mode 100644 index 000000000..52d45346a --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/case.json @@ -0,0 +1,4 @@ +{ + "description": "Classic route served by a peer group instead of one peer: each routing peer's copy takes the route id with its own peer id appended and drops the PeerGroups field, and the distribution group's peer-a must receive both copies as an HA pair. Each router receives only its own copy plus a default-permit route firewall rule, because the route carries no access control groups. A policy connecting the two groups is required — route distribution follows peers the target may already talk to.", + "peers": ["peer-a", "peer-r1"] +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/golden/peer-a.json new file mode 100644 index 000000000..fd9e32274 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/golden/peer-a.json @@ -0,0 +1,114 @@ +{ + "Serial": "26", + "peerConfig": { + "address": "100.64.0.1/10", + "sshConfig": {}, + "fqdn": "peer-a.netbird.test", + "RoutingPeerDnsResolutionEnabled": true, + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "Iu6Lj1HqDXfgDh5RppHCRI6RO3lMlZBCndRNrdS7QUI=", + "allowedIps": [ + "100.64.0.11/32" + ], + "sshConfig": {}, + "fqdn": "peer-r1.netbird.test", + "agentVersion": "0.60.0" + }, + { + "wgPubKey": "YC6sbWtvpB2d6W/XqsNIV9crsrXBoVDdwmhCoszjCps=", + "allowedIps": [ + "100.64.0.12/32" + ], + "sshConfig": {}, + "fqdn": "peer-r2.netbird.test", + "agentVersion": "0.60.0" + } + ], + "Routes": [ + { + "ID": "rt-ha:peer-r1", + "Network": "10.60.0.0/24", + "NetworkType": "1", + "Peer": "Iu6Lj1HqDXfgDh5RppHCRI6RO3lMlZBCndRNrdS7QUI=", + "Metric": "9999", + "Masquerade": true, + "NetID": "ha-net" + }, + { + "ID": "rt-ha:peer-r2", + "Network": "10.60.0.0/24", + "NetworkType": "1", + "Peer": "YC6sbWtvpB2d6W/XqsNIV9crsrXBoVDdwmhCoszjCps=", + "Metric": "9999", + "Masquerade": true, + "NetID": "ha-net" + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-a.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + }, + { + "Name": "peer-r1.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.11" + }, + { + "Name": "peer-r2.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.12" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "FirewallRules": [ + { + "PeerIP": "100.64.0.11", + "Protocol": "TCP", + "Port": "8080", + "PolicyID": "cG9sLWNvbm4=" + }, + { + "PeerIP": "100.64.0.11", + "Direction": "OUT", + "Protocol": "TCP", + "Port": "8080", + "PolicyID": "cG9sLWNvbm4=" + }, + { + "PeerIP": "100.64.0.12", + "Protocol": "TCP", + "Port": "8080", + "PolicyID": "cG9sLWNvbm4=" + }, + { + "PeerIP": "100.64.0.12", + "Direction": "OUT", + "Protocol": "TCP", + "Port": "8080", + "PolicyID": "cG9sLWNvbm4=" + } + ], + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/golden/peer-r1.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/golden/peer-r1.json new file mode 100644 index 000000000..bd8bdecd0 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/golden/peer-r1.json @@ -0,0 +1,93 @@ +{ + "Serial": "26", + "peerConfig": { + "address": "100.64.0.11/10", + "sshConfig": {}, + "fqdn": "peer-r1.netbird.test", + "RoutingPeerDnsResolutionEnabled": true, + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=", + "allowedIps": [ + "100.64.0.1/32" + ], + "sshConfig": {}, + "fqdn": "peer-a.netbird.test", + "agentVersion": "0.60.0" + } + ], + "Routes": [ + { + "ID": "rt-ha:peer-r1", + "Network": "10.60.0.0/24", + "NetworkType": "1", + "Peer": "Iu6Lj1HqDXfgDh5RppHCRI6RO3lMlZBCndRNrdS7QUI=", + "Metric": "9999", + "Masquerade": true, + "NetID": "ha-net" + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-a.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + }, + { + "Name": "peer-r1.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.11" + }, + { + "Name": "peer-r2.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.12" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "FirewallRules": [ + { + "PeerIP": "100.64.0.1", + "Protocol": "TCP", + "Port": "8080", + "PolicyID": "cG9sLWNvbm4=" + }, + { + "PeerIP": "100.64.0.1", + "Direction": "OUT", + "Protocol": "TCP", + "Port": "8080", + "PolicyID": "cG9sLWNvbm4=" + } + ], + "routesFirewallRules": [ + { + "sourceRanges": [ + "0.0.0.0/0" + ], + "destination": "10.60.0.0/24", + "protocol": "ALL", + "portInfo": {}, + "RouteID": "rt-ha:peer-r1" + } + ], + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/nmdata.json new file mode 100644 index 000000000..8b81a585f --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/nmdata.json @@ -0,0 +1,43 @@ +{ + "Network": {"Serial": 26}, + "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true}, + "Peers": { + "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}}, + "peer-r1": {"IP": "100.64.0.11", "Meta": {"WtVersion": "0.60.0"}}, + "peer-r2": {"IP": "100.64.0.12", "Meta": {"WtVersion": "0.60.0"}} + }, + "Groups": { + "grp-dev": {"Peers": ["peer-a"]}, + "grp-routers": {"Peers": ["peer-r1", "peer-r2"]} + }, + "Policies": [ + { + "ID": "pol-conn", + "Enabled": true, + "Rules": [ + { + "Enabled": true, + "Action": "accept", + "Protocol": "tcp", + "Ports": ["8080"], + "Bidirectional": true, + "Sources": ["grp-dev"], + "Destinations": ["grp-routers"] + } + ] + } + ], + "Routes": [ + { + "ID": "rt-ha", + "NetID": "ha-net", + "Network": "10.60.0.0/24", + "NetworkType": 1, + "PeerGroups": ["grp-routers"], + "Groups": ["grp-dev"], + "Metric": 9999, + "Masquerade": true, + "Enabled": true + } + ] +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/case.json new file mode 100644 index 000000000..a94d3653f --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/case.json @@ -0,0 +1,7 @@ +{ + "description": "Classic route distributed to grp-dev plus a network resource behind router peer-r with a resource policy; peer-a gets routes and route firewall rules, peer-r gets the routing-peer view.", + "peers": [ + "peer-a", + "peer-r" + ] +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/golden/peer-a.json new file mode 100644 index 000000000..9426bd846 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/golden/peer-a.json @@ -0,0 +1,86 @@ +{ + "Serial": "7", + "peerConfig": { + "address": "100.64.0.1/10", + "sshConfig": {}, + "fqdn": "peer-a.netbird.test", + "RoutingPeerDnsResolutionEnabled": true, + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=", + "allowedIps": [ + "100.64.0.9/32" + ], + "sshConfig": {}, + "fqdn": "peer-r.netbird.test", + "agentVersion": "0.60.0" + } + ], + "Routes": [ + { + "ID": "res-db:peer-r", + "Network": "10.10.0.0/24", + "NetworkType": "1", + "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=", + "Metric": "9999", + "Masquerade": true, + "NetID": "db", + "keepRoute": true + }, + { + "ID": "rt-1", + "Network": "10.20.0.0/24", + "NetworkType": "1", + "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=", + "Metric": "9999", + "Masquerade": true, + "NetID": "office-net" + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-a.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + }, + { + "Name": "peer-r.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.9" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "FirewallRules": [ + { + "PeerIP": "100.64.0.9", + "Protocol": "TCP", + "Port": "8080", + "PolicyID": "cG9sLWNvbm4=" + }, + { + "PeerIP": "100.64.0.9", + "Direction": "OUT", + "Protocol": "TCP", + "Port": "8080", + "PolicyID": "cG9sLWNvbm4=" + } + ], + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/golden/peer-r.json new file mode 100644 index 000000000..e5971796d --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/golden/peer-r.json @@ -0,0 +1,138 @@ +{ + "Serial": "7", + "peerConfig": { + "address": "100.64.0.9/10", + "sshConfig": {}, + "fqdn": "peer-r.netbird.test", + "RoutingPeerDnsResolutionEnabled": true, + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "AvldyrZ12Pf90jzf3AXmhPwg3UcI+jtJHfbpBlupvko=", + "allowedIps": [ + "100.64.0.2/32" + ], + "sshConfig": {}, + "fqdn": "peer-b.netbird.test", + "agentVersion": "0.60.0" + }, + { + "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=", + "allowedIps": [ + "100.64.0.1/32" + ], + "sshConfig": {}, + "fqdn": "peer-a.netbird.test", + "agentVersion": "0.60.0" + } + ], + "Routes": [ + { + "ID": "res-db:peer-r", + "Network": "10.10.0.0/24", + "NetworkType": "1", + "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=", + "Metric": "9999", + "Masquerade": true, + "NetID": "db", + "keepRoute": true + }, + { + "ID": "rt-1", + "Network": "10.20.0.0/24", + "NetworkType": "1", + "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=", + "Metric": "9999", + "Masquerade": true, + "NetID": "office-net" + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-a.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + }, + { + "Name": "peer-b.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.2" + }, + { + "Name": "peer-r.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.9" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "FirewallRules": [ + { + "PeerIP": "100.64.0.1", + "Protocol": "TCP", + "Port": "8080", + "PolicyID": "cG9sLWNvbm4=" + }, + { + "PeerIP": "100.64.0.1", + "Direction": "OUT", + "Protocol": "TCP", + "Port": "8080", + "PolicyID": "cG9sLWNvbm4=" + }, + { + "PeerIP": "100.64.0.2", + "Protocol": "TCP", + "Port": "8080", + "PolicyID": "cG9sLWNvbm4=" + }, + { + "PeerIP": "100.64.0.2", + "Direction": "OUT", + "Protocol": "TCP", + "Port": "8080", + "PolicyID": "cG9sLWNvbm4=" + } + ], + "routesFirewallRules": [ + { + "sourceRanges": [ + "0.0.0.0/0" + ], + "destination": "10.20.0.0/24", + "protocol": "ALL", + "portInfo": {}, + "RouteID": "rt-1" + }, + { + "sourceRanges": [ + "100.64.0.1/32", + "100.64.0.2/32" + ], + "destination": "10.10.0.0/24", + "protocol": "TCP", + "portInfo": { + "port": 5432 + }, + "PolicyID": "cG9sLWRi", + "RouteID": "res-db:peer-r" + } + ], + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/nmdata.json new file mode 100644 index 000000000..795047f50 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/nmdata.json @@ -0,0 +1,97 @@ +{ + "Network": {"Serial": 7}, + "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true}, + "Peers": { + "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}}, + "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}}, + "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}} + }, + "Groups": { + "grp-dev": {"Peers": ["peer-a", "peer-b"]}, + "grp-routers": {"Peers": ["peer-r"]} + }, + "Policies": [ + { + "ID": "pol-conn", + "PublicID": "pol-conn-pub", + "Enabled": true, + "Rules": [ + { + "Enabled": true, + "Action": "accept", + "Protocol": "tcp", + "Bidirectional": true, + "Ports": ["8080"], + "Sources": ["grp-dev"], + "Destinations": ["grp-routers"] + } + ] + }, + { + "ID": "pol-db", + "PublicID": "pol-db-pub", + "Enabled": true, + "Rules": [ + { + "Enabled": true, + "Action": "accept", + "Protocol": "tcp", + "Ports": ["5432"], + "Sources": ["grp-dev"], + "DestinationResource": {"ID": "res-db", "Type": "subnet"} + } + ] + } + ], + "ResourcePolicies": { + "res-db": [ + { + "ID": "pol-db", + "PublicID": "pol-db-pub", + "Enabled": true, + "Rules": [ + { + "Enabled": true, + "Action": "accept", + "Protocol": "tcp", + "Ports": ["5432"], + "Sources": ["grp-dev"], + "DestinationResource": {"ID": "res-db", "Type": "subnet"} + } + ] + } + ] + }, + "Routes": [ + { + "ID": "rt-1", + "PublicID": "rt-1-pub", + "NetID": "office-net", + "Network": "10.20.0.0/24", + "NetworkType": 1, + "Peer": "peer-r", + "PeerID": "peer-r", + "Metric": 9999, + "Masquerade": true, + "Enabled": true, + "Groups": ["grp-dev"] + } + ], + "NetworkResources": [ + { + "ID": "res-db", + "PublicID": "res-db-pub", + "NetworkID": "net-1", + "Name": "db", + "Type": "subnet", + "Prefix": "10.10.0.0/24", + "Enabled": true + } + ], + "Routers": { + "net-1": { + "peer-r": {"Masquerade": true, "Metric": 9999, "Enabled": true} + } + }, + "NetworkXIDToPublicID": {"net-1": "net-1-pub"} +} \ No newline at end of file diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/case.json new file mode 100644 index 000000000..78ac576d9 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/case.json @@ -0,0 +1,4 @@ +{ + "description": "netbird-ssh with AuthorizedGroups: grp-admins members may log in as root, grp-oncall (empty local-user list) as any machine user; peer-srv must receive both mappings in SshAuth, clients get plain TCP firewall rules. THE ENVELOPE EXPECTATION CURRENTLY FAILS, and should: encodeAuthorizedGroups/encodeGroupIDToUserIDs translate group keys via components.Groups, which never holds user-only groups, so the wire loses every authorized user while PeerConfig still reports sshEnabled — the peer runs sshd and denies every login. Pre-existing on main since PR #6711, not a regression. Fix the encoder, do not weaken this expectation.", + "peers": ["peer-srv", "peer-a"] +} \ No newline at end of file diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/golden/peer-a.json new file mode 100644 index 000000000..6560bc74d --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/golden/peer-a.json @@ -0,0 +1,63 @@ +{ + "Serial": "10", + "peerConfig": { + "address": "100.64.0.1/10", + "sshConfig": {}, + "fqdn": "peer-a.netbird.test", + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "jEboa3bOv65XRbq8+8JuagiwWz+mM7Fc5MUfBQbOf6Y=", + "allowedIps": [ + "100.64.0.10/32" + ], + "sshConfig": {}, + "fqdn": "peer-srv.netbird.test", + "agentVersion": "0.60.0" + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-a.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + }, + { + "Name": "peer-srv.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.10" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "FirewallRules": [ + { + "PeerIP": "100.64.0.10", + "Direction": "OUT", + "Protocol": "TCP", + "PortInfo": { + "range": { + "start": 22022, + "end": 22022 + } + }, + "PolicyID": "cG9sLXNzaA==" + } + ], + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/golden/peer-srv.json new file mode 100644 index 000000000..0a66bcbf0 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/golden/peer-srv.json @@ -0,0 +1,109 @@ +{ + "Serial": "10", + "peerConfig": { + "address": "100.64.0.10/10", + "sshConfig": { + "sshEnabled": true + }, + "fqdn": "peer-srv.netbird.test", + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "AvldyrZ12Pf90jzf3AXmhPwg3UcI+jtJHfbpBlupvko=", + "allowedIps": [ + "100.64.0.2/32" + ], + "sshConfig": {}, + "fqdn": "peer-b.netbird.test", + "agentVersion": "0.60.0" + }, + { + "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=", + "allowedIps": [ + "100.64.0.1/32" + ], + "sshConfig": {}, + "fqdn": "peer-a.netbird.test", + "agentVersion": "0.60.0" + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-a.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + }, + { + "Name": "peer-b.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.2" + }, + { + "Name": "peer-srv.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.10" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "FirewallRules": [ + { + "PeerIP": "100.64.0.1", + "Protocol": "TCP", + "PortInfo": { + "range": { + "start": 22022, + "end": 22022 + } + }, + "PolicyID": "cG9sLXNzaA==" + }, + { + "PeerIP": "100.64.0.2", + "Protocol": "TCP", + "PortInfo": { + "range": { + "start": 22022, + "end": 22022 + } + }, + "PolicyID": "cG9sLXNzaA==" + } + ], + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub", + "AuthorizedUsers": [ + "CF6q+CJTtcJE8MVIcpPyOw==", + "zSsmm7BAxWD/EuunyETFXA==", + "0M0MizUGgS6HAaJa0LjGKQ==" + ], + "machineUsers": { + "*": { + "indexes": [ + 2 + ] + }, + "root": { + "indexes": [ + 0, + 1 + ] + } + } + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/nmdata.json new file mode 100644 index 000000000..de0788ac9 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/nmdata.json @@ -0,0 +1,37 @@ +{ + "Network": {"Serial": 10}, + "Peers": { + "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}}, + "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}}, + "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}} + }, + "Groups": { + "grp-dev": {"Peers": ["peer-a", "peer-b"]}, + "grp-srv": {"Peers": ["peer-srv"]} + }, + "GroupIDToUserIDs": { + "grp-admins": ["user-x", "user-y"], + "grp-oncall": ["user-z"] + }, + "Policies": [ + { + "ID": "pol-ssh", + "PublicID": "pol-ssh-pub", + "Enabled": true, + "Rules": [ + { + "Enabled": true, + "Action": "accept", + "Protocol": "netbird-ssh", + "PortRanges": [{"Start": 22022, "End": 22022}], + "Sources": ["grp-dev"], + "Destinations": ["grp-srv"], + "AuthorizedGroups": { + "grp-admins": ["root"], + "grp-oncall": [] + } + } + ] + } + ] +} \ No newline at end of file diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/case.json new file mode 100644 index 000000000..e4799638e --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/case.json @@ -0,0 +1,4 @@ +{ + "description": "netbird-ssh with a single AuthorizedUser: peer-srv's SshAuth maps the wildcard machine user to exactly user-solo.", + "peers": ["peer-srv"] +} \ No newline at end of file diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/golden/peer-srv.json new file mode 100644 index 000000000..dde0d4014 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/golden/peer-srv.json @@ -0,0 +1,74 @@ +{ + "Serial": "11", + "peerConfig": { + "address": "100.64.0.10/10", + "sshConfig": { + "sshEnabled": true + }, + "fqdn": "peer-srv.netbird.test", + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=", + "allowedIps": [ + "100.64.0.1/32" + ], + "sshConfig": {}, + "fqdn": "peer-a.netbird.test", + "agentVersion": "0.60.0" + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-a.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + }, + { + "Name": "peer-srv.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.10" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "FirewallRules": [ + { + "PeerIP": "100.64.0.1", + "Protocol": "TCP", + "PortInfo": { + "range": { + "start": 22022, + "end": 22022 + } + }, + "PolicyID": "cG9sLXNzaC11c2Vy" + } + ], + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub", + "AuthorizedUsers": [ + "/6cwl49UgLozU42NCr0RUA==" + ], + "machineUsers": { + "*": { + "indexes": [ + 0 + ] + } + } + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/nmdata.json new file mode 100644 index 000000000..1c92329a8 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/nmdata.json @@ -0,0 +1,29 @@ +{ + "Network": {"Serial": 11}, + "Peers": { + "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}}, + "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}} + }, + "Groups": { + "grp-dev": {"Peers": ["peer-a"]}, + "grp-srv": {"Peers": ["peer-srv"]} + }, + "Policies": [ + { + "ID": "pol-ssh-user", + "PublicID": "pol-ssh-user-pub", + "Enabled": true, + "Rules": [ + { + "Enabled": true, + "Action": "accept", + "Protocol": "netbird-ssh", + "PortRanges": [{"Start": 22022, "End": 22022}], + "Sources": ["grp-dev"], + "Destinations": ["grp-srv"], + "AuthorizedUser": "user-solo" + } + ] + } + ] +} \ No newline at end of file diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/case.json new file mode 100644 index 000000000..8d4fb6c1a --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/case.json @@ -0,0 +1,4 @@ +{ + "description": "netbird-ssh with neither AuthorizedGroups nor AuthorizedUser falls back to the account AllowedUserIDs under the wildcard machine user — and works with the peer's own SSHEnabled left off, unlike legacy SSH.", + "peers": ["peer-srv"] +} \ No newline at end of file diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/golden/peer-srv.json new file mode 100644 index 000000000..e63768a95 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/golden/peer-srv.json @@ -0,0 +1,76 @@ +{ + "Serial": "12", + "peerConfig": { + "address": "100.64.0.10/10", + "sshConfig": { + "sshEnabled": true + }, + "fqdn": "peer-srv.netbird.test", + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=", + "allowedIps": [ + "100.64.0.1/32" + ], + "sshConfig": {}, + "fqdn": "peer-a.netbird.test", + "agentVersion": "0.60.0" + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-a.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + }, + { + "Name": "peer-srv.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.10" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "FirewallRules": [ + { + "PeerIP": "100.64.0.1", + "Protocol": "TCP", + "PortInfo": { + "range": { + "start": 22022, + "end": 22022 + } + }, + "PolicyID": "cG9sLXNzaC1hbnk=" + } + ], + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub", + "AuthorizedUsers": [ + "O8fBfcakRSAM4gX+YBNe+w==", + "1vwcS03btOdBRX0dhz0NRg==" + ], + "machineUsers": { + "*": { + "indexes": [ + 0, + 1 + ] + } + } + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/nmdata.json new file mode 100644 index 000000000..97bfe3342 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/nmdata.json @@ -0,0 +1,29 @@ +{ + "Network": {"Serial": 12}, + "Peers": { + "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}}, + "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}} + }, + "Groups": { + "grp-dev": {"Peers": ["peer-a"]}, + "grp-srv": {"Peers": ["peer-srv"]} + }, + "AllowedUserIDs": {"user-1": {}, "user-2": {}}, + "Policies": [ + { + "ID": "pol-ssh-any", + "PublicID": "pol-ssh-any-pub", + "Enabled": true, + "Rules": [ + { + "Enabled": true, + "Action": "accept", + "Protocol": "netbird-ssh", + "PortRanges": [{"Start": 22022, "End": 22022}], + "Sources": ["grp-dev"], + "Destinations": ["grp-srv"] + } + ] + } + ] +} \ No newline at end of file diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/case.json new file mode 100644 index 000000000..1427fbd19 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/case.json @@ -0,0 +1,4 @@ +{ + "description": "A tcp/22 policy implies legacy SSH only when the destination peer has SSHEnabled; here it does not, so peer-srv gets the firewall rules but no authorized users.", + "peers": ["peer-srv"] +} \ No newline at end of file diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/golden/peer-srv.json new file mode 100644 index 000000000..1ab6d4697 --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/golden/peer-srv.json @@ -0,0 +1,64 @@ +{ + "Serial": "13", + "peerConfig": { + "address": "100.64.0.10/10", + "sshConfig": {}, + "fqdn": "peer-srv.netbird.test", + "autoUpdate": {} + }, + "remotePeers": [ + { + "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=", + "allowedIps": [ + "100.64.0.1/32" + ], + "sshConfig": {}, + "fqdn": "peer-a.netbird.test", + "agentVersion": "0.60.0" + } + ], + "DNSConfig": { + "ServiceEnable": true, + "CustomZones": [ + { + "Domain": "netbird.test.", + "Records": [ + { + "Name": "peer-a.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.1" + }, + { + "Name": "peer-srv.netbird.test", + "Type": "1", + "Class": "IN", + "TTL": "300", + "RData": "100.64.0.10" + } + ] + } + ], + "ForwarderPort": "22054" + }, + "FirewallRules": [ + { + "PeerIP": "100.64.0.1", + "Protocol": "TCP", + "Port": "22", + "PolicyID": "cG9sLXRjcDIy" + }, + { + "PeerIP": "100.64.0.1", + "Direction": "OUT", + "Protocol": "TCP", + "Port": "22", + "PolicyID": "cG9sLXRjcDIy" + } + ], + "routesFirewallRulesIsEmpty": true, + "sshAuth": { + "UserIDClaim": "sub" + } +} diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/nmdata.json new file mode 100644 index 000000000..f00d0f06e --- /dev/null +++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/nmdata.json @@ -0,0 +1,30 @@ +{ + "Network": {"Serial": 13}, + "Peers": { + "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}}, + "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}} + }, + "Groups": { + "grp-dev": {"Peers": ["peer-a"]}, + "grp-srv": {"Peers": ["peer-srv"]} + }, + "AllowedUserIDs": {"user-1": {}}, + "Policies": [ + { + "ID": "pol-tcp22", + "PublicID": "pol-tcp22-pub", + "Enabled": true, + "Rules": [ + { + "Enabled": true, + "Action": "accept", + "Protocol": "tcp", + "Ports": ["22"], + "Bidirectional": true, + "Sources": ["grp-dev"], + "Destinations": ["grp-srv"] + } + ] + } + ] +} \ No newline at end of file diff --git a/management/internals/network_map_db/factory/db_store.go b/management/internals/network_map_db/factory/db_store.go new file mode 100644 index 000000000..3eea0ae69 --- /dev/null +++ b/management/internals/network_map_db/factory/db_store.go @@ -0,0 +1,76 @@ +package networkmapdbfactory + +import ( + "context" + "errors" + "fmt" + "os" + + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" + networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql" + networkmap_sqlite "github.com/netbirdio/netbird/management/internals/network_map_db/sqlite" + "github.com/netbirdio/netbird/management/server/integrations/integrated_validator" + "github.com/netbirdio/netbird/management/server/settings" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + log "github.com/sirupsen/logrus" +) + +const storeSqliteFileName = "store.db" + +var ErrNotSupportedStoreEngine = errors.New("unsupported store engine") + +func NewNetworkMapDBStore( + ctx context.Context, + kind types.Engine, + dataDir string, + integratedPeerValidator integrated_validator.IntegratedValidator, + extraSettingsManager settings.Manager) (*networkmapdb.NetworkMapDBStoreImpl, error) { + switch kind { + case types.SqliteStoreEngine: + log.WithContext(ctx).Info("networkmap store is using SQLite") + storeFile := storeSqliteFileName + if envFile, ok := os.LookupEnv("NB_STORE_ENGINE_SQLITE_FILE"); ok && envFile != "" { + storeFile = envFile + } + store, err := networkmap_sqlite.NewSqliteStore(storeFile, dataDir) + if err != nil { + return nil, err + } + return &networkmapdb.NetworkMapDBStoreImpl{ + Store: store, + IntegratedPeerValidator: integratedPeerValidator, + ExtraSettingsManager: extraSettingsManager, + }, nil + case types.PostgresStoreEngine: + log.WithContext(ctx).Info("using Postgres store engine") + dsn, err := mustLookupDsnEnv() + if err != nil { + return nil, err + } + + store, err := networkmap_pgsql.NewPostgresqlStore(ctx, dsn) + if err != nil { + return nil, err + } + + return &networkmapdb.NetworkMapDBStoreImpl{ + Store: store, + IntegratedPeerValidator: integratedPeerValidator, + ExtraSettingsManager: extraSettingsManager, + }, nil + } + + return nil, fmt.Errorf("networkmap store doesn't support engine %s, %w", kind, ErrNotSupportedStoreEngine) +} + +func mustLookupDsnEnv() (string, error) { + if v, ok := os.LookupEnv(store.PostgresDsnEnv); ok { + return v, nil + } + if v, ok := os.LookupEnv(store.PostgresDsnEnvLegacy); ok { + return v, nil + } + + return "", fmt.Errorf("%s env var must be set when using postgres networkmap store", store.PostgresDsnEnv) +} diff --git a/management/internals/network_map_db/network_map_data.go b/management/internals/network_map_db/network_map_data.go new file mode 100644 index 000000000..f18cc8650 --- /dev/null +++ b/management/internals/network_map_db/network_map_data.go @@ -0,0 +1,277 @@ +package networkmapdb + +import ( + "context" + "fmt" + "net/netip" + "strings" + + "github.com/miekg/dns" + log "github.com/sirupsen/logrus" + "golang.org/x/exp/maps" + + "github.com/netbirdio/netbird/shared/management/networkmap" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" +) + +func (s *NetworkMapDBStoreImpl) GetNetworkMapData(ctx context.Context, accountId string) (*networkmap.NetworkMapData, error) { + tx, err := s.Store.BeginTx(ctx) + if err != nil { + return nil, err + } + + acctSettings, err := tx.GetAccountSettings(ctx, accountId) + if err != nil { + return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get account settings: %w", err)) + } + dnsZones, err := tx.GetAppliedZoneCandidates(ctx, accountId) + if err != nil { + return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get applied zone candidates: %w", err)) + } + groups, resourceToGroupIdx, err := tx.GetGroups(ctx, accountId) + if err != nil { + return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get groups: %w", err)) + } + nsGroups, err := tx.GetNameServerGroups(ctx, accountId) + if err != nil { + return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get nameserver groups: %w", err)) + } + networkResources, err := tx.GetNetworkResources(ctx, accountId) + if err != nil { + return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get network resources: %w", err)) + } + routers, err := tx.GetNetworkRouters(ctx, accountId) + if err != nil { + return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get network routers: %w", err)) + } + network, err := tx.GetNetwork(ctx, accountId) + if err != nil { + return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get network: %w", err)) + } + peers, proxyPeers, err := tx.GetPeers(ctx, accountId) + if err != nil { + return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get peers: %w", err)) + } + policies, policyToDestinationResourceIdx, policyToDestinationGroupIdx, err := tx.GetPolicies(ctx, accountId) + if err != nil { + return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get policies: %w", err)) + } + postureChecks, postureCheckXIDToPublicID, err := tx.GetPostureChecks(ctx, accountId) + if err != nil { + return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get posture checks: %w", err)) + } + routes, err := tx.GetRoutes(ctx, accountId) + if err != nil { + return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get routes: %w", err)) + } + networkXIDToPublicID, err := tx.GetNetworkXIDToPublicIdMap(ctx, accountId) + if err != nil { + return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get network xid to public id map: %w", err)) + } + allowedUserIds, groupsToUserIds, err := tx.GetAllowedUsers(ctx, accountId) + if err != nil { + return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get allowed users: %w", err)) + } + dnsSettings, err := tx.GetDnsSettings(ctx, accountId) + if err != nil { + return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get dns settings: %w", err)) + } + domains, err := tx.GetDomains(ctx, 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)) + } + + 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, + Network: &network, + Peers: toMap(peers, func(p nmdata.Peer) string { return p.ID }), + Groups: toMap(groups, func(g nmdata.Group) string { return g.ID }), + Policies: toSliceOfPtrs(policies), + ResourcePolicies: resourcePolicies, + Routes: toSliceOfPtrs(routes), + Routers: routers, + NameServerGroups: toSliceOfPtrs(nsGroups), + NetworkResources: toSliceOfPtrs(networkResources), + PostureChecks: toMap(postureChecks, func(pc nmdata.PostureChecks) string { return pc.ID }), + AllowedUserIDs: allowedUserIds, + GroupIDToUserIDs: groupsToUserIds, + NetworkXIDToPublicID: networkXIDToPublicID, // TODO (dmitri) maybe we can switch to public ids everywhere? + AppliedZoneCandidates: dnsZones, + PrivateServiceCandidates: buildPrivateServiceCandidates(services, domains, proxyPeers), + PostureCheckXIDToPublicID: postureCheckXIDToPublicID, + ProxyTargetedDomainResourceIDs: proxyTargetedDomainResourceIDs, + } + + extraSettings, err := s.ExtraSettingsManager.GetExtraSettings(ctx, accountId) + if err != nil { + return nil, err + } + + toret.ValidatedPeers, err = s.IntegratedPeerValidator.GetValidatedPeers(ctx, accountId, maps.Values(toret.Groups), maps.Values(toret.Peers), extraSettings) + if err != nil { + return nil, err + } + + return &toret, nil +} + +func rollbackAndReturnError(ctx context.Context, tx NetworkMapDBStoreConn, err error) (*networkmap.NetworkMapData, error) { + if errr := tx.RollbackTx(ctx); errr != nil { + log.WithContext(ctx).Warnf("failed to rollback network map read transaction: %v", errr) + } + return nil, err +} + +func toMap[T any](all []T, id func(t T) string) map[string]*T { + toret := make(map[string]*T, len(all)) + for _, t := range all { + toret[id(t)] = &t + } + return toret +} + +func toSliceOfPtrs[T any](all []T) []*T { + toret := make([]*T, 0, len(all)) + for _, t := range all { + toret = append(toret, &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, + 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..925a23d1c --- /dev/null +++ b/management/internals/network_map_db/network_map_data_test.go @@ -0,0 +1,399 @@ +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{ + {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/account_settings.go b/management/internals/network_map_db/pgsql/account_settings.go new file mode 100644 index 000000000..cd5a36e35 --- /dev/null +++ b/management/internals/network_map_db/pgsql/account_settings.go @@ -0,0 +1,61 @@ +package networkmap_pgsql + +import ( + "context" + "encoding/json" + "time" + + "github.com/jackc/pgx/v5" + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" +) + +const ( + GetAccountSettingsQuery = ` + select settings_peer_login_expiration_enabled as peer_login_expiration_enabled, + settings_peer_login_expiration as peer_login_expiration, + settings_peer_inactivity_expiration_enabled as peer_inactivity_expiration_enabled, + settings_peer_inactivity_expiration as peer_inactivity_expiration, + settings_dns_domain as dns_domain, + settings_ipv6_enabled_groups as ipv6_enabled_groups, + settings_routing_peer_dns_resolution_enabled as routing_peer_dns_resolution_enabled, + settings_lazy_connection_enabled as lazy_connection_enabled, + settings_auto_update_version as auto_update_version, + settings_auto_update_always as auto_update_always, + settings_metrics_push_enabled as metrics_push_enabled + from accounts + where id=$1 + ` +) + +func (pgc *PgStoreConn) GetAccountSettings(ctx context.Context, accountId string) (nmdata.AccountSettingsInfo, error) { + rows, err := pgc.Conn.Query(ctx, GetAccountSettingsQuery, accountId) + if err != nil { + return nmdata.AccountSettingsInfo{}, err + } + + settings, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[networkmapdb.Account]) + if err != nil { + return nmdata.AccountSettingsInfo{}, err + } + + settingsInfo := nmdata.AccountSettingsInfo{ + PeerLoginExpirationEnabled: settings.PeerLoginExpirationEnabled.Bool, + PeerLoginExpiration: time.Duration(settings.PeerLoginExpiration.Int64), + PeerInactivityExpirationEnabled: settings.PeerInactivityExpirationEnabled.Bool, + PeerInactivityExpiration: time.Duration(settings.PeerInactivityExpiration.Int64), + DNSDomain: settings.DNSDomain.String, + RoutingPeerDNSResolutionEnabled: settings.RoutingPeerDNSResolutionEnabled.Bool, + LazyConnectionEnabled: settings.LazyConnectionEnabled.Bool, + AutoUpdateVersion: settings.AutoUpdateVersion.String, + AutoUpdateAlways: settings.AutoUpdateAlways.Bool, + MetricsPushEnabled: settings.MetricsPushEnabled.Bool, + } + if settings.IPv6EnabledGroups != nil { + if err := json.Unmarshal(settings.IPv6EnabledGroups, &settingsInfo.IPv6EnabledGroups); err != nil { + return nmdata.AccountSettingsInfo{}, err + } + } + + return settingsInfo, nil +} diff --git a/management/internals/network_map_db/pgsql/dns.go b/management/internals/network_map_db/pgsql/dns.go new file mode 100644 index 000000000..b22b43903 --- /dev/null +++ b/management/internals/network_map_db/pgsql/dns.go @@ -0,0 +1,33 @@ +package networkmap_pgsql + +import ( + "context" + + "github.com/jackc/pgx/v5" + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" + "github.com/netbirdio/netbird/shared/management/networkmap" +) + +const ( + GetAccountZonesQuery = ` + select zones.id as id, domain, not enable_search_domain as search_domain_disabled, distribution_groups, + r.name as record_name, r.type as record_type, 'IN' record_class, r.ttl as record_ttl, r.content as record_rdata + from zones + left join records as r on r.zone_id = zones.id + where zones.account_id=$1 and zones.enabled + ` +) + +func (pgc *PgStoreConn) GetAppliedZoneCandidates(ctx context.Context, accountId string) ([]networkmap.AppliedZoneCandidate, error) { + rows, err := pgc.Conn.Query(ctx, GetAccountZonesQuery, accountId) + if err != nil { + return nil, err + } + + zones, err := pgx.CollectRows(rows, pgx.RowToStructByName[networkmapdb.Zone]) + if err != nil { + return nil, err + } + + return networkmapdb.ZonesToAppliedZoneCandidates(zones) +} diff --git a/management/internals/network_map_db/pgsql/dns_settings.go b/management/internals/network_map_db/pgsql/dns_settings.go new file mode 100644 index 000000000..aec44e0f2 --- /dev/null +++ b/management/internals/network_map_db/pgsql/dns_settings.go @@ -0,0 +1,45 @@ +package networkmap_pgsql + +import ( + "context" + "encoding/json" + + "github.com/jackc/pgx/v5" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" +) + +const ( + GetDnsSettingsQuery = ` + select dns_settings_disabled_management_groups + from accounts + where id=$1 + ` +) + +func (pgc *PgStoreConn) GetDnsSettings(ctx context.Context, accountId string) (nmdata.DNSSettings, error) { + rows, err := pgc.Conn.Query(ctx, GetDnsSettingsQuery, accountId) + if err != nil { + return nmdata.DNSSettings{}, err + } + + return pgx.CollectOneRow(rows, rowToDnsSettings) +} + +func rowToDnsSettings(row pgx.CollectableRow) (nmdata.DNSSettings, error) { + var value nmdata.DNSSettings + var settings json.RawMessage + + if err := row.Scan(&settings); err != nil { + return value, err + } + + if settings == nil { + return nmdata.DNSSettings{}, nil + } + + if err := json.Unmarshal(settings, &value.DisabledManagementGroups); err != nil { + return value, err + } + + return value, nil +} diff --git a/management/internals/network_map_db/pgsql/domain.go b/management/internals/network_map_db/pgsql/domain.go new file mode 100644 index 000000000..8730007c5 --- /dev/null +++ b/management/internals/network_map_db/pgsql/domain.go @@ -0,0 +1,25 @@ +package networkmap_pgsql + +import ( + "context" + + "github.com/jackc/pgx/v5" + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" +) + +const ( + GetDomainsQuery = ` + select domain, target_cluster + from domains + where account_id=$1 and domain<>'' and target_cluster<>'' + ` +) + +func (pgc *PgStoreConn) GetDomains(ctx context.Context, accountId string) ([]networkmapdb.Domain, error) { + rows, err := pgc.Conn.Query(ctx, GetDomainsQuery, accountId) + if err != nil { + return nil, err + } + + return pgx.CollectRows(rows, pgx.RowToStructByName[networkmapdb.Domain]) +} diff --git a/management/internals/network_map_db/pgsql/group.go b/management/internals/network_map_db/pgsql/group.go new file mode 100644 index 000000000..874e743a5 --- /dev/null +++ b/management/internals/network_map_db/pgsql/group.go @@ -0,0 +1,64 @@ +package networkmap_pgsql + +import ( + "context" + "database/sql" + "encoding/json" + "reflect" + + "github.com/jackc/pgx/v5" + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" +) + +const ( + GetGroupsQuery = ` + select id, name, public_id, resources, + ( + select array_agg(group_peers.peer_id) + from group_peers + where group_peers.group_id = groups.id and group_peers.account_id=$1 + ) as peers + from groups where account_id=$1 + ` +) + +// we also return a resource-to-group index. +// an alternative is to add json indexes, query this directly. Not sure how expensive +// json indexes are. TODO (dmitri) verify and maybe change the implementation here. +func (pgc *PgStoreConn) GetGroups(ctx context.Context, accountId string) ([]nmdata.Group, map[string]map[string]any, error) { + rows, err := pgc.Conn.Query(ctx, GetGroupsQuery, accountId) + if err != nil { + return nil, nil, err + } + + groups, err := pgx.CollectRows(rows, pgx.RowToStructByName[group]) + toret := make([]nmdata.Group, 0, len(groups)) + resourceToGroupIdx := make(map[string]map[string]any) + + for _, g := range groups { + dg := nmdata.Group{} + err := networkmapdb.FromSqlTypesToSharedTypes( + reflect.ValueOf(&g), reflect.ValueOf(&dg)) + if err != nil { + return nil, nil, err + } + toret = append(toret, dg) + for _, resource := range dg.Resources { + if _, ok := resourceToGroupIdx[resource.ID]; !ok { + resourceToGroupIdx[resource.ID] = make(map[string]any) + } + resourceToGroupIdx[resource.ID][g.ID] = struct{}{} + } + } + + return toret, resourceToGroupIdx, err +} + +type group struct { + ID string + Name sql.NullString + PublicID sql.NullString + Resources json.RawMessage + Peers []string +} diff --git a/management/internals/network_map_db/pgsql/nameserver.go b/management/internals/network_map_db/pgsql/nameserver.go new file mode 100644 index 000000000..12f215edb --- /dev/null +++ b/management/internals/network_map_db/pgsql/nameserver.go @@ -0,0 +1,31 @@ +package networkmap_pgsql + +import ( + "context" + + "github.com/jackc/pgx/v5" + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" +) + +const ( + GetNameserversQuery = ` + select id, public_id, name, description, name_servers, groups, "primary", domains, enabled, search_domains_enabled + from name_server_groups + where account_id=$1 + ` +) + +func (pgc *PgStoreConn) GetNameServerGroups(ctx context.Context, accountId string) ([]nmdata.NameServerGroup, error) { + rows, err := pgc.Conn.Query(ctx, GetNameserversQuery, accountId) + if err != nil { + return nil, err + } + + nsgroups, err := pgx.CollectRows(rows, pgx.RowToStructByName[networkmapdb.NameserverGroup]) + if err != nil { + return nil, err + } + + return networkmapdb.ConvertAllToSharedTypes[networkmapdb.NameserverGroup, nmdata.NameServerGroup](nsgroups) +} diff --git a/management/internals/network_map_db/pgsql/network.go b/management/internals/network_map_db/pgsql/network.go new file mode 100644 index 000000000..5d7d33bcc --- /dev/null +++ b/management/internals/network_map_db/pgsql/network.go @@ -0,0 +1,39 @@ +package networkmap_pgsql + +import ( + "context" + "reflect" + + "github.com/jackc/pgx/v5" + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" +) + +const ( + GetNetworkQuery = ` + select network_identifier as identifier, network_net as net, network_net_v6 as net_v6, network_dns as dns, network_serial as serial + from accounts + where id=$1 + ` +) + +func (pgc *PgStoreConn) GetNetwork(ctx context.Context, accountId string) (nmdata.Network, error) { + rows, err := pgc.Conn.Query(ctx, GetNetworkQuery, accountId) + if err != nil { + return nmdata.Network{}, err + } + + n, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[networkmapdb.AccountNetwork]) + if err != nil { + return nmdata.Network{}, err + } + + toret := nmdata.Network{} + err = networkmapdb.FromSqlTypesToSharedTypes( + reflect.ValueOf(&n), reflect.ValueOf(&toret)) + if err != nil { + return nmdata.Network{}, err + } + + return toret, nil +} diff --git a/management/internals/network_map_db/pgsql/network_resource.go b/management/internals/network_map_db/pgsql/network_resource.go new file mode 100644 index 000000000..48c9b0611 --- /dev/null +++ b/management/internals/network_map_db/pgsql/network_resource.go @@ -0,0 +1,31 @@ +package networkmap_pgsql + +import ( + "context" + + "github.com/jackc/pgx/v5" + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" +) + +const ( + GetNetworkResourcesQuery = ` + select id, network_id, account_id, public_id, name, description, type, domain, prefix, enabled + from network_resources + where account_id=$1 + ` +) + +func (pgc *PgStoreConn) GetNetworkResources(ctx context.Context, accountId string) ([]nmdata.NetworkResource, error) { + rows, err := pgc.Conn.Query(ctx, GetNetworkResourcesQuery, accountId) + if err != nil { + return nil, err + } + + netresorces, err := pgx.CollectRows(rows, pgx.RowToStructByName[networkmapdb.Networkresource]) + if err != nil { + return nil, err + } + + return networkmapdb.ConvertAllToSharedTypes[networkmapdb.Networkresource, nmdata.NetworkResource](netresorces) +} diff --git a/management/internals/network_map_db/pgsql/network_router.go b/management/internals/network_map_db/pgsql/network_router.go new file mode 100644 index 000000000..42d5e3b28 --- /dev/null +++ b/management/internals/network_map_db/pgsql/network_router.go @@ -0,0 +1,80 @@ +package networkmap_pgsql + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "reflect" + + "github.com/jackc/pgx/v5" + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" +) + +const ( + GetNetworkRouterQuery = ` + select public_id, peer, network_id, masquerade, metric, enabled, peer_groups, + ( + select array_agg(group_peers.peer_id) + from group_peers + where group_peers.account_id=$1 and group_peers.group_id in (select json_array_elements_text(peer_groups::json)) + ) as peers_via_groups + from network_routers + where account_id=$1 + ` +) + +func (pgc *PgStoreConn) GetNetworkRouters(ctx context.Context, accountId string) (map[string]map[string]*nmdata.NetworkRouter, error) { + rows, err := pgc.Conn.Query(ctx, GetNetworkRouterQuery, accountId) + if err != nil { + return nil, err + } + + routers, err := pgx.CollectRows(rows, pgx.RowToStructByName[networkrouter]) + if err != nil { + return nil, err + } + + toret := make(map[string]map[string]*nmdata.NetworkRouter) + for _, router := range routers { + if !router.Enabled.Bool { + continue + } + + networkId := router.NetworkID.String + if networkId == "" { + return nil, fmt.Errorf("router with public_id %s doesn't have network_id set", router.PublicID.String) + } + + nmdatarouter := nmdata.NetworkRouter{} + err := networkmapdb.FromSqlTypesToSharedTypes(reflect.ValueOf(&router), reflect.ValueOf(&nmdatarouter)) + if err != nil { + return nil, err + } + + if toret[networkId] == nil { + toret[networkId] = make(map[string]*nmdata.NetworkRouter) + } + if router.Peer.String != "" { + toret[networkId][router.Peer.String] = &nmdatarouter + continue + } + for _, peerId := range router.PeersViaGroups { + toret[networkId][peerId] = &nmdatarouter + } + } + + return toret, nil +} + +type networkrouter struct { + PublicID sql.NullString + NetworkID sql.NullString `nmap:"skip"` + Peer sql.NullString `nmap:"skip"` + PeerGroups json.RawMessage + PeersViaGroups []string `nmap:"skip"` + Masquerade sql.NullBool + Metric sql.NullInt64 + Enabled sql.NullBool +} diff --git a/management/internals/network_map_db/pgsql/networks.go b/management/internals/network_map_db/pgsql/networks.go new file mode 100644 index 000000000..306356972 --- /dev/null +++ b/management/internals/network_map_db/pgsql/networks.go @@ -0,0 +1,36 @@ +package networkmap_pgsql + +import ( + "context" + + "github.com/jackc/pgx/v5" + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" +) + +const ( + GetNetworksQuery = ` + select id, public_id + from networks where account_id=$1 + ` +) + +func (pgc *PgStoreConn) GetNetworkXIDToPublicIdMap(ctx context.Context, accountId string) (map[string]string, error) { + rows, err := pgc.Conn.Query(ctx, GetNetworksQuery, accountId) + if err != nil { + return nil, err + } + + networks, err := pgx.CollectRows(rows, pgx.RowToStructByName[networkmapdb.Network]) + if err != nil { + return nil, err + } + + toret := make(map[string]string) + for _, n := range networks { + if n.PublicID.Valid { + toret[n.ID] = n.PublicID.String + } + } + + return toret, nil +} diff --git a/management/internals/network_map_db/pgsql/peer.go b/management/internals/network_map_db/pgsql/peer.go new file mode 100644 index 000000000..962669f7a --- /dev/null +++ b/management/internals/network_map_db/pgsql/peer.go @@ -0,0 +1,34 @@ +package networkmap_pgsql + +import ( + "context" + + "github.com/jackc/pgx/v5" + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" +) + +const ( + GetPeersQuery = ` + select id, key, ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6, + peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster, + meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, meta_capabilities, meta_flags, meta_sync_message_version, + location_country_code, location_city_name, location_connection_ip + from peers + where account_id = $1 + ` +) + +func (pgc *PgStoreConn) GetPeers(ctx context.Context, accountId string) ([]nmdata.Peer, map[string][]*nmdata.Peer, error) { + rows, err := pgc.Conn.Query(ctx, GetPeersQuery, accountId) + if err != nil { + return nil, nil, err + } + + peers, err := pgx.CollectRows(rows, pgx.RowToStructByName[networkmapdb.Peer]) + if err != nil { + return nil, nil, err + } + + return networkmapdb.ConvertToNmdataPeers(peers) +} diff --git a/management/internals/network_map_db/pgsql/pg_store.go b/management/internals/network_map_db/pgsql/pg_store.go new file mode 100644 index 000000000..0cae610f8 --- /dev/null +++ b/management/internals/network_map_db/pgsql/pg_store.go @@ -0,0 +1,128 @@ +package networkmap_pgsql + +import ( + "context" + "fmt" + "reflect" + "time" + + "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" +) + +const ( + pgMaxConnections = 30 + pgMinConnections = 1 + pgMaxConnLifetime = 60 * time.Minute + pgHealthCheckPeriod = 1 * time.Minute +) + +var _ networkmapdb.NetworkMapDBStore = &PgStore{} + +type PgStore struct { + Pool *pgxpool.Pool + Location *time.Location +} + +type PgStoreConn struct { + Conn pgInterface +} + +type pgInterface interface { + Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) + Exec(ctx context.Context, sql string, arguments ...any) (pgconn.CommandTag, error) +} + +var _ networkmapdb.NetworkMapDBStoreConn = &PgStoreConn{} + +func NewPostgresqlStore(ctx context.Context, dsn string) (*PgStore, error) { + pool, err := connectToPgDb(ctx, dsn) + if err != nil { + return nil, err + } + + 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} +} + +func (p *PgStore) Exec(ctx context.Context, query string, args ...any) error { + _, err := p.Pool.Exec(ctx, query, args...) + return err +} + +func (p *PgStore) BeginTx(ctx context.Context) (networkmapdb.NetworkMapDBStoreConn, error) { + tx, err := p.Pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}) + 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 +} + +func (c *PgStoreConn) RollbackTx(ctx context.Context) error { + tx, ok := c.Conn.(pgx.Tx) + if !ok { + return fmt.Errorf("expected an pgx.Tx got %s", reflect.TypeOf(c.Conn).Kind()) + } + return tx.Rollback(ctx) +} + +func (c *PgStoreConn) CommitTx(ctx context.Context) error { + tx, ok := c.Conn.(pgx.Tx) + if !ok { + return fmt.Errorf("expected an sql.Tx got %s", reflect.TypeOf(c.Conn).Kind()) + } + return tx.Commit(ctx) +} + +func connectToPgDb(ctx context.Context, dsn string) (*pgxpool.Pool, error) { + config, err := pgxpool.ParseConfig(dsn) + if err != nil { + return nil, fmt.Errorf("unable to parse database config: %w", err) + } + + config.MaxConns = pgMaxConnections + config.MinConns = pgMinConnections + config.MaxConnLifetime = pgMaxConnLifetime + config.HealthCheckPeriod = pgHealthCheckPeriod + + pool, err := pgxpool.NewWithConfig(ctx, config) + if err != nil { + return nil, fmt.Errorf("unable to create connection pool: %w", err) + } + + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("unable to ping database: %w", err) + } + + return pool, nil +} diff --git a/management/internals/network_map_db/pgsql/policy.go b/management/internals/network_map_db/pgsql/policy.go new file mode 100644 index 000000000..45927d7a2 --- /dev/null +++ b/management/internals/network_map_db/pgsql/policy.go @@ -0,0 +1,34 @@ +package networkmap_pgsql + +import ( + "context" + + "github.com/jackc/pgx/v5" + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" +) + +const ( + GetPoliciesQuery = ` + select p.id, p.public_id, p.enabled, p.source_posture_checks, pr.enabled as rule_enabled, pr.action, pr.protocol, pr.bidirectional, + pr.sources, pr.destinations, pr.source_resource, pr.destination_resource, pr.ports, pr.port_ranges, + pr.authorized_groups, pr.authorized_user + from policies as p + left join policy_rules as pr on p.id = pr.policy_id + where account_id=$1 + ` +) + +func (pgc *PgStoreConn) GetPolicies(ctx context.Context, accountId string) ([]nmdata.Policy, map[string]map[string]any, map[string]map[string]any, error) { + rows, err := pgc.Conn.Query(ctx, GetPoliciesQuery, accountId) + if err != nil { + return nil, nil, nil, err + } + + policies, err := pgx.CollectRows(rows, pgx.RowToStructByName[networkmapdb.Policy]) + if err != nil { + return nil, nil, nil, err + } + + return networkmapdb.ConvertToNmdataPolicy(policies) +} diff --git a/management/internals/network_map_db/pgsql/posture.go b/management/internals/network_map_db/pgsql/posture.go new file mode 100644 index 000000000..aedfec2a5 --- /dev/null +++ b/management/internals/network_map_db/pgsql/posture.go @@ -0,0 +1,44 @@ +package networkmap_pgsql + +import ( + "context" + "reflect" + + "github.com/jackc/pgx/v5" + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" +) + +const ( + GetPostureChecksQuery = ` + select id, public_id, checks + from posture_checks + where account_id=$1 + ` +) + +func (pgc *PgStoreConn) GetPostureChecks(ctx context.Context, accountId string) ([]nmdata.PostureChecks, map[string]string, error) { + rows, err := pgc.Conn.Query(ctx, GetPostureChecksQuery, accountId) + if err != nil { + return nil, nil, err + } + + checks, err := pgx.CollectRows(rows, pgx.RowToStructByName[networkmapdb.PostureChecks]) + if err != nil { + return nil, nil, err + } + + toret := make([]nmdata.PostureChecks, 0, len(checks)) + idToPublicIDIdx := make(map[string]string) + for _, c := range checks { + checks := nmdata.PostureChecks{} + err := networkmapdb.FromSqlTypesToSharedTypes(reflect.ValueOf(&c), reflect.ValueOf(&checks)) + if err != nil { + return nil, nil, err + } + toret = append(toret, checks) + idToPublicIDIdx[checks.ID] = c.PublicID.String + } + + return toret, idToPublicIDIdx, nil +} diff --git a/management/internals/network_map_db/pgsql/route.go b/management/internals/network_map_db/pgsql/route.go new file mode 100644 index 000000000..4f9a16c0e --- /dev/null +++ b/management/internals/network_map_db/pgsql/route.go @@ -0,0 +1,33 @@ +package networkmap_pgsql + +import ( + "context" + + "github.com/jackc/pgx/v5" + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" +) + +const ( + GetRoutesQuery = ` + select id, account_id, public_id, network, domains, keep_route, net_id, description, + peer, peer as peer_id, peer_groups, network_type, masquerade, metric, enabled, + groups, access_control_groups, skip_auto_apply + from routes + where account_id=$1 + ` +) + +func (pgc *PgStoreConn) GetRoutes(ctx context.Context, accountId string) ([]nmdata.Route, error) { + rows, err := pgc.Conn.Query(ctx, GetRoutesQuery, accountId) + if err != nil { + return nil, err + } + + routes, err := pgx.CollectRows(rows, pgx.RowToStructByName[networkmapdb.Route]) + if err != nil { + return nil, err + } + + return networkmapdb.ConvertAllToSharedTypes[networkmapdb.Route, nmdata.Route](routes) +} diff --git a/management/internals/network_map_db/pgsql/service.go b/management/internals/network_map_db/pgsql/service.go new file mode 100644 index 000000000..5d82046be --- /dev/null +++ b/management/internals/network_map_db/pgsql/service.go @@ -0,0 +1,51 @@ +package networkmap_pgsql + +import ( + "context" + + "github.com/jackc/pgx/v5" + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" +) + +const ( + GetServicesQuery = ` + select enabled, private, array (select json_array_elements_text(access_groups::json)) as access_groups, proxy_cluster, domain + from services + where account_id=$1 + ` + + GetProxyTargetedDomainResourcesQuery = ` + select t.target_id + from targets as t + join services as s on s.id = t.service_id + where s.account_id=$1 and s.enabled and not coalesce(s.terminated, false) + and t.enabled and t.target_type='domain' and t.target_id is not null + ` +) + +func (pgc *PgStoreConn) GetPrivateServices(ctx context.Context, accountId string) ([]networkmapdb.Service, error) { + rows, err := pgc.Conn.Query(ctx, GetServicesQuery, accountId) + if err != nil { + return nil, err + } + + return pgx.CollectRows(rows, pgx.RowToStructByName[networkmapdb.Service]) +} + +func (pgc *PgStoreConn) GetProxyTargetedDomainResourceIDs(ctx context.Context, accountId string) (map[string]struct{}, error) { + rows, err := pgc.Conn.Query(ctx, GetProxyTargetedDomainResourcesQuery, accountId) + if err != nil { + return nil, err + } + + ids, err := pgx.CollectRows(rows, pgx.RowTo[string]) + if err != nil { + return nil, err + } + + toret := make(map[string]struct{}, len(ids)) + for _, id := range ids { + toret[id] = struct{}{} + } + return toret, nil +} diff --git a/management/internals/network_map_db/pgsql/user.go b/management/internals/network_map_db/pgsql/user.go new file mode 100644 index 000000000..9e22c3575 --- /dev/null +++ b/management/internals/network_map_db/pgsql/user.go @@ -0,0 +1,60 @@ +package networkmap_pgsql + +import ( + "context" + + "github.com/jackc/pgx/v5" +) + +const ( + GetAllowedUserIdsQuery = ` + select id, array (select json_array_elements_text(auto_groups::json)) as auto_groups + from users + where account_id=$1 and not blocked and not is_service_user + ` + + GetAllGroupIdQuery = ` + select array_agg(id) from groups + where account_id=$1 and name='All' + ` +) + +func (pgc *PgStoreConn) GetAllowedUsers(ctx context.Context, accountId string) (map[string]struct{}, map[string][]string, error) { + rows, err := pgc.Conn.Query(ctx, GetAllowedUserIdsQuery, accountId) + if err != nil { + return nil, nil, err + } + + users, err := pgx.CollectRows(rows, pgx.RowToStructByName[user]) + if err != nil { + return nil, nil, err + } + + rows, err = pgc.Conn.Query(ctx, GetAllGroupIdQuery, accountId) + if err != nil { + return nil, nil, err + } + allGroupIds, err := pgx.CollectOneRow(rows, pgx.RowTo[[]string]) + if err != nil { + return nil, nil, err + } + + userIdIdx := make(map[string]struct{}) + groupIdToUserIds := make(map[string][]string) + for _, user := range users { + userIdIdx[user.ID] = struct{}{} + for _, groupId := range user.AutoGroups { + groupIdToUserIds[groupId] = append(groupIdToUserIds[groupId], user.ID) + } + for _, allgid := range allGroupIds { + groupIdToUserIds[allgid] = append(groupIdToUserIds[allgid], user.ID) + } + } + + return userIdIdx, groupIdToUserIds, nil +} + +type user struct { + ID string + AutoGroups []string +} diff --git a/management/internals/network_map_db/shared_types.go b/management/internals/network_map_db/shared_types.go new file mode 100644 index 000000000..bdd387877 --- /dev/null +++ b/management/internals/network_map_db/shared_types.go @@ -0,0 +1,472 @@ +package networkmapdb + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "reflect" + + "github.com/miekg/dns" + "github.com/netbirdio/netbird/management/server/integrations/integrated_validator" + "github.com/netbirdio/netbird/management/server/settings" + "github.com/netbirdio/netbird/shared/management/networkmap" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" +) + +var ErrDnsUnsupportedRecordType = errors.New("unsupported record type") + +type NetworkMapDBStore interface { //nolint:revive // established name across the codebase + BeginTx(ctx context.Context) (NetworkMapDBStoreConn, error) + Exec(ctx context.Context, query string, args ...any) error +} + +type NetworkMapDBStoreConn interface { //nolint:revive // established name across the codebase + GetGroups(ctx context.Context, accountId string) ([]nmdata.Group, map[string]map[string]any, error) + GetDomains(ctx context.Context, accountId string) ([]Domain, error) + GetPeers(ctx context.Context, accountId string) ([]nmdata.Peer, map[string][]*nmdata.Peer, error) + GetPolicies(ctx context.Context, accountId string) ([]nmdata.Policy, map[string]map[string]any, map[string]map[string]any, error) + GetRoutes(ctx context.Context, accountId string) ([]nmdata.Route, error) + GetNameServerGroups(ctx context.Context, accountId string) ([]nmdata.NameServerGroup, error) + GetNetworkResources(ctx context.Context, accountId string) ([]nmdata.NetworkResource, error) + GetNetworkRouters(ctx context.Context, accountId string) (map[string]map[string]*nmdata.NetworkRouter, error) + GetNetwork(ctx context.Context, accountId string) (nmdata.Network, error) + GetAppliedZoneCandidates(ctx context.Context, accountId string) ([]networkmap.AppliedZoneCandidate, error) + GetAccountSettings(ctx context.Context, accountId string) (nmdata.AccountSettingsInfo, error) + GetPostureChecks(ctx context.Context, accountId string) ([]nmdata.PostureChecks, map[string]string, error) + GetAllowedUsers(ctx context.Context, accountId string) (map[string]struct{}, map[string][]string, error) + GetDnsSettings(ctx context.Context, accountId string) (nmdata.DNSSettings, error) + GetNetworkXIDToPublicIdMap(ctx context.Context, accountId string) (map[string]string, error) + GetPrivateServices(ctx context.Context, accountId string) ([]Service, error) + GetProxyTargetedDomainResourceIDs(ctx context.Context, accountId string) (map[string]struct{}, error) + + CommitTx(ctx context.Context) error + RollbackTx(ctx context.Context) error +} + +type NetworkMapDBStoreImpl struct { //nolint:revive // established name across the codebase + Store NetworkMapDBStore + IntegratedPeerValidator integrated_validator.IntegratedValidator + ExtraSettingsManager settings.Manager +} + +// The order of fields in these structs is important. +// Mapping of results of sqlite queries relies on the order +// of the fields in these structs, when a query or a struct changes, +// corresponding changes must be made to its counterpart. + +type Account struct { + PeerLoginExpirationEnabled sql.NullBool + PeerLoginExpiration sql.NullInt64 + PeerInactivityExpirationEnabled sql.NullBool + PeerInactivityExpiration sql.NullInt64 + DNSDomain sql.NullString + IPv6EnabledGroups []byte `nmap:"json"` + RoutingPeerDNSResolutionEnabled sql.NullBool + LazyConnectionEnabled sql.NullBool + AutoUpdateVersion sql.NullString + AutoUpdateAlways sql.NullBool + MetricsPushEnabled sql.NullBool +} + +type Domain struct { + Domain sql.NullString + TargetCluster sql.NullString +} + +type Service struct { + Enabled sql.NullBool + Private sql.NullBool + AccessGroups []string + ProxyCluster sql.NullString + Domain sql.NullString +} + +type Zone struct { + Id string `nmap:"skip"` + Domain sql.NullString + SearchDomainDisabled sql.NullBool + DistributionGroups []byte `nmap:"skip,json"` + RecordName sql.NullString `nmap:"skip"` + RecordType sql.NullString `nmap:"skip"` + RecordClass sql.NullString `nmap:"skip"` + RecordTTL sql.NullInt64 `nmap:"skip"` + RecordRData sql.NullString `nmap:"skip"` +} + +type NameserverGroup struct { + ID string + PublicID sql.NullString + Name sql.NullString + Description sql.NullString + NameServers []byte `nmap:"json"` + Groups []byte `nmap:"json"` + Primary sql.NullBool + Domains []byte `nmap:"json"` + Enabled sql.NullBool + SearchDomainsEnabled sql.NullBool +} + +type Networkresource struct { + ID string + NetworkID sql.NullString + AccountID sql.NullString + PublicID sql.NullString + Name sql.NullString + Description sql.NullString + Type sql.NullString + Domain sql.NullString + Prefix []byte `nmap:"json"` + Enabled sql.NullBool +} + +type AccountNetwork struct { + Identifier sql.NullString + Net []byte `nmap:"json"` + NetV6 []byte `nmap:"json"` + Dns sql.NullString + Serial sql.NullInt64 +} + +type Network struct { + ID string + PublicID sql.NullString +} + +type Policy struct { + ID string + PublicID sql.NullString + Enabled sql.NullBool + SourcePostureChecks []byte `nmap:"json"` + RuleEnabled sql.NullBool `nmap:"skip"` + Action sql.NullString `nmap:"skip"` + Protocol sql.NullString `nmap:"skip"` + Bidirectional sql.NullBool `nmap:"skip"` + Sources []byte `nmap:"skip,json"` + Destinations []byte `nmap:"skip,json"` + SourceResource []byte `nmap:"skip,json"` + DestinationResource []byte `nmap:"skip,json"` + Ports []byte `nmap:"skip,json"` + PortRanges []byte `nmap:"skip,json"` + AuthorizedGroups []byte `nmap:"skip,json"` + AuthorizedUser sql.NullString `nmap:"skip"` +} + +// Depending on db interface LastLogin contains time in different formats: +// for sqlite/sql.NullTime the time in UTC +// for pgx the time is in the local timezone +// TODO add support for creating struct fields from denormalized fields +type Peer struct { + ID string + Key sql.NullString + SSHKey sql.NullString + DNSLabel sql.NullString + ExtraDNSLabels []byte `nmap:"json"` + UserID sql.NullString + SSHEnabled sql.NullBool + LoginExpirationEnabled sql.NullBool + LastLogin sql.NullTime + IP []byte `nmap:"json"` + IPv6 []byte `nmap:"json"` + PeerStatusRequiresApproval sql.NullBool `nmap:"map_to:RequiresApproval"` + PeerStatusConnected sql.NullBool `nmap:"skip"` + ProxyMetaEmbedded sql.NullBool `nmap:"skip"` + ProxyMetaCluster sql.NullString `nmap:"skip"` + MetaWtVersion sql.NullString `nmap:"skip"` + MetaGoOS sql.NullString `nmap:"skip"` + MetaOSVersion sql.NullString `nmap:"skip"` + MetaKernelVersion sql.NullString `nmap:"skip"` + MetaNetworkAddresses []byte `nmap:"skip,json"` + MetaFiles []byte `nmap:"skip,json"` + MetaCapabilities []byte `nmap:"skip,json"` + MetaFlags []byte `nmap:"skip,json"` + MetaSyncMessageVersion sql.NullInt64 `nmap:"skip"` + LocationCountryCode sql.NullString `nmap:"skip"` + LocationCityName sql.NullString `nmap:"skip"` + LocationConnectionIp []byte `nmap:"skip,json"` +} + +type PostureChecks struct { + ID string + PublicID sql.NullString `nmap:"skip"` + Checks []byte `nmap:"json"` +} + +type Route struct { + ID string + AccountID sql.NullString + PublicID sql.NullString + Network []byte `nmap:"json"` + Domains []byte `nmap:"json"` + KeepRoute sql.NullBool + NetID sql.NullString + Description sql.NullString + Peer sql.NullString + PeerID sql.NullString + PeerGroups []byte `nmap:"json"` + NetworkType sql.NullInt64 + Masquerade sql.NullBool + Metric sql.NullInt64 + Enabled sql.NullBool + Groups []byte `nmap:"json"` + AccessControlGroups []byte `nmap:"json"` + SkipAutoApply sql.NullBool +} + +func RecordTypeAndRdata(t, rdata string) (int, string, error) { + switch t { + case "A": + return int(dns.TypeA), rdata, nil + case "AAAA": + return int(dns.TypeAAAA), rdata, nil + case "CNAME": + return int(dns.TypeCNAME), dns.Fqdn(rdata), nil + default: + return 0, "", fmt.Errorf("record type: %s %w", t, ErrDnsUnsupportedRecordType) + } +} + +func ZonesToAppliedZoneCandidates(zones []Zone) ([]networkmap.AppliedZoneCandidate, error) { + toret := make([]networkmap.AppliedZoneCandidate, 0, len(zones)) + currentZoneId := "" + for _, z := range zones { + if !z.RecordType.Valid { + continue + } + + zone := nmdata.CustomZone{} + err := FromSqlTypesToSharedTypes( + reflect.ValueOf(&z), reflect.ValueOf(&zone)) + if err != nil { + return nil, err + } + + var distributionGroups []string + if err := json.Unmarshal(z.DistributionGroups, &distributionGroups); err != nil { + return nil, err + } + + if z.Id != currentZoneId { + // The account-side builder (types.buildAppliedZoneCandidates) states + // the shape of an applied zone: names fully qualified, served + // non-authoritatively. Both builders feed the same client-facing map, + // so this one has to produce the same value. + zone.Domain = dns.Fqdn(zone.Domain) + zone.NonAuthoritative = true + zone.Records = []nmdata.SimpleRecord{} + toret = append(toret, AppliedZoneCandidateFromZone(zone, distributionGroups)) + currentZoneId = z.Id + } + + rtype, rdata, err := RecordTypeAndRdata(z.RecordType.String, z.RecordRData.String) + if err != nil { + if errors.Is(err, ErrDnsUnsupportedRecordType) { + continue + } + return nil, err + } + + lastZone := &toret[len(toret)-1] + lastZone.Zone.Records = append(lastZone.Zone.Records, nmdata.SimpleRecord{ + Name: dns.Fqdn(z.RecordName.String), + Class: z.RecordClass.String, + TTL: int(z.RecordTTL.Int64), + RData: rdata, + Type: rtype, + }) + } + return toret, nil +} + +func AppliedZoneCandidateFromZone(z nmdata.CustomZone, distributionGroups []string) networkmap.AppliedZoneCandidate { + return networkmap.AppliedZoneCandidate{ + DistributionGroups: distributionGroups, + Zone: z, + } +} + +func ConvertToNmdataPeers(peers []Peer) ([]nmdata.Peer, map[string][]*nmdata.Peer, error) { + toret := make([]nmdata.Peer, 0, len(peers)) + clusterToPeerIdx := make(map[string][]*nmdata.Peer) + for _, p := range peers { + dp := nmdata.Peer{} + err := FromSqlTypesToSharedTypes( + reflect.ValueOf(&p), reflect.ValueOf(&dp)) + if err != nil { + return nil, nil, err + } + + if p.ProxyMetaEmbedded.Valid { + dp.ProxyMeta.Embedded = p.ProxyMetaEmbedded.Bool + } + dp.ProxyMeta.Cluster = p.ProxyMetaCluster.String + // This is only used to build private service candidates, not connected peers are skipped + if dp.ProxyMeta.Embedded && p.PeerStatusConnected.Bool { + clusterToPeerIdx[p.ProxyMetaCluster.String] = append(clusterToPeerIdx[p.ProxyMetaCluster.String], &dp) + } + if p.MetaWtVersion.Valid { + dp.Meta.WtVersion = p.MetaWtVersion.String + } + if p.MetaSyncMessageVersion.Valid { + dp.Meta.SyncMessageVersion = int(p.MetaSyncMessageVersion.Int64) + } + if p.MetaGoOS.Valid { + dp.Meta.GoOS = p.MetaGoOS.String + } + if p.MetaOSVersion.Valid { + dp.Meta.OSVersion = p.MetaOSVersion.String + } + if p.MetaKernelVersion.Valid { + dp.Meta.KernelVersion = p.MetaKernelVersion.String + } + if p.LocationCountryCode.Valid { + dp.Location.CountryCode = p.LocationCountryCode.String + } + if p.LocationCityName.Valid { + dp.Location.CityName = p.LocationCityName.String + } + if p.LocationConnectionIp != nil { + err := json.Unmarshal(p.LocationConnectionIp, &dp.Location.ConnectionIP) + if err != nil { + return toret, nil, err + } + } + if p.MetaFiles != nil { + err := json.Unmarshal(p.MetaFiles, &dp.Meta.Files) + if err != nil { + return toret, nil, err + } + } + if p.MetaCapabilities != nil { + err := json.Unmarshal(p.MetaCapabilities, &dp.Meta.Capabilities) + if err != nil { + return toret, nil, err + } + } + if p.MetaFlags != nil { + err := json.Unmarshal(p.MetaFlags, &dp.Meta.Flags) + if err != nil { + return toret, nil, err + } + } + if p.MetaNetworkAddresses != nil { + err := json.Unmarshal(p.MetaNetworkAddresses, &dp.Meta.NetworkAddresses) + if err != nil { + return toret, nil, err + } + } + + toret = append(toret, dp) + } + + return toret, clusterToPeerIdx, nil +} + +func ConvertToNmdataPolicy(policies []Policy) ([]nmdata.Policy, map[string]map[string]any, map[string]map[string]any, error) { + toret := make([]nmdata.Policy, 0, len(policies)) + policyToDestinationResourceIdx := make(map[string]map[string]any) // policy id to destination resource id + policyToDestinationGroupIdx := make(map[string]map[string]any) // policy id to destination group id + for _, p := range policies { + policy := nmdata.Policy{} + err := FromSqlTypesToSharedTypes( + reflect.ValueOf(&p), reflect.ValueOf(&policy)) + if err != nil { + return nil, nil, nil, err + } + + var policyRule *nmdata.PolicyRule + pr := func() *nmdata.PolicyRule { + if policyRule != nil { + return policyRule + } + + policyRule = &nmdata.PolicyRule{} + return policyRule + } + + if p.RuleEnabled.Valid { + pr().Enabled = p.RuleEnabled.Bool + } + if p.Action.Valid { + pr().Action = p.Action.String + } + if p.Protocol.Valid { + pr().Protocol = p.Protocol.String + } + if p.Bidirectional.Valid { + pr().Bidirectional = p.Bidirectional.Bool + } + if len(p.Sources) > 0 { + err := json.Unmarshal([]byte(p.Sources), &pr().Sources) + if err != nil { + return toret, nil, nil, err + } + } + if len(p.Destinations) > 0 { + err := json.Unmarshal([]byte(p.Destinations), &pr().Destinations) + if err != nil { + return toret, nil, nil, err + } + + if p.RuleEnabled.Valid && p.RuleEnabled.Bool { + for _, dst := range pr().Destinations { + if _, ok := policyToDestinationGroupIdx[p.ID]; !ok { + policyToDestinationGroupIdx[p.ID] = make(map[string]any) + } + policyToDestinationGroupIdx[p.ID][dst] = struct{}{} + } + } + } + if len(p.SourceResource) > 0 { + err := json.Unmarshal([]byte(p.SourceResource), &pr().SourceResource) + if err != nil { + return toret, nil, nil, err + } + } + if len(p.DestinationResource) > 0 { + err := json.Unmarshal([]byte(p.DestinationResource), &pr().DestinationResource) + if err != nil { + return toret, nil, nil, err + } + + if p.RuleEnabled.Valid && p.RuleEnabled.Bool { + if _, ok := policyToDestinationResourceIdx[p.ID]; !ok { + policyToDestinationResourceIdx[p.ID] = make(map[string]any) + } + policyToDestinationResourceIdx[p.ID][pr().DestinationResource.ID] = struct{}{} + } + } + if len(p.Ports) > 0 { + err := json.Unmarshal([]byte(p.Ports), &pr().Ports) + if err != nil { + return toret, nil, nil, err + } + } + if len(p.PortRanges) > 0 { + err := json.Unmarshal([]byte(p.PortRanges), &pr().PortRanges) + if err != nil { + return toret, nil, nil, err + } + } + if len(p.AuthorizedGroups) > 0 { + err := json.Unmarshal([]byte(p.AuthorizedGroups), &pr().AuthorizedGroups) + if err != nil { + return toret, nil, nil, err + } + } + if p.AuthorizedUser.Valid { + pr().AuthorizedUser = p.AuthorizedUser.String + } + + if policyRule != nil { + policyRule.ID = p.ID + policyRule.PolicyID = p.ID + policy.Rules = []*nmdata.PolicyRule{policyRule} + } + + toret = append(toret, policy) + } + + return toret, policyToDestinationResourceIdx, policyToDestinationGroupIdx, nil +} diff --git a/management/internals/network_map_db/shared_types_test.go b/management/internals/network_map_db/shared_types_test.go new file mode 100644 index 000000000..8a1239e26 --- /dev/null +++ b/management/internals/network_map_db/shared_types_test.go @@ -0,0 +1,38 @@ +package networkmapdb + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRecordTypeAndRdata(t *testing.T) { + var tests = []struct { + recordType string + expectedRecordType int + rdata string + expectedRdata string + expectedErr error + }{ + {recordType: "A", expectedRecordType: 1, rdata: "test.com", expectedRdata: "test.com", expectedErr: nil}, + {recordType: "AAAA", expectedRecordType: 28, rdata: "test.com", expectedRdata: "test.com", expectedErr: nil}, + {recordType: "CNAME", expectedRecordType: 5, rdata: "test.com", expectedRdata: "test.com.", expectedErr: nil}, + {recordType: "CNAME", expectedRecordType: 5, rdata: "test.com.", expectedRdata: "test.com.", expectedErr: nil}, + {recordType: "TypeMX", expectedErr: ErrDnsUnsupportedRecordType}, + } + + for _, tt := range tests { + t.Run(tt.recordType, func(t *testing.T) { + recordType, rdata, err := RecordTypeAndRdata(tt.recordType, tt.rdata) + + if tt.expectedErr != nil { + assert.ErrorIs(t, err, ErrDnsUnsupportedRecordType) + return + } + + assert.NoError(t, err) + assert.Equal(t, recordType, tt.expectedRecordType) + assert.Equal(t, rdata, tt.expectedRdata) + }) + } +} diff --git a/management/internals/network_map_db/sql_type_conversion_test.go b/management/internals/network_map_db/sql_type_conversion_test.go new file mode 100644 index 000000000..77dab93a8 --- /dev/null +++ b/management/internals/network_map_db/sql_type_conversion_test.go @@ -0,0 +1,253 @@ +package networkmapdb + +import ( + "database/sql" + "encoding/json" + "reflect" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestNullStringSupport(t *testing.T) { + src := withNullString{Name: sql.NullString{String: "string", Valid: true}} + dst := withString{} + assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst))) + assert.Equal(t, withString{Name: "string"}, dst) + + src = withNullString{Name: sql.NullString{Valid: false}} + dst = withString{} + assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst))) + assert.Equal(t, withString{Name: ""}, dst) +} + +func TestNullBoolSupport(t *testing.T) { + src := withNullBool{TrueOrFalse: sql.NullBool{Bool: true, Valid: true}} + dst := withBool{} + assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst))) + assert.Equal(t, withBool{TrueOrFalse: true}, dst) + +} + +func TestRawJsonSupport(t *testing.T) { + jb, _ := json.Marshal(embeddedS{Name: "blob-name", SomeField: 1}) + src := withRawJson{Blob: json.RawMessage(jb)} + dst := fromJson{} + assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst))) + assert.Equal(t, fromJson{Blob: embeddedS{Name: "blob-name", SomeField: 1}}, dst) + + src1 := withRawJson{} + dst1 := fromJson{} + assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src1), reflect.ValueOf(&dst1))) + assert.Equal(t, fromJson{}, dst1) +} + +func TestShouldSkipTag(t *testing.T) { + src5 := withSkipTag{Field: "shouldskip"} + dst5 := emptySkipTagTarget{} + assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src5), reflect.ValueOf(&dst5))) + assert.Equal(t, emptySkipTagTarget{}, dst5) + +} + +func TestMapToTag(t *testing.T) { + src6 := withMapToTag{Field: "fieldvalue"} + dst6 := mapToTagTarget{} + assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src6), reflect.ValueOf(&dst6))) + assert.Equal(t, mapToTagTarget{AnotherField: "fieldvalue"}, dst6) +} + +func TestNullableInt64Support(t *testing.T) { + src := withInt64{Field: sql.NullInt64{Int64: int64(1), Valid: true}} + dst := int64Target{} + assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst))) + assert.Equal(t, int64Target{Field: 1}, dst) +} + +func TestNullableTimeSupport(t *testing.T) { + now := time.Now() + src := withNullableTime{Field: sql.NullTime{Time: now, Valid: true}} + dst := nullableTimeTarget{} + assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst))) + assert.Equal(t, nullableTimeTarget{Field: now}, dst) +} + +func TestNullableTimePointerSupport(t *testing.T) { + now := time.Now() + src := withNullableTime{Field: sql.NullTime{Time: now, Valid: true}} + dst := nullableTimePointerTarget{} + assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst))) + assert.Equal(t, nullableTimePointerTarget{Field: &now}, dst) +} + +func TestStringSLiceSupport(t *testing.T) { + src := withStringSlice{Field: []string{"one"}} + dst := withStringSlice{} + assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst))) + assert.Equal(t, withStringSlice{Field: []string{"one"}}, dst) +} + +func TestNullStringSLiceSupport(t *testing.T) { + src := withStringSlice{} + dst := withStringSlice{} + assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst))) + assert.Equal(t, withStringSlice{}, dst) +} + +func TestWithMultipleFields(t *testing.T) { + now := time.Now() + src := withMultipleFields{ + Field1: sql.NullString{String: "aaa", Valid: true}, + Field2: sql.NullBool{Bool: true, Valid: true}, + Field3: sql.NullTime{Time: now, Valid: true}, + Field4: sql.NullInt64{Int64: 1, Valid: true}, + Field5: "another", + } + dst := multipleFieldsTarget{} + assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst))) + assert.Equal(t, multipleFieldsTarget{ + Field1: "aaa", + Field2: true, + Field3: now, + Field4: 1, + Field5: "another", + }, dst) +} + +func TestEmptyPublicIdsFilled(t *testing.T) { + src := withEmptyPublicIds{} + dst := emptyPublicIdTarget{} + assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst))) + assert.NotEmpty(t, dst.PublicID) + assert.NotEmpty(t, dst.PublicId) +} + +// only []byte and []uint8 slices with "json" tag are being parsed +func TestByteSliceSupport(t *testing.T) { + src := withByteSlice{ + Field: []byte("[\"one\",\"two\",\"three\"]"), + } + dst := byteSliceTarget{} + assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst))) + assert.Equal(t, []string{"one", "two", "three"}, dst.Field) +} + +func TestUint8SliceSupport(t *testing.T) { + src := withUint8Slice{ + Field: []uint8("[\"one\",\"two\",\"three\"]"), + } + dst := uint8SliceTarget{} + assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst))) + assert.Equal(t, []string{"one", "two", "three"}, dst.Field) +} + +type withNullString struct { + Name sql.NullString +} + +type withString struct { + Name string +} + +type withMultipleFields struct { + Field1 sql.NullString + Field2 sql.NullBool + Field3 sql.NullTime + Field4 sql.NullInt64 + Field5 string +} + +type multipleFieldsTarget struct { + Field1 string + Field2 bool + Field3 time.Time + Field4 int64 + Field5 string +} + +type withNullBool struct { + TrueOrFalse sql.NullBool +} + +type withBool struct { + TrueOrFalse bool +} + +type withRawJson struct { + Blob json.RawMessage +} + +type embeddedS struct { + Name string + SomeField int +} +type fromJson struct { + Blob embeddedS +} + +type withSkipTag struct { + Field string `nmap:"skip"` +} + +type emptySkipTagTarget struct { + Field string +} + +type withMapToTag struct { + Field string `nmap:"map_to:AnotherField"` +} + +type mapToTagTarget struct { + AnotherField string +} + +type withInt64 struct { + Field sql.NullInt64 +} + +type int64Target struct { + Field int +} + +type withNullableTime struct { + Field sql.NullTime +} + +type nullableTimeTarget struct { + Field time.Time +} + +type nullableTimePointerTarget struct { + Field *time.Time +} + +type withStringSlice struct { + Field []string +} + +type withEmptyPublicIds struct { + PublicID sql.NullString + PublicId sql.NullString +} + +type emptyPublicIdTarget struct { + PublicID string + PublicId string +} + +type withByteSlice struct { + Field []byte `nmap:"json"` +} + +type byteSliceTarget struct { + Field []string +} + +type withUint8Slice struct { + Field []byte `nmap:"json"` +} + +type uint8SliceTarget struct { + Field []string +} diff --git a/management/internals/network_map_db/sqlite/account_setting.go b/management/internals/network_map_db/sqlite/account_setting.go new file mode 100644 index 000000000..9a1a152fe --- /dev/null +++ b/management/internals/network_map_db/sqlite/account_setting.go @@ -0,0 +1,47 @@ +package networkmap_sqlite + +import ( + "context" + "reflect" + + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" +) + +const ( + GetAccountSettingsQuery = ` + select settings_peer_login_expiration_enabled as peer_login_expiration_enabled, + settings_peer_login_expiration as peer_login_expiration, + settings_peer_inactivity_expiration_enabled as peer_inactivity_expiration_enabled, + settings_peer_inactivity_expiration as peer_inactivity_expiration, + settings_dns_domain as dns_domain, + settings_ipv6_enabled_groups as ipv6_enabled_groups, + settings_routing_peer_dns_resolution_enabled as routing_peer_dns_resolution_enabled, + settings_lazy_connection_enabled as lazy_connection_enabled, + settings_auto_update_version as auto_update_version, + settings_auto_update_always as auto_update_always, + settings_metrics_push_enabled as metrics_push_enabled + from accounts + where id=? + ` +) + +func (sc *SqliteStoreConn) GetAccountSettings(ctx context.Context, accountId string) (nmdata.AccountSettingsInfo, error) { + rows, err := sc.Conn.QueryContext(ctx, GetAccountSettingsQuery, accountId) + if err != nil { + return nmdata.AccountSettingsInfo{}, err + } + + a, err := CollectOneRowForSqlite[networkmapdb.Account](rows) + if err != nil { + return nmdata.AccountSettingsInfo{}, err + } + + settingsInfo := nmdata.AccountSettingsInfo{} + err = networkmapdb.FromSqlTypesToSharedTypes(reflect.ValueOf(&a), reflect.ValueOf(&settingsInfo)) + if err != nil { + return nmdata.AccountSettingsInfo{}, err + } + + return settingsInfo, nil +} diff --git a/management/internals/network_map_db/sqlite/dns.go b/management/internals/network_map_db/sqlite/dns.go new file mode 100644 index 000000000..dd2cb3758 --- /dev/null +++ b/management/internals/network_map_db/sqlite/dns.go @@ -0,0 +1,32 @@ +package networkmap_sqlite + +import ( + "context" + + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" + "github.com/netbirdio/netbird/shared/management/networkmap" +) + +const ( + GetAccountZonesQuery = ` + select zones.id as id, domain, not enable_search_domain as search_domain_disabled, distribution_groups, + r.name as record_name, r.type as record_type, 'IN' record_class, r.ttl as record_ttl, r.content as record_rdata + from zones + left join records as r on r.zone_id = zones.id + where zones.account_id=? and zones.enabled + ` +) + +func (sc *SqliteStoreConn) GetAppliedZoneCandidates(ctx context.Context, accountId string) ([]networkmap.AppliedZoneCandidate, error) { + rows, err := sc.Conn.QueryContext(ctx, GetAccountZonesQuery, accountId) + if err != nil { + return nil, err + } + + zones, err := CollectRowsForSqlite[networkmapdb.Zone](rows) + if err != nil { + return nil, err + } + + return networkmapdb.ZonesToAppliedZoneCandidates(zones) +} diff --git a/management/internals/network_map_db/sqlite/dns_setting.go b/management/internals/network_map_db/sqlite/dns_setting.go new file mode 100644 index 000000000..7c6e9e7eb --- /dev/null +++ b/management/internals/network_map_db/sqlite/dns_setting.go @@ -0,0 +1,42 @@ +package networkmap_sqlite + +import ( + "context" + "encoding/json" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" +) + +const ( + GetDnsSettingsQuery = ` + select dns_settings_disabled_management_groups + from accounts + where id=? + ` +) + +func (sc *SqliteStoreConn) GetDnsSettings(ctx context.Context, accountId string) (nmdata.DNSSettings, error) { + rows, err := sc.Conn.QueryContext(ctx, GetDnsSettingsQuery, accountId) + if err != nil { + return nmdata.DNSSettings{}, err + } + defer rows.Close() + + var value nmdata.DNSSettings + var settings []byte + + rows.Next() + if err := rows.Scan(&settings); err != nil { + return value, err + } + + if settings == nil { + return nmdata.DNSSettings{}, nil + } + + if err := json.Unmarshal(settings, &value.DisabledManagementGroups); err != nil { + return value, err + } + + return value, nil +} diff --git a/management/internals/network_map_db/sqlite/domain.go b/management/internals/network_map_db/sqlite/domain.go new file mode 100644 index 000000000..572977c3b --- /dev/null +++ b/management/internals/network_map_db/sqlite/domain.go @@ -0,0 +1,24 @@ +package networkmap_sqlite + +import ( + "context" + + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" +) + +const ( + GetDomainsQuery = ` + select domain, target_cluster + from domains + where account_id=? and domain<>'' and target_cluster<>'' + ` +) + +func (sc *SqliteStoreConn) GetDomains(ctx context.Context, accountId string) ([]networkmapdb.Domain, error) { + rows, err := sc.Conn.QueryContext(ctx, GetDomainsQuery, accountId) + if err != nil { + return nil, err + } + + return CollectRowsForSqlite[networkmapdb.Domain](rows) +} diff --git a/management/internals/network_map_db/sqlite/group.go b/management/internals/network_map_db/sqlite/group.go new file mode 100644 index 000000000..c324d0d29 --- /dev/null +++ b/management/internals/network_map_db/sqlite/group.go @@ -0,0 +1,70 @@ +package networkmap_sqlite + +import ( + "context" + "database/sql" + "reflect" + + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" +) + +const ( + GetGroupsQuery = ` + select groups.id, groups.name, groups.public_id, groups.resources, gp.peer_id + from groups + left join group_peers gp on gp.group_id=groups.id and gp.account_id=? + where groups.account_id=? + ` +) + +// we also return a resource-to-group index. +// an alternative is to add json indexes, query this directly. Not sure how expensive +// json indexes are. TODO (dmitri) verify and maybe change the implementation here. +func (sc *SqliteStoreConn) GetGroups(ctx context.Context, accountId string) ([]nmdata.Group, map[string]map[string]any, error) { + rows, err := sc.Conn.QueryContext(ctx, GetGroupsQuery, accountId, accountId) + if err != nil { + return nil, nil, err + } + + groups, err := CollectRowsForSqlite[group](rows) + + toret := make([]nmdata.Group, 0, len(groups)) + resourceToGroupIdx := make(map[string]map[string]any) + + for _, g := range groups { + if len(toret) > 0 && toret[len(toret)-1].ID == g.ID && g.PeerID.Valid { + toret[len(toret)-1].Peers = append(toret[len(toret)-1].Peers, g.PeerID.String) + continue + } + + dg := nmdata.Group{} + err := networkmapdb.FromSqlTypesToSharedTypes( + reflect.ValueOf(&g), reflect.ValueOf(&dg)) + if err != nil { + return nil, nil, err + } + + if g.PeerID.Valid { + dg.Peers = append(dg.Peers, g.PeerID.String) + } + toret = append(toret, dg) + + for _, resource := range dg.Resources { + if _, ok := resourceToGroupIdx[resource.ID]; !ok { + resourceToGroupIdx[resource.ID] = make(map[string]any) + } + resourceToGroupIdx[resource.ID][g.ID] = struct{}{} + } + } + + return toret, resourceToGroupIdx, err +} + +type group struct { + ID string + Name sql.NullString + PublicID sql.NullString + Resources []byte `nmap:"json"` + PeerID sql.NullString `nmap:"skip"` +} diff --git a/management/internals/network_map_db/sqlite/nameserver.go b/management/internals/network_map_db/sqlite/nameserver.go new file mode 100644 index 000000000..618e1a1f3 --- /dev/null +++ b/management/internals/network_map_db/sqlite/nameserver.go @@ -0,0 +1,30 @@ +package networkmap_sqlite + +import ( + "context" + + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" +) + +const ( + GetNameserversQuery = ` + select id, public_id, name, description, name_servers, groups, "primary", domains, enabled, search_domains_enabled + from name_server_groups + where account_id=? + ` +) + +func (sc *SqliteStoreConn) GetNameServerGroups(ctx context.Context, accountId string) ([]nmdata.NameServerGroup, error) { + rows, err := sc.Conn.QueryContext(ctx, GetNameserversQuery, accountId) + if err != nil { + return nil, err + } + + nsgroups, err := CollectRowsForSqlite[networkmapdb.NameserverGroup](rows) + if err != nil { + return nil, err + } + + return networkmapdb.ConvertAllToSharedTypes[networkmapdb.NameserverGroup, nmdata.NameServerGroup](nsgroups) +} diff --git a/management/internals/network_map_db/sqlite/network.go b/management/internals/network_map_db/sqlite/network.go new file mode 100644 index 000000000..3fa85ecdf --- /dev/null +++ b/management/internals/network_map_db/sqlite/network.go @@ -0,0 +1,38 @@ +package networkmap_sqlite + +import ( + "context" + "reflect" + + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" +) + +const ( + GetNetworkQuery = ` + select network_identifier as identifier, network_net as net, network_net_v6 as net_v6, network_dns as dns, network_serial as serial + from accounts + where id=? + ` +) + +func (sc *SqliteStoreConn) GetNetwork(ctx context.Context, accountId string) (nmdata.Network, error) { + rows, err := sc.Conn.QueryContext(ctx, GetNetworkQuery, accountId) + if err != nil { + return nmdata.Network{}, err + } + + n, err := CollectOneRowForSqlite[networkmapdb.AccountNetwork](rows) + if err != nil { + return nmdata.Network{}, err + } + + toret := nmdata.Network{} + err = networkmapdb.FromSqlTypesToSharedTypes( + reflect.ValueOf(&n), reflect.ValueOf(&toret)) + if err != nil { + return nmdata.Network{}, err + } + + return toret, nil +} diff --git a/management/internals/network_map_db/sqlite/network_resource.go b/management/internals/network_map_db/sqlite/network_resource.go new file mode 100644 index 000000000..1d98a12e9 --- /dev/null +++ b/management/internals/network_map_db/sqlite/network_resource.go @@ -0,0 +1,30 @@ +package networkmap_sqlite + +import ( + "context" + + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" +) + +const ( + GetNetworkResourcesQuery = ` + select id, network_id, account_id, public_id, name, description, type, domain, prefix, enabled + from network_resources + where account_id=? + ` +) + +func (sc *SqliteStoreConn) GetNetworkResources(ctx context.Context, accountId string) ([]nmdata.NetworkResource, error) { + rows, err := sc.Conn.QueryContext(ctx, GetNetworkResourcesQuery, accountId) + if err != nil { + return nil, err + } + + netresorces, err := CollectRowsForSqlite[networkmapdb.Networkresource](rows) + if err != nil { + return nil, err + } + + return networkmapdb.ConvertAllToSharedTypes[networkmapdb.Networkresource, nmdata.NetworkResource](netresorces) +} diff --git a/management/internals/network_map_db/sqlite/network_router.go b/management/internals/network_map_db/sqlite/network_router.go new file mode 100644 index 000000000..8c4c31cd6 --- /dev/null +++ b/management/internals/network_map_db/sqlite/network_router.go @@ -0,0 +1,74 @@ +package networkmap_sqlite + +import ( + "context" + "database/sql" + "fmt" + "reflect" + + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" +) + +const ( + GetNetworkRouterQuery = ` + select public_id, peer, network_id, masquerade, metric, enabled, peer_groups, group_peers.peer_id + from network_routers, json_each(peer_groups) + left join group_peers on group_peers.account_id=? and group_peers.group_id=json_each.value + where network_routers.account_id=? + ` +) + +func (sc *SqliteStoreConn) GetNetworkRouters(ctx context.Context, accountId string) (map[string]map[string]*nmdata.NetworkRouter, error) { + rows, err := sc.Conn.QueryContext(ctx, GetNetworkRouterQuery, accountId, accountId) + if err != nil { + return nil, err + } + + routers, err := CollectRowsForSqlite[networkrouter](rows) + if err != nil { + return nil, err + } + + toret := make(map[string]map[string]*nmdata.NetworkRouter) + for _, router := range routers { + if !router.Enabled.Bool { + continue + } + + networkId := router.NetworkID.String + if networkId == "" { + return nil, fmt.Errorf("router with public_id %s doesn't have network_id set", router.PublicID.String) + } + + nmdatarouter := nmdata.NetworkRouter{} + err := networkmapdb.FromSqlTypesToSharedTypes(reflect.ValueOf(&router), reflect.ValueOf(&nmdatarouter)) + if err != nil { + return nil, err + } + + if toret[networkId] == nil { + toret[networkId] = make(map[string]*nmdata.NetworkRouter) + } + if router.Peer.String != "" { + toret[networkId][router.Peer.String] = &nmdatarouter + continue + } + if router.PeerViaGroups.String != "" { + toret[networkId][router.PeerViaGroups.String] = &nmdatarouter + } + } + + return toret, nil +} + +type networkrouter struct { + PublicID sql.NullString + Peer sql.NullString `nmap:"skip"` + NetworkID sql.NullString `nmap:"skip"` + Masquerade sql.NullBool + Metric sql.NullInt64 + Enabled sql.NullBool + PeerGroups []byte `nmap:"json"` + PeerViaGroups sql.NullString `nmap:"skip"` +} diff --git a/management/internals/network_map_db/sqlite/networks.go b/management/internals/network_map_db/sqlite/networks.go new file mode 100644 index 000000000..e19336846 --- /dev/null +++ b/management/internals/network_map_db/sqlite/networks.go @@ -0,0 +1,35 @@ +package networkmap_sqlite + +import ( + "context" + + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" +) + +const ( + GetNetworksQuery = ` + select id, public_id + from networks where account_id=? + ` +) + +func (sc *SqliteStoreConn) GetNetworkXIDToPublicIdMap(ctx context.Context, accountId string) (map[string]string, error) { + rows, err := sc.Conn.QueryContext(ctx, GetNetworksQuery, accountId) + if err != nil { + return nil, err + } + + networks, err := CollectRowsForSqlite[networkmapdb.Network](rows) + if err != nil { + return nil, err + } + + toret := make(map[string]string) + for _, n := range networks { + if n.PublicID.Valid { + toret[n.ID] = n.PublicID.String + } + } + + return toret, nil +} diff --git a/management/internals/network_map_db/sqlite/peer.go b/management/internals/network_map_db/sqlite/peer.go new file mode 100644 index 000000000..12d9e9ab7 --- /dev/null +++ b/management/internals/network_map_db/sqlite/peer.go @@ -0,0 +1,33 @@ +package networkmap_sqlite + +import ( + "context" + + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" +) + +const ( + GetPeersQuery = ` + select id, key, ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6, + peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster, + meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, meta_capabilities, meta_flags, meta_sync_message_version, + location_country_code, location_city_name, location_connection_ip + from peers + where account_id = ? + ` +) + +func (sc *SqliteStoreConn) GetPeers(ctx context.Context, accountId string) ([]nmdata.Peer, map[string][]*nmdata.Peer, error) { + rows, err := sc.Conn.QueryContext(ctx, GetPeersQuery, accountId) + if err != nil { + return nil, nil, err + } + + peers, err := CollectRowsForSqlite[networkmapdb.Peer](rows) + if err != nil { + return nil, nil, err + } + + return networkmapdb.ConvertToNmdataPeers(peers) +} diff --git a/management/internals/network_map_db/sqlite/policy.go b/management/internals/network_map_db/sqlite/policy.go new file mode 100644 index 000000000..1a11f6e20 --- /dev/null +++ b/management/internals/network_map_db/sqlite/policy.go @@ -0,0 +1,33 @@ +package networkmap_sqlite + +import ( + "context" + + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" +) + +const ( + GetPoliciesQuery = ` + select p.id, p.public_id, p.enabled, p.source_posture_checks, pr.enabled as rule_enabled, pr.action, pr.protocol, pr.bidirectional, + pr.sources, pr.destinations, pr.source_resource, pr.destination_resource, pr.ports, pr.port_ranges, + pr.authorized_groups, pr.authorized_user + from policies as p + left join policy_rules as pr on p.id = pr.policy_id + where account_id=? + ` +) + +func (sc *SqliteStoreConn) GetPolicies(ctx context.Context, accountId string) ([]nmdata.Policy, map[string]map[string]any, map[string]map[string]any, error) { + rows, err := sc.Conn.QueryContext(ctx, GetPoliciesQuery, accountId) + if err != nil { + return nil, nil, nil, err + } + + policies, err := CollectRowsForSqlite[networkmapdb.Policy](rows) + if err != nil { + return nil, nil, nil, err + } + + return networkmapdb.ConvertToNmdataPolicy(policies) +} diff --git a/management/internals/network_map_db/sqlite/posture.go b/management/internals/network_map_db/sqlite/posture.go new file mode 100644 index 000000000..6caee6e79 --- /dev/null +++ b/management/internals/network_map_db/sqlite/posture.go @@ -0,0 +1,43 @@ +package networkmap_sqlite + +import ( + "context" + "reflect" + + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" +) + +const ( + GetPostureChecksQuery = ` + select id, public_id, checks + from posture_checks + where account_id=? + ` +) + +func (sc *SqliteStoreConn) GetPostureChecks(ctx context.Context, accountId string) ([]nmdata.PostureChecks, map[string]string, error) { + rows, err := sc.Conn.QueryContext(ctx, GetPostureChecksQuery, accountId) + if err != nil { + return nil, nil, err + } + + checks, err := CollectRowsForSqlite[networkmapdb.PostureChecks](rows) + if err != nil { + return nil, nil, err + } + + toret := make([]nmdata.PostureChecks, 0, len(checks)) + idToPublicIDIdx := make(map[string]string) + for _, c := range checks { + checks := nmdata.PostureChecks{} + err := networkmapdb.FromSqlTypesToSharedTypes(reflect.ValueOf(&c), reflect.ValueOf(&checks)) + if err != nil { + return nil, nil, err + } + toret = append(toret, checks) + idToPublicIDIdx[checks.ID] = c.PublicID.String + } + + return toret, idToPublicIDIdx, nil +} diff --git a/management/internals/network_map_db/sqlite/route.go b/management/internals/network_map_db/sqlite/route.go new file mode 100644 index 000000000..58b3eca55 --- /dev/null +++ b/management/internals/network_map_db/sqlite/route.go @@ -0,0 +1,32 @@ +package networkmap_sqlite + +import ( + "context" + + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" +) + +const ( + GetRoutesQuery = ` + select id, account_id, public_id, network, domains, keep_route, net_id, description, + peer, peer as peer_id, peer_groups, network_type, masquerade, metric, enabled, + groups, access_control_groups, skip_auto_apply + from routes + where account_id=? + ` +) + +func (sc *SqliteStoreConn) GetRoutes(ctx context.Context, accountId string) ([]nmdata.Route, error) { + rows, err := sc.Conn.QueryContext(ctx, GetRoutesQuery, accountId) + if err != nil { + return nil, err + } + + routes, err := CollectRowsForSqlite[networkmapdb.Route](rows) + if err != nil { + return nil, err + } + + return networkmapdb.ConvertAllToSharedTypes[networkmapdb.Route, nmdata.Route](routes) +} diff --git a/management/internals/network_map_db/sqlite/service.go b/management/internals/network_map_db/sqlite/service.go new file mode 100644 index 000000000..5d25f69e5 --- /dev/null +++ b/management/internals/network_map_db/sqlite/service.go @@ -0,0 +1,89 @@ +package networkmap_sqlite + +import ( + "context" + "database/sql" + "encoding/json" + + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" +) + +const ( + GetServicesQuery = ` + select enabled, private, access_groups, proxy_cluster, domain + from services + where account_id=? + ` + + GetProxyTargetedDomainResourcesQuery = ` + select t.target_id + from targets as t + join services as s on s.id = t.service_id + where s.account_id=? and s.enabled and not coalesce(s.terminated, false) + and t.enabled and t.target_type='domain' and t.target_id is not null + ` +) + +func (sc *SqliteStoreConn) GetPrivateServices(ctx context.Context, accountId string) ([]networkmapdb.Service, error) { + rows, err := sc.Conn.QueryContext(ctx, GetServicesQuery, accountId) + if err != nil { + return nil, err + } + + services, err := CollectRowsForSqlite[service](rows) + if err != nil { + return nil, err + } + + toret := make([]networkmapdb.Service, 0, len(services)) + for _, service := range services { + acg := []string{} + if service.AccessGroups != nil { + if err := json.Unmarshal(service.AccessGroups, &acg); err != nil { + return nil, err + } + } + s := networkmapdb.Service{ + Enabled: service.Enabled, + Private: service.Private, + AccessGroups: acg, + ProxyCluster: service.ProxyCluster, + Domain: service.Domain, + } + + toret = append(toret, s) + } + return toret, nil +} + +func (sc *SqliteStoreConn) GetProxyTargetedDomainResourceIDs(ctx context.Context, accountId string) (map[string]struct{}, error) { + rows, err := sc.Conn.QueryContext(ctx, GetProxyTargetedDomainResourcesQuery, accountId) + if err != nil { + return nil, err + } + defer rows.Close() + + toret := make(map[string]struct{}) + for rows.Next() { + var id string + err := rows.Scan(&id) + if err != nil { + return nil, err + } + toret[id] = struct{}{} + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return toret, nil +} + +type service struct { + Enabled sql.NullBool + Private sql.NullBool + AccessGroups []byte + ProxyCluster sql.NullString + Domain sql.NullString +} diff --git a/management/internals/network_map_db/sqlite/sqlite_store.go b/management/internals/network_map_db/sqlite/sqlite_store.go new file mode 100644 index 000000000..14abf80bc --- /dev/null +++ b/management/internals/network_map_db/sqlite/sqlite_store.go @@ -0,0 +1,148 @@ +package networkmap_sqlite + +import ( + "context" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + + "database/sql" + + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" +) + +var ErrNoRows = errors.New("no rows in result set") + +type SqliteStore struct { + Db *sql.DB +} + +type sqliteInterface interface { + QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) +} + +type SqliteStoreConn struct { + Conn sqliteInterface +} + +func NewSqliteStore(storeFile, dataDir string) (*SqliteStore, error) { + dbfile := storeFile + if envFile, ok := os.LookupEnv("NB_STORE_ENGINE_SQLITE_FILE"); ok && envFile != "" { + dbfile = envFile + } + + // Separate file path from any SQLite URI query parameters (e.g., "store.db?mode=rwc") + filePath, query, hasQuery := strings.Cut(dbfile, "?") + + connStr := filePath + if filePath != ":memory:" && !filepath.IsAbs(filePath) { + connStr = filepath.Join(dataDir, filePath) + } + + // Compose query parameters. User-provided ?_busy_timeout (or its mattn alias + // ?_timeout) overrides our default; otherwise inject 30s so SQLite waits at + // most that long on a lock instead of blocking the only Go-side connection. + // mattn/go-sqlite3 applies PRAGMA from the DSN on every fresh connection, so + // the value survives ConnMaxIdleTime/ConnMaxLifetime recycling. cache=shared + // stays the default on non-Windows for the same reason as before. + parsed, _ := url.ParseQuery(query) + var defaults []string + if parsed.Get("_busy_timeout") == "" && parsed.Get("_timeout") == "" { + defaults = append(defaults, "_busy_timeout=30000") + } + if !hasQuery && runtime.GOOS != "windows" { + // To avoid `The process cannot access the file because it is being used by another process` on Windows + defaults = append(defaults, "cache=shared") + } + parts := defaults + if hasQuery { + parts = append(parts, query) + } + if len(parts) > 0 { + connStr += "?" + strings.Join(parts, "&") + } + + db, err := sql.Open("sqlite3", connStr) + if err != nil { + return nil, err + } + + return &SqliteStore{Db: db}, nil +} + +func (s *SqliteStore) BeginTx(ctx context.Context) (networkmapdb.NetworkMapDBStoreConn, error) { + tx, err := s.Db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true, Isolation: sql.LevelRepeatableRead}) + if err != nil { + return nil, err + } + return &SqliteStoreConn{Conn: tx}, nil +} + +func (s *SqliteStore) Exec(_ context.Context, query string, args ...any) error { + _, err := s.Db.Exec(query, args...) + return err +} + +func (sc *SqliteStoreConn) RollbackTx(ctx context.Context) error { + tx, ok := sc.Conn.(*sql.Tx) + if !ok { + return fmt.Errorf("expected an sql.Tx got %s", reflect.TypeOf(sc.Conn).Kind()) + } + return tx.Rollback() +} + +func (sc *SqliteStoreConn) CommitTx(ctx context.Context) error { + tx, ok := sc.Conn.(*sql.Tx) + if !ok { + return fmt.Errorf("expected an sql.Tx got %s", reflect.TypeOf(sc.Conn).Kind()) + } + return tx.Commit() +} + +func (s *SqliteStore) UsingConn() *SqliteStoreConn { + return &SqliteStoreConn{Conn: s.Db} +} + +func CollectOneRowForSqlite[T any](rows *sql.Rows) (T, error) { + defer rows.Close() + var r T + + if !rows.Next() { + if err := rows.Err(); err != nil { + return r, err + } + return r, ErrNoRows + } + err := rows.Scan(networkmapdb.StructFields(&r)...) + if err != nil { + return r, err + } + + return r, nil +} + +func CollectRowsForSqlite[T any](rows *sql.Rows) ([]T, error) { + defer rows.Close() + toret := make([]T, 0) + + for rows.Next() { + var r T + err := rows.Scan(networkmapdb.StructFields(&r)...) + if err != nil { + return nil, err + } + toret = append(toret, r) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return toret, nil +} diff --git a/management/internals/network_map_db/sqlite/user.go b/management/internals/network_map_db/sqlite/user.go new file mode 100644 index 000000000..0bdda372e --- /dev/null +++ b/management/internals/network_map_db/sqlite/user.go @@ -0,0 +1,84 @@ +package networkmap_sqlite + +import ( + "context" + "database/sql" + "encoding/json" +) + +const ( + GetAllowedUserIdsQuery = ` + select id, auto_groups + from users + where account_id=? and not blocked and not is_service_user + ` + + GetAllGroupIdQuery = ` + select id from groups + where account_id=? and name='All' + ` +) + +func (sc *SqliteStoreConn) GetAllowedUsers(ctx context.Context, accountId string) (map[string]struct{}, map[string][]string, error) { + rows, err := sc.Conn.QueryContext(ctx, GetAllowedUserIdsQuery, accountId) + if err != nil { + return nil, nil, err + } + + users, err := CollectRowsForSqlite[user](rows) + if err != nil { + return nil, nil, err + } + + rows, err = sc.Conn.QueryContext(ctx, GetAllGroupIdQuery, accountId) + if err != nil { + return nil, nil, err + } + allGroupIds, err := collectAllGroupIds(rows) + if err != nil { + return nil, nil, err + } + + userIdIdx := make(map[string]struct{}) + groupIdToUserIds := make(map[string][]string) + for _, user := range users { + autogroups := make([]string, 0) + if err := json.Unmarshal(user.AutoGroups, &autogroups); err != nil { + return nil, nil, err + } + userIdIdx[user.ID] = struct{}{} + for _, groupId := range autogroups { + groupIdToUserIds[groupId] = append(groupIdToUserIds[groupId], user.ID) + } + for _, allgid := range allGroupIds { + groupIdToUserIds[allgid] = append(groupIdToUserIds[allgid], user.ID) + } + } + + return userIdIdx, groupIdToUserIds, nil +} + +func collectAllGroupIds(rows *sql.Rows) ([]string, error) { + defer rows.Close() + var toret []string + + for rows.Next() { + var id string + err := rows.Scan(&id) + if err != nil { + return nil, err + } + toret = append(toret, id) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return toret, nil +} + +type user struct { + ID string + AutoGroups []byte +} diff --git a/management/internals/network_map_db/struct_helpers.go b/management/internals/network_map_db/struct_helpers.go new file mode 100644 index 000000000..1719662fd --- /dev/null +++ b/management/internals/network_map_db/struct_helpers.go @@ -0,0 +1,157 @@ +package networkmapdb + +import ( + "database/sql" + "encoding/json" + "errors" + "reflect" + "strings" + + "github.com/rs/xid" +) + +var ErrNoRows = errors.New("no rows in result set") + +const ( + NMAP_STRUCT_TAG = "nmap" + NMAP_SKIP = "skip" + NMAP_MAP_TO = "map_to" + NMAP_JSON = "json" +) + +type fieldTag struct { + Key string + Value string +} + +func tagFromString(t string) fieldTag { + kv := strings.Split(t, ":") + if len(kv) == 1 { + return fieldTag{Key: strings.TrimSpace(kv[0])} + } + return fieldTag{Key: strings.TrimSpace(kv[0]), Value: strings.TrimSpace(kv[1])} +} + +func FromSqlTypesToSharedTypes(src reflect.Value, dst reflect.Value) error { + typ := src.Elem().Type() + + for i := 0; i < typ.NumField(); i++ { + f := typ.Field(i) + + fieldTags := make(map[string]string) + if v := f.Tag.Get(NMAP_STRUCT_TAG); v != "" { + for _, t := range strings.Split(v, ",") { + kv := tagFromString(t) + fieldTags[kv.Key] = kv.Value + } + } + if _, ok := fieldTags[NMAP_SKIP]; ok { + continue + } + if f.PkgPath != "" { // skip unexported fields + continue + } + dstFieldName := f.Name + if override, ok := fieldTags[NMAP_MAP_TO]; ok { + dstFieldName = override + } + + dstField := dst.Elem().FieldByName(dstFieldName) + if !dstField.IsValid() { + return errors.New("unsupported type in destination field: " + dstFieldName) + } + + srcField := src.Elem().Field(i) + srcFieldType := srcField.Type().String() + switch srcFieldType { + case "string": + s := srcField.Interface().(string) + dstField.SetString(s) + case "sql.NullString": + s := srcField.Interface().(sql.NullString) + if s.Valid { + dstField.SetString(s.String) + } + if (dstFieldName == "PublicId" || dstFieldName == "PublicID") && s.String == "" { + dstField.SetString(xid.New().String()) // TODO (dmitri) this needs to be removed to support delta updates + } + case "sql.NullTime": + s := srcField.Interface().(sql.NullTime) + if s.Valid { + if dstField.Kind() == reflect.Ptr { + t := reflect.ValueOf(&s.Time).Elem() + dstField.Set(t.Addr()) + } else { + dstField.Set(reflect.ValueOf(s.Time)) + } + } + case "sql.NullBool": + s := srcField.Interface().(sql.NullBool) + if s.Valid { + dstField.SetBool(s.Bool) + } + case "sql.NullInt64": + s := srcField.Interface().(sql.NullInt64) + if s.Valid { + dstField.SetInt(s.Int64) + } + case "json.RawMessage": + s := srcField.Interface().(json.RawMessage) + if len(s) == 0 { + continue + } + if err := json.Unmarshal(s, dstField.Addr().Interface()); err != nil { + return err + } + case "[]byte", "[]uint8": + s := srcField.Interface().([]byte) + if _, ok := fieldTags[NMAP_JSON]; !ok || len(s) == 0 { + continue + } + if err := json.Unmarshal(s, dstField.Addr().Interface()); err != nil { + return err + } + case "[]string": + if srcField.IsNil() { + continue + } + dstv := reflect.MakeSlice(dstField.Type(), srcField.Len(), srcField.Cap()) + reflect.Copy(dstv, srcField) + dstField.Set(dstv) + } + } + + return nil +} + +func StructFields(s any) []any { + src := reflect.ValueOf(s) + toret := make([]any, 0) + typ := src.Elem().Type() + + for i := 0; i < typ.NumField(); i++ { + f := typ.Field(i) + if f.PkgPath != "" { // skip unexported fields + continue + } + + srcField := src.Elem().Field(i) + toret = append(toret, srcField.Addr().Interface()) + } + + return toret +} + +func ConvertAllToSharedTypes[T any, T1 any](allsrc []T) ([]T1, error) { + toret := make([]T1, 0, len(allsrc)) + for _, src := range allsrc { + var dst T1 + err := FromSqlTypesToSharedTypes( + reflect.ValueOf(&src), reflect.ValueOf(&dst)) + if err != nil { + return nil, err + } + toret = append(toret, dst) + } + return toret, nil +} diff --git a/management/internals/server/boot.go b/management/internals/server/boot.go index e8f4964c6..0a4df3924 100644 --- a/management/internals/server/boot.go +++ b/management/internals/server/boot.go @@ -5,6 +5,7 @@ package server import ( "context" "crypto/tls" + "errors" "net/http" "net/netip" "slices" @@ -30,6 +31,8 @@ import ( proxyactivity "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/activity" proxyactivitymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/activity/manager" rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" + networkmapdbfactory "github.com/netbirdio/netbird/management/internals/network_map_db/factory" nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" "github.com/netbirdio/netbird/management/server/activity" activitystore "github.com/netbirdio/netbird/management/server/activity/store" @@ -101,6 +104,26 @@ func (s *BaseServer) Store() store.Store { }) } +// TODO dmitri: move all validation checks (e.g. config+env vars) from runtime to base server creation +// this way we don't need to spread defensive checks throughout the codebase +func (s *BaseServer) NetworkMapStore() *networkmapdb.NetworkMapDBStoreImpl { + return Create(s, func() *networkmapdb.NetworkMapDBStoreImpl { + store, err := networkmapdbfactory.NewNetworkMapDBStore( + context.Background(), + s.Config.StoreConfig.Engine, + s.Config.Datadir, + s.IntegratedValidator(), + s.SettingsManager()) + // networkmap db store supports postgres and sqlite backends only + // for other backends a fallback is used, so NotSupportedStoreEngineError + // is not a fatal error + if err != nil && !errors.Is(err, networkmapdbfactory.ErrNotSupportedStoreEngine) { + log.Fatalf("failed to create network map store: %v", err) + } + return store + }) +} + func (s *BaseServer) EventStore() activity.Store { return Create(s, func() activity.Store { var err error diff --git a/management/internals/server/controllers.go b/management/internals/server/controllers.go index 1b2556809..a9293d266 100644 --- a/management/internals/server/controllers.go +++ b/management/internals/server/controllers.go @@ -123,7 +123,7 @@ func (s *BaseServer) EphemeralManager() ephemeral.Manager { func (s *BaseServer) NetworkMapController() network_map.Controller { return Create(s, func() network_map.Controller { - return nmapcontroller.NewController(context.Background(), s.Store(), s.Metrics(), s.PeersUpdateManager(), s.AccountRequestBuffer(), s.IntegratedValidator(), s.SettingsManager(), s.DNSDomain(), s.ProxyController(), s.EphemeralManager(), s.Config) + return nmapcontroller.NewController(context.Background(), s.Store(), s.Metrics(), s.PeersUpdateManager(), s.AccountRequestBuffer(), s.IntegratedValidator(), s.SettingsManager(), s.DNSDomain(), s.ProxyController(), s.EphemeralManager(), s.Config, s.NetworkMapStore()) }) } diff --git a/management/internals/shared/grpc/components_encoder.go b/management/internals/shared/grpc/components_encoder.go index baf21af94..a2aad19b6 100644 --- a/management/internals/shared/grpc/components_encoder.go +++ b/management/internals/shared/grpc/components_encoder.go @@ -4,10 +4,9 @@ import ( "encoding/base64" "strconv" - nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/management/server/types" - nbroute "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/networkmap" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" "github.com/netbirdio/netbird/shared/management/proto" ) @@ -84,6 +83,7 @@ func EncodeNetworkMapEnvelope(in ComponentsEnvelopeInput) *proto.NetworkMapEnvel enc := newComponentEncoder(c) enc.indexAllPeers() routerIdxs := enc.indexRouterPeers(c.RouterPeers) + enc.indexAllNetworkResources() // Phase 2: gather every policy that any consumer references (peer-pair // policies + resource-only policies) so encodeResourcePoliciesMap can @@ -105,7 +105,6 @@ func EncodeNetworkMapEnvelope(in ComponentsEnvelopeInput) *proto.NetworkMapEnvel DnsSettings: enc.encodeDNSSettings(c.DNSSettings), DnsDomain: in.DNSDomain, CustomZoneDomain: c.CustomZoneDomain, - AgentVersions: enc.agentVersions, Peers: enc.peers, RouterPeerIndexes: routerIdxs, Policies: policies, @@ -130,7 +129,7 @@ func EncodeNetworkMapEnvelope(in ComponentsEnvelopeInput) *proto.NetworkMapEnvel // networkSerial returns c.Network.CurrentSerial() with a nil guard. The // production path always populates c.Network, but the encoder is exported // and a hand-built components struct may omit it. -func networkSerial(n *types.Network) uint64 { +func networkSerial(n *nmdata.Network) uint64 { if n == nil { return 0 } @@ -143,16 +142,15 @@ type componentEncoder struct { peerOrder map[string]uint32 peers []*proto.PeerCompact - agentVersionOrder map[string]uint32 - agentVersions []string + networkIdToPublicId map[string]string } func newComponentEncoder(c *types.NetworkMapComponents) *componentEncoder { return &componentEncoder{ - components: c, - peerOrder: make(map[string]uint32, len(c.Peers)), - peers: make([]*proto.PeerCompact, 0, len(c.Peers)), - agentVersionOrder: make(map[string]uint32), + components: c, + peerOrder: make(map[string]uint32, len(c.Peers)), + peers: make([]*proto.PeerCompact, 0, len(c.Peers)), + networkIdToPublicId: make(map[string]string), } } @@ -165,7 +163,7 @@ func (e *componentEncoder) indexAllPeers() { } } -func (e *componentEncoder) appendPeer(p *types.ComponentPeer) uint32 { +func (e *componentEncoder) appendPeer(p *nmdata.Peer) uint32 { if idx, ok := e.peerOrder[p.ID]; ok { return idx } @@ -175,11 +173,10 @@ func (e *componentEncoder) appendPeer(p *types.ComponentPeer) uint32 { return idx } -// indexRouterPeers ensures every router peer is in the peer dedup table -// (c.RouterPeers may contain peers not in c.Peers when validation rules drop -// them) and returns their wire indexes for the RouterPeerIndexes field. Must -// run before any encoder that resolves peer ids via e.peerOrder. -func (e *componentEncoder) indexRouterPeers(routers map[string]*types.ComponentPeer) []uint32 { +// indexRouterPeers ensures every router peer is in the peer dedup table and +// returns their wire indexes for the RouterPeerIndexes field. Must run before +// any encoder that resolves peer ids via e.peerOrder. +func (e *componentEncoder) indexRouterPeers(routers map[string]*nmdata.Peer) []uint32 { if len(routers) == 0 { return nil } @@ -193,6 +190,15 @@ func (e *componentEncoder) indexRouterPeers(routers map[string]*types.ComponentP return out } +func (e *componentEncoder) indexAllNetworkResources() { + for _, r := range e.components.NetworkResources { + if !r.Enabled { + continue + } + e.networkIdToPublicId[r.ID] = r.PublicID + } +} + func (e *componentEncoder) encodeGroups() []*proto.GroupCompact { if len(e.components.Groups) == 0 { return nil @@ -206,10 +212,22 @@ func (e *componentEncoder) encodeGroups() []*proto.GroupCompact { peerIdxs = append(peerIdxs, idx) } } + + groupCompactResources := func() []*proto.ResourceCompact { + var toret []*proto.ResourceCompact + for _, r := range g.Resources { + if pr := e.resourceToProto(r); pr != nil { + toret = append(toret, pr) + } + } + return toret + } + out = append(out, &proto.GroupCompact{ Id: g.PublicID, PeerIndexes: peerIdxs, IsAll: g.IsGroupAll(), + Resources: groupCompactResources(), }) } return out @@ -219,7 +237,7 @@ func (e *componentEncoder) encodeGroups() []*proto.GroupCompact { // list and a map from policy pointer to the indexes of its emitted rules in // that list — used by encodeResourcePoliciesMap to translate // ResourcePoliciesMap[resourceID][]*Policy into wire-side indexes. -func (e *componentEncoder) encodePolicies(policies []*types.Policy) []*proto.PolicyCompact { +func (e *componentEncoder) encodePolicies(policies []*nmdata.Policy) []*proto.PolicyCompact { if len(policies) == 0 { return nil } @@ -241,7 +259,7 @@ func (e *componentEncoder) encodePolicies(policies []*types.Policy) []*proto.Pol } // encodePolicyRule maps a single PolicyRule under pol to a PolicyCompact entry. -func (e *componentEncoder) encodePolicyRule(pol *types.Policy, r *types.PolicyRule) *proto.PolicyCompact { +func (e *componentEncoder) encodePolicyRule(pol *nmdata.Policy, r *nmdata.PolicyRule) *proto.PolicyCompact { return &proto.PolicyCompact{ Id: pol.PublicID, Action: networkmap.GetProtoAction(string(r.Action)), @@ -280,14 +298,14 @@ func (e *componentEncoder) groupPublicXids(src []string) []string { // only live in ResourcePoliciesMap; without this union step they'd be lost // from the wire and the client's resource-policy lookup would come back // empty. -func unionPolicies(policies []*types.Policy, resourcePolicies map[string][]*types.Policy) []*types.Policy { +func unionPolicies(policies []*nmdata.Policy, resourcePolicies map[string][]*nmdata.Policy) []*nmdata.Policy { // Fast path: non-router peers have no resource-only policies, so the // "union" is identical to `policies`. Skip the dedup map allocation. if len(resourcePolicies) == 0 { return policies } seen := make(map[string]struct{}, len(policies)) - out := make([]*types.Policy, 0, len(policies)) + out := make([]*nmdata.Policy, 0, len(policies)) for _, p := range policies { if p == nil { continue @@ -314,16 +332,15 @@ func unionPolicies(policies []*types.Policy, resourcePolicies map[string][]*type } // encodeAuthorizedGroups translates rule.AuthorizedGroups (map keyed by -// group xid → local-user names) to the wire form (map keyed by group -// account_seq_id → UserNameList). Groups without a seq id are dropped — -// matches how source/destination group references handle the same case. +// group xid → local-user names) to the wire form (map keyed by +// authorizedGroupKey → UserNameList). func (e *componentEncoder) encodeAuthorizedGroups(m map[string][]string) map[string]*proto.UserNameList { if len(m) == 0 { return nil } out := make(map[string]*proto.UserNameList, len(m)) for groupID, names := range m { - id, ok := e.groupPublicXid(groupID) + id, ok := e.authorizedGroupKey(groupID) if !ok { continue } @@ -332,6 +349,24 @@ func (e *componentEncoder) encodeAuthorizedGroups(m map[string][]string) map[str return out } +// authorizedGroupKey resolves the wire key for a group that grants SSH access. +// These are user groups: they hold no peers, so nothing puts them in +// components.Groups and groupPublicXid cannot see them. Dropping them the way a +// missing source/destination group is dropped would strip every authorized user +// from the envelope while PeerConfig still reports SSH enabled, leaving the peer +// running sshd with nobody able to log in — so the id is passed through instead. +// AuthorizedGroups and GroupIDToUserIDs are only ever used against each other, +// on both sides of the wire, so they just have to agree. +func (e *componentEncoder) authorizedGroupKey(groupID string) (string, bool) { + if groupID == "" { + return "", false + } + if id, ok := e.groupPublicXid(groupID); ok { + return id, true + } + return groupID, true +} + func (e *componentEncoder) groupPublicXid(groupID string) (string, bool) { g, ok := e.components.Groups[groupID] if !ok { @@ -345,17 +380,29 @@ func (e *componentEncoder) groupPublicXid(groupID string) (string, bool) { // peers array. For other resource types only the type string is shipped // today (Calculate's resource-typed rule path consults SourceResource only // for "peer" — other types fall through to group-based lookup). -func (e *componentEncoder) resourceToProto(r types.Resource) *proto.ResourceCompact { - if r.ID == "" && r.Type == "" { +func (e *componentEncoder) resourceToProto(r nmdata.Resource) *proto.ResourceCompact { + if !types.ResourceType(r.Type).Valid() || r.ID == "" { return nil } - out := &proto.ResourceCompact{Type: string(r.Type)} - if r.Type == types.ResourceTypePeer && r.ID != "" { - if idx, ok := e.peerOrder[r.ID]; ok { - out.PeerIndexSet = true - out.PeerIndex = idx + + out := &proto.ResourceCompact{Type: r.Type} + + if r.Type == string(types.ResourceTypePeer) { + idx, ok := e.peerOrder[r.ID] + if !ok { + return nil } + out.PeerIndexSet = true + out.PeerIndex = idx + return out } + + publicID, ok := e.networkIdToPublicId[r.ID] + if !ok { + return nil + } + out.Id = publicID + return out } @@ -389,7 +436,7 @@ func (e *componentEncoder) networkPublicId(xid string) (string, bool) { return id, true } -func (e *componentEncoder) encodeDNSSettings(s *types.DNSSettings) *proto.DNSSettingsCompact { +func (e *componentEncoder) encodeDNSSettings(s *nmdata.DNSSettings) *proto.DNSSettingsCompact { if s == nil || len(s.DisabledManagementGroups) == 0 { return nil } @@ -404,7 +451,7 @@ func (e *componentEncoder) encodeDNSSettings(s *types.DNSSettings) *proto.DNSSet return out } -func (e *componentEncoder) encodeRoutes(routes []*nbroute.Route) []*proto.RouteRaw { +func (e *componentEncoder) encodeRoutes(routes []*nmdata.Route) []*proto.RouteRaw { if len(routes) == 0 { return nil } @@ -442,7 +489,7 @@ func (e *componentEncoder) encodeRoutes(routes []*nbroute.Route) []*proto.RouteR return out } -func (e *componentEncoder) encodeNameServerGroups(nsgs []*nbdns.NameServerGroup) []*proto.NameServerGroupRaw { +func (e *componentEncoder) encodeNameServerGroups(nsgs []*nmdata.NameServerGroup) []*proto.NameServerGroupRaw { if len(nsgs) == 0 { return nil } @@ -465,7 +512,7 @@ func (e *componentEncoder) encodeNameServerGroups(nsgs []*nbdns.NameServerGroup) return out } -func encodeNameServers(servers []nbdns.NameServer) []*proto.NameServer { +func encodeNameServers(servers []nmdata.NameServer) []*proto.NameServer { if len(servers) == 0 { return nil } @@ -480,7 +527,7 @@ func encodeNameServers(servers []nbdns.NameServer) []*proto.NameServer { return out } -func encodeSimpleRecords(records []nbdns.SimpleRecord) []*proto.SimpleRecord { +func encodeSimpleRecords(records []nmdata.SimpleRecord) []*proto.SimpleRecord { if len(records) == 0 { return nil } @@ -497,7 +544,7 @@ func encodeSimpleRecords(records []nbdns.SimpleRecord) []*proto.SimpleRecord { return out } -func encodeCustomZones(zones []nbdns.CustomZone) []*proto.CustomZone { +func encodeCustomZones(zones []nmdata.CustomZone) []*proto.CustomZone { if len(zones) == 0 { return nil } @@ -513,7 +560,7 @@ func encodeCustomZones(zones []nbdns.CustomZone) []*proto.CustomZone { return out } -func (e *componentEncoder) encodeNetworkResources(resources []*types.ComponentResource) []*proto.NetworkResourceRaw { +func (e *componentEncoder) encodeNetworkResources(resources []*nmdata.NetworkResource) []*proto.NetworkResourceRaw { if len(resources) == 0 { return nil } @@ -542,7 +589,7 @@ func (e *componentEncoder) encodeNetworkResources(resources []*types.ComponentRe return out } -func (e *componentEncoder) encodeRoutersMap(routersMap map[string]map[string]*types.ComponentRouter) map[string]*proto.NetworkRouterList { +func (e *componentEncoder) encodeRoutersMap(routersMap map[string]map[string]*nmdata.NetworkRouter) map[string]*proto.NetworkRouterList { if len(routersMap) == 0 { return nil } @@ -578,7 +625,7 @@ func (e *componentEncoder) encodeRoutersMap(routersMap map[string]map[string]*ty return out } -func (e *componentEncoder) encodeResourcePoliciesMap(rpm map[string][]*types.Policy) map[string]*proto.PolicyIds { +func (e *componentEncoder) encodeResourcePoliciesMap(rpm map[string][]*nmdata.Policy) map[string]*proto.PolicyIds { if len(rpm) == 0 { return nil } @@ -599,6 +646,9 @@ func (e *componentEncoder) encodeResourcePoliciesMap(rpm map[string][]*types.Pol } ids := make([]string, 0, len(policies)) for _, pol := range policies { + if pol == nil { + continue + } ids = append(ids, pol.PublicID) } if len(ids) == 0 { @@ -615,7 +665,7 @@ func (e *componentEncoder) encodeGroupIDToUserIDs(m map[string][]string) map[str } out := make(map[string]*proto.UserIDList, len(m)) for groupID, userIDs := range m { - id, ok := e.groupPublicXid(groupID) + id, ok := e.authorizedGroupKey(groupID) if !ok || len(userIDs) == 0 { continue } @@ -665,7 +715,7 @@ func (e *componentEncoder) encodePostureFailedPeers(m map[string]map[string]stru // (which shouldn't happen in production but the encoder is exported) // degrades to login_expiration_enabled = false, which makes // LoginExpired() return false for every peer. -func toAccountSettingsCompact(s *types.AccountSettingsInfo) *proto.AccountSettingsCompact { +func toAccountSettingsCompact(s *nmdata.AccountSettingsInfo) *proto.AccountSettingsCompact { if s == nil { return &proto.AccountSettingsCompact{} } @@ -675,7 +725,7 @@ func toAccountSettingsCompact(s *types.AccountSettingsInfo) *proto.AccountSettin } } -func toAccountNetwork(n *types.Network) *proto.AccountNetwork { +func toAccountNetwork(n *nmdata.Network) *proto.AccountNetwork { if n == nil { return nil } @@ -691,21 +741,21 @@ func toAccountNetwork(n *types.Network) *proto.AccountNetwork { return out } -func toPeerCompact(p *types.ComponentPeer) *proto.PeerCompact { +func toPeerCompact(p *nmdata.Peer) *proto.PeerCompact { pc := &proto.PeerCompact{ WgPubKey: decodeWgKey(p.Key), SshPubKey: []byte(p.SSHKey), DnsLabel: p.DNSLabel, - AgentVersion: p.AgentVersion, - AddedWithSsoLogin: p.AddedWithSSOLogin, + AgentVersion: p.Meta.WtVersion, + AddedWithSsoLogin: p.UserID != "", LoginExpirationEnabled: p.LoginExpirationEnabled, SshEnabled: p.SSHEnabled, - SupportsIpv6: p.SupportsIPv6, - SupportsSourcePrefixes: p.SupportsSourcePrefixes, - ServerSshAllowed: p.ServerSSHAllowed, - ProxyEmbedded: p.ProxyEmbedded, + SupportsIpv6: p.SupportsIPv6(), + SupportsSourcePrefixes: p.SupportsSourcePrefixes(), + ServerSshAllowed: p.Meta.Flags.ServerSSHAllowed, + ProxyEmbedded: p.ProxyMeta.Embedded, } - if !p.LastLogin.IsZero() { + if p.LastLogin != nil { pc.LastLoginUnixNano = p.LastLogin.UnixNano() } switch { @@ -754,7 +804,7 @@ func portsToUint32(ports []string) []uint32 { return out } -func portRangesToProto(ranges []types.RulePortRange) []*proto.PortInfo_Range { +func portRangesToProto(ranges []nmdata.RulePortRange) []*proto.PortInfo_Range { if len(ranges) == 0 { return nil } diff --git a/management/internals/shared/grpc/components_encoder_test.go b/management/internals/shared/grpc/components_encoder_test.go index f7a27ceba..6ee554e8b 100644 --- a/management/internals/shared/grpc/components_encoder_test.go +++ b/management/internals/shared/grpc/components_encoder_test.go @@ -16,7 +16,7 @@ import ( nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/management/server/types" - nbroute "github.com/netbirdio/netbird/route" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" "github.com/netbirdio/netbird/shared/management/proto" ) @@ -152,66 +152,66 @@ func envelopesEquivalent(a, b *proto.NetworkMapEnvelope) bool { } func newTestComponents() *types.NetworkMapComponents { - peerA := &types.ComponentPeer{ - ID: "peer-a", - Key: testWgKeyA, - IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), - DNSLabel: "peera", - SSHKey: "ssh-a", - AgentVersion: "0.40.0", + peerA := &nmdata.Peer{ + ID: "peer-a", + Key: testWgKeyA, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), + DNSLabel: "peera", + SSHKey: "ssh-a", + Meta: nmdata.PeerSystemMeta{WtVersion: "0.40.0"}, } - peerB := &types.ComponentPeer{ - ID: "peer-b", - Key: testWgKeyB, - IP: netip.AddrFrom4([4]byte{100, 64, 0, 2}), - IPv6: netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}), - DNSLabel: "peerb", - AgentVersion: "0.25.0", + peerB := &nmdata.Peer{ + ID: "peer-b", + Key: testWgKeyB, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 2}), + IPv6: netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}), + DNSLabel: "peerb", + Meta: nmdata.PeerSystemMeta{WtVersion: "0.25.0"}, } - peerC := &types.ComponentPeer{ - ID: "peer-c", - Key: testWgKeyC, - IP: netip.AddrFrom4([4]byte{100, 64, 0, 3}), - DNSLabel: "peerc", - AgentVersion: "0.40.0", + peerC := &nmdata.Peer{ + ID: "peer-c", + Key: testWgKeyC, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 3}), + DNSLabel: "peerc", + Meta: nmdata.PeerSystemMeta{WtVersion: "0.40.0"}, } return &types.NetworkMapComponents{ PeerID: "peer-a", - Network: &types.Network{ + Network: &nmdata.Network{ Identifier: "net-test", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}, Serial: 7, }, - AccountSettings: &types.AccountSettingsInfo{ + AccountSettings: &nmdata.AccountSettingsInfo{ PeerLoginExpirationEnabled: true, PeerLoginExpiration: 2 * time.Hour, }, - Peers: map[string]*types.ComponentPeer{ + Peers: map[string]*nmdata.Peer{ "peer-a": peerA, "peer-b": peerB, "peer-c": peerC, }, - Groups: map[string]*types.ComponentGroup{ - "group-src": {ID: "group-src", PublicID: "1", Name: "Src", Peers: []string{"peer-a"}}, - "group-dst": {ID: "group-dst", PublicID: "2", Name: "Dst", Peers: []string{"peer-b", "peer-c"}}, + Groups: map[string]*nmdata.Group{ + "group-src": {PublicID: "1", Name: "Src", Peers: []string{"peer-a"}}, + "group-dst": {PublicID: "2", Name: "Dst", Peers: []string{"peer-b", "peer-c"}}, }, - Policies: []*types.Policy{ + Policies: []*nmdata.Policy{ { ID: "pol-1", PublicID: "10", Enabled: true, - Rules: []*types.PolicyRule{{ - ID: "rule-1", Enabled: true, Action: types.PolicyTrafficActionAccept, - Protocol: types.PolicyRuleProtocolTCP, Bidirectional: true, + Rules: []*nmdata.PolicyRule{{ + ID: "rule-1", Enabled: true, Action: string(types.PolicyTrafficActionAccept), + Protocol: string(types.PolicyRuleProtocolTCP), Bidirectional: true, Ports: []string{"22", "80"}, - PortRanges: []types.RulePortRange{{Start: 8000, End: 8100}}, + PortRanges: []nmdata.RulePortRange{{Start: 8000, End: 8100}}, Sources: []string{"group-src"}, Destinations: []string{"group-dst"}, }}, }, }, - RouterPeers: map[string]*types.ComponentPeer{"peer-c": peerC}, + RouterPeers: map[string]*nmdata.Peer{"peer-c": peerC}, } } @@ -304,6 +304,31 @@ func TestEncodeNetworkMapEnvelope_GroupsByAccountPublicId(t *testing.T) { assert.Len(t, groupByID["2"].PeerIndexes, 2) } +func TestEncodePolicy(t *testing.T) { + encoder := componentEncoder{peerOrder: map[string]uint32{"peerId": uint32(1234)}, networkIdToPublicId: map[string]string{"domain": "publicDomain", "host": "publicHost", "subnet": "publicSubnet"}} + assert.Equal(t, + encoder.resourceToProto(nmdata.Resource{Type: "peer", ID: "peerId"}), + &proto.ResourceCompact{Type: "peer", PeerIndexSet: true, PeerIndex: uint32(1234)}) + // verify invalid peer id results in nil + assert.Nil(t, + encoder.resourceToProto(nmdata.Resource{Type: "peer", ID: "boom"})) + assert.Equal(t, + encoder.resourceToProto(nmdata.Resource{Type: "domain", ID: "domain"}), + &proto.ResourceCompact{Type: "domain", Id: "publicDomain"}) + assert.Equal(t, + encoder.resourceToProto(nmdata.Resource{Type: "host", ID: "host"}), + &proto.ResourceCompact{Type: "host", Id: "publicHost"}) + assert.Equal(t, + encoder.resourceToProto(nmdata.Resource{Type: "subnet", ID: "subnet"}), + &proto.ResourceCompact{Type: "subnet", Id: "publicSubnet"}) + // verify invalid resource type results in nil + assert.Nil(t, + encoder.resourceToProto(nmdata.Resource{Type: "boom", ID: "boom"})) + // verify invalid networkresource id results in nil + assert.Nil(t, + encoder.resourceToProto(nmdata.Resource{Type: "host", ID: "boom"})) +} + func TestEncodeNetworkMapEnvelope_PolicyExpansion(t *testing.T) { c := newTestComponents() @@ -377,12 +402,12 @@ func TestEncodeNetworkMapEnvelope_MalformedWgKey(t *testing.T) { func TestEncodeNetworkMapEnvelope_IPv6OnlyPeer(t *testing.T) { c := newTestComponents() - v6Only := &types.ComponentPeer{ - ID: "peer-v6", - Key: testWgKeyA, - IPv6: netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9}), - DNSLabel: "peerv6", - AgentVersion: "0.40.0", + v6Only := &nmdata.Peer{ + ID: "peer-v6", + Key: testWgKeyA, + IPv6: netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9}), + DNSLabel: "peerv6", + Meta: nmdata.PeerSystemMeta{WtVersion: "0.40.0"}, } c.Peers["peer-v6"] = v6Only @@ -401,11 +426,11 @@ func TestEncodeNetworkMapEnvelope_IPv6OnlyPeer(t *testing.T) { func TestEncodeNetworkMapEnvelope_PeerWithoutIP(t *testing.T) { c := newTestComponents() - c.Peers["peer-noip"] = &types.ComponentPeer{ - ID: "peer-noip", - Key: testWgKeyA, - DNSLabel: "peernoip", - AgentVersion: "0.40.0", + c.Peers["peer-noip"] = &nmdata.Peer{ + ID: "peer-noip", + Key: testWgKeyA, + DNSLabel: "peernoip", + Meta: nmdata.PeerSystemMeta{WtVersion: "0.40.0"}, } full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() @@ -423,7 +448,7 @@ func TestEncodeNetworkMapEnvelope_PeerWithoutIP(t *testing.T) { func TestEncodeNetworkMapEnvelope_EmptyInput(t *testing.T) { c := &types.NetworkMapComponents{ - Network: &types.Network{Identifier: "x", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}}, + Network: &nmdata.Network{Identifier: "x", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}}, } env := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}) @@ -440,9 +465,9 @@ func TestEncodeNetworkMapEnvelope_EmptyInput(t *testing.T) { func TestEncodeNetworkMapEnvelope_PeerLoginExpirationFields(t *testing.T) { c := newTestComponents() now := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC) - c.Peers["peer-a"].AddedWithSSOLogin = true + c.Peers["peer-a"].UserID = "user-1" c.Peers["peer-a"].LoginExpirationEnabled = true - c.Peers["peer-a"].LastLogin = now + c.Peers["peer-a"].LastLogin = &now full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() @@ -472,7 +497,7 @@ func TestEncodeNetworkMapEnvelope_PeerLoginExpirationFields(t *testing.T) { func TestEncodeNetworkMapEnvelope_RoutesRoundTrip(t *testing.T) { c := newTestComponents() - c.Routes = []*nbroute.Route{ + c.Routes = []*nmdata.Route{ { ID: "route-peer", PublicID: "100", @@ -519,7 +544,7 @@ func TestEncodeNetworkMapEnvelope_RoutesRoundTrip(t *testing.T) { func TestEncodeNetworkMapEnvelope_RouteWithMissingPeerLeavesIndexUnset(t *testing.T) { c := newTestComponents() - c.Routes = []*nbroute.Route{{ + c.Routes = []*nmdata.Route{{ ID: "route-x", PublicID: "100", Peer: "peer-not-in-components", @@ -539,21 +564,21 @@ func TestEncodeNetworkMapEnvelope_ResourceOnlyPolicyShippedAndIndexed(t *testing // Policy that exists ONLY in ResourcePoliciesMap, not in c.Policies. This // is the I1 case — without unionPolicies the encoder would silently // drop it from the wire. - resourceOnlyPolicy := &types.Policy{ + resourceOnlyPolicy := &nmdata.Policy{ ID: "pol-resource", PublicID: "99", Enabled: true, - Rules: []*types.PolicyRule{{ - ID: "rule-r", Enabled: true, Action: types.PolicyTrafficActionAccept, - Protocol: types.PolicyRuleProtocolTCP, + Rules: []*nmdata.PolicyRule{{ + ID: "rule-r", Enabled: true, Action: string(types.PolicyTrafficActionAccept), + Protocol: string(types.PolicyRuleProtocolTCP), Sources: []string{"group-src"}, Destinations: []string{"group-dst"}, }}, } - c.ResourcePoliciesMap = map[string][]*types.Policy{ + c.ResourcePoliciesMap = map[string][]*nmdata.Policy{ "resource-x": {c.Policies[0], resourceOnlyPolicy}, // shared + resource-only } // Resource must appear in components.NetworkResources with a seq id — // encoder uses that to translate the xid map key to uint32. - c.NetworkResources = []*types.ComponentResource{ + c.NetworkResources = []*nmdata.NetworkResource{ {ID: "resource-x", PublicID: "77", Name: "res-x", Enabled: true}, } @@ -579,10 +604,10 @@ func TestEncodeNetworkMapEnvelope_ResourceOnlyPolicyShippedAndIndexed(t *testing func TestEncodeNetworkMapEnvelope_NameServerGroups(t *testing.T) { c := newTestComponents() - c.NameServerGroups = []*nbdns.NameServerGroup{{ + c.NameServerGroups = []*nmdata.NameServerGroup{{ ID: "nsg-1", PublicID: "50", Name: "Main", Description: "primary", - NameServers: []nbdns.NameServer{{ - IP: netip.MustParseAddr("8.8.8.8"), NSType: nbdns.UDPNameServerType, Port: 53, + NameServers: []nmdata.NameServer{{ + IP: netip.MustParseAddr("8.8.8.8"), NSType: int(nbdns.UDPNameServerType), Port: 53, }}, Groups: []string{"group-src", "group-not-persisted"}, Primary: true, Enabled: true, @@ -621,11 +646,11 @@ func TestEncodeNetworkMapEnvelope_PostureFailedPeers(t *testing.T) { func TestEncodeNetworkMapEnvelope_RoutersMap(t *testing.T) { c := newTestComponents() c.NetworkXIDToPublicID = map[string]string{"net-1": "5"} - c.RoutersMap = map[string]map[string]*types.ComponentRouter{ + c.RoutersMap = map[string]map[string]*nmdata.NetworkRouter{ "net-1": { "peer-c": { - PublicID: "200", - Peer: "peer-c", Masquerade: true, Metric: 10, Enabled: true, + PublicID: "200", + Masquerade: true, Metric: 10, Enabled: true, }, }, } @@ -651,14 +676,14 @@ func TestEncodeNetworkMapEnvelope_RouterPeerNotInComponentsPeers(t *testing.T) { // peer_index reference must still resolve. c := newTestComponents() delete(c.Peers, "peer-c") - routerPeer := &types.ComponentPeer{ + routerPeer := &nmdata.Peer{ ID: "peer-c", Key: testWgKeyC, IP: netip.AddrFrom4([4]byte{100, 64, 0, 3}), - DNSLabel: "peerc", AgentVersion: "0.40.0", + DNSLabel: "peerc", Meta: nmdata.PeerSystemMeta{WtVersion: "0.40.0"}, } - c.RouterPeers = map[string]*types.ComponentPeer{"peer-c": routerPeer} + c.RouterPeers = map[string]*nmdata.Peer{"peer-c": routerPeer} c.NetworkXIDToPublicID = map[string]string{"net-1": "5"} - c.RoutersMap = map[string]map[string]*types.ComponentRouter{ - "net-1": {"peer-c": {PublicID: "1", Peer: "peer-c", Enabled: true}}, + c.RoutersMap = map[string]map[string]*nmdata.NetworkRouter{ + "net-1": {"peer-c": {PublicID: "1", Enabled: true}}, } full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() @@ -672,15 +697,20 @@ func TestEncodeNetworkMapEnvelope_RouterPeerNotInComponentsPeers(t *testing.T) { func TestEncodeNetworkMapEnvelope_GroupIDToUserIDs(t *testing.T) { c := newTestComponents() c.GroupIDToUserIDs = map[string][]string{ - "group-src": {"user-1", "user-2"}, - "group-missing": {"user-4"}, // group not in components → drop + "group-src": {"user-1", "user-2"}, + "group-users": {"user-4"}, } full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() - require.Len(t, full.GroupIdToUserIds, 1, "only present groups survive") + require.Len(t, full.GroupIdToUserIds, 2, + "a peer group is keyed by its public id, and a user group — which never appears in "+ + "components.Groups — keeps its own id rather than being dropped, or the peer would "+ + "receive no authorized SSH users at all") require.Contains(t, full.GroupIdToUserIds, "1") assert.ElementsMatch(t, []string{"user-1", "user-2"}, full.GroupIdToUserIds["1"].UserIds) + require.Contains(t, full.GroupIdToUserIds, "group-users") + assert.ElementsMatch(t, []string{"user-4"}, full.GroupIdToUserIds["group-users"].UserIds) } func TestToProxyPatch_EmptyInputReturnsNil(t *testing.T) { @@ -691,9 +721,9 @@ func TestToProxyPatch_EmptyInputReturnsNil(t *testing.T) { func TestToProxyPatch_PopulatesAllFields(t *testing.T) { nm := &types.NetworkMap{ - Peers: []*types.ComponentPeer{{ + Peers: []*nmdata.Peer{{ ID: "ext-peer", Key: testWgKeyA, IP: netip.AddrFrom4([4]byte{100, 64, 0, 9}), - DNSLabel: "extpeer", AgentVersion: "0.40.0", + DNSLabel: "extpeer", Meta: nmdata.PeerSystemMeta{WtVersion: "0.40.0"}, }}, FirewallRules: []*types.FirewallRule{{ PeerIP: "100.64.0.9", Action: "accept", Direction: 0, Protocol: "tcp", @@ -765,7 +795,7 @@ func TestEncodeNetworkMapEnvelope_NilComponentsGracefulDegrade(t *testing.T) { func TestEncodeNetworkMapEnvelope_AccountSettingsAlwaysEmitted(t *testing.T) { c := &types.NetworkMapComponents{ - Network: &types.Network{Identifier: "x", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}}, + Network: &nmdata.Network{Identifier: "x", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}}, // AccountSettings deliberately nil } @@ -779,8 +809,8 @@ func TestEncodeNetworkMapEnvelope_AccountSettingsAlwaysEmitted(t *testing.T) { func emptyNetworkMapComponents() *types.NetworkMapComponents { return types.EmptyNetworkMapComponents( &types.NetworkMapComponents{ - PeerID: "peer-id", Peers: map[string]*types.ComponentPeer{"peer-id": {}}, - Network: &types.Network{ + PeerID: "peer-id", Peers: map[string]*nmdata.Peer{"peer-id": {}}, + Network: &nmdata.Network{ Identifier: "net-empty", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}, Serial: 9, diff --git a/management/internals/shared/grpc/components_envelope_response.go b/management/internals/shared/grpc/components_envelope_response.go index 88fa4a22d..c059b2248 100644 --- a/management/internals/shared/grpc/components_envelope_response.go +++ b/management/internals/shared/grpc/components_envelope_response.go @@ -7,11 +7,11 @@ import ( "github.com/netbirdio/netbird/client/ssh/auth" nbconfig "github.com/netbirdio/netbird/management/internals/server/config" - nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/posture" "github.com/netbirdio/netbird/management/server/types" sharedgrpc "github.com/netbirdio/netbird/shared/management/grpc" "github.com/netbirdio/netbird/shared/management/networkmap" + nmdata "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" "github.com/netbirdio/netbird/shared/management/proto" ) @@ -31,14 +31,14 @@ func ToComponentSyncResponse( config *nbconfig.Config, httpConfig *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, - peer *nbpeer.Peer, + peer *nmdata.Peer, turnCredentials *Token, relayCredentials *Token, components *types.NetworkMapComponents, proxyPatch *types.NetworkMap, dnsName string, checks []*posture.Checks, - settings *types.Settings, + settings *nmdata.AccountSettingsInfo, extraSettings *types.ExtraSettings, peerGroups []string, dnsFwdPort int64, @@ -145,7 +145,7 @@ func toProxyPatch(nm *types.NetworkMap, dnsName string, includeIPv6, useSourcePr // // The full SSH AuthorizedUsers map is still produced by the client when it // runs Calculate() over the envelope. -func computeSSHEnabledForPeer(c *types.NetworkMapComponents, peer *nbpeer.Peer) bool { +func computeSSHEnabledForPeer(c *types.NetworkMapComponents, peer *nmdata.Peer) bool { if c == nil || peer == nil { return false } @@ -170,25 +170,25 @@ func computeSSHEnabledForPeer(c *types.NetworkMapComponents, peer *nbpeer.Peer) // ruleEnablesSSHForPeer returns true when rule is active, targets peer, and // either explicitly authorises SSH or covers the legacy TCP/22 path while the // peer itself has SSH enabled locally. -func ruleEnablesSSHForPeer(c *types.NetworkMapComponents, rule *types.PolicyRule, peer *nbpeer.Peer) bool { +func ruleEnablesSSHForPeer(c *types.NetworkMapComponents, rule *nmdata.PolicyRule, peer *nmdata.Peer) bool { if rule == nil || !rule.Enabled { return false } if !peerInDestinations(c, rule, peer.ID) { return false } - if rule.Protocol == types.PolicyRuleProtocolNetbirdSSH { + if rule.Protocol == string(types.PolicyRuleProtocolNetbirdSSH) { return true } - return peer.SSHEnabled && types.PolicyRuleImpliesLegacySSH(rule) + return peer.SSHEnabled && nmdata.PolicyRuleImpliesLegacySSH(rule) } // peerInDestinations reports whether peerID is in any of rule.Destinations' // groups (or matches DestinationResource if it's a peer-typed resource — // for non-peer types Calculate falls through to group lookup, so we mirror // that exactly to avoid silent divergence). -func peerInDestinations(c *types.NetworkMapComponents, rule *types.PolicyRule, peerID string) bool { - if rule.DestinationResource.Type == types.ResourceTypePeer && rule.DestinationResource.ID != "" { +func peerInDestinations(c *types.NetworkMapComponents, rule *nmdata.PolicyRule, peerID string) bool { + if rule.DestinationResource.Type == string(types.ResourceTypePeer) && rule.DestinationResource.ID != "" { return rule.DestinationResource.ID == peerID } for _, groupID := range rule.Destinations { diff --git a/management/internals/shared/grpc/components_envelope_response_test.go b/management/internals/shared/grpc/components_envelope_response_test.go index 20f4e6824..039cb73f4 100644 --- a/management/internals/shared/grpc/components_envelope_response_test.go +++ b/management/internals/shared/grpc/components_envelope_response_test.go @@ -5,8 +5,8 @@ import ( "github.com/stretchr/testify/assert" - nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" ) // TestComputeSSHEnabledForPeer covers both Calculate-mirroring branches: @@ -17,16 +17,15 @@ func TestComputeSSHEnabledForPeer(t *testing.T) { const targetPeerID = "target" const targetGroupID = "g_dst" - mkComponents := func(rule *types.PolicyRule, sshEnabled bool) (*types.NetworkMapComponents, *nbpeer.Peer) { - peer := &nbpeer.Peer{ID: targetPeerID, SSHEnabled: sshEnabled} - group := &types.ComponentGroup{ID: targetGroupID, Name: "dst", Peers: []string{targetPeerID}} + mkComponents := func(rule *nmdata.PolicyRule, sshEnabled bool) (*types.NetworkMapComponents, *nmdata.Peer) { + peer := &nmdata.Peer{ID: targetPeerID, SSHEnabled: sshEnabled} return &types.NetworkMapComponents{ - Peers: map[string]*types.ComponentPeer{targetPeerID: peer.ToComponent()}, - Groups: map[string]*types.ComponentGroup{targetGroupID: group}, - Policies: []*types.Policy{{ + Peers: map[string]*nmdata.Peer{targetPeerID: peer}, + Groups: map[string]*nmdata.Group{targetGroupID: {Name: "dst", Peers: []string{targetPeerID}}}, + Policies: []*nmdata.Policy{{ ID: "p", Enabled: true, - Rules: []*types.PolicyRule{rule}, + Rules: []*nmdata.PolicyRule{rule}, }}, }, peer } @@ -34,14 +33,14 @@ func TestComputeSSHEnabledForPeer(t *testing.T) { cases := []struct { name string peerSSH bool - rule types.PolicyRule + rule nmdata.PolicyRule wantEnabled bool }{ { name: "explicit-netbird-ssh-activates-regardless-of-peer-ssh", peerSSH: false, - rule: types.PolicyRule{ - Enabled: true, Protocol: types.PolicyRuleProtocolNetbirdSSH, + rule: nmdata.PolicyRule{ + Enabled: true, Protocol: string(types.PolicyRuleProtocolNetbirdSSH), Destinations: []string{targetGroupID}, }, wantEnabled: true, @@ -49,8 +48,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) { { name: "implicit-tcp-22-with-peer-ssh", peerSSH: true, - rule: types.PolicyRule{ - Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"22"}, + rule: nmdata.PolicyRule{ + Enabled: true, Protocol: string(types.PolicyRuleProtocolTCP), Ports: []string{"22"}, Destinations: []string{targetGroupID}, }, wantEnabled: true, @@ -58,8 +57,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) { { name: "implicit-tcp-22-without-peer-ssh-disabled", peerSSH: false, - rule: types.PolicyRule{ - Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"22"}, + rule: nmdata.PolicyRule{ + Enabled: true, Protocol: string(types.PolicyRuleProtocolTCP), Ports: []string{"22"}, Destinations: []string{targetGroupID}, }, wantEnabled: false, @@ -67,8 +66,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) { { name: "implicit-tcp-22022-with-peer-ssh", peerSSH: true, - rule: types.PolicyRule{ - Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"22022"}, + rule: nmdata.PolicyRule{ + Enabled: true, Protocol: string(types.PolicyRuleProtocolTCP), Ports: []string{"22022"}, Destinations: []string{targetGroupID}, }, wantEnabled: true, @@ -76,8 +75,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) { { name: "implicit-all-protocol-with-peer-ssh", peerSSH: true, - rule: types.PolicyRule{ - Enabled: true, Protocol: types.PolicyRuleProtocolALL, + rule: nmdata.PolicyRule{ + Enabled: true, Protocol: string(types.PolicyRuleProtocolALL), Destinations: []string{targetGroupID}, }, wantEnabled: true, @@ -85,10 +84,10 @@ func TestComputeSSHEnabledForPeer(t *testing.T) { { name: "implicit-port-range-covers-22", peerSSH: true, - rule: types.PolicyRule{ + rule: nmdata.PolicyRule{ Enabled: true, - Protocol: types.PolicyRuleProtocolTCP, - PortRanges: []types.RulePortRange{{Start: 20, End: 30}}, + Protocol: string(types.PolicyRuleProtocolTCP), + PortRanges: []nmdata.RulePortRange{{Start: 20, End: 30}}, Destinations: []string{targetGroupID}, }, wantEnabled: true, @@ -96,8 +95,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) { { name: "tcp-80-no-ssh", peerSSH: true, - rule: types.PolicyRule{ - Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"80"}, + rule: nmdata.PolicyRule{ + Enabled: true, Protocol: string(types.PolicyRuleProtocolTCP), Ports: []string{"80"}, Destinations: []string{targetGroupID}, }, wantEnabled: false, @@ -105,8 +104,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) { { name: "disabled-rule-skipped", peerSSH: true, - rule: types.PolicyRule{ - Enabled: false, Protocol: types.PolicyRuleProtocolNetbirdSSH, + rule: nmdata.PolicyRule{ + Enabled: false, Protocol: string(types.PolicyRuleProtocolNetbirdSSH), Destinations: []string{targetGroupID}, }, wantEnabled: false, @@ -114,8 +113,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) { { name: "peer-not-in-destinations", peerSSH: true, - rule: types.PolicyRule{ - Enabled: true, Protocol: types.PolicyRuleProtocolNetbirdSSH, + rule: nmdata.PolicyRule{ + Enabled: true, Protocol: string(types.PolicyRuleProtocolNetbirdSSH), Destinations: []string{"g_other"}, // target not in this group }, wantEnabled: false, @@ -123,21 +122,21 @@ func TestComputeSSHEnabledForPeer(t *testing.T) { { name: "peer-typed-destination-resource-matches", peerSSH: false, - rule: types.PolicyRule{ + rule: nmdata.PolicyRule{ Enabled: true, - Protocol: types.PolicyRuleProtocolNetbirdSSH, - DestinationResource: types.Resource{ID: targetPeerID, Type: types.ResourceTypePeer}, + Protocol: string(types.PolicyRuleProtocolNetbirdSSH), + DestinationResource: nmdata.Resource{ID: targetPeerID, Type: string(types.ResourceTypePeer)}, }, wantEnabled: true, }, { name: "non-peer-destination-resource-falls-through-to-groups", peerSSH: false, - rule: types.PolicyRule{ + rule: nmdata.PolicyRule{ Enabled: true, - Protocol: types.PolicyRuleProtocolNetbirdSSH, - DestinationResource: types.Resource{ID: targetPeerID, Type: "host"}, // wrong type - Destinations: []string{targetGroupID}, // saved by group fallback + Protocol: string(types.PolicyRuleProtocolNetbirdSSH), + DestinationResource: nmdata.Resource{ID: targetPeerID, Type: "host"}, // wrong type + Destinations: []string{targetGroupID}, // saved by group fallback }, wantEnabled: true, }, @@ -156,16 +155,16 @@ func TestComputeSSHEnabledForPeer(t *testing.T) { // belt-and-suspenders presence guard mirroring Calculate's // getAllPeersFromGroups invariant. func TestComputeSSHEnabledForPeer_TargetMissingFromComponents(t *testing.T) { - peer := &nbpeer.Peer{ID: "missing", SSHEnabled: true} + peer := &nmdata.Peer{ID: "missing", SSHEnabled: true} c := &types.NetworkMapComponents{ - Peers: map[string]*types.ComponentPeer{}, // target peer NOT present - Groups: map[string]*types.ComponentGroup{ - "g": {ID: "g", Peers: []string{"missing"}}, + Peers: map[string]*nmdata.Peer{}, // target peer NOT present + Groups: map[string]*nmdata.Group{ + "g": {Peers: []string{"missing"}}, }, - Policies: []*types.Policy{{ + Policies: []*nmdata.Policy{{ ID: "p", Enabled: true, - Rules: []*types.PolicyRule{{ - Enabled: true, Protocol: types.PolicyRuleProtocolNetbirdSSH, + Rules: []*nmdata.PolicyRule{{ + Enabled: true, Protocol: string(types.PolicyRuleProtocolNetbirdSSH), Destinations: []string{"g"}, }}, }}, @@ -179,6 +178,6 @@ func TestComputeSSHEnabledForPeer_TargetMissingFromComponents(t *testing.T) { // exported indirectly via ToComponentSyncResponse and may receive nil // components on graceful-degrade paths. func TestComputeSSHEnabledForPeer_NilInputs(t *testing.T) { - assert.False(t, computeSSHEnabledForPeer(nil, &nbpeer.Peer{ID: "x"})) + assert.False(t, computeSSHEnabledForPeer(nil, &nmdata.Peer{ID: "x"})) assert.False(t, computeSSHEnabledForPeer(&types.NetworkMapComponents{}, nil)) } diff --git a/management/internals/shared/grpc/conversion.go b/management/internals/shared/grpc/conversion.go index c30b27f9e..5640127ca 100644 --- a/management/internals/shared/grpc/conversion.go +++ b/management/internals/shared/grpc/conversion.go @@ -18,10 +18,10 @@ import ( "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache" nbconfig "github.com/netbirdio/netbird/management/internals/server/config" - nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/posture" "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/shared/management/networkmap" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" "github.com/netbirdio/netbird/shared/management/proto" "github.com/netbirdio/netbird/shared/netiputil" ) @@ -47,7 +47,7 @@ func init() { // nil when no server config is set (the fan-out network-map path) because clients treat any // non-nil config as authoritative: a config without a relay section is interpreted as relay // disabled and wipes the clients' relay URLs. -func toNetbirdConfig(config *nbconfig.Config, turnCredentials *Token, relayToken *Token, extraSettings *types.ExtraSettings, settings *types.Settings) *proto.NetbirdConfig { +func toNetbirdConfig(config *nbconfig.Config, turnCredentials *Token, relayToken *Token, extraSettings *types.ExtraSettings, settings *nmdata.AccountSettingsInfo) *proto.NetbirdConfig { if config == nil { return nil } @@ -119,7 +119,7 @@ func toNetbirdConfig(config *nbconfig.Config, turnCredentials *Token, relayToken return nbConfig } -func toPeerConfig(peer *nbpeer.Peer, network *types.Network, dnsName string, settings *types.Settings, httpConfig *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, enableSSH bool, forceRoutingPeerDNS bool) *proto.PeerConfig { +func toPeerConfig(peer *nmdata.Peer, network *nmdata.Network, dnsName string, settings *nmdata.AccountSettingsInfo, httpConfig *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, enableSSH bool, forceRoutingPeerDNS bool) *proto.PeerConfig { netmask, _ := network.Net.Mask.Size() fqdn := peer.FQDN(dnsName) @@ -154,7 +154,7 @@ func toPeerConfig(peer *nbpeer.Peer, network *types.Network, dnsName string, set return peerConfig } -func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, peer *nbpeer.Peer, turnCredentials *Token, relayCredentials *Token, networkMap *types.NetworkMap, dnsName string, checks []*posture.Checks, dnsCache *cache.DNSConfigCache, settings *types.Settings, extraSettings *types.ExtraSettings, peerGroups []string, dnsFwdPort int64) *proto.SyncResponse { +func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, peer *nmdata.Peer, turnCredentials *Token, relayCredentials *Token, networkMap *types.NetworkMap, dnsName string, checks []*posture.Checks, dnsCache *cache.DNSConfigCache, settings *nmdata.AccountSettingsInfo, extraSettings *types.ExtraSettings, peerGroups []string, dnsFwdPort int64) *proto.SyncResponse { // IPv6 data in AllowedIPs and SourcePrefixes wildcard expansion depends on // whether the target peer supports IPv6. Routes and firewall rules are already // filtered at the source (network map builder). diff --git a/management/internals/shared/grpc/conversion_test.go b/management/internals/shared/grpc/conversion_test.go index 38d370740..559699d8c 100644 --- a/management/internals/shared/grpc/conversion_test.go +++ b/management/internals/shared/grpc/conversion_test.go @@ -278,7 +278,7 @@ func TestToNetbirdConfig_RelayInvariant(t *testing.T) { settings := &types.Settings{MetricsPushEnabled: true} t.Run("nil server config returns nil config", func(t *testing.T) { - nbCfg := toNetbirdConfig(nil, nil, nil, nil, settings) + nbCfg := toNetbirdConfig(nil, nil, nil, nil, types.TwinAccountSettings(settings)) assert.Nil(t, nbCfg, "fan-out updates must not carry a partial NetbirdConfig even when settings are present") }) @@ -293,7 +293,7 @@ func TestToNetbirdConfig_RelayInvariant(t *testing.T) { } relayToken := &Token{Payload: "token-payload", Signature: "token-signature"} - nbCfg := toNetbirdConfig(cfg, nil, relayToken, nil, settings) + nbCfg := toNetbirdConfig(cfg, nil, relayToken, nil, types.TwinAccountSettings(settings)) require.NotNil(t, nbCfg) require.NotNil(t, nbCfg.Relay, "non-nil NetbirdConfig must include the relay section") assert.Equal(t, cfg.Relay.Addresses, nbCfg.Relay.Urls, "relay URLs should match the server config") @@ -329,7 +329,7 @@ func TestToPeerConfig_RoutingPeerDNSResolution(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { settings := &types.Settings{RoutingPeerDNSResolutionEnabled: tt.globalFlag} - cfg := toPeerConfig(newPeer(tt.embedded), network, "netbird.selfhosted", settings, nil, nil, false, tt.forceParam) + cfg := toPeerConfig(types.TwinPeer(newPeer(tt.embedded)), types.TwinNetwork(network), "netbird.selfhosted", types.TwinAccountSettings(settings), nil, nil, false, tt.forceParam) assert.Equal(t, tt.wantEnabled, cfg.RoutingPeerDnsResolutionEnabled, "RoutingPeerDnsResolutionEnabled should reflect global || embedded || forced") }) diff --git a/management/internals/shared/grpc/server.go b/management/internals/shared/grpc/server.go index 3d5f0a1b7..4435f6706 100644 --- a/management/internals/shared/grpc/server.go +++ b/management/internals/shared/grpc/server.go @@ -920,8 +920,8 @@ func (s *Server) prepareLoginResponse(ctx context.Context, peer *nbpeer.Peer, ne // if peer has reached this point then it has logged in loginResp := &proto.LoginResponse{ - NetbirdConfig: toNetbirdConfig(s.config, nil, relayToken, nil, settings), - PeerConfig: toPeerConfig(peer, network, s.networkMapController.GetDNSDomain(settings), settings, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, enableSSH, false), + NetbirdConfig: toNetbirdConfig(s.config, nil, relayToken, nil, types.TwinAccountSettings(settings)), + PeerConfig: toPeerConfig(types.TwinPeer(peer), types.TwinNetwork(network), s.networkMapController.GetDNSDomain(settings), types.TwinAccountSettings(settings), s.config.HttpConfig, s.config.DeviceAuthorizationFlow, enableSSH, false), Checks: toProtocolChecks(ctx, postureChecks), } @@ -1052,9 +1052,9 @@ func (s *Server) sendInitialSync(ctx context.Context, peerKey wgtypes.Key, peer log.WithContext(ctx).Errorf("failed to build components for peer %s on initial sync: %v", peer.ID, err) return status.Errorf(codes.Internal, "failed to build initial sync envelope") } - plainResp = ToComponentSyncResponse(ctx, s.config, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, freshPeer, turnToken, relayToken, components, proxyPatch, dnsName, freshPostureChecks, settings, settings.Extra, peerGroups, freshDnsFwdPort) + plainResp = ToComponentSyncResponse(ctx, s.config, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, types.TwinPeer(freshPeer), turnToken, relayToken, components, proxyPatch, dnsName, freshPostureChecks, types.TwinAccountSettings(settings), settings.Extra, peerGroups, freshDnsFwdPort) } else { - plainResp = ToSyncResponse(ctx, s.config, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, peer, turnToken, relayToken, networkMap, dnsName, postureChecks, nil, settings, settings.Extra, peerGroups, dnsFwdPort) + plainResp = ToSyncResponse(ctx, s.config, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, types.TwinPeer(peer), turnToken, relayToken, networkMap, dnsName, postureChecks, nil, types.TwinAccountSettings(settings), settings.Extra, peerGroups, dnsFwdPort) } key, err := s.secretsManager.GetWGKey() diff --git a/management/internals/shared/requestbuffer/buffer.go b/management/internals/shared/requestbuffer/buffer.go new file mode 100644 index 000000000..c3823776c --- /dev/null +++ b/management/internals/shared/requestbuffer/buffer.go @@ -0,0 +1,102 @@ +// Package requestbuffer coalesces concurrent reads of the same expensive +// resource into a single fetch. +package requestbuffer + +import ( + "context" + "os" + "sync" + "time" + + log "github.com/sirupsen/logrus" +) + +// FetchFunc reads the resource identified by key. +type FetchFunc[T any] func(ctx context.Context, key string) (T, error) + +// Buffer batches requests per key: the first request opens a window, every +// request arriving within it joins the batch, and a single fetch serves them +// all. The fetch starts only after the window closed, so a caller never +// observes data read before its own request. +type Buffer[T any] struct { + ctx context.Context + name string + fetch FetchFunc[T] + interval time.Duration + + mu sync.Mutex + waiting map[string][]chan result[T] +} + +type result[T any] struct { + value T + err error +} + +// New returns a Buffer serving batched requests through fetch. ctx bounds the +// fetches, not the callers, and must outlive them. +func New[T any](ctx context.Context, name string, interval time.Duration, fetch FetchFunc[T]) *Buffer[T] { + return &Buffer[T]{ + ctx: ctx, + name: name, + fetch: fetch, + interval: interval, + waiting: make(map[string][]chan result[T]), + } +} + +// Get returns the value for key, sharing one fetch with the other callers of +// the current batch. The value is shared as is, so callers must treat it as +// read-only unless the fetch hands out copies. +func (b *Buffer[T]) Get(ctx context.Context, key string) (T, error) { + ch := make(chan result[T], 1) + + b.mu.Lock() + b.waiting[key] = append(b.waiting[key], ch) + first := len(b.waiting[key]) == 1 + b.mu.Unlock() + + if first { + time.AfterFunc(b.interval, func() { b.flush(key) }) + } + + select { + case res := <-ch: + return res.value, res.err + case <-ctx.Done(): + var zero T + return zero, ctx.Err() + } +} + +func (b *Buffer[T]) flush(key string) { + b.mu.Lock() + waiting := b.waiting[key] + delete(b.waiting, key) + b.mu.Unlock() + + if len(waiting) == 0 { + return + } + + start := time.Now() + value, err := b.fetch(b.ctx, key) + log.WithContext(b.ctx).Tracef("%s: fetched %s for %d waiters in %s", b.name, key, len(waiting), time.Since(start)) + + for _, ch := range waiting { + ch <- result[T]{value: value, err: err} + } +} + +// Interval reads a buffer interval from envVar, falling back to def. +func Interval(ctx context.Context, envVar string, def time.Duration) time.Duration { + value := os.Getenv(envVar) + interval, err := time.ParseDuration(value) + if err != nil { + if value != "" { + log.WithContext(ctx).Warnf("failed to parse %s: %s", envVar, err) + } + return def + } + return interval +} diff --git a/management/internals/shared/requestbuffer/buffer_test.go b/management/internals/shared/requestbuffer/buffer_test.go new file mode 100644 index 000000000..9e356145e --- /dev/null +++ b/management/internals/shared/requestbuffer/buffer_test.go @@ -0,0 +1,106 @@ +package requestbuffer + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBufferCoalescesConcurrentRequests(t *testing.T) { + var fetches atomic.Int32 + buffer := New(context.Background(), "test", 50*time.Millisecond, + func(ctx context.Context, key string) (string, error) { + fetches.Add(1) + return key, nil + }) + + var wg sync.WaitGroup + for range 10 { + wg.Add(1) + go func() { + defer wg.Done() + value, err := buffer.Get(context.Background(), "account") + assert.NoError(t, err) + assert.Equal(t, "account", value) + }() + } + wg.Wait() + + assert.Equal(t, int32(1), fetches.Load()) +} + +func TestBufferSeparatesKeys(t *testing.T) { + keys := make(chan string, 2) + buffer := New(context.Background(), "test", 10*time.Millisecond, + func(ctx context.Context, key string) (string, error) { + keys <- key + return key, nil + }) + + var wg sync.WaitGroup + for _, key := range []string{"a", "b"} { + wg.Add(1) + go func() { + defer wg.Done() + _, err := buffer.Get(context.Background(), key) + assert.NoError(t, err) + }() + } + wg.Wait() + close(keys) + + var fetched []string + for key := range keys { + fetched = append(fetched, key) + } + assert.ElementsMatch(t, []string{"a", "b"}, fetched) +} + +func TestBufferFetchesAfterRequest(t *testing.T) { + var version atomic.Int32 + buffer := New(context.Background(), "test", 10*time.Millisecond, + func(ctx context.Context, key string) (int32, error) { + return version.Load(), nil + }) + + first, err := buffer.Get(context.Background(), "account") + require.NoError(t, err) + assert.Equal(t, int32(0), first) + + version.Store(1) + + second, err := buffer.Get(context.Background(), "account") + require.NoError(t, err) + assert.Equal(t, int32(1), second) +} + +func TestBufferPropagatesError(t *testing.T) { + fetchErr := errors.New("fetch failed") + buffer := New(context.Background(), "test", 10*time.Millisecond, + func(ctx context.Context, key string) (*int, error) { + return nil, fetchErr + }) + + value, err := buffer.Get(context.Background(), "account") + assert.ErrorIs(t, err, fetchErr) + assert.Nil(t, value) +} + +func TestBufferHonorsCallerContext(t *testing.T) { + buffer := New(context.Background(), "test", time.Minute, + func(ctx context.Context, key string) (string, error) { + return key, nil + }) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + + _, err := buffer.Get(ctx, "account") + assert.ErrorIs(t, err, context.DeadlineExceeded) +} diff --git a/management/server/account_request_buffer.go b/management/server/account_request_buffer.go index e1672c2d0..792099431 100644 --- a/management/server/account_request_buffer.go +++ b/management/server/account_request_buffer.go @@ -2,117 +2,38 @@ package server import ( "context" - "os" - "sync" "time" log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/management/internals/shared/requestbuffer" "github.com/netbirdio/netbird/management/server/store" "github.com/netbirdio/netbird/management/server/types" ) -// AccountRequest holds the result channel to return the requested account. -type AccountRequest struct { - AccountID string - ResultChan chan *AccountResult -} - -// AccountResult holds the account data or an error. -type AccountResult struct { - Account *types.Account - Err error -} +const defaultAccountBufferInterval = 100 * time.Millisecond type AccountRequestBuffer struct { - store store.Store - getAccountRequests map[string][]*AccountRequest - mu sync.Mutex - getAccountRequestCh chan *AccountRequest - bufferInterval time.Duration + buffer *requestbuffer.Buffer[*types.Account] } func NewAccountRequestBuffer(ctx context.Context, store store.Store) *AccountRequestBuffer { - bufferIntervalStr := os.Getenv("NB_GET_ACCOUNT_BUFFER_INTERVAL") - bufferInterval, err := time.ParseDuration(bufferIntervalStr) - if err != nil { - if bufferIntervalStr != "" { - log.WithContext(ctx).Warnf("failed to parse account request buffer interval: %s", err) - } - bufferInterval = 100 * time.Millisecond + interval := requestbuffer.Interval(ctx, "NB_GET_ACCOUNT_BUFFER_INTERVAL", defaultAccountBufferInterval) + log.WithContext(ctx).Infof("set account request buffer interval to %s", interval) + + return &AccountRequestBuffer{ + buffer: requestbuffer.New(ctx, "account request buffer", interval, store.GetAccount), } - - log.WithContext(ctx).Infof("set account request buffer interval to %s", bufferInterval) - - ac := AccountRequestBuffer{ - store: store, - getAccountRequests: make(map[string][]*AccountRequest), - getAccountRequestCh: make(chan *AccountRequest), - bufferInterval: bufferInterval, - } - - go ac.processGetAccountRequests(ctx) - - return &ac } + func (ac *AccountRequestBuffer) GetAccountWithBackpressure(ctx context.Context, accountID string) (*types.Account, error) { - req := &AccountRequest{ - AccountID: accountID, - ResultChan: make(chan *AccountResult, 1), + account, err := ac.buffer.Get(ctx, accountID) + if err != nil || account == nil { + return account, err } - log.WithContext(ctx).Tracef("requesting account %s with backpressure", accountID) - startTime := time.Now() - ac.getAccountRequestCh <- req - - result := <-req.ResultChan - log.WithContext(ctx).Tracef("got account with backpressure after %s", time.Since(startTime)) - return result.Account, result.Err -} - -func (ac *AccountRequestBuffer) processGetAccountBatch(ctx context.Context, accountID string) { - ac.mu.Lock() - requests := ac.getAccountRequests[accountID] - delete(ac.getAccountRequests, accountID) - ac.mu.Unlock() - - if len(requests) == 0 { - return - } - - startTime := time.Now() - account, err := ac.store.GetAccount(ctx, accountID) - log.WithContext(ctx).Tracef("getting account %s in batch took %s", accountID, time.Since(startTime)) - result := &AccountResult{Account: account, Err: err} - - for _, req := range requests { - if account != nil { - // Shallow copy the account so each goroutine gets its own struct value. - // This prevents data races when callers mutate fields like Policies. - accountCopy := *account - req.ResultChan <- &AccountResult{Account: &accountCopy, Err: err} - } else { - req.ResultChan <- result - } - close(req.ResultChan) - } -} - -func (ac *AccountRequestBuffer) processGetAccountRequests(ctx context.Context) { - for { - select { - case req := <-ac.getAccountRequestCh: - ac.mu.Lock() - ac.getAccountRequests[req.AccountID] = append(ac.getAccountRequests[req.AccountID], req) - if len(ac.getAccountRequests[req.AccountID]) == 1 { - go func(ctx context.Context, accountID string) { - time.Sleep(ac.bufferInterval) - ac.processGetAccountBatch(ctx, accountID) - }(ctx, req.AccountID) - } - ac.mu.Unlock() - case <-ctx.Done(): - return - } - } + // Shallow copy the account so each caller gets its own struct value. + // This prevents data races when callers mutate fields like Policies. + accountCopy := *account + return &accountCopy, nil } diff --git a/management/server/account_test.go b/management/server/account_test.go index 5a826e103..a5a484c1a 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -3331,7 +3331,7 @@ func createManager(t testing.TB) (*DefaultAccountManager, *update_channel.PeersU updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}) + networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil) manager, err := BuildManager(ctx, &config.Config{}, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) if err != nil { return nil, nil, err diff --git a/management/server/affected_peers_property_test.go b/management/server/affected_peers_property_test.go index f393465bc..b64aeb813 100644 --- a/management/server/affected_peers_property_test.go +++ b/management/server/affected_peers_property_test.go @@ -27,8 +27,6 @@ func allPeerMaps(t *testing.T, manager *DefaultAccountManager, accountID string) account, err := manager.Store.GetAccount(ctx, accountID) require.NoError(t, err) - account.InjectProxyPolicies(ctx) - validated := make(map[string]struct{}, len(account.Peers)) for id := range account.Peers { validated[id] = struct{}{} diff --git a/management/server/dns_test.go b/management/server/dns_test.go index d7667a304..25bef664c 100644 --- a/management/server/dns_test.go +++ b/management/server/dns_test.go @@ -234,7 +234,7 @@ func createDNSManager(t *testing.T) (*DefaultAccountManager, error) { updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.test", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}) + networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.test", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil) return BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) } diff --git a/management/server/groups/manager.go b/management/server/groups/manager.go index 6d19b1c35..893be1e5a 100644 --- a/management/server/groups/manager.go +++ b/management/server/groups/manager.go @@ -6,7 +6,6 @@ import ( "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/activity" - resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/permissions/modules" "github.com/netbirdio/netbird/management/server/permissions/operations" @@ -31,10 +30,6 @@ type managerImpl struct { accountManager account.Manager } -func eventMetaResource(group *types.Group, resource *resourceTypes.NetworkResource) map[string]any { - return map[string]any{"name": group.Name, "id": group.ID, "resource_name": resource.Name, "resource_id": resource.ID, "resource_type": resource.Type} -} - type mockManager struct { } @@ -114,7 +109,7 @@ func (m *managerImpl) AddResourceToGroupInTransaction(ctx context.Context, trans } event := func() { - m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceAddedToGroup, eventMetaResource(group, networkResource)) + m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceAddedToGroup, group.EventMetaResource(types.TwinNetworkResource(networkResource))) } return event, nil @@ -138,7 +133,7 @@ func (m *managerImpl) RemoveResourceFromGroupInTransaction(ctx context.Context, } event := func() { - m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceRemovedFromGroup, eventMetaResource(group, networkResource)) + m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceRemovedFromGroup, group.EventMetaResource(types.TwinNetworkResource(networkResource))) } return event, nil diff --git a/management/server/http/handlers/peers/peers_handler.go b/management/server/http/handlers/peers/peers_handler.go index 03a37c3ec..1d7dd69f5 100644 --- a/management/server/http/handlers/peers/peers_handler.go +++ b/management/server/http/handlers/peers/peers_handler.go @@ -446,7 +446,7 @@ func (h *Handler) GetAccessiblePeers(w http.ResponseWriter, r *http.Request) { netMap := account.GetPeerNetworkMapFromComponents(ctx, peerID, dns.CustomZone{}, nil, validPeers, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), nil, account.GetActiveGroupUsers()) - util.WriteJSONObject(ctx, w, toAccessiblePeers(netMap, account.Peers, dnsDomain)) + util.WriteJSONObject(ctx, w, toAccessiblePeers(account.Peers, netMap, dnsDomain)) } func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) { @@ -534,20 +534,22 @@ func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) util.WriteJSONObject(r.Context(), w, resp) } -// toAccessiblePeers rehydrates the calculated map's component peers into the -// account's full peer objects, which carry the location/status/meta fields -// the API response needs. -func toAccessiblePeers(netMap *types.NetworkMap, accountPeers map[string]*nbpeer.Peer, dnsDomain string) []api.AccessiblePeer { +// toAccessiblePeers resolves the twin peers in netMap back to the full account +// peers (by ID) so the API response keeps Status/Name/OS/GeoNameID, which the +// slim netmap twins intentionally don't carry. +func toAccessiblePeers(accountPeers map[string]*nbpeer.Peer, netMap *types.NetworkMap, dnsDomain string) []api.AccessiblePeer { accessiblePeers := make([]api.AccessiblePeer, 0, len(netMap.Peers)+len(netMap.OfflinePeers)) - add := func(peers []*types.ComponentPeer) { - for _, p := range peers { - if peer := accountPeers[p.ID]; peer != nil { - accessiblePeers = append(accessiblePeers, peerToAccessiblePeer(peer, dnsDomain)) - } + appendByID := func(id string) { + if p, ok := accountPeers[id]; ok && p != nil { + accessiblePeers = append(accessiblePeers, peerToAccessiblePeer(p, dnsDomain)) } } - add(netMap.Peers) - add(netMap.OfflinePeers) + for _, p := range netMap.Peers { + appendByID(p.ID) + } + for _, p := range netMap.OfflinePeers { + appendByID(p.ID) + } return accessiblePeers } diff --git a/management/server/http/testing/testing_tools/channel/channel.go b/management/server/http/testing/testing_tools/channel/channel.go index 8b05b2ddf..44408d751 100644 --- a/management/server/http/testing/testing_tools/channel/channel.go +++ b/management/server/http/testing/testing_tools/channel/channel.go @@ -96,7 +96,7 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee } requestBuffer := server.NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsManager, "", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peersManager), &config.Config{}) + networkMapController := controller.NewController(ctx, store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsManager, "", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peersManager), &config.Config{}, nil) am, err := server.BuildManager(ctx, nil, store, networkMapController, jobManager, nil, "", &activity.InMemoryEventStore{}, geoMock, false, validatorMock, metrics, proxyController, settingsManager, permissionsManager, false, cacheStore) if err != nil { t.Fatalf("Failed to create manager: %v", err) @@ -226,7 +226,7 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin } requestBuffer := server.NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsManager, "", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peersManager), &config.Config{}) + networkMapController := controller.NewController(ctx, store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsManager, "", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peersManager), &config.Config{}, nil) am, err := server.BuildManager(ctx, nil, store, networkMapController, jobManager, nil, "", &activity.InMemoryEventStore{}, geoMock, false, validatorMock, metrics, proxyController, settingsManager, permissionsManager, false, cacheStore) if err != nil { t.Fatalf("Failed to create manager: %v", err) diff --git a/management/server/identity_provider_test.go b/management/server/identity_provider_test.go index b55d4f24c..eef69dc14 100644 --- a/management/server/identity_provider_test.go +++ b/management/server/identity_provider_test.go @@ -92,7 +92,7 @@ func createManagerWithEmbeddedIdP(t testing.TB) (*DefaultAccountManager, *update updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, testStore) - networkMapController := controller.NewController(ctx, testStore, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(testStore, peersManager), &config.Config{}) + networkMapController := controller.NewController(ctx, testStore, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(testStore, peersManager), &config.Config{}, nil) manager, err := BuildManager(ctx, &config.Config{}, testStore, networkMapController, job.NewJobManager(nil, testStore, peersManager), idpManager, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) if err != nil { return nil, nil, err diff --git a/management/server/integrated_validator.go b/management/server/integrated_validator.go index 69ea668ad..9ec1f491e 100644 --- a/management/server/integrated_validator.go +++ b/management/server/integrated_validator.go @@ -11,6 +11,7 @@ import ( nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/store" "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" ) // UpdateIntegratedValidator updates the integrated validator groups for a specified account. @@ -109,7 +110,7 @@ func (am *DefaultAccountManager) GetValidatedPeers(ctx context.Context, accountI return nil, nil, err } - validPeers, err := am.integratedPeerValidator.GetValidatedPeers(ctx, accountID, groups, peers, settings.Extra) + validPeers, err := am.integratedPeerValidator.GetValidatedPeers(ctx, accountID, types.TwinGroups(groups), types.TwinPeers(peers), settings.Extra) if err != nil { return nil, nil, err } @@ -138,7 +139,7 @@ func (a MockIntegratedValidator) ValidatePeer(_ context.Context, update *nbpeer. return update, false, nil } -func (a MockIntegratedValidator) GetValidatedPeers(_ context.Context, accountID string, groups []*types.Group, peers []*nbpeer.Peer, extraSettings *types.ExtraSettings) (map[string]struct{}, error) { +func (a MockIntegratedValidator) GetValidatedPeers(_ context.Context, accountID string, groups []*nmdata.Group, peers []*nmdata.Peer, extraSettings *types.ExtraSettings) (map[string]struct{}, error) { validatedPeers := make(map[string]struct{}) for _, peer := range peers { validatedPeers[peer.ID] = struct{}{} diff --git a/management/server/integrations/integrated_validator/integrated_validator_mock.go b/management/server/integrations/integrated_validator/integrated_validator_mock.go new file mode 100644 index 000000000..73178a869 --- /dev/null +++ b/management/server/integrations/integrated_validator/integrated_validator_mock.go @@ -0,0 +1,187 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./interface.go +// +// Generated by this command: +// +// mockgen -package integrated_validator -destination=integrated_validator_mock.go -source=./interface.go -build_flags=-mod=mod +// + +// Package integrated_validator is a generated GoMock package. +package integrated_validator + +import ( + context "context" + reflect "reflect" + + peer "github.com/netbirdio/netbird/management/server/peer" + types "github.com/netbirdio/netbird/management/server/types" + nmdata "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + proto "github.com/netbirdio/netbird/shared/management/proto" + gomock "go.uber.org/mock/gomock" +) + +// MockIntegratedValidator is a mock of IntegratedValidator interface. +type MockIntegratedValidator struct { + ctrl *gomock.Controller + recorder *MockIntegratedValidatorMockRecorder + isgomock struct{} +} + +// MockIntegratedValidatorMockRecorder is the mock recorder for MockIntegratedValidator. +type MockIntegratedValidatorMockRecorder struct { + mock *MockIntegratedValidator +} + +// NewMockIntegratedValidator creates a new mock instance. +func NewMockIntegratedValidator(ctrl *gomock.Controller) *MockIntegratedValidator { + mock := &MockIntegratedValidator{ctrl: ctrl} + mock.recorder = &MockIntegratedValidatorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockIntegratedValidator) EXPECT() *MockIntegratedValidatorMockRecorder { + return m.recorder +} + +// GetInvalidPeers mocks base method. +func (m *MockIntegratedValidator) GetInvalidPeers(ctx context.Context, accountID string, extraSettings *types.ExtraSettings) (map[string]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetInvalidPeers", ctx, accountID, extraSettings) + ret0, _ := ret[0].(map[string]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetInvalidPeers indicates an expected call of GetInvalidPeers. +func (mr *MockIntegratedValidatorMockRecorder) GetInvalidPeers(ctx, accountID, extraSettings any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInvalidPeers", reflect.TypeOf((*MockIntegratedValidator)(nil).GetInvalidPeers), ctx, accountID, extraSettings) +} + +// GetValidatedPeers mocks base method. +func (m *MockIntegratedValidator) GetValidatedPeers(ctx context.Context, accountID string, groups []*nmdata.Group, peers []*nmdata.Peer, extraSettings *types.ExtraSettings) (map[string]struct{}, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetValidatedPeers", ctx, accountID, groups, peers, extraSettings) + ret0, _ := ret[0].(map[string]struct{}) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetValidatedPeers indicates an expected call of GetValidatedPeers. +func (mr *MockIntegratedValidatorMockRecorder) GetValidatedPeers(ctx, accountID, groups, peers, extraSettings any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetValidatedPeers", reflect.TypeOf((*MockIntegratedValidator)(nil).GetValidatedPeers), ctx, accountID, groups, peers, extraSettings) +} + +// IsNotValidPeer mocks base method. +func (m *MockIntegratedValidator) IsNotValidPeer(ctx context.Context, accountID string, arg2 *peer.Peer, peersGroup []string, extraSettings *types.ExtraSettings) (bool, bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsNotValidPeer", ctx, accountID, arg2, peersGroup, extraSettings) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(bool) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// IsNotValidPeer indicates an expected call of IsNotValidPeer. +func (mr *MockIntegratedValidatorMockRecorder) IsNotValidPeer(ctx, accountID, arg2, peersGroup, extraSettings any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsNotValidPeer", reflect.TypeOf((*MockIntegratedValidator)(nil).IsNotValidPeer), ctx, accountID, arg2, peersGroup, extraSettings) +} + +// PeerDeleted mocks base method. +func (m *MockIntegratedValidator) PeerDeleted(ctx context.Context, accountID, peerID string, extraSettings *types.ExtraSettings) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PeerDeleted", ctx, accountID, peerID, extraSettings) + ret0, _ := ret[0].(error) + return ret0 +} + +// PeerDeleted indicates an expected call of PeerDeleted. +func (mr *MockIntegratedValidatorMockRecorder) PeerDeleted(ctx, accountID, peerID, extraSettings any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PeerDeleted", reflect.TypeOf((*MockIntegratedValidator)(nil).PeerDeleted), ctx, accountID, peerID, extraSettings) +} + +// PreparePeer mocks base method. +func (m *MockIntegratedValidator) PreparePeer(ctx context.Context, accountID string, p *peer.Peer, peersGroup []string, extraSettings *types.ExtraSettings, temporary bool) *peer.Peer { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PreparePeer", ctx, accountID, p, peersGroup, extraSettings, temporary) + ret0, _ := ret[0].(*peer.Peer) + return ret0 +} + +// PreparePeer indicates an expected call of PreparePeer. +func (mr *MockIntegratedValidatorMockRecorder) PreparePeer(ctx, accountID, p, peersGroup, extraSettings, temporary any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PreparePeer", reflect.TypeOf((*MockIntegratedValidator)(nil).PreparePeer), ctx, accountID, p, peersGroup, extraSettings, temporary) +} + +// SetPeerInvalidationListener mocks base method. +func (m *MockIntegratedValidator) SetPeerInvalidationListener(fn func(string, []string)) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetPeerInvalidationListener", fn) +} + +// SetPeerInvalidationListener indicates an expected call of SetPeerInvalidationListener. +func (mr *MockIntegratedValidatorMockRecorder) SetPeerInvalidationListener(fn any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPeerInvalidationListener", reflect.TypeOf((*MockIntegratedValidator)(nil).SetPeerInvalidationListener), fn) +} + +// Stop mocks base method. +func (m *MockIntegratedValidator) Stop(ctx context.Context) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Stop", ctx) +} + +// Stop indicates an expected call of Stop. +func (mr *MockIntegratedValidatorMockRecorder) Stop(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stop", reflect.TypeOf((*MockIntegratedValidator)(nil).Stop), ctx) +} + +// ValidateExtraSettings mocks base method. +func (m *MockIntegratedValidator) ValidateExtraSettings(ctx context.Context, newExtraSettings, oldExtraSettings *types.ExtraSettings, userID, accountID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ValidateExtraSettings", ctx, newExtraSettings, oldExtraSettings, userID, accountID) + ret0, _ := ret[0].(error) + return ret0 +} + +// ValidateExtraSettings indicates an expected call of ValidateExtraSettings. +func (mr *MockIntegratedValidatorMockRecorder) ValidateExtraSettings(ctx, newExtraSettings, oldExtraSettings, userID, accountID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateExtraSettings", reflect.TypeOf((*MockIntegratedValidator)(nil).ValidateExtraSettings), ctx, newExtraSettings, oldExtraSettings, userID, accountID) +} + +// ValidateFlowResponse mocks base method. +func (m *MockIntegratedValidator) ValidateFlowResponse(ctx context.Context, peerKey string, flowResponse *proto.PKCEAuthorizationFlow) *proto.PKCEAuthorizationFlow { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ValidateFlowResponse", ctx, peerKey, flowResponse) + ret0, _ := ret[0].(*proto.PKCEAuthorizationFlow) + return ret0 +} + +// ValidateFlowResponse indicates an expected call of ValidateFlowResponse. +func (mr *MockIntegratedValidatorMockRecorder) ValidateFlowResponse(ctx, peerKey, flowResponse any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateFlowResponse", reflect.TypeOf((*MockIntegratedValidator)(nil).ValidateFlowResponse), ctx, peerKey, flowResponse) +} + +// ValidatePeer mocks base method. +func (m *MockIntegratedValidator) ValidatePeer(ctx context.Context, update, p *peer.Peer, userID, accountID, dnsDomain string, peersGroup []string, extraSettings *types.ExtraSettings) (*peer.Peer, bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ValidatePeer", ctx, update, p, userID, accountID, dnsDomain, peersGroup, extraSettings) + ret0, _ := ret[0].(*peer.Peer) + ret1, _ := ret[1].(bool) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// ValidatePeer indicates an expected call of ValidatePeer. +func (mr *MockIntegratedValidatorMockRecorder) ValidatePeer(ctx, update, p, userID, accountID, dnsDomain, peersGroup, extraSettings any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidatePeer", reflect.TypeOf((*MockIntegratedValidator)(nil).ValidatePeer), ctx, update, p, userID, accountID, dnsDomain, peersGroup, extraSettings) +} diff --git a/management/server/integrations/integrated_validator/interface.go b/management/server/integrations/integrated_validator/interface.go index 326fbfaf0..dc3332177 100644 --- a/management/server/integrations/integrated_validator/interface.go +++ b/management/server/integrations/integrated_validator/interface.go @@ -5,16 +5,19 @@ import ( 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" ) +//go:generate go tool mockgen -package integrated_validator -destination=integrated_validator_mock.go -source=./interface.go -build_flags=-mod=mod + // IntegratedValidator interface exists to avoid the circle dependencies type IntegratedValidator interface { ValidateExtraSettings(ctx context.Context, newExtraSettings *types.ExtraSettings, oldExtraSettings *types.ExtraSettings, userID string, accountID string) error - 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) - PreparePeer(ctx context.Context, accountID string, peer *nbpeer.Peer, peersGroup []string, extraSettings *types.ExtraSettings, temporary bool) *nbpeer.Peer + ValidatePeer(ctx context.Context, update *nbpeer.Peer, p *nbpeer.Peer, userID string, accountID string, dnsDomain string, peersGroup []string, extraSettings *types.ExtraSettings) (*nbpeer.Peer, bool, error) + PreparePeer(ctx context.Context, accountID string, p *nbpeer.Peer, peersGroup []string, extraSettings *types.ExtraSettings, temporary bool) *nbpeer.Peer IsNotValidPeer(ctx context.Context, accountID string, peer *nbpeer.Peer, peersGroup []string, extraSettings *types.ExtraSettings) (bool, bool, error) - GetValidatedPeers(ctx context.Context, accountID string, groups []*types.Group, peers []*nbpeer.Peer, extraSettings *types.ExtraSettings) (map[string]struct{}, error) + GetValidatedPeers(ctx context.Context, accountID string, groups []*nmdata.Group, peers []*nmdata.Peer, extraSettings *types.ExtraSettings) (map[string]struct{}, error) GetInvalidPeers(ctx context.Context, accountID string, extraSettings *types.ExtraSettings) (map[string]string, error) PeerDeleted(ctx context.Context, accountID, peerID string, extraSettings *types.ExtraSettings) error SetPeerInvalidationListener(fn func(accountID string, peerIDs []string)) diff --git a/management/server/integrations/integrated_validator/validator/validator.go b/management/server/integrations/integrated_validator/validator/validator.go index db1d34373..33199c065 100644 --- a/management/server/integrations/integrated_validator/validator/validator.go +++ b/management/server/integrations/integrated_validator/validator/validator.go @@ -10,6 +10,7 @@ import ( nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/settings" "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" "github.com/netbirdio/netbird/shared/management/proto" ) @@ -35,7 +36,7 @@ func (v *IntegratedValidatorImpl) IsNotValidPeer(_ context.Context, _ string, _ return false, false, nil } -func (v *IntegratedValidatorImpl) GetValidatedPeers(_ context.Context, _ string, _ []*types.Group, peers []*nbpeer.Peer, _ *types.ExtraSettings) (map[string]struct{}, error) { +func (v *IntegratedValidatorImpl) GetValidatedPeers(_ context.Context, _ string, _ []*nmdata.Group, peers []*nmdata.Peer, _ *types.ExtraSettings) (map[string]struct{}, error) { validatedPeers := make(map[string]struct{}) for _, p := range peers { validatedPeers[p.ID] = struct{}{} diff --git a/management/server/management_proto_test.go b/management/server/management_proto_test.go index c23ca6237..4f8aa8265 100644 --- a/management/server/management_proto_test.go +++ b/management/server/management_proto_test.go @@ -376,7 +376,7 @@ func startManagementForTest(t *testing.T, testFile string, config *config.Config return nil, nil, "", cleanup, err } - networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeralMgr, config) + networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeralMgr, config, nil) accountManager, err := BuildManager(ctx, nil, store, networkMapController, jobManager, nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) diff --git a/management/server/management_test.go b/management/server/management_test.go index 80c76f0de..3a8d6ecc2 100644 --- a/management/server/management_test.go +++ b/management/server/management_test.go @@ -216,7 +216,7 @@ func startServer( updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := server.NewAccountRequestBuffer(ctx, str) - networkMapController := controller.NewController(ctx, str, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(str, peers.NewManager(str, permissionsManager)), config) + networkMapController := controller.NewController(ctx, str, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(str, peers.NewManager(str, permissionsManager)), config, nil) accountManager, err := server.BuildManager( context.Background(), diff --git a/management/server/nameserver_test.go b/management/server/nameserver_test.go index ce5d5d57b..deed9c34f 100644 --- a/management/server/nameserver_test.go +++ b/management/server/nameserver_test.go @@ -803,7 +803,7 @@ func createNSManager(t *testing.T) (*DefaultAccountManager, error) { updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}) + networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil) return BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) } diff --git a/management/server/networks/resources/types/resource.go b/management/server/networks/resources/types/resource.go index 643f9cdd6..4cf7f7ea3 100644 --- a/management/server/networks/resources/types/resource.go +++ b/management/server/networks/resources/types/resource.go @@ -14,7 +14,6 @@ import ( nbDomain "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/shared/management/http/api" - sharedTypes "github.com/netbirdio/netbird/shared/management/types" ) type NetworkResourceType string @@ -65,27 +64,6 @@ func NewNetworkResource(accountID, networkID, name, description, address string, }, nil } -// ToComponent converts the resource to its self-contained components -// representation. Returns nil for a nil resource. -func (n *NetworkResource) ToComponent() *sharedTypes.ComponentResource { - if n == nil { - return nil - } - return &sharedTypes.ComponentResource{ - ID: n.ID, - PublicID: n.PublicID, - NetworkID: n.NetworkID, - AccountID: n.AccountID, - Name: n.Name, - Description: n.Description, - Type: sharedTypes.ComponentResourceType(n.Type), - Address: n.Address, - Domain: n.Domain, - Prefix: n.Prefix, - Enabled: n.Enabled, - } -} - func (n *NetworkResource) ToAPIResponse(groups []api.GroupMinimum) *api.NetworkResource { addr := n.Prefix.String() if n.Type == Domain { diff --git a/management/server/networks/routers/types/router.go b/management/server/networks/routers/types/router.go index b8097cdbb..189d7f792 100644 --- a/management/server/networks/routers/types/router.go +++ b/management/server/networks/routers/types/router.go @@ -7,7 +7,6 @@ import ( "github.com/netbirdio/netbird/management/server/networks/types" "github.com/netbirdio/netbird/shared/management/http/api" - sharedTypes "github.com/netbirdio/netbird/shared/management/types" ) type NetworkRouter struct { @@ -22,36 +21,6 @@ type NetworkRouter struct { Enabled bool } -// ToComponent converts the router to its self-contained components -// representation. Returns nil for a nil router. -func (n *NetworkRouter) ToComponent() *sharedTypes.ComponentRouter { - if n == nil { - return nil - } - return &sharedTypes.ComponentRouter{ - NetworkID: n.NetworkID, - PublicID: n.PublicID, - Peer: n.Peer, - PeerGroups: n.PeerGroups, - Masquerade: n.Masquerade, - Metric: n.Metric, - Enabled: n.Enabled, - } -} - -// ToComponentMap converts a peer-keyed router map to its components -// representation. -func ToComponentMap(routers map[string]*NetworkRouter) map[string]*sharedTypes.ComponentRouter { - if routers == nil { - return nil - } - out := make(map[string]*sharedTypes.ComponentRouter, len(routers)) - for id, r := range routers { - out[id] = r.ToComponent() - } - return out -} - func NewNetworkRouter(accountID string, networkID string, peer string, peerGroups []string, masquerade bool, metric int, enabled bool) (*NetworkRouter, error) { r := &NetworkRouter{ ID: xid.New().String(), diff --git a/management/server/peer.go b/management/server/peer.go index 589cf9abf..579ff2708 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -21,6 +21,7 @@ import ( "github.com/netbirdio/netbird/management/server/permissions/modules" "github.com/netbirdio/netbird/management/server/permissions/operations" "github.com/netbirdio/netbird/shared/management/domain" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" "github.com/netbirdio/netbird/management/server/posture" "github.com/netbirdio/netbird/management/server/store" @@ -1588,7 +1589,7 @@ func affectedPeerIDsFromNetworkMap(nmap *types.NetworkMap, selfPeerID string) [] } seen := make(map[string]struct{}, len(nmap.Peers)+len(nmap.OfflinePeers)) ids := make([]string, 0, len(nmap.Peers)+len(nmap.OfflinePeers)) - add := func(peers []*types.ComponentPeer) { + add := func(peers []*nmdata.Peer) { for _, p := range peers { if p == nil || p.ID == "" || p.ID == selfPeerID { continue diff --git a/management/server/peer/peer.go b/management/server/peer/peer.go index a7be63ff9..80c77592c 100644 --- a/management/server/peer/peer.go +++ b/management/server/peer/peer.go @@ -13,14 +13,14 @@ import ( "github.com/netbirdio/netbird/management/server/util" "github.com/netbirdio/netbird/shared/management/http/api" - sharedTypes "github.com/netbirdio/netbird/shared/management/types" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" ) // Peer capability constants mirror the proto enum values. const ( - PeerCapabilitySourcePrefixes int32 = 1 - PeerCapabilityIPv6Overlay int32 = 2 - PeerCapabilityComponentNetworkMap int32 = 3 + PeerCapabilitySourcePrefixes = nmdata.PeerCapabilitySourcePrefixes + PeerCapabilityIPv6Overlay = nmdata.PeerCapabilityIPv6Overlay + PeerCapabilityComponentNetworkMap = nmdata.PeerCapabilityComponentNetworkMap ) // Peer represents a machine connected to the network. @@ -206,36 +206,6 @@ func (p *Peer) AddedWithSSOLogin() bool { return p.UserID != "" } -// ToComponent converts the peer to its self-contained components -// representation, carrying exactly the subset of peer data that crosses the -// components wire format. Returns nil for a nil peer so callers can convert -// possibly-missing peers without guarding. -func (p *Peer) ToComponent() *sharedTypes.ComponentPeer { - if p == nil { - return nil - } - cp := &sharedTypes.ComponentPeer{ - ID: p.ID, - Key: p.Key, - IP: p.IP, - IPv6: p.IPv6, - DNSLabel: p.DNSLabel, - SSHKey: p.SSHKey, - SSHEnabled: p.SSHEnabled, - ServerSSHAllowed: p.Meta.Flags.ServerSSHAllowed, - AgentVersion: p.Meta.WtVersion, - SupportsSourcePrefixes: p.SupportsSourcePrefixes(), - SupportsIPv6: p.SupportsIPv6(), - LoginExpirationEnabled: p.LoginExpirationEnabled, - AddedWithSSOLogin: p.AddedWithSSOLogin(), - ProxyEmbedded: p.ProxyMeta.Embedded, - } - if p.LastLogin != nil { - cp.LastLogin = *p.LastLogin - } - return cp -} - // HasCapability reports whether the peer has the given capability. func (p *Peer) HasCapability(capability int32) bool { return slices.Contains(p.Meta.Capabilities, capability) diff --git a/management/server/peer_test.go b/management/server/peer_test.go index 80d270e98..9a662bdbf 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -57,6 +57,7 @@ import ( "github.com/netbirdio/netbird/management/server/types" nbroute "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/domain" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" "github.com/netbirdio/netbird/shared/management/proto" ) @@ -1091,22 +1092,22 @@ func TestToSyncResponse(t *testing.T) { Signature: "turn-pass", } networkMap := &types.NetworkMap{ - Network: &types.Network{Net: *ipnet, Serial: 1000}, - Peers: []*types.ComponentPeer{{ + Network: &nmdata.Network{Net: *ipnet, Serial: 1000}, + Peers: []*nmdata.Peer{{ IP: netip.MustParseAddr("192.168.1.2"), IPv6: netip.MustParseAddr("fd00::2"), Key: "peer2-key", DNSLabel: "peer2", SSHEnabled: true, SSHKey: "peer2-ssh-key"}}, - OfflinePeers: []*types.ComponentPeer{{ + OfflinePeers: []*nmdata.Peer{{ IP: netip.MustParseAddr("192.168.1.3"), IPv6: netip.MustParseAddr("fd00::3"), Key: "peer3-key", DNSLabel: "peer3", SSHEnabled: true, SSHKey: "peer3-ssh-key"}}, - Routes: []*nbroute.Route{ + Routes: []*nmdata.Route{ { ID: "route1", Network: netip.MustParsePrefix("10.0.0.0/24"), @@ -1180,7 +1181,7 @@ func TestToSyncResponse(t *testing.T) { } dnsCache := &cache.DNSConfigCache{} accountSettings := &types.Settings{RoutingPeerDNSResolutionEnabled: true} - response := grpc.ToSyncResponse(context.Background(), config, config.HttpConfig, config.DeviceAuthorizationFlow, peer, turnRelayToken, turnRelayToken, networkMap, dnsName, checks, dnsCache, accountSettings, nil, []string{}, int64(dnsForwarderPort)) + response := grpc.ToSyncResponse(context.Background(), config, config.HttpConfig, config.DeviceAuthorizationFlow, types.TwinPeer(peer), turnRelayToken, turnRelayToken, networkMap, dnsName, checks, dnsCache, types.TwinAccountSettings(accountSettings), nil, []string{}, int64(dnsForwarderPort)) assert.NotNil(t, response) // assert peer config @@ -1300,7 +1301,7 @@ func Test_RegisterPeerByUser(t *testing.T) { updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, s) - networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}) + networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil) am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) assert.NoError(t, err) @@ -1391,7 +1392,7 @@ func Test_RegisterPeerBySetupKey(t *testing.T) { updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, s) - networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}) + networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil) am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) assert.NoError(t, err) @@ -1550,7 +1551,7 @@ func Test_RegisterPeerRollbackOnFailure(t *testing.T) { updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, s) - networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}) + networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil) am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) assert.NoError(t, err) @@ -1635,7 +1636,7 @@ func Test_LoginPeer(t *testing.T) { updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, s) - networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}) + networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil) am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) assert.NoError(t, err) diff --git a/management/server/route_test.go b/management/server/route_test.go index 53dbb29d9..4ca9ee48f 100644 --- a/management/server/route_test.go +++ b/management/server/route_test.go @@ -1201,7 +1201,7 @@ func TestGetNetworkMap_RouteSync(t *testing.T) { peer1Routes, err := am.GetNetworkMap(context.Background(), peer1ID) require.NoError(t, err) require.Len(t, peer1Routes.Routes, 1, "we should receive one route for peer1") - require.True(t, expectedRoute.Equal(peer1Routes.Routes[0]), "received route should be equal") + require.True(t, types.TwinRoute(expectedRoute).Equal(peer1Routes.Routes[0]), "received route should be equal") peer2Routes, err := am.GetNetworkMap(context.Background(), peer2ID) require.NoError(t, err) @@ -1299,7 +1299,7 @@ func createRouterManager(t *testing.T) (*DefaultAccountManager, *update_channel. updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}) + networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil) am, err := BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) if err != nil { diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index 99bb2c2c1..6337ebf1a 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -3166,9 +3166,9 @@ func getGormConfig() *gorm.Config { // newPostgresStore initializes a new Postgres store. func newPostgresStore(ctx context.Context, metrics telemetry.AppMetrics, skipMigration bool) (Store, error) { - dsn, ok := lookupDSNEnv(postgresDsnEnv, postgresDsnEnvLegacy) + dsn, ok := lookupDSNEnv(PostgresDsnEnv, PostgresDsnEnvLegacy) if !ok { - return nil, fmt.Errorf("%s is not set", postgresDsnEnv) + return nil, fmt.Errorf("%s is not set", PostgresDsnEnv) } return NewPostgresqlStore(ctx, dsn, metrics, skipMigration) } diff --git a/management/server/store/sql_store_get_account_test.go b/management/server/store/sql_store_get_account_test.go index 56f2a6c41..686839b1f 100644 --- a/management/server/store/sql_store_get_account_test.go +++ b/management/server/store/sql_store_get_account_test.go @@ -13,7 +13,6 @@ import ( "github.com/stretchr/testify/require" nbdns "github.com/netbirdio/netbird/dns" - "github.com/netbirdio/netbird/management/server/integration_reference" resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" networkTypes "github.com/netbirdio/netbird/management/server/networks/types" @@ -21,6 +20,7 @@ import ( "github.com/netbirdio/netbird/management/server/posture" "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/route" + "github.com/netbirdio/netbird/shared/management/integration_reference" ) // TestGetAccount_LoadsCustomDomains verifies GetAccount populates account.Domains. diff --git a/management/server/store/store.go b/management/server/store/store.go index ca911092b..7daeb28a9 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -436,8 +436,8 @@ type AgentNetworkMetrics struct { } const ( - postgresDsnEnv = "NB_STORE_ENGINE_POSTGRES_DSN" - postgresDsnEnvLegacy = "NETBIRD_STORE_ENGINE_POSTGRES_DSN" + PostgresDsnEnv = "NB_STORE_ENGINE_POSTGRES_DSN" + PostgresDsnEnvLegacy = "NETBIRD_STORE_ENGINE_POSTGRES_DSN" mysqlDsnEnv = "NB_STORE_ENGINE_MYSQL_DSN" mysqlDsnEnvLegacy = "NETBIRD_STORE_ENGINE_MYSQL_DSN" ) @@ -781,7 +781,7 @@ func getSqlStoreEngine(ctx context.Context, store *SqlStore, kind types.Engine) } func newReusedPostgresStore(ctx context.Context, store *SqlStore, kind types.Engine) (*SqlStore, func(), error) { - dsn, ok := lookupDSNEnv(postgresDsnEnv, postgresDsnEnvLegacy) + dsn, ok := lookupDSNEnv(PostgresDsnEnv, PostgresDsnEnvLegacy) if !ok || dsn == "" { var err error _, dsn, err = testutil.CreatePostgresTestContainer() @@ -791,7 +791,7 @@ func newReusedPostgresStore(ctx context.Context, store *SqlStore, kind types.Eng } if dsn == "" { - return nil, nil, fmt.Errorf("%s is not set", postgresDsnEnv) + return nil, nil, fmt.Errorf("%s is not set", PostgresDsnEnv) } db, err := openDBWithRetry(dsn, kind, 5) diff --git a/management/server/types/account.go b/management/server/types/account.go index 4616fe26b..522bb8be6 100644 --- a/management/server/types/account.go +++ b/management/server/types/account.go @@ -9,7 +9,6 @@ import ( "strings" "time" - "github.com/hashicorp/go-multierror" "github.com/miekg/dns" "github.com/rs/xid" log "github.com/sirupsen/logrus" @@ -18,8 +17,6 @@ import ( 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" resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" networkTypes "github.com/netbirdio/netbird/management/server/networks/types" @@ -28,11 +25,12 @@ import ( "github.com/netbirdio/netbird/management/server/util" "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/domain" + "github.com/netbirdio/netbird/shared/management/networkmap" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" "github.com/netbirdio/netbird/shared/management/status" ) const ( - defaultTTL = 300 // privateServiceDNSRecordTTL is short so proxy-peer changes propagate quickly to clients. privateServiceDNSRecordTTL = 5 DefaultPeerLoginExpiration = 24 * time.Hour @@ -384,94 +382,11 @@ func peerInDistributionGroups(peerGroups LookupMap, distributionGroups []string) } func (a *Account) GetPeersCustomZone(ctx context.Context, dnsDomain string) nbdns.CustomZone { - var merr *multierror.Error - - if dnsDomain == "" { - log.WithContext(ctx).Error("no dns domain is set, returning empty zone") - return nbdns.CustomZone{} + twins := make(map[string]*nmdata.Peer, len(a.Peers)) + for id, p := range a.Peers { + twins[id] = twinPeer(p) } - - customZone := nbdns.CustomZone{ - Domain: dns.Fqdn(dnsDomain), - Records: make([]nbdns.SimpleRecord, 0, len(a.Peers)), - } - - domainSuffix := "." + dnsDomain - - ipv6AllowedPeers := a.peerIPv6AllowedSet() - - var sb strings.Builder - for _, peer := range a.Peers { - if peer.DNSLabel == "" { - merr = multierror.Append(merr, fmt.Errorf("peer %s has an empty DNS label", peer.Name)) - continue - } - - sb.Grow(len(peer.DNSLabel) + len(domainSuffix)) - sb.WriteString(peer.DNSLabel) - sb.WriteString(domainSuffix) - - fqdn := sb.String() - customZone.Records = append(customZone.Records, nbdns.SimpleRecord{ - Name: fqdn, - Type: int(dns.TypeA), - Class: nbdns.DefaultClass, - TTL: defaultTTL, - RData: peer.IP.String(), - }) - // Only advertise AAAA for peers that have a valid IPv6, whose client supports it, - // and that belong to an IPv6-enabled group. Old clients don't configure v6 on their - // WireGuard interface, so resolving their AAAA causes connections to hang. - // Capability changes (client upgrade/downgrade, --disable-ipv6 toggle) propagate - // to other peers via SyncPeer/LoginPeer regardless of version change, so AAAA - // records refresh when a peer first reports the IPv6 overlay capability. - _, peerAllowed := ipv6AllowedPeers[peer.ID] - hasIPv6 := peer.IPv6.IsValid() && peer.SupportsIPv6() && peerAllowed - if hasIPv6 { - customZone.Records = append(customZone.Records, nbdns.SimpleRecord{ - Name: fqdn, - Type: int(dns.TypeAAAA), - Class: nbdns.DefaultClass, - TTL: defaultTTL, - RData: peer.IPv6.String(), - }) - } - sb.Reset() - - for _, extraLabel := range peer.ExtraDNSLabels { - sb.Grow(len(extraLabel) + len(domainSuffix)) - sb.WriteString(extraLabel) - sb.WriteString(domainSuffix) - - extraFqdn := sb.String() - customZone.Records = append(customZone.Records, nbdns.SimpleRecord{ - Name: extraFqdn, - Type: int(dns.TypeA), - Class: nbdns.DefaultClass, - TTL: defaultTTL, - RData: peer.IP.String(), - }) - if hasIPv6 { - customZone.Records = append(customZone.Records, nbdns.SimpleRecord{ - Name: extraFqdn, - Type: int(dns.TypeAAAA), - Class: nbdns.DefaultClass, - TTL: defaultTTL, - RData: peer.IPv6.String(), - }) - } - sb.Reset() - } - - } - - go func() { - if merr != nil { - log.WithContext(ctx).Errorf("error generating custom zone for account %s: %v", a.Id, merr) - } - }() - - return customZone + return fromTwinCustomZone(networkmap.PeersCustomZone(ctx, a.Id, dnsDomain, twins, a.peerIPv6AllowedSet())) } // GetExpiredPeers returns peers that have been expired @@ -1065,6 +980,26 @@ func (a *Account) GetPeerConnectionResources(ctx context.Context, peer *nbpeer.P return peers, fwRules, authorizedUsers, sshEnabled } +// proxyTargetedDomainResourceIDs returns the set of domain network resource IDs +// targeted by an enabled, non-terminated reverse-proxy service. +func (a *Account) proxyTargetedDomainResourceIDs() map[string]struct{} { + ids := make(map[string]struct{}) + for _, svc := range a.Services { + if svc == nil || !svc.Enabled || svc.Terminated { + continue + } + for _, target := range svc.Targets { + if target == nil || !target.Enabled { + continue + } + if target.TargetType == service.TargetTypeDomain { + ids[target.TargetId] = struct{}{} + } + } + } + return ids +} + func (a *Account) getAllowedUserIDs() map[string]struct{} { users := make(map[string]struct{}) for _, nbUser := range a.Users { @@ -1085,7 +1020,6 @@ func (a *Account) connResourcesGenerator(ctx context.Context, targetPeer *nbpeer peersExists := make(map[string]struct{}) rules := make([]*FirewallRule, 0) peers := make([]*nbpeer.Peer, 0) - targetComponent := targetPeer.ToComponent() return func(rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int) { for _, peer := range groupPeers { @@ -1121,10 +1055,10 @@ func (a *Account) connResourcesGenerator(ctx context.Context, targetPeer *nbpeer if len(rule.Ports) == 0 && len(rule.PortRanges) == 0 { rules = append(rules, &fr) } else { - rules = append(rules, ExpandPortsAndRanges(fr, rule, targetComponent)...) + rules = append(rules, ExpandPortsAndRanges(fr, rule, targetPeer)...) } - rules = AppendIPv6FirewallRule(rules, rulesExists, peer.ToComponent(), targetComponent, rule, FirewallRuleContext{ + rules = AppendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, rule, FirewallRuleContext{ Direction: direction, DirStr: strconv.Itoa(direction), ProtocolStr: string(protocol), @@ -1284,7 +1218,7 @@ func (a *Account) getRouteFirewallRules(ctx context.Context, peerID string, poli return fwRules } -func (a *Account) getRulePeers(rule *PolicyRule, postureChecks []string, peerID string, distributionPeers map[string]struct{}, validatedPeersMap map[string]struct{}) []*ComponentPeer { +func (a *Account) getRulePeers(rule *PolicyRule, postureChecks []string, peerID string, distributionPeers map[string]struct{}, validatedPeersMap map[string]struct{}) []*nbpeer.Peer { distPeersWithPolicy := make(map[string]struct{}) for _, id := range rule.Sources { group := a.Groups[id] @@ -1311,13 +1245,13 @@ func (a *Account) getRulePeers(rule *PolicyRule, postureChecks []string, peerID } } - distributionGroupPeers := make([]*ComponentPeer, 0, len(distPeersWithPolicy)) + distributionGroupPeers := make([]*nbpeer.Peer, 0, len(distPeersWithPolicy)) for pID := range distPeersWithPolicy { peer := a.Peers[pID] if peer == nil { continue } - distributionGroupPeers = append(distributionGroupPeers, peer.ToComponent()) + distributionGroupPeers = append(distributionGroupPeers, peer) } return distributionGroupPeers } @@ -1520,54 +1454,6 @@ func (a *Account) GetResourceRoutersMap() map[string]map[string]*routerTypes.Net return routers } -// forcesRoutingPeerDNSResolution reports whether the given peer must run -// routing-peer DNS resolution regardless of the account-global -// RoutingPeerDNSResolutionEnabled setting. It returns true when the peer is a -// router for a domain network resource that is targeted by an enabled -// reverse-proxy service, so the peer's DNS forwarder starts and can resolve -// the target for the embedded proxy peers. Embedded proxy peers themselves are -// handled at PeerConfig build time. -func (a *Account) forcesRoutingPeerDNSResolution(peerID string, routers map[string]map[string]*routerTypes.NetworkRouter) bool { - targeted := a.proxyTargetedDomainResourceIDs() - if len(targeted) == 0 { - return false - } - - for _, resource := range a.NetworkResources { - if resource == nil || !resource.Enabled || resource.Type != resourceTypes.Domain { - continue - } - if _, ok := targeted[resource.ID]; !ok { - continue - } - if _, isRouter := routers[resource.NetworkID][peerID]; isRouter { - return true - } - } - - return false -} - -// proxyTargetedDomainResourceIDs returns the set of domain network resource IDs -// targeted by an enabled, non-terminated reverse-proxy service. -func (a *Account) proxyTargetedDomainResourceIDs() map[string]struct{} { - ids := make(map[string]struct{}) - for _, svc := range a.Services { - if svc == nil || !svc.Enabled || svc.Terminated { - continue - } - for _, target := range svc.Targets { - if target == nil || !target.Enabled { - continue - } - if target.TargetType == service.TargetTypeDomain { - ids[target.TargetId] = struct{}{} - } - } - } - return ids -} - // getPoliciesSourcePeers collects all unique peers from the source groups defined in the given policies. func getPoliciesSourcePeers(policies []*Policy, groups map[string]*Group) map[string]struct{} { sourcePeers := make(map[string]struct{}) @@ -1668,176 +1554,6 @@ func (a *Account) GetProxyPeers() map[string][]*nbpeer.Peer { return proxyPeers } -func (a *Account) InjectProxyPolicies(ctx context.Context) { - if len(a.Services) == 0 { - return - } - - proxyPeersByCluster := a.GetProxyPeers() - if len(proxyPeersByCluster) == 0 { - return - } - - for _, service := range a.Services { - if !service.Enabled { - continue - } - a.injectServiceProxyPolicies(ctx, service, proxyPeersByCluster) - } - -} - -func (a *Account) injectServiceProxyPolicies(ctx context.Context, service *service.Service, proxyPeersByCluster map[string][]*nbpeer.Peer) { - proxyPeers := proxyPeersByCluster[service.ProxyCluster] - for _, target := range service.Targets { - if !target.Enabled { - continue - } - a.injectTargetProxyPolicies(ctx, service, target, proxyPeers) - } - - a.injectPrivateServicePolicies(service, proxyPeers) -} - -// injectPrivateServicePolicies synthesises an in-memory ACL: AccessGroups → cluster proxy peers on TCP 80/443. -func (a *Account) injectPrivateServicePolicies(svc *service.Service, proxyPeers []*nbpeer.Peer) { - if !svc.Private { - return - } - if len(svc.AccessGroups) == 0 { - return - } - if len(proxyPeers) == 0 { - return - } - // A service's AccessGroups can name groups that no longer exist — persisted - // services and the agent-network synthesiser both carry the ids verbatim from - // their own state. An unresolvable source authorises nothing, so drop it here - // rather than let the network-map assembly resolve it to a nil group. - sources := a.existingGroupIDs(svc.AccessGroups) - if len(sources) == 0 { - return - } - for _, proxyPeer := range proxyPeers { - a.Policies = append(a.Policies, a.createPrivateServicePolicy(svc, proxyPeer, sources)) - } -} - -// existingGroupIDs returns the subset of groupIDs that resolve to a group in the account, -// preserving the input order. -func (a *Account) existingGroupIDs(groupIDs []string) []string { - out := make([]string, 0, len(groupIDs)) - for _, groupID := range groupIDs { - if _, ok := a.Groups[groupID]; ok { - out = append(out, groupID) - } - } - return out -} - -func (a *Account) createPrivateServicePolicy(svc *service.Service, proxyPeer *nbpeer.Peer, accessGroups []string) *Policy { - policyID := fmt.Sprintf("private-access-%s-%s", svc.ID, proxyPeer.ID) - sources := append([]string(nil), accessGroups...) - return &Policy{ - ID: policyID, - Name: fmt.Sprintf("Private Access to %s", svc.Name), - Enabled: true, - Rules: []*PolicyRule{ - { - ID: policyID, - PolicyID: policyID, - Name: fmt.Sprintf("Allow access groups to reach %s", svc.Name), - Enabled: true, - Sources: sources, - DestinationResource: Resource{ - ID: proxyPeer.ID, - Type: ResourceTypePeer, - }, - Bidirectional: false, - Protocol: PolicyRuleProtocolTCP, - Action: PolicyTrafficActionAccept, - PortRanges: []RulePortRange{ - {Start: 80, End: 80}, - {Start: 443, End: 443}, - }, - }, - }, - } -} - -func (a *Account) injectTargetProxyPolicies(ctx context.Context, service *service.Service, target *service.Target, proxyPeers []*nbpeer.Peer) { - port, ok := a.resolveTargetPort(ctx, target) - if !ok { - return - } - - path := "" - if target.Path != nil { - path = *target.Path - } - - for _, proxyPeer := range proxyPeers { - policy := a.createProxyPolicy(service, target, proxyPeer, port, path) - a.Policies = append(a.Policies, policy) - } -} - -func (a *Account) resolveTargetPort(ctx context.Context, target *service.Target) (uint16, bool) { - if target.Port != 0 { - return target.Port, true - } - - switch target.Protocol { - case "https", "tls": - return 443, true - case "http": - return 80, true - default: - log.WithContext(ctx).Warnf("unsupported protocol %s for proxy target %s, skipping policy injection", target.Protocol, target.TargetId) - return 0, false - } -} - -func (a *Account) createProxyPolicy(svc *service.Service, target *service.Target, proxyPeer *nbpeer.Peer, port uint16, path string) *Policy { - policyID := fmt.Sprintf("proxy-access-%s-%s-%s", svc.ID, proxyPeer.ID, path) - - protocol := PolicyRuleProtocolTCP - if svc.Mode == service.ModeUDP { - protocol = PolicyRuleProtocolUDP - } - - return &Policy{ - ID: policyID, - Name: fmt.Sprintf("Proxy Access to %s", svc.Name), - Enabled: true, - Rules: []*PolicyRule{ - { - ID: policyID, - PolicyID: policyID, - Name: fmt.Sprintf("Allow access to %s", svc.Name), - Enabled: true, - SourceResource: Resource{ - ID: proxyPeer.ID, - Type: ResourceTypePeer, - }, - DestinationResource: Resource{ - ID: target.TargetId, - Type: ResourceType(target.TargetType), - }, - Bidirectional: false, - Protocol: protocol, - Action: PolicyTrafficActionAccept, - PortRanges: []RulePortRange{ - { - Start: port, - End: port, - }, - }, - }, - }, - } -} - // filterZoneRecordsForPeers filters DNS records to only include peers to connect. // AAAA records are excluded when the requesting peer lacks IPv6 capability. func filterZoneRecordsForPeers(peer *nbpeer.Peer, customZone nbdns.CustomZone, peersToConnect, expiredPeers []*nbpeer.Peer) []nbdns.SimpleRecord { @@ -1870,66 +1586,3 @@ func filterZoneRecordsForPeers(peer *nbpeer.Peer, customZone nbdns.CustomZone, p return filteredRecords } - -// filterPeerAppliedZones filters account zones based on the peer's group membership -func filterPeerAppliedZones(ctx context.Context, accountZones []*zones.Zone, peerGroups LookupMap) []nbdns.CustomZone { - var customZones []nbdns.CustomZone - - if len(peerGroups) == 0 { - return customZones - } - - for _, zone := range accountZones { - if !zone.Enabled || len(zone.Records) == 0 { - continue - } - - hasAccess := false - for _, distGroupID := range zone.DistributionGroups { - if _, found := peerGroups[distGroupID]; found { - hasAccess = true - break - } - } - - if !hasAccess { - continue - } - - simpleRecords := make([]nbdns.SimpleRecord, 0, len(zone.Records)) - for _, record := range zone.Records { - var recordType int - rData := record.Content - - switch record.Type { - case records.RecordTypeA: - recordType = int(dns.TypeA) - case records.RecordTypeAAAA: - recordType = int(dns.TypeAAAA) - case records.RecordTypeCNAME: - recordType = int(dns.TypeCNAME) - rData = dns.Fqdn(record.Content) - default: - log.WithContext(ctx).Warnf("unknown DNS record type %s for record %s", record.Type, record.ID) - continue - } - - simpleRecords = append(simpleRecords, nbdns.SimpleRecord{ - Name: dns.Fqdn(record.Name), - Type: recordType, - Class: nbdns.DefaultClass, - TTL: record.TTL, - RData: rData, - }) - } - - customZones = append(customZones, nbdns.CustomZone{ - Domain: dns.Fqdn(zone.Domain), - Records: simpleRecords, - SearchDomainDisabled: !zone.EnableSearchDomain, - NonAuthoritative: true, - }) - } - - return customZones -} diff --git a/management/server/types/account_components.go b/management/server/types/account_components.go index 3f2d5485f..3545fc8c8 100644 --- a/management/server/types/account_components.go +++ b/management/server/types/account_components.go @@ -2,7 +2,6 @@ package types import ( "context" - "slices" "time" log "github.com/sirupsen/logrus" @@ -10,10 +9,7 @@ import ( nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/management/internals/modules/zones" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" - nbpeer "github.com/netbirdio/netbird/management/server/peer" - "github.com/netbirdio/netbird/management/server/posture" "github.com/netbirdio/netbird/management/server/telemetry" - "github.com/netbirdio/netbird/route" ) // GetPeerNetworkMapResult dispatches to either the legacy-NetworkMap path or @@ -94,6 +90,9 @@ func (a *Account) GetPeerNetworkMapFromComponents( return nm } +// GetPeerNetworkMapComponents builds the account's slim twin store and computes +// the peer's components on it. The calculation itself lives on +// networkmap.NetworkMapData and never touches the Account. func (a *Account) GetPeerNetworkMapComponents( ctx context.Context, peerID string, @@ -104,722 +103,19 @@ func (a *Account) GetPeerNetworkMapComponents( routers map[string]map[string]*routerTypes.NetworkRouter, groupIDToUserIDs map[string][]string, ) *NetworkMapComponents { - peer := a.Peers[peerID] - // this can never happen, things are very wrong if it did - // TODO (dmitri) maybe consider using invariants? - if peer == nil { - log.WithField("peer id", peerID).Error("NetworkMapComponents are computed for a peer missing from the account") - return EmptyNetworkMapComponents(&NetworkMapComponents{ - PeerID: peerID, - Network: a.Network.Copy(), - }) - } - if _, ok := validatedPeersMap[peerID]; !ok { - // Mirror legacy graceful-degrade: GetPeerNetworkMapFromComponents - // returns &NetworkMap{Network: a.Network.Copy()} when components is - // nil. Match that floor so the receiving client always sees the - // account Network identifier, not a fully-empty envelope. - return EmptyNetworkMapComponents(&NetworkMapComponents{ - PeerID: peerID, - Network: a.Network.Copy(), - // must include the target peer as it's required on the client - Peers: map[string]*ComponentPeer{peerID: peer.ToComponent()}, - }) - } - - components := &NetworkMapComponents{ - PeerID: peerID, - Network: a.Network.Copy(), - NameServerGroups: make([]*nbdns.NameServerGroup, 0), - CustomZoneDomain: peersCustomZone.Domain, - ResourcePoliciesMap: make(map[string][]*Policy), - RoutersMap: make(map[string]map[string]*ComponentRouter), - NetworkResources: make([]*ComponentResource, 0), - PostureFailedPeers: make(map[string]map[string]struct{}, len(a.PostureChecks)), - RouterPeers: make(map[string]*ComponentPeer), - NetworkXIDToPublicID: make(map[string]string, len(a.Networks)), - PostureCheckXIDToPublicID: make(map[string]string, len(a.PostureChecks)), - - ForceRoutingPeerDNSResolution: a.forcesRoutingPeerDNSResolution(peerID, routers), - } - for _, n := range a.Networks { - if n != nil { - components.NetworkXIDToPublicID[n.ID] = n.PublicID - } - } - for _, pc := range a.PostureChecks { - if pc != nil { - components.PostureCheckXIDToPublicID[pc.ID] = pc.PublicID - } - } - - components.AccountSettings = &AccountSettingsInfo{ - PeerLoginExpirationEnabled: a.Settings.PeerLoginExpirationEnabled, - PeerLoginExpiration: a.Settings.PeerLoginExpiration, - PeerInactivityExpirationEnabled: a.Settings.PeerInactivityExpirationEnabled, - PeerInactivityExpiration: a.Settings.PeerInactivityExpiration, - } - - components.DNSSettings = &a.DNSSettings - - // relevantPeers always contains the target peer (peerID) - relevantPeers, relevantGroups, relevantPolicies, relevantRoutes, sshReqs := a.getPeersGroupsPoliciesRoutes(ctx, peerID, peer.SSHEnabled, validatedPeersMap, &components.PostureFailedPeers) - - if len(sshReqs.neededGroupIDs) > 0 { - components.GroupIDToUserIDs = filterGroupIDToUserIDs(groupIDToUserIDs, sshReqs.neededGroupIDs) - } - if sshReqs.needAllowedUserIDs { - components.AllowedUserIDs = a.getAllowedUserIDs() - } - - components.Peers = relevantPeers - components.Groups = GroupsToComponent(relevantGroups) - components.Policies = relevantPolicies - components.Routes = relevantRoutes - components.AllDNSRecords = filterDNSRecordsByPeers(peersCustomZone.Records, relevantPeers, peer.SupportsIPv6() && peer.IPv6.IsValid()) - - peerGroups := a.GetPeerGroups(peerID) - components.AccountZones = filterPeerAppliedZones(ctx, accountZones, peerGroups) - components.AccountZones = append(components.AccountZones, a.SynthesizePrivateServiceZones(peerID)...) - - for _, nsGroup := range a.NameServerGroups { - if nsGroup.Enabled { - for _, gID := range nsGroup.Groups { - if _, found := relevantGroups[gID]; found { - components.NameServerGroups = append(components.NameServerGroups, nsGroup) - break - } - } - } - } - - for _, resource := range a.NetworkResources { - if !resource.Enabled { - continue - } - - policies, exists := resourcePolicies[resource.ID] - if !exists { - continue - } - - addSourcePeers := false - - networkRoutingPeers, routerExists := routers[resource.NetworkID] - if routerExists { - if _, ok := networkRoutingPeers[peerID]; ok { - addSourcePeers = true - } - } - - for _, policy := range policies { - if addSourcePeers { - var peers []string - if policy.Rules[0].SourceResource.Type == ResourceTypePeer && policy.Rules[0].SourceResource.ID != "" { - peers = []string{policy.Rules[0].SourceResource.ID} - } else { - peers = a.getUniquePeerIDsFromGroupsIDs(ctx, policy.SourceGroups()) - } - for _, pID := range a.getPostureValidPeersSaveFailed(peers, policy.SourcePostureChecks, validatedPeersMap, &components.PostureFailedPeers) { - if _, exists := components.Peers[pID]; !exists { - components.Peers[pID] = a.GetPeer(pID).ToComponent() - } - } - } else { - peerInSources := false - if policy.Rules[0].SourceResource.Type == ResourceTypePeer && policy.Rules[0].SourceResource.ID != "" { - peerInSources = policy.Rules[0].SourceResource.ID == peerID - } else { - for _, groupID := range policy.SourceGroups() { - if group := a.GetGroup(groupID); group != nil && slices.Contains(group.Peers, peerID) { - peerInSources = true - break - } - } - } - if !peerInSources { - continue - } - isValid, pname := a.validatePostureChecksOnPeerGetFailed(ctx, policy.SourcePostureChecks, peerID) - if !isValid && len(pname) > 0 { - if _, ok := components.PostureFailedPeers[pname]; !ok { - components.PostureFailedPeers[pname] = make(map[string]struct{}) - } - components.PostureFailedPeers[pname][peer.ID] = struct{}{} - continue - } - addSourcePeers = true - } - - for _, rule := range policy.Rules { - for _, srcGroupID := range rule.Sources { - if g := a.Groups[srcGroupID]; g != nil { - if _, exists := components.Groups[srcGroupID]; !exists { - components.Groups[srcGroupID] = g.ToComponent() - } - } - } - for _, dstGroupID := range rule.Destinations { - if g := a.Groups[dstGroupID]; g != nil { - if _, exists := components.Groups[dstGroupID]; !exists { - components.Groups[dstGroupID] = g.ToComponent() - } - } - } - } - components.ResourcePoliciesMap[resource.ID] = policies - } - - // Only expose router peers and the per-network routers_map when this - // target peer actually has access to the resource (either as a router - // itself or via a policy that includes it as a source). Without this - // gate, every peer's envelope was leaking router peers of every - // network in the account — accounts with many tenants/networks - // shipped tens of unrelated peers in `peers[]` and `routers_map`. - if addSourcePeers { - components.RoutersMap[resource.NetworkID] = routerTypes.ToComponentMap(networkRoutingPeers) - for peerIDKey := range networkRoutingPeers { - if p := a.Peers[peerIDKey]; p != nil { - cp := components.RouterPeers[peerIDKey] - if cp == nil { - cp = p.ToComponent() - components.RouterPeers[peerIDKey] = cp - } - if _, exists := components.Peers[peerIDKey]; !exists { - if _, validated := validatedPeersMap[peerIDKey]; validated { - components.Peers[peerIDKey] = cp - } - } - } - } - components.NetworkResources = append(components.NetworkResources, resource.ToComponent()) - } - } - - filterGroupPeers(&components.Groups, components.Peers) - filterPostureFailedPeers(&components.PostureFailedPeers, components.Policies, components.ResourcePoliciesMap, components.Peers) - - return components -} - -type sshRequirements struct { - neededGroupIDs map[string]struct{} - needAllowedUserIDs bool -} - -func (a *Account) getPeersGroupsPoliciesRoutes( - ctx context.Context, - peerID string, - peerSSHEnabled bool, - validatedPeersMap map[string]struct{}, - postureFailedPeers *map[string]map[string]struct{}, -) (map[string]*ComponentPeer, map[string]*Group, []*Policy, []*route.Route, sshRequirements) { - relevantPeerIDs := make(map[string]*ComponentPeer, len(a.Peers)/4) - relevantGroupIDs := make(map[string]*Group, len(a.Groups)/4) - relevantPolicies := make([]*Policy, 0, len(a.Policies)) - relevantRoutes := make([]*route.Route, 0, len(a.Routes)) - sshReqs := sshRequirements{neededGroupIDs: make(map[string]struct{})} - - relevantPeerIDs[peerID] = a.GetPeer(peerID).ToComponent() - - peerGroupSet := make(map[string]struct{}, 8) - for groupID, group := range a.Groups { - if slices.Contains(group.Peers, peerID) { - relevantGroupIDs[groupID] = a.GetGroup(groupID) - peerGroupSet[groupID] = struct{}{} - } - } - - routeAccessControlGroups := make(map[string]struct{}) - for _, r := range a.Routes { - if r == nil { - continue - } - relevant := r.Peer == peerID - if !relevant { - for _, groupID := range r.PeerGroups { - if _, ok := peerGroupSet[groupID]; ok { - relevant = true - break - } - } - } - if !relevant && r.Enabled { - for _, groupID := range r.Groups { - if _, ok := peerGroupSet[groupID]; ok { - relevant = true - break - } - } - } - if !relevant { - continue - } - - for _, groupID := range r.PeerGroups { - relevantGroupIDs[groupID] = a.GetGroup(groupID) - } - for _, groupID := range r.Groups { - relevantGroupIDs[groupID] = a.GetGroup(groupID) - } - if r.Enabled { - for _, groupID := range r.AccessControlGroups { - relevantGroupIDs[groupID] = a.GetGroup(groupID) - routeAccessControlGroups[groupID] = struct{}{} - } - } - - // Include route advertisers in relevantPeerIDs. The envelope - // encoder writes route.peer_index by looking up r.Peer in the - // shipped peers list; if the advertiser is policy-isolated from - // the target peer (no rule edge between them), it would otherwise - // be omitted and the decoder would fail to resolve r.Peer, leaving - // the client without a WG tunnel target for this route. Legacy - // NetworkMap.Routes shipped the WG public key inline, so the - // equivalence path doesn't surface this — but the dependency is - // real once a client actually tries to use the route. - // Gate by validatedPeersMap so non-validated advertisers stay out - // (matches the network-resource router behaviour at the bottom of - // this loop, and the legacy invariant that only validated peers - // reach a client's view). - if r.Peer != "" { - if _, ok := validatedPeersMap[r.Peer]; ok { - if p := a.GetPeer(r.Peer); p != nil { - relevantPeerIDs[r.Peer] = p.ToComponent() - } - } - } - for _, groupID := range r.PeerGroups { - g := a.GetGroup(groupID) - if g == nil { - continue - } - for _, pid := range g.Peers { - if _, exists := relevantPeerIDs[pid]; exists { - continue - } - if _, ok := validatedPeersMap[pid]; !ok { - continue - } - if p := a.GetPeer(pid); p != nil { - relevantPeerIDs[pid] = p.ToComponent() - } - } - } - relevantRoutes = append(relevantRoutes, r) - } - - for _, policy := range a.Policies { - if !policy.Enabled { - continue - } - - policyRelevant := false - for _, rule := range policy.Rules { - if !rule.Enabled { - continue - } - - if len(routeAccessControlGroups) > 0 { - for _, destGroupID := range rule.Destinations { - if _, needed := routeAccessControlGroups[destGroupID]; needed { - policyRelevant = true - for _, srcGroupID := range rule.Sources { - relevantGroupIDs[srcGroupID] = a.GetGroup(srcGroupID) - } - for _, dstGroupID := range rule.Destinations { - relevantGroupIDs[dstGroupID] = a.GetGroup(dstGroupID) - } - break - } - } - } - - var sourcePeers, destinationPeers []string - var peerInSources, peerInDestinations bool - - if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" { - sourcePeers = []string{rule.SourceResource.ID} - if rule.SourceResource.ID == peerID { - peerInSources = true - } - } else { - sourcePeers, peerInSources = a.getPeersFromGroups(ctx, rule.Sources, peerID, policy.SourcePostureChecks, validatedPeersMap, postureFailedPeers) - } - - if rule.DestinationResource.Type == ResourceTypePeer && rule.DestinationResource.ID != "" { - destinationPeers = []string{rule.DestinationResource.ID} - if rule.DestinationResource.ID == peerID { - peerInDestinations = true - } - } else { - destinationPeers, peerInDestinations = a.getPeersFromGroups(ctx, rule.Destinations, peerID, nil, validatedPeersMap, postureFailedPeers) - } - - if peerInSources { - policyRelevant = true - for _, pid := range destinationPeers { - if _, exists := relevantPeerIDs[pid]; !exists { - relevantPeerIDs[pid] = a.GetPeer(pid).ToComponent() - } - } - for _, dstGroupID := range rule.Destinations { - relevantGroupIDs[dstGroupID] = a.GetGroup(dstGroupID) - } - } - - if peerInDestinations { - policyRelevant = true - for _, pid := range sourcePeers { - if _, exists := relevantPeerIDs[pid]; !exists { - relevantPeerIDs[pid] = a.GetPeer(pid).ToComponent() - } - } - for _, srcGroupID := range rule.Sources { - relevantGroupIDs[srcGroupID] = a.GetGroup(srcGroupID) - } - - if rule.Protocol == PolicyRuleProtocolNetbirdSSH { - switch { - case len(rule.AuthorizedGroups) > 0: - for groupID := range rule.AuthorizedGroups { - sshReqs.neededGroupIDs[groupID] = struct{}{} - } - case rule.AuthorizedUser != "": - default: - sshReqs.needAllowedUserIDs = true - } - } else if PolicyRuleImpliesLegacySSH(rule) && peerSSHEnabled { - sshReqs.needAllowedUserIDs = true - } - } - } - if policyRelevant { - relevantPolicies = append(relevantPolicies, policy) - } - } - - return relevantPeerIDs, relevantGroupIDs, relevantPolicies, relevantRoutes, sshReqs -} - -func (a *Account) getPeersFromGroups(ctx context.Context, groups []string, peerID string, sourcePostureChecksIDs []string, - validatedPeersMap map[string]struct{}, postureFailedPeers *map[string]map[string]struct{}) ([]string, bool) { - peerInGroups := false - var filteredPeerIDs []string - var seenPeerIds map[string]struct{} - - for _, gid := range groups { - group := a.GetGroup(gid) - if group == nil { - continue - } - - if group.IsGroupAll() || len(groups) == 1 { - filteredPeerIDs = make([]string, 0, len(group.Peers)) - peerInGroups = false - for _, pid := range group.Peers { - peer, ok := a.Peers[pid] - if !ok || peer == nil { - continue - } - - if _, ok := validatedPeersMap[peer.ID]; !ok { - continue - } - - isValid, pname := a.validatePostureChecksOnPeerGetFailed(ctx, sourcePostureChecksIDs, peer.ID) - if !isValid && len(pname) > 0 { - if _, ok := (*postureFailedPeers)[pname]; !ok { - (*postureFailedPeers)[pname] = make(map[string]struct{}) - } - (*postureFailedPeers)[pname][peer.ID] = struct{}{} - continue - } - - if peer.ID == peerID { - peerInGroups = true - continue - } - - filteredPeerIDs = append(filteredPeerIDs, peer.ID) - } - return filteredPeerIDs, peerInGroups - } - - if seenPeerIds == nil { - totalGroupPeers := 0 - for _, g := range groups { - if grp := a.GetGroup(g); grp != nil { - totalGroupPeers += len(grp.Peers) - } - } - filteredPeerIDs = make([]string, 0, totalGroupPeers) - seenPeerIds = make(map[string]struct{}, totalGroupPeers) - } - - for _, pid := range group.Peers { - if _, seen := seenPeerIds[pid]; seen { - continue - } - seenPeerIds[pid] = struct{}{} - peer, ok := a.Peers[pid] - if !ok || peer == nil { - continue - } - - if _, ok := validatedPeersMap[peer.ID]; !ok { - continue - } - - isValid, pname := a.validatePostureChecksOnPeerGetFailed(ctx, sourcePostureChecksIDs, peer.ID) - if !isValid && len(pname) > 0 { - if _, ok := (*postureFailedPeers)[pname]; !ok { - (*postureFailedPeers)[pname] = make(map[string]struct{}) - } - (*postureFailedPeers)[pname][peer.ID] = struct{}{} - continue - } - - if peer.ID == peerID { - peerInGroups = true - continue - } - - filteredPeerIDs = append(filteredPeerIDs, peer.ID) - } - } - - return filteredPeerIDs, peerInGroups -} - -func (a *Account) validatePostureChecksOnPeerGetFailed(ctx context.Context, sourcePostureChecksID []string, peerID string) (bool, string) { - peer, ok := a.Peers[peerID] - if !ok || peer == nil { - return false, "" - } - - for _, postureChecksID := range sourcePostureChecksID { - if valid, cached := a.cachedPostureCheckResult(postureChecksID, peerID); cached { - if !valid { - return false, postureChecksID - } - continue - } - - postureChecks := a.GetPostureChecks(postureChecksID) - if postureChecks == nil { - continue - } - - if !peerPassesPostureChecks(ctx, postureChecks.GetChecks(), peer) { - return false, postureChecksID - } - } - return true, "" + nmd := a.toNetworkMapData(accountZones, validatedPeersMap, resourcePolicies, routers, groupIDToUserIDs) + return nmd.GetPeerNetworkMapComponents(peerID, TwinCustomZone(peersCustomZone)) } // PrecomputePostureValidation evaluates every posture check referenced by an enabled -// policy once against the peers of that policy's source groups and stores the results, -// so the per-peer network map calculations that follow look them up instead of -// re-evaluating checks for every peer pair. It must be called before the account is -// shared across goroutines; lookups not covered by the precomputed results fall back -// to direct evaluation. +// policy once and stores the results on the account, so the per-peer components +// calculations that follow look them up instead of re-evaluating checks for every +// peer pair. The evaluation itself runs on the twin store; every twin built from +// this account afterwards inherits the results. It must be called before the +// account is shared across goroutines. func (a *Account) PrecomputePostureValidation(ctx context.Context) { - if len(a.PostureChecks) == 0 { - a.PostureValidation = nil - return - } - - checkPeerIDs := make(map[string]map[string]struct{}) - for _, policy := range a.Policies { - if !policy.Enabled || len(policy.SourcePostureChecks) == 0 { - continue - } - - peerIDs := a.getUniquePeerIDsFromGroupsIDs(ctx, policy.SourceGroups()) - for _, rule := range policy.Rules { - if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" { - peerIDs = append(peerIDs, rule.SourceResource.ID) - } - } - - for _, postureChecksID := range policy.SourcePostureChecks { - set := checkPeerIDs[postureChecksID] - if set == nil { - set = make(map[string]struct{}, len(peerIDs)) - checkPeerIDs[postureChecksID] = set - } - for _, pid := range peerIDs { - set[pid] = struct{}{} - } - } - } - - results := make(map[string]map[string]bool, len(checkPeerIDs)) - for postureChecksID, peerIDs := range checkPeerIDs { - results[postureChecksID] = a.evaluatePostureChecksForPeers(ctx, postureChecksID, peerIDs) - } - a.PostureValidation = results -} - -func (a *Account) evaluatePostureChecksForPeers(ctx context.Context, postureChecksID string, peerIDs map[string]struct{}) map[string]bool { - postureChecks := a.GetPostureChecks(postureChecksID) - if postureChecks == nil { - return nil - } - - checks := postureChecks.GetChecks() - results := make(map[string]bool, len(peerIDs)) - for peerID := range peerIDs { - peer, ok := a.Peers[peerID] - if !ok || peer == nil { - continue - } - results[peerID] = peerPassesPostureChecks(ctx, checks, peer) - } - return results -} - -func (a *Account) cachedPostureCheckResult(postureChecksID, peerID string) (bool, bool) { - results, ok := a.PostureValidation[postureChecksID] - if !ok { - return false, false - } - if results == nil { - return true, true - } - valid, found := results[peerID] - return valid, found -} - -func peerPassesPostureChecks(ctx context.Context, checks []posture.Check, peer *nbpeer.Peer) bool { - for _, check := range checks { - isValid, _ := check.Check(ctx, *peer) - if !isValid { - return false - } - } - return true -} - -func (a *Account) getPostureValidPeersSaveFailed(inputPeers []string, postureChecksIDs []string, validatedPeersMap map[string]struct{}, postureFailedPeers *map[string]map[string]struct{}) []string { - var dest []string - for _, peerID := range inputPeers { - if _, validated := validatedPeersMap[peerID]; !validated { - continue - } - valid, pname := a.validatePostureChecksOnPeerGetFailed(context.Background(), postureChecksIDs, peerID) - if valid { - dest = append(dest, peerID) - continue - } - if _, ok := (*postureFailedPeers)[pname]; !ok { - (*postureFailedPeers)[pname] = make(map[string]struct{}) - } - (*postureFailedPeers)[pname][peerID] = struct{}{} - } - return dest -} - -// filterGroupPeers trims each group's Peers slice to only those peers that -// also appear in `peers`. Groups whose filtered list is empty are NOT -// deleted from the map — they're kept so the components wire encoder can -// still resolve seq references from routes/policies/access-control groups -// that name them. Calculate() tolerates groups with empty Peers (the inner -// loops simply iterate zero times), so retaining them is behaviourally a -// no-op for the legacy path that consumes the same NetworkMapComponents. -func filterGroupPeers(groups *map[string]*ComponentGroup, peers map[string]*ComponentPeer) { - for groupID, groupInfo := range *groups { - filteredPeers := make([]string, 0, len(groupInfo.Peers)) - for _, pid := range groupInfo.Peers { - if _, exists := peers[pid]; exists { - filteredPeers = append(filteredPeers, pid) - } - } - - if len(filteredPeers) != len(groupInfo.Peers) { - ng := *groupInfo - ng.Peers = filteredPeers - (*groups)[groupID] = &ng - } - } -} - -func filterPostureFailedPeers(postureFailedPeers *map[string]map[string]struct{}, policies []*Policy, resourcePoliciesMap map[string][]*Policy, peers map[string]*ComponentPeer) { - if len(*postureFailedPeers) == 0 { - return - } - - referencedPostureChecks := make(map[string]struct{}) - for _, policy := range policies { - for _, checkID := range policy.SourcePostureChecks { - referencedPostureChecks[checkID] = struct{}{} - } - } - for _, resPolicies := range resourcePoliciesMap { - for _, policy := range resPolicies { - for _, checkID := range policy.SourcePostureChecks { - referencedPostureChecks[checkID] = struct{}{} - } - } - } - - for checkID, failedPeers := range *postureFailedPeers { - if _, referenced := referencedPostureChecks[checkID]; !referenced { - delete(*postureFailedPeers, checkID) - continue - } - for peerID := range failedPeers { - if _, exists := peers[peerID]; !exists { - delete(failedPeers, peerID) - } - } - if len(failedPeers) == 0 { - delete(*postureFailedPeers, checkID) - } - } -} - -func filterDNSRecordsByPeers(records []nbdns.SimpleRecord, peers map[string]*ComponentPeer, includeIPv6 bool) []nbdns.SimpleRecord { - if len(records) == 0 || len(peers) == 0 { - return nil - } - - // Include both v4 and v6 addresses so AAAA records (whose RData is an IPv6 - // address) are not filtered out when peers have IPv6 assigned. When the - // requesting peer doesn't have IPv6, omit v6 IPs so AAAA records get dropped. - peerIPs := make(map[string]struct{}, len(peers)*2) - for _, peer := range peers { - if peer == nil { - continue - } - peerIPs[peer.IP.String()] = struct{}{} - if includeIPv6 && peer.IPv6.IsValid() { - peerIPs[peer.IPv6.String()] = struct{}{} - } - } - - filteredRecords := make([]nbdns.SimpleRecord, 0, len(records)) - for _, record := range records { - if _, exists := peerIPs[record.RData]; exists { - filteredRecords = append(filteredRecords, record) - } - } - - return filteredRecords -} - -func filterGroupIDToUserIDs(fullMap map[string][]string, neededGroupIDs map[string]struct{}) map[string][]string { - if len(neededGroupIDs) == 0 { - return nil - } - - filtered := make(map[string][]string, len(neededGroupIDs)) - for groupID := range neededGroupIDs { - if users, ok := fullMap[groupID]; ok { - filtered[groupID] = users - } - } - return filtered + nmd := a.toNetworkMapData(nil, nil, nil, nil, nil) + nmd.PrecomputePostureValidation() + a.PostureValidation = nmd.PostureValidation } diff --git a/management/server/types/account_components_test.go b/management/server/types/account_components_test.go index 3574480e8..99f5f9b72 100644 --- a/management/server/types/account_components_test.go +++ b/management/server/types/account_components_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" "github.com/netbirdio/netbird/shared/management/types" "github.com/stretchr/testify/assert" ) @@ -14,7 +15,9 @@ func TestGetPeerNetworkMapComponents_PeerMissingFromAcount(t *testing.T) { nmapcomponets := account.GetPeerNetworkMapComponents(context.TODO(), "missing-peer", dns.CustomZone{}, nil, nil, nil, nil, nil) assert.Equal(t, EmptyNetworkMapComponents(&types.NetworkMapComponents{ - PeerID: "missing-peer", - Network: account.Network, + PeerID: "missing-peer", + Network: TwinNetwork(account.Network), + Peers: map[string]*nmdata.Peer{"missing-peer": nil}, + ForceRoutingPeerDNSResolution: false, }), nmapcomponets) } diff --git a/management/server/types/account_networkmapdata.go b/management/server/types/account_networkmapdata.go new file mode 100644 index 000000000..8f2e03a10 --- /dev/null +++ b/management/server/types/account_networkmapdata.go @@ -0,0 +1,613 @@ +package types + +import ( + "github.com/miekg/dns" + + nbdns "github.com/netbirdio/netbird/dns" + "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" + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" + routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/posture" + nbroute "github.com/netbirdio/netbird/route" + "github.com/netbirdio/netbird/shared/management/networkmap" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" +) + +// toNetworkMapData builds the slim twin store from the account once per +// account. The per-peer components calculation then runs on the twin. +func (a *Account) toNetworkMapData( + accountZones []*zones.Zone, + validatedPeersMap map[string]struct{}, + resourcePolicies map[string][]*Policy, + routers map[string]map[string]*routerTypes.NetworkRouter, + groupIDToUserIDs map[string][]string, +) *networkmap.NetworkMapData { + nmd := &networkmap.NetworkMapData{ + Peers: make(map[string]*nmdata.Peer, len(a.Peers)), + Groups: make(map[string]*nmdata.Group, len(a.Groups)), + Policies: make([]*nmdata.Policy, 0, len(a.Policies)), + Routes: make([]*nmdata.Route, 0, len(a.Routes)), + NameServerGroups: make([]*nmdata.NameServerGroup, 0, len(a.NameServerGroups)), + NetworkResources: make([]*nmdata.NetworkResource, 0, len(a.NetworkResources)), + PostureChecks: make(map[string]*nmdata.PostureChecks, len(a.PostureChecks)), + ResourcePolicies: make(map[string][]*nmdata.Policy, len(resourcePolicies)), + Routers: make(map[string]map[string]*nmdata.NetworkRouter, len(routers)), + ValidatedPeers: validatedPeersMap, + GroupIDToUserIDs: groupIDToUserIDs, + PostureValidation: a.PostureValidation, + AllowedUserIDs: a.getAllowedUserIDs(), + NetworkXIDToPublicID: make(map[string]string, len(a.Networks)), + PostureCheckXIDToPublicID: make(map[string]string, len(a.PostureChecks)), + } + + if a.Network != nil { + nmd.Network = TwinNetwork(a.Network) + } + nmd.DNSSettings = &nmdata.DNSSettings{DisabledManagementGroups: a.DNSSettings.DisabledManagementGroups} + nmd.AccountSettings = TwinAccountSettings(a.Settings) + + for id, p := range a.Peers { + nmd.Peers[id] = twinPeer(p) + } + for id, g := range a.Groups { + nmd.Groups[id] = twinGroup(g) + } + + policyCache := make(map[string]*nmdata.Policy, len(a.Policies)) + twinPol := func(p *Policy) *nmdata.Policy { + if p == nil { + return nil + } + if tp, ok := policyCache[p.ID]; ok { + return tp + } + tp := twinPolicy(p) + policyCache[p.ID] = tp + return tp + } + for _, p := range a.Policies { + nmd.Policies = append(nmd.Policies, twinPol(p)) + } + for resID, pols := range resourcePolicies { + twinPols := make([]*nmdata.Policy, 0, len(pols)) + for _, p := range pols { + twinPols = append(twinPols, twinPol(p)) + } + nmd.ResourcePolicies[resID] = twinPols + } + + for _, r := range a.Routes { + if r == nil { + continue + } + nmd.Routes = append(nmd.Routes, twinRoute(r)) + } + for _, nsg := range a.NameServerGroups { + nmd.NameServerGroups = append(nmd.NameServerGroups, twinNSG(nsg)) + } + for _, res := range a.NetworkResources { + nmd.NetworkResources = append(nmd.NetworkResources, TwinNetworkResource(res)) + } + for _, pc := range a.PostureChecks { + if pc != nil { + nmd.PostureChecks[pc.ID] = twinPostureChecks(pc) + nmd.PostureCheckXIDToPublicID[pc.ID] = pc.PublicID + } + } + for _, n := range a.Networks { + if n != nil { + nmd.NetworkXIDToPublicID[n.ID] = n.PublicID + } + } + for networkID, inner := range routers { + twinInner := make(map[string]*nmdata.NetworkRouter, len(inner)) + for peerID, router := range inner { + twinInner[peerID] = twinRouter(router) + } + nmd.Routers[networkID] = twinInner + } + + nmd.ProxyTargetedDomainResourceIDs = a.proxyTargetedDomainResourceIDs() + nmd.AppliedZoneCandidates = buildAppliedZoneCandidates(accountZones) + nmd.PrivateServiceCandidates = a.buildPrivateServiceCandidates() + nmd.Services = TwinServices(a.Services) + + return nmd +} + +// TwinServices converts reverse-proxy services to their slim nmdata twins. +// Exported for the network-map controller, which hands the store-backed twin +// the same services the account carries. +func TwinServices(services []*service.Service) []*nmdata.Service { + if len(services) == 0 { + return nil + } + out := make([]*nmdata.Service, 0, len(services)) + for _, svc := range services { + if svc == nil { + continue + } + targets := make([]*nmdata.ServiceTarget, 0, len(svc.Targets)) + for _, t := range svc.Targets { + if t == nil { + continue + } + path := "" + if t.Path != nil { + path = *t.Path + } + targets = append(targets, &nmdata.ServiceTarget{ + Enabled: t.Enabled, + Path: path, + Port: t.Port, + Protocol: t.Protocol, + TargetID: t.TargetId, + TargetType: string(t.TargetType), + }) + } + out = append(out, &nmdata.Service{ + ID: svc.ID, + Enabled: svc.Enabled, + Private: svc.Private, + Mode: svc.Mode, + ProxyCluster: svc.ProxyCluster, + AccessGroups: svc.AccessGroups, + Targets: targets, + }) + } + return out +} + +func twinPeer(p *nbpeer.Peer) *nmdata.Peer { + if p == nil { + return nil + } + networkAddresses := make([]nmdata.NetworkAddress, 0, len(p.Meta.NetworkAddresses)) + for _, na := range p.Meta.NetworkAddresses { + networkAddresses = append(networkAddresses, nmdata.NetworkAddress{NetIP: na.NetIP}) + } + files := make([]nmdata.File, 0, len(p.Meta.Files)) + for _, f := range p.Meta.Files { + files = append(files, nmdata.File{Path: f.Path, ProcessIsRunning: f.ProcessIsRunning}) + } + return &nmdata.Peer{ + ID: p.ID, + Key: p.Key, + SSHKey: p.SSHKey, + DNSLabel: p.DNSLabel, + UserID: p.UserID, + SSHEnabled: p.SSHEnabled, + LoginExpirationEnabled: p.LoginExpirationEnabled, + LastLogin: p.LastLogin, + IP: p.IP, + IPv6: p.IPv6, + RequiresApproval: p.Status != nil && p.Status.RequiresApproval, + ExtraDNSLabels: p.ExtraDNSLabels, + ProxyMeta: nmdata.ProxyMeta{Embedded: p.ProxyMeta.Embedded, Cluster: p.ProxyMeta.Cluster}, + Meta: nmdata.PeerSystemMeta{ + WtVersion: p.Meta.WtVersion, + GoOS: p.Meta.GoOS, + OSVersion: p.Meta.OSVersion, + KernelVersion: p.Meta.KernelVersion, + NetworkAddresses: networkAddresses, + Files: files, + Capabilities: p.Meta.Capabilities, + SyncMessageVersion: p.Meta.SyncMessageVersion, + Flags: nmdata.Flags{ + ServerSSHAllowed: p.Meta.Flags.ServerSSHAllowed, + DisableIPv6: p.Meta.Flags.DisableIPv6, + }, + }, + Location: nmdata.PeerLocation{ + CountryCode: p.Location.CountryCode, + CityName: p.Location.CityName, + ConnectionIP: p.Location.ConnectionIP, + }, + } +} + +// TwinPeer converts a real peer to its slim nmdata twin. Exported for the +// port-forwarding integration, which builds proxy NetworkMaps holding twins. +func TwinPeer(p *nbpeer.Peer) *nmdata.Peer { + return twinPeer(p) +} + +// TwinPeers converts real peers to their slim nmdata twins. +func TwinPeers(peers []*nbpeer.Peer) []*nmdata.Peer { + out := make([]*nmdata.Peer, len(peers)) + for i, p := range peers { + out[i] = twinPeer(p) + } + return out +} + +// TwinGroups converts real groups to their slim nmdata twins. +func TwinGroups(groups []*Group) []*nmdata.Group { + out := make([]*nmdata.Group, len(groups)) + for i, g := range groups { + out[i] = twinGroup(g) + } + return out +} + +func twinGroup(g *Group) *nmdata.Group { + if g == nil { + return nil + } + return &nmdata.Group{ + ID: g.ID, + Name: g.Name, + PublicID: g.PublicID, + Peers: g.Peers, + } +} + +func twinPolicy(p *Policy) *nmdata.Policy { + if p == nil { + return nil + } + rules := make([]*nmdata.PolicyRule, 0, len(p.Rules)) + for _, r := range p.Rules { + rules = append(rules, twinRule(r)) + } + return &nmdata.Policy{ + ID: p.ID, + PublicID: p.PublicID, + Enabled: p.Enabled, + SourcePostureChecks: p.SourcePostureChecks, + Rules: rules, + } +} + +func twinRule(r *PolicyRule) *nmdata.PolicyRule { + if r == nil { + return nil + } + var portRanges []nmdata.RulePortRange + if r.PortRanges != nil { + portRanges = make([]nmdata.RulePortRange, len(r.PortRanges)) + for i, pr := range r.PortRanges { + portRanges[i] = nmdata.RulePortRange{Start: pr.Start, End: pr.End} + } + } + return &nmdata.PolicyRule{ + ID: r.ID, + PolicyID: r.PolicyID, + Enabled: r.Enabled, + Action: string(r.Action), + Protocol: string(r.Protocol), + Bidirectional: r.Bidirectional, + Sources: r.Sources, + Destinations: r.Destinations, + SourceResource: nmdata.Resource{ID: r.SourceResource.ID, Type: string(r.SourceResource.Type)}, + DestinationResource: nmdata.Resource{ID: r.DestinationResource.ID, Type: string(r.DestinationResource.Type)}, + Ports: r.Ports, + PortRanges: portRanges, + AuthorizedGroups: r.AuthorizedGroups, + AuthorizedUser: r.AuthorizedUser, + } +} + +func twinRoute(r *nbroute.Route) *nmdata.Route { + return &nmdata.Route{ + ID: string(r.ID), + AccountID: r.AccountID, + PublicID: r.PublicID, + Network: r.Network, + Domains: r.Domains, + KeepRoute: r.KeepRoute, + NetID: string(r.NetID), + Description: r.Description, + Peer: r.Peer, + PeerID: r.PeerID, + PeerGroups: r.PeerGroups, + NetworkType: int(r.NetworkType), + Masquerade: r.Masquerade, + Metric: r.Metric, + Enabled: r.Enabled, + Groups: r.Groups, + AccessControlGroups: r.AccessControlGroups, + SkipAutoApply: r.SkipAutoApply, + } +} + +// TwinRoute converts a real *route.Route to its slim nmdata twin. Exported for +// tests that assert against twin routes returned in a NetworkMap. +func TwinRoute(r *nbroute.Route) *nmdata.Route { + return twinRoute(r) +} + +func TwinNetworkResource(r *resourceTypes.NetworkResource) *nmdata.NetworkResource { + if r == nil { + return nil + } + return &nmdata.NetworkResource{ + ID: r.ID, + NetworkID: r.NetworkID, + AccountID: r.AccountID, + PublicID: r.PublicID, + Name: r.Name, + Description: r.Description, + Type: string(r.Type), + Address: r.Address, + Domain: r.Domain, + Prefix: r.Prefix, + Enabled: r.Enabled, + } +} + +func twinRouter(r *routerTypes.NetworkRouter) *nmdata.NetworkRouter { + if r == nil { + return nil + } + return &nmdata.NetworkRouter{ + PublicID: r.PublicID, + PeerGroups: r.PeerGroups, + Masquerade: r.Masquerade, + Metric: r.Metric, + Enabled: r.Enabled, + } +} + +func twinNSG(n *nbdns.NameServerGroup) *nmdata.NameServerGroup { + if n == nil { + return nil + } + nameServers := make([]nmdata.NameServer, 0, len(n.NameServers)) + for _, ns := range n.NameServers { + nameServers = append(nameServers, nmdata.NameServer{ + IP: ns.IP, + NSType: int(ns.NSType), + Port: ns.Port, + }) + } + return &nmdata.NameServerGroup{ + ID: n.ID, + PublicID: n.PublicID, + Name: n.Name, + Description: n.Description, + NameServers: nameServers, + Groups: n.Groups, + Primary: n.Primary, + Domains: n.Domains, + Enabled: n.Enabled, + SearchDomainsEnabled: n.SearchDomainsEnabled, + } +} + +// TwinNetwork converts a real *Network to its slim twin. Exported for the +// graceful-degrade path that builds a minimal NetworkMapComponents directly. +func TwinNetwork(n *Network) *nmdata.Network { + nc := n.Copy() + return &nmdata.Network{ + Identifier: nc.Identifier, + Net: nc.Net, + NetV6: nc.NetV6, + Dns: nc.Dns, + Serial: int64(nc.Serial), + } +} + +func twinPostureChecks(pc *posture.Checks) *nmdata.PostureChecks { + if pc == nil { + return nil + } + out := &nmdata.PostureChecks{ID: pc.ID} + def := pc.Checks + if def.NBVersionCheck != nil { + out.Checks.NBVersionCheck = &nmdata.NBVersionCheck{MinVersion: def.NBVersionCheck.MinVersion} + } + if def.OSVersionCheck != nil { + oc := &nmdata.OSVersionCheck{} + if def.OSVersionCheck.Android != nil { + oc.Android = &nmdata.MinVersionCheck{MinVersion: def.OSVersionCheck.Android.MinVersion} + } + if def.OSVersionCheck.Darwin != nil { + oc.Darwin = &nmdata.MinVersionCheck{MinVersion: def.OSVersionCheck.Darwin.MinVersion} + } + if def.OSVersionCheck.Ios != nil { + oc.Ios = &nmdata.MinVersionCheck{MinVersion: def.OSVersionCheck.Ios.MinVersion} + } + if def.OSVersionCheck.Linux != nil { + oc.Linux = &nmdata.MinKernelVersionCheck{MinKernelVersion: def.OSVersionCheck.Linux.MinKernelVersion} + } + if def.OSVersionCheck.Windows != nil { + oc.Windows = &nmdata.MinKernelVersionCheck{MinKernelVersion: def.OSVersionCheck.Windows.MinKernelVersion} + } + out.Checks.OSVersionCheck = oc + } + if def.GeoLocationCheck != nil { + gc := &nmdata.GeoLocationCheck{Action: def.GeoLocationCheck.Action} + for _, loc := range def.GeoLocationCheck.Locations { + gc.Locations = append(gc.Locations, nmdata.GeoLocation{CountryCode: loc.CountryCode, CityName: loc.CityName}) + } + out.Checks.GeoLocationCheck = gc + } + if def.PeerNetworkRangeCheck != nil { + out.Checks.PeerNetworkRangeCheck = &nmdata.PeerNetworkRangeCheck{ + Action: def.PeerNetworkRangeCheck.Action, + Ranges: def.PeerNetworkRangeCheck.Ranges, + } + } + if def.ProcessCheck != nil { + procs := make([]nmdata.Process, 0, len(def.ProcessCheck.Processes)) + for _, p := range def.ProcessCheck.Processes { + procs = append(procs, nmdata.Process{LinuxPath: p.LinuxPath, MacPath: p.MacPath, WindowsPath: p.WindowsPath}) + } + out.Checks.ProcessCheck = &nmdata.ProcessCheck{Processes: procs} + } + return out +} + +// buildAppliedZoneCandidates precomputes the account-level custom DNS zones +// (record conversion) once; the per-peer distribution-group gate runs in the +// components calc. Mirrors the account-level half of filterPeerAppliedZones. +func buildAppliedZoneCandidates(accountZones []*zones.Zone) []networkmap.AppliedZoneCandidate { + var out []networkmap.AppliedZoneCandidate + for _, zone := range accountZones { + if !zone.Enabled || len(zone.Records) == 0 { + continue + } + simpleRecords := make([]nmdata.SimpleRecord, 0, len(zone.Records)) + for _, record := range zone.Records { + var recordType int + rData := record.Content + switch record.Type { + case records.RecordTypeA: + recordType = int(dns.TypeA) + case records.RecordTypeAAAA: + recordType = int(dns.TypeAAAA) + case records.RecordTypeCNAME: + recordType = int(dns.TypeCNAME) + rData = dns.Fqdn(record.Content) + default: + continue + } + simpleRecords = append(simpleRecords, nmdata.SimpleRecord{ + Name: dns.Fqdn(record.Name), + Type: recordType, + Class: nbdns.DefaultClass, + TTL: record.TTL, + RData: rData, + }) + } + out = append(out, networkmap.AppliedZoneCandidate{ + DistributionGroups: zone.DistributionGroups, + Zone: nmdata.CustomZone{ + Domain: dns.Fqdn(zone.Domain), + Records: simpleRecords, + SearchDomainDisabled: !zone.EnableSearchDomain, + NonAuthoritative: true, + }, + }) + } + return out +} + +// buildPrivateServiceCandidates precomputes the connected-proxy A records per +// private service (account-level); the per-peer access-group gate + apex merge +// run in the components calc. Mirrors the account-level half of +// SynthesizePrivateServiceZones. +func (a *Account) buildPrivateServiceCandidates() []networkmap.PrivateServiceCandidate { + if len(a.Services) == 0 { + return nil + } + proxyPeersByCluster := a.GetProxyPeers() + if len(proxyPeersByCluster) == 0 { + return nil + } + + var out []networkmap.PrivateServiceCandidate + for _, svc := range a.Services { + if svc == nil || !svc.Enabled || !svc.Private { + continue + } + if len(svc.AccessGroups) == 0 { + continue + } + proxyPeers := proxyPeersByCluster[svc.ProxyCluster] + if len(proxyPeers) == 0 { + continue + } + apex := a.privateServiceDomainZone(svc) + if apex == "" { + continue + } + + var recs []nmdata.SimpleRecord + for _, p := range proxyPeers { + if p == nil || !p.IP.IsValid() { + continue + } + if p.Status == nil || !p.Status.Connected { + continue + } + recs = append(recs, nmdata.SimpleRecord{ + Name: dns.Fqdn(svc.Domain), + Type: int(dns.TypeA), + Class: nbdns.DefaultClass, + TTL: privateServiceDNSRecordTTL, + RData: p.IP.String(), + }) + } + if len(recs) == 0 { + continue + } + + out = append(out, networkmap.PrivateServiceCandidate{ + AccessGroups: svc.AccessGroups, + Zone: nmdata.CustomZone{ + Domain: dns.Fqdn(apex), + Records: recs, + NonAuthoritative: true, + SearchDomainDisabled: true, + }, + }) + } + return out +} + +// TwinAccountSettings converts real account settings to the slim nmdata twin. +// Exported for callers of the twin-based sync response builders. +func TwinAccountSettings(s *Settings) *nmdata.AccountSettingsInfo { + if s == nil { + return nil + } + return &nmdata.AccountSettingsInfo{ + PeerLoginExpirationEnabled: s.PeerLoginExpirationEnabled, + PeerLoginExpiration: s.PeerLoginExpiration, + PeerInactivityExpirationEnabled: s.PeerInactivityExpirationEnabled, + PeerInactivityExpiration: s.PeerInactivityExpiration, + DNSDomain: s.DNSDomain, + IPv6EnabledGroups: s.IPv6EnabledGroups, + RoutingPeerDNSResolutionEnabled: s.RoutingPeerDNSResolutionEnabled, + LazyConnectionEnabled: s.LazyConnectionEnabled, + AutoUpdateVersion: s.AutoUpdateVersion, + AutoUpdateAlways: s.AutoUpdateAlways, + MetricsPushEnabled: s.MetricsPushEnabled, + } +} + +func fromTwinCustomZone(z nmdata.CustomZone) nbdns.CustomZone { + records := make([]nbdns.SimpleRecord, 0, len(z.Records)) + for _, r := range z.Records { + records = append(records, nbdns.SimpleRecord{ + Name: r.Name, + Type: r.Type, + Class: r.Class, + TTL: r.TTL, + RData: r.RData, + }) + } + return nbdns.CustomZone{ + Domain: z.Domain, + Records: records, + SearchDomainDisabled: z.SearchDomainDisabled, + NonAuthoritative: z.NonAuthoritative, + } +} + +// TwinCustomZone converts a real DNS custom zone to its slim nmdata twin. +// Exported for the network-map controller's DB-store path, which feeds real +// zones into the twin-based components calculation. +func TwinCustomZone(z nbdns.CustomZone) nmdata.CustomZone { + records := make([]nmdata.SimpleRecord, 0, len(z.Records)) + for _, r := range z.Records { + records = append(records, nmdata.SimpleRecord{ + Name: r.Name, + Type: r.Type, + Class: r.Class, + TTL: r.TTL, + RData: r.RData, + }) + } + return nmdata.CustomZone{ + Domain: z.Domain, + Records: records, + SearchDomainDisabled: z.SearchDomainDisabled, + NonAuthoritative: z.NonAuthoritative, + } +} diff --git a/management/server/types/account_private_netmap_test.go b/management/server/types/account_private_netmap_test.go index 11b3d985a..5dccfbf30 100644 --- a/management/server/types/account_private_netmap_test.go +++ b/management/server/types/account_private_netmap_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" ) func TestPrivateService_NetworkMap_UserPeer_AndProxyPeer(t *testing.T) { @@ -17,7 +18,6 @@ func TestPrivateService_NetworkMap_UserPeer_AndProxyPeer(t *testing.T) { account.Peers["proxy-peer"].Meta.WtVersion = "0.50.0" ctx := context.Background() - account.InjectProxyPolicies(ctx) validated := map[string]struct{}{ "user-peer": {}, @@ -48,7 +48,7 @@ func TestPrivateService_NetworkMap_UserPeer_AndProxyPeer(t *testing.T) { }) } -func netmapPeerIDs(peers []*ComponentPeer) []string { +func netmapPeerIDs(peers []*nmdata.Peer) []string { ids := make([]string, 0, len(peers)) for _, p := range peers { if p == nil { diff --git a/management/server/types/account_test.go b/management/server/types/account_test.go index 80f2a950a..063b2d7e7 100644 --- a/management/server/types/account_test.go +++ b/management/server/types/account_test.go @@ -5,6 +5,7 @@ import ( "fmt" "net" "net/netip" + "strings" "testing" "github.com/miekg/dns" @@ -13,13 +14,12 @@ import ( nbdns "github.com/netbirdio/netbird/dns" "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" resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" networkTypes "github.com/netbirdio/netbird/management/server/networks/types" nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/route" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" ) func setupTestAccount() *Account { @@ -666,7 +666,7 @@ func Test_ExpandPortsAndRanges_SSHRuleExpansion(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := ExpandPortsAndRanges(tt.base, tt.rule, tt.peer.ToComponent()) + result := ExpandPortsAndRanges(tt.base, tt.rule, tt.peer) var ports []string for _, fr := range result { @@ -1040,518 +1040,6 @@ func Test_FilterZoneRecordsForPeers(t *testing.T) { } } -func Test_filterPeerAppliedZones(t *testing.T) { - ctx := context.Background() - - tests := []struct { - name string - accountZones []*zones.Zone - peerGroups LookupMap - expected []nbdns.CustomZone - }{ - { - name: "empty peer groups returns empty custom zones", - accountZones: []*zones.Zone{}, - peerGroups: LookupMap{}, - expected: []nbdns.CustomZone{}, - }, - { - name: "peer has access to zone with A record", - accountZones: []*zones.Zone{ - { - ID: "zone1", - Domain: "example.com", - Enabled: true, - EnableSearchDomain: false, - DistributionGroups: []string{"group1"}, - Records: []*records.Record{ - { - ID: "record1", - Name: "www.example.com", - Type: records.RecordTypeA, - Content: "192.168.1.1", - TTL: 300, - }, - }, - }, - }, - peerGroups: LookupMap{"group1": struct{}{}}, - expected: []nbdns.CustomZone{ - { - Domain: "example.com.", - Records: []nbdns.SimpleRecord{ - { - Name: "www.example.com.", - Type: int(dns.TypeA), - Class: nbdns.DefaultClass, - TTL: 300, - RData: "192.168.1.1", - }, - }, - SearchDomainDisabled: true, - }, - }, - }, - { - name: "peer has access to zone with search domain enabled", - accountZones: []*zones.Zone{ - { - ID: "zone1", - Domain: "internal.local", - Enabled: true, - EnableSearchDomain: true, - DistributionGroups: []string{"group1"}, - Records: []*records.Record{ - { - ID: "record1", - Name: "api.internal.local", - Type: records.RecordTypeA, - Content: "10.0.0.1", - TTL: 600, - }, - }, - }, - }, - peerGroups: LookupMap{"group1": struct{}{}}, - expected: []nbdns.CustomZone{ - { - Domain: "internal.local.", - Records: []nbdns.SimpleRecord{ - { - Name: "api.internal.local.", - Type: int(dns.TypeA), - Class: nbdns.DefaultClass, - TTL: 600, - RData: "10.0.0.1", - }, - }, - SearchDomainDisabled: false, - }, - }, - }, - { - name: "peer has no access to zone", - accountZones: []*zones.Zone{ - { - ID: "zone1", - Domain: "private.com", - Enabled: true, - EnableSearchDomain: false, - DistributionGroups: []string{"group2"}, - Records: []*records.Record{ - { - ID: "record1", - Name: "secret.private.com", - Type: records.RecordTypeA, - Content: "192.168.1.1", - TTL: 300, - }, - }, - }, - }, - peerGroups: LookupMap{"group1": struct{}{}}, - expected: []nbdns.CustomZone{}, - }, - { - name: "disabled zone is filtered out", - accountZones: []*zones.Zone{ - { - ID: "zone1", - Domain: "disabled.com", - Enabled: false, - EnableSearchDomain: false, - DistributionGroups: []string{"group1"}, - Records: []*records.Record{ - { - ID: "record1", - Name: "www.disabled.com", - Type: records.RecordTypeA, - Content: "192.168.1.1", - TTL: 300, - }, - }, - }, - }, - peerGroups: LookupMap{"group1": struct{}{}}, - expected: []nbdns.CustomZone{}, - }, - { - name: "zone with no records is filtered out", - accountZones: []*zones.Zone{ - { - ID: "zone1", - Domain: "empty.com", - Enabled: true, - EnableSearchDomain: false, - DistributionGroups: []string{"group1"}, - Records: []*records.Record{}, - }, - }, - peerGroups: LookupMap{"group1": struct{}{}}, - expected: []nbdns.CustomZone{}, - }, - { - name: "peer has access via multiple groups", - accountZones: []*zones.Zone{ - { - ID: "zone1", - Domain: "multi.com", - Enabled: true, - EnableSearchDomain: false, - DistributionGroups: []string{"group1", "group2", "group3"}, - Records: []*records.Record{ - { - ID: "record1", - Name: "www.multi.com", - Type: records.RecordTypeA, - Content: "192.168.1.1", - TTL: 300, - }, - }, - }, - }, - peerGroups: LookupMap{"group2": struct{}{}}, - expected: []nbdns.CustomZone{ - { - Domain: "multi.com.", - Records: []nbdns.SimpleRecord{ - { - Name: "www.multi.com.", - Type: int(dns.TypeA), - Class: nbdns.DefaultClass, - TTL: 300, - RData: "192.168.1.1", - }, - }, - SearchDomainDisabled: true, - }, - }, - }, - { - name: "multiple zones with mixed access", - accountZones: []*zones.Zone{ - { - ID: "zone1", - Domain: "allowed.com", - Enabled: true, - EnableSearchDomain: false, - DistributionGroups: []string{"group1"}, - Records: []*records.Record{ - { - ID: "record1", - Name: "www.allowed.com", - Type: records.RecordTypeA, - Content: "192.168.1.1", - TTL: 300, - }, - }, - }, - { - ID: "zone2", - Domain: "denied.com", - Enabled: true, - EnableSearchDomain: false, - DistributionGroups: []string{"group2"}, - Records: []*records.Record{ - { - ID: "record2", - Name: "www.denied.com", - Type: records.RecordTypeA, - Content: "192.168.1.2", - TTL: 300, - }, - }, - }, - }, - peerGroups: LookupMap{"group1": struct{}{}}, - expected: []nbdns.CustomZone{ - { - Domain: "allowed.com.", - Records: []nbdns.SimpleRecord{ - { - Name: "www.allowed.com.", - Type: int(dns.TypeA), - Class: nbdns.DefaultClass, - TTL: 300, - RData: "192.168.1.1", - }, - }, - SearchDomainDisabled: true, - }, - }, - }, - { - name: "zone with multiple record types", - accountZones: []*zones.Zone{ - { - ID: "zone1", - Domain: "mixed.com", - Enabled: true, - EnableSearchDomain: false, - DistributionGroups: []string{"group1"}, - Records: []*records.Record{ - { - ID: "record1", - Name: "www.mixed.com", - Type: records.RecordTypeA, - Content: "192.168.1.1", - TTL: 300, - }, - { - ID: "record2", - Name: "ipv6.mixed.com", - Type: records.RecordTypeAAAA, - Content: "2001:db8::1", - TTL: 600, - }, - { - ID: "record3", - Name: "alias.mixed.com", - Type: records.RecordTypeCNAME, - Content: "www.mixed.com", - TTL: 900, - }, - }, - }, - }, - peerGroups: LookupMap{"group1": struct{}{}}, - expected: []nbdns.CustomZone{ - { - Domain: "mixed.com.", - Records: []nbdns.SimpleRecord{ - { - Name: "www.mixed.com.", - Type: int(dns.TypeA), - Class: nbdns.DefaultClass, - TTL: 300, - RData: "192.168.1.1", - }, - { - Name: "ipv6.mixed.com.", - Type: int(dns.TypeAAAA), - Class: nbdns.DefaultClass, - TTL: 600, - RData: "2001:db8::1", - }, - { - Name: "alias.mixed.com.", - Type: int(dns.TypeCNAME), - Class: nbdns.DefaultClass, - TTL: 900, - RData: "www.mixed.com.", - }, - }, - SearchDomainDisabled: true, - }, - }, - }, - { - name: "multiple zones both accessible", - accountZones: []*zones.Zone{ - { - ID: "zone1", - Domain: "first.com", - Enabled: true, - EnableSearchDomain: true, - DistributionGroups: []string{"group1"}, - Records: []*records.Record{ - { - ID: "record1", - Name: "www.first.com", - Type: records.RecordTypeA, - Content: "192.168.1.1", - TTL: 300, - }, - }, - }, - { - ID: "zone2", - Domain: "second.com", - Enabled: true, - EnableSearchDomain: false, - DistributionGroups: []string{"group1"}, - Records: []*records.Record{ - { - ID: "record2", - Name: "www.second.com", - Type: records.RecordTypeA, - Content: "192.168.1.2", - TTL: 600, - }, - }, - }, - }, - peerGroups: LookupMap{"group1": struct{}{}}, - expected: []nbdns.CustomZone{ - { - Domain: "first.com.", - Records: []nbdns.SimpleRecord{ - { - Name: "www.first.com.", - Type: int(dns.TypeA), - Class: nbdns.DefaultClass, - TTL: 300, - RData: "192.168.1.1", - }, - }, - SearchDomainDisabled: false, - }, - { - Domain: "second.com.", - Records: []nbdns.SimpleRecord{ - { - Name: "www.second.com.", - Type: int(dns.TypeA), - Class: nbdns.DefaultClass, - TTL: 600, - RData: "192.168.1.2", - }, - }, - SearchDomainDisabled: true, - }, - }, - }, - { - name: "zone with multiple records of same type", - accountZones: []*zones.Zone{ - { - ID: "zone1", - Domain: "multi-a.com", - Enabled: true, - EnableSearchDomain: false, - DistributionGroups: []string{"group1"}, - Records: []*records.Record{ - { - ID: "record1", - Name: "www.multi-a.com", - Type: records.RecordTypeA, - Content: "192.168.1.1", - TTL: 300, - }, - { - ID: "record2", - Name: "www.multi-a.com", - Type: records.RecordTypeA, - Content: "192.168.1.2", - TTL: 300, - }, - }, - }, - }, - peerGroups: LookupMap{"group1": struct{}{}}, - expected: []nbdns.CustomZone{ - { - Domain: "multi-a.com.", - Records: []nbdns.SimpleRecord{ - { - Name: "www.multi-a.com.", - Type: int(dns.TypeA), - Class: nbdns.DefaultClass, - TTL: 300, - RData: "192.168.1.1", - }, - { - Name: "www.multi-a.com.", - Type: int(dns.TypeA), - Class: nbdns.DefaultClass, - TTL: 300, - RData: "192.168.1.2", - }, - }, - SearchDomainDisabled: true, - }, - }, - }, - { - name: "peer in multiple groups accessing different zones", - accountZones: []*zones.Zone{ - { - ID: "zone1", - Domain: "zone1.com", - Enabled: true, - EnableSearchDomain: false, - DistributionGroups: []string{"group1"}, - Records: []*records.Record{ - { - ID: "record1", - Name: "www.zone1.com", - Type: records.RecordTypeA, - Content: "192.168.1.1", - TTL: 300, - }, - }, - }, - { - ID: "zone2", - Domain: "zone2.com", - Enabled: true, - EnableSearchDomain: false, - DistributionGroups: []string{"group2"}, - Records: []*records.Record{ - { - ID: "record2", - Name: "www.zone2.com", - Type: records.RecordTypeA, - Content: "192.168.1.2", - TTL: 300, - }, - }, - }, - }, - peerGroups: LookupMap{"group1": struct{}{}, "group2": struct{}{}}, - expected: []nbdns.CustomZone{ - { - Domain: "zone1.com.", - Records: []nbdns.SimpleRecord{ - { - Name: "www.zone1.com.", - Type: int(dns.TypeA), - Class: nbdns.DefaultClass, - TTL: 300, - RData: "192.168.1.1", - }, - }, - SearchDomainDisabled: true, - }, - { - Domain: "zone2.com.", - Records: []nbdns.SimpleRecord{ - { - Name: "www.zone2.com.", - Type: int(dns.TypeA), - Class: nbdns.DefaultClass, - TTL: 300, - RData: "192.168.1.2", - }, - }, - SearchDomainDisabled: true, - }, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := filterPeerAppliedZones(ctx, tt.accountZones, tt.peerGroups) - require.Equal(t, len(tt.expected), len(result), "number of custom zones should match") - - for i, expectedZone := range tt.expected { - assert.Equal(t, expectedZone.Domain, result[i].Domain, "domain should match") - assert.Equal(t, expectedZone.SearchDomainDisabled, result[i].SearchDomainDisabled, "search domain disabled flag should match") - assert.Equal(t, len(expectedZone.Records), len(result[i].Records), "number of records should match") - - for j, expectedRecord := range expectedZone.Records { - assert.Equal(t, expectedRecord.Name, result[i].Records[j].Name, "record name should match") - assert.Equal(t, expectedRecord.Type, result[i].Records[j].Type, "record type should match") - assert.Equal(t, expectedRecord.Class, result[i].Records[j].Class, "record class should match") - assert.Equal(t, expectedRecord.TTL, result[i].Records[j].TTL, "record TTL should match") - assert.Equal(t, expectedRecord.RData, result[i].Records[j].RData, "record RData should match") - } - } - }) - } -} - func TestInjectPrivateServicePolicies_ProxyPeerGetsInboundRule(t *testing.T) { ctx := context.Background() @@ -1564,6 +1052,7 @@ func TestInjectPrivateServicePolicies_ProxyPeerGetsInboundRule(t *testing.T) { Identifier: "net-1", Net: net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.CIDRMask(10, 32)}, }, + Settings: &Settings{}, Peers: map[string]*nbpeer.Peer{ "user-peer": { ID: "user-peer", @@ -1614,41 +1103,25 @@ func TestInjectPrivateServicePolicies_ProxyPeerGetsInboundRule(t *testing.T) { }, } - account.InjectProxyPolicies(ctx) - - var found *Policy - for _, p := range account.Policies { - if p != nil && p.ID == "private-access-svc-1-proxy-peer" { - found = p - break - } - } - require.NotNil(t, found, "expected synthesised private-access policy in account.Policies") + found := findPolicy(injectedPolicies(account), "private-access-svc-1-proxy-peer") + require.NotNil(t, found, "expected synthesised private-access policy in the twin store") require.Len(t, found.Rules, 1, "policy should have exactly one rule") rule := found.Rules[0] assert.Equal(t, []string{"grp-admins"}, rule.Sources, "sources should be group IDs verbatim") assert.Equal(t, "proxy-peer", rule.DestinationResource.ID, "destination resource should be the proxy peer ID") - assert.Equal(t, ResourceTypePeer, rule.DestinationResource.Type, "destination resource type should be peer") + assert.Equal(t, string(ResourceTypePeer), rule.DestinationResource.Type, "destination resource type should be peer") validatedPeersMap := map[string]struct{}{ "user-peer": {}, "proxy-peer": {}, } - proxyPeer := account.Peers["proxy-peer"] - aclPeers, firewallRules, _, _ := account.GetPeerConnectionResources(ctx, proxyPeer, validatedPeersMap, nil) + nm := account.GetPeerNetworkMapFromComponents(ctx, "proxy-peer", nbdns.CustomZone{}, nil, validatedPeersMap, nil, nil, nil, nil) - var sawUserAsAclPeer bool - for _, p := range aclPeers { - if p.ID == "user-peer" { - sawUserAsAclPeer = true - break - } - } - assert.True(t, sawUserAsAclPeer, "proxy peer should see the user peer as an ACL peer") + assert.Contains(t, netmapPeerIDs(nm.Peers), "user-peer", "proxy peer should see the user peer as an ACL peer") var inboundRules []*FirewallRule - for _, r := range firewallRules { + for _, r := range nm.FirewallRules { if r.Direction == FirewallRuleDirectionIN && r.PeerIP == userPeerIP.String() { inboundRules = append(inboundRules, r) } @@ -1657,29 +1130,23 @@ func TestInjectPrivateServicePolicies_ProxyPeerGetsInboundRule(t *testing.T) { } func TestInjectPrivateServicePolicies_NotPrivate_NoPolicy(t *testing.T) { - ctx := context.Background() account := privateServiceTestAccount(t) account.Services[0].Private = false - account.InjectProxyPolicies(ctx) assert.False(t, hasPrivateAccessPolicy(account, "svc-1"), "non-private service must not synthesise an access policy") } func TestInjectPrivateServicePolicies_EmptyAccessGroups_NoPolicy(t *testing.T) { - ctx := context.Background() account := privateServiceTestAccount(t) account.Services[0].AccessGroups = nil - account.InjectProxyPolicies(ctx) assert.False(t, hasPrivateAccessPolicy(account, "svc-1"), "private service with no access groups must not synthesise a policy") } func TestInjectPrivateServicePolicies_NoProxyPeers_NoPolicy(t *testing.T) { - ctx := context.Background() account := privateServiceTestAccount(t) delete(account.Peers, "proxy-peer") - account.InjectProxyPolicies(ctx) assert.False(t, hasPrivateAccessPolicy(account, "svc-1"), "policy must not synthesise when the cluster has no proxy peers") } @@ -1742,10 +1209,27 @@ func privateServiceTestAccount(t *testing.T) *Account { } } +// injectedPolicies returns the twin's policies with the synthesised proxy ACLs +// already in place, the way the per-peer computation sees them. +func injectedPolicies(account *Account) []*nmdata.Policy { + nmd := account.toNetworkMapData(nil, nil, nil, nil, nil) + nmd.InjectProxyPolicies() + return nmd.Policies +} + +func findPolicy(policies []*nmdata.Policy, id string) *nmdata.Policy { + for _, p := range policies { + if p != nil && p.ID == id { + return p + } + } + return nil +} + func hasPrivateAccessPolicy(account *Account, serviceID string) bool { prefix := "private-access-" + serviceID + "-" - for _, p := range account.Policies { - if p != nil && len(p.ID) > len(prefix) && p.ID[:len(prefix)] == prefix { + for _, p := range injectedPolicies(account) { + if p != nil && strings.HasPrefix(p.ID, prefix) { return true } } @@ -1781,41 +1265,45 @@ func TestForcesRoutingPeerDNSResolution(t *testing.T) { return buildAccountRes(serviceEnabled, targetEnabled, resourceEnabled, targetType, resourceTypes.Domain) } + forced := func(account *Account, peerID string) bool { + nmd := account.toNetworkMapData(nil, nil, nil, account.GetResourceRoutersMap(), nil) + return nmd.GetPeerNetworkMapComponents(peerID, nmdata.CustomZone{}).ForceRoutingPeerDNSResolution + } + t.Run("router peer for RP-targeted domain resource is forced", func(t *testing.T) { account := buildAccount(true, true, true, service.TargetTypeDomain) - routers := account.GetResourceRoutersMap() - assert.True(t, account.forcesRoutingPeerDNSResolution("router-peer", routers), "direct router peer should be forced") - assert.True(t, account.forcesRoutingPeerDNSResolution("router-peer-grp", routers), "group-member router peer should be forced") + assert.True(t, forced(account, "router-peer"), "direct router peer should be forced") + assert.True(t, forced(account, "router-peer-grp"), "group-member router peer should be forced") }) t.Run("non-router peer is not forced", func(t *testing.T) { account := buildAccount(true, true, true, service.TargetTypeDomain) - assert.False(t, account.forcesRoutingPeerDNSResolution("other-peer", account.GetResourceRoutersMap())) + assert.False(t, forced(account, "other-peer")) }) t.Run("not forced when service disabled", func(t *testing.T) { account := buildAccount(false, true, true, service.TargetTypeDomain) - assert.False(t, account.forcesRoutingPeerDNSResolution("router-peer", account.GetResourceRoutersMap())) + assert.False(t, forced(account, "router-peer")) }) t.Run("not forced when target disabled", func(t *testing.T) { account := buildAccount(true, false, true, service.TargetTypeDomain) - assert.False(t, account.forcesRoutingPeerDNSResolution("router-peer", account.GetResourceRoutersMap())) + assert.False(t, forced(account, "router-peer")) }) t.Run("not forced when resource disabled", func(t *testing.T) { account := buildAccount(true, true, false, service.TargetTypeDomain) - assert.False(t, account.forcesRoutingPeerDNSResolution("router-peer", account.GetResourceRoutersMap())) + assert.False(t, forced(account, "router-peer")) }) t.Run("not forced for non-domain target type", func(t *testing.T) { account := buildAccount(true, true, true, service.TargetTypePeer) - assert.False(t, account.forcesRoutingPeerDNSResolution("router-peer", account.GetResourceRoutersMap())) + assert.False(t, forced(account, "router-peer")) }) t.Run("not forced when targeted resource is not a domain", func(t *testing.T) { account := buildAccountRes(true, true, true, service.TargetTypeDomain, resourceTypes.Host) - assert.False(t, account.forcesRoutingPeerDNSResolution("router-peer", account.GetResourceRoutersMap()), + assert.False(t, forced(account, "router-peer"), "a domain target pointing at a non-domain resource must not force resolution") }) } diff --git a/management/server/types/aliases.go b/management/server/types/aliases.go index 9324cfa1e..452a2746d 100644 --- a/management/server/types/aliases.go +++ b/management/server/types/aliases.go @@ -2,54 +2,31 @@ package types import ( "context" - "math/rand" - "net" - "net/netip" + nbpeer "github.com/netbirdio/netbird/management/server/peer" nbroute "github.com/netbirdio/netbird/route" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" sharedtypes "github.com/netbirdio/netbird/shared/management/types" ) // Type aliases for types relocated to shared/management/types so that the // client-side compute path can depend on them -type DNSSettings = sharedtypes.DNSSettings - type FirewallRule = sharedtypes.FirewallRule -type Network = sharedtypes.Network type NetworkMap = sharedtypes.NetworkMap type ForwardingRule = sharedtypes.ForwardingRule -type Policy = sharedtypes.Policy -type PolicyUpdateOperation = sharedtypes.PolicyUpdateOperation - -type PolicyRule = sharedtypes.PolicyRule -type PolicyUpdateOperationType = sharedtypes.PolicyUpdateOperationType type PolicyTrafficActionType = sharedtypes.PolicyTrafficActionType type PolicyRuleProtocolType = sharedtypes.PolicyRuleProtocolType -type PolicyRuleDirection = sharedtypes.PolicyRuleDirection type RulePortRange = sharedtypes.RulePortRange -type Resource = sharedtypes.Resource type ResourceType = sharedtypes.ResourceType type RouteFirewallRule = sharedtypes.RouteFirewallRule type NetworkMapComponents = sharedtypes.NetworkMapComponents -type ComponentPeer = sharedtypes.ComponentPeer -type ComponentGroup = sharedtypes.ComponentGroup -type ComponentRouter = sharedtypes.ComponentRouter -type ComponentResource = sharedtypes.ComponentResource -type ComponentResourceType = sharedtypes.ComponentResourceType - -const ( - ComponentResourceHost = sharedtypes.ComponentResourceHost - ComponentResourceSubnet = sharedtypes.ComponentResourceSubnet - ComponentResourceDomain = sharedtypes.ComponentResourceDomain -) - var EmptyNetworkMapComponents = sharedtypes.EmptyNetworkMapComponents type AccountSettingsInfo = sharedtypes.AccountSettingsInfo @@ -60,54 +37,36 @@ type NetworkMapComponentsCompact = sharedtypes.NetworkMapComponentsCompact type LookupMap = sharedtypes.LookupMap type FirewallRuleContext = sharedtypes.FirewallRuleContext -const GroupAllName = sharedtypes.GroupAllName - // Function forwarders preserve types.X(...) call sites that previously // resolved to package-local funcs. Plain forwarders (not var aliases) keep // the symbol immutable and allow the inliner to flatten the call. +func ParseRuleString(rule string) (PolicyRuleProtocolType, RulePortRange, error) { + return sharedtypes.ParseRuleString(rule) +} + func PolicyRuleImpliesLegacySSH(rule *PolicyRule) bool { - return sharedtypes.PolicyRuleImpliesLegacySSH(rule) + return nmdata.PolicyRuleImpliesLegacySSH(twinRule(rule)) } -func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *ComponentPeer) []*FirewallRule { - return sharedtypes.ExpandPortsAndRanges(base, rule, peer) +// ExpandPortsAndRanges / AppendIPv6FirewallRule / GenerateRouteFirewallRules +// forward to the shared twin-typed helpers, converting the real types the +// legacy Account calc still uses to nmdata twins at this boundary. + +func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *nbpeer.Peer) []*FirewallRule { + return sharedtypes.ExpandPortsAndRanges(base, twinRule(rule), twinPeer(peer)) } -func AppendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *ComponentPeer, rule *PolicyRule, rc FirewallRuleContext) []*FirewallRule { - return sharedtypes.AppendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, rule, rc) +func AppendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *nbpeer.Peer, rule *PolicyRule, rc FirewallRuleContext) []*FirewallRule { + return sharedtypes.AppendIPv6FirewallRule(rules, rulesExists, twinPeer(peer), twinPeer(targetPeer), twinRule(rule), rc) } func CalculateNetworkMapFromComponents(ctx context.Context, components *NetworkMapComponents) *NetworkMap { return sharedtypes.CalculateNetworkMapFromComponents(ctx, components) } -func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*ComponentPeer, direction int, includeIPv6 bool) []*RouteFirewallRule { - return sharedtypes.GenerateRouteFirewallRules(ctx, route, rule, groupPeers, direction, includeIPv6) -} - -func AllocateIPv6Subnet(r *rand.Rand) net.IPNet { - return sharedtypes.AllocateIPv6Subnet(r) -} - -func NewNetwork() *Network { - return sharedtypes.NewNetwork() -} - -func AllocatePeerIP(prefix netip.Prefix, takenIps []netip.Addr) (netip.Addr, error) { - return sharedtypes.AllocatePeerIP(prefix, takenIps) -} - -func AllocateRandomPeerIP(prefix netip.Prefix) (netip.Addr, error) { - return sharedtypes.AllocateRandomPeerIP(prefix) -} - -func AllocateRandomPeerIPv6(prefix netip.Prefix) (netip.Addr, error) { - return sharedtypes.AllocateRandomPeerIPv6(prefix) -} - -func ParseRuleString(rule string) (PolicyRuleProtocolType, RulePortRange, error) { - return sharedtypes.ParseRuleString(rule) +func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int, includeIPv6 bool) []*RouteFirewallRule { + return sharedtypes.GenerateRouteFirewallRules(ctx, twinRoute(route), twinRule(rule), TwinPeers(groupPeers), direction, includeIPv6) } const ( @@ -115,6 +74,11 @@ const ( FirewallRuleDirectionOUT = sharedtypes.FirewallRuleDirectionOUT ) +const ( + AllowedIPsFormat = sharedtypes.AllowedIPsFormat + AllowedIPsV6Format = sharedtypes.AllowedIPsV6Format +) + const ( ResourceTypePeer = sharedtypes.ResourceTypePeer ResourceTypeDomain = sharedtypes.ResourceTypeDomain @@ -134,15 +98,3 @@ const ( PolicyRuleProtocolICMP = sharedtypes.PolicyRuleProtocolICMP PolicyRuleProtocolNetbirdSSH = sharedtypes.PolicyRuleProtocolNetbirdSSH ) - -const ( - PolicyRuleFlowDirect = sharedtypes.PolicyRuleFlowDirect - PolicyRuleFlowBidirect = sharedtypes.PolicyRuleFlowBidirect -) - -const ( - DefaultRuleName = sharedtypes.DefaultRuleName - DefaultRuleDescription = sharedtypes.DefaultRuleDescription - DefaultPolicyName = sharedtypes.DefaultPolicyName - DefaultPolicyDescription = sharedtypes.DefaultPolicyDescription -) diff --git a/shared/management/types/dns_settings.go b/management/server/types/dns_settings.go similarity index 100% rename from shared/management/types/dns_settings.go rename to management/server/types/dns_settings.go diff --git a/management/server/types/group.go b/management/server/types/group.go index a5e196997..ac0a2a7f2 100644 --- a/management/server/types/group.go +++ b/management/server/types/group.go @@ -1,7 +1,8 @@ package types import ( - "github.com/netbirdio/netbird/management/server/integration_reference" + "github.com/netbirdio/netbird/shared/management/integration_reference" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" ) const ( @@ -67,6 +68,10 @@ func (g *Group) EventMeta() map[string]any { return map[string]any{"name": g.Name} } +func (g *Group) EventMetaResource(resource *nmdata.NetworkResource) map[string]any { + return map[string]any{"name": g.Name, "id": g.ID, "resource_name": resource.Name, "resource_id": resource.ID, "resource_type": resource.Type} +} + func (g *Group) Copy() *Group { group := &Group{ ID: g.ID, @@ -90,39 +95,14 @@ func (g *Group) HasPeers() bool { return len(g.Peers) > 0 } +// GroupAllName is the reserved name of the default group that contains every peer in an account. +const GroupAllName = "All" + // IsGroupAll checks if the group is a default "All" group. func (g *Group) IsGroupAll() bool { return g.Name == GroupAllName } -// ToComponent converts the group to its self-contained components -// representation. The Peers slice is shared, not copied — components are -// treated as immutable snapshots. Returns nil for a nil group. -func (g *Group) ToComponent() *ComponentGroup { - if g == nil { - return nil - } - return &ComponentGroup{ - ID: g.ID, - PublicID: g.PublicID, - Name: g.Name, - Peers: g.Peers, - } -} - -// GroupsToComponent converts an id-keyed group map to its components -// representation, preserving nil entries. -func GroupsToComponent(groups map[string]*Group) map[string]*ComponentGroup { - if groups == nil { - return nil - } - out := make(map[string]*ComponentGroup, len(groups)) - for id, g := range groups { - out[id] = g.ToComponent() - } - return out -} - // AddPeer adds peerID to Peers if not present, returning true if added. func (g *Group) AddPeer(peerID string) bool { if peerID == "" { diff --git a/management/server/types/ipv6_endtoend_test.go b/management/server/types/ipv6_endtoend_test.go index d83603abe..76c61369e 100644 --- a/management/server/types/ipv6_endtoend_test.go +++ b/management/server/types/ipv6_endtoend_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/require" nbpeer "github.com/netbirdio/netbird/management/server/peer" - "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" ) func TestNetworkMapComponents_IPv6EndToEnd(t *testing.T) { @@ -105,7 +105,7 @@ func TestNetworkMapComponents_RemotePeerWithoutCapability(t *testing.T) { require.NotNil(t, nm) t.Run("AllowedIPs include remote v6", func(t *testing.T) { - var dst *types.ComponentPeer + var dst *nmdata.Peer for _, p := range nm.Peers { if p.ID == "peer-dst-1" { dst = p diff --git a/management/server/types/legacynmap/account_components.go b/management/server/types/legacynmap/account_components.go new file mode 100644 index 000000000..5d5b4a9cf --- /dev/null +++ b/management/server/types/legacynmap/account_components.go @@ -0,0 +1,701 @@ +package legacynmap + +import ( + "context" + "slices" + "time" + + log "github.com/sirupsen/logrus" + + nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/management/internals/modules/zones" + routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" + "github.com/netbirdio/netbird/management/server/telemetry" + "github.com/netbirdio/netbird/route" +) + +// GetPeerNetworkMapResult dispatches to either the legacy-NetworkMap path or +// the components path based on the peer's capability and the kill switch. +// Capable peers (PeerCapabilityComponentNetworkMap) get the raw components +// shape — the server skips Calculate() entirely for them, saving CPU +// proportional to the number of capable peers in the account. Legacy peers +// (or any peer when componentsDisabled is true) get the fully-expanded +// NetworkMap as before. + +func GetPeerNetworkMapFromComponents(a *Account, + ctx context.Context, + peerID string, + peersCustomZone nbdns.CustomZone, + accountZones []*zones.Zone, + validatedPeersMap map[string]struct{}, + resourcePolicies map[string][]*Policy, + routers map[string]map[string]*routerTypes.NetworkRouter, + metrics *telemetry.AccountManagerMetrics, + groupIDToUserIDs map[string][]string, +) *NetworkMap { + start := time.Now() + + components := GetPeerNetworkMapComponents(a, + ctx, + peerID, + peersCustomZone, + accountZones, + validatedPeersMap, + resourcePolicies, + routers, + groupIDToUserIDs, + ) + + if components.IsEmpty() { + return &NetworkMap{Network: components.Network} + } + + nm := CalculateNetworkMapFromComponents(ctx, components) + + if metrics != nil { + objectCount := int64(len(nm.Peers) + len(nm.OfflinePeers) + len(nm.Routes) + len(nm.FirewallRules) + len(nm.RoutesFirewallRules)) + metrics.CountNetworkMapObjects(objectCount) + metrics.CountGetPeerNetworkMapDuration(time.Since(start)) + + if objectCount > 5000 { + log.WithContext(ctx).Tracef("account: %s has a total resource count of %d objects from components, "+ + "peers: %d, offline peers: %d, routes: %d, firewall rules: %d, route firewall rules: %d", + a.Id, objectCount, len(nm.Peers), len(nm.OfflinePeers), len(nm.Routes), len(nm.FirewallRules), len(nm.RoutesFirewallRules)) + } + } + + return nm +} + +func GetPeerNetworkMapComponents(a *Account, + ctx context.Context, + peerID string, + peersCustomZone nbdns.CustomZone, + accountZones []*zones.Zone, + validatedPeersMap map[string]struct{}, + resourcePolicies map[string][]*Policy, + routers map[string]map[string]*routerTypes.NetworkRouter, + groupIDToUserIDs map[string][]string, +) *NetworkMapComponents { + peer := a.Peers[peerID] + // this can never happen, things are very wrong if it did + // TODO (dmitri) maybe consider using invariants? + if peer == nil { + log.WithField("peer id", peerID).Error("NetworkMapComponents are computed for a peer missing from the account") + return EmptyNetworkMapComponents(&NetworkMapComponents{ + PeerID: peerID, + Network: a.Network.Copy(), + // must include the target peer as it's required on the client + Peers: map[string]*ComponentPeer{peerID: peerToComponent(peer)}, + }) + } + + if _, ok := validatedPeersMap[peerID]; !ok { + // Mirror legacy graceful-degrade: GetPeerNetworkMapFromComponents + // returns &NetworkMap{Network: a.Network.Copy()} when components is + // nil. Match that floor so the receiving client always sees the + // account Network identifier, not a fully-empty envelope. + return EmptyNetworkMapComponents(&NetworkMapComponents{ + PeerID: peerID, + Network: a.Network.Copy(), + // must include the target peer as it's required on the client + Peers: map[string]*ComponentPeer{peerID: peerToComponent(peer)}, + }) + } + + components := &NetworkMapComponents{ + PeerID: peerID, + Network: a.Network.Copy(), + NameServerGroups: make([]*nbdns.NameServerGroup, 0), + CustomZoneDomain: peersCustomZone.Domain, + ResourcePoliciesMap: make(map[string][]*Policy), + RoutersMap: make(map[string]map[string]*ComponentRouter), + NetworkResources: make([]*ComponentResource, 0), + PostureFailedPeers: make(map[string]map[string]struct{}, len(a.PostureChecks)), + RouterPeers: make(map[string]*ComponentPeer), + NetworkXIDToPublicID: make(map[string]string, len(a.Networks)), + PostureCheckXIDToPublicID: make(map[string]string, len(a.PostureChecks)), + + ForceRoutingPeerDNSResolution: forcesRoutingPeerDNSResolution(a, peerID, routers), + } + for _, n := range a.Networks { + if n != nil { + components.NetworkXIDToPublicID[n.ID] = n.PublicID + } + } + for _, pc := range a.PostureChecks { + if pc != nil { + components.PostureCheckXIDToPublicID[pc.ID] = pc.PublicID + } + } + + components.AccountSettings = &AccountSettingsInfo{ + PeerLoginExpirationEnabled: a.Settings.PeerLoginExpirationEnabled, + PeerLoginExpiration: a.Settings.PeerLoginExpiration, + PeerInactivityExpirationEnabled: a.Settings.PeerInactivityExpirationEnabled, + PeerInactivityExpiration: a.Settings.PeerInactivityExpiration, + } + + components.DNSSettings = &a.DNSSettings + + // relevantPeers always contains the target peer (peerID) + relevantPeers, relevantGroups, relevantPolicies, relevantRoutes, sshReqs := getPeersGroupsPoliciesRoutes(a, ctx, peerID, peer.SSHEnabled, validatedPeersMap, &components.PostureFailedPeers) + + if len(sshReqs.neededGroupIDs) > 0 { + components.GroupIDToUserIDs = filterGroupIDToUserIDs(groupIDToUserIDs, sshReqs.neededGroupIDs) + } + if sshReqs.needAllowedUserIDs { + components.AllowedUserIDs = getAllowedUserIDs(a) + } + + components.Peers = relevantPeers + components.Groups = groupsToComponent(relevantGroups) + components.Policies = relevantPolicies + components.Routes = relevantRoutes + components.AllDNSRecords = filterDNSRecordsByPeers(peersCustomZone.Records, relevantPeers, peer.SupportsIPv6() && peer.IPv6.IsValid()) + + peerGroups := a.GetPeerGroups(peerID) + components.AccountZones = filterPeerAppliedZones(ctx, accountZones, LookupMap(peerGroups)) + components.AccountZones = append(components.AccountZones, a.SynthesizePrivateServiceZones(peerID)...) + + for _, nsGroup := range a.NameServerGroups { + if nsGroup.Enabled { + for _, gID := range nsGroup.Groups { + if _, found := relevantGroups[gID]; found { + components.NameServerGroups = append(components.NameServerGroups, nsGroup) + break + } + } + } + } + + for _, resource := range a.NetworkResources { + if !resource.Enabled { + continue + } + + policies, exists := resourcePolicies[resource.ID] + if !exists { + continue + } + + addSourcePeers := false + + networkRoutingPeers, routerExists := routers[resource.NetworkID] + if routerExists { + if _, ok := networkRoutingPeers[peerID]; ok { + addSourcePeers = true + } + } + + for _, policy := range policies { + if addSourcePeers { + var peers []string + if policy.Rules[0].SourceResource.Type == ResourceTypePeer && policy.Rules[0].SourceResource.ID != "" { + peers = []string{policy.Rules[0].SourceResource.ID} + } else { + peers = getUniquePeerIDsFromGroupsIDs(a, ctx, policy.SourceGroups()) + } + for _, pID := range getPostureValidPeersSaveFailed(a, peers, policy.SourcePostureChecks, validatedPeersMap, &components.PostureFailedPeers) { + if _, exists := components.Peers[pID]; !exists { + components.Peers[pID] = peerToComponent(a.GetPeer(pID)) + } + } + } else { + peerInSources := false + if policy.Rules[0].SourceResource.Type == ResourceTypePeer && policy.Rules[0].SourceResource.ID != "" { + peerInSources = policy.Rules[0].SourceResource.ID == peerID + } else { + for _, groupID := range policy.SourceGroups() { + if group := a.GetGroup(groupID); group != nil && slices.Contains(group.Peers, peerID) { + peerInSources = true + break + } + } + } + if !peerInSources { + continue + } + isValid, pname := validatePostureChecksOnPeerGetFailed(a, ctx, policy.SourcePostureChecks, peerID) + if !isValid && len(pname) > 0 { + if _, ok := components.PostureFailedPeers[pname]; !ok { + components.PostureFailedPeers[pname] = make(map[string]struct{}) + } + components.PostureFailedPeers[pname][peer.ID] = struct{}{} + continue + } + addSourcePeers = true + } + + for _, rule := range policy.Rules { + for _, srcGroupID := range rule.Sources { + if g := a.Groups[srcGroupID]; g != nil { + if _, exists := components.Groups[srcGroupID]; !exists { + components.Groups[srcGroupID] = groupToComponent(g) + } + } + } + for _, dstGroupID := range rule.Destinations { + if g := a.Groups[dstGroupID]; g != nil { + if _, exists := components.Groups[dstGroupID]; !exists { + components.Groups[dstGroupID] = groupToComponent(g) + } + } + } + } + components.ResourcePoliciesMap[resource.ID] = policies + } + + // Only expose router peers and the per-network routers_map when this + // target peer actually has access to the resource (either as a router + // itself or via a policy that includes it as a source). Without this + // gate, every peer's envelope was leaking router peers of every + // network in the account — accounts with many tenants/networks + // shipped tens of unrelated peers in `peers[]` and `routers_map`. + if addSourcePeers { + components.RoutersMap[resource.NetworkID] = routersToComponentMap(networkRoutingPeers) + for peerIDKey := range networkRoutingPeers { + if p := a.Peers[peerIDKey]; p != nil { + cp := components.RouterPeers[peerIDKey] + if cp == nil { + cp = peerToComponent(p) + components.RouterPeers[peerIDKey] = cp + } + if _, exists := components.Peers[peerIDKey]; !exists { + if _, validated := validatedPeersMap[peerIDKey]; validated { + components.Peers[peerIDKey] = cp + } + } + } + } + components.NetworkResources = append(components.NetworkResources, resourceToComponent(resource)) + } + } + + filterGroupPeers(&components.Groups, components.Peers) + filterPostureFailedPeers(&components.PostureFailedPeers, components.Policies, components.ResourcePoliciesMap, components.Peers) + + return components +} + +type sshRequirements struct { + neededGroupIDs map[string]struct{} + needAllowedUserIDs bool +} + +func getPeersGroupsPoliciesRoutes(a *Account, + ctx context.Context, + peerID string, + peerSSHEnabled bool, + validatedPeersMap map[string]struct{}, + postureFailedPeers *map[string]map[string]struct{}, +) (map[string]*ComponentPeer, map[string]*Group, []*Policy, []*route.Route, sshRequirements) { + relevantPeerIDs := make(map[string]*ComponentPeer, len(a.Peers)/4) + relevantGroupIDs := make(map[string]*Group, len(a.Groups)/4) + relevantPolicies := make([]*Policy, 0, len(a.Policies)) + relevantRoutes := make([]*route.Route, 0, len(a.Routes)) + sshReqs := sshRequirements{neededGroupIDs: make(map[string]struct{})} + + relevantPeerIDs[peerID] = peerToComponent(a.GetPeer(peerID)) + + peerGroupSet := make(map[string]struct{}, 8) + for groupID, group := range a.Groups { + if slices.Contains(group.Peers, peerID) { + relevantGroupIDs[groupID] = a.GetGroup(groupID) + peerGroupSet[groupID] = struct{}{} + } + } + + routeAccessControlGroups := make(map[string]struct{}) + for _, r := range a.Routes { + if r == nil { + continue + } + relevant := r.Peer == peerID + if !relevant { + for _, groupID := range r.PeerGroups { + if _, ok := peerGroupSet[groupID]; ok { + relevant = true + break + } + } + } + if !relevant && r.Enabled { + for _, groupID := range r.Groups { + if _, ok := peerGroupSet[groupID]; ok { + relevant = true + break + } + } + } + if !relevant { + continue + } + + for _, groupID := range r.PeerGroups { + relevantGroupIDs[groupID] = a.GetGroup(groupID) + } + for _, groupID := range r.Groups { + relevantGroupIDs[groupID] = a.GetGroup(groupID) + } + if r.Enabled { + for _, groupID := range r.AccessControlGroups { + relevantGroupIDs[groupID] = a.GetGroup(groupID) + routeAccessControlGroups[groupID] = struct{}{} + } + } + + // Include route advertisers in relevantPeerIDs. The envelope + // encoder writes route.peer_index by looking up r.Peer in the + // shipped peers list; if the advertiser is policy-isolated from + // the target peer (no rule edge between them), it would otherwise + // be omitted and the decoder would fail to resolve r.Peer, leaving + // the client without a WG tunnel target for this route. Legacy + // NetworkMap.Routes shipped the WG public key inline, so the + // equivalence path doesn't surface this — but the dependency is + // real once a client actually tries to use the route. + // Gate by validatedPeersMap so non-validated advertisers stay out + // (matches the network-resource router behaviour at the bottom of + // this loop, and the legacy invariant that only validated peers + // reach a client's view). + if r.Peer != "" { + if _, ok := validatedPeersMap[r.Peer]; ok { + if p := a.GetPeer(r.Peer); p != nil { + relevantPeerIDs[r.Peer] = peerToComponent(p) + } + } + } + for _, groupID := range r.PeerGroups { + g := a.GetGroup(groupID) + if g == nil { + continue + } + for _, pid := range g.Peers { + if _, exists := relevantPeerIDs[pid]; exists { + continue + } + if _, ok := validatedPeersMap[pid]; !ok { + continue + } + if p := a.GetPeer(pid); p != nil { + relevantPeerIDs[pid] = peerToComponent(p) + } + } + } + relevantRoutes = append(relevantRoutes, r) + } + + for _, policy := range a.Policies { + if !policy.Enabled { + continue + } + + policyRelevant := false + for _, rule := range policy.Rules { + if !rule.Enabled { + continue + } + + if len(routeAccessControlGroups) > 0 { + for _, destGroupID := range rule.Destinations { + if _, needed := routeAccessControlGroups[destGroupID]; needed { + policyRelevant = true + for _, srcGroupID := range rule.Sources { + relevantGroupIDs[srcGroupID] = a.GetGroup(srcGroupID) + } + for _, dstGroupID := range rule.Destinations { + relevantGroupIDs[dstGroupID] = a.GetGroup(dstGroupID) + } + break + } + } + } + + var sourcePeers, destinationPeers []string + var peerInSources, peerInDestinations bool + + if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" { + sourcePeers = []string{rule.SourceResource.ID} + if rule.SourceResource.ID == peerID { + peerInSources = true + } + } else { + sourcePeers, peerInSources = getPeersFromGroups(a, ctx, rule.Sources, peerID, policy.SourcePostureChecks, validatedPeersMap, postureFailedPeers) + } + + if rule.DestinationResource.Type == ResourceTypePeer && rule.DestinationResource.ID != "" { + destinationPeers = []string{rule.DestinationResource.ID} + if rule.DestinationResource.ID == peerID { + peerInDestinations = true + } + } else { + destinationPeers, peerInDestinations = getPeersFromGroups(a, ctx, rule.Destinations, peerID, nil, validatedPeersMap, postureFailedPeers) + } + + if peerInSources { + policyRelevant = true + for _, pid := range destinationPeers { + if _, exists := relevantPeerIDs[pid]; !exists { + relevantPeerIDs[pid] = peerToComponent(a.GetPeer(pid)) + } + } + for _, dstGroupID := range rule.Destinations { + relevantGroupIDs[dstGroupID] = a.GetGroup(dstGroupID) + } + } + + if peerInDestinations { + policyRelevant = true + for _, pid := range sourcePeers { + if _, exists := relevantPeerIDs[pid]; !exists { + relevantPeerIDs[pid] = peerToComponent(a.GetPeer(pid)) + } + } + for _, srcGroupID := range rule.Sources { + relevantGroupIDs[srcGroupID] = a.GetGroup(srcGroupID) + } + + if rule.Protocol == PolicyRuleProtocolNetbirdSSH { + switch { + case len(rule.AuthorizedGroups) > 0: + for groupID := range rule.AuthorizedGroups { + sshReqs.neededGroupIDs[groupID] = struct{}{} + } + case rule.AuthorizedUser != "": + default: + sshReqs.needAllowedUserIDs = true + } + } else if PolicyRuleImpliesLegacySSH(rule) && peerSSHEnabled { + sshReqs.needAllowedUserIDs = true + } + } + } + if policyRelevant { + relevantPolicies = append(relevantPolicies, policy) + } + } + + return relevantPeerIDs, relevantGroupIDs, relevantPolicies, relevantRoutes, sshReqs +} + +func getPeersFromGroups(a *Account, ctx context.Context, groups []string, peerID string, sourcePostureChecksIDs []string, + validatedPeersMap map[string]struct{}, postureFailedPeers *map[string]map[string]struct{}) ([]string, bool) { + peerInGroups := false + filteredPeerIDs := make([]string, 0, len(groups)) + seenPeerIds := make(map[string]struct{}, len(groups)) + + for _, gid := range groups { + group := a.GetGroup(gid) + if group == nil { + continue + } + + if group.IsGroupAll() || len(groups) == 1 { + filteredPeerIDs = make([]string, 0, len(group.Peers)) + peerInGroups = false + for _, pid := range group.Peers { + peer, ok := a.Peers[pid] + if !ok || peer == nil { + continue + } + + if _, ok := validatedPeersMap[peer.ID]; !ok { + continue + } + + isValid, pname := validatePostureChecksOnPeerGetFailed(a, ctx, sourcePostureChecksIDs, peer.ID) + if !isValid && len(pname) > 0 { + if _, ok := (*postureFailedPeers)[pname]; !ok { + (*postureFailedPeers)[pname] = make(map[string]struct{}) + } + (*postureFailedPeers)[pname][peer.ID] = struct{}{} + continue + } + + if peer.ID == peerID { + peerInGroups = true + continue + } + + filteredPeerIDs = append(filteredPeerIDs, peer.ID) + } + return filteredPeerIDs, peerInGroups + } + + for _, pid := range group.Peers { + if _, seen := seenPeerIds[pid]; seen { + continue + } + seenPeerIds[pid] = struct{}{} + peer, ok := a.Peers[pid] + if !ok || peer == nil { + continue + } + + if _, ok := validatedPeersMap[peer.ID]; !ok { + continue + } + + isValid, pname := validatePostureChecksOnPeerGetFailed(a, ctx, sourcePostureChecksIDs, peer.ID) + if !isValid && len(pname) > 0 { + if _, ok := (*postureFailedPeers)[pname]; !ok { + (*postureFailedPeers)[pname] = make(map[string]struct{}) + } + (*postureFailedPeers)[pname][peer.ID] = struct{}{} + continue + } + + if peer.ID == peerID { + peerInGroups = true + continue + } + + filteredPeerIDs = append(filteredPeerIDs, peer.ID) + } + } + + return filteredPeerIDs, peerInGroups +} + +func validatePostureChecksOnPeerGetFailed(a *Account, ctx context.Context, sourcePostureChecksID []string, peerID string) (bool, string) { + peer, ok := a.Peers[peerID] + if !ok || peer == nil { + return false, "" + } + + for _, postureChecksID := range sourcePostureChecksID { + postureChecks := a.GetPostureChecks(postureChecksID) + if postureChecks == nil { + continue + } + + for _, check := range postureChecks.GetChecks() { + isValid, _ := check.Check(ctx, *peer) + if !isValid { + return false, postureChecksID + } + } + } + return true, "" +} + +func getPostureValidPeersSaveFailed(a *Account, inputPeers []string, postureChecksIDs []string, validatedPeersMap map[string]struct{}, postureFailedPeers *map[string]map[string]struct{}) []string { + var dest []string + for _, peerID := range inputPeers { + if _, validated := validatedPeersMap[peerID]; !validated { + continue + } + valid, pname := validatePostureChecksOnPeerGetFailed(a, context.Background(), postureChecksIDs, peerID) + if valid { + dest = append(dest, peerID) + continue + } + if _, ok := (*postureFailedPeers)[pname]; !ok { + (*postureFailedPeers)[pname] = make(map[string]struct{}) + } + (*postureFailedPeers)[pname][peerID] = struct{}{} + } + return dest +} + +// filterGroupPeers trims each group's Peers slice to only those peers that +// also appear in `peers`. Groups whose filtered list is empty are NOT +// deleted from the map — they're kept so the components wire encoder can +// still resolve seq references from routes/policies/access-control groups +// that name them. Calculate() tolerates groups with empty Peers (the inner +// loops simply iterate zero times), so retaining them is behaviourally a +// no-op for the legacy path that consumes the same NetworkMapComponents. +func filterGroupPeers(groups *map[string]*ComponentGroup, peers map[string]*ComponentPeer) { + for groupID, groupInfo := range *groups { + filteredPeers := make([]string, 0, len(groupInfo.Peers)) + for _, pid := range groupInfo.Peers { + if _, exists := peers[pid]; exists { + filteredPeers = append(filteredPeers, pid) + } + } + + if len(filteredPeers) != len(groupInfo.Peers) { + ng := *groupInfo + ng.Peers = filteredPeers + (*groups)[groupID] = &ng + } + } +} + +func filterPostureFailedPeers(postureFailedPeers *map[string]map[string]struct{}, policies []*Policy, resourcePoliciesMap map[string][]*Policy, peers map[string]*ComponentPeer) { + if len(*postureFailedPeers) == 0 { + return + } + + referencedPostureChecks := make(map[string]struct{}) + for _, policy := range policies { + for _, checkID := range policy.SourcePostureChecks { + referencedPostureChecks[checkID] = struct{}{} + } + } + for _, resPolicies := range resourcePoliciesMap { + for _, policy := range resPolicies { + for _, checkID := range policy.SourcePostureChecks { + referencedPostureChecks[checkID] = struct{}{} + } + } + } + + for checkID, failedPeers := range *postureFailedPeers { + if _, referenced := referencedPostureChecks[checkID]; !referenced { + delete(*postureFailedPeers, checkID) + continue + } + for peerID := range failedPeers { + if _, exists := peers[peerID]; !exists { + delete(failedPeers, peerID) + } + } + if len(failedPeers) == 0 { + delete(*postureFailedPeers, checkID) + } + } +} + +func filterDNSRecordsByPeers(records []nbdns.SimpleRecord, peers map[string]*ComponentPeer, includeIPv6 bool) []nbdns.SimpleRecord { + if len(records) == 0 || len(peers) == 0 { + return nil + } + + // Include both v4 and v6 addresses so AAAA records (whose RData is an IPv6 + // address) are not filtered out when peers have IPv6 assigned. When the + // requesting peer doesn't have IPv6, omit v6 IPs so AAAA records get dropped. + peerIPs := make(map[string]struct{}, len(peers)*2) + for _, peer := range peers { + if peer == nil { + continue + } + peerIPs[peer.IP.String()] = struct{}{} + if includeIPv6 && peer.IPv6.IsValid() { + peerIPs[peer.IPv6.String()] = struct{}{} + } + } + + filteredRecords := make([]nbdns.SimpleRecord, 0, len(records)) + for _, record := range records { + if _, exists := peerIPs[record.RData]; exists { + filteredRecords = append(filteredRecords, record) + } + } + + return filteredRecords +} + +func filterGroupIDToUserIDs(fullMap map[string][]string, neededGroupIDs map[string]struct{}) map[string][]string { + if len(neededGroupIDs) == 0 { + return nil + } + + filtered := make(map[string][]string, len(neededGroupIDs)) + for groupID := range neededGroupIDs { + if users, ok := fullMap[groupID]; ok { + filtered[groupID] = users + } + } + return filtered +} diff --git a/management/server/types/legacynmap/aliases.go b/management/server/types/legacynmap/aliases.go new file mode 100644 index 000000000..82a18192b --- /dev/null +++ b/management/server/types/legacynmap/aliases.go @@ -0,0 +1,35 @@ +package legacynmap + +import ( + types "github.com/netbirdio/netbird/management/server/types" + sharedtypes "github.com/netbirdio/netbird/shared/management/types" +) + +type ( + Account = types.Account + + DNSSettings = types.DNSSettings + FirewallRule = sharedtypes.FirewallRule + ForwardingRule = sharedtypes.ForwardingRule + Group = types.Group + Network = types.Network + Policy = types.Policy + PolicyRule = types.PolicyRule + Resource = types.Resource + RulePortRange = sharedtypes.RulePortRange + RouteFirewallRule = sharedtypes.RouteFirewallRule +) + +const ( + FirewallRuleDirectionIN = sharedtypes.FirewallRuleDirectionIN + FirewallRuleDirectionOUT = sharedtypes.FirewallRuleDirectionOUT + + PolicyRuleProtocolALL = sharedtypes.PolicyRuleProtocolALL + PolicyRuleProtocolTCP = sharedtypes.PolicyRuleProtocolTCP + PolicyRuleProtocolNetbirdSSH = sharedtypes.PolicyRuleProtocolNetbirdSSH + PolicyTrafficActionAccept = sharedtypes.PolicyTrafficActionAccept + ResourceTypePeer = sharedtypes.ResourceTypePeer + + AllowedIPsFormat = sharedtypes.AllowedIPsFormat + AllowedIPsV6Format = sharedtypes.AllowedIPsV6Format +) diff --git a/management/server/types/legacynmap/benchmark_test.go b/management/server/types/legacynmap/benchmark_test.go new file mode 100644 index 000000000..22e291e00 --- /dev/null +++ b/management/server/types/legacynmap/benchmark_test.go @@ -0,0 +1,350 @@ +//go:build nmapequiv + +// Account-load benchmark: the legacy store.GetAccount hydration (pgx fast +// path, as in production) vs the nmdata store's GetNetworkMapData, against the +// same Postgres copy as the equivalence test. +// +// NETBIRD_STORE_ENGINE_POSTGRES_DSN='...' go test -tags nmapequiv \ +// -run '^$' -bench . -benchtime 5x -timeout 60m \ +// ./management/server/types/legacynmap/ +// +// NETMAP_ACCOUNTS selects the accounts (comma-separated); by default the ten +// accounts with the most peers are used. Each account is a sub-benchmark, so +// the two paths can be compared per account. One warmup call runs untimed +// before each measurement so Postgres buffer-cache state is comparable. +// +// Reported metrics beyond ns/op and allocs: +// +// - queries/op round trips, counted client-side via a pgx tracer +// (GetNetworkMapData only — the legacy store's pool is internal) +// - xact/op committed transactions from pg_stat_database; the legacy +// pgx path runs autocommit statements, so this approximates its round +// trips, while GetNetworkMapData runs a single transaction +// - tup_returned/op, tup_fetched/op rows scanned/fetched server-side +// - blks_read/op, blks_hit/op buffer cache misses/hits +// +// The pg_stat_database numbers are database-global: run without concurrent +// load. The two stat snapshots per sub-benchmark add a small constant +// overhead to the server-side deltas. +package legacynmap_test + +import ( + "context" + "os" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/require" + + nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache" + networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql" + mgmtgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/networkmap" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" +) + +func BenchmarkGetAccount(b *testing.B) { + dsn := equivDSN() + if dsn == "" { + b.Skip("NETBIRD_STORE_ENGINE_POSTGRES_DSN not set") + } + ctx := context.Background() + + statsConn, err := pgx.Connect(ctx, dsn) + require.NoError(b, err, "connect stats connection") + b.Cleanup(func() { statsConn.Close(ctx) }) + + testStore, err := store.NewPostgresqlStore(ctx, dsn, nil, true) + require.NoError(b, err, "connect to postgres") + b.Cleanup(func() { testStore.Close(ctx) }) + + for _, accountID := range benchAccountIDs(b, ctx, statsConn) { + b.Run(accountID, func(b *testing.B) { + logAccountShape(b, ctx, statsConn, accountID) + benchDBLoad(b, ctx, statsConn, nil, func() error { + _, err := testStore.GetAccount(ctx, accountID) + return err + }) + }) + } +} + +func BenchmarkGetNetworkMapData(b *testing.B) { + dsn := equivDSN() + if dsn == "" { + b.Skip("NETBIRD_STORE_ENGINE_POSTGRES_DSN not set") + } + ctx := context.Background() + + statsConn, err := pgx.Connect(ctx, dsn) + require.NoError(b, err, "connect stats connection") + b.Cleanup(func() { statsConn.Close(ctx) }) + + tracer := &queryCountTracer{} + cfg, err := pgxpool.ParseConfig(dsn) + require.NoError(b, err, "parse dsn") + cfg.ConnConfig.Tracer = tracer + pool, err := pgxpool.NewWithConfig(ctx, cfg) + require.NoError(b, err, "connect nmdata store") + b.Cleanup(pool.Close) + nmStore := nmDataStore(b, &networkmap_pgsql.PgStore{Pool: pool}) + + for _, accountID := range benchAccountIDs(b, ctx, statsConn) { + b.Run(accountID, func(b *testing.B) { + logAccountShape(b, ctx, statsConn, accountID) + benchDBLoad(b, ctx, statsConn, tracer, func() error { + _, err := nmStore.GetNetworkMapData(ctx, accountID) + return err + }) + }) + } +} + +// BenchmarkAccountFullRound measures store load plus the full per-peer fan-out +// to *proto.SyncResponse for every peer of the account, the way the production +// account path runs it: index maps and per-peer twin building happen after +// GetAccount and are part of the measured op. BenchmarkNetworkMapDataFullRound +// is the equivalent for the nmdata path, whose index building happens inside +// GetNetworkMapData. Select both with -bench FullRound. +func BenchmarkAccountFullRound(b *testing.B) { + dsn := equivDSN() + if dsn == "" { + b.Skip("NETBIRD_STORE_ENGINE_POSTGRES_DSN not set") + } + ctx := context.Background() + + statsConn, err := pgx.Connect(ctx, dsn) + require.NoError(b, err, "connect stats connection") + b.Cleanup(func() { statsConn.Close(ctx) }) + + testStore, err := store.NewPostgresqlStore(ctx, dsn, nil, true) + require.NoError(b, err, "connect to postgres") + b.Cleanup(func() { testStore.Close(ctx) }) + + for _, accountID := range benchAccountIDs(b, ctx, statsConn) { + b.Run(accountID, func(b *testing.B) { + logAccountShape(b, ctx, statsConn, accountID) + benchDBLoad(b, ctx, statsConn, nil, func() error { + account, err := testStore.GetAccount(ctx, accountID) + if err != nil { + return err + } + buildAccountSyncResponses(ctx, account) + return nil + }) + }) + } +} + +func BenchmarkNetworkMapDataFullRound(b *testing.B) { + dsn := equivDSN() + if dsn == "" { + b.Skip("NETBIRD_STORE_ENGINE_POSTGRES_DSN not set") + } + ctx := context.Background() + + statsConn, err := pgx.Connect(ctx, dsn) + require.NoError(b, err, "connect stats connection") + b.Cleanup(func() { statsConn.Close(ctx) }) + + tracer := &queryCountTracer{} + cfg, err := pgxpool.ParseConfig(dsn) + require.NoError(b, err, "parse dsn") + cfg.ConnConfig.Tracer = tracer + pool, err := pgxpool.NewWithConfig(ctx, cfg) + require.NoError(b, err, "connect nmdata store") + b.Cleanup(pool.Close) + nmStore := nmDataStore(b, &networkmap_pgsql.PgStore{Pool: pool}) + + for _, accountID := range benchAccountIDs(b, ctx, statsConn) { + b.Run(accountID, func(b *testing.B) { + logAccountShape(b, ctx, statsConn, accountID) + benchDBLoad(b, ctx, statsConn, tracer, func() error { + nmData, err := nmStore.GetNetworkMapData(ctx, accountID) + if err != nil { + return err + } + buildDataSyncResponses(ctx, nmData) + return nil + }) + }) + } +} + +// buildAccountSyncResponses fans out to every peer like the controller's +// account path: index maps once, twin conversion and network-map computation +// per peer. +func buildAccountSyncResponses(ctx context.Context, account *types.Account) { + validated := make(map[string]struct{}, len(account.Peers)) + for peerID := range account.Peers { + validated[peerID] = struct{}{} + } + resourcePolicies := account.GetResourcePoliciesMap() + routers := account.GetResourceRoutersMap() + groupUsers := account.GetActiveGroupUsers() + settings := account.Settings + if settings == nil { + settings = &types.Settings{} + } + dnsCache := &cache.DNSConfigCache{} + + for peerID, peer := range account.Peers { + nm := account.GetPeerNetworkMapFromComponents( + ctx, peerID, nbdns.CustomZone{}, nil, validated, resourcePolicies, routers, nil, groupUsers, + ) + mgmtgrpc.ToSyncResponse( + ctx, nil, nil, nil, types.TwinPeer(peer), nil, nil, nm, equivDNSName, nil, + dnsCache, types.TwinAccountSettings(settings), settings.Extra, nil, 0, + ) + } +} + +// buildDataSyncResponses is the nmdata-path equivalent of +// buildAccountSyncResponses. +func buildDataSyncResponses(ctx context.Context, nmData *networkmap.NetworkMapData) { + validated := make(map[string]struct{}, len(nmData.Peers)) + for peerID := range nmData.Peers { + validated[peerID] = struct{}{} + } + nmData.ValidatedPeers = validated + dnsCache := &cache.DNSConfigCache{} + + for peerID, peer := range nmData.Peers { + components := nmData.GetPeerNetworkMapComponents(peerID, nmdata.CustomZone{}) + nm := &types.NetworkMap{Network: components.Network} + if !components.IsEmpty() { + nm = types.CalculateNetworkMapFromComponents(ctx, components) + } + mgmtgrpc.ToSyncResponse( + ctx, nil, nil, nil, peer, nil, nil, nm, equivDNSName, nil, + dnsCache, nmData.AccountSettings, nil, nil, 0, + ) + } +} + +// benchDBLoad runs op b.N times and reports server-side pg_stat_database +// deltas per op. A non-nil tracer additionally reports exact client round +// trips per op. +// +// Backends flush cumulative stats at most once per second and only while +// processing commands, so around each snapshot the load settles: sleep past +// the flush interval, then run one extra untimed op whose command end flushes +// everything pending. The trailing extra op lands inside the measured window, +// hence the b.N+1 denominator for the server-side metrics. +func benchDBLoad(b *testing.B, ctx context.Context, statsConn *pgx.Conn, tracer *queryCountTracer, op func() error) { + b.Helper() + + require.NoError(b, op(), "warmup") + settleDBStats(b, op) + + before, err := snapshotDBStats(ctx, statsConn) + require.NoError(b, err, "stats snapshot") + var queriesBefore int64 + if tracer != nil { + queriesBefore = tracer.queries.Load() + } + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if err := op(); err != nil { + b.Fatal(err) + } + } + b.StopTimer() + + settleDBStats(b, op) + after, err := snapshotDBStats(ctx, statsConn) + require.NoError(b, err, "stats snapshot") + + ops := float64(b.N + 1) + if tracer != nil { + b.ReportMetric(float64(tracer.queries.Load()-queriesBefore)/ops, "queries/op") + } + b.ReportMetric(float64(after.xactCommit-before.xactCommit)/ops, "xact/op") + b.ReportMetric(float64(after.tupReturned-before.tupReturned)/ops, "tup_returned/op") + b.ReportMetric(float64(after.tupFetched-before.tupFetched)/ops, "tup_fetched/op") + b.ReportMetric(float64(after.blksRead-before.blksRead)/ops, "blks_read/op") + b.ReportMetric(float64(after.blksHit-before.blksHit)/ops, "blks_hit/op") +} + +func settleDBStats(b *testing.B, op func() error) { + b.Helper() + time.Sleep(1100 * time.Millisecond) + require.NoError(b, op(), "stats flush op") + time.Sleep(100 * time.Millisecond) +} + +func benchAccountIDs(b *testing.B, ctx context.Context, conn *pgx.Conn) []string { + b.Helper() + + if ids := strings.TrimSpace(os.Getenv("NETMAP_ACCOUNTS")); ids != "" { + var out []string + for _, id := range strings.Split(ids, ",") { + if id = strings.TrimSpace(id); id != "" { + out = append(out, id) + } + } + return out + } + + rows, err := conn.Query(ctx, + "select account_id from peers group by account_id order by count(*) desc, account_id limit 10") + require.NoError(b, err, "list benchmark accounts") + ids, err := pgx.CollectRows(rows, pgx.RowTo[string]) + require.NoError(b, err, "collect benchmark accounts") + require.NotEmpty(b, ids, "no accounts found") + return ids +} + +func logAccountShape(b *testing.B, ctx context.Context, conn *pgx.Conn, accountID string) { + b.Helper() + + var peers, groups, users, policies, routes, resources, nsGroups int + err := conn.QueryRow(ctx, `select + (select count(*) from peers where account_id=$1), + (select count(*) from groups where account_id=$1), + (select count(*) from users where account_id=$1), + (select count(*) from policies where account_id=$1), + (select count(*) from routes where account_id=$1), + (select count(*) from network_resources where account_id=$1), + (select count(*) from name_server_groups where account_id=$1)`, accountID). + Scan(&peers, &groups, &users, &policies, &routes, &resources, &nsGroups) + require.NoError(b, err, "account shape") + b.Logf("account=%s peers=%d groups=%d users=%d policies=%d routes=%d resources=%d nsgroups=%d", + accountID, peers, groups, users, policies, routes, resources, nsGroups) +} + +type dbStats struct { + xactCommit int64 + tupReturned int64 + tupFetched int64 + blksRead int64 + blksHit int64 +} + +func snapshotDBStats(ctx context.Context, conn *pgx.Conn) (dbStats, error) { + var s dbStats + err := conn.QueryRow(ctx, `select xact_commit, tup_returned, tup_fetched, blks_read, blks_hit + from pg_stat_database where datname = current_database()`). + Scan(&s.xactCommit, &s.tupReturned, &s.tupFetched, &s.blksRead, &s.blksHit) + return s, err +} + +type queryCountTracer struct { + queries atomic.Int64 +} + +func (t *queryCountTracer) TraceQueryStart(ctx context.Context, _ *pgx.Conn, _ pgx.TraceQueryStartData) context.Context { + t.queries.Add(1) + return ctx +} + +func (t *queryCountTracer) TraceQueryEnd(context.Context, *pgx.Conn, pgx.TraceQueryEndData) {} diff --git a/shared/management/types/component_types.go b/management/server/types/legacynmap/component_types.go similarity index 99% rename from shared/management/types/component_types.go rename to management/server/types/legacynmap/component_types.go index 41ed758dd..a584b59af 100644 --- a/shared/management/types/component_types.go +++ b/management/server/types/legacynmap/component_types.go @@ -1,4 +1,4 @@ -package types +package legacynmap import ( "net/netip" diff --git a/management/server/types/legacynmap/converters.go b/management/server/types/legacynmap/converters.go new file mode 100644 index 000000000..34e709413 --- /dev/null +++ b/management/server/types/legacynmap/converters.go @@ -0,0 +1,127 @@ +package legacynmap + +import ( + nbdns "github.com/netbirdio/netbird/dns" + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" + routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/route" +) + +// NetworkMap is main's shape. It is copied rather than aliased because this +// branch's NetworkMap dropped ForceRoutingPeerDNSResolution, which main threads +// into PeerConfig.RoutingPeerDnsResolutionEnabled. +type NetworkMap struct { + Peers []*ComponentPeer + Network *Network + Routes []*route.Route + DNSConfig nbdns.Config + OfflinePeers []*ComponentPeer + FirewallRules []*FirewallRule + RoutesFirewallRules []*RouteFirewallRule + ForwardingRules []*ForwardingRule + AuthorizedUsers map[string]map[string]struct{} + EnableSSH bool + // ForceRoutingPeerDNSResolution forces the peer to run/use routing-peer DNS + // resolution regardless of the account-global setting, for reverse-proxy + // domain targets. + ForceRoutingPeerDNSResolution bool +} + +// The ToComponent converters below are main's methods, re-expressed as free +// functions because their receivers live in packages this one cannot extend. +// Bodies are otherwise unchanged. + +func peerToComponent(p *nbpeer.Peer) *ComponentPeer { + if p == nil { + return nil + } + cp := &ComponentPeer{ + ID: p.ID, + Key: p.Key, + IP: p.IP, + IPv6: p.IPv6, + DNSLabel: p.DNSLabel, + SSHKey: p.SSHKey, + SSHEnabled: p.SSHEnabled, + ServerSSHAllowed: p.Meta.Flags.ServerSSHAllowed, + AgentVersion: p.Meta.WtVersion, + SupportsSourcePrefixes: p.SupportsSourcePrefixes(), + SupportsIPv6: p.SupportsIPv6(), + LoginExpirationEnabled: p.LoginExpirationEnabled, + AddedWithSSOLogin: p.AddedWithSSOLogin(), + ProxyEmbedded: p.ProxyMeta.Embedded, + } + if p.LastLogin != nil { + cp.LastLogin = *p.LastLogin + } + return cp +} + +func groupToComponent(g *Group) *ComponentGroup { + if g == nil { + return nil + } + return &ComponentGroup{ + ID: g.ID, + PublicID: g.PublicID, + Name: g.Name, + Peers: g.Peers, + } +} + +func groupsToComponent(groups map[string]*Group) map[string]*ComponentGroup { + if groups == nil { + return nil + } + out := make(map[string]*ComponentGroup, len(groups)) + for id, g := range groups { + out[id] = groupToComponent(g) + } + return out +} + +func routerToComponent(n *routerTypes.NetworkRouter) *ComponentRouter { + if n == nil { + return nil + } + return &ComponentRouter{ + NetworkID: n.NetworkID, + PublicID: n.PublicID, + Peer: n.Peer, + PeerGroups: n.PeerGroups, + Masquerade: n.Masquerade, + Metric: n.Metric, + Enabled: n.Enabled, + } +} + +func routersToComponentMap(routers map[string]*routerTypes.NetworkRouter) map[string]*ComponentRouter { + if routers == nil { + return nil + } + out := make(map[string]*ComponentRouter, len(routers)) + for id, r := range routers { + out[id] = routerToComponent(r) + } + return out +} + +func resourceToComponent(n *resourceTypes.NetworkResource) *ComponentResource { + if n == nil { + return nil + } + return &ComponentResource{ + ID: n.ID, + PublicID: n.PublicID, + NetworkID: n.NetworkID, + AccountID: n.AccountID, + Name: n.Name, + Description: n.Description, + Type: ComponentResourceType(n.Type), + Address: n.Address, + Domain: n.Domain, + Prefix: n.Prefix, + Enabled: n.Enabled, + } +} diff --git a/management/server/types/legacynmap/copied_funcs.go b/management/server/types/legacynmap/copied_funcs.go new file mode 100644 index 000000000..4477967f5 --- /dev/null +++ b/management/server/types/legacynmap/copied_funcs.go @@ -0,0 +1,282 @@ +package legacynmap + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/miekg/dns" + log "github.com/sirupsen/logrus" + + nbdns "github.com/netbirdio/netbird/dns" + "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" + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" + routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" + nbroute "github.com/netbirdio/netbird/route" +) + +func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*ComponentPeer, direction int, includeIPv6 bool) []*RouteFirewallRule { + rulesExists := make(map[string]struct{}) + rules := make([]*RouteFirewallRule, 0) + + v4Sources, v6Sources := splitPeerSourcesByFamily(groupPeers) + + isV6Route := route.Network.Addr().Is6() + + // Skip v6 destination routes entirely for peers without IPv6 support + if isV6Route && !includeIPv6 { + return rules + } + + // Pick sources matching the destination family + sourceRanges := v4Sources + if isV6Route { + sourceRanges = v6Sources + } + + baseRule := RouteFirewallRule{ + PolicyID: rule.PolicyID, + RouteID: route.ID, + SourceRanges: sourceRanges, + Action: string(rule.Action), + Destination: route.Network.String(), + Protocol: string(rule.Protocol), + Domains: route.Domains, + IsDynamic: route.IsDynamic(), + } + + if len(rule.Ports) == 0 { + rules = append(rules, generateRulesWithPortRanges(baseRule, rule, rulesExists)...) + } else { + rules = append(rules, generateRulesWithPorts(ctx, baseRule, rule, rulesExists)...) + } + + // Generate v6 counterpart for dynamic routes and 0.0.0.0/0 exit node routes. + isDefaultV4 := !isV6Route && route.Network.Bits() == 0 + if includeIPv6 && (route.IsDynamic() || isDefaultV4) && len(v6Sources) > 0 { + v6Rule := baseRule + v6Rule.SourceRanges = v6Sources + if isDefaultV4 { + v6Rule.Destination = "::/0" + v6Rule.RouteID = route.ID + "-v6-default" + } + if len(rule.Ports) == 0 { + rules = append(rules, generateRulesWithPortRanges(v6Rule, rule, rulesExists)...) + } else { + rules = append(rules, generateRulesWithPorts(ctx, v6Rule, rule, rulesExists)...) + } + } + + return rules +} + +func filterPeerAppliedZones(ctx context.Context, accountZones []*zones.Zone, peerGroups LookupMap) []nbdns.CustomZone { + var customZones []nbdns.CustomZone + + if len(peerGroups) == 0 { + return customZones + } + + for _, zone := range accountZones { + if !zone.Enabled || len(zone.Records) == 0 { + continue + } + + hasAccess := false + for _, distGroupID := range zone.DistributionGroups { + if _, found := peerGroups[distGroupID]; found { + hasAccess = true + break + } + } + + if !hasAccess { + continue + } + + simpleRecords := make([]nbdns.SimpleRecord, 0, len(zone.Records)) + for _, record := range zone.Records { + var recordType int + rData := record.Content + + switch record.Type { + case records.RecordTypeA: + recordType = int(dns.TypeA) + case records.RecordTypeAAAA: + recordType = int(dns.TypeAAAA) + case records.RecordTypeCNAME: + recordType = int(dns.TypeCNAME) + rData = dns.Fqdn(record.Content) + default: + log.WithContext(ctx).Warnf("unknown DNS record type %s for record %s", record.Type, record.ID) + continue + } + + simpleRecords = append(simpleRecords, nbdns.SimpleRecord{ + Name: dns.Fqdn(record.Name), + Type: recordType, + Class: nbdns.DefaultClass, + TTL: record.TTL, + RData: rData, + }) + } + + customZones = append(customZones, nbdns.CustomZone{ + Domain: dns.Fqdn(zone.Domain), + Records: simpleRecords, + SearchDomainDisabled: !zone.EnableSearchDomain, + NonAuthoritative: true, + }) + } + + return customZones +} + +func getAllowedUserIDs(a *Account) map[string]struct{} { + users := make(map[string]struct{}) + for _, nbUser := range a.Users { + if !nbUser.IsBlocked() && !nbUser.IsServiceUser { + users[nbUser.Id] = struct{}{} + } + } + return users +} + +func getUniquePeerIDsFromGroupsIDs(a *Account, ctx context.Context, groups []string) []string { + peerIDs := make(map[string]struct{}, len(groups)) // we expect at least one peer per group as initial capacity + for _, groupID := range groups { + group := a.GetGroup(groupID) + if group == nil { + log.WithContext(ctx).Warnf("group %s doesn't exist under account %s, will continue map generation without it", groupID, a.Id) + continue + } + + if group.IsGroupAll() || len(groups) == 1 { + return group.Peers + } + + for _, peerID := range group.Peers { + peerIDs[peerID] = struct{}{} + } + } + + ids := make([]string, 0, len(peerIDs)) + for peerID := range peerIDs { + ids = append(ids, peerID) + } + + return ids +} + +func forcesRoutingPeerDNSResolution(a *Account, peerID string, routers map[string]map[string]*routerTypes.NetworkRouter) bool { + targeted := proxyTargetedDomainResourceIDs(a) + if len(targeted) == 0 { + return false + } + + for _, resource := range a.NetworkResources { + if resource == nil || !resource.Enabled || resource.Type != resourceTypes.Domain { + continue + } + if _, ok := targeted[resource.ID]; !ok { + continue + } + if _, isRouter := routers[resource.NetworkID][peerID]; isRouter { + return true + } + } + + return false +} + +func proxyTargetedDomainResourceIDs(a *Account) map[string]struct{} { + ids := make(map[string]struct{}) + for _, svc := range a.Services { + if svc == nil || !svc.Enabled || svc.Terminated { + continue + } + for _, target := range svc.Targets { + if target == nil || !target.Enabled { + continue + } + if target.TargetType == service.TargetTypeDomain { + ids[target.TargetId] = struct{}{} + } + } + } + return ids +} + +func splitPeerSourcesByFamily(groupPeers []*ComponentPeer) (v4, v6 []string) { + v4 = make([]string, 0, len(groupPeers)) + v6 = make([]string, 0, len(groupPeers)) + for _, peer := range groupPeers { + if peer == nil { + continue + } + v4 = append(v4, fmt.Sprintf(AllowedIPsFormat, peer.IP)) + if peer.IPv6.IsValid() { + v6 = append(v6, fmt.Sprintf(AllowedIPsV6Format, peer.IPv6)) + } + } + return +} + +func generateRulesWithPortRanges(baseRule RouteFirewallRule, rule *PolicyRule, rulesExists map[string]struct{}) []*RouteFirewallRule { + rules := make([]*RouteFirewallRule, 0) + + ruleIDBase := generateRuleIDBase(rule, baseRule) + if len(rule.Ports) == 0 { + if len(rule.PortRanges) == 0 { + if _, ok := rulesExists[ruleIDBase]; !ok { + rulesExists[ruleIDBase] = struct{}{} + rules = append(rules, &baseRule) + } + } else { + for _, portRange := range rule.PortRanges { + ruleID := fmt.Sprintf("%s%d-%d", ruleIDBase, portRange.Start, portRange.End) + if _, ok := rulesExists[ruleID]; !ok { + rulesExists[ruleID] = struct{}{} + pr := baseRule + pr.PortRange = portRange + rules = append(rules, &pr) + } + } + } + return rules + } + + return rules +} + +func generateRulesWithPorts(ctx context.Context, baseRule RouteFirewallRule, rule *PolicyRule, rulesExists map[string]struct{}) []*RouteFirewallRule { + rules := make([]*RouteFirewallRule, 0) + ruleIDBase := generateRuleIDBase(rule, baseRule) + + for _, port := range rule.Ports { + ruleID := ruleIDBase + port + if _, ok := rulesExists[ruleID]; ok { + continue + } + rulesExists[ruleID] = struct{}{} + + pr := baseRule + p, err := strconv.ParseUint(port, 10, 16) + if err != nil { + log.WithContext(ctx).Errorf("failed to parse port %s for rule: %s", port, rule.ID) + continue + } + + pr.Port = uint16(p) + rules = append(rules, &pr) + } + + return rules +} + +func generateRuleIDBase(rule *PolicyRule, baseRule RouteFirewallRule) string { + return rule.ID + strings.Join(baseRule.SourceRanges, ",") + strconv.Itoa(FirewallRuleDirectionIN) + baseRule.Protocol + baseRule.Action +} diff --git a/management/server/types/legacynmap/doc.go b/management/server/types/legacynmap/doc.go new file mode 100644 index 000000000..e0b0ecd11 --- /dev/null +++ b/management/server/types/legacynmap/doc.go @@ -0,0 +1,16 @@ +// Package legacynmap is a frozen copy of main's Account → NetworkMapComponents +// → NetworkMap → proto path. It exists only to measure this tree against main: +// the proto-equivalence test runs it over a production database copy, and the +// nmaptest golden suite runs it as a third mode so every case pins all three +// shapes to one expectation. +// +// It lives in its own package so it cannot reach this tree's unexported +// helpers — a divergence can therefore never be hidden by the two sides +// sharing code. Nothing in production imports it. +// +// Types are aliased rather than copied where they are byte-identical between +// main and this branch. Anything that drifted is copied instead; see +// converters.go and copied_funcs.go. +// +// Delete this package once the nmdata refactor is validated. +package legacynmap diff --git a/management/server/types/legacynmap/equivalence_test.go b/management/server/types/legacynmap/equivalence_test.go new file mode 100644 index 000000000..d12e666b8 --- /dev/null +++ b/management/server/types/legacynmap/equivalence_test.go @@ -0,0 +1,680 @@ +//go:build nmapequiv + +// Main-vs-branch equivalence check. For every peer of every account in a real +// Postgres copy it computes the client-facing proto.NetworkMap twice: +// +// - legacy path: main's Account → NetworkMapComponents → Calculate → proto +// (the frozen copy in this package) +// - store path: the pgsql nmdata store's NetworkMapData → components → +// Calculate → ToSyncResponse → proto (no Account involved) +// - account path: Account → toNetworkMapData twins → components → Calculate +// → ToSyncResponse → proto (the in-memory builder, no store queries) +// +// Both new paths are checked against the legacy proto. +// +// proto.NetworkMap is generated code identical in both trees, which is what +// makes it the one usable comparison surface — the intermediate Go types differ +// by design. proto.Equal would trip over repeated-field ordering, so both sides +// are canonicalized first. +// +// NETBIRD_STORE_ENGINE_POSTGRES_DSN='...' go test -tags nmapequiv \ +// -run TestNetworkMapProtoEquivalence -count=1 -timeout 60m \ +// ./management/server/types/legacynmap/ +// +// Accounts are loaded one at a time and released between iterations, so peak +// memory tracks the largest single account rather than the whole database. +// +// Env knobs: NETMAP_ACCOUNTS (comma-separated ids, skips discovery), +// NETMAP_MAX_ACCOUNTS (0 = all), NETMAP_MAX_PEERS (0 = all). Fails at the +// first divergence. +package legacynmap_test + +import ( + "bytes" + "cmp" + "context" + "os" + "runtime" + "runtime/debug" + "slices" + "sort" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "google.golang.org/protobuf/encoding/prototext" + goproto "google.golang.org/protobuf/proto" + "gorm.io/driver/postgres" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" + + "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller" + "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache" + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" + networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql" + mgmtgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" + "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator" + "github.com/netbirdio/netbird/management/server/settings" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/management/server/types/legacynmap" + "github.com/netbirdio/netbird/shared/management/networkmap" + "github.com/netbirdio/netbird/shared/management/proto" +) + +const ( + equivDNSName = "netbird.cloud" + progressEvery = 5000 +) + +type equivStats struct { + accounts int + peersChecked int +} + +func TestNetworkMapProtoEquivalence(t *testing.T) { + if testing.Short() { + t.Skip("prod-db equivalence test, skipped in short mode") + } + dsn := equivDSN() + if dsn == "" { + t.Skip("NETBIRD_STORE_ENGINE_POSTGRES_DSN not set") + } + + ctx := context.Background() + // skipMigration=true: this reads a restored production copy and must not + // alter its schema. Flip to false only if reads fail on an older dump. + testStore, err := store.NewPostgresqlStore(ctx, dsn, nil, true) + require.NoError(t, err, "connect to postgres") + t.Cleanup(func() { testStore.Close(ctx) }) + + pgStore, err := networkmap_pgsql.NewPostgresqlStore(ctx, dsn) + require.NoError(t, err, "connect nmdata store") + t.Cleanup(func() { pgStore.Pool.Close() }) + nmStore := nmDataStore(t, pgStore) + + accountIDs := equivAccountIDs(t, dsn) + require.NotEmpty(t, accountIDs, "no accounts selected") + + stats := &equivStats{accounts: len(accountIDs)} + maxPeers := envInt("NETMAP_MAX_PEERS", 0) + + for i, accountID := range accountIDs { + account, err := testStore.GetAccount(ctx, accountID) + if err != nil { + t.Logf("account %s: load failed, skipping: %v", accountID, err) + continue + } + + checkAccount(ctx, t, testStore, nmStore, account, maxPeers, stats) + + account = nil + debug.FreeOSMemory() + + if i%progressEvery == 0 { + var ms runtime.MemStats + runtime.ReadMemStats(&ms) + t.Logf("progress: accounts=%d/%d peers_checked=%d heap=%dMiB", i, len(accountIDs), stats.peersChecked, ms.HeapAlloc>>20) + } + } + + t.Logf("equivalence: accounts=%d peers_checked=%d — no divergence", + stats.accounts, stats.peersChecked) +} + +// checkAccount compares both paths for every peer of one account. Nothing is +// retained across peers, so memory stays flat within an account. +func checkAccount(ctx context.Context, t *testing.T, accountStore store.Store, nmStore *networkmapdb.NetworkMapDBStoreImpl, account *types.Account, maxPeers int, stats *equivStats) { + t.Helper() + + if len(account.Peers) == 0 { + return + } + + nmData, err := nmStore.GetNetworkMapData(ctx, account.Id) + require.NoError(t, err, "account %s: nmdata store load", account.Id) + + validated := make(map[string]struct{}, len(account.Peers)) + peerIDs := make([]string, 0, len(account.Peers)) + for peerID := range account.Peers { + validated[peerID] = struct{}{} + peerIDs = append(peerIDs, peerID) + } + sort.Strings(peerIDs) + if maxPeers > 0 && len(peerIDs) > maxPeers { + peerIDs = peerIDs[:maxPeers] + } + + // Production fills ValidatedPeers via the integrated-validator wrapper; here + // every peer counts as validated, matching the legacy side's map. + nmData.ValidatedPeers = validated + + // Custom DNS zones are built twice from the same rows — the account side + // from the zones manager, the store side in SQL — so both are fed in and + // compared rather than dropped. The same goes for the peers zone below: + // each side computes it with its own helper, which is where an AAAA gate + // that disagrees between the two would show up. + accountZones, err := accountStore.GetAccountZones(ctx, store.LockingStrengthNone, account.Id) + require.NoError(t, err, "account %s: load account zones", account.Id) + + resourcePolicies := account.GetResourcePoliciesMap() + routers := account.GetResourceRoutersMap() + groupUsers := account.GetActiveGroupUsers() + + // The reverse-proxy ACLs are synthesised, never persisted. Both new paths + // derive them inside the twin; main derived them in the controller, onto + // the account, before the resource-policy map. The legacy side therefore + // runs on its own view of the policies — a shallow copy so the account the + // other two paths read stays untouched and cannot double-count them. + legacyAccount := *account + if synth := legacynmap.SynthesizeProxyPolicies(account); len(synth) > 0 { + legacyAccount.Policies = append(slices.Clone(account.Policies), synth...) + } + legacyResourcePolicies := legacyAccount.GetResourcePoliciesMap() + + settings := account.Settings + if settings == nil { + settings = &types.Settings{} + } + + accountPeersZone := account.GetPeersCustomZone(ctx, equivDNSName) + storePeersZone := networkmap.PeersCustomZone(ctx, account.Id, equivDNSName, nmData.Peers, controller.IPv6AllowedPeersFromData(nmData)) + + for _, peerID := range peerIDs { + peer := account.Peers[peerID] + if peer == nil { + continue + } + dataPeer := nmData.Peers[peerID] + if dataPeer == nil { + t.Fatalf("after %d peers: account=%s peer=%s present in account store, missing in nmdata store", stats.peersChecked, account.Id, peerID) + } + + // STORE PATH — nmdata store through the production computation, mirroring + // the controller's networkMapFromData. + components := nmData.GetPeerNetworkMapComponents(peerID, storePeersZone) + storeNM := &types.NetworkMap{Network: components.Network} + if !components.IsEmpty() { + storeNM = types.CalculateNetworkMapFromComponents(ctx, components) + } + // A separate cache per side: sharing one would let the first path + // populate entries the second then reuses, which can mask a real diff. + storeProto := mgmtgrpc.ToSyncResponse( + ctx, nil, nil, nil, dataPeer, nil, nil, storeNM, equivDNSName, nil, + &cache.DNSConfigCache{}, nmData.AccountSettings, settings.Extra, nil, 0, + ).NetworkMap + + // ACCOUNT PATH — Account → toNetworkMapData twins → components. + acctNM := account.GetPeerNetworkMapFromComponents( + ctx, peerID, accountPeersZone, accountZones, validated, resourcePolicies, routers, nil, groupUsers, + ) + acctProto := mgmtgrpc.ToSyncResponse( + ctx, nil, nil, nil, types.TwinPeer(peer), nil, nil, acctNM, equivDNSName, nil, + &cache.DNSConfigCache{}, types.TwinAccountSettings(settings), settings.Extra, nil, 0, + ).NetworkMap + + // LEGACY PATH — main's frozen copy. + legacyNM := legacynmap.GetPeerNetworkMapFromComponents( + &legacyAccount, ctx, peerID, accountPeersZone, accountZones, validated, legacyResourcePolicies, routers, nil, groupUsers, + ) + if legacyNM == nil { + t.Fatalf("after %d peers: account=%s peer=%s legacy NetworkMap nil, new non-nil", stats.peersChecked, account.Id, peerID) + } + legacyProto := legacynmap.ToProtoNetworkMap( + ctx, peer, legacyNM, equivDNSName, settings, nil, &cache.DNSConfigCache{}, 0, + ) + + canonicalize(legacyProto) + canonicalize(storeProto) + canonicalize(acctProto) + stats.peersChecked++ + + if !goproto.Equal(legacyProto, storeProto) { + t.Fatalf("after %d peers: store path: %s", stats.peersChecked, describeDivergence(legacyProto, storeProto, account.Id, peerID)) + } + if !goproto.Equal(legacyProto, acctProto) { + t.Fatalf("after %d peers: account path: %s", stats.peersChecked, describeDivergence(legacyProto, acctProto, account.Id, peerID)) + } + } +} + +// nmDataStore wraps a raw connection store the way production's factory does. +// The validator marks every peer validated and the extra settings are empty: +// checkAccount overwrites ValidatedPeers anyway, and neither reaches the +// compared network map. +func nmDataStore(tb testing.TB, s networkmapdb.NetworkMapDBStore) *networkmapdb.NetworkMapDBStoreImpl { + tb.Helper() + + extraSettings := settings.NewMockManager(gomock.NewController(tb)) + extraSettings.EXPECT().GetExtraSettings(gomock.Any(), gomock.Any()).Return(&types.ExtraSettings{}, nil).AnyTimes() + + return &networkmapdb.NetworkMapDBStoreImpl{ + Store: s, + IntegratedPeerValidator: &validator.IntegratedValidatorImpl{}, + ExtraSettingsManager: extraSettings, + } +} + +func equivDSN() string { + if dsn := os.Getenv("NETBIRD_STORE_ENGINE_POSTGRES_DSN"); dsn != "" { + return dsn + } + return os.Getenv("NB_STORE_ENGINE_POSTGRES_DSN") +} + +// equivAccountIDs lists account ids with an id-only query. store.GetAllAccounts +// would hydrate every account in the database before the first comparison runs. +// Sorting happens in Go so the order does not depend on database collation. +func equivAccountIDs(t *testing.T, dsn string) []string { + t.Helper() + + if ids := strings.TrimSpace(os.Getenv("NETMAP_ACCOUNTS")); ids != "" { + var out []string + for _, id := range strings.Split(ids, ",") { + if id = strings.TrimSpace(id); id != "" { + out = append(out, id) + } + } + sort.Strings(out) + return out + } + + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: gormlogger.Discard}) + require.NoError(t, err, "open id-listing connection") + defer func() { + if sqlDB, err := db.DB(); err == nil { + sqlDB.Close() + } + }() + + var ids []string + require.NoError(t, db.Model(&types.Account{}).Pluck("id", &ids).Error) + sort.Strings(ids) + + if max := envInt("NETMAP_MAX_ACCOUNTS", 0); max > 0 && len(ids) > max { + ids = ids[:max] + } + return ids +} + +func envInt(name string, def int) int { + if v := os.Getenv(name); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return def +} + +// canonicalize sorts every repeated field by a stable key. Both paths iterate Go +// maps while building these slices, so order can differ even when the content is +// identical; without this proto.Equal reports noise. +func canonicalize(nm *proto.NetworkMap) { + if nm == nil { + return + } + slices.SortFunc(nm.RemotePeers, cmpRemotePeer) + slices.SortFunc(nm.OfflinePeers, cmpRemotePeer) + slices.SortFunc(nm.Routes, cmpRoute) + slices.SortFunc(nm.FirewallRules, cmpFirewallRule) + slices.SortFunc(nm.RoutesFirewallRules, cmpRouteFirewallRule) + slices.SortFunc(nm.ForwardingRules, cmpForwardingRule) + + for _, r := range nm.FirewallRules { + slices.SortFunc(r.SourcePrefixes, bytes.Compare) + } + for _, r := range nm.RoutesFirewallRules { + slices.Sort(r.SourceRanges) + } + canonicalizeDNSConfig(nm.DNSConfig) + canonicalizeSSHAuth(nm.SshAuth) +} + +func canonicalizeDNSConfig(d *proto.DNSConfig) { + if d == nil { + return + } + for _, g := range d.NameServerGroups { + if g == nil { + continue + } + slices.Sort(g.Domains) + slices.SortFunc(g.NameServers, func(a, b *proto.NameServer) int { + if a == nil || b == nil { + return boolCmp(a == nil, b == nil) + } + if c := cmp.Compare(a.IP, b.IP); c != 0 { + return c + } + if c := cmp.Compare(a.Port, b.Port); c != 0 { + return c + } + return cmp.Compare(a.NSType, b.NSType) + }) + } + slices.SortFunc(d.NameServerGroups, func(a, b *proto.NameServerGroup) int { + return cmp.Compare(nsgKey(a), nsgKey(b)) + }) + for _, z := range d.CustomZones { + if z == nil { + continue + } + slices.SortFunc(z.Records, cmpSimpleRecord) + } + slices.SortFunc(d.CustomZones, func(a, b *proto.CustomZone) int { + if a == nil || b == nil { + return boolCmp(a == nil, b == nil) + } + return cmp.Compare(a.Domain, b.Domain) + }) +} + +// canonicalizeSSHAuth sorts AuthorizedUsers and re-keys MachineUsers.Indexes +// against the new ordering, preserving which machine user maps to which hashes. +func canonicalizeSSHAuth(s *proto.SSHAuth) { + if s == nil || len(s.AuthorizedUsers) == 0 { + return + } + type hashed struct { + bytes []byte + old uint32 + } + entries := make([]hashed, len(s.AuthorizedUsers)) + for i, b := range s.AuthorizedUsers { + entries[i] = hashed{bytes: b, old: uint32(i)} + } + slices.SortFunc(entries, func(a, b hashed) int { return bytes.Compare(a.bytes, b.bytes) }) + + remap := make(map[uint32]uint32, len(entries)) + sorted := make([][]byte, len(entries)) + for newIdx, e := range entries { + remap[e.old] = uint32(newIdx) + sorted[newIdx] = e.bytes + } + s.AuthorizedUsers = sorted + + for _, mu := range s.MachineUsers { + if mu == nil { + continue + } + for i, oldIdx := range mu.Indexes { + if newIdx, ok := remap[oldIdx]; ok { + mu.Indexes[i] = newIdx + } + } + slices.Sort(mu.Indexes) + } +} + +func boolCmp(a, b bool) int { + if a == b { + return 0 + } + if a { + return 1 + } + return -1 +} + +func nsgKey(g *proto.NameServerGroup) string { + if g == nil { + return "" + } + var parts []string + for _, ns := range g.NameServers { + if ns == nil { + continue + } + parts = append(parts, ns.IP+":"+strconv.FormatInt(ns.Port, 10)+":"+strconv.FormatInt(ns.NSType, 10)) + } + slices.Sort(parts) + key := strings.Join(parts, ",") + domains := append([]string(nil), g.Domains...) + slices.Sort(domains) + key += "|" + strings.Join(domains, "|") + if g.Primary { + key += "|P" + } + if g.SearchDomainsEnabled { + key += "|S" + } + return key +} + +func cmpSimpleRecord(a, b *proto.SimpleRecord) int { + if a == nil || b == nil { + return boolCmp(a == nil, b == nil) + } + if c := cmp.Compare(a.Name, b.Name); c != 0 { + return c + } + if c := cmp.Compare(a.Type, b.Type); c != 0 { + return c + } + if c := cmp.Compare(a.Class, b.Class); c != 0 { + return c + } + if c := cmp.Compare(a.RData, b.RData); c != 0 { + return c + } + return cmp.Compare(a.TTL, b.TTL) +} + +func cmpRemotePeer(a, b *proto.RemotePeerConfig) int { + if a == nil || b == nil { + return boolCmp(a == nil, b == nil) + } + return cmp.Compare(a.WgPubKey, b.WgPubKey) +} + +func cmpRoute(a, b *proto.Route) int { + if a == nil || b == nil { + return boolCmp(a == nil, b == nil) + } + if c := cmp.Compare(a.ID, b.ID); c != 0 { + return c + } + if c := cmp.Compare(a.NetID, b.NetID); c != 0 { + return c + } + if c := cmp.Compare(a.Network, b.Network); c != 0 { + return c + } + if c := cmp.Compare(a.Peer, b.Peer); c != 0 { + return c + } + if c := cmp.Compare(a.Metric, b.Metric); c != 0 { + return c + } + return slices.Compare(a.Domains, b.Domains) +} + +func cmpFirewallRule(a, b *proto.FirewallRule) int { + if a == nil || b == nil { + return boolCmp(a == nil, b == nil) + } + if c := bytes.Compare(a.PolicyID, b.PolicyID); c != 0 { + return c + } + if c := cmp.Compare(a.PeerIP, b.PeerIP); c != 0 { //nolint:staticcheck + return c + } + if c := cmp.Compare(int32(a.Direction), int32(b.Direction)); c != 0 { + return c + } + if c := cmp.Compare(int32(a.Action), int32(b.Action)); c != 0 { + return c + } + if c := cmp.Compare(int32(a.Protocol), int32(b.Protocol)); c != 0 { + return c + } + if c := cmp.Compare(a.Port, b.Port); c != 0 { + return c + } + return cmp.Compare(portInfoKey(a.PortInfo), portInfoKey(b.PortInfo)) +} + +func cmpRouteFirewallRule(a, b *proto.RouteFirewallRule) int { + if a == nil || b == nil { + return boolCmp(a == nil, b == nil) + } + if c := bytes.Compare(a.PolicyID, b.PolicyID); c != 0 { + return c + } + if c := cmp.Compare(a.RouteID, b.RouteID); c != 0 { + return c + } + if c := cmp.Compare(a.Destination, b.Destination); c != 0 { + return c + } + if c := cmp.Compare(int32(a.Protocol), int32(b.Protocol)); c != 0 { + return c + } + if c := cmp.Compare(portInfoKey(a.PortInfo), portInfoKey(b.PortInfo)); c != 0 { + return c + } + if c := cmp.Compare(int32(a.Action), int32(b.Action)); c != 0 { + return c + } + if c := slices.Compare(a.Domains, b.Domains); c != 0 { + return c + } + if c := slices.Compare(a.SourceRanges, b.SourceRanges); c != 0 { + return c + } + if c := cmp.Compare(a.CustomProtocol, b.CustomProtocol); c != 0 { + return c + } + return boolCmp(a.IsDynamic, b.IsDynamic) +} + +func cmpForwardingRule(a, b *proto.ForwardingRule) int { + if a == nil || b == nil { + return boolCmp(a == nil, b == nil) + } + if c := cmp.Compare(int32(a.Protocol), int32(b.Protocol)); c != 0 { + return c + } + return bytes.Compare(a.TranslatedAddress, b.TranslatedAddress) +} + +func portInfoKey(pi *proto.PortInfo) string { + if pi == nil { + return "" + } + switch sel := pi.PortSelection.(type) { + case *proto.PortInfo_Port: + return "P" + strconv.FormatUint(uint64(sel.Port), 10) + case *proto.PortInfo_Range_: + if sel.Range == nil { + return "R" + } + return "R" + strconv.FormatUint(uint64(sel.Range.Start), 10) + "-" + strconv.FormatUint(uint64(sel.Range.End), 10) + } + return "" +} + +// describeDivergence names the first differing field so a failure is actionable +// without re-running against the database. +func describeDivergence(legacy, updated *proto.NetworkMap, accountID, peerID string) string { + prefix := "account=" + accountID + " peer=" + peerID + + lens := []struct { + field string + a, b int + diff func() string + }{ + {"RemotePeers", len(legacy.RemotePeers), len(updated.RemotePeers), func() string { return diffLists(legacy.RemotePeers, updated.RemotePeers) }}, + {"OfflinePeers", len(legacy.OfflinePeers), len(updated.OfflinePeers), func() string { return diffLists(legacy.OfflinePeers, updated.OfflinePeers) }}, + {"Routes", len(legacy.Routes), len(updated.Routes), func() string { return diffLists(legacy.Routes, updated.Routes) }}, + {"FirewallRules", len(legacy.FirewallRules), len(updated.FirewallRules), func() string { return diffLists(legacy.FirewallRules, updated.FirewallRules) }}, + {"RoutesFirewallRules", len(legacy.RoutesFirewallRules), len(updated.RoutesFirewallRules), func() string { return diffLists(legacy.RoutesFirewallRules, updated.RoutesFirewallRules) }}, + {"ForwardingRules", len(legacy.ForwardingRules), len(updated.ForwardingRules), func() string { return diffLists(legacy.ForwardingRules, updated.ForwardingRules) }}, + } + for _, l := range lens { + if l.a != l.b { + return prefix + " field=" + l.field + " legacy_len=" + strconv.Itoa(l.a) + " new_len=" + strconv.Itoa(l.b) + l.diff() + } + } + + for i := range legacy.RemotePeers { + if !goproto.Equal(legacy.RemotePeers[i], updated.RemotePeers[i]) { + return prefix + " field=RemotePeers[" + strconv.Itoa(i) + "] legacy=" + protoStr(legacy.RemotePeers[i]) + " new=" + protoStr(updated.RemotePeers[i]) + } + } + for i := range legacy.Routes { + if !goproto.Equal(legacy.Routes[i], updated.Routes[i]) { + return prefix + " field=Routes[" + strconv.Itoa(i) + "] legacy=" + protoStr(legacy.Routes[i]) + " new=" + protoStr(updated.Routes[i]) + } + } + for i := range legacy.FirewallRules { + if !goproto.Equal(legacy.FirewallRules[i], updated.FirewallRules[i]) { + return prefix + " field=FirewallRules[" + strconv.Itoa(i) + "] legacy=" + protoStr(legacy.FirewallRules[i]) + " new=" + protoStr(updated.FirewallRules[i]) + } + } + for i := range legacy.RoutesFirewallRules { + if !goproto.Equal(legacy.RoutesFirewallRules[i], updated.RoutesFirewallRules[i]) { + return prefix + " field=RoutesFirewallRules[" + strconv.Itoa(i) + "] legacy=" + protoStr(legacy.RoutesFirewallRules[i]) + " new=" + protoStr(updated.RoutesFirewallRules[i]) + } + } + if !goproto.Equal(legacy.PeerConfig, updated.PeerConfig) { + return prefix + " field=PeerConfig legacy=" + protoStr(legacy.PeerConfig) + " new=" + protoStr(updated.PeerConfig) + } + if !goproto.Equal(legacy.DNSConfig, updated.DNSConfig) { + return prefix + " field=DNSConfig legacy=" + protoStr(legacy.DNSConfig) + " new=" + protoStr(updated.DNSConfig) + } + if !goproto.Equal(legacy.SshAuth, updated.SshAuth) { + return prefix + " field=SshAuth legacy=" + protoStr(legacy.SshAuth) + " new=" + protoStr(updated.SshAuth) + } + if legacy.Serial != updated.Serial { + return prefix + " field=Serial legacy=" + strconv.FormatUint(legacy.Serial, 10) + " new=" + strconv.FormatUint(updated.Serial, 10) + } + return prefix + " (repeated fields equal element-wise — scalar/oneof mismatch)" +} + +// diffLists reports the multiset difference of two repeated proto fields, so a +// length mismatch shows which elements each side is missing. +func diffLists[M goproto.Message](legacy, updated []M) string { + counts := make(map[string]int) + for _, m := range legacy { + counts[prototext.MarshalOptions{}.Format(m)]++ + } + for _, m := range updated { + counts[prototext.MarshalOptions{}.Format(m)]-- + } + + var onlyLegacy, onlyNew []string + for k, c := range counts { + for ; c > 0; c-- { + onlyLegacy = append(onlyLegacy, k) + } + for ; c < 0; c++ { + onlyNew = append(onlyNew, k) + } + } + slices.Sort(onlyLegacy) + slices.Sort(onlyNew) + + var b strings.Builder + for _, k := range onlyLegacy { + b.WriteString("\n only_legacy: " + k) + } + for _, k := range onlyNew { + b.WriteString("\n only_new: " + k) + } + return b.String() +} + +func protoStr(m goproto.Message) string { + if m == nil { + return " " + } + s := prototext.Format(m) + const maxLen = 800 + if len(s) > maxLen { + return s[:maxLen] + "...(truncated)" + } + return s +} diff --git a/management/server/types/legacynmap/firewall_helpers.go b/management/server/types/legacynmap/firewall_helpers.go new file mode 100644 index 000000000..d78690f3e --- /dev/null +++ b/management/server/types/legacynmap/firewall_helpers.go @@ -0,0 +1,155 @@ +package legacynmap + +import ( + "strconv" + "strings" + + v "github.com/hashicorp/go-version" + + "github.com/netbirdio/netbird/version" +) + +const ( + firewallRuleMinPortRangesVer = "0.48.0" + firewallRuleMinNativeSSHVer = "0.60.0" + + nativeSSHPortString = "22022" + nativeSSHPortNumber = 22022 + defaultSSHPortString = "22" + defaultSSHPortNumber = 22 +) + +type supportedFeatures struct { + nativeSSH bool + portRanges bool +} + +type LookupMap map[string]struct{} + +func PolicyRuleImpliesLegacySSH(rule *PolicyRule) bool { + return rule.Protocol == PolicyRuleProtocolALL || (rule.Protocol == PolicyRuleProtocolTCP && (portsIncludesSSH(rule.Ports) || portRangeIncludesSSH(rule.PortRanges))) +} + +func portRangeIncludesSSH(portRanges []RulePortRange) bool { + for _, pr := range portRanges { + if (pr.Start <= defaultSSHPortNumber && pr.End >= defaultSSHPortNumber) || (pr.Start <= nativeSSHPortNumber && pr.End >= nativeSSHPortNumber) { + return true + } + } + return false +} + +func portsIncludesSSH(ports []string) bool { + for _, port := range ports { + if port == defaultSSHPortString || port == nativeSSHPortString { + return true + } + } + return false +} + +// ExpandPortsAndRanges expands Ports and PortRanges of a rule into individual firewall rules. +func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *ComponentPeer) []*FirewallRule { + features := peerSupportedFirewallFeatures(peer.AgentVersion) + + var expanded []*FirewallRule + + for _, port := range rule.Ports { + fr := base + fr.Port = port + expanded = append(expanded, &fr) + } + + for _, portRange := range rule.PortRanges { + if len(rule.Ports) > 0 { + break + } + fr := base + + if features.portRanges { + fr.PortRange = portRange + } else { + if portRange.Start != portRange.End { + continue + } + fr.Port = strconv.FormatUint(uint64(portRange.Start), 10) + } + expanded = append(expanded, &fr) + } + + if shouldCheckRulesForNativeSSH(features.nativeSSH, rule, peer) || rule.Protocol == PolicyRuleProtocolNetbirdSSH { + expanded = addNativeSSHRule(base, expanded) + } + + return expanded +} + +func addNativeSSHRule(base FirewallRule, expanded []*FirewallRule) []*FirewallRule { + shouldAdd := false + for _, fr := range expanded { + if isPortInRule(nativeSSHPortString, 22022, fr) { + return expanded + } + if isPortInRule(defaultSSHPortString, 22, fr) { + shouldAdd = true + } + } + if !shouldAdd { + return expanded + } + + fr := base + fr.Port = nativeSSHPortString + return append(expanded, &fr) +} + +func isPortInRule(portString string, portInt uint16, rule *FirewallRule) bool { + return rule.Port == portString || (rule.PortRange.Start <= portInt && portInt <= rule.PortRange.End) +} + +func shouldCheckRulesForNativeSSH(supportsNative bool, rule *PolicyRule, peer *ComponentPeer) bool { + return supportsNative && peer.SSHEnabled && peer.ServerSSHAllowed && rule.Protocol == PolicyRuleProtocolTCP +} + +func peerSupportedFirewallFeatures(peerVer string) supportedFeatures { + if version.IsDevelopmentVersion(peerVer) { + return supportedFeatures{true, true} + } + + var features supportedFeatures + + meetMinVer, err := meetsMinVersion(firewallRuleMinNativeSSHVer, peerVer) + features.nativeSSH = err == nil && meetMinVer + + if features.nativeSSH { + features.portRanges = true + } else { + meetMinVer, err = meetsMinVersion(firewallRuleMinPortRangesVer, peerVer) + features.portRanges = err == nil && meetMinVer + } + + return features +} + +// meetsMinVersion is main's version.MeetsMinVersion, which does not exist at HEAD. +func meetsMinVersion(minVer, peerVer string) (bool, error) { + peerVer = sanitizeVersion(peerVer) + minVer = sanitizeVersion(minVer) + + peerNBVer, err := v.NewVersion(peerVer) + if err != nil { + return false, err + } + + constraints, err := v.NewConstraint(">= " + minVer) + if err != nil { + return false, err + } + + return constraints.Check(peerNBVer), nil +} + +func sanitizeVersion(version string) string { + parts := strings.Split(version, "-") + return parts[0] +} diff --git a/management/server/types/legacynmap/networkmap_components.go b/management/server/types/legacynmap/networkmap_components.go new file mode 100644 index 000000000..3f71dafa5 --- /dev/null +++ b/management/server/types/legacynmap/networkmap_components.go @@ -0,0 +1,1032 @@ +package legacynmap + +import ( + "context" + "maps" + "net/netip" + "slices" + "strconv" + "strings" + "sync" + "time" + + "github.com/netbirdio/netbird/client/ssh/auth" + nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/route" + "github.com/netbirdio/netbird/shared/management/domain" +) + +type NetworkMapComponents struct { + PeerID string + + Network *Network + AccountSettings *AccountSettingsInfo + DNSSettings *DNSSettings + CustomZoneDomain string + + Peers map[string]*ComponentPeer + Groups map[string]*ComponentGroup + Policies []*Policy + Routes []*route.Route + NameServerGroups []*nbdns.NameServerGroup + AllDNSRecords []nbdns.SimpleRecord + AccountZones []nbdns.CustomZone + ResourcePoliciesMap map[string][]*Policy + RoutersMap map[string]map[string]*ComponentRouter + NetworkResources []*ComponentResource + + GroupIDToUserIDs map[string][]string + AllowedUserIDs map[string]struct{} + PostureFailedPeers map[string]map[string]struct{} + + RouterPeers map[string]*ComponentPeer + + // NetworkXIDToPublicID maps Network.ID (xid) → PublicID. + // Consumed by the envelope encoder to + // translate RoutersMap keys and NetworkResource.NetworkID references + // to compact uint32 ids. Legacy Calculate() doesn't consult it. + NetworkXIDToPublicID map[string]string + + // PostureCheckXIDToPublicID maps posture.Checks.ID (xid) → PublicID. + // Same role as NetworkXIDToPublicID, used for PostureFailedPeers keys and + // policy SourcePostureChecks references. + PostureCheckXIDToPublicID map[string]string + routesByPeerOnce sync.Once + routesByPeerIdx map[string][]routeIndexEntry + + // true when returning an empty-like map (returned instead of nil) + empty bool + + // ForceRoutingPeerDNSResolution forces the peer to run/use routing-peer DNS + // resolution regardless of the account-global setting, for reverse-proxy + // domain targets. + ForceRoutingPeerDNSResolution bool +} + +type routeIndexEntry struct { + route *route.Route + viaGroup bool +} + +type AccountSettingsInfo struct { + PeerLoginExpirationEnabled bool + PeerLoginExpiration time.Duration + PeerInactivityExpirationEnabled bool + PeerInactivityExpiration time.Duration +} + +func EmptyNetworkMapComponents(nm *NetworkMapComponents) *NetworkMapComponents { + nm.empty = true + return nm +} + +func (c *NetworkMapComponents) GetPeerInfo(peerID string) *ComponentPeer { + return c.Peers[peerID] +} + +func (c *NetworkMapComponents) GetRouterPeerInfo(peerID string) *ComponentPeer { + return c.RouterPeers[peerID] +} + +func (c *NetworkMapComponents) GetGroupInfo(groupID string) *ComponentGroup { + return c.Groups[groupID] +} + +func (c *NetworkMapComponents) IsPeerInGroup(peerID, groupID string) bool { + group := c.GetGroupInfo(groupID) + if group == nil { + return false + } + + return slices.Contains(group.Peers, peerID) +} + +func (c *NetworkMapComponents) GetPeerGroups(peerID string) map[string]struct{} { + groups := make(map[string]struct{}) + for groupID, group := range c.Groups { + if slices.Contains(group.Peers, peerID) { + groups[groupID] = struct{}{} + } + } + return groups +} + +func (c *NetworkMapComponents) ValidatePostureChecksOnPeer(peerID string, postureCheckIDs []string) bool { + _, exists := c.Peers[peerID] + if !exists { + return false + } + if len(postureCheckIDs) == 0 { + return true + } + for _, checkID := range postureCheckIDs { + if failedPeers, exists := c.PostureFailedPeers[checkID]; exists { + if _, failed := failedPeers[peerID]; failed { + return false + } + } + } + return true +} + +func CalculateNetworkMapFromComponents(ctx context.Context, components *NetworkMapComponents) *NetworkMap { + return components.Calculate(ctx) +} + +func (c *NetworkMapComponents) Calculate(ctx context.Context) *NetworkMap { + targetPeerID := c.PeerID + + peerGroups := c.GetPeerGroups(targetPeerID) + + aclPeers, firewallRules, authorizedUsers, sshEnabled := c.getPeerConnectionResources(targetPeerID) + + peersToConnect, expiredPeers := c.filterPeersByLoginExpiration(aclPeers) + + includeIPv6 := false + if p := c.Peers[targetPeerID]; p != nil { + includeIPv6 = p.SupportsIPv6 && p.IPv6.IsValid() + } + routesUpdate := filterAndExpandRoutes(c.getRoutesToSync(targetPeerID, peersToConnect, peerGroups), includeIPv6) + routesFirewallRules := c.getPeerRoutesFirewallRules(ctx, targetPeerID, includeIPv6) + + isRouter, networkResourcesRoutes, sourcePeers := c.getNetworkResourcesRoutesToSync(targetPeerID) + var networkResourcesFirewallRules []*RouteFirewallRule + if isRouter { + networkResourcesFirewallRules = c.getPeerNetworkResourceFirewallRules(ctx, targetPeerID, networkResourcesRoutes, includeIPv6) + } + + peersToConnectIncludingRouters := c.addNetworksRoutingPeers( + networkResourcesRoutes, + targetPeerID, + peersToConnect, + expiredPeers, + isRouter, + sourcePeers, + ) + + dnsManagementStatus := c.getPeerDNSManagementStatusFromGroups(peerGroups) + dnsUpdate := nbdns.Config{ + ServiceEnable: dnsManagementStatus, + } + + if dnsManagementStatus { + var customZones []nbdns.CustomZone + + if c.CustomZoneDomain != "" && len(c.AllDNSRecords) > 0 { + customZones = append(customZones, nbdns.CustomZone{ + Domain: c.CustomZoneDomain, + Records: c.AllDNSRecords, + }) + } + + customZones = append(customZones, c.AccountZones...) + + dnsUpdate.CustomZones = customZones + dnsUpdate.NameServerGroups = c.getPeerNSGroupsFromGroups(targetPeerID, peerGroups) + } + + return &NetworkMap{ + Peers: peersToConnectIncludingRouters, + Network: c.Network.Copy(), + Routes: append(filterAndExpandRoutes(networkResourcesRoutes, includeIPv6), routesUpdate...), + DNSConfig: dnsUpdate, + OfflinePeers: expiredPeers, + FirewallRules: firewallRules, + RoutesFirewallRules: append(networkResourcesFirewallRules, routesFirewallRules...), + AuthorizedUsers: authorizedUsers, + EnableSSH: sshEnabled, + + ForceRoutingPeerDNSResolution: c.ForceRoutingPeerDNSResolution, + } +} + +func (c *NetworkMapComponents) IsEmpty() bool { + return c.empty +} + +func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) ([]*ComponentPeer, []*FirewallRule, map[string]map[string]struct{}, bool) { + targetPeer := c.GetPeerInfo(targetPeerID) + if targetPeer == nil { + return nil, nil, nil, false + } + + generateResources, getAccumulatedResources := c.connResourcesGenerator(targetPeer) + authorizedUsers := make(map[string]map[string]struct{}) + sshEnabled := false + + for _, policy := range c.Policies { + if !policy.Enabled { + continue + } + + for _, rule := range policy.Rules { + if !rule.Enabled { + continue + } + + var sourcePeers, destinationPeers []*ComponentPeer + var peerInSources, peerInDestinations bool + + if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" { + sourcePeers, peerInSources = c.getPeerFromResource(rule.SourceResource, targetPeerID) + } else { + sourcePeers, peerInSources = c.getAllPeersFromGroups(rule.Sources, targetPeerID, policy.SourcePostureChecks) + } + + if rule.DestinationResource.Type == ResourceTypePeer && rule.DestinationResource.ID != "" { + destinationPeers, peerInDestinations = c.getPeerFromResource(rule.DestinationResource, targetPeerID) + } else { + destinationPeers, peerInDestinations = c.getAllPeersFromGroups(rule.Destinations, targetPeerID, nil) + } + + if rule.Bidirectional { + if peerInSources { + generateResources(rule, destinationPeers, FirewallRuleDirectionIN) + } + if peerInDestinations { + generateResources(rule, sourcePeers, FirewallRuleDirectionOUT) + } + } + + if peerInSources { + generateResources(rule, destinationPeers, FirewallRuleDirectionOUT) + } + + if peerInDestinations { + generateResources(rule, sourcePeers, FirewallRuleDirectionIN) + } + + if peerInDestinations && rule.Protocol == PolicyRuleProtocolNetbirdSSH { + sshEnabled = true + switch { + case len(rule.AuthorizedGroups) > 0: + for groupID, localUsers := range rule.AuthorizedGroups { + userIDs, ok := c.GroupIDToUserIDs[groupID] + if !ok { + continue + } + + if len(localUsers) == 0 { + localUsers = []string{auth.Wildcard} + } + + for _, localUser := range localUsers { + if authorizedUsers[localUser] == nil { + authorizedUsers[localUser] = make(map[string]struct{}) + } + for _, userID := range userIDs { + authorizedUsers[localUser][userID] = struct{}{} + } + } + } + case rule.AuthorizedUser != "": + if authorizedUsers[auth.Wildcard] == nil { + authorizedUsers[auth.Wildcard] = make(map[string]struct{}) + } + authorizedUsers[auth.Wildcard][rule.AuthorizedUser] = struct{}{} + default: + authorizedUsers[auth.Wildcard] = c.getAllowedUserIDs() + } + } else if peerInDestinations && PolicyRuleImpliesLegacySSH(rule) && targetPeer.SSHEnabled { + sshEnabled = true + authorizedUsers[auth.Wildcard] = c.getAllowedUserIDs() + } + } + } + + peers, fwRules := getAccumulatedResources() + return peers, fwRules, authorizedUsers, sshEnabled +} + +func (c *NetworkMapComponents) getAllowedUserIDs() map[string]struct{} { + if c.AllowedUserIDs != nil { + result := make(map[string]struct{}, len(c.AllowedUserIDs)) + maps.Copy(result, c.AllowedUserIDs) + return result + } + return make(map[string]struct{}) +} + +func (c *NetworkMapComponents) connResourcesGenerator(targetPeer *ComponentPeer) (func(*PolicyRule, []*ComponentPeer, int), func() ([]*ComponentPeer, []*FirewallRule)) { + rulesExists := make(map[string]struct{}) + peersExists := make(map[string]struct{}) + rules := make([]*FirewallRule, 0) + peers := make([]*ComponentPeer, 0) + + return func(rule *PolicyRule, groupPeers []*ComponentPeer, direction int) { + protocol := rule.Protocol + if protocol == PolicyRuleProtocolNetbirdSSH { + protocol = PolicyRuleProtocolTCP + } + + protocolStr := string(protocol) + actionStr := string(rule.Action) + dirStr := strconv.Itoa(direction) + portsJoined := strings.Join(rule.Ports, ",") + + for _, peer := range groupPeers { + if peer == nil { + continue + } + + if _, ok := peersExists[peer.ID]; !ok { + peers = append(peers, peer) + peersExists[peer.ID] = struct{}{} + } + + peerIP := peer.IP.String() + + fr := FirewallRule{ + PolicyID: rule.ID, + PeerIP: peerIP, + Direction: direction, + Action: actionStr, + Protocol: protocolStr, + } + + ruleID := rule.ID + peerIP + dirStr + + protocolStr + actionStr + portsJoined + if _, ok := rulesExists[ruleID]; ok { + continue + } + rulesExists[ruleID] = struct{}{} + + if len(rule.Ports) == 0 && len(rule.PortRanges) == 0 { + rules = append(rules, &fr) + } else { + rules = append(rules, ExpandPortsAndRanges(fr, rule, targetPeer)...) + } + + rules = AppendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, rule, FirewallRuleContext{ + Direction: direction, + DirStr: dirStr, + ProtocolStr: protocolStr, + ActionStr: actionStr, + PortsJoined: portsJoined, + }) + } + }, func() ([]*ComponentPeer, []*FirewallRule) { + return peers, rules + } +} + +func (c *NetworkMapComponents) getAllPeersFromGroups(groups []string, peerID string, sourcePostureChecksIDs []string) ([]*ComponentPeer, bool) { + peerInGroups := false + uniquePeerIDs := c.getUniquePeerIDsFromGroupsIDs(groups) + filteredPeers := make([]*ComponentPeer, 0, len(uniquePeerIDs)) + + for _, p := range uniquePeerIDs { + peerInfo := c.GetPeerInfo(p) + if peerInfo == nil { + continue + } + + if _, ok := c.Peers[p]; !ok { + continue + } + + if !c.ValidatePostureChecksOnPeer(p, sourcePostureChecksIDs) { + continue + } + + if p == peerID { + peerInGroups = true + continue + } + + filteredPeers = append(filteredPeers, peerInfo) + } + + return filteredPeers, peerInGroups +} + +func (c *NetworkMapComponents) getUniquePeerIDsFromGroupsIDs(groups []string) []string { + peerIDs := make(map[string]struct{}, len(groups)) + for _, groupID := range groups { + group := c.GetGroupInfo(groupID) + if group == nil { + continue + } + + if group.IsGroupAll() || len(groups) == 1 { + return group.Peers + } + + for _, peerID := range group.Peers { + peerIDs[peerID] = struct{}{} + } + } + + ids := make([]string, 0, len(peerIDs)) + for peerID := range peerIDs { + ids = append(ids, peerID) + } + + return ids +} + +func (c *NetworkMapComponents) getPeerFromResource(resource Resource, peerID string) ([]*ComponentPeer, bool) { + if resource.ID == peerID { + return []*ComponentPeer{}, true + } + + peerInfo := c.GetPeerInfo(resource.ID) + if peerInfo == nil { + return []*ComponentPeer{}, false + } + + return []*ComponentPeer{peerInfo}, false +} + +func (c *NetworkMapComponents) filterPeersByLoginExpiration(aclPeers []*ComponentPeer) ([]*ComponentPeer, []*ComponentPeer) { + peersToConnect := make([]*ComponentPeer, 0, len(aclPeers)) + var expiredPeers []*ComponentPeer + + for _, p := range aclPeers { + expired, _ := p.LoginExpired(c.AccountSettings.PeerLoginExpiration) + if c.AccountSettings.PeerLoginExpirationEnabled && expired { + expiredPeers = append(expiredPeers, p) + continue + } + peersToConnect = append(peersToConnect, p) + } + + return peersToConnect, expiredPeers +} + +func (c *NetworkMapComponents) getPeerDNSManagementStatusFromGroups(peerGroups map[string]struct{}) bool { + for _, groupID := range c.DNSSettings.DisabledManagementGroups { + if _, found := peerGroups[groupID]; found { + return false + } + } + return true +} + +func (c *NetworkMapComponents) getPeerNSGroupsFromGroups(peerID string, groupList map[string]struct{}) []*nbdns.NameServerGroup { + var peerNSGroups []*nbdns.NameServerGroup + + targetPeerInfo := c.GetPeerInfo(peerID) + if targetPeerInfo == nil { + return peerNSGroups + } + + peerIPStr := targetPeerInfo.IP.String() + + for _, nsGroup := range c.NameServerGroups { + if !nsGroup.Enabled { + continue + } + for _, gID := range nsGroup.Groups { + if _, found := groupList[gID]; found { + if !c.peerIsNameserver(peerIPStr, nsGroup) { + peerNSGroups = append(peerNSGroups, nsGroup.Copy()) + } + break + } + } + } + + return peerNSGroups +} + +func (c *NetworkMapComponents) peerIsNameserver(peerIPStr string, nsGroup *nbdns.NameServerGroup) bool { + for _, ns := range nsGroup.NameServers { + if peerIPStr == ns.IP.String() { + return true + } + } + return false +} + +// filterAndExpandRoutes drops v6 routes for non-capable peers and duplicates +// the default v4 route (0.0.0.0/0) as ::/0 for v6-capable peers. +// TODO: the "-v6" suffix on IDs could collide with user-supplied route IDs. +func filterAndExpandRoutes(routes []*route.Route, includeIPv6 bool) []*route.Route { + filtered := make([]*route.Route, 0, len(routes)) + for _, r := range routes { + if !includeIPv6 && r.Network.Addr().Is6() { + continue + } + filtered = append(filtered, r) + + if includeIPv6 && r.Network.Bits() == 0 && r.Network.Addr().Is4() { + v6 := r.Copy() + v6.ID = r.ID + "-v6-default" + v6.NetID = r.NetID + "-v6" + v6.Network = netip.MustParsePrefix("::/0") + v6.NetworkType = route.IPv6Network + filtered = append(filtered, v6) + } + } + return filtered +} + +func (c *NetworkMapComponents) getRoutesToSync(peerID string, aclPeers []*ComponentPeer, peerGroups LookupMap) []*route.Route { + routes, peerDisabledRoutes := c.getRoutingPeerRoutes(peerID) + peerRoutesMembership := make(LookupMap) + for _, r := range append(routes, peerDisabledRoutes...) { + peerRoutesMembership[string(r.GetHAUniqueID())] = struct{}{} + } + + for _, peer := range aclPeers { + activeRoutes, _ := c.getRoutingPeerRoutes(peer.ID) + groupFilteredRoutes := c.filterRoutesByGroups(activeRoutes, peerGroups) + filteredRoutes := c.filterRoutesFromPeersOfSameHAGroup(groupFilteredRoutes, peerRoutesMembership) + routes = append(routes, filteredRoutes...) + } + + return routes +} + +func (c *NetworkMapComponents) getRoutingPeerRoutes(peerID string) (enabledRoutes []*route.Route, disabledRoutes []*route.Route) { + peerInfo := c.GetPeerInfo(peerID) + if peerInfo == nil { + peerInfo = c.GetRouterPeerInfo(peerID) + } + if peerInfo == nil { + return enabledRoutes, disabledRoutes + } + + seenRoute := make(map[route.ID]struct{}) + + takeRoute := func(r *route.Route) { + if _, ok := seenRoute[r.ID]; ok { + return + } + seenRoute[r.ID] = struct{}{} + + r.Peer = peerInfo.Key + + if r.Enabled { + enabledRoutes = append(enabledRoutes, r) + return + } + disabledRoutes = append(disabledRoutes, r) + } + + for _, entry := range c.routesByPeer()[peerID] { + if entry.viaGroup { + newPeerRoute := entry.route.Copy() + newPeerRoute.PeerGroups = nil + newPeerRoute.ID = route.ID(string(entry.route.ID) + ":" + peerID) + takeRoute(newPeerRoute) + continue + } + takeRoute(entry.route.Copy()) + } + + return enabledRoutes, disabledRoutes +} + +func (c *NetworkMapComponents) routesByPeer() map[string][]routeIndexEntry { + c.routesByPeerOnce.Do(func() { + idx := make(map[string][]routeIndexEntry) + for _, r := range c.Routes { + for _, groupID := range r.PeerGroups { + group := c.GetGroupInfo(groupID) + if group == nil { + continue + } + for _, id := range group.Peers { + idx[id] = append(idx[id], routeIndexEntry{route: r, viaGroup: true}) + } + } + if r.Peer != "" { + idx[r.Peer] = append(idx[r.Peer], routeIndexEntry{route: r}) + } + } + c.routesByPeerIdx = idx + }) + + return c.routesByPeerIdx +} + +func (c *NetworkMapComponents) filterRoutesByGroups(routes []*route.Route, groupListMap LookupMap) []*route.Route { + var filteredRoutes []*route.Route + for _, r := range routes { + for _, groupID := range r.Groups { + _, found := groupListMap[groupID] + if found { + filteredRoutes = append(filteredRoutes, r) + break + } + } + } + return filteredRoutes +} + +func (c *NetworkMapComponents) filterRoutesFromPeersOfSameHAGroup(routes []*route.Route, peerMemberships LookupMap) []*route.Route { + var filteredRoutes []*route.Route + for _, r := range routes { + _, found := peerMemberships[string(r.GetHAUniqueID())] + if !found { + filteredRoutes = append(filteredRoutes, r) + } + } + return filteredRoutes +} + +func (c *NetworkMapComponents) getPeerRoutesFirewallRules(ctx context.Context, peerID string, includeIPv6 bool) []*RouteFirewallRule { + routesFirewallRules := make([]*RouteFirewallRule, 0) + + enabledRoutes, _ := c.getRoutingPeerRoutes(peerID) + for _, r := range enabledRoutes { + if len(r.AccessControlGroups) == 0 { + defaultPermit := c.getDefaultPermit(r, includeIPv6) + routesFirewallRules = append(routesFirewallRules, defaultPermit...) + continue + } + + distributionPeers := c.getDistributionGroupsPeers(r) + + for _, accessGroup := range r.AccessControlGroups { + policies := c.getAllRoutePoliciesFromGroups([]string{accessGroup}) + rules := c.getRouteFirewallRules(ctx, peerID, policies, r, distributionPeers, includeIPv6) + routesFirewallRules = append(routesFirewallRules, rules...) + } + } + + return routesFirewallRules +} + +func (c *NetworkMapComponents) getDefaultPermit(r *route.Route, includeIPv6 bool) []*RouteFirewallRule { + if r.Network.Addr().Is6() && !includeIPv6 { + return nil + } + + sources := []string{"0.0.0.0/0"} + if r.Network.Addr().Is6() { + sources = []string{"::/0"} + } + + rule := RouteFirewallRule{ + SourceRanges: sources, + Action: string(PolicyTrafficActionAccept), + Destination: r.Network.String(), + Protocol: string(PolicyRuleProtocolALL), + Domains: r.Domains, + IsDynamic: r.IsDynamic(), + RouteID: r.ID, + } + + rules := []*RouteFirewallRule{&rule} + + isDefaultV4 := r.Network.Addr().Is4() && r.Network.Bits() == 0 + if includeIPv6 && (r.IsDynamic() || isDefaultV4) { + ruleV6 := rule + ruleV6.SourceRanges = []string{"::/0"} + if isDefaultV4 { + ruleV6.Destination = "::/0" + ruleV6.RouteID = r.ID + "-v6-default" + } + rules = append(rules, &ruleV6) + } + + return rules +} + +func (c *NetworkMapComponents) getDistributionGroupsPeers(r *route.Route) map[string]struct{} { + distPeers := make(map[string]struct{}) + for _, id := range r.Groups { + group := c.GetGroupInfo(id) + if group == nil { + continue + } + + for _, pID := range group.Peers { + distPeers[pID] = struct{}{} + } + } + return distPeers +} + +func (c *NetworkMapComponents) getAllRoutePoliciesFromGroups(accessControlGroups []string) []*Policy { + routePolicies := make([]*Policy, 0) + for _, groupID := range accessControlGroups { + for _, policy := range c.Policies { + for _, rule := range policy.Rules { + if slices.Contains(rule.Destinations, groupID) { + routePolicies = append(routePolicies, policy) + } + } + } + } + + return routePolicies +} + +func (c *NetworkMapComponents) getRouteFirewallRules(ctx context.Context, peerID string, policies []*Policy, route *route.Route, distributionPeers map[string]struct{}, includeIPv6 bool) []*RouteFirewallRule { + var fwRules []*RouteFirewallRule + for _, policy := range policies { + if !policy.Enabled { + continue + } + + for _, rule := range policy.Rules { + if !rule.Enabled { + continue + } + + rulePeers := c.getRulePeers(rule, policy.SourcePostureChecks, peerID, distributionPeers) + rules := GenerateRouteFirewallRules(ctx, route, rule, rulePeers, FirewallRuleDirectionIN, includeIPv6) + fwRules = append(fwRules, rules...) + } + } + return fwRules +} + +func (c *NetworkMapComponents) getRulePeers(rule *PolicyRule, postureChecks []string, peerID string, distributionPeers map[string]struct{}) []*ComponentPeer { + distPeersWithPolicy := make(map[string]struct{}) + for _, id := range rule.Sources { + group := c.GetGroupInfo(id) + if group == nil { + continue + } + + for _, pID := range group.Peers { + if pID == peerID { + continue + } + _, distPeer := distributionPeers[pID] + _, valid := c.Peers[pID] + if distPeer && valid && c.ValidatePostureChecksOnPeer(pID, postureChecks) { + distPeersWithPolicy[pID] = struct{}{} + } + } + } + if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" { + _, distPeer := distributionPeers[rule.SourceResource.ID] + _, valid := c.Peers[rule.SourceResource.ID] + if distPeer && valid && c.ValidatePostureChecksOnPeer(rule.SourceResource.ID, postureChecks) { + distPeersWithPolicy[rule.SourceResource.ID] = struct{}{} + } + } + + distributionGroupPeers := make([]*ComponentPeer, 0, len(distPeersWithPolicy)) + for pID := range distPeersWithPolicy { + peerInfo := c.GetPeerInfo(pID) + if peerInfo == nil { + continue + } + distributionGroupPeers = append(distributionGroupPeers, peerInfo) + } + return distributionGroupPeers +} + +func (c *NetworkMapComponents) getNetworkResourcesRoutesToSync(peerID string) (bool, []*route.Route, map[string]struct{}) { + var isRoutingPeer bool + var routes []*route.Route + allSourcePeers := make(map[string]struct{}) + + for _, resource := range c.NetworkResources { + if !resource.Enabled { + continue + } + + var addSourcePeers bool + + networkRoutingPeers, exists := c.RoutersMap[resource.NetworkID] + if exists { + if router, ok := networkRoutingPeers[peerID]; ok { + isRoutingPeer, addSourcePeers = true, true + routes = append(routes, c.getNetworkResourcesRoutes(resource, peerID, router)...) + } + } + + newRoutes := c.processResourcePolicies(peerID, resource, networkRoutingPeers, addSourcePeers, allSourcePeers) + routes = append(routes, newRoutes...) + } + + return isRoutingPeer, routes, allSourcePeers +} + +func (c *NetworkMapComponents) processResourcePolicies( + peerID string, + resource *ComponentResource, + networkRoutingPeers map[string]*ComponentRouter, + addSourcePeers bool, + allSourcePeers map[string]struct{}, +) []*route.Route { + var routes []*route.Route + + for _, policy := range c.ResourcePoliciesMap[resource.ID] { + peers := c.getResourcePolicyPeers(policy) + if addSourcePeers { + for _, pID := range c.getPostureValidPeers(peers, policy.SourcePostureChecks) { + allSourcePeers[pID] = struct{}{} + } + continue + } + + if slices.Contains(peers, peerID) && c.ValidatePostureChecksOnPeer(peerID, policy.SourcePostureChecks) { + for peerId, router := range networkRoutingPeers { + routes = append(routes, c.getNetworkResourcesRoutes(resource, peerId, router)...) + } + break + } + } + + return routes +} + +func (c *NetworkMapComponents) getResourcePolicyPeers(policy *Policy) []string { + if policy.Rules[0].SourceResource.Type == ResourceTypePeer && policy.Rules[0].SourceResource.ID != "" { + return []string{policy.Rules[0].SourceResource.ID} + } + return c.getUniquePeerIDsFromGroupsIDs(policy.SourceGroups()) +} + +func (c *NetworkMapComponents) getNetworkResourcesRoutes(resource *ComponentResource, peerID string, router *ComponentRouter) []*route.Route { + resourceAppliedPolicies := c.ResourcePoliciesMap[resource.ID] + + var routes []*route.Route + if len(resourceAppliedPolicies) > 0 { + peerInfo := c.GetPeerInfo(peerID) + if peerInfo != nil { + routes = append(routes, c.networkResourceToRoute(resource, peerInfo, router)) + } + } + + return routes +} + +func (c *NetworkMapComponents) networkResourceToRoute(resource *ComponentResource, peer *ComponentPeer, router *ComponentRouter) *route.Route { + r := &route.Route{ + ID: route.ID(resource.ID + ":" + peer.ID), + AccountID: resource.AccountID, + Peer: peer.Key, + PeerID: peer.ID, + Metric: router.Metric, + Masquerade: router.Masquerade, + Enabled: resource.Enabled, + KeepRoute: true, + NetID: route.NetID(resource.Name), + Description: resource.Description, + } + + if resource.Type == ComponentResourceHost || resource.Type == ComponentResourceSubnet { + r.Network = resource.Prefix + + r.NetworkType = route.IPv4Network + if resource.Prefix.Addr().Is6() { + r.NetworkType = route.IPv6Network + } + } + + if resource.Type == ComponentResourceDomain { + domainList, err := domain.FromStringList([]string{resource.Domain}) + if err == nil { + r.Domains = domainList + r.NetworkType = route.DomainNetwork + r.Network = netip.PrefixFrom(netip.AddrFrom4([4]byte{192, 0, 2, 0}), 32) + } + } + + return r +} + +func (c *NetworkMapComponents) getPostureValidPeers(inputPeers []string, postureChecksIDs []string) []string { + var dest []string + for _, peerID := range inputPeers { + if c.ValidatePostureChecksOnPeer(peerID, postureChecksIDs) { + dest = append(dest, peerID) + } + } + return dest +} + +func (c *NetworkMapComponents) getPeerNetworkResourceFirewallRules(ctx context.Context, peerID string, routes []*route.Route, includeIPv6 bool) []*RouteFirewallRule { + routesFirewallRules := make([]*RouteFirewallRule, 0) + + peerInfo := c.GetPeerInfo(peerID) + if peerInfo == nil { + return routesFirewallRules + } + + for _, r := range routes { + if r.Peer != peerInfo.Key { + continue + } + + resourceID := string(r.GetResourceID()) + resourcePolicies := c.ResourcePoliciesMap[resourceID] + distributionPeers := c.getPoliciesSourcePeers(resourcePolicies) + + rules := c.getRouteFirewallRules(ctx, peerID, resourcePolicies, r, distributionPeers, includeIPv6) + for _, rule := range rules { + if len(rule.SourceRanges) > 0 { + routesFirewallRules = append(routesFirewallRules, rule) + } + } + } + + return routesFirewallRules +} + +func (c *NetworkMapComponents) getPoliciesSourcePeers(policies []*Policy) map[string]struct{} { + sourcePeers := make(map[string]struct{}) + + for _, policy := range policies { + for _, rule := range policy.Rules { + for _, sourceGroup := range rule.Sources { + group := c.GetGroupInfo(sourceGroup) + if group == nil { + continue + } + + for _, peer := range group.Peers { + sourcePeers[peer] = struct{}{} + } + } + + if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" { + sourcePeers[rule.SourceResource.ID] = struct{}{} + } + } + } + + return sourcePeers +} + +func (c *NetworkMapComponents) addNetworksRoutingPeers( + networkResourcesRoutes []*route.Route, + peerID string, + peersToConnect []*ComponentPeer, + expiredPeers []*ComponentPeer, + isRouter bool, + sourcePeers map[string]struct{}, +) []*ComponentPeer { + + networkRoutesPeers := make(map[string]struct{}, len(networkResourcesRoutes)) + for _, r := range networkResourcesRoutes { + networkRoutesPeers[r.PeerID] = struct{}{} + } + + delete(sourcePeers, peerID) + delete(networkRoutesPeers, peerID) + + for _, existingPeer := range peersToConnect { + delete(sourcePeers, existingPeer.ID) + delete(networkRoutesPeers, existingPeer.ID) + } + for _, expPeer := range expiredPeers { + delete(sourcePeers, expPeer.ID) + delete(networkRoutesPeers, expPeer.ID) + } + + missingPeers := make(map[string]struct{}, len(sourcePeers)+len(networkRoutesPeers)) + if isRouter { + for p := range sourcePeers { + missingPeers[p] = struct{}{} + } + } + for p := range networkRoutesPeers { + missingPeers[p] = struct{}{} + } + + for p := range missingPeers { + peerInfo := c.GetPeerInfo(p) + if peerInfo == nil { + peerInfo = c.GetRouterPeerInfo(p) + } + if peerInfo != nil { + peersToConnect = append(peersToConnect, peerInfo) + } + } + + return peersToConnect +} + +type FirewallRuleContext struct { + Direction int + DirStr string + ProtocolStr string + ActionStr string + PortsJoined string +} + +func AppendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *ComponentPeer, rule *PolicyRule, rc FirewallRuleContext) []*FirewallRule { + if !peer.IPv6.IsValid() || !targetPeer.SupportsIPv6 || !targetPeer.IPv6.IsValid() { + return rules + } + + v6IP := peer.IPv6.String() + v6RuleID := rule.ID + v6IP + rc.DirStr + rc.ProtocolStr + rc.ActionStr + rc.PortsJoined + if _, ok := rulesExists[v6RuleID]; ok { + return rules + } + rulesExists[v6RuleID] = struct{}{} + + v6fr := FirewallRule{ + PolicyID: rule.ID, + PeerIP: v6IP, + Direction: rc.Direction, + Action: rc.ActionStr, + Protocol: rc.ProtocolStr, + } + if len(rule.Ports) == 0 && len(rule.PortRanges) == 0 { + return append(rules, &v6fr) + } + return append(rules, ExpandPortsAndRanges(v6fr, rule, targetPeer)...) +} diff --git a/management/server/types/legacynmap/proto_legacy.go b/management/server/types/legacynmap/proto_legacy.go new file mode 100644 index 000000000..74451b268 --- /dev/null +++ b/management/server/types/legacynmap/proto_legacy.go @@ -0,0 +1,220 @@ +package legacynmap + +import ( + "context" + "fmt" + "net/netip" + "net/url" + "strings" + + "github.com/netbirdio/netbird/client/ssh/auth" + nbconfig "github.com/netbirdio/netbird/management/internals/server/config" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/types" + nbroute "github.com/netbirdio/netbird/route" + "github.com/netbirdio/netbird/shared/management/networkmap" + "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/shared/netiputil" +) + +func ToProtocolRoutes(routes []*nbroute.Route) []*proto.Route { + protoRoutes := make([]*proto.Route, 0, len(routes)) + for _, r := range routes { + protoRoutes = append(protoRoutes, ToProtocolRoute(r)) + } + return protoRoutes +} + +func ToProtocolRoute(route *nbroute.Route) *proto.Route { + return &proto.Route{ + ID: string(route.ID), + NetID: string(route.NetID), + Network: route.Network.String(), + Domains: route.Domains.ToPunycodeList(), + NetworkType: int64(route.NetworkType), + Peer: route.Peer, + Metric: int64(route.Metric), + Masquerade: route.Masquerade, + KeepRoute: route.KeepRoute, + SkipAutoApply: route.SkipAutoApply, + } +} + +func AppendRemotePeerConfig(dst []*proto.RemotePeerConfig, peers []*ComponentPeer, dnsName string, includeIPv6 bool, localIsProxy bool) []*proto.RemotePeerConfig { + for _, rPeer := range peers { + allowedIPs := []string{rPeer.IP.String() + "/32"} + if includeIPv6 && rPeer.IPv6.IsValid() { + allowedIPs = append(allowedIPs, rPeer.IPv6.String()+"/128") + } + dst = append(dst, &proto.RemotePeerConfig{ + WgPubKey: rPeer.Key, + AllowedIps: allowedIPs, + SshConfig: &proto.SSHConfig{SshPubKey: []byte(rPeer.SSHKey)}, + Fqdn: rPeer.FQDN(dnsName), + AgentVersion: rPeer.AgentVersion, + LazyState: lazyStateFor(localIsProxy, rPeer), + }) + } + return dst +} + +// lazyStateFor returns the per-peer lazy override for a remote peer. Connections +// involving an ephemeral proxy peer on either endpoint default to lazy so shared +// proxy infrastructure is not kept permanently connected to every peer. All +// other peers follow the account-wide flag. A future admin-facing per-peer +// setting can return LazyStateEager here to force a peer always-active. +func lazyStateFor(localIsProxy bool, rPeer *ComponentPeer) proto.LazyState { + if localIsProxy || rPeer.ProxyEmbedded { + return proto.LazyState_LazyStateLazy + } + return proto.LazyState_LazyStateDefault +} + +func buildJWTConfig(config *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow) *proto.JWTConfig { + if config == nil || config.AuthAudience == "" { + return nil + } + + issuer := strings.TrimSpace(config.AuthIssuer) + if issuer == "" && deviceFlowConfig != nil { + if d := deriveIssuerFromTokenEndpoint(deviceFlowConfig.ProviderConfig.TokenEndpoint); d != "" { + issuer = d + } + } + if issuer == "" { + return nil + } + + keysLocation := strings.TrimSpace(config.AuthKeysLocation) + if keysLocation == "" { + keysLocation = strings.TrimSuffix(issuer, "/") + "/.well-known/jwks.json" + } + + audience := config.AuthAudience + if config.CLIAuthAudience != "" { + audience = config.CLIAuthAudience + } + + audiences := []string{config.AuthAudience} + if config.CLIAuthAudience != "" && config.CLIAuthAudience != config.AuthAudience { + audiences = append(audiences, config.CLIAuthAudience) + } + + return &proto.JWTConfig{ + Issuer: issuer, + Audience: audience, //nolint:staticcheck + Audiences: audiences, + KeysLocation: keysLocation, + } +} + +func toPeerConfig(peer *nbpeer.Peer, network *Network, dnsName string, settings *types.Settings, httpConfig *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, enableSSH bool, forceRoutingPeerDNS bool) *proto.PeerConfig { + netmask, _ := network.Net.Mask.Size() + fqdn := peer.FQDN(dnsName) + + sshConfig := &proto.SSHConfig{ + SshEnabled: peer.SSHEnabled || enableSSH, + } + + if sshConfig.SshEnabled { + sshConfig.JwtConfig = buildJWTConfig(httpConfig, deviceFlowConfig) + } + + peerConfig := &proto.PeerConfig{ + Address: fmt.Sprintf("%s/%d", peer.IP.String(), netmask), + SshConfig: sshConfig, + Fqdn: fqdn, + RoutingPeerDnsResolutionEnabled: settings.RoutingPeerDNSResolutionEnabled || peer.ProxyMeta.Embedded || forceRoutingPeerDNS, + LazyConnectionEnabled: settings.LazyConnectionEnabled, + AutoUpdate: &proto.AutoUpdateSettings{ + Version: settings.AutoUpdateVersion, + AlwaysUpdate: settings.AutoUpdateAlways, + }, + } + + if peer.SupportsIPv6() && peer.IPv6.IsValid() && network.NetV6.IP != nil { + ones, _ := network.NetV6.Mask.Size() + v6Prefix := netip.PrefixFrom(peer.IPv6.Unmap(), ones) + if b, err := netiputil.EncodePrefix(v6Prefix); err == nil { + peerConfig.AddressV6 = b + } + } + + return peerConfig +} + +// ToProtoNetworkMap mirrors main's ToSyncResponse, restricted to the +// proto.NetworkMap it produces. SyncResponse-level fields (NetbirdConfig, +// Checks, the deprecated top-level RemotePeers) are omitted — they are not part +// of the equivalence surface. PeerConfig is included because proto.NetworkMap +// carries it, and it is where main's ForceRoutingPeerDNSResolution surfaces. +func ToProtoNetworkMap( + ctx context.Context, + peer *nbpeer.Peer, + nm *NetworkMap, + dnsName string, + settings *types.Settings, + httpConfig *nbconfig.HttpServerConfig, + dnsCache networkmap.DNSConfigCache, + dnsFwdPort int64, +) *proto.NetworkMap { + includeIPv6 := peer.SupportsIPv6() && peer.IPv6.IsValid() + useSourcePrefixes := peer.SupportsSourcePrefixes() + localIsProxy := peer.ProxyMeta.Embedded + + peerConfig := toPeerConfig(peer, nm.Network, dnsName, settings, httpConfig, nil, nm.EnableSSH, nm.ForceRoutingPeerDNSResolution) + + pm := &proto.NetworkMap{ + Serial: nm.Network.CurrentSerial(), + Routes: ToProtocolRoutes(nm.Routes), + DNSConfig: networkmap.ToProtocolDNSConfig(nm.DNSConfig, dnsCache, dnsFwdPort), + PeerConfig: peerConfig, + } + + remotePeers := make([]*proto.RemotePeerConfig, 0, len(nm.Peers)+len(nm.OfflinePeers)) + remotePeers = AppendRemotePeerConfig(remotePeers, nm.Peers, dnsName, includeIPv6, localIsProxy) + pm.RemotePeers = remotePeers + pm.RemotePeersIsEmpty = len(remotePeers) == 0 + + pm.OfflinePeers = AppendRemotePeerConfig(nil, nm.OfflinePeers, dnsName, includeIPv6, localIsProxy) + + firewallRules := networkmap.ToProtocolFirewallRules(nm.FirewallRules, includeIPv6, useSourcePrefixes) + pm.FirewallRules = firewallRules + pm.FirewallRulesIsEmpty = len(firewallRules) == 0 + + routesFirewallRules := networkmap.ToProtocolRoutesFirewallRules(nm.RoutesFirewallRules) + pm.RoutesFirewallRules = routesFirewallRules + pm.RoutesFirewallRulesIsEmpty = len(routesFirewallRules) == 0 + + if nm.ForwardingRules != nil { + forwardingRules := make([]*proto.ForwardingRule, 0, len(nm.ForwardingRules)) + for _, rule := range nm.ForwardingRules { + forwardingRules = append(forwardingRules, rule.ToProto()) + } + pm.ForwardingRules = forwardingRules + } + + if nm.AuthorizedUsers != nil { + hashedUsers, machineUsers := networkmap.BuildAuthorizedUsersProto(ctx, nm.AuthorizedUsers) + userIDClaim := auth.DefaultUserIDClaim + if httpConfig != nil && httpConfig.AuthUserIDClaim != "" { + userIDClaim = httpConfig.AuthUserIDClaim + } + pm.SshAuth = &proto.SSHAuth{AuthorizedUsers: hashedUsers, MachineUsers: machineUsers, UserIDClaim: userIDClaim} + } + + return pm +} + +func deriveIssuerFromTokenEndpoint(tokenEndpoint string) string { + if tokenEndpoint == "" { + return "" + } + + u, err := url.Parse(tokenEndpoint) + if err != nil { + return "" + } + + return fmt.Sprintf("%s://%s/", u.Scheme, u.Host) +} diff --git a/management/server/types/legacynmap/proxy_policies.go b/management/server/types/legacynmap/proxy_policies.go new file mode 100644 index 000000000..e8f8c3969 --- /dev/null +++ b/management/server/types/legacynmap/proxy_policies.go @@ -0,0 +1,150 @@ +package legacynmap + +import ( + "fmt" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + sharedtypes "github.com/netbirdio/netbird/shared/management/types" +) + +// SynthesizeProxyPolicies is main's Account.InjectProxyPolicies, frozen. On +// main the network-map controller called it on the account before computing, +// so a comparison that starts from the account has to apply it too. It returns +// the policies instead of appending them, so the caller can measure the legacy +// path without mutating the account the other paths share. +func SynthesizeProxyPolicies(a *Account) []*Policy { + if len(a.Services) == 0 { + return nil + } + + proxyPeersByCluster := a.GetProxyPeers() + if len(proxyPeersByCluster) == 0 { + return nil + } + + var out []*Policy + for _, svc := range a.Services { + if svc == nil || !svc.Enabled { + continue + } + + proxyPeers := proxyPeersByCluster[svc.ProxyCluster] + for _, target := range svc.Targets { + if target == nil || !target.Enabled { + continue + } + port, ok := legacyTargetPort(target) + if !ok { + continue + } + path := "" + if target.Path != nil { + path = *target.Path + } + for _, proxyPeer := range proxyPeers { + out = append(out, legacyProxyPolicy(svc, target, proxyPeer, port, path)) + } + } + + out = append(out, legacyPrivateServicePolicies(a, svc, proxyPeers)...) + } + return out +} + +func legacyPrivateServicePolicies(a *Account, svc *service.Service, proxyPeers []*nbpeer.Peer) []*Policy { + if !svc.Private || len(svc.AccessGroups) == 0 || len(proxyPeers) == 0 { + return nil + } + + sources := make([]string, 0, len(svc.AccessGroups)) + for _, groupID := range svc.AccessGroups { + if _, ok := a.Groups[groupID]; ok { + sources = append(sources, groupID) + } + } + if len(sources) == 0 { + return nil + } + + out := make([]*Policy, 0, len(proxyPeers)) + for _, proxyPeer := range proxyPeers { + policyID := fmt.Sprintf("private-access-%s-%s", svc.ID, proxyPeer.ID) + out = append(out, &Policy{ + ID: policyID, + Name: fmt.Sprintf("Private Access to %s", svc.Name), + Enabled: true, + Rules: []*PolicyRule{ + { + ID: policyID, + PolicyID: policyID, + Name: fmt.Sprintf("Allow access groups to reach %s", svc.Name), + Enabled: true, + Sources: append([]string(nil), sources...), + DestinationResource: Resource{ + ID: proxyPeer.ID, + Type: ResourceTypePeer, + }, + Bidirectional: false, + Protocol: PolicyRuleProtocolTCP, + Action: PolicyTrafficActionAccept, + PortRanges: []RulePortRange{ + {Start: 80, End: 80}, + {Start: 443, End: 443}, + }, + }, + }, + }) + } + return out +} + +func legacyProxyPolicy(svc *service.Service, target *service.Target, proxyPeer *nbpeer.Peer, port uint16, path string) *Policy { + policyID := fmt.Sprintf("proxy-access-%s-%s-%s", svc.ID, proxyPeer.ID, path) + + protocol := PolicyRuleProtocolTCP + if svc.Mode == service.ModeUDP { + protocol = sharedtypes.PolicyRuleProtocolUDP + } + + return &Policy{ + ID: policyID, + Name: fmt.Sprintf("Proxy Access to %s", svc.Name), + Enabled: true, + Rules: []*PolicyRule{ + { + ID: policyID, + PolicyID: policyID, + Name: fmt.Sprintf("Allow access to %s", svc.Name), + Enabled: true, + SourceResource: Resource{ + ID: proxyPeer.ID, + Type: ResourceTypePeer, + }, + DestinationResource: Resource{ + ID: target.TargetId, + Type: sharedtypes.ResourceType(target.TargetType), + }, + Bidirectional: false, + Protocol: protocol, + Action: PolicyTrafficActionAccept, + PortRanges: []RulePortRange{{Start: port, End: port}}, + }, + }, + } +} + +func legacyTargetPort(target *service.Target) (uint16, bool) { + if target.Port != 0 { + return target.Port, true + } + + switch target.Protocol { + case "https", "tls": + return 443, true + case "http": + return 80, true + default: + return 0, false + } +} diff --git a/management/server/types/network.go b/management/server/types/network.go new file mode 100644 index 000000000..72ca1af85 --- /dev/null +++ b/management/server/types/network.go @@ -0,0 +1,271 @@ +package types + +import ( + "encoding/binary" + "fmt" + "math/rand" + "net" + "net/netip" + "slices" + "sync" + "time" + + "github.com/c-robinson/iplib" + "github.com/rs/xid" + + "github.com/netbirdio/netbird/shared/management/status" +) + +const ( + // SubnetSize is a size of the subnet of the global network, e.g. 100.77.0.0/16 + SubnetSize = 16 + // NetSize is a global network size 100.64.0.0/10 + NetSize = 10 + + // IPv6SubnetSize is the prefix length of per-account IPv6 subnets. + // Each account gets a /64 from its unique /48 ULA prefix. + IPv6SubnetSize = 64 +) + +type Network struct { + Identifier string `json:"id"` + Net net.IPNet `gorm:"serializer:json"` + // NetV6 is the IPv6 ULA subnet for this account's overlay. Empty if not yet allocated. + NetV6 net.IPNet `gorm:"serializer:json"` + Dns string + // Serial is an ID that increments by 1 when any change to the network happened (e.g. new peer has been added). + // Used to synchronize state to the client apps. + Serial uint64 + + Mu sync.Mutex `json:"-" gorm:"-"` +} + +// NewNetwork creates a new Network initializing it with a Serial=0 +// It takes a random /16 subnet from 100.64.0.0/10 (64 different subnets) +// and a random /64 subnet from fd00:4e42::/32 for IPv6. +func NewNetwork() *Network { + n := iplib.NewNet4(net.ParseIP("100.64.0.0"), NetSize) + sub, _ := n.Subnet(SubnetSize) + + s := rand.NewSource(time.Now().UnixNano()) + r := rand.New(s) + intn := r.Intn(len(sub)) + + return &Network{ + Identifier: xid.New().String(), + Net: sub[intn].IPNet, + NetV6: AllocateIPv6Subnet(r), + Dns: "", + Serial: 0, + } +} + +// AllocateIPv6Subnet generates a random RFC 4193 ULA /64 prefix. +// The format follows RFC 4193 section 3.1: fd + 40-bit Global ID + 16-bit Subnet ID. +// The Global ID and Subnet ID are randomized (simplified from the SHA-1 algorithm +// in section 3.2.2), giving 2^56 possible /64 subnets across all accounts. +func AllocateIPv6Subnet(r *rand.Rand) net.IPNet { + ip := make(net.IP, 16) + ip[0] = 0xfd + // Bytes 1-5: 40-bit random Global ID + ip[1] = byte(r.Intn(256)) + ip[2] = byte(r.Intn(256)) + ip[3] = byte(r.Intn(256)) + ip[4] = byte(r.Intn(256)) + ip[5] = byte(r.Intn(256)) + // Bytes 6-7: 16-bit random Subnet ID + ip[6] = byte(r.Intn(256)) + ip[7] = byte(r.Intn(256)) + + return net.IPNet{ + IP: ip, + Mask: net.CIDRMask(IPv6SubnetSize, 128), + } +} + +// IncSerial increments Serial by 1 reflecting that the network state has been changed +func (n *Network) IncSerial() { + n.Mu.Lock() + defer n.Mu.Unlock() + n.Serial++ +} + +// CurrentSerial returns the Network.Serial of the network (latest state id) +func (n *Network) CurrentSerial() uint64 { + n.Mu.Lock() + defer n.Mu.Unlock() + return n.Serial +} + +func (n *Network) Copy() *Network { + n.Mu.Lock() + defer n.Mu.Unlock() + return &Network{ + Identifier: n.Identifier, + Net: n.Net, + NetV6: n.NetV6, + Dns: n.Dns, + Serial: n.Serial, + } +} + +// AllocatePeerIP picks an available IP from a netip.Prefix. +// This method considers already taken IPs and reuses IPs if there are gaps in takenIps. +// E.g. if prefix=100.30.0.0/16 and takenIps=[100.30.0.1, 100.30.0.4] then the result would be 100.30.0.2 or 100.30.0.3. +func AllocatePeerIP(prefix netip.Prefix, takenIps []netip.Addr) (netip.Addr, error) { + b := prefix.Masked().Addr().As4() + baseIP := binary.BigEndian.Uint32(b[:]) + hostBits := 32 - prefix.Bits() + totalIPs := uint32(1 << hostBits) + + taken := make(map[uint32]struct{}, len(takenIps)+1) + taken[baseIP] = struct{}{} // reserve network IP + taken[baseIP+totalIPs-1] = struct{}{} // reserve broadcast IP + + for _, ip := range takenIps { + ab := ip.As4() + taken[binary.BigEndian.Uint32(ab[:])] = struct{}{} + } + + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + maxAttempts := (int(totalIPs) - len(taken)) / 100 + + for i := 0; i < maxAttempts; i++ { + offset := uint32(rng.Intn(int(totalIPs-2))) + 1 + candidate := baseIP + offset + if _, exists := taken[candidate]; !exists { + return uint32ToIP(candidate), nil + } + } + + for offset := uint32(1); offset < totalIPs-1; offset++ { + candidate := baseIP + offset + if _, exists := taken[candidate]; !exists { + return uint32ToIP(candidate), nil + } + } + + return netip.Addr{}, status.Errorf(status.PreconditionFailed, "network %s is out of IPs", prefix.String()) +} + +// AllocateRandomPeerIP picks a random available IP from a netip.Prefix. +func AllocateRandomPeerIP(prefix netip.Prefix) (netip.Addr, error) { + b := prefix.Masked().Addr().As4() + baseIP := binary.BigEndian.Uint32(b[:]) + hostBits := 32 - prefix.Bits() + totalIPs := uint32(1 << hostBits) + + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + offset := uint32(rng.Intn(int(totalIPs-2))) + 1 + + candidate := baseIP + offset + return uint32ToIP(candidate), nil +} + +// AllocateRandomPeerIPv6 picks a random host address within the given IPv6 prefix. +// Only the host bits (after the prefix length) are randomized. +func AllocateRandomPeerIPv6(prefix netip.Prefix) (netip.Addr, error) { + ones := prefix.Bits() + if ones == 0 || ones > 126 || !prefix.Addr().Is6() { + return netip.Addr{}, fmt.Errorf("invalid IPv6 subnet: %s", prefix.String()) + } + + ip := prefix.Addr().As16() + + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + + // Determine which byte the host bits start in + firstHostByte := ones / 8 + // If the prefix doesn't end on a byte boundary, handle the partial byte + partialBits := ones % 8 + + if partialBits > 0 { + // Keep the network bits in the partial byte, randomize the rest + hostMask := byte(0xff >> partialBits) + ip[firstHostByte] = (ip[firstHostByte] & ^hostMask) | (byte(rng.Intn(256)) & hostMask) + firstHostByte++ + } + + // Randomize remaining full host bytes + for i := firstHostByte; i < 16; i++ { + ip[i] = byte(rng.Intn(256)) + } + + // Avoid all-zeros and all-ones host parts by checking only host bits. + if isHostAllZeroOrOnes(ip[:], ones) { + ip = prefix.Masked().Addr().As16() + ip[15] |= 0x01 + } + + return netip.AddrFrom16(ip).Unmap(), nil +} + +// isHostAllZeroOrOnes checks whether all host bits (after prefixLen) are zero or all ones. +func isHostAllZeroOrOnes(ip []byte, prefixLen int) bool { + hostStart := prefixLen / 8 + partialBits := prefixLen % 8 + + hostSlice := slices.Clone(ip[hostStart:]) + if partialBits > 0 { + hostSlice[0] &= 0xff >> partialBits + } + + allZero := !slices.ContainsFunc(hostSlice, func(v byte) bool { return v != 0 }) + if allZero { + return true + } + + // Build the all-ones mask for host bits + onesMask := make([]byte, len(hostSlice)) + for i := range onesMask { + onesMask[i] = 0xff + } + if partialBits > 0 { + onesMask[0] = 0xff >> partialBits + } + + return slices.Equal(hostSlice, onesMask) +} + +func uint32ToIP(n uint32) netip.Addr { + var b [4]byte + binary.BigEndian.PutUint32(b[:], n) + return netip.AddrFrom4(b) +} + +// generateIPs generates a list of all possible IPs of the given network excluding IPs specified in the exclusion list +func generateIPs(ipNet *net.IPNet, exclusions map[string]struct{}) ([]net.IP, int) { + + var ips []net.IP + for ip := ipNet.IP.Mask(ipNet.Mask); ipNet.Contains(ip); incIP(ip) { + if _, ok := exclusions[ip.String()]; !ok && ip[3] != 0 { + ips = append(ips, copyIP(ip)) + } + } + + // remove network address, broadcast and Fake DNS resolver address + lenIPs := len(ips) + switch { + case lenIPs < 2: + return ips, lenIPs + case lenIPs < 3: + return ips[1 : len(ips)-1], lenIPs - 2 + default: + return ips[1 : len(ips)-2], lenIPs - 3 + } +} + +func copyIP(ip net.IP) net.IP { + dup := make(net.IP, len(ip)) + copy(dup, ip) + return dup +} + +func incIP(ip net.IP) { + for j := len(ip) - 1; j >= 0; j-- { + ip[j]++ + if ip[j] > 0 { + break + } + } +} diff --git a/management/server/types/network_test.go b/management/server/types/network_test.go new file mode 100644 index 000000000..d8a06dbbc --- /dev/null +++ b/management/server/types/network_test.go @@ -0,0 +1,264 @@ +package types + +import ( + "encoding/binary" + "net" + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewNetwork(t *testing.T) { + network := NewNetwork() + + // generated net should be a subnet of a larger 100.64.0.0/10 net + ipNet := net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.IPMask{255, 192, 0, 0}} + assert.Equal(t, ipNet.Contains(network.Net.IP), true) +} + +func TestAllocatePeerIP(t *testing.T) { + prefix := netip.MustParsePrefix("100.64.0.0/24") + var ips []netip.Addr + for i := 0; i < 252; i++ { + ip, err := AllocatePeerIP(prefix, ips) + if err != nil { + t.Fatal(err) + } + ips = append(ips, ip) + } + + assert.Len(t, ips, 252) + + uniq := make(map[string]struct{}) + for _, ip := range ips { + if _, ok := uniq[ip.String()]; !ok { + uniq[ip.String()] = struct{}{} + } else { + t.Errorf("found duplicate IP %s", ip.String()) + } + } +} + +func TestAllocatePeerIPSmallSubnet(t *testing.T) { + // Test /27 network (10.0.0.0/27) - should only have 30 usable IPs (10.0.0.1 to 10.0.0.30) + prefix := netip.MustParsePrefix("10.0.0.0/27") + var ips []netip.Addr + + // Allocate all available IPs in the /27 network + for i := 0; i < 30; i++ { + ip, err := AllocatePeerIP(prefix, ips) + if err != nil { + t.Fatal(err) + } + + // Verify IP is within the correct range + if !prefix.Contains(ip) { + t.Errorf("allocated IP %s is not within network %s", ip.String(), prefix.String()) + } + + ips = append(ips, ip) + } + + assert.Len(t, ips, 30) + + // Verify all IPs are unique + uniq := make(map[string]struct{}) + for _, ip := range ips { + if _, ok := uniq[ip.String()]; !ok { + uniq[ip.String()] = struct{}{} + } else { + t.Errorf("found duplicate IP %s", ip.String()) + } + } + + // Try to allocate one more IP - should fail as network is full + _, err := AllocatePeerIP(prefix, ips) + if err == nil { + t.Error("expected error when network is full, but got none") + } +} + +func TestAllocatePeerIPVariousCIDRs(t *testing.T) { + testCases := []struct { + name string + cidr string + expectedUsable int + }{ + {"/30 network", "192.168.1.0/30", 2}, // 4 total - 2 reserved = 2 usable + {"/29 network", "192.168.1.0/29", 6}, // 8 total - 2 reserved = 6 usable + {"/28 network", "192.168.1.0/28", 14}, // 16 total - 2 reserved = 14 usable + {"/27 network", "192.168.1.0/27", 30}, // 32 total - 2 reserved = 30 usable + {"/26 network", "192.168.1.0/26", 62}, // 64 total - 2 reserved = 62 usable + {"/25 network", "192.168.1.0/25", 126}, // 128 total - 2 reserved = 126 usable + {"/16 network", "10.0.0.0/16", 65534}, // 65536 total - 2 reserved = 65534 usable + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + prefix, err := netip.ParsePrefix(tc.cidr) + require.NoError(t, err) + prefix = prefix.Masked() + + var ips []netip.Addr + + // For larger networks, test only a subset to avoid long test runs + testCount := tc.expectedUsable + if testCount > 1000 { + testCount = 1000 + } + + // Allocate IPs and verify they're within the correct range + for i := 0; i < testCount; i++ { + ip, err := AllocatePeerIP(prefix, ips) + require.NoError(t, err, "failed to allocate IP %d", i) + + // Verify IP is within the correct range + assert.True(t, prefix.Contains(ip), "allocated IP %s is not within network %s", ip.String(), prefix.String()) + + // Verify IP is not network or broadcast address + networkAddr := prefix.Masked().Addr() + hostBits := 32 - prefix.Bits() + b := networkAddr.As4() + baseIP := binary.BigEndian.Uint32(b[:]) + broadcastIP := uint32ToIP(baseIP + (1 << hostBits) - 1) + + assert.NotEqual(t, networkAddr, ip, "allocated network address %s", ip.String()) + assert.NotEqual(t, broadcastIP, ip, "allocated broadcast address %s", ip.String()) + + ips = append(ips, ip) + } + + assert.Len(t, ips, testCount) + + // Verify all IPs are unique + uniq := make(map[string]struct{}) + for _, ip := range ips { + ipStr := ip.String() + assert.NotContains(t, uniq, ipStr, "found duplicate IP %s", ipStr) + uniq[ipStr] = struct{}{} + } + }) + } +} + +func TestGenerateIPs(t *testing.T) { + ipNet := net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.IPMask{255, 255, 255, 0}} + ips, ipsLen := generateIPs(&ipNet, map[string]struct{}{"100.64.0.0": {}}) + if ipsLen != 252 { + t.Errorf("expected 252 ips, got %d", len(ips)) + return + } + if ips[len(ips)-1].String() != "100.64.0.253" { + t.Errorf("expected last ip to be: 100.64.0.253, got %s", ips[len(ips)-1].String()) + } +} + +func TestNewNetworkHasIPv6(t *testing.T) { + network := NewNetwork() + + assert.NotNil(t, network.NetV6.IP, "v6 subnet should be allocated") + assert.True(t, network.NetV6.IP.To4() == nil, "v6 subnet should be IPv6") + assert.Equal(t, byte(0xfd), network.NetV6.IP[0], "v6 subnet should be ULA (fd prefix)") + + ones, bits := network.NetV6.Mask.Size() + assert.Equal(t, 64, ones, "v6 subnet should be /64") + assert.Equal(t, 128, bits) +} + +func TestAllocateIPv6SubnetUniqueness(t *testing.T) { + seen := make(map[string]struct{}) + for i := 0; i < 100; i++ { + network := NewNetwork() + key := network.NetV6.IP.String() + _, duplicate := seen[key] + assert.False(t, duplicate, "duplicate v6 subnet: %s", key) + seen[key] = struct{}{} + } +} + +func TestAllocateRandomPeerIPv6(t *testing.T) { + prefix := netip.MustParsePrefix("fd12:3456:7890:abcd::/64") + + ip, err := AllocateRandomPeerIPv6(prefix) + require.NoError(t, err) + + assert.True(t, ip.Is6(), "should be IPv6") + assert.True(t, prefix.Contains(ip), "should be within subnet") + // First 8 bytes (network prefix) should match + b := ip.As16() + prefixBytes := prefix.Addr().As16() + assert.Equal(t, prefixBytes[:8], b[:8], "prefix should match") + // Interface ID should not be all zeros + allZero := true + for _, v := range b[8:] { + if v != 0 { + allZero = false + break + } + } + assert.False(t, allZero, "interface ID should not be all zeros") +} + +func TestAllocateRandomPeerIPv6_VariousPrefixes(t *testing.T) { + tests := []struct { + name string + cidr string + prefix int + }{ + {"standard /64", "fd00:1234:5678:abcd::/64", 64}, + {"small /112", "fd00:1234:5678:abcd::/112", 112}, + {"large /48", "fd00:1234::/48", 48}, + {"non-boundary /60", "fd00:1234:5670::/60", 60}, + {"non-boundary /52", "fd00:1230::/52", 52}, + {"minimum /120", "fd00:1234:5678:abcd::100/120", 120}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + prefix, err := netip.ParsePrefix(tt.cidr) + require.NoError(t, err) + prefix = prefix.Masked() + + assert.Equal(t, tt.prefix, prefix.Bits()) + + for i := 0; i < 50; i++ { + ip, err := AllocateRandomPeerIPv6(prefix) + require.NoError(t, err) + assert.True(t, prefix.Contains(ip), "IP %s should be within %s", ip, prefix) + } + }) + } +} + +func TestAllocateRandomPeerIPv6_PreservesNetworkBits(t *testing.T) { + // For a /112, bytes 0-13 should be preserved, only bytes 14-15 should vary + prefix := netip.MustParsePrefix("fd00:1234:5678:abcd:ef01:2345:6789:0/112") + + prefixBytes := prefix.Addr().As16() + for i := 0; i < 20; i++ { + ip, err := AllocateRandomPeerIPv6(prefix) + require.NoError(t, err) + // First 14 bytes (112 bits = 14 bytes) must match the network + b := ip.As16() + assert.Equal(t, prefixBytes[:14], b[:14], "network bytes should be preserved for /112") + } +} + +func TestAllocateRandomPeerIPv6_NonByteBoundary(t *testing.T) { + // For a /60, the first 7.5 bytes are network, so byte 7 is partial + prefix := netip.MustParsePrefix("fd00:1234:5678:abc0::/60") + + prefixBytes := prefix.Addr().As16() + for i := 0; i < 50; i++ { + ip, err := AllocateRandomPeerIPv6(prefix) + require.NoError(t, err) + b := ip.As16() + assert.True(t, prefix.Contains(ip), "IP %s should be within %s", ip, prefix) + // First 7 bytes must match exactly + assert.Equal(t, prefixBytes[:7], b[:7], "full network bytes should match for /60") + // Byte 7: top 4 bits (0xc = 1100) must be preserved + assert.Equal(t, prefixBytes[7]&0xf0, b[7]&0xf0, "partial byte network bits should be preserved for /60") + } +} diff --git a/management/server/types/networkmap_components_correctness_test.go b/management/server/types/networkmap_components_correctness_test.go index 825d51d4e..eb3e4fe3b 100644 --- a/management/server/types/networkmap_components_correctness_test.go +++ b/management/server/types/networkmap_components_correctness_test.go @@ -388,7 +388,7 @@ func TestComponents_NetworkSerial(t *testing.T) { account.Network.Serial = 42 nm := componentsNetworkMap(account, "peer-0", validatedPeers) require.NotNil(t, nm) - assert.Equal(t, uint64(42), nm.Network.Serial, "network serial should match") + assert.Equal(t, uint64(42), nm.Network.CurrentSerial(), "network serial should match") } // ────────────────────────────────────────────────────────────────────────────── @@ -812,7 +812,7 @@ func TestComponents_AllPeersGetValidMaps(t *testing.T) { } nm := componentsNetworkMap(account, peerID, validatedPeers) require.NotNil(t, nm, "network map should not be nil for %s", peerID) - assert.Equal(t, account.Network.Serial, nm.Network.Serial, "serial mismatch for %s", peerID) + assert.Equal(t, account.Network.Serial, nm.Network.CurrentSerial(), "serial mismatch for %s", peerID) assert.NotEmpty(t, nm.Peers, "validated peer %s should see other peers", peerID) } } @@ -833,7 +833,7 @@ func TestComponents_LargeScaleMapGeneration(t *testing.T) { require.NotNil(t, nm, "network map should not be nil for %s", peerID) assert.NotEmpty(t, nm.Peers, "peer %s should see other peers at scale", peerID) assert.NotEmpty(t, nm.Routes, "peer %s should have routes at scale", peerID) - assert.Equal(t, account.Network.Serial, nm.Network.Serial, "serial mismatch for %s", peerID) + assert.Equal(t, account.Network.Serial, nm.Network.CurrentSerial(), "serial mismatch for %s", peerID) } }) } diff --git a/management/server/types/networkmap_components_test.go b/management/server/types/networkmap_components_test.go index 3f2288f88..f6d542609 100644 --- a/management/server/types/networkmap_components_test.go +++ b/management/server/types/networkmap_components_test.go @@ -18,6 +18,7 @@ import ( "github.com/netbirdio/netbird/management/server/posture" "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/route" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" ) func networkMapFromComponents(t *testing.T, account *types.Account, peerID string, validatedPeers map[string]struct{}) *types.NetworkMap { @@ -49,7 +50,7 @@ func allPeersValidated(account *types.Account, excludePeerIDs ...string) map[str return validated } -func peerIDs(peers []*types.ComponentPeer) []string { +func peerIDs(peers []*nmdata.Peer) []string { ids := make([]string, len(peers)) for i, p := range peers { ids[i] = p.ID @@ -625,7 +626,7 @@ func TestNetworkMapComponents_DomainNetworkResource(t *testing.T) { var hasDomainRoute bool for _, r := range nm.Routes { - if r.NetworkType == route.DomainNetwork && len(r.Domains) > 0 && r.Domains[0].SafeString() == "api.example.com" { + if r.NetworkType == int(route.DomainNetwork) && len(r.Domains) > 0 && r.Domains[0].SafeString() == "api.example.com" { hasDomainRoute = true } } diff --git a/management/server/types/networkmap_wire_benchmark_test.go b/management/server/types/networkmap_wire_benchmark_test.go index ee9839a3f..ccec054cd 100644 --- a/management/server/types/networkmap_wire_benchmark_test.go +++ b/management/server/types/networkmap_wire_benchmark_test.go @@ -66,7 +66,7 @@ func BenchmarkNetworkMapWireEncode(b *testing.B) { // Pre-encode once so the size metric is identical for every run inside // the same scale; the b.Loop call only re-runs encode + Marshal. - legacyResp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, peer, nil, nil, networkMap, "netbird.cloud", nil, dnsCache, settings, nil, nil, 0) + legacyResp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, types.TwinPeer(peer), nil, nil, networkMap, "netbird.cloud", nil, dnsCache, types.TwinAccountSettings(settings), nil, nil, 0) legacyBytes, err := goproto.Marshal(legacyResp.NetworkMap) if err != nil { b.Fatalf("marshal legacy networkmap: %v", err) @@ -88,7 +88,7 @@ func BenchmarkNetworkMapWireEncode(b *testing.B) { b.ReportMetric(float64(len(legacyBytes)), "bytes/msg") b.ResetTimer() for range b.N { - resp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, peer, nil, nil, networkMap, "netbird.cloud", nil, dnsCache, settings, nil, nil, 0) + resp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, types.TwinPeer(peer), nil, nil, networkMap, "netbird.cloud", nil, dnsCache, types.TwinAccountSettings(settings), nil, nil, 0) if _, err := goproto.Marshal(resp.NetworkMap); err != nil { b.Fatal(err) } @@ -135,7 +135,7 @@ func BenchmarkNetworkMapWireSize(b *testing.B) { dnsCache := &cache.DNSConfigCache{} settings := &types.Settings{} - legacyResp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, peer, nil, nil, networkMap, "netbird.cloud", nil, dnsCache, settings, nil, nil, 0) + legacyResp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, types.TwinPeer(peer), nil, nil, networkMap, "netbird.cloud", nil, dnsCache, types.TwinAccountSettings(settings), nil, nil, 0) legacyBytes, err := goproto.Marshal(legacyResp.NetworkMap) if err != nil { b.Fatalf("marshal legacy networkmap: %v", err) diff --git a/management/server/types/networkmap_wire_breakdown_test.go b/management/server/types/networkmap_wire_breakdown_test.go index ac2855fa3..adf66b386 100644 --- a/management/server/types/networkmap_wire_breakdown_test.go +++ b/management/server/types/networkmap_wire_breakdown_test.go @@ -45,7 +45,7 @@ func TestNetworkMapWireBreakdown(t *testing.T) { dnsCache := &cache.DNSConfigCache{} settings := &types.Settings{} - legacyResp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, peer, nil, nil, networkMap, "netbird.cloud", nil, dnsCache, settings, nil, nil, 0) + legacyResp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, types.TwinPeer(peer), nil, nil, networkMap, "netbird.cloud", nil, dnsCache, types.TwinAccountSettings(settings), nil, nil, 0) legacyTotal := mustMarshalSize(t, legacyResp.NetworkMap) envelope := mgmtgrpc.EncodeNetworkMapEnvelope(mgmtgrpc.ComponentsEnvelopeInput{ diff --git a/shared/management/types/policy.go b/management/server/types/policy.go similarity index 58% rename from shared/management/types/policy.go rename to management/server/types/policy.go index b8f605b94..0f7298d18 100644 --- a/shared/management/types/policy.go +++ b/management/server/types/policy.go @@ -1,32 +1,5 @@ package types -import ( - "errors" - "fmt" - "strconv" - "strings" -) - -const ( - // PolicyTrafficActionAccept indicates that the traffic is accepted - PolicyTrafficActionAccept = PolicyTrafficActionType("accept") - // PolicyTrafficActionDrop indicates that the traffic is dropped - PolicyTrafficActionDrop = PolicyTrafficActionType("drop") -) - -const ( - // PolicyRuleProtocolALL type of traffic - PolicyRuleProtocolALL = PolicyRuleProtocolType("all") - // PolicyRuleProtocolTCP type of traffic - PolicyRuleProtocolTCP = PolicyRuleProtocolType("tcp") - // PolicyRuleProtocolUDP type of traffic - PolicyRuleProtocolUDP = PolicyRuleProtocolType("udp") - // PolicyRuleProtocolICMP type of traffic - PolicyRuleProtocolICMP = PolicyRuleProtocolType("icmp") - // PolicyRuleProtocolNetbirdSSH type of traffic - PolicyRuleProtocolNetbirdSSH = PolicyRuleProtocolType("netbird-ssh") -) - const ( // PolicyRuleFlowDirect allows traffic from source to destination PolicyRuleFlowDirect = PolicyRuleDirection("direct") @@ -184,85 +157,3 @@ func (p *Policy) SourceGroups() []string { return groupIDs } - -func ParseRuleString(rule string) (PolicyRuleProtocolType, RulePortRange, error) { - rule = strings.TrimSpace(strings.ToLower(rule)) - if rule == "all" { - return PolicyRuleProtocolALL, RulePortRange{}, nil - } - if rule == "icmp" { - return PolicyRuleProtocolICMP, RulePortRange{}, nil - } - - split := strings.Split(rule, "/") - if len(split) != 2 { - return "", RulePortRange{}, errors.New("invalid rule format: expected protocol/port or protocol/port-range") - } - - protoStr := strings.TrimSpace(split[0]) - portStr := strings.TrimSpace(split[1]) - - var protocol PolicyRuleProtocolType - switch protoStr { - case "tcp": - protocol = PolicyRuleProtocolTCP - case "udp": - protocol = PolicyRuleProtocolUDP - case "icmp": - return "", RulePortRange{}, errors.New("icmp does not accept ports; use 'icmp' without '/…'") - case "netbird-ssh": - return PolicyRuleProtocolNetbirdSSH, RulePortRange{Start: nativeSSHPortNumber, End: nativeSSHPortNumber}, nil - default: - return "", RulePortRange{}, fmt.Errorf("invalid protocol: %q", protoStr) - } - - portRange, err := parsePortRange(portStr) - if err != nil { - return "", RulePortRange{}, err - } - - return protocol, portRange, nil -} - -func parsePortRange(portStr string) (RulePortRange, error) { - if strings.Contains(portStr, "-") { - rangeParts := strings.Split(portStr, "-") - if len(rangeParts) != 2 { - return RulePortRange{}, fmt.Errorf("invalid port range %q", portStr) - } - start, err := parsePort(strings.TrimSpace(rangeParts[0])) - if err != nil { - return RulePortRange{}, err - } - end, err := parsePort(strings.TrimSpace(rangeParts[1])) - if err != nil { - return RulePortRange{}, err - } - if start > end { - return RulePortRange{}, fmt.Errorf("invalid port range: start %d > end %d", start, end) - } - return RulePortRange{Start: uint16(start), End: uint16(end)}, nil - } - - p, err := parsePort(portStr) - if err != nil { - return RulePortRange{}, err - } - - return RulePortRange{Start: uint16(p), End: uint16(p)}, nil -} - -func parsePort(portStr string) (int, error) { - - if portStr == "" { - return 0, errors.New("empty port") - } - p, err := strconv.Atoi(portStr) - if err != nil { - return 0, fmt.Errorf("invalid port %q: %w", portStr, err) - } - if p < 1 || p > 65535 { - return 0, fmt.Errorf("port out of range (1–65535): %d", p) - } - return p, nil -} diff --git a/management/server/types/policyrule.go b/management/server/types/policyrule.go new file mode 100644 index 000000000..87905f005 --- /dev/null +++ b/management/server/types/policyrule.go @@ -0,0 +1,196 @@ +package types + +import ( + "slices" +) + +// PolicyUpdateOperationType operation type +type PolicyUpdateOperationType int + +// PolicyRuleDirection direction of traffic +type PolicyRuleDirection string + +// PolicyRule is the metadata of the policy +type PolicyRule struct { + // ID of the policy rule + ID string `gorm:"primaryKey"` + + // PolicyID is a reference to Policy that this object belongs + PolicyID string `json:"-" gorm:"index"` + + // Name of the rule visible in the UI + Name string + + // Description of the rule visible in the UI + Description string + + // Enabled status of rule in the system + Enabled bool + + // Action policy accept or drops packets + Action PolicyTrafficActionType + + // Destinations policy destination groups + Destinations []string `gorm:"serializer:json"` + + // DestinationResource policy destination resource that the rule is applied to + DestinationResource Resource `gorm:"serializer:json"` + + // Sources policy source groups + Sources []string `gorm:"serializer:json"` + + // SourceResource policy source resource that the rule is applied to + SourceResource Resource `gorm:"serializer:json"` + + // Bidirectional define if the rule is applicable in both directions, sources, and destinations + Bidirectional bool + + // Protocol type of the traffic + Protocol PolicyRuleProtocolType + + // Ports or it ranges list + Ports []string `gorm:"serializer:json"` + + // PortRanges a list of port ranges. + PortRanges []RulePortRange `gorm:"serializer:json"` + + // AuthorizedGroups is a map of groupIDs and their respective access to local users via ssh + AuthorizedGroups map[string][]string `gorm:"serializer:json"` + + // AuthorizedUser is a list of userIDs that are authorized to access local resources via ssh + AuthorizedUser string +} + +// Copy returns a copy of a policy rule +func (pm *PolicyRule) Copy() *PolicyRule { + rule := &PolicyRule{ + ID: pm.ID, + PolicyID: pm.PolicyID, + Name: pm.Name, + Description: pm.Description, + Enabled: pm.Enabled, + Action: pm.Action, + Destinations: make([]string, len(pm.Destinations)), + DestinationResource: pm.DestinationResource, + Sources: make([]string, len(pm.Sources)), + SourceResource: pm.SourceResource, + Bidirectional: pm.Bidirectional, + Protocol: pm.Protocol, + Ports: make([]string, len(pm.Ports)), + PortRanges: make([]RulePortRange, len(pm.PortRanges)), + AuthorizedGroups: make(map[string][]string, len(pm.AuthorizedGroups)), + AuthorizedUser: pm.AuthorizedUser, + } + copy(rule.Destinations, pm.Destinations) + copy(rule.Sources, pm.Sources) + copy(rule.Ports, pm.Ports) + copy(rule.PortRanges, pm.PortRanges) + for k, v := range pm.AuthorizedGroups { + rule.AuthorizedGroups[k] = make([]string, len(v)) + copy(rule.AuthorizedGroups[k], v) + } + return rule +} + +func (pm *PolicyRule) Equal(other *PolicyRule) bool { + if pm == nil || other == nil { + return pm == other + } + + if pm.ID != other.ID || + pm.PolicyID != other.PolicyID || + pm.Name != other.Name || + pm.Description != other.Description || + pm.Enabled != other.Enabled || + pm.Action != other.Action || + pm.Bidirectional != other.Bidirectional || + pm.Protocol != other.Protocol || + pm.SourceResource != other.SourceResource || + pm.DestinationResource != other.DestinationResource || + pm.AuthorizedUser != other.AuthorizedUser { + return false + } + + if !stringSlicesEqualUnordered(pm.Sources, other.Sources) { + return false + } + if !stringSlicesEqualUnordered(pm.Destinations, other.Destinations) { + return false + } + if !stringSlicesEqualUnordered(pm.Ports, other.Ports) { + return false + } + if !portRangeSlicesEqualUnordered(pm.PortRanges, other.PortRanges) { + return false + } + if !authorizedGroupsEqual(pm.AuthorizedGroups, other.AuthorizedGroups) { + return false + } + + return true +} + +func stringSlicesEqualUnordered(a, b []string) bool { + if len(a) != len(b) { + return false + } + if len(a) == 0 { + return true + } + sorted1 := make([]string, len(a)) + sorted2 := make([]string, len(b)) + copy(sorted1, a) + copy(sorted2, b) + slices.Sort(sorted1) + slices.Sort(sorted2) + return slices.Equal(sorted1, sorted2) +} + +func portRangeSlicesEqualUnordered(a, b []RulePortRange) bool { + if len(a) != len(b) { + return false + } + if len(a) == 0 { + return true + } + cmp := func(x, y RulePortRange) int { + if x.Start != y.Start { + if x.Start < y.Start { + return -1 + } + return 1 + } + if x.End != y.End { + if x.End < y.End { + return -1 + } + return 1 + } + return 0 + } + sorted1 := make([]RulePortRange, len(a)) + sorted2 := make([]RulePortRange, len(b)) + copy(sorted1, a) + copy(sorted2, b) + slices.SortFunc(sorted1, cmp) + slices.SortFunc(sorted2, cmp) + return slices.EqualFunc(sorted1, sorted2, func(x, y RulePortRange) bool { + return x.Start == y.Start && x.End == y.End + }) +} + +func authorizedGroupsEqual(a, b map[string][]string) bool { + if len(a) != len(b) { + return false + } + for k, va := range a { + vb, ok := b[k] + if !ok { + return false + } + if !stringSlicesEqualUnordered(va, vb) { + return false + } + } + return true +} diff --git a/management/server/types/resource.go b/management/server/types/resource.go new file mode 100644 index 000000000..0f065c850 --- /dev/null +++ b/management/server/types/resource.go @@ -0,0 +1,30 @@ +package types + +import ( + "github.com/netbirdio/netbird/shared/management/http/api" +) + +type Resource struct { + ID string + Type ResourceType +} + +func (r *Resource) ToAPIResponse() *api.Resource { + if r.ID == "" && r.Type == "" { + return nil + } + + return &api.Resource{ + Id: r.ID, + Type: api.ResourceType(r.Type), + } +} + +func (r *Resource) FromAPIRequest(req *api.Resource) { + if req == nil { + return + } + + r.ID = req.Id + r.Type = ResourceType(req.Type) +} diff --git a/management/server/types/user.go b/management/server/types/user.go index dc601e15b..2e975809c 100644 --- a/management/server/types/user.go +++ b/management/server/types/user.go @@ -6,7 +6,7 @@ import ( "time" "github.com/netbirdio/netbird/management/server/idp" - "github.com/netbirdio/netbird/management/server/integration_reference" + "github.com/netbirdio/netbird/shared/management/integration_reference" "github.com/netbirdio/netbird/util/crypt" ) diff --git a/management/server/user_test.go b/management/server/user_test.go index a2e71616a..3a2414540 100644 --- a/management/server/user_test.go +++ b/management/server/user_test.go @@ -33,7 +33,7 @@ import ( "github.com/netbirdio/netbird/idp/dex" "github.com/netbirdio/netbird/management/server/activity" "github.com/netbirdio/netbird/management/server/idp" - "github.com/netbirdio/netbird/management/server/integration_reference" + "github.com/netbirdio/netbird/shared/management/integration_reference" ) const ( diff --git a/shared/management/client/client_test.go b/shared/management/client/client_test.go index d4888fee2..d91dab221 100644 --- a/shared/management/client/client_test.go +++ b/shared/management/client/client_test.go @@ -126,7 +126,7 @@ func startManagement(t *testing.T) (*grpc.Server, net.Listener) { updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := mgmt.NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peersManger), config) + networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peersManger), config, nil) accountManager, err := mgmt.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore) if err != nil { t.Fatal(err) diff --git a/management/server/integration_reference/integration_reference.go b/shared/management/integration_reference/integration_reference.go similarity index 100% rename from management/server/integration_reference/integration_reference.go rename to shared/management/integration_reference/integration_reference.go diff --git a/shared/management/networkmap/decode.go b/shared/management/networkmap/decode.go index 07a7e400e..0cd45e417 100644 --- a/shared/management/networkmap/decode.go +++ b/shared/management/networkmap/decode.go @@ -1,18 +1,19 @@ package networkmap import ( + "context" "encoding/base64" "fmt" "net" "net/netip" + "slices" "strconv" "time" log "github.com/sirupsen/logrus" - nbdns "github.com/netbirdio/netbird/dns" - nbroute "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/domain" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" "github.com/netbirdio/netbird/shared/management/proto" "github.com/netbirdio/netbird/shared/management/types" ) @@ -24,7 +25,7 @@ import ( // ID scheme on the client side: // // Peers base64(wg_pub_key) // stable across snapshots -func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents, error) { +func DecodeEnvelope(ctx context.Context, env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents, error) { full := env.GetFull() if full == nil { return nil, fmt.Errorf("envelope has no Full payload") @@ -35,28 +36,28 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents, Network: decodeAccountNetwork(full.Network), AccountSettings: decodeAccountSettings(full.AccountSettings), CustomZoneDomain: full.CustomZoneDomain, - Peers: make(map[string]*types.ComponentPeer, len(full.Peers)), - Groups: make(map[string]*types.ComponentGroup, len(full.Groups)), - Policies: make([]*types.Policy, 0, len(full.Policies)), - Routes: make([]*nbroute.Route, 0, len(full.Routes)), - NameServerGroups: make([]*nbdns.NameServerGroup, 0, len(full.NameserverGroups)), + Peers: make(map[string]*nmdata.Peer, len(full.Peers)), + Groups: make(map[string]*nmdata.Group, len(full.Groups)), + Policies: make([]*nmdata.Policy, 0, len(full.Policies)), + Routes: make([]*nmdata.Route, 0, len(full.Routes)), + NameServerGroups: make([]*nmdata.NameServerGroup, 0, len(full.NameserverGroups)), AllDNSRecords: decodeSimpleRecords(full.AllDnsRecords), AccountZones: decodeCustomZones(full.AccountZones), - ResourcePoliciesMap: make(map[string][]*types.Policy), - RoutersMap: make(map[string]map[string]*types.ComponentRouter), - NetworkResources: make([]*types.ComponentResource, 0, len(full.NetworkResources)), - RouterPeers: make(map[string]*types.ComponentPeer), + ResourcePoliciesMap: make(map[string][]*nmdata.Policy), + RoutersMap: make(map[string]map[string]*nmdata.NetworkRouter), + NetworkResources: make([]*nmdata.NetworkResource, 0, len(full.NetworkResources)), + RouterPeers: make(map[string]*nmdata.Peer), AllowedUserIDs: stringSliceToSet(full.AllowedUserIds), PostureFailedPeers: make(map[string]map[string]struct{}, len(full.PostureFailedPeers)), GroupIDToUserIDs: make(map[string][]string, len(full.GroupIdToUserIds)), } if full.DnsSettings != nil { - c.DNSSettings = &types.DNSSettings{ + c.DNSSettings = &nmdata.DNSSettings{ DisabledManagementGroups: full.DnsSettings.DisabledManagementGroupIds, } } else { - c.DNSSettings = &types.DNSSettings{} + c.DNSSettings = &nmdata.DNSSettings{} } // Phase 1: peers. The envelope's peers slice is index-addressed on the @@ -98,20 +99,36 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents, log.WithField("peer idx", idx).Error("unrecognized peer idx during decoding") } } - group := &types.ComponentGroup{ - ID: groupID, - PublicID: gc.Id, - Peers: peerIDs, + + fromCompactResources := func() []nmdata.Resource { + var toret []nmdata.Resource + + for _, r := range gc.Resources { + res := resourceFromProto(r, peerIDByIndex) + if res == (nmdata.Resource{}) { + log.WithContext(ctx).Warnf("skipping invalid resource in group compact: %s", r.String()) + continue + } + toret = append(toret, res) + } + + return toret + } + + group := &nmdata.Group{ + PublicID: gc.Id, + Peers: peerIDs, + Resources: fromCompactResources(), } if gc.IsAll { - group.Name = types.GroupAllName + group.Name = nmdata.GroupAllName } c.Groups[groupID] = group } // Phase 3: policies (PolicyCompact = one rule per entry; current data // model is 1 rule per policy). - policyByID := make(map[string]*types.Policy, len(full.Policies)) + policyByID := make(map[string]*nmdata.Policy, len(full.Policies)) for i, pc := range full.Policies { if pc == nil { return nil, fmt.Errorf("invalid envelope: policies[%d] is nil", i) @@ -148,7 +165,7 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents, // Phase 7: routers_map (outer key = network seq id, inner key = peer-id // reconstructed from peer_index). Synthesized network id is "net_ ". for networkID, list := range full.RoutersMap { - inner := make(map[string]*types.ComponentRouter, len(list.Entries)) + inner := make(map[string]*nmdata.NetworkRouter, len(list.Entries)) for _, entry := range list.Entries { if !entry.PeerIndexSet { continue @@ -158,10 +175,8 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents, continue } peerID := peerIDByIndex[entry.PeerIndex] - inner[peerID] = &types.ComponentRouter{ - NetworkID: networkID, + inner[peerID] = &nmdata.NetworkRouter{ PublicID: entry.Id, - Peer: peerID, PeerGroups: entry.PeerGroupIds, Masquerade: entry.Masquerade, Metric: int(entry.Metric), @@ -180,7 +195,7 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents, if len(ids.Ids) == 0 { continue } - policies := make([]*types.Policy, 0, len(ids.Ids)) + policies := make([]*nmdata.Policy, 0, len(ids.Ids)) for _, id := range ids.Ids { if p, ok := policyByID[id]; ok { policies = append(policies, p) @@ -193,6 +208,15 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents, } } + // Phase 8: rebuild resource_policies_map + for _, r := range c.NetworkResources { + policies := policiesForNetworkResource(r.ID, c.Policies, c.Groups) + if len(policies) == 0 { + continue + } + c.ResourcePoliciesMap[r.ID] = policies + } + // Phase 9: group_id_to_user_ids — wire keys are seq ids, synth to strings. for groupId, list := range full.GroupIdToUserIds { c.GroupIDToUserIDs[groupId] = append([]string(nil), list.UserIds...) @@ -228,17 +252,54 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents, return c, nil } +func networkResourceGroups(resourceId string, groups map[string]*nmdata.Group) []string { + var toret []string + for _, group := range groups { + for _, resource := range group.Resources { + if resource.ID == resourceId { + toret = append(toret, group.PublicID) + } + } + } + return toret +} + +func policiesForNetworkResource(resourceId string, allPolicies []*nmdata.Policy, groups map[string]*nmdata.Group) []*nmdata.Policy { + var toret []*nmdata.Policy + + networkResourceGroups := networkResourceGroups(resourceId, groups) + for _, p := range allPolicies { + if p == nil || !p.Enabled || len(p.Rules) == 0 { + continue + } + + // there's always only one rule in each policy + if p.Rules[0].DestinationResource.ID == resourceId { + toret = append(toret, p) + continue + } + for _, groupId := range networkResourceGroups { + if slices.Contains(p.Rules[0].Destinations, groupId) { + toret = append(toret, p) + break + } + } + } + + return toret +} + // decodeAccountNetwork never returns nil — Calculate() dereferences // c.Network unconditionally, and servers that predate the fix omit the field // entirely from the empty-components envelope. -func decodeAccountNetwork(an *proto.AccountNetwork) *types.Network { - n := &types.Network{} +func decodeAccountNetwork(an *proto.AccountNetwork) *nmdata.Network { + n := &nmdata.Network{} if an == nil { return n } n.Identifier = an.Identifier n.Dns = an.Dns - n.Serial = an.Serial + n.Serial = int64(an.Serial) if an.NetCidr != "" { if _, ipnet, err := net.ParseCIDR(an.NetCidr); err == nil && ipnet != nil { n.Net = *ipnet @@ -252,33 +313,51 @@ func decodeAccountNetwork(an *proto.AccountNetwork) *types.Network { return n } -func decodeAccountSettings(as *proto.AccountSettingsCompact) *types.AccountSettingsInfo { +func decodeAccountSettings(as *proto.AccountSettingsCompact) *nmdata.AccountSettingsInfo { if as == nil { - return &types.AccountSettingsInfo{} + return &nmdata.AccountSettingsInfo{} } - return &types.AccountSettingsInfo{ + return &nmdata.AccountSettingsInfo{ PeerLoginExpirationEnabled: as.PeerLoginExpirationEnabled, PeerLoginExpiration: time.Duration(as.PeerLoginExpirationNs), } } -func decodePeerCompact(pc *proto.PeerCompact, peerID string) *types.ComponentPeer { - peer := &types.ComponentPeer{ +func decodePeerCompact(pc *proto.PeerCompact, peerID string) *nmdata.Peer { + var caps []int32 + if pc.SupportsSourcePrefixes { + caps = append(caps, nmdata.PeerCapabilitySourcePrefixes) + } + if pc.SupportsIpv6 { + caps = append(caps, nmdata.PeerCapabilityIPv6Overlay) + } + peer := &nmdata.Peer{ ID: peerID, Key: peerID, SSHKey: string(pc.SshPubKey), SSHEnabled: pc.SshEnabled, DNSLabel: pc.DnsLabel, LoginExpirationEnabled: pc.LoginExpirationEnabled, - AgentVersion: pc.AgentVersion, - SupportsSourcePrefixes: pc.SupportsSourcePrefixes, - SupportsIPv6: pc.SupportsIpv6, - ServerSSHAllowed: pc.ServerSshAllowed, - AddedWithSSOLogin: pc.AddedWithSsoLogin, - ProxyEmbedded: pc.ProxyEmbedded, + ProxyMeta: nmdata.ProxyMeta{Embedded: pc.ProxyEmbedded}, + Meta: nmdata.PeerSystemMeta{ + WtVersion: pc.AgentVersion, + Capabilities: caps, + Flags: nmdata.Flags{ + ServerSSHAllowed: pc.ServerSshAllowed, + }, + }, + } + if pc.AddedWithSsoLogin { + // Set a non-empty UserID so (*Peer).AddedWithSSOLogin() returns true. + // The original UserID isn't on the wire; the value is intentionally + // visibly synthetic so any future consumer that mistakes UserID for a + // real account user xid won't silently match (or worse, write the + // sentinel into a downstream record). + peer.UserID = " " } if pc.LastLoginUnixNano != 0 { - peer.LastLogin = time.Unix(0, pc.LastLoginUnixNano) + t := time.Unix(0, pc.LastLoginUnixNano) + peer.LastLogin = &t } switch len(pc.Ip) { case 4: @@ -296,13 +375,13 @@ func decodePeerCompact(pc *proto.PeerCompact, peerID string) *types.ComponentPee return peer } -func decodePolicyCompact(pc *proto.PolicyCompact, policyID string, peerIDByIndex []string) *types.Policy { - rule := &types.PolicyRule{ +func decodePolicyCompact(pc *proto.PolicyCompact, policyID string, peerIDByIndex []string) *nmdata.Policy { + rule := &nmdata.PolicyRule{ ID: policyID, // 1 rule per policy → reuse synthesized id PolicyID: policyID, Enabled: true, - Action: actionFromProto(pc.Action), - Protocol: protocolFromProto(pc.Protocol), + Action: string(actionFromProto(pc.Action)), + Protocol: string(protocolFromProto(pc.Protocol)), Bidirectional: pc.Bidirectional, Ports: uint32SliceToStrings(pc.Ports), PortRanges: portRangesFromProto(pc.PortRanges), @@ -313,11 +392,11 @@ func decodePolicyCompact(pc *proto.PolicyCompact, policyID string, peerIDByIndex SourceResource: resourceFromProto(pc.SourceResource, peerIDByIndex), DestinationResource: resourceFromProto(pc.DestinationResource, peerIDByIndex), } - return &types.Policy{ + return &nmdata.Policy{ ID: policyID, PublicID: pc.Id, Enabled: true, - Rules: []*types.PolicyRule{rule}, + Rules: []*nmdata.PolicyRule{rule}, SourcePostureChecks: pc.SourcePostureCheckIds, } } @@ -325,15 +404,19 @@ func decodePolicyCompact(pc *proto.PolicyCompact, policyID string, peerIDByIndex // resourceFromProto rebuilds types.Resource. For peer-typed resources the // peer reference is reconstructed from the envelope's peer index — wire // format ships no xid for peers, so we use the synthesized peer id. -func resourceFromProto(r *proto.ResourceCompact, peerIDByIndex []string) types.Resource { - if r == nil { - return types.Resource{} +func resourceFromProto(r *proto.ResourceCompact, peerIDByIndex []string) nmdata.Resource { + if r == nil || !types.ResourceType(r.Type).Valid() { + return nmdata.Resource{} } - out := types.Resource{Type: types.ResourceType(r.Type)} - if r.PeerIndexSet && int(r.PeerIndex) < len(peerIDByIndex) { - out.ID = peerIDByIndex[r.PeerIndex] + + if r.Type == string(types.ResourceTypePeer) { + if !r.PeerIndexSet || int(r.PeerIndex) >= len(peerIDByIndex) { + return nmdata.Resource{} + } + return nmdata.Resource{Type: r.Type, ID: peerIDByIndex[int(r.PeerIndex)]} } - return out + + return nmdata.Resource{Type: r.Type, ID: r.Id} } // authorizedGroupsFromProto inverts encodeAuthorizedGroups: the wire form @@ -354,15 +437,15 @@ func authorizedGroupsFromProto(m map[string]*proto.UserNameList) map[string][]st return out } -func decodeRouteRaw(rr *proto.RouteRaw, peerIDByIndex []string) *nbroute.Route { - r := &nbroute.Route{ - ID: nbroute.ID(rr.Id), +func decodeRouteRaw(rr *proto.RouteRaw, peerIDByIndex []string) *nmdata.Route { + r := &nmdata.Route{ + ID: rr.Id, PublicID: rr.Id, - NetID: nbroute.NetID(rr.NetId), + NetID: rr.NetId, Description: rr.Description, Domains: domainsFromPunycode(rr.Domains), KeepRoute: rr.KeepRoute, - NetworkType: nbroute.NetworkType(rr.NetworkType), + NetworkType: int(rr.NetworkType), Masquerade: rr.Masquerade, Metric: int(rr.Metric), Enabled: rr.Enabled, @@ -382,8 +465,8 @@ func decodeRouteRaw(rr *proto.RouteRaw, peerIDByIndex []string) *nbroute.Route { return r } -func decodeNameServerGroupRaw(nsg *proto.NameServerGroupRaw) *nbdns.NameServerGroup { - out := &nbdns.NameServerGroup{ +func decodeNameServerGroupRaw(nsg *proto.NameServerGroupRaw) *nmdata.NameServerGroup { + out := &nmdata.NameServerGroup{ ID: nsg.Id, PublicID: nsg.Id, Groups: nsg.GroupIds, @@ -391,13 +474,13 @@ func decodeNameServerGroupRaw(nsg *proto.NameServerGroupRaw) *nbdns.NameServerGr Domains: nsg.Domains, Enabled: nsg.Enabled, SearchDomainsEnabled: nsg.SearchDomainsEnabled, - NameServers: make([]nbdns.NameServer, 0, len(nsg.Nameservers)), + NameServers: make([]nmdata.NameServer, 0, len(nsg.Nameservers)), } for _, ns := range nsg.Nameservers { if addr, err := netip.ParseAddr(ns.IP); err == nil { - out.NameServers = append(out.NameServers, nbdns.NameServer{ + out.NameServers = append(out.NameServers, nmdata.NameServer{ IP: addr, - NSType: nbdns.NameServerType(ns.NSType), + NSType: int(ns.NSType), Port: int(ns.Port), }) } @@ -405,14 +488,14 @@ func decodeNameServerGroupRaw(nsg *proto.NameServerGroupRaw) *nbdns.NameServerGr return out } -func decodeNetworkResource(nr *proto.NetworkResourceRaw) *types.ComponentResource { - out := &types.ComponentResource{ +func decodeNetworkResource(nr *proto.NetworkResourceRaw) *nmdata.NetworkResource { + out := &nmdata.NetworkResource{ ID: nr.Id, PublicID: nr.Id, NetworkID: nr.NetworkSeq, Name: nr.Name, Description: nr.Description, - Type: types.ComponentResourceType(nr.Type), + Type: nr.Type, Address: nr.Address, Domain: nr.DomainValue, Enabled: nr.Enabled, @@ -425,10 +508,10 @@ func decodeNetworkResource(nr *proto.NetworkResourceRaw) *types.ComponentResourc return out } -func decodeSimpleRecords(records []*proto.SimpleRecord) []nbdns.SimpleRecord { - out := make([]nbdns.SimpleRecord, 0, len(records)) +func decodeSimpleRecords(records []*proto.SimpleRecord) []nmdata.SimpleRecord { + out := make([]nmdata.SimpleRecord, 0, len(records)) for _, r := range records { - out = append(out, nbdns.SimpleRecord{ + out = append(out, nmdata.SimpleRecord{ Name: r.Name, Type: int(r.Type), Class: r.Class, @@ -439,10 +522,10 @@ func decodeSimpleRecords(records []*proto.SimpleRecord) []nbdns.SimpleRecord { return out } -func decodeCustomZones(zones []*proto.CustomZone) []nbdns.CustomZone { - out := make([]nbdns.CustomZone, 0, len(zones)) +func decodeCustomZones(zones []*proto.CustomZone) []nmdata.CustomZone { + out := make([]nmdata.CustomZone, 0, len(zones)) for _, z := range zones { - out = append(out, nbdns.CustomZone{ + out = append(out, nmdata.CustomZone{ Domain: z.Domain, Records: decodeSimpleRecords(z.Records), SearchDomainDisabled: z.SearchDomainDisabled, @@ -463,16 +546,16 @@ func uint32SliceToStrings(ports []uint32) []string { return out } -func portRangesFromProto(ranges []*proto.PortInfo_Range) []types.RulePortRange { +func portRangesFromProto(ranges []*proto.PortInfo_Range) []nmdata.RulePortRange { if len(ranges) == 0 { return nil } - out := make([]types.RulePortRange, 0, len(ranges)) + out := make([]nmdata.RulePortRange, 0, len(ranges)) for _, r := range ranges { if r == nil || r.Start > 65535 || r.End > 65535 { continue } - out = append(out, types.RulePortRange{ + out = append(out, nmdata.RulePortRange{ Start: uint16(r.Start), End: uint16(r.End), }) diff --git a/shared/management/networkmap/decode_test.go b/shared/management/networkmap/decode_test.go new file mode 100644 index 000000000..7e2f17c60 --- /dev/null +++ b/shared/management/networkmap/decode_test.go @@ -0,0 +1,61 @@ +package networkmap + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + protobuf "google.golang.org/protobuf/proto" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/netbirdio/netbird/shared/management/proto" +) + +func TestDecodePolicy(t *testing.T) { + assert.Equal(t, + nmdata.Resource{Type: "peer", ID: "valid-id"}, + resourceFromProto( + &proto.ResourceCompact{Type: "peer", PeerIndexSet: true, PeerIndex: uint32(1)}, + []string{"invalid-id-0", "valid-id", "invalid-id-2"})) + // check invalid peer index returns an empty resource + assert.Equal(t, + nmdata.Resource{}, + resourceFromProto( + &proto.ResourceCompact{Type: "peer", PeerIndexSet: true, PeerIndex: uint32(100)}, + []string{"invalid-id-0", "valid-id", "invalid-id-2"})) + assert.Equal(t, + nmdata.Resource{Type: "domain", ID: "domain"}, + resourceFromProto( + &proto.ResourceCompact{Type: "domain", Id: "domain"}, []string{})) + assert.Equal(t, + nmdata.Resource{Type: "host", ID: "host"}, + resourceFromProto( + &proto.ResourceCompact{Type: "host", Id: "host"}, []string{})) + assert.Equal(t, + nmdata.Resource{Type: "subnet", ID: "subnet"}, + resourceFromProto( + &proto.ResourceCompact{Type: "subnet", Id: "subnet"}, []string{})) + // an unknown resource type return an empty resource + assert.Equal(t, + nmdata.Resource{}, + resourceFromProto( + &proto.ResourceCompact{Type: "boom", Id: "boom"}, []string{})) +} + +// ResourceCompact fields 1-3 are the v0.77 wire contract. Retyping any of them +// makes peers on either side of the change silently drop policy resources, so +// the encoding is pinned here as raw bytes: field 1 "peer" (bytes), field 2 +// true (varint), field 3 7 (varint). +func TestResourceCompactLegacyWireFormat(t *testing.T) { + legacy := []byte{0x0a, 0x04, 'p', 'e', 'e', 'r', 0x10, 0x01, 0x18, 0x07} + + var decoded proto.ResourceCompact + require.NoError(t, protobuf.Unmarshal(legacy, &decoded)) + assert.Equal(t, "peer", decoded.Type) + assert.True(t, decoded.PeerIndexSet) + assert.Equal(t, uint32(7), decoded.PeerIndex) + + encoded, err := protobuf.Marshal(&proto.ResourceCompact{Type: "peer", PeerIndexSet: true, PeerIndex: 7}) + require.NoError(t, err) + assert.Equal(t, legacy, encoded) +} diff --git a/shared/management/networkmap/encode.go b/shared/management/networkmap/encode.go index 7f7f04204..dfacabe18 100644 --- a/shared/management/networkmap/encode.go +++ b/shared/management/networkmap/encode.go @@ -17,10 +17,11 @@ import ( log "github.com/sirupsen/logrus" goproto "google.golang.org/protobuf/proto" - nbdns "github.com/netbirdio/netbird/dns" "net/netip" - nbroute "github.com/netbirdio/netbird/route" + nbdns "github.com/netbirdio/netbird/dns" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" "github.com/netbirdio/netbird/shared/management/proto" "github.com/netbirdio/netbird/shared/management/types" "github.com/netbirdio/netbird/shared/netiputil" @@ -28,7 +29,7 @@ import ( ) // ToProtocolRoutes converts a slice of typed routes to their proto form. -func ToProtocolRoutes(routes []*nbroute.Route) []*proto.Route { +func ToProtocolRoutes(routes []*nmdata.Route) []*proto.Route { protoRoutes := make([]*proto.Route, 0, len(routes)) for _, r := range routes { protoRoutes = append(protoRoutes, ToProtocolRoute(r)) @@ -37,7 +38,7 @@ func ToProtocolRoutes(routes []*nbroute.Route) []*proto.Route { } // ToProtocolRoute converts one typed route to its proto form. -func ToProtocolRoute(route *nbroute.Route) *proto.Route { +func ToProtocolRoute(route *nmdata.Route) *proto.Route { return &proto.Route{ ID: string(route.ID), NetID: string(route.NetID), @@ -274,7 +275,7 @@ func ToProtocolDNSConfig(update nbdns.Config, cache DNSConfigCache, forwardPort // AppendRemotePeerConfig appends typed peers as proto.RemotePeerConfig // entries to dst and returns the result. localIsProxy reports whether the peer // receiving this config is itself an embedded proxy. -func AppendRemotePeerConfig(dst []*proto.RemotePeerConfig, peers []*types.ComponentPeer, dnsName string, includeIPv6 bool, localIsProxy bool) []*proto.RemotePeerConfig { +func AppendRemotePeerConfig(dst []*proto.RemotePeerConfig, peers []*nmdata.Peer, dnsName string, includeIPv6 bool, localIsProxy bool) []*proto.RemotePeerConfig { for _, rPeer := range peers { allowedIPs := []string{rPeer.IP.String() + "/32"} if includeIPv6 && rPeer.IPv6.IsValid() { @@ -285,7 +286,7 @@ func AppendRemotePeerConfig(dst []*proto.RemotePeerConfig, peers []*types.Compon AllowedIps: allowedIPs, SshConfig: &proto.SSHConfig{SshPubKey: []byte(rPeer.SSHKey)}, Fqdn: rPeer.FQDN(dnsName), - AgentVersion: rPeer.AgentVersion, + AgentVersion: rPeer.Meta.WtVersion, LazyState: lazyStateFor(localIsProxy, rPeer), }) } @@ -297,8 +298,8 @@ func AppendRemotePeerConfig(dst []*proto.RemotePeerConfig, peers []*types.Compon // proxy infrastructure is not kept permanently connected to every peer. All // other peers follow the account-wide flag. A future admin-facing per-peer // setting can return LazyStateEager here to force a peer always-active. -func lazyStateFor(localIsProxy bool, rPeer *types.ComponentPeer) proto.LazyState { - if localIsProxy || rPeer.ProxyEmbedded { +func lazyStateFor(localIsProxy bool, rPeer *nmdata.Peer) proto.LazyState { + if localIsProxy || rPeer.ProxyMeta.Embedded { return proto.LazyState_LazyStateLazy } return proto.LazyState_LazyStateDefault diff --git a/shared/management/networkmap/envelope.go b/shared/management/networkmap/envelope.go index cd3f862ec..e7961fd7b 100644 --- a/shared/management/networkmap/envelope.go +++ b/shared/management/networkmap/envelope.go @@ -36,7 +36,7 @@ type EnvelopeResult struct { // dnsName is the account's DNS domain ("netbird.cloud" etc.); used when // rebuilding the per-peer FQDNs that proto.RemotePeerConfig carries. func EnvelopeToNetworkMap(ctx context.Context, env *proto.NetworkMapEnvelope, localPeerKey, dnsName string) (*EnvelopeResult, error) { - components, err := DecodeEnvelope(env) + components, err := DecodeEnvelope(ctx, env) if err != nil { return nil, fmt.Errorf("decode envelope: %w", err) } @@ -54,8 +54,8 @@ func EnvelopeToNetworkMap(ctx context.Context, env *proto.NetworkMapEnvelope, lo } components.PeerID = canonicalKey - includeIPv6 := localPeer.SupportsIPv6 && localPeer.IPv6.IsValid() - useSourcePrefixes := localPeer.SupportsSourcePrefixes + includeIPv6 := localPeer.SupportsIPv6() && localPeer.IPv6.IsValid() + useSourcePrefixes := localPeer.SupportsSourcePrefixes() typedNM := components.Calculate(ctx) @@ -74,11 +74,11 @@ func EnvelopeToNetworkMap(ctx context.Context, env *proto.NetworkMapEnvelope, lo protoNM.Routes = ToProtocolRoutes(typedNM.Routes) protoNM.DNSConfig = ToProtocolDNSConfig(typedNM.DNSConfig, nil, dnsFwdPort) - remotePeers := AppendRemotePeerConfig(nil, typedNM.Peers, dnsName, includeIPv6, localPeer.ProxyEmbedded) + remotePeers := AppendRemotePeerConfig(nil, typedNM.Peers, dnsName, includeIPv6, localPeer.ProxyMeta.Embedded) protoNM.RemotePeers = remotePeers protoNM.RemotePeersIsEmpty = len(remotePeers) == 0 - protoNM.OfflinePeers = AppendRemotePeerConfig(nil, typedNM.OfflinePeers, dnsName, includeIPv6, localPeer.ProxyEmbedded) + protoNM.OfflinePeers = AppendRemotePeerConfig(nil, typedNM.OfflinePeers, dnsName, includeIPv6, localPeer.ProxyMeta.Embedded) firewallRules := ToProtocolFirewallRules(typedNM.FirewallRules, includeIPv6, useSourcePrefixes) protoNM.FirewallRules = firewallRules diff --git a/shared/management/networkmap/envelope_test.go b/shared/management/networkmap/envelope_test.go index 92e1916da..7fe2a5277 100644 --- a/shared/management/networkmap/envelope_test.go +++ b/shared/management/networkmap/envelope_test.go @@ -15,6 +15,7 @@ import ( mgmtgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" "github.com/netbirdio/netbird/management/server/types" nbnetworkmap "github.com/netbirdio/netbird/shared/management/networkmap" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" "github.com/netbirdio/netbird/shared/management/proto" ) @@ -55,13 +56,13 @@ func TestEnvelopeToNetworkMap_RoundTrip(t *testing.T) { func TestCalculate_FirewallRuleProtocol_NeverNetbirdSSH(t *testing.T) { c, localPeerKey := buildSmokeComponents(t) // Replace the smoke policy with a NetbirdSSH-protocol allow. - c.Policies = []*types.Policy{{ + c.Policies = []*nmdata.Policy{{ ID: "pol-ssh", PublicID: "2", Enabled: true, - Rules: []*types.PolicyRule{{ + Rules: []*nmdata.PolicyRule{{ ID: "rule-ssh", Enabled: true, - Action: types.PolicyTrafficActionAccept, - Protocol: types.PolicyRuleProtocolNetbirdSSH, + Action: string(types.PolicyTrafficActionAccept), + Protocol: string(types.PolicyRuleProtocolNetbirdSSH), Bidirectional: true, Sources: []string{"group-all"}, Destinations: []string{"group-all"}, @@ -143,39 +144,39 @@ func TestDecodeEnvelope_MalformedWgKeyPeerSkipped(t *testing.T) { func TestEnvelopeRoundTrip_AllGroupShortCircuitParity(t *testing.T) { ctx := context.Background() - peers := map[string]*types.ComponentPeer{} + peers := map[string]*nmdata.Peer{} for i, id := range []string{"peer-T", "peer-S", "peer-ALL", "peer-O"} { - peers[id] = &types.ComponentPeer{ - ID: id, - Key: randomWgKey(t), - IP: netip.AddrFrom4([4]byte{100, 64, 0, byte(i + 1)}), - DNSLabel: id, - AgentVersion: "0.40.0", + peers[id] = &nmdata.Peer{ + ID: id, + Key: randomWgKey(t), + IP: netip.AddrFrom4([4]byte{100, 64, 0, byte(i + 1)}), + DNSLabel: id, + Meta: nmdata.PeerSystemMeta{WtVersion: "0.40.0"}, } } c := &types.NetworkMapComponents{ PeerID: "peer-T", - Network: &types.Network{ + Network: &nmdata.Network{ Identifier: "net-all-groups", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}, Serial: 1, }, - AccountSettings: &types.AccountSettingsInfo{}, - DNSSettings: &types.DNSSettings{}, + AccountSettings: &nmdata.AccountSettingsInfo{}, + DNSSettings: &nmdata.DNSSettings{}, Peers: peers, - Groups: map[string]*types.ComponentGroup{ - "g-src": {ID: "g-src", PublicID: "1", Name: "staff", Peers: []string{"peer-T", "peer-S"}}, - "g-all": {ID: "g-all", PublicID: "2", Name: "All", Peers: []string{"peer-ALL"}}, - "g-two": {ID: "g-two", PublicID: "3", Name: "second", Peers: []string{"peer-T", "peer-O"}}, + Groups: map[string]*nmdata.Group{ + "g-src": {PublicID: "1", Name: "staff", Peers: []string{"peer-T", "peer-S"}}, + "g-all": {PublicID: "2", Name: "All", Peers: []string{"peer-ALL"}}, + "g-two": {PublicID: "3", Name: "second", Peers: []string{"peer-T", "peer-O"}}, }, - Policies: []*types.Policy{{ + Policies: []*nmdata.Policy{{ ID: "pol-multi-dest", PublicID: "10", Enabled: true, - Rules: []*types.PolicyRule{{ + Rules: []*nmdata.PolicyRule{{ ID: "rule-multi-dest", Enabled: true, - Action: types.PolicyTrafficActionAccept, - Protocol: types.PolicyRuleProtocolALL, + Action: string(types.PolicyTrafficActionAccept), + Protocol: string(types.PolicyRuleProtocolALL), Sources: []string{"g-src"}, Destinations: []string{"g-all", "g-two"}, }}, @@ -231,12 +232,12 @@ func TestEnvelopeToNetworkMap_EmptyComponents(t *testing.T) { localPeerKey := randomWgKey(t) c := types.EmptyNetworkMapComponents(&types.NetworkMapComponents{ PeerID: "peer-A", - Network: &types.Network{ + Network: &nmdata.Network{ Identifier: "net-empty", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}, Serial: 7, }, - Peers: map[string]*types.ComponentPeer{ + Peers: map[string]*nmdata.Peer{ "peer-A": {ID: "peer-A", Key: localPeerKey, IP: netip.AddrFrom4([4]byte{100, 64, 0, 1})}, }, }) @@ -291,33 +292,33 @@ func buildSmokeComponents(t *testing.T) (*types.NetworkMapComponents, string) { peerAKey := randomWgKey(t) peerBKey := randomWgKey(t) - peerA := &types.ComponentPeer{ - ID: "peer-A", - Key: peerAKey, - IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), - DNSLabel: "peerA", - AgentVersion: "0.40.0", + peerA := &nmdata.Peer{ + ID: "peer-A", + Key: peerAKey, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), + DNSLabel: "peerA", + Meta: nmdata.PeerSystemMeta{WtVersion: "0.40.0"}, } - peerB := &types.ComponentPeer{ - ID: "peer-B", - Key: peerBKey, - IP: netip.AddrFrom4([4]byte{100, 64, 0, 2}), - DNSLabel: "peerB", - AgentVersion: "0.40.0", + peerB := &nmdata.Peer{ + ID: "peer-B", + Key: peerBKey, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 2}), + DNSLabel: "peerB", + Meta: nmdata.PeerSystemMeta{WtVersion: "0.40.0"}, } - group := &types.ComponentGroup{ - ID: "group-all", PublicID: "1", Name: "All", + group := &nmdata.Group{ + PublicID: "1", Name: "All", Peers: []string{"peer-A", "peer-B"}, } - policy := &types.Policy{ + policy := &nmdata.Policy{ ID: "pol-allow", PublicID: "1", Enabled: true, - Rules: []*types.PolicyRule{{ + Rules: []*nmdata.PolicyRule{{ ID: "rule-allow", Enabled: true, - Action: types.PolicyTrafficActionAccept, - Protocol: types.PolicyRuleProtocolALL, + Action: string(types.PolicyTrafficActionAccept), + Protocol: string(types.PolicyRuleProtocolALL), Bidirectional: true, Sources: []string{"group-all"}, Destinations: []string{"group-all"}, @@ -326,21 +327,21 @@ func buildSmokeComponents(t *testing.T) (*types.NetworkMapComponents, string) { c := &types.NetworkMapComponents{ PeerID: "peer-A", - Network: &types.Network{ + Network: &nmdata.Network{ Identifier: "net-smoke", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}, Serial: 1, }, - AccountSettings: &types.AccountSettingsInfo{}, - DNSSettings: &types.DNSSettings{}, - Peers: map[string]*types.ComponentPeer{ + AccountSettings: &nmdata.AccountSettingsInfo{}, + DNSSettings: &nmdata.DNSSettings{}, + Peers: map[string]*nmdata.Peer{ "peer-A": peerA, "peer-B": peerB, }, - Groups: map[string]*types.ComponentGroup{ + Groups: map[string]*nmdata.Group{ "group-all": group, }, - Policies: []*types.Policy{policy}, + Policies: []*nmdata.Policy{policy}, } return c, peerAKey } diff --git a/shared/management/networkmap/networkmapcompute.go b/shared/management/networkmap/networkmapcompute.go new file mode 100644 index 000000000..1cf7aeef4 --- /dev/null +++ b/shared/management/networkmap/networkmapcompute.go @@ -0,0 +1,812 @@ +package networkmap + +import ( + "slices" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/netbirdio/netbird/shared/management/types" +) + +type sshRequirements struct { + neededGroupIDs map[string]struct{} + needAllowedUserIDs bool +} + +// GetPeerNetworkMapComponents computes the peer's NetworkMapComponents from the +// slim twin store. It mirrors the former Account.GetPeerNetworkMapComponents +// exactly, operating on nmdata twins throughout — no Account reference and no +// twin↔real conversion, since the produced components hold twins. +func (nmd *NetworkMapData) GetPeerNetworkMapComponents(peerID string, peersCustomZone nmdata.CustomZone) *types.NetworkMapComponents { + nmd.InjectProxyPolicies() + + forceRoutingPeerDNS := nmd.forcesRoutingPeerDNSResolution(peerID) + + peer := nmd.Peers[peerID] + if peer == nil { + return types.EmptyNetworkMapComponents(&types.NetworkMapComponents{ + PeerID: peerID, + Network: nmd.Network, + Peers: map[string]*nmdata.Peer{peerID: peer}, + ForceRoutingPeerDNSResolution: forceRoutingPeerDNS, + }) + } + + if _, ok := nmd.ValidatedPeers[peerID]; !ok { + return types.EmptyNetworkMapComponents(&types.NetworkMapComponents{ + PeerID: peerID, + Network: nmd.Network, + Peers: map[string]*nmdata.Peer{peerID: peer}, + ForceRoutingPeerDNSResolution: forceRoutingPeerDNS, + }) + } + + components := &types.NetworkMapComponents{ + PeerID: peerID, + Network: nmd.Network, + AccountSettings: nmd.AccountSettings, + DNSSettings: nmd.DNSSettings, + CustomZoneDomain: peersCustomZone.Domain, + NameServerGroups: make([]*nmdata.NameServerGroup, 0), + ResourcePoliciesMap: make(map[string][]*nmdata.Policy), + RoutersMap: make(map[string]map[string]*nmdata.NetworkRouter), + NetworkResources: make([]*nmdata.NetworkResource, 0), + PostureFailedPeers: make(map[string]map[string]struct{}, len(nmd.PostureChecks)), + RouterPeers: make(map[string]*nmdata.Peer), + NetworkXIDToPublicID: nmd.NetworkXIDToPublicID, + PostureCheckXIDToPublicID: nmd.PostureCheckXIDToPublicID, + ForceRoutingPeerDNSResolution: forceRoutingPeerDNS, + } + + relevantPeers, relevantGroups, relevantPolicies, relevantRoutes, sshReqs := nmd.getPeersGroupsPoliciesRoutes(peerID, peer.SSHEnabled, &components.PostureFailedPeers) + + if len(sshReqs.neededGroupIDs) > 0 { + components.GroupIDToUserIDs = filterGroupIDToUserIDs(nmd.GroupIDToUserIDs, sshReqs.neededGroupIDs) + } + if sshReqs.needAllowedUserIDs { + components.AllowedUserIDs = nmd.getAllowedUserIDs() + } + + components.Peers = relevantPeers + components.Groups = relevantGroups + components.Policies = relevantPolicies + components.Routes = relevantRoutes + components.AllDNSRecords = filterDNSRecordsByPeers(peersCustomZone.Records, relevantPeers, peer.SupportsIPv6() && peer.IPv6.IsValid()) + + peerGroups := nmd.GetPeerGroups(peerID) + components.AccountZones = nmd.appliedZones(peerGroups) + components.AccountZones = append(components.AccountZones, nmd.privateServiceZones(peerGroups)...) + + for _, nsGroup := range nmd.NameServerGroups { + if nsGroup != nil && nsGroup.Enabled { + for _, gID := range nsGroup.Groups { + if _, found := relevantGroups[gID]; found { + components.NameServerGroups = append(components.NameServerGroups, nsGroup) + break + } + } + } + } + + for _, resource := range nmd.NetworkResources { + if resource == nil || !resource.Enabled { + continue + } + + policies, exists := nmd.ResourcePolicies[resource.ID] + if !exists { + continue + } + + addSourcePeers := false + + networkRoutingPeers, routerExists := nmd.Routers[resource.NetworkID] + if routerExists { + if _, ok := networkRoutingPeers[peerID]; ok { + addSourcePeers = true + } + } + + for _, policy := range policies { + if policy == nil || !policy.Enabled || len(policy.Rules) == 0 || policy.Rules[0] == nil { + continue + } + if addSourcePeers { + var peers []string + if policy.Rules[0].SourceResource.Type == string(types.ResourceTypePeer) && policy.Rules[0].SourceResource.ID != "" { + peers = []string{policy.Rules[0].SourceResource.ID} + } else { + peers = nmd.getUniquePeerIDsFromGroupsIDs(policy.SourceGroups()) + } + for _, pID := range nmd.getPostureValidPeersSaveFailed(peers, policy.SourcePostureChecks, &components.PostureFailedPeers) { + if _, exists := components.Peers[pID]; !exists { + components.Peers[pID] = nmd.Peers[pID] + } + } + } else { + peerInSources := false + if policy.Rules[0].SourceResource.Type == string(types.ResourceTypePeer) && policy.Rules[0].SourceResource.ID != "" { + peerInSources = policy.Rules[0].SourceResource.ID == peerID + } else { + for _, groupID := range policy.SourceGroups() { + if group := nmd.Groups[groupID]; group != nil && slices.Contains(group.Peers, peerID) { + peerInSources = true + break + } + } + } + if !peerInSources { + continue + } + isValid, pname := nmd.validatePostureChecksOnPeerGetFailed(policy.SourcePostureChecks, peerID) + if !isValid && len(pname) > 0 { + if _, ok := components.PostureFailedPeers[pname]; !ok { + components.PostureFailedPeers[pname] = make(map[string]struct{}) + } + components.PostureFailedPeers[pname][peer.ID] = struct{}{} + continue + } + addSourcePeers = true + } + + for _, rule := range policy.Rules { + if rule == nil || !rule.Enabled { + continue + } + for _, srcGroupID := range rule.Sources { + if g := nmd.Groups[srcGroupID]; g != nil { + if _, exists := components.Groups[srcGroupID]; !exists { + components.Groups[srcGroupID] = g + } + } + } + for _, dstGroupID := range rule.Destinations { + if g := nmd.Groups[dstGroupID]; g != nil { + if _, exists := components.Groups[dstGroupID]; !exists { + components.Groups[dstGroupID] = g + } + } + } + } + components.ResourcePoliciesMap[resource.ID] = policies + } + + if addSourcePeers { + components.RoutersMap[resource.NetworkID] = networkRoutingPeers + for peerIDKey := range networkRoutingPeers { + p := nmd.Peers[peerIDKey] + if p == nil { + continue + } + // An unapproved peer must not carry traffic, so it is kept out of + // RouterPeers as well: the envelope encoder indexes that map into + // the wire peer table, from which the client restores every entry. + if _, validated := nmd.ValidatedPeers[peerIDKey]; !validated { + continue + } + if _, exists := components.RouterPeers[peerIDKey]; !exists { + components.RouterPeers[peerIDKey] = p + } + if _, exists := components.Peers[peerIDKey]; !exists { + components.Peers[peerIDKey] = p + } + } + components.NetworkResources = append(components.NetworkResources, resource) + } + } + + filterGroupPeers(&components.Groups, components.Peers) + filterPostureFailedPeers(&components.PostureFailedPeers, components.Policies, components.ResourcePoliciesMap, components.Peers) + + return components +} + +func (nmd *NetworkMapData) getPeersGroupsPoliciesRoutes( + peerID string, + peerSSHEnabled bool, + postureFailedPeers *map[string]map[string]struct{}, +) (map[string]*nmdata.Peer, map[string]*nmdata.Group, []*nmdata.Policy, []*nmdata.Route, sshRequirements) { + relevantPeerIDs := make(map[string]*nmdata.Peer, len(nmd.Peers)/4) + relevantGroupIDs := make(map[string]*nmdata.Group, len(nmd.Groups)/4) + relevantPolicies := make([]*nmdata.Policy, 0, len(nmd.Policies)) + relevantRoutes := make([]*nmdata.Route, 0, len(nmd.Routes)) + sshReqs := sshRequirements{neededGroupIDs: make(map[string]struct{})} + + relevantPeerIDs[peerID] = nmd.Peers[peerID] + + peerGroupSet := nmd.GetPeerGroups(peerID) + for groupID := range peerGroupSet { + relevantGroupIDs[groupID] = nmd.Groups[groupID] + } + + routeAccessControlGroups := make(map[string]struct{}) + for _, r := range nmd.Routes { + if r == nil { + continue + } + relevant := r.Peer == peerID + if !relevant { + for _, groupID := range r.PeerGroups { + if _, ok := peerGroupSet[groupID]; ok { + relevant = true + break + } + } + } + if !relevant && r.Enabled { + for _, groupID := range r.Groups { + if _, ok := peerGroupSet[groupID]; ok { + relevant = true + break + } + } + } + if !relevant { + continue + } + + for _, groupID := range r.PeerGroups { + if g := nmd.Groups[groupID]; g != nil { + relevantGroupIDs[groupID] = g + } + } + for _, groupID := range r.Groups { + if g := nmd.Groups[groupID]; g != nil { + relevantGroupIDs[groupID] = g + } + } + if r.Enabled { + for _, groupID := range r.AccessControlGroups { + if g := nmd.Groups[groupID]; g != nil { + relevantGroupIDs[groupID] = g + } + routeAccessControlGroups[groupID] = struct{}{} + } + } + + if r.Peer != "" { + if _, ok := nmd.ValidatedPeers[r.Peer]; ok { + if p := nmd.Peers[r.Peer]; p != nil { + relevantPeerIDs[r.Peer] = p + } + } + } + for _, groupID := range r.PeerGroups { + g := nmd.Groups[groupID] + if g == nil { + continue + } + for _, pid := range g.Peers { + if _, exists := relevantPeerIDs[pid]; exists { + continue + } + if _, ok := nmd.ValidatedPeers[pid]; !ok { + continue + } + if p := nmd.Peers[pid]; p != nil { + relevantPeerIDs[pid] = p + } + } + } + relevantRoutes = append(relevantRoutes, r) + } + + for _, policy := range nmd.Policies { + if policy == nil || !policy.Enabled { + continue + } + + policyRelevant := false + for _, rule := range policy.Rules { + if rule == nil || !rule.Enabled { + continue + } + + if len(routeAccessControlGroups) > 0 { + for _, destGroupID := range rule.Destinations { + if _, needed := routeAccessControlGroups[destGroupID]; needed { + policyRelevant = true + for _, srcGroupID := range rule.Sources { + if g := nmd.Groups[srcGroupID]; g != nil { + relevantGroupIDs[srcGroupID] = g + } + } + for _, dstGroupID := range rule.Destinations { + if g := nmd.Groups[dstGroupID]; g != nil { + relevantGroupIDs[dstGroupID] = g + } + } + break + } + } + } + + var sourcePeers, destinationPeers []string + var peerInSources, peerInDestinations bool + + if rule.SourceResource.Type == string(types.ResourceTypePeer) && rule.SourceResource.ID != "" { + sourcePeers = []string{rule.SourceResource.ID} + if rule.SourceResource.ID == peerID { + peerInSources = true + } + } else { + sourcePeers, peerInSources = nmd.getPeersFromGroups(rule.Sources, peerID, policy.SourcePostureChecks, postureFailedPeers) + } + + if rule.DestinationResource.Type == string(types.ResourceTypePeer) && rule.DestinationResource.ID != "" { + destinationPeers = []string{rule.DestinationResource.ID} + if rule.DestinationResource.ID == peerID { + peerInDestinations = true + } + } else { + destinationPeers, peerInDestinations = nmd.getPeersFromGroups(rule.Destinations, peerID, nil, postureFailedPeers) + } + + if peerInSources { + policyRelevant = true + for _, pid := range destinationPeers { + relevantPeerIDs[pid] = nmd.Peers[pid] + } + for _, dstGroupID := range rule.Destinations { + if g := nmd.Groups[dstGroupID]; g != nil { + relevantGroupIDs[dstGroupID] = g + } + } + } + + if peerInDestinations { + policyRelevant = true + for _, pid := range sourcePeers { + relevantPeerIDs[pid] = nmd.Peers[pid] + } + for _, srcGroupID := range rule.Sources { + if g := nmd.Groups[srcGroupID]; g != nil { + relevantGroupIDs[srcGroupID] = g + } + } + + if rule.Protocol == string(types.PolicyRuleProtocolNetbirdSSH) { + switch { + case len(rule.AuthorizedGroups) > 0: + for groupID := range rule.AuthorizedGroups { + sshReqs.neededGroupIDs[groupID] = struct{}{} + } + case rule.AuthorizedUser != "": + default: + sshReqs.needAllowedUserIDs = true + } + } else if nmdata.PolicyRuleImpliesLegacySSH(rule) && peerSSHEnabled { + sshReqs.needAllowedUserIDs = true + } + } + } + if policyRelevant { + relevantPolicies = append(relevantPolicies, policy) + } + } + + return relevantPeerIDs, relevantGroupIDs, relevantPolicies, relevantRoutes, sshReqs +} + +func (nmd *NetworkMapData) getPeersFromGroups(groups []string, peerID string, sourcePostureChecksIDs []string, + postureFailedPeers *map[string]map[string]struct{}) ([]string, bool) { + peerInGroups := false + filteredPeerIDs := make([]string, 0, len(groups)) + seenPeerIds := make(map[string]struct{}, len(groups)) + + for _, gid := range groups { + group := nmd.Groups[gid] + if group == nil { + continue + } + + if group.IsGroupAll() || len(groups) == 1 { + filteredPeerIDs = make([]string, 0, len(group.Peers)) + peerInGroups = false + for _, pid := range group.Peers { + peer, ok := nmd.Peers[pid] + if !ok || peer == nil { + continue + } + + if _, ok := nmd.ValidatedPeers[peer.ID]; !ok { + continue + } + + isValid, pname := nmd.validatePostureChecksOnPeerGetFailed(sourcePostureChecksIDs, peer.ID) + if !isValid && len(pname) > 0 { + if _, ok := (*postureFailedPeers)[pname]; !ok { + (*postureFailedPeers)[pname] = make(map[string]struct{}) + } + (*postureFailedPeers)[pname][peer.ID] = struct{}{} + continue + } + + if peer.ID == peerID { + peerInGroups = true + continue + } + + filteredPeerIDs = append(filteredPeerIDs, peer.ID) + } + return filteredPeerIDs, peerInGroups + } + + for _, pid := range group.Peers { + if _, seen := seenPeerIds[pid]; seen { + continue + } + seenPeerIds[pid] = struct{}{} + peer, ok := nmd.Peers[pid] + if !ok || peer == nil { + continue + } + + if _, ok := nmd.ValidatedPeers[peer.ID]; !ok { + continue + } + + isValid, pname := nmd.validatePostureChecksOnPeerGetFailed(sourcePostureChecksIDs, peer.ID) + if !isValid && len(pname) > 0 { + if _, ok := (*postureFailedPeers)[pname]; !ok { + (*postureFailedPeers)[pname] = make(map[string]struct{}) + } + (*postureFailedPeers)[pname][peer.ID] = struct{}{} + continue + } + + if peer.ID == peerID { + peerInGroups = true + continue + } + + filteredPeerIDs = append(filteredPeerIDs, peer.ID) + } + } + + return filteredPeerIDs, peerInGroups +} + +func (nmd *NetworkMapData) validatePostureChecksOnPeerGetFailed(sourcePostureChecksID []string, peerID string) (bool, string) { + peer, ok := nmd.Peers[peerID] + if !ok || peer == nil { + return false, "" + } + + for _, postureChecksID := range sourcePostureChecksID { + if valid, cached := nmd.cachedPostureCheckResult(postureChecksID, peerID); cached { + if !valid { + return false, postureChecksID + } + continue + } + + postureChecks := nmd.PostureChecks[postureChecksID] + if postureChecks == nil { + continue + } + if !postureChecks.Passes(peer) { + return false, postureChecksID + } + } + return true, "" +} + +func (nmd *NetworkMapData) PrecomputePostureValidation() { + if len(nmd.PostureChecks) == 0 { + nmd.PostureValidation = nil + return + } + + checkPeerIDs := make(map[string]map[string]struct{}) + for _, policy := range nmd.Policies { + if policy == nil || !policy.Enabled || len(policy.SourcePostureChecks) == 0 { + continue + } + + groupPeerIDs := nmd.getUniquePeerIDsFromGroupsIDs(policy.SourceGroups()) + for _, postureChecksID := range policy.SourcePostureChecks { + set := checkPeerIDs[postureChecksID] + if set == nil { + set = make(map[string]struct{}, len(groupPeerIDs)) + checkPeerIDs[postureChecksID] = set + } + for _, pid := range groupPeerIDs { + set[pid] = struct{}{} + } + for _, rule := range policy.Rules { + if rule == nil { + continue + } + if rule.SourceResource.Type == string(types.ResourceTypePeer) && rule.SourceResource.ID != "" { + set[rule.SourceResource.ID] = struct{}{} + } + } + } + } + + results := make(map[string]map[string]bool, len(checkPeerIDs)) + for postureChecksID, peerIDs := range checkPeerIDs { + results[postureChecksID] = nmd.evaluatePostureChecksForPeers(postureChecksID, peerIDs) + } + nmd.PostureValidation = results +} + +func (nmd *NetworkMapData) evaluatePostureChecksForPeers(postureChecksID string, peerIDs map[string]struct{}) map[string]bool { + postureChecks := nmd.PostureChecks[postureChecksID] + if postureChecks == nil { + return nil + } + + checks := postureChecks.GetChecks() + results := make(map[string]bool, len(peerIDs)) + for peerID := range peerIDs { + peer := nmd.Peers[peerID] + if peer == nil { + continue + } + results[peerID] = nmdata.PassesChecks(checks, peer) + } + return results +} + +func (nmd *NetworkMapData) cachedPostureCheckResult(postureChecksID, peerID string) (bool, bool) { + results, ok := nmd.PostureValidation[postureChecksID] + if !ok { + return false, false + } + if results == nil { + return true, true + } + valid, found := results[peerID] + return valid, found +} + +func (nmd *NetworkMapData) getPostureValidPeersSaveFailed(inputPeers []string, postureChecksIDs []string, postureFailedPeers *map[string]map[string]struct{}) []string { + var dest []string + for _, peerID := range inputPeers { + if _, validated := nmd.ValidatedPeers[peerID]; !validated { + continue + } + valid, pname := nmd.validatePostureChecksOnPeerGetFailed(postureChecksIDs, peerID) + if valid { + dest = append(dest, peerID) + continue + } + if pname == "" { + continue + } + if _, ok := (*postureFailedPeers)[pname]; !ok { + (*postureFailedPeers)[pname] = make(map[string]struct{}) + } + (*postureFailedPeers)[pname][peerID] = struct{}{} + } + return dest +} + +// forcesRoutingPeerDNSResolution reports whether the given peer must run +// routing-peer DNS resolution regardless of the account-global +// RoutingPeerDNSResolutionEnabled setting: true when the peer routes a domain +// network resource targeted by an enabled reverse-proxy service, so the peer's +// DNS forwarder starts and can resolve the target for the embedded proxy peers. +func (nmd *NetworkMapData) forcesRoutingPeerDNSResolution(peerID string) bool { + if len(nmd.ProxyTargetedDomainResourceIDs) == 0 { + return false + } + + for _, resource := range nmd.NetworkResources { + if resource == nil || !resource.Enabled || resource.Type != string(types.ResourceTypeDomain) { + continue + } + if _, ok := nmd.ProxyTargetedDomainResourceIDs[resource.ID]; !ok { + continue + } + if _, isRouter := nmd.Routers[resource.NetworkID][peerID]; isRouter { + return true + } + } + + return false +} + +// GetPeerGroups returns the set of group IDs the peer belongs to. The +// underlying peer→groups index is built once per NetworkMapData and the +// returned set is shared — callers must not mutate it. +func (nmd *NetworkMapData) GetPeerGroups(peerID string) map[string]struct{} { + nmd.peerGroupsOnce.Do(func() { + idx := make(map[string]map[string]struct{}, len(nmd.Peers)) + for groupID, group := range nmd.Groups { + if group == nil { + continue + } + for _, pid := range group.Peers { + set, ok := idx[pid] + if !ok { + set = make(map[string]struct{}) + idx[pid] = set + } + set[groupID] = struct{}{} + } + } + nmd.peerGroupsIdx = idx + }) + + if set, ok := nmd.peerGroupsIdx[peerID]; ok { + return set + } + return map[string]struct{}{} +} + +func (nmd *NetworkMapData) getUniquePeerIDsFromGroupsIDs(groups []string) []string { + peerIDs := make(map[string]struct{}, len(groups)) + for _, groupID := range groups { + group := nmd.Groups[groupID] + if group == nil { + continue + } + + if group.IsGroupAll() || len(groups) == 1 { + return group.Peers + } + + for _, peerID := range group.Peers { + peerIDs[peerID] = struct{}{} + } + } + + ids := make([]string, 0, len(peerIDs)) + for peerID := range peerIDs { + ids = append(ids, peerID) + } + + return ids +} + +func (nmd *NetworkMapData) getAllowedUserIDs() map[string]struct{} { + return nmd.AllowedUserIDs +} + +func (nmd *NetworkMapData) appliedZones(peerGroups map[string]struct{}) []nmdata.CustomZone { + if len(peerGroups) == 0 { + return nil + } + var out []nmdata.CustomZone + for _, cand := range nmd.AppliedZoneCandidates { + if peerInDistributionGroups(peerGroups, cand.DistributionGroups) { + out = append(out, cand.Zone) + } + } + return out +} + +func (nmd *NetworkMapData) privateServiceZones(peerGroups map[string]struct{}) []nmdata.CustomZone { + byApex := make(map[string]*nmdata.CustomZone) + var order []string + for _, cand := range nmd.PrivateServiceCandidates { + if !peerInDistributionGroups(peerGroups, cand.AccessGroups) { + continue + } + zone, exists := byApex[cand.Zone.Domain] + if !exists { + nz := nmdata.CustomZone{ + Domain: cand.Zone.Domain, + SearchDomainDisabled: cand.Zone.SearchDomainDisabled, + NonAuthoritative: cand.Zone.NonAuthoritative, + } + byApex[cand.Zone.Domain] = &nz + zone = &nz + order = append(order, cand.Zone.Domain) + } + zone.Records = append(zone.Records, cand.Zone.Records...) + } + + var out []nmdata.CustomZone + for _, apex := range order { + zone := byApex[apex] + if len(zone.Records) == 0 { + continue + } + out = append(out, *zone) + } + return out +} + +func peerInDistributionGroups(peerGroups map[string]struct{}, groups []string) bool { + for _, g := range groups { + if _, ok := peerGroups[g]; ok { + return true + } + } + return false +} + +func filterGroupPeers(groups *map[string]*nmdata.Group, peers map[string]*nmdata.Peer) { + for groupID, groupInfo := range *groups { + filteredPeers := make([]string, 0, len(groupInfo.Peers)) + for _, pid := range groupInfo.Peers { + if _, exists := peers[pid]; exists { + filteredPeers = append(filteredPeers, pid) + } + } + + if len(filteredPeers) != len(groupInfo.Peers) { + ng := groupInfo.Copy() + ng.Peers = filteredPeers + (*groups)[groupID] = ng + } + } +} + +func filterPostureFailedPeers(postureFailedPeers *map[string]map[string]struct{}, policies []*nmdata.Policy, resourcePoliciesMap map[string][]*nmdata.Policy, peers map[string]*nmdata.Peer) { + if len(*postureFailedPeers) == 0 { + return + } + + referencedPostureChecks := make(map[string]struct{}) + for _, policy := range policies { + for _, checkID := range policy.SourcePostureChecks { + referencedPostureChecks[checkID] = struct{}{} + } + } + for _, resPolicies := range resourcePoliciesMap { + for _, policy := range resPolicies { + for _, checkID := range policy.SourcePostureChecks { + referencedPostureChecks[checkID] = struct{}{} + } + } + } + + for checkID, failedPeers := range *postureFailedPeers { + if _, referenced := referencedPostureChecks[checkID]; !referenced { + delete(*postureFailedPeers, checkID) + continue + } + for peerID := range failedPeers { + if _, exists := peers[peerID]; !exists { + delete(failedPeers, peerID) + } + } + if len(failedPeers) == 0 { + delete(*postureFailedPeers, checkID) + } + } +} + +func filterDNSRecordsByPeers(records []nmdata.SimpleRecord, peers map[string]*nmdata.Peer, includeIPv6 bool) []nmdata.SimpleRecord { + if len(records) == 0 || len(peers) == 0 { + return nil + } + + peerIPs := make(map[string]struct{}, len(peers)*2) + for _, peer := range peers { + if peer == nil { + continue + } + peerIPs[peer.IP.String()] = struct{}{} + if includeIPv6 && peer.IPv6.IsValid() { + peerIPs[peer.IPv6.String()] = struct{}{} + } + } + + filteredRecords := make([]nmdata.SimpleRecord, 0, len(records)) + for _, record := range records { + if _, exists := peerIPs[record.RData]; exists { + filteredRecords = append(filteredRecords, record) + } + } + + return filteredRecords +} + +func filterGroupIDToUserIDs(fullMap map[string][]string, neededGroupIDs map[string]struct{}) map[string][]string { + if len(neededGroupIDs) == 0 { + return nil + } + + filtered := make(map[string][]string, len(neededGroupIDs)) + for groupID := range neededGroupIDs { + if users, ok := fullMap[groupID]; ok { + filtered[groupID] = users + } + } + return filtered +} diff --git a/shared/management/networkmap/networkmapcompute_test.go b/shared/management/networkmap/networkmapcompute_test.go new file mode 100644 index 000000000..8c9add8c1 --- /dev/null +++ b/shared/management/networkmap/networkmapcompute_test.go @@ -0,0 +1,1610 @@ +package networkmap_test + +import ( + "context" + "fmt" + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/shared/management/networkmap" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + nbtypes "github.com/netbirdio/netbird/shared/management/types" +) + +const ( + targetID = "peer-target" + postureMinVersion = "0.30.0" + passingVersion = "1.0.0" + failingVersion = "0.1.0" +) + +func newPeer(id string, hostNum byte) *nmdata.Peer { + return &nmdata.Peer{ + ID: id, + Key: "key-" + id, + IP: netip.AddrFrom4([4]byte{100, 64, 0, hostNum}), + DNSLabel: id, + Meta: nmdata.PeerSystemMeta{WtVersion: passingVersion}, + } +} + +func newNMD(peers ...*nmdata.Peer) *networkmap.NetworkMapData { + nmd := &networkmap.NetworkMapData{ + Peers: make(map[string]*nmdata.Peer), + Groups: make(map[string]*nmdata.Group), + ValidatedPeers: make(map[string]struct{}), + Network: &nmdata.Network{Identifier: "network-1", Serial: 7}, + AccountSettings: &nmdata.AccountSettingsInfo{}, + DNSSettings: &nmdata.DNSSettings{}, + } + for _, p := range peers { + nmd.Peers[p.ID] = p + nmd.ValidatedPeers[p.ID] = struct{}{} + } + return nmd +} + +func addGroup(nmd *networkmap.NetworkMapData, id string, peerIDs ...string) *nmdata.Group { + g := &nmdata.Group{ID: id, Name: id, Peers: peerIDs} + nmd.Groups[id] = g + return g +} + +func newRule(sources, destinations []string) *nmdata.PolicyRule { + return &nmdata.PolicyRule{ + Enabled: true, + Action: string(nbtypes.PolicyTrafficActionAccept), + Protocol: string(nbtypes.PolicyRuleProtocolTCP), + Bidirectional: true, + Sources: sources, + Destinations: destinations, + } +} + +func newPolicy(id string, rules ...*nmdata.PolicyRule) *nmdata.Policy { + for i, r := range rules { + if r.ID == "" { + r.ID = fmt.Sprintf("%s-rule-%d", id, i) + } + r.PolicyID = id + } + return &nmdata.Policy{ID: id, Enabled: true, Rules: rules} +} + +func addVersionCheck(nmd *networkmap.NetworkMapData, id, minVersion string) { + if nmd.PostureChecks == nil { + nmd.PostureChecks = make(map[string]*nmdata.PostureChecks) + } + nmd.PostureChecks[id] = &nmdata.PostureChecks{ + ID: id, + Checks: nmdata.ChecksDefinition{NBVersionCheck: &nmdata.NBVersionCheck{MinVersion: minVersion}}, + } +} + +func compute(nmd *networkmap.NetworkMapData, peerID string) *nbtypes.NetworkMapComponents { + return nmd.GetPeerNetworkMapComponents(peerID, nmdata.CustomZone{}) +} + +func peerIDSet(peers map[string]*nmdata.Peer) []string { + ids := make([]string, 0, len(peers)) + for id := range peers { + ids = append(ids, id) + } + return ids +} + +func policyIDs(policies []*nmdata.Policy) []string { + ids := make([]string, 0, len(policies)) + for _, p := range policies { + ids = append(ids, p.ID) + } + return ids +} + +func groupIDSet(groups map[string]*nmdata.Group) []string { + ids := make([]string, 0, len(groups)) + for id := range groups { + ids = append(ids, id) + } + return ids +} + +func TestGetPeerNetworkMapComponents_UnknownPeer(t *testing.T) { + nmd := newNMD(newPeer("peer-a", 2)) + + c := compute(nmd, "missing") + + require.True(t, c.IsEmpty()) + assert.Equal(t, "missing", c.PeerID) + assert.Same(t, nmd.Network, c.Network) + require.Contains(t, c.Peers, "missing") + assert.Nil(t, c.Peers["missing"]) + assert.Len(t, c.Peers, 1) + assert.Nil(t, c.AccountSettings) + assert.Nil(t, c.Policies) + assert.False(t, c.ForceRoutingPeerDNSResolution) +} + +func TestGetPeerNetworkMapComponents_UnvalidatedPeer(t *testing.T) { + target := newPeer(targetID, 1) + nmd := newNMD(target) + delete(nmd.ValidatedPeers, targetID) + + c := compute(nmd, targetID) + + require.True(t, c.IsEmpty()) + assert.Equal(t, targetID, c.PeerID) + assert.Same(t, target, c.Peers[targetID]) + assert.Len(t, c.Peers, 1) + assert.Nil(t, c.AccountSettings) + assert.Nil(t, c.Groups) +} + +// The forced-DNS flag must be computed even on the empty-components early +// exits, so an unknown or unvalidated proxy routing peer still starts its DNS +// forwarder. +func TestGetPeerNetworkMapComponents_EmptyComponentsKeepForcedDNSResolution(t *testing.T) { + build := func() *networkmap.NetworkMapData { + nmd := newNMD(newPeer("unval-router", 1)) + delete(nmd.ValidatedPeers, "unval-router") + nmd.NetworkResources = []*nmdata.NetworkResource{ + {ID: "res-1", NetworkID: "net-1", Type: string(nbtypes.ResourceTypeDomain), Enabled: true}, + } + nmd.ProxyTargetedDomainResourceIDs = map[string]struct{}{"res-1": {}} + nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {"ghost-router": {}, "unval-router": {}}} + return nmd + } + + t.Run("unknown peer", func(t *testing.T) { + c := compute(build(), "ghost-router") + require.True(t, c.IsEmpty()) + assert.True(t, c.ForceRoutingPeerDNSResolution) + }) + + t.Run("unvalidated peer", func(t *testing.T) { + c := compute(build(), "unval-router") + require.True(t, c.IsEmpty()) + assert.True(t, c.ForceRoutingPeerDNSResolution) + }) +} + +func TestGetPeerNetworkMapComponents_ForceRoutingPeerDNSResolution(t *testing.T) { + forced := func(mutate func(*networkmap.NetworkMapData)) bool { + nmd := newNMD(newPeer(targetID, 1)) + nmd.NetworkResources = []*nmdata.NetworkResource{ + {ID: "res-1", NetworkID: "net-1", Type: string(nbtypes.ResourceTypeDomain), Enabled: true}, + } + nmd.ProxyTargetedDomainResourceIDs = map[string]struct{}{"res-1": {}} + nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {targetID: {}}} + if mutate != nil { + mutate(nmd) + } + return compute(nmd, targetID).ForceRoutingPeerDNSResolution + } + + t.Run("router of targeted domain resource is forced", func(t *testing.T) { + assert.True(t, forced(nil)) + }) + t.Run("no proxy-targeted resources", func(t *testing.T) { + assert.False(t, forced(func(nmd *networkmap.NetworkMapData) { + nmd.ProxyTargetedDomainResourceIDs = nil + })) + }) + t.Run("resource disabled", func(t *testing.T) { + assert.False(t, forced(func(nmd *networkmap.NetworkMapData) { + nmd.NetworkResources[0].Enabled = false + })) + }) + t.Run("resource not a domain", func(t *testing.T) { + assert.False(t, forced(func(nmd *networkmap.NetworkMapData) { + nmd.NetworkResources[0].Type = string(nbtypes.ResourceTypeHost) + })) + }) + t.Run("resource not targeted", func(t *testing.T) { + assert.False(t, forced(func(nmd *networkmap.NetworkMapData) { + nmd.ProxyTargetedDomainResourceIDs = map[string]struct{}{"res-other": {}} + })) + }) + t.Run("peer not a router of the resource network", func(t *testing.T) { + assert.False(t, forced(func(nmd *networkmap.NetworkMapData) { + nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {"someone-else": {}}} + })) + }) + t.Run("nil resource entry tolerated", func(t *testing.T) { + assert.True(t, forced(func(nmd *networkmap.NetworkMapData) { + nmd.NetworkResources = append([]*nmdata.NetworkResource{nil}, nmd.NetworkResources...) + })) + }) +} + +func TestGetPeerNetworkMapComponents_CoreFieldsPassThrough(t *testing.T) { + target := newPeer(targetID, 1) + nmd := newNMD(target) + nmd.NetworkXIDToPublicID = map[string]string{"net-xid": "net-pub"} + nmd.PostureCheckXIDToPublicID = map[string]string{"pc-xid": "pc-pub"} + + c := nmd.GetPeerNetworkMapComponents(targetID, nmdata.CustomZone{Domain: "acme.netbird.cloud."}) + + require.False(t, c.IsEmpty()) + assert.Equal(t, targetID, c.PeerID) + assert.Same(t, nmd.Network, c.Network) + assert.Same(t, nmd.AccountSettings, c.AccountSettings) + assert.Same(t, nmd.DNSSettings, c.DNSSettings) + assert.Equal(t, "acme.netbird.cloud.", c.CustomZoneDomain) + assert.Equal(t, nmd.NetworkXIDToPublicID, c.NetworkXIDToPublicID) + assert.Equal(t, nmd.PostureCheckXIDToPublicID, c.PostureCheckXIDToPublicID) + + assert.Equal(t, map[string]*nmdata.Peer{targetID: target}, c.Peers) + assert.Empty(t, c.Groups) + assert.Empty(t, c.Policies) + assert.Empty(t, c.Routes) + assert.Empty(t, c.NameServerGroups) + assert.Empty(t, c.NetworkResources) + assert.Empty(t, c.ResourcePoliciesMap) + assert.Empty(t, c.RoutersMap) + assert.Empty(t, c.RouterPeers) + assert.Empty(t, c.PostureFailedPeers) + assert.Nil(t, c.AllDNSRecords) + assert.Empty(t, c.AccountZones) + assert.Nil(t, c.GroupIDToUserIDs) + assert.Nil(t, c.AllowedUserIDs) + assert.False(t, c.ForceRoutingPeerDNSResolution) +} + +func TestGetPeerNetworkMapComponents_OwnGroupsTrimmedWithoutMutatingStore(t *testing.T) { + target := newPeer(targetID, 1) + bystander := newPeer("peer-bystander", 2) + nmd := newNMD(target, bystander) + stored := addGroup(nmd, "g-mixed", targetID, bystander.ID) + + c := compute(nmd, targetID) + + require.Contains(t, c.Groups, "g-mixed") + assert.Equal(t, []string{targetID}, c.Groups["g-mixed"].Peers) + assert.NotSame(t, stored, c.Groups["g-mixed"]) + assert.Equal(t, []string{targetID, bystander.ID}, stored.Peers) +} + +func TestGetPeerNetworkMapComponents_PolicyRelevance(t *testing.T) { + t.Run("peer in sources pulls destination peers and groups", func(t *testing.T) { + target := newPeer(targetID, 1) + srcSibling := newPeer("peer-src-sibling", 2) + dst := newPeer("peer-dst", 3) + nmd := newNMD(target, srcSibling, dst) + addGroup(nmd, "g-src", targetID, srcSibling.ID) + addGroup(nmd, "g-dst", dst.ID) + p := newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"})) + nmd.Policies = []*nmdata.Policy{p} + + c := compute(nmd, targetID) + + assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies)) + assert.ElementsMatch(t, []string{targetID, dst.ID}, peerIDSet(c.Peers), + "source-side siblings must not be connected") + assert.ElementsMatch(t, []string{"g-src", "g-dst"}, groupIDSet(c.Groups)) + assert.Equal(t, []string{targetID}, c.Groups["g-src"].Peers) + assert.Equal(t, []string{dst.ID}, c.Groups["g-dst"].Peers) + }) + + t.Run("peer in destinations pulls source peers and groups", func(t *testing.T) { + target := newPeer(targetID, 1) + src := newPeer("peer-src", 2) + nmd := newNMD(target, src) + addGroup(nmd, "g-src", src.ID) + addGroup(nmd, "g-dst", targetID) + nmd.Policies = []*nmdata.Policy{newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"}))} + + c := compute(nmd, targetID) + + assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies)) + assert.ElementsMatch(t, []string{targetID, src.ID}, peerIDSet(c.Peers)) + assert.ElementsMatch(t, []string{"g-src", "g-dst"}, groupIDSet(c.Groups)) + }) + + t.Run("unrelated policy contributes nothing", func(t *testing.T) { + target := newPeer(targetID, 1) + a := newPeer("peer-a", 2) + b := newPeer("peer-b", 3) + nmd := newNMD(target, a, b) + addGroup(nmd, "g-own", targetID) + addGroup(nmd, "g-a", a.ID) + addGroup(nmd, "g-b", b.ID) + nmd.Policies = []*nmdata.Policy{newPolicy("p-1", newRule([]string{"g-a"}, []string{"g-b"}))} + + c := compute(nmd, targetID) + + assert.Empty(t, c.Policies) + assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers)) + assert.ElementsMatch(t, []string{"g-own"}, groupIDSet(c.Groups)) + }) + + t.Run("disabled policy ignored", func(t *testing.T) { + target := newPeer(targetID, 1) + src := newPeer("peer-src", 2) + nmd := newNMD(target, src) + addGroup(nmd, "g-src", src.ID) + addGroup(nmd, "g-dst", targetID) + p := newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"})) + p.Enabled = false + nmd.Policies = []*nmdata.Policy{p} + + c := compute(nmd, targetID) + + assert.Empty(t, c.Policies) + assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers)) + }) + + t.Run("disabled rule ignored", func(t *testing.T) { + target := newPeer(targetID, 1) + src := newPeer("peer-src", 2) + nmd := newNMD(target, src) + addGroup(nmd, "g-src", src.ID) + addGroup(nmd, "g-dst", targetID) + rule := newRule([]string{"g-src"}, []string{"g-dst"}) + rule.Enabled = false + nmd.Policies = []*nmdata.Policy{newPolicy("p-1", rule)} + + c := compute(nmd, targetID) + + assert.Empty(t, c.Policies) + assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers)) + }) + + t.Run("peer on both sides pulls peers from both directions", func(t *testing.T) { + target := newPeer(targetID, 1) + x := newPeer("peer-x", 2) + y := newPeer("peer-y", 3) + nmd := newNMD(target, x, y) + addGroup(nmd, "g-src", targetID, x.ID) + addGroup(nmd, "g-dst", targetID, y.ID) + nmd.Policies = []*nmdata.Policy{newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"}))} + + c := compute(nmd, targetID) + + assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies)) + assert.ElementsMatch(t, []string{targetID, x.ID, y.ID}, peerIDSet(c.Peers), + "both the source-side and destination-side counterparts must connect") + }) + + t.Run("rule referencing missing group tolerated", func(t *testing.T) { + target := newPeer(targetID, 1) + nmd := newNMD(target) + addGroup(nmd, "g-dst", targetID) + nmd.Policies = []*nmdata.Policy{newPolicy("p-1", newRule([]string{"g-ghost"}, []string{"g-dst"}))} + + c := compute(nmd, targetID) + + assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies)) + assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers)) + }) + + t.Run("nil policy and rule entries tolerated", func(t *testing.T) { + target := newPeer(targetID, 1) + dst := newPeer("peer-dst", 2) + nmd := newNMD(target, dst) + addGroup(nmd, "g-src", targetID) + addGroup(nmd, "g-dst", dst.ID) + p := newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"})) + p.Rules = append([]*nmdata.PolicyRule{nil}, p.Rules...) + nmd.Policies = []*nmdata.Policy{nil, p} + + c := compute(nmd, targetID) + + assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies)) + assert.ElementsMatch(t, []string{targetID, dst.ID}, peerIDSet(c.Peers)) + }) +} + +func TestGetPeerNetworkMapComponents_PeerResourceRules(t *testing.T) { + peerResource := func(id string) nmdata.Resource { + return nmdata.Resource{ID: id, Type: string(nbtypes.ResourceTypePeer)} + } + + t.Run("target as source resource", func(t *testing.T) { + target := newPeer(targetID, 1) + dst := newPeer("peer-dst", 2) + nmd := newNMD(target, dst) + addGroup(nmd, "g-dst", dst.ID) + rule := newRule(nil, []string{"g-dst"}) + rule.SourceResource = peerResource(targetID) + nmd.Policies = []*nmdata.Policy{newPolicy("p-1", rule)} + + c := compute(nmd, targetID) + + assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies)) + assert.ElementsMatch(t, []string{targetID, dst.ID}, peerIDSet(c.Peers)) + }) + + t.Run("target as destination resource", func(t *testing.T) { + target := newPeer(targetID, 1) + src := newPeer("peer-src", 2) + nmd := newNMD(target, src) + addGroup(nmd, "g-src", src.ID) + rule := newRule([]string{"g-src"}, nil) + rule.DestinationResource = peerResource(targetID) + nmd.Policies = []*nmdata.Policy{newPolicy("p-1", rule)} + + c := compute(nmd, targetID) + + assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies)) + assert.ElementsMatch(t, []string{targetID, src.ID}, peerIDSet(c.Peers)) + }) + + t.Run("remote peer as destination resource", func(t *testing.T) { + target := newPeer(targetID, 1) + remote := newPeer("peer-remote", 2) + nmd := newNMD(target, remote) + addGroup(nmd, "g-src", targetID) + rule := newRule([]string{"g-src"}, nil) + rule.DestinationResource = peerResource(remote.ID) + nmd.Policies = []*nmdata.Policy{newPolicy("p-1", rule)} + + c := compute(nmd, targetID) + + assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies)) + assert.ElementsMatch(t, []string{targetID, remote.ID}, peerIDSet(c.Peers)) + }) + + // Legacy parity: directly referenced peers bypass the ValidatedPeers gate + // and posture checks that group-derived peers go through; the client-side + // Calculate shares this behavior via getPeerFromResource. + t.Run("unvalidated source resource peer still connects", func(t *testing.T) { + target := newPeer(targetID, 1) + unval := newPeer("peer-unval", 2) + nmd := newNMD(target, unval) + delete(nmd.ValidatedPeers, unval.ID) + addGroup(nmd, "g-dst", targetID) + rule := newRule(nil, []string{"g-dst"}) + rule.SourceResource = peerResource(unval.ID) + nmd.Policies = []*nmdata.Policy{newPolicy("p-1", rule)} + + c := compute(nmd, targetID) + + assert.ElementsMatch(t, []string{targetID, unval.ID}, peerIDSet(c.Peers)) + }) + + t.Run("source resource peer bypasses posture checks", func(t *testing.T) { + target := newPeer(targetID, 1) + failing := newPeer("peer-failing", 2) + failing.Meta.WtVersion = failingVersion + nmd := newNMD(target, failing) + addVersionCheck(nmd, "pc-1", postureMinVersion) + addGroup(nmd, "g-dst", targetID) + rule := newRule(nil, []string{"g-dst"}) + rule.SourceResource = peerResource(failing.ID) + p := newPolicy("p-1", rule) + p.SourcePostureChecks = []string{"pc-1"} + nmd.Policies = []*nmdata.Policy{p} + + c := compute(nmd, targetID) + + assert.ElementsMatch(t, []string{targetID, failing.ID}, peerIDSet(c.Peers)) + assert.Empty(t, c.PostureFailedPeers) + }) + + t.Run("unrelated peer resource rule ignored", func(t *testing.T) { + target := newPeer(targetID, 1) + a := newPeer("peer-a", 2) + b := newPeer("peer-b", 3) + nmd := newNMD(target, a, b) + rule := newRule(nil, nil) + rule.SourceResource = peerResource(a.ID) + rule.DestinationResource = peerResource(b.ID) + nmd.Policies = []*nmdata.Policy{newPolicy("p-1", rule)} + + c := compute(nmd, targetID) + + assert.Empty(t, c.Policies) + assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers)) + }) +} + +// A destination list containing a group named "All" short-circuits peer +// expansion to that group alone, dropping peers accumulated from earlier +// groups. Groups themselves are still all shipped. Mirrors legacy behavior +// that the wire encoding depends on (see +// TestEnvelopeRoundTrip_AllGroupShortCircuitParity). +func TestGetPeerNetworkMapComponents_AllGroupShortCircuit(t *testing.T) { + target := newPeer(targetID, 1) + first := newPeer("peer-first", 2) + allMember := newPeer("peer-all-member", 3) + nmd := newNMD(target, first, allMember) + addGroup(nmd, "g-src", targetID) + addGroup(nmd, "g-first", first.ID) + nmd.Groups["g-all"] = &nmdata.Group{ID: "g-all", Name: nmdata.GroupAllName, Peers: []string{targetID, allMember.ID}} + nmd.Policies = []*nmdata.Policy{newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-first", "g-all"}))} + + c := compute(nmd, targetID) + + assert.ElementsMatch(t, []string{targetID, allMember.ID}, peerIDSet(c.Peers), + "peers from groups before the All group must be dropped by the short-circuit") + assert.ElementsMatch(t, []string{"g-src", "g-first", "g-all"}, groupIDSet(c.Groups)) + assert.Empty(t, c.Groups["g-first"].Peers) +} + +func TestGetPeerNetworkMapComponents_UnvalidatedPolicyPeersExcluded(t *testing.T) { + target := newPeer(targetID, 1) + srcOK := newPeer("peer-src-ok", 2) + srcUnval := newPeer("peer-src-unval", 3) + nmd := newNMD(target, srcOK, srcUnval) + delete(nmd.ValidatedPeers, srcUnval.ID) + addGroup(nmd, "g-src", srcOK.ID, srcUnval.ID, "peer-deleted") + addGroup(nmd, "g-dst", targetID) + nmd.Policies = []*nmdata.Policy{newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"}))} + + c := compute(nmd, targetID) + + assert.ElementsMatch(t, []string{targetID, srcOK.ID}, peerIDSet(c.Peers), + "unvalidated and dangling group members must not connect") + assert.Equal(t, []string{srcOK.ID}, c.Groups["g-src"].Peers) +} + +// Multi-group rules take the union path of getPeersFromGroups (no All-group +// short-circuit); validation and source posture checks apply per member. +func TestGetPeerNetworkMapComponents_MultiGroupSources(t *testing.T) { + target := newPeer(targetID, 1) + dup := newPeer("peer-dup", 2) + unval := newPeer("peer-unval", 3) + failing := newPeer("peer-failing", 4) + failing.Meta.WtVersion = failingVersion + solo := newPeer("peer-solo", 5) + nmd := newNMD(target, dup, unval, failing, solo) + delete(nmd.ValidatedPeers, unval.ID) + addVersionCheck(nmd, "pc-1", postureMinVersion) + addGroup(nmd, "g-1", targetID, dup.ID, unval.ID, "peer-deleted") + addGroup(nmd, "g-2", dup.ID, failing.ID, solo.ID) + addGroup(nmd, "g-tgt", targetID) + p := newPolicy("p-1", newRule([]string{"g-1", "g-2"}, []string{"g-tgt"})) + p.SourcePostureChecks = []string{"pc-1"} + nmd.Policies = []*nmdata.Policy{p} + + c := compute(nmd, targetID) + + assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies)) + assert.ElementsMatch(t, []string{targetID, dup.ID, solo.ID}, peerIDSet(c.Peers)) + assert.Empty(t, c.PostureFailedPeers, + "failing is not otherwise connected, so its failure record is pruned") +} + +func TestGetPeerNetworkMapComponents_PostureChecks(t *testing.T) { + t.Run("failing source peer excluded without orphan failure record", func(t *testing.T) { + target := newPeer(targetID, 1) + failing := newPeer("peer-failing", 2) + failing.Meta.WtVersion = failingVersion + nmd := newNMD(target, failing) + addVersionCheck(nmd, "pc-1", postureMinVersion) + addGroup(nmd, "g-src", failing.ID) + addGroup(nmd, "g-dst", targetID) + p := newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"})) + p.SourcePostureChecks = []string{"pc-1"} + nmd.Policies = []*nmdata.Policy{p} + + c := compute(nmd, targetID) + + assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies)) + assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers)) + assert.Empty(t, c.PostureFailedPeers, + "failure records for peers absent from the map must be pruned") + }) + + t.Run("failure recorded when peer is connected via another policy", func(t *testing.T) { + target := newPeer(targetID, 1) + failing := newPeer("peer-failing", 2) + failing.Meta.WtVersion = failingVersion + nmd := newNMD(target, failing) + addVersionCheck(nmd, "pc-1", postureMinVersion) + addGroup(nmd, "g-src", failing.ID) + addGroup(nmd, "g-dst", targetID) + checked := newPolicy("p-checked", newRule([]string{"g-src"}, []string{"g-dst"})) + checked.SourcePostureChecks = []string{"pc-1"} + open := newPolicy("p-open", newRule([]string{"g-src"}, []string{"g-dst"})) + nmd.Policies = []*nmdata.Policy{checked, open} + + c := compute(nmd, targetID) + + assert.ElementsMatch(t, []string{targetID, failing.ID}, peerIDSet(c.Peers)) + assert.Equal(t, map[string]map[string]struct{}{"pc-1": {failing.ID: {}}}, c.PostureFailedPeers) + }) + + t.Run("destination peers bypass source posture checks", func(t *testing.T) { + target := newPeer(targetID, 1) + failing := newPeer("peer-failing", 2) + failing.Meta.WtVersion = failingVersion + nmd := newNMD(target, failing) + addVersionCheck(nmd, "pc-1", postureMinVersion) + addGroup(nmd, "g-src", targetID) + addGroup(nmd, "g-dst", failing.ID) + p := newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"})) + p.SourcePostureChecks = []string{"pc-1"} + nmd.Policies = []*nmdata.Policy{p} + + c := compute(nmd, targetID) + + assert.ElementsMatch(t, []string{targetID, failing.ID}, peerIDSet(c.Peers)) + assert.Empty(t, c.PostureFailedPeers) + }) + + t.Run("target failing its own source check drops the policy", func(t *testing.T) { + target := newPeer(targetID, 1) + target.Meta.WtVersion = failingVersion + dst := newPeer("peer-dst", 2) + nmd := newNMD(target, dst) + addVersionCheck(nmd, "pc-1", postureMinVersion) + addGroup(nmd, "g-src", targetID) + addGroup(nmd, "g-dst", dst.ID) + p := newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"})) + p.SourcePostureChecks = []string{"pc-1"} + nmd.Policies = []*nmdata.Policy{p} + + c := compute(nmd, targetID) + + assert.Empty(t, c.Policies) + assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers)) + assert.Empty(t, c.PostureFailedPeers) + }) + + t.Run("failure keyed by the first failing check", func(t *testing.T) { + target := newPeer(targetID, 1) + failing := newPeer("peer-failing", 2) + nmd := newNMD(target, failing) + addVersionCheck(nmd, "pc-pass", postureMinVersion) + addVersionCheck(nmd, "pc-fail", "2.0.0") + addGroup(nmd, "g-src", failing.ID) + addGroup(nmd, "g-dst", targetID) + checked := newPolicy("p-checked", newRule([]string{"g-src"}, []string{"g-dst"})) + checked.SourcePostureChecks = []string{"pc-pass", "pc-fail"} + open := newPolicy("p-open", newRule([]string{"g-src"}, []string{"g-dst"})) + nmd.Policies = []*nmdata.Policy{checked, open} + + c := compute(nmd, targetID) + + assert.Equal(t, map[string]map[string]struct{}{"pc-fail": {failing.ID: {}}}, c.PostureFailedPeers, + "the record must be keyed by the failing check, not the first listed") + }) + + t.Run("unknown posture check id passes everyone", func(t *testing.T) { + target := newPeer(targetID, 1) + src := newPeer("peer-src", 2) + nmd := newNMD(target, src) + addGroup(nmd, "g-src", src.ID) + addGroup(nmd, "g-dst", targetID) + p := newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"})) + p.SourcePostureChecks = []string{"pc-ghost"} + nmd.Policies = []*nmdata.Policy{p} + + c := compute(nmd, targetID) + + assert.ElementsMatch(t, []string{targetID, src.ID}, peerIDSet(c.Peers)) + assert.Empty(t, c.PostureFailedPeers) + }) +} + +func TestGetPeerNetworkMapComponents_Routes(t *testing.T) { + t.Run("owned route relevant even when disabled", func(t *testing.T) { + target := newPeer(targetID, 1) + dist := newPeer("peer-dist", 2) + nmd := newNMD(target, dist) + addGroup(nmd, "g-dist", dist.ID) + addGroup(nmd, "g-acl") + r := &nmdata.Route{ID: "r-1", Peer: targetID, Enabled: false, Groups: []string{"g-dist"}, AccessControlGroups: []string{"g-acl"}} + nmd.Routes = []*nmdata.Route{r} + + c := compute(nmd, targetID) + + require.Len(t, c.Routes, 1) + assert.Same(t, r, c.Routes[0]) + assert.Contains(t, c.Groups, "g-dist") + assert.NotContains(t, c.Groups, "g-acl", + "access control groups of a disabled route must not be collected") + assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers), + "distribution group members are not connected by the route itself") + }) + + t.Run("peer-group route disabled still ships and connects HA members", func(t *testing.T) { + target := newPeer(targetID, 1) + ha := newPeer("peer-ha", 2) + haUnval := newPeer("peer-ha-unval", 3) + nmd := newNMD(target, ha, haUnval) + delete(nmd.ValidatedPeers, haUnval.ID) + addGroup(nmd, "g-ha", targetID, ha.ID, haUnval.ID) + r := &nmdata.Route{ID: "r-1", PeerGroups: []string{"g-ha", "g-ghost"}, Enabled: false} + nmd.Routes = []*nmdata.Route{r} + + c := compute(nmd, targetID) + + require.Len(t, c.Routes, 1) + assert.ElementsMatch(t, []string{targetID, ha.ID}, peerIDSet(c.Peers)) + assert.Equal(t, []string{targetID, ha.ID}, c.Groups["g-ha"].Peers) + }) + + t.Run("route consumer connects HA routing peers from peer groups", func(t *testing.T) { + target := newPeer(targetID, 1) + router1 := newPeer("peer-router-1", 2) + router2 := newPeer("peer-router-2", 3) + routerUnval := newPeer("peer-router-unval", 4) + nmd := newNMD(target, router1, router2, routerUnval) + delete(nmd.ValidatedPeers, routerUnval.ID) + addGroup(nmd, "g-ha", router1.ID, router2.ID, routerUnval.ID) + addGroup(nmd, "g-dist", targetID) + nmd.Routes = []*nmdata.Route{{ID: "r-1", PeerGroups: []string{"g-ha"}, Groups: []string{"g-dist"}, Enabled: true}} + + c := compute(nmd, targetID) + + require.Len(t, c.Routes, 1) + assert.ElementsMatch(t, []string{targetID, router1.ID, router2.ID}, peerIDSet(c.Peers), + "the consumer must connect to every validated HA router") + assert.Equal(t, []string{router1.ID, router2.ID}, c.Groups["g-ha"].Peers) + }) + + t.Run("distribution route connects routing peer", func(t *testing.T) { + target := newPeer(targetID, 1) + router := newPeer("peer-router", 2) + nmd := newNMD(target, router) + addGroup(nmd, "g-dist", targetID) + r := &nmdata.Route{ID: "r-1", Peer: router.ID, Enabled: true, Groups: []string{"g-dist"}} + nmd.Routes = []*nmdata.Route{r} + + c := compute(nmd, targetID) + + require.Len(t, c.Routes, 1) + assert.ElementsMatch(t, []string{targetID, router.ID}, peerIDSet(c.Peers)) + }) + + t.Run("disabled distribution route not relevant", func(t *testing.T) { + target := newPeer(targetID, 1) + router := newPeer("peer-router", 2) + nmd := newNMD(target, router) + addGroup(nmd, "g-dist", targetID) + nmd.Routes = []*nmdata.Route{{ID: "r-1", Peer: router.ID, Enabled: false, Groups: []string{"g-dist"}}} + + c := compute(nmd, targetID) + + assert.Empty(t, c.Routes) + assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers)) + }) + + t.Run("unvalidated routing peer excluded but route ships", func(t *testing.T) { + target := newPeer(targetID, 1) + router := newPeer("peer-router", 2) + nmd := newNMD(target, router) + delete(nmd.ValidatedPeers, router.ID) + addGroup(nmd, "g-dist", targetID) + nmd.Routes = []*nmdata.Route{{ID: "r-1", Peer: router.ID, Enabled: true, Groups: []string{"g-dist"}}} + + c := compute(nmd, targetID) + + require.Len(t, c.Routes, 1) + assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers)) + }) + + t.Run("nil and unrelated routes skipped", func(t *testing.T) { + target := newPeer(targetID, 1) + other := newPeer("peer-other", 2) + nmd := newNMD(target, other) + addGroup(nmd, "g-dist", targetID) + addGroup(nmd, "g-foreign", other.ID) + owned := &nmdata.Route{ID: "r-owned", Peer: targetID, Enabled: true} + nmd.Routes = []*nmdata.Route{nil, {ID: "r-foreign", Peer: other.ID, Enabled: true, Groups: []string{"g-foreign"}}, owned} + + c := compute(nmd, targetID) + + require.Len(t, c.Routes, 1) + assert.Same(t, owned, c.Routes[0]) + }) +} + +// A policy whose destinations hit an enabled route's access control groups is +// shipped so the routing peer can build route firewall rules, but its peers +// are not connected through this bridge. +func TestGetPeerNetworkMapComponents_RouteAccessControlBridging(t *testing.T) { + t.Run("policy targeting route ACG becomes relevant", func(t *testing.T) { + target := newPeer(targetID, 1) + router := newPeer("peer-router", 2) + remote := newPeer("peer-remote", 3) + nmd := newNMD(target, router, remote) + addGroup(nmd, "g-dist", targetID) + addGroup(nmd, "g-acl") + addGroup(nmd, "g-remote", remote.ID) + nmd.Routes = []*nmdata.Route{{ID: "r-1", Peer: router.ID, Enabled: true, Groups: []string{"g-dist"}, AccessControlGroups: []string{"g-acl"}}} + nmd.Policies = []*nmdata.Policy{newPolicy("p-acl", newRule([]string{"g-remote"}, []string{"g-acl"}))} + + c := compute(nmd, targetID) + + assert.Equal(t, []string{"p-acl"}, policyIDs(c.Policies)) + assert.ElementsMatch(t, []string{"g-dist", "g-acl", "g-remote"}, groupIDSet(c.Groups)) + assert.ElementsMatch(t, []string{targetID, router.ID}, peerIDSet(c.Peers), + "the bridged policy's source peers must not be connected") + }) + + t.Run("disabled route does not bridge its ACG policies", func(t *testing.T) { + target := newPeer(targetID, 1) + remote := newPeer("peer-remote", 2) + nmd := newNMD(target, remote) + addGroup(nmd, "g-acl") + addGroup(nmd, "g-remote", remote.ID) + nmd.Routes = []*nmdata.Route{{ID: "r-1", Peer: targetID, Enabled: false, AccessControlGroups: []string{"g-acl"}}} + nmd.Policies = []*nmdata.Policy{newPolicy("p-acl", newRule([]string{"g-remote"}, []string{"g-acl"}))} + + c := compute(nmd, targetID) + + assert.Empty(t, c.Policies) + }) +} + +func TestGetPeerNetworkMapComponents_SSHRequirements(t *testing.T) { + allowedUsers := map[string]struct{}{"user-1": {}, "user-2": {}} + groupUsers := map[string][]string{"g-auth": {"user-a"}, "g-other": {"user-b"}} + + cases := []struct { + name string + mutateRule func(*nmdata.PolicyRule) + sshEnabled bool + targetInSrc bool + wantAllowed bool + wantGroupsMap map[string][]string + }{ + { + name: "netbird-ssh with authorized groups", + mutateRule: func(r *nmdata.PolicyRule) { + r.Protocol = string(nbtypes.PolicyRuleProtocolNetbirdSSH) + r.AuthorizedGroups = map[string][]string{"g-auth": nil} + }, + wantGroupsMap: map[string][]string{"g-auth": {"user-a"}}, + }, + { + name: "netbird-ssh with authorized user", + mutateRule: func(r *nmdata.PolicyRule) { + r.Protocol = string(nbtypes.PolicyRuleProtocolNetbirdSSH) + r.AuthorizedUser = "root" + }, + }, + { + name: "netbird-ssh default needs allowed users", + mutateRule: func(r *nmdata.PolicyRule) { + r.Protocol = string(nbtypes.PolicyRuleProtocolNetbirdSSH) + }, + wantAllowed: true, + }, + { + name: "legacy all-protocol with SSH enabled", + mutateRule: func(r *nmdata.PolicyRule) { r.Protocol = string(nbtypes.PolicyRuleProtocolALL) }, + sshEnabled: true, + wantAllowed: true, + }, + { + name: "legacy all-protocol with SSH disabled", + mutateRule: func(r *nmdata.PolicyRule) { r.Protocol = string(nbtypes.PolicyRuleProtocolALL) }, + }, + { + name: "tcp port 22 with SSH enabled", + mutateRule: func(r *nmdata.PolicyRule) { r.Ports = []string{"22"} }, + sshEnabled: true, + wantAllowed: true, + }, + { + name: "tcp port range covering 22", + mutateRule: func(r *nmdata.PolicyRule) { r.PortRanges = []nmdata.RulePortRange{{Start: 20, End: 30}} }, + sshEnabled: true, + wantAllowed: true, + }, + { + name: "tcp native ssh port 22022", + mutateRule: func(r *nmdata.PolicyRule) { r.Ports = []string{"22022"} }, + sshEnabled: true, + wantAllowed: true, + }, + { + name: "tcp port range covering only native ssh port", + mutateRule: func(r *nmdata.PolicyRule) { r.PortRanges = []nmdata.RulePortRange{{Start: 22000, End: 23000}} }, + sshEnabled: true, + wantAllowed: true, + }, + { + name: "tcp unrelated port", + mutateRule: func(r *nmdata.PolicyRule) { r.Ports = []string{"443"} }, + sshEnabled: true, + }, + { + name: "netbird-ssh only counts on the destination side", + mutateRule: func(r *nmdata.PolicyRule) { + r.Protocol = string(nbtypes.PolicyRuleProtocolNetbirdSSH) + }, + targetInSrc: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + target := newPeer(targetID, 1) + target.SSHEnabled = tc.sshEnabled + admin := newPeer("peer-admin", 2) + nmd := newNMD(target, admin) + nmd.AllowedUserIDs = allowedUsers + nmd.GroupIDToUserIDs = groupUsers + addGroup(nmd, "g-adm", admin.ID) + addGroup(nmd, "g-tgt", targetID) + rule := newRule([]string{"g-adm"}, []string{"g-tgt"}) + if tc.targetInSrc { + rule = newRule([]string{"g-tgt"}, []string{"g-adm"}) + } + tc.mutateRule(rule) + nmd.Policies = []*nmdata.Policy{newPolicy("p-1", rule)} + + c := compute(nmd, targetID) + + if tc.wantAllowed { + assert.Equal(t, allowedUsers, c.AllowedUserIDs) + } else { + assert.Nil(t, c.AllowedUserIDs) + } + assert.Equal(t, tc.wantGroupsMap, c.GroupIDToUserIDs) + }) + } +} + +func TestGetPeerNetworkMapComponents_DNSRecordFiltering(t *testing.T) { + record := func(name, rdata string) nmdata.SimpleRecord { + return nmdata.SimpleRecord{Name: name, Type: 1, Class: "IN", TTL: 300, RData: rdata} + } + + build := func(ipv6Target bool) (*networkmap.NetworkMapData, nmdata.CustomZone) { + target := newPeer(targetID, 1) + if ipv6Target { + target.IPv6 = netip.MustParseAddr("fd00::1") + target.Meta.Capabilities = []int32{nmdata.PeerCapabilityIPv6Overlay} + } + buddy := newPeer("peer-buddy", 2) + buddy.IPv6 = netip.MustParseAddr("fd00::2") + stranger := newPeer("peer-stranger", 3) + nmd := newNMD(target, buddy, stranger) + addGroup(nmd, "g-src", targetID) + addGroup(nmd, "g-dst", buddy.ID) + nmd.Policies = []*nmdata.Policy{newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"}))} + zone := nmdata.CustomZone{ + Domain: "acme.netbird.cloud.", + Records: []nmdata.SimpleRecord{ + record(targetID, "100.64.0.1"), + record("peer-buddy", "100.64.0.2"), + record("peer-stranger", "100.64.0.3"), + record("outsider", "9.9.9.9"), + record("peer-buddy-v6", "fd00::2"), + }, + } + return nmd, zone + } + + t.Run("records limited to relevant peers, IPv6 dropped without capability", func(t *testing.T) { + nmd, zone := build(false) + + c := nmd.GetPeerNetworkMapComponents(targetID, zone) + + assert.Equal(t, "acme.netbird.cloud.", c.CustomZoneDomain) + assert.Equal(t, []nmdata.SimpleRecord{ + record(targetID, "100.64.0.1"), + record("peer-buddy", "100.64.0.2"), + }, c.AllDNSRecords) + }) + + t.Run("IPv6 records of relevant peers kept for capable target", func(t *testing.T) { + nmd, zone := build(true) + + c := nmd.GetPeerNetworkMapComponents(targetID, zone) + + assert.Equal(t, []nmdata.SimpleRecord{ + record(targetID, "100.64.0.1"), + record("peer-buddy", "100.64.0.2"), + record("peer-buddy-v6", "fd00::2"), + }, c.AllDNSRecords) + }) + + t.Run("no records yields nil", func(t *testing.T) { + nmd, _ := build(false) + + c := nmd.GetPeerNetworkMapComponents(targetID, nmdata.CustomZone{Domain: "acme.netbird.cloud."}) + + assert.Nil(t, c.AllDNSRecords) + }) +} + +func TestGetPeerNetworkMapComponents_AccountZones(t *testing.T) { + rec := func(name string) nmdata.SimpleRecord { + return nmdata.SimpleRecord{Name: name, Type: 1, Class: "IN", RData: "100.64.0.9"} + } + + t.Run("applied and private service zones for peer groups", func(t *testing.T) { + target := newPeer(targetID, 1) + nmd := newNMD(target) + addGroup(nmd, "g-a", targetID) + appliedZone := nmdata.CustomZone{Domain: "zone-one.example.com.", Records: []nmdata.SimpleRecord{rec("z1")}} + nmd.AppliedZoneCandidates = []networkmap.AppliedZoneCandidate{ + {DistributionGroups: []string{"g-a"}, Zone: appliedZone}, + {DistributionGroups: []string{"g-x"}, Zone: nmdata.CustomZone{Domain: "zone-two.example.com."}}, + } + nmd.PrivateServiceCandidates = []networkmap.PrivateServiceCandidate{ + {AccessGroups: []string{"g-a"}, Zone: nmdata.CustomZone{Domain: "svc.example.com", SearchDomainDisabled: true, NonAuthoritative: true, Records: []nmdata.SimpleRecord{rec("svc-1")}}}, + {AccessGroups: []string{"g-a"}, Zone: nmdata.CustomZone{Domain: "svc.example.com", Records: []nmdata.SimpleRecord{rec("svc-2")}}}, + {AccessGroups: []string{"g-x"}, Zone: nmdata.CustomZone{Domain: "other.example.com", Records: []nmdata.SimpleRecord{rec("other")}}}, + {AccessGroups: []string{"g-a"}, Zone: nmdata.CustomZone{Domain: "empty.example.com"}}, + } + + c := compute(nmd, targetID) + + require.Len(t, c.AccountZones, 2) + assert.Equal(t, appliedZone, c.AccountZones[0]) + assert.Equal(t, nmdata.CustomZone{ + Domain: "svc.example.com", + SearchDomainDisabled: true, + NonAuthoritative: true, + Records: []nmdata.SimpleRecord{rec("svc-1"), rec("svc-2")}, + }, c.AccountZones[1], "same-apex private service candidates must merge, flags from the first") + }) + + t.Run("groupless peer receives no zones", func(t *testing.T) { + target := newPeer(targetID, 1) + nmd := newNMD(target) + nmd.AppliedZoneCandidates = []networkmap.AppliedZoneCandidate{ + {DistributionGroups: []string{"g-a"}, Zone: nmdata.CustomZone{Domain: "zone-one.example.com."}}, + } + nmd.PrivateServiceCandidates = []networkmap.PrivateServiceCandidate{ + {AccessGroups: []string{"g-a"}, Zone: nmdata.CustomZone{Domain: "svc.example.com", Records: []nmdata.SimpleRecord{rec("svc")}}}, + } + + c := compute(nmd, targetID) + + assert.Empty(t, c.AccountZones) + }) +} + +func TestGetPeerNetworkMapComponents_NameServerGroups(t *testing.T) { + target := newPeer(targetID, 1) + other := newPeer("peer-other", 2) + nmd := newNMD(target, other) + addGroup(nmd, "g-own", targetID) + addGroup(nmd, "g-dst", other.ID) + addGroup(nmd, "g-foreign") + nmd.Policies = []*nmdata.Policy{newPolicy("p-1", newRule([]string{"g-own"}, []string{"g-dst"}))} + nsOwn := &nmdata.NameServerGroup{ID: "ns-own", Enabled: true, Groups: []string{"g-own"}} + nsDst := &nmdata.NameServerGroup{ID: "ns-dst", Enabled: true, Groups: []string{"g-dst"}} + nsDisabled := &nmdata.NameServerGroup{ID: "ns-disabled", Enabled: false, Groups: []string{"g-own"}} + nsForeign := &nmdata.NameServerGroup{ID: "ns-foreign", Enabled: true, Groups: []string{"g-foreign"}} + nsBoth := &nmdata.NameServerGroup{ID: "ns-both", Enabled: true, Groups: []string{"g-own", "g-dst"}} + nmd.NameServerGroups = []*nmdata.NameServerGroup{nsOwn, nil, nsDst, nsDisabled, nsForeign, nsBoth} + + c := compute(nmd, targetID) + + assert.Equal(t, []*nmdata.NameServerGroup{nsOwn, nsDst, nsBoth}, c.NameServerGroups, + "nameserver groups attach to any relevant group and ship once even when several groups match") +} + +func TestGetPeerNetworkMapComponents_NetworkResources_SourceSide(t *testing.T) { + target := newPeer(targetID, 1) + routerOK := newPeer("peer-router-ok", 2) + routerUnval := newPeer("peer-router-unval", 3) + nmd := newNMD(target, routerOK, routerUnval) + delete(nmd.ValidatedPeers, routerUnval.ID) + addGroup(nmd, "g-clients", targetID) + addGroup(nmd, "g-resource") + res := &nmdata.NetworkResource{ID: "res-1", NetworkID: "net-1", Enabled: true} + nmd.NetworkResources = []*nmdata.NetworkResource{res} + rp := newPolicy("rp-1", newRule([]string{"g-clients"}, []string{"g-resource"})) + nmd.Policies = []*nmdata.Policy{rp} + nmd.ResourcePolicies = map[string][]*nmdata.Policy{"res-1": {rp}} + routers := map[string]*nmdata.NetworkRouter{ + routerOK.ID: {Metric: 100}, + routerUnval.ID: {Metric: 200}, + } + nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": routers} + + c := compute(nmd, targetID) + + assert.Equal(t, []*nmdata.NetworkResource{res}, c.NetworkResources) + assert.Equal(t, map[string][]*nmdata.Policy{"res-1": {rp}}, c.ResourcePoliciesMap) + assert.Equal(t, map[string]map[string]*nmdata.NetworkRouter{"net-1": routers}, c.RoutersMap) + assert.ElementsMatch(t, []string{routerOK.ID}, peerIDSet(c.RouterPeers), + "an unvalidated routing peer is withheld from RouterPeers too, since the envelope encoder "+ + "indexes that map into the wire peer table and the client restores every entry from it") + assert.ElementsMatch(t, []string{targetID, routerOK.ID}, peerIDSet(c.Peers), + "only validated routing peers are connected") + assert.ElementsMatch(t, []string{"g-clients", "g-resource"}, groupIDSet(c.Groups)) +} + +func TestGetPeerNetworkMapComponents_NetworkResources_RouterSide(t *testing.T) { + t.Run("posture-valid validated source peers connected, failures recorded", func(t *testing.T) { + target := newPeer(targetID, 1) + clientOK := newPeer("peer-client-ok", 2) + clientUnval := newPeer("peer-client-unval", 3) + clientFail := newPeer("peer-client-fail", 4) + clientFail.Meta.WtVersion = failingVersion + nmd := newNMD(target, clientOK, clientUnval, clientFail) + delete(nmd.ValidatedPeers, clientUnval.ID) + addVersionCheck(nmd, "pc-1", postureMinVersion) + addGroup(nmd, "g-clients", clientOK.ID, clientUnval.ID, clientFail.ID) + addGroup(nmd, "g-resource") + addGroup(nmd, "g-tgt", targetID) + res := &nmdata.NetworkResource{ID: "res-1", NetworkID: "net-1", Enabled: true} + nmd.NetworkResources = []*nmdata.NetworkResource{res} + rp := newPolicy("rp-1", newRule([]string{"g-clients"}, []string{"g-resource"})) + rp.SourcePostureChecks = []string{"pc-1"} + acl := newPolicy("p-acl", newRule([]string{"g-clients"}, []string{"g-tgt"})) + nmd.Policies = []*nmdata.Policy{acl} + nmd.ResourcePolicies = map[string][]*nmdata.Policy{"res-1": {rp}} + nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {targetID: {Metric: 100}}} + + c := compute(nmd, targetID) + + assert.Equal(t, []*nmdata.NetworkResource{res}, c.NetworkResources) + assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.RouterPeers)) + assert.ElementsMatch(t, []string{targetID, clientOK.ID, clientFail.ID}, peerIDSet(c.Peers), + "clientFail connects via the open ACL policy, clientUnval never connects") + assert.Equal(t, map[string]map[string]struct{}{"pc-1": {clientFail.ID: {}}}, c.PostureFailedPeers) + }) + + t.Run("peer source resource collects exactly that peer", func(t *testing.T) { + target := newPeer(targetID, 1) + client := newPeer("peer-client", 2) + other := newPeer("peer-other", 3) + nmd := newNMD(target, client, other) + res := &nmdata.NetworkResource{ID: "res-1", NetworkID: "net-1", Enabled: true} + nmd.NetworkResources = []*nmdata.NetworkResource{res} + rule := newRule(nil, nil) + rule.SourceResource = nmdata.Resource{ID: client.ID, Type: string(nbtypes.ResourceTypePeer)} + nmd.ResourcePolicies = map[string][]*nmdata.Policy{"res-1": {newPolicy("rp-1", rule)}} + nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {targetID: {}}} + + c := compute(nmd, targetID) + + assert.Equal(t, []*nmdata.NetworkResource{res}, c.NetworkResources) + assert.ElementsMatch(t, []string{targetID, client.ID}, peerIDSet(c.Peers)) + }) + + t.Run("multiple source groups unioned, missing group tolerated", func(t *testing.T) { + target := newPeer(targetID, 1) + c1 := newPeer("peer-c1", 2) + c2 := newPeer("peer-c2", 3) + nmd := newNMD(target, c1, c2) + addGroup(nmd, "g-c1", c1.ID) + addGroup(nmd, "g-c2", c2.ID) + res := &nmdata.NetworkResource{ID: "res-1", NetworkID: "net-1", Enabled: true} + nmd.NetworkResources = []*nmdata.NetworkResource{res} + nmd.ResourcePolicies = map[string][]*nmdata.Policy{ + "res-1": {newPolicy("rp-1", newRule([]string{"g-c1", "g-c2", "g-ghost"}, nil))}, + } + nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {targetID: {}}} + + c := compute(nmd, targetID) + + assert.ElementsMatch(t, []string{targetID, c1.ID, c2.ID}, peerIDSet(c.Peers)) + }) +} + +func TestGetPeerNetworkMapComponents_NetworkResources_PeerResourceSource(t *testing.T) { + build := func(sourcePeerID string) *networkmap.NetworkMapData { + target := newPeer(targetID, 1) + router := newPeer("peer-router", 2) + other := newPeer("peer-other", 3) + nmd := newNMD(target, router, other) + res := &nmdata.NetworkResource{ID: "res-1", NetworkID: "net-1", Enabled: true} + nmd.NetworkResources = []*nmdata.NetworkResource{res} + rule := newRule(nil, nil) + rule.SourceResource = nmdata.Resource{ID: sourcePeerID, Type: string(nbtypes.ResourceTypePeer)} + nmd.ResourcePolicies = map[string][]*nmdata.Policy{"res-1": {newPolicy("rp-1", rule)}} + nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {"peer-router": {}}} + return nmd + } + + t.Run("target named as source resource gains access", func(t *testing.T) { + c := compute(build(targetID), targetID) + + assert.Len(t, c.NetworkResources, 1) + assert.ElementsMatch(t, []string{targetID, "peer-router"}, peerIDSet(c.Peers)) + }) + + t.Run("other peer named as source resource denies target", func(t *testing.T) { + c := compute(build("peer-other"), targetID) + + assert.Empty(t, c.NetworkResources) + assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers)) + }) +} + +func TestGetPeerNetworkMapComponents_NetworkResources_Gating(t *testing.T) { + build := func() (*networkmap.NetworkMapData, *nmdata.NetworkResource, *nmdata.Policy) { + target := newPeer(targetID, 1) + router := newPeer("peer-router", 2) + nmd := newNMD(target, router) + addGroup(nmd, "g-clients", targetID) + addGroup(nmd, "g-resource") + res := &nmdata.NetworkResource{ID: "res-1", NetworkID: "net-1", Enabled: true} + nmd.NetworkResources = []*nmdata.NetworkResource{res} + rp := newPolicy("rp-1", newRule([]string{"g-clients"}, []string{"g-resource"})) + nmd.Policies = []*nmdata.Policy{rp} + nmd.ResourcePolicies = map[string][]*nmdata.Policy{"res-1": {rp}} + nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {router.ID: {}}} + return nmd, res, rp + } + + assertResourceSkipped := func(t *testing.T, c *nbtypes.NetworkMapComponents) { + t.Helper() + assert.Empty(t, c.NetworkResources) + assert.Empty(t, c.RoutersMap) + assert.Empty(t, c.RouterPeers) + assert.Empty(t, c.ResourcePoliciesMap) + } + + t.Run("baseline grants access", func(t *testing.T) { + nmd, res, _ := build() + c := compute(nmd, targetID) + assert.Equal(t, []*nmdata.NetworkResource{res}, c.NetworkResources) + }) + + t.Run("disabled resource skipped", func(t *testing.T) { + nmd, res, _ := build() + res.Enabled = false + assertResourceSkipped(t, compute(nmd, targetID)) + }) + + t.Run("resource without policies skipped", func(t *testing.T) { + nmd, _, _ := build() + nmd.ResourcePolicies = nil + assertResourceSkipped(t, compute(nmd, targetID)) + }) + + t.Run("peer neither router nor in sources skipped", func(t *testing.T) { + nmd, _, _ := build() + nmd.Groups["g-clients"].Peers = []string{"peer-router"} + c := compute(nmd, targetID) + assertResourceSkipped(t, c) + assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers)) + }) + + t.Run("nil and rule-less resource policy entries tolerated", func(t *testing.T) { + nmd, res, rp := build() + nmd.NetworkResources = append([]*nmdata.NetworkResource{nil}, nmd.NetworkResources...) + rp.Rules = append(rp.Rules, nil) + nmd.ResourcePolicies["res-1"] = append([]*nmdata.Policy{nil, {ID: "rp-empty", Enabled: true}}, nmd.ResourcePolicies["res-1"]...) + + c := compute(nmd, targetID) + + assert.Equal(t, []*nmdata.NetworkResource{res}, c.NetworkResources, + "poisoned sibling entries must not prevent the valid policy from granting access") + assert.NotPanics(t, func() { c.Calculate(context.Background()) }, + "the downstream network map calculation must survive the poisoned components") + }) + + t.Run("granting policy without routers still ships the resource", func(t *testing.T) { + nmd, res, rp := build() + nmd.Routers = nil + c := compute(nmd, targetID) + assert.Equal(t, []*nmdata.NetworkResource{res}, c.NetworkResources) + assert.Equal(t, map[string][]*nmdata.Policy{"res-1": {rp}}, c.ResourcePoliciesMap) + assert.Contains(t, c.RoutersMap, "net-1") + assert.Empty(t, c.RoutersMap["net-1"]) + assert.Empty(t, c.RouterPeers) + }) + + t.Run("target failing resource policy posture check skipped", func(t *testing.T) { + nmd, _, rp := build() + addVersionCheck(nmd, "pc-1", postureMinVersion) + rp.SourcePostureChecks = []string{"pc-1"} + nmd.Policies = nil + nmd.Peers[targetID].Meta.WtVersion = failingVersion + c := compute(nmd, targetID) + assertResourceSkipped(t, c) + assert.Empty(t, c.PostureFailedPeers) + assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers)) + }) +} + +// Legacy parity: resource-policy access consults only Rules[0] for peer-type +// sources, while group sources union across all rules via SourceGroups. +func TestGetPeerNetworkMapComponents_MultiRulePolicies(t *testing.T) { + t.Run("policy matching via multiple rules ships once", func(t *testing.T) { + target := newPeer(targetID, 1) + a := newPeer("peer-a", 2) + b := newPeer("peer-b", 3) + nmd := newNMD(target, a, b) + addGroup(nmd, "g-tgt", targetID) + addGroup(nmd, "g-a", a.ID) + addGroup(nmd, "g-b", b.ID) + p := newPolicy("p-1", + newRule([]string{"g-tgt"}, []string{"g-a"}), + newRule([]string{"g-tgt"}, []string{"g-b"})) + nmd.Policies = []*nmdata.Policy{p} + + c := compute(nmd, targetID) + + assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies)) + assert.ElementsMatch(t, []string{targetID, a.ID, b.ID}, peerIDSet(c.Peers)) + }) + + t.Run("resource access consults only the first rule's peer source", func(t *testing.T) { + target := newPeer(targetID, 1) + other := newPeer("peer-other", 2) + router := newPeer("peer-router", 3) + nmd := newNMD(target, other, router) + addGroup(nmd, "g-other", other.ID) + res := &nmdata.NetworkResource{ID: "res-1", NetworkID: "net-1", Enabled: true} + nmd.NetworkResources = []*nmdata.NetworkResource{res} + second := newRule(nil, nil) + second.SourceResource = nmdata.Resource{ID: targetID, Type: string(nbtypes.ResourceTypePeer)} + rp := newPolicy("rp-1", newRule([]string{"g-other"}, nil), second) + nmd.ResourcePolicies = map[string][]*nmdata.Policy{"res-1": {rp}} + nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {router.ID: {}}} + + c := compute(nmd, targetID) + + assert.Empty(t, c.NetworkResources, + "a second rule naming the target as peer source must not grant resource access") + }) + + t.Run("router-side source collection consults only the first rule's peer source", func(t *testing.T) { + target := newPeer(targetID, 1) + x := newPeer("peer-x", 2) + y := newPeer("peer-y", 3) + nmd := newNMD(target, x, y) + addGroup(nmd, "g-y", y.ID) + res := &nmdata.NetworkResource{ID: "res-1", NetworkID: "net-1", Enabled: true} + nmd.NetworkResources = []*nmdata.NetworkResource{res} + first := newRule(nil, nil) + first.SourceResource = nmdata.Resource{ID: x.ID, Type: string(nbtypes.ResourceTypePeer)} + rp := newPolicy("rp-1", first, newRule([]string{"g-y"}, nil)) + nmd.ResourcePolicies = map[string][]*nmdata.Policy{"res-1": {rp}} + nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {targetID: {}}} + + c := compute(nmd, targetID) + + assert.ElementsMatch(t, []string{targetID, x.ID}, peerIDSet(c.Peers), + "second-rule group sources are not collected when the first rule names a peer") + }) +} + +// Characterization of legacy parity: once one resource policy grants the peer +// access, the source peers of the resource's subsequent policies are collected +// as if the peer were a router. +func TestGetPeerNetworkMapComponents_NetworkResources_LaterPoliciesContributeSourcePeers(t *testing.T) { + target := newPeer(targetID, 1) + otherSrc := newPeer("peer-other-src", 2) + router := newPeer("peer-router", 3) + nmd := newNMD(target, otherSrc, router) + addGroup(nmd, "g-a", targetID) + addGroup(nmd, "g-b", otherSrc.ID) + addGroup(nmd, "g-resource") + res := &nmdata.NetworkResource{ID: "res-1", NetworkID: "net-1", Enabled: true} + nmd.NetworkResources = []*nmdata.NetworkResource{res} + rpA := newPolicy("rp-a", newRule([]string{"g-a"}, []string{"g-resource"})) + rpB := newPolicy("rp-b", newRule([]string{"g-b"}, []string{"g-resource"})) + nmd.ResourcePolicies = map[string][]*nmdata.Policy{"res-1": {rpA, rpB}} + nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {router.ID: {}}} + + c := compute(nmd, targetID) + + assert.Contains(t, c.Peers, otherSrc.ID) +} + +func TestGetPeerNetworkMapComponents_StoreImmutableAndDeterministic(t *testing.T) { + zone := nmdata.CustomZone{ + Domain: "acme.netbird.cloud.", + Records: []nmdata.SimpleRecord{{Name: "peer-src", Type: 1, Class: "IN", TTL: 300, RData: "100.64.0.2"}}, + } + build := func() *networkmap.NetworkMapData { + target := newPeer(targetID, 1) + src := newPeer("peer-src", 2) + failing := newPeer("peer-failing", 3) + failing.Meta.WtVersion = failingVersion + router := newPeer("peer-router", 4) + resRouter := newPeer("peer-res-router", 5) + nmd := newNMD(target, src, failing, router, resRouter) + addVersionCheck(nmd, "pc-1", postureMinVersion) + addGroup(nmd, "g-src", src.ID, failing.ID) + addGroup(nmd, "g-dst", targetID, src.ID) + addGroup(nmd, "g-dist", targetID, src.ID) + addGroup(nmd, "g-auth", src.ID) + addGroup(nmd, "g-clients", targetID) + addGroup(nmd, "g-resource") + nmd.AllowedUserIDs = map[string]struct{}{"user-1": {}} + nmd.GroupIDToUserIDs = map[string][]string{"g-auth": {"user-a"}} + checked := newPolicy("p-checked", newRule([]string{"g-src"}, []string{"g-dst"})) + checked.SourcePostureChecks = []string{"pc-1"} + open := newPolicy("p-open", newRule([]string{"g-src"}, []string{"g-dst"})) + sshAuth := newRule([]string{"g-src"}, []string{"g-dst"}) + sshAuth.Protocol = string(nbtypes.PolicyRuleProtocolNetbirdSSH) + sshAuth.AuthorizedGroups = map[string][]string{"g-auth": nil} + sshPlain := newRule([]string{"g-src"}, []string{"g-dst"}) + sshPlain.Protocol = string(nbtypes.PolicyRuleProtocolNetbirdSSH) + rp := newPolicy("rp-1", newRule([]string{"g-clients"}, []string{"g-resource"})) + nmd.Policies = []*nmdata.Policy{checked, open, newPolicy("p-ssh", sshAuth, sshPlain), rp} + nmd.Routes = []*nmdata.Route{{ID: "r-1", Peer: router.ID, Enabled: true, Groups: []string{"g-dist"}}} + nmd.NetworkResources = []*nmdata.NetworkResource{{ID: "res-1", NetworkID: "net-1", Enabled: true}} + nmd.ResourcePolicies = map[string][]*nmdata.Policy{"res-1": {rp}} + nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {resRouter.ID: {Metric: 100}}} + nmd.NameServerGroups = []*nmdata.NameServerGroup{{ID: "ns-1", Enabled: true, Groups: []string{"g-dst"}}} + nmd.AppliedZoneCandidates = []networkmap.AppliedZoneCandidate{ + {DistributionGroups: []string{"g-dst"}, Zone: nmdata.CustomZone{Domain: "zone.example.com.", Records: []nmdata.SimpleRecord{{Name: "z", Type: 1, RData: "100.64.0.9"}}}}, + } + nmd.PrivateServiceCandidates = []networkmap.PrivateServiceCandidate{ + {AccessGroups: []string{"g-dst"}, Zone: nmdata.CustomZone{Domain: "svc.example.com", Records: []nmdata.SimpleRecord{{Name: "s", Type: 1, RData: "100.64.0.8"}}}}, + } + return nmd + } + + nmd := build() + groupSnapshots := make(map[string][]string, len(nmd.Groups)) + for id, g := range nmd.Groups { + groupSnapshots[id] = append([]string(nil), g.Peers...) + } + + first := nmd.GetPeerNetworkMapComponents(targetID, zone) + _ = nmd.GetPeerNetworkMapComponents("peer-src", zone) + second := nmd.GetPeerNetworkMapComponents(targetID, zone) + + for id, g := range nmd.Groups { + assert.Equal(t, groupSnapshots[id], g.Peers, "group %s mutated in the store", id) + } + + for name, field := range map[string]any{ + "Peers": first.Peers, + "PostureFailedPeers": first.PostureFailedPeers, + "RoutersMap": first.RoutersMap, + "RouterPeers": first.RouterPeers, + "NetworkResources": first.NetworkResources, + "NameServerGroups": first.NameServerGroups, + "AccountZones": first.AccountZones, + "AllDNSRecords": first.AllDNSRecords, + "AllowedUserIDs": first.AllowedUserIDs, + "GroupIDToUserIDs": first.GroupIDToUserIDs, + "ResourcePoliciesMap": first.ResourcePoliciesMap, + } { + require.NotEmpty(t, field, "fixture must populate %s or the determinism check is vacuous", name) + } + + assert.Equal(t, first.Peers, second.Peers) + assert.Equal(t, first.Groups, second.Groups) + assert.Equal(t, first.Policies, second.Policies) + assert.Equal(t, first.Routes, second.Routes) + assert.Equal(t, first.PostureFailedPeers, second.PostureFailedPeers) + assert.Equal(t, first.ResourcePoliciesMap, second.ResourcePoliciesMap) + assert.Equal(t, first.RoutersMap, second.RoutersMap) + assert.Equal(t, first.RouterPeers, second.RouterPeers) + assert.Equal(t, first.NetworkResources, second.NetworkResources) + assert.Equal(t, first.NameServerGroups, second.NameServerGroups) + assert.Equal(t, first.AccountZones, second.AccountZones) + assert.Equal(t, first.AllDNSRecords, second.AllDNSRecords) + assert.Equal(t, first.AllowedUserIDs, second.AllowedUserIDs) + assert.Equal(t, first.GroupIDToUserIDs, second.GroupIDToUserIDs) +} + +func TestPrecomputePostureValidation(t *testing.T) { + newFixture := func() *networkmap.NetworkMapData { + target := newPeer(targetID, 1) + srcPass := newPeer("peer-src-pass", 2) + srcFail := newPeer("peer-src-fail", 3) + srcFail.Meta.WtVersion = failingVersion + other := newPeer("peer-other", 4) + other.Meta.WtVersion = failingVersion + + nmd := newNMD(target, srcPass, srcFail, other) + addVersionCheck(nmd, "pc-1", postureMinVersion) + addGroup(nmd, "g-src", srcPass.ID, srcFail.ID) + addGroup(nmd, "g-dst", targetID) + addGroup(nmd, "g-open", srcPass.ID, srcFail.ID, other.ID) + + checked := newPolicy("p-checked", newRule([]string{"g-src"}, []string{"g-dst"})) + checked.SourcePostureChecks = []string{"pc-1"} + open := newPolicy("p-open", newRule([]string{"g-open"}, []string{"g-dst"})) + disabled := newPolicy("p-disabled", newRule([]string{"g-open"}, []string{"g-dst"})) + disabled.Enabled = false + disabled.SourcePostureChecks = []string{"pc-1"} + nmd.Policies = []*nmdata.Policy{checked, open, disabled} + + return nmd + } + + type snapshot struct { + peers []string + postureFailedPeers map[string]map[string]struct{} + } + snapshotAll := func(nmd *networkmap.NetworkMapData) map[string]snapshot { + out := make(map[string]snapshot, len(nmd.Peers)) + for peerID := range nmd.Peers { + c := compute(nmd, peerID) + out[peerID] = snapshot{peers: peerIDSet(c.Peers), postureFailedPeers: c.PostureFailedPeers} + } + return out + } + + t.Run("memoized results match direct evaluation", func(t *testing.T) { + nmd := newFixture() + direct := snapshotAll(nmd) + + nmd.PrecomputePostureValidation() + memoized := snapshotAll(nmd) + + require.Len(t, memoized, len(direct)) + for peerID, want := range direct { + assert.ElementsMatch(t, want.peers, memoized[peerID].peers, "visible peers changed for %s", peerID) + assert.Equal(t, want.postureFailedPeers, memoized[peerID].postureFailedPeers, "posture failures changed for %s", peerID) + } + }) + + t.Run("only source peers of enabled checked policies are evaluated", func(t *testing.T) { + nmd := newFixture() + nmd.PrecomputePostureValidation() + + assert.Equal(t, map[string]map[string]bool{ + "pc-1": {"peer-src-pass": true, "peer-src-fail": false}, + }, nmd.PostureValidation) + }) + + t.Run("peer source resources are evaluated", func(t *testing.T) { + nmd := newFixture() + resourcePolicy := newPolicy("p-resource", newRule(nil, []string{"g-dst"})) + resourcePolicy.Rules[0].SourceResource = nmdata.Resource{ID: "peer-other", Type: string(nbtypes.ResourceTypePeer)} + resourcePolicy.SourcePostureChecks = []string{"pc-1"} + nmd.Policies = append(nmd.Policies, resourcePolicy) + + nmd.PrecomputePostureValidation() + + assert.Equal(t, map[string]bool{"peer-src-pass": true, "peer-src-fail": false, "peer-other": false}, + nmd.PostureValidation["pc-1"]) + }) + + t.Run("memoized result wins over direct evaluation", func(t *testing.T) { + nmd := newFixture() + nmd.PostureValidation = map[string]map[string]bool{ + "pc-1": {"peer-src-pass": false, "peer-src-fail": true}, + } + + c := compute(nmd, targetID) + + assert.Equal(t, map[string]map[string]struct{}{"pc-1": {"peer-src-pass": {}}}, c.PostureFailedPeers) + }) + + t.Run("no posture checks clears the memo", func(t *testing.T) { + nmd := newFixture() + nmd.PrecomputePostureValidation() + require.NotEmpty(t, nmd.PostureValidation) + + nmd.PostureChecks = nil + nmd.PrecomputePostureValidation() + + assert.Nil(t, nmd.PostureValidation) + }) + + t.Run("unresolvable check id memoized as passing", func(t *testing.T) { + nmd := newFixture() + nmd.Policies[0].SourcePostureChecks = []string{"pc-ghost"} + nmd.PrecomputePostureValidation() + + require.Contains(t, nmd.PostureValidation, "pc-ghost") + assert.Nil(t, nmd.PostureValidation["pc-ghost"]) + + c := compute(nmd, targetID) + assert.ElementsMatch(t, []string{targetID, "peer-src-pass", "peer-src-fail", "peer-other"}, peerIDSet(c.Peers)) + assert.Empty(t, c.PostureFailedPeers) + }) + + t.Run("peers missing from the memo fall back to direct evaluation", func(t *testing.T) { + nmd := newFixture() + nmd.PostureValidation = map[string]map[string]bool{"pc-1": {"peer-src-pass": true}} + + c := compute(nmd, targetID) + + assert.Equal(t, map[string]map[string]struct{}{"pc-1": {"peer-src-fail": {}}}, c.PostureFailedPeers) + }) +} + +func TestNetworkMapData_GetPeerGroups(t *testing.T) { + target := newPeer(targetID, 1) + other := newPeer("peer-other", 2) + nmd := newNMD(target, other) + addGroup(nmd, "g-1", targetID, other.ID) + addGroup(nmd, "g-2", targetID) + addGroup(nmd, "g-3", other.ID) + nmd.Groups["g-nil"] = nil + + assert.Equal(t, map[string]struct{}{"g-1": {}, "g-2": {}}, nmd.GetPeerGroups(targetID)) + assert.Empty(t, nmd.GetPeerGroups("missing")) +} diff --git a/shared/management/networkmap/networkmapdata.go b/shared/management/networkmap/networkmapdata.go new file mode 100644 index 000000000..e27605d64 --- /dev/null +++ b/shared/management/networkmap/networkmapdata.go @@ -0,0 +1,79 @@ +package networkmap + +import ( + "sync" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" +) + +// NetworkMapData is a dependency-light, slim twin of the server Account. It +// carries only the state GetPeerNetworkMapComponents needs, expressed in the +// fresh nmdata twin types. A builder converts an Account into a NetworkMapData +// once per account; the per-peer components calculation then runs on this twin +// with no reference back to the Account. +type NetworkMapData struct { //nolint:revive // established name across the codebase + Peers map[string]*nmdata.Peer + Groups map[string]*nmdata.Group + Policies []*nmdata.Policy + Routes []*nmdata.Route + NameServerGroups []*nmdata.NameServerGroup + NetworkResources []*nmdata.NetworkResource + + Network *nmdata.Network + DNSSettings *nmdata.DNSSettings + AccountSettings *nmdata.AccountSettingsInfo + + PostureChecks map[string]*nmdata.PostureChecks + + // PostureValidation holds the precomputed posture-check results, keyed by + // posture check ID then peer ID. Filled by PrecomputePostureValidation; a + // present but nil inner map marks a check ID that resolves to no posture + // check, which the calc treats as passing. + PostureValidation map[string]map[string]bool + + AllowedUserIDs map[string]struct{} + NetworkXIDToPublicID map[string]string + PostureCheckXIDToPublicID map[string]string + ValidatedPeers map[string]struct{} + ResourcePolicies map[string][]*nmdata.Policy + Routers map[string]map[string]*nmdata.NetworkRouter + GroupIDToUserIDs map[string][]string + DNSDomain string + + // ProxyTargetedDomainResourceIDs is the account-level half of + // forcesRoutingPeerDNSResolution: domain network resources targeted by an + // enabled reverse-proxy service. + ProxyTargetedDomainResourceIDs map[string]struct{} + + AppliedZoneCandidates []AppliedZoneCandidate + PrivateServiceCandidates []PrivateServiceCandidate + + // Services are the account's reverse-proxy services, persisted ones and + // the in-memory ones synthesised from agent-network state. They are the + // source of the proxy ACLs injectProxyPolicies synthesises, which no + // builder can load because they are never written to the database. + Services []*nmdata.Service + + peerGroupsOnce sync.Once + peerGroupsIdx map[string]map[string]struct{} + + proxyPoliciesOnce sync.Once +} + +// AppliedZoneCandidate is an account-level custom DNS zone reduced to the +// per-peer decision the components calc still makes: include the zone only when +// the peer belongs to one of its distribution groups. Record conversion is done +// once at build time. +type AppliedZoneCandidate struct { + DistributionGroups []string + Zone nmdata.CustomZone +} + +// PrivateServiceCandidate is a single private service's synthesized records, +// carried per apex zone. The builder resolves proxy-cluster connectivity and +// domain-suffix matching once; the calc merges the candidates whose AccessGroups +// the peer belongs to, grouped by Zone.Domain. +type PrivateServiceCandidate struct { + AccessGroups []string + Zone nmdata.CustomZone +} diff --git a/shared/management/networkmap/nmdata/account_settings.go b/shared/management/networkmap/nmdata/account_settings.go new file mode 100644 index 000000000..57e29e838 --- /dev/null +++ b/shared/management/networkmap/nmdata/account_settings.go @@ -0,0 +1,18 @@ +package nmdata + +import "time" + +// AccountSettingsInfo is the slim twin of types.AccountSettingsInfo. +type AccountSettingsInfo struct { + PeerLoginExpirationEnabled bool + PeerLoginExpiration time.Duration + PeerInactivityExpirationEnabled bool + PeerInactivityExpiration time.Duration + DNSDomain string + IPv6EnabledGroups []string + RoutingPeerDNSResolutionEnabled bool + LazyConnectionEnabled bool + AutoUpdateVersion string + AutoUpdateAlways bool + MetricsPushEnabled bool +} diff --git a/shared/management/networkmap/nmdata/dns.go b/shared/management/networkmap/nmdata/dns.go new file mode 100644 index 000000000..fe681af1a --- /dev/null +++ b/shared/management/networkmap/nmdata/dns.go @@ -0,0 +1,18 @@ +package nmdata + +// SimpleRecord is the slim twin of dns.SimpleRecord. +type SimpleRecord struct { + Name string + Type int + Class string + TTL int + RData string +} + +// CustomZone is the slim twin of dns.CustomZone. +type CustomZone struct { + Domain string + Records []SimpleRecord + SearchDomainDisabled bool + NonAuthoritative bool +} diff --git a/shared/management/networkmap/nmdata/dns_settings.go b/shared/management/networkmap/nmdata/dns_settings.go new file mode 100644 index 000000000..69fd5f517 --- /dev/null +++ b/shared/management/networkmap/nmdata/dns_settings.go @@ -0,0 +1,6 @@ +package nmdata + +// DNSSettings is the slim twin of types.DNSSettings. +type DNSSettings struct { + DisabledManagementGroups []string +} diff --git a/shared/management/networkmap/nmdata/group.go b/shared/management/networkmap/nmdata/group.go new file mode 100644 index 000000000..1cd2cd15e --- /dev/null +++ b/shared/management/networkmap/nmdata/group.go @@ -0,0 +1,30 @@ +package nmdata + +import "slices" + +// GroupAllName is the reserved name of the default group that contains every +// peer in an account. +const GroupAllName = "All" + +// Group is the slim twin of types.Group. +type Group struct { + ID string + Name string + PublicID string + Peers []string + Resources []Resource +} + +func (g *Group) IsGroupAll() bool { + return g.Name == GroupAllName +} + +func (g *Group) Copy() *Group { + return &Group{ + ID: g.ID, + Name: g.Name, + PublicID: g.PublicID, + Peers: slices.Clone(g.Peers), + Resources: slices.Clone(g.Resources), + } +} diff --git a/shared/management/networkmap/nmdata/group_test.go b/shared/management/networkmap/nmdata/group_test.go new file mode 100644 index 000000000..20aaa240f --- /dev/null +++ b/shared/management/networkmap/nmdata/group_test.go @@ -0,0 +1,84 @@ +package nmdata + +import ( + "reflect" + "testing" +) + +// TestGroupCopy_AllFieldsCopied fills every Group field with a unique non-zero +// value derived from its field path, so a field added to Group but forgotten +// in Copy fails here by name without the test needing an update. The unique +// per-path values also catch fields swapped inside Copy. +func TestGroupCopy_AllFieldsCopied(t *testing.T) { + src := &Group{} + seed := 0 + fillValue(t, reflect.ValueOf(src).Elem(), "Group", &seed) + + copied := src.Copy() + + srcV := reflect.ValueOf(src).Elem() + copiedV := reflect.ValueOf(copied).Elem() + for i := 0; i < srcV.NumField(); i++ { + name := srcV.Type().Field(i).Name + if !reflect.DeepEqual(srcV.Field(i).Interface(), copiedV.Field(i).Interface()) { + t.Errorf("field %s not copied: src=%#v copy=%#v", + name, srcV.Field(i).Interface(), copiedV.Field(i).Interface()) + } + } + + for i := 0; i < srcV.NumField(); i++ { + f := srcV.Field(i) + if f.Kind() != reflect.Slice || f.Len() == 0 { + continue + } + name := srcV.Type().Field(i).Name + fillValue(t, f.Index(0), name+"-mutated", &seed) + if reflect.DeepEqual(f.Interface(), copiedV.Field(i).Interface()) { + t.Errorf("field %s shares memory with the copy", name) + } + } +} + +// fillValue sets v to a deterministic non-zero value derived from its field +// path. Kinds it does not handle fail the test loudly, so the filler is +// extended together with the struct instead of silently under-testing new +// fields. +func fillValue(t *testing.T, v reflect.Value, path string, seed *int) { + t.Helper() + + switch v.Kind() { + case reflect.String: + v.SetString(path) + case reflect.Bool: + v.SetBool(true) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + *seed++ + v.SetInt(int64(*seed)) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + *seed++ + v.SetUint(uint64(*seed)) + case reflect.Float32, reflect.Float64: + *seed++ + v.SetFloat(float64(*seed)) + case reflect.Slice: + s := reflect.MakeSlice(v.Type(), 2, 2) + fillValue(t, s.Index(0), path+"[0]", seed) + fillValue(t, s.Index(1), path+"[1]", seed) + v.Set(s) + case reflect.Struct: + settable := 0 + for i := 0; i < v.NumField(); i++ { + f := v.Field(i) + if !f.CanSet() { + continue + } + settable++ + fillValue(t, f, path+"."+v.Type().Field(i).Name, seed) + } + if settable == 0 { + t.Fatalf("struct %s at %s has no settable fields — extend fillValue to construct it", v.Type(), path) + } + default: + t.Fatalf("unsupported kind %s at %s — extend fillValue", v.Kind(), path) + } +} diff --git a/shared/management/networkmap/nmdata/nameserver.go b/shared/management/networkmap/nmdata/nameserver.go new file mode 100644 index 000000000..2698dd8d0 --- /dev/null +++ b/shared/management/networkmap/nmdata/nameserver.go @@ -0,0 +1,24 @@ +package nmdata + +import "net/netip" + +// NameServerGroup is the slim twin of dns.NameServerGroup. +type NameServerGroup struct { + ID string + PublicID string + Name string + Description string + NameServers []NameServer + Groups []string + Primary bool + Domains []string + Enabled bool + SearchDomainsEnabled bool +} + +// NameServer is the slim twin of dns.NameServer. +type NameServer struct { + IP netip.Addr + NSType int + Port int +} diff --git a/shared/management/networkmap/nmdata/network.go b/shared/management/networkmap/nmdata/network.go new file mode 100644 index 000000000..72b6502ef --- /dev/null +++ b/shared/management/networkmap/nmdata/network.go @@ -0,0 +1,16 @@ +package nmdata + +import "net" + +// Network is the slim twin of types.Network. +type Network struct { + Identifier string + Net net.IPNet + NetV6 net.IPNet + Dns string + Serial int64 +} + +func (n *Network) CurrentSerial() uint64 { + return uint64(n.Serial) +} diff --git a/shared/management/networkmap/nmdata/network_resource.go b/shared/management/networkmap/nmdata/network_resource.go new file mode 100644 index 000000000..44f3c477b --- /dev/null +++ b/shared/management/networkmap/nmdata/network_resource.go @@ -0,0 +1,18 @@ +package nmdata + +import "net/netip" + +// NetworkResource is the slim twin of resources/types.NetworkResource. +type NetworkResource struct { + ID string + NetworkID string + AccountID string + PublicID string + Name string + Description string + Type string + Address string // TODO: isn't persisted in the DB + Domain string + Prefix netip.Prefix + Enabled bool +} diff --git a/shared/management/networkmap/nmdata/network_router.go b/shared/management/networkmap/nmdata/network_router.go new file mode 100644 index 000000000..fd5df37c4 --- /dev/null +++ b/shared/management/networkmap/nmdata/network_router.go @@ -0,0 +1,10 @@ +package nmdata + +// NetworkRouter is the slim twin of routers/types.NetworkRouter. +type NetworkRouter struct { + PublicID string + PeerGroups []string + Masquerade bool + Metric int + Enabled bool +} diff --git a/shared/management/networkmap/nmdata/peer.go b/shared/management/networkmap/nmdata/peer.go new file mode 100644 index 000000000..3ceb1dbc1 --- /dev/null +++ b/shared/management/networkmap/nmdata/peer.go @@ -0,0 +1,129 @@ +package nmdata + +import ( + "net" + "net/netip" + "slices" + "time" +) + +// Peer capability constants mirror the proto enum values. +const ( + PeerCapabilitySourcePrefixes int32 = 1 + PeerCapabilityIPv6Overlay int32 = 2 + PeerCapabilityComponentNetworkMap int32 = 3 +) + +// Peer is the slim twin of peer.Peer. +type Peer struct { + ID string + Key string + SSHKey string + DNSLabel string + UserID string + SSHEnabled bool + LoginExpirationEnabled bool + LastLogin *time.Time + IP netip.Addr + IPv6 netip.Addr + RequiresApproval bool + ExtraDNSLabels []string + Meta PeerSystemMeta + ProxyMeta ProxyMeta + Location PeerLocation +} + +// ProxyMeta is the slim twin of peer.ProxyMeta. +type ProxyMeta struct { + Embedded bool + Cluster string +} + +// PeerSystemMeta is the slim twin of peer.PeerSystemMeta. +type PeerSystemMeta struct { + WtVersion string + GoOS string + OSVersion string + KernelVersion string + NetworkAddresses []NetworkAddress + Files []File + Capabilities []int32 + Flags Flags + SyncMessageVersion int +} + +// Flags is the slim twin of peer.Flags. +type Flags struct { + ServerSSHAllowed bool + DisableIPv6 bool +} + +// NetworkAddress is the slim twin of peer.NetworkAddress. +type NetworkAddress struct { + NetIP netip.Prefix +} + +// File is the slim twin of peer.File. +type File struct { + Path string + ProcessIsRunning bool +} + +// PeerLocation is the slim twin of peer.Location. +type PeerLocation struct { + CountryCode string + CityName string + ConnectionIP net.IP +} + +func (p *Peer) HasCapability(capability int32) bool { + return slices.Contains(p.Meta.Capabilities, capability) +} + +func (p *Peer) SupportsIPv6() bool { + return !p.Meta.Flags.DisableIPv6 && p.HasCapability(PeerCapabilityIPv6Overlay) +} + +func (p *Peer) SupportsSourcePrefixes() bool { + return p.HasCapability(PeerCapabilitySourcePrefixes) +} + +func (p *Peer) AddedWithSSOLogin() bool { + return p.UserID != "" +} + +func (p *Peer) FQDN(dnsDomain string) string { + if dnsDomain == "" { + return "" + } + return p.DNSLabel + "." + dnsDomain +} + +func (p *Peer) GetLastLogin() time.Time { + if p.LastLogin != nil { + return *p.LastLogin + } + return time.Time{} +} + +// SessionExpiresAt mirrors peer.Peer.SessionExpiresAt. +func (p *Peer) SessionExpiresAt(accountExpirationEnabled bool, expiresIn time.Duration) time.Time { + if !accountExpirationEnabled || !p.AddedWithSSOLogin() || !p.LoginExpirationEnabled { + return time.Time{} + } + last := p.GetLastLogin() + if last.IsZero() { + return time.Time{} + } + return last.Add(expiresIn).UTC() +} + +func (p *Peer) LoginExpired(expiresIn time.Duration) (bool, time.Duration) { + if !p.AddedWithSSOLogin() || !p.LoginExpirationEnabled { + return false, 0 + } + expiresAt := p.GetLastLogin().Add(expiresIn) + now := time.Now() + timeLeft := expiresAt.Sub(now) + return timeLeft <= 0, timeLeft +} diff --git a/shared/management/networkmap/nmdata/policy.go b/shared/management/networkmap/nmdata/policy.go new file mode 100644 index 000000000..df0c77518 --- /dev/null +++ b/shared/management/networkmap/nmdata/policy.go @@ -0,0 +1,96 @@ +package nmdata + +const ( + policyRuleProtocolALL = "all" + policyRuleProtocolTCP = "tcp" + + defaultSSHPortString = "22" + nativeSSHPortString = "22022" + defaultSSHPortNumber uint16 = 22 + nativeSSHPortNumber uint16 = 22022 +) + +// Policy is the slim twin of types.Policy. +type Policy struct { + ID string + PublicID string + Enabled bool + SourcePostureChecks []string + Rules []*PolicyRule +} + +// PolicyRule is the slim twin of types.PolicyRule. +type PolicyRule struct { + ID string + PolicyID string + Enabled bool + Action string + Protocol string + Bidirectional bool + Sources []string + Destinations []string + SourceResource Resource + DestinationResource Resource + Ports []string + PortRanges []RulePortRange + AuthorizedGroups map[string][]string + AuthorizedUser string +} + +// RulePortRange is the slim twin of types.RulePortRange. +type RulePortRange struct { + Start uint16 + End uint16 +} + +// Resource is the slim twin of types.Resource. +type Resource struct { + ID string + Type string +} + +func (p *Policy) SourceGroups() []string { + if len(p.Rules) == 1 && p.Rules[0] != nil { + return p.Rules[0].Sources + } + groups := make(map[string]struct{}, len(p.Rules)) + for _, rule := range p.Rules { + if rule == nil { + continue + } + for _, source := range rule.Sources { + groups[source] = struct{}{} + } + } + + groupIDs := make([]string, 0, len(groups)) + for groupID := range groups { + groupIDs = append(groupIDs, groupID) + } + + return groupIDs +} + +// PolicyRuleImpliesLegacySSH is the twin-typed sibling of types.PolicyRuleImpliesLegacySSH. +func PolicyRuleImpliesLegacySSH(rule *PolicyRule) bool { + return rule.Protocol == policyRuleProtocolALL || + (rule.Protocol == policyRuleProtocolTCP && (portsIncludesSSH(rule.Ports) || portRangeIncludesSSH(rule.PortRanges))) +} + +func portRangeIncludesSSH(portRanges []RulePortRange) bool { + for _, pr := range portRanges { + if (pr.Start <= defaultSSHPortNumber && pr.End >= defaultSSHPortNumber) || (pr.Start <= nativeSSHPortNumber && pr.End >= nativeSSHPortNumber) { + return true + } + } + return false +} + +func portsIncludesSSH(ports []string) bool { + for _, port := range ports { + if port == defaultSSHPortString || port == nativeSSHPortString { + return true + } + } + return false +} diff --git a/shared/management/networkmap/nmdata/posture.go b/shared/management/networkmap/nmdata/posture.go new file mode 100644 index 000000000..dc1753791 --- /dev/null +++ b/shared/management/networkmap/nmdata/posture.go @@ -0,0 +1,67 @@ +package nmdata + +const ( + checkActionAllow = "allow" + checkActionDeny = "deny" +) + +// PostureChecks is the slim twin of posture.Checks. +type PostureChecks struct { + ID string + Checks ChecksDefinition +} + +// ChecksDefinition is the slim twin of posture.ChecksDefinition. +type ChecksDefinition struct { + NBVersionCheck *NBVersionCheck + OSVersionCheck *OSVersionCheck + GeoLocationCheck *GeoLocationCheck + PeerNetworkRangeCheck *PeerNetworkRangeCheck + ProcessCheck *ProcessCheck +} + +// Check is the slim twin of posture.Check. It is sealed: only the check types +// in this package implement it. +type Check interface { + check(peer *Peer) (bool, error) +} + +// Passes reports whether the peer satisfies every check in this bundle. It +// mirrors the server posture path: a check returning (false, _) — including on +// an evaluation error — fails the bundle. +func (pc *PostureChecks) Passes(peer *Peer) bool { + return PassesChecks(pc.GetChecks(), peer) +} + +// PassesChecks is Passes over an already built check set, for callers that +// evaluate many peers against the same bundle. +func PassesChecks(checks []Check, peer *Peer) bool { + for _, c := range checks { + valid, _ := c.check(peer) + if !valid { + return false + } + } + return true +} + +// GetChecks returns the initialized checks in the same order as posture.Checks.GetChecks. +func (pc *PostureChecks) GetChecks() []Check { + var checks []Check + if pc.Checks.NBVersionCheck != nil { + checks = append(checks, pc.Checks.NBVersionCheck) + } + if pc.Checks.OSVersionCheck != nil { + checks = append(checks, pc.Checks.OSVersionCheck) + } + if pc.Checks.GeoLocationCheck != nil { + checks = append(checks, pc.Checks.GeoLocationCheck) + } + if pc.Checks.PeerNetworkRangeCheck != nil { + checks = append(checks, pc.Checks.PeerNetworkRangeCheck) + } + if pc.Checks.ProcessCheck != nil { + checks = append(checks, pc.Checks.ProcessCheck) + } + return checks +} diff --git a/shared/management/networkmap/nmdata/posture_geo_location.go b/shared/management/networkmap/nmdata/posture_geo_location.go new file mode 100644 index 000000000..18b0919b2 --- /dev/null +++ b/shared/management/networkmap/nmdata/posture_geo_location.go @@ -0,0 +1,45 @@ +package nmdata + +import "fmt" + +// GeoLocation is the slim twin of posture.Location. +type GeoLocation struct { + CountryCode string + CityName string +} + +// GeoLocationCheck is the slim twin of posture.GeoLocationCheck. +type GeoLocationCheck struct { + Locations []GeoLocation + Action string +} + +func (g *GeoLocationCheck) check(peer *Peer) (bool, error) { + if peer.Location.CountryCode == "" && peer.Location.CityName == "" { + return false, fmt.Errorf("peer's location is not set") + } + + for _, loc := range g.Locations { + if loc.CountryCode == peer.Location.CountryCode { + if loc.CityName == "" || loc.CityName == peer.Location.CityName { + switch g.Action { + case checkActionDeny: + return false, nil + case checkActionAllow: + return true, nil + default: + return false, fmt.Errorf("invalid geo location action: %s", g.Action) + } + } + } + } + + if g.Action == checkActionDeny { + return true, nil + } + if g.Action == checkActionAllow { + return false, nil + } + + return false, fmt.Errorf("invalid geo location action: %s", g.Action) +} diff --git a/shared/management/networkmap/nmdata/posture_nb_version.go b/shared/management/networkmap/nmdata/posture_nb_version.go new file mode 100644 index 000000000..3d82a4c80 --- /dev/null +++ b/shared/management/networkmap/nmdata/posture_nb_version.go @@ -0,0 +1,38 @@ +package nmdata + +import ( + "strings" + + "github.com/hashicorp/go-version" +) + +// NBVersionCheck is the slim twin of posture.NBVersionCheck. +type NBVersionCheck struct { + MinVersion string +} + +func (n *NBVersionCheck) check(peer *Peer) (bool, error) { + return meetsMinVersion(n.MinVersion, peer.Meta.WtVersion) +} + +func meetsMinVersion(minVer, peerVer string) (bool, error) { + peerVer = sanitizeVersion(peerVer) + minVer = sanitizeVersion(minVer) + + peerNBVer, err := version.NewVersion(peerVer) + if err != nil { + return false, err + } + + constraints, err := version.NewConstraint(">= " + minVer) + if err != nil { + return false, err + } + + return constraints.Check(peerNBVer), nil +} + +func sanitizeVersion(v string) string { + parts := strings.Split(v, "-") + return parts[0] +} diff --git a/shared/management/networkmap/nmdata/posture_network.go b/shared/management/networkmap/nmdata/posture_network.go new file mode 100644 index 000000000..d8dd2cf00 --- /dev/null +++ b/shared/management/networkmap/nmdata/posture_network.go @@ -0,0 +1,62 @@ +package nmdata + +import ( + "fmt" + "net/netip" +) + +// PeerNetworkRangeCheck is the slim twin of posture.PeerNetworkRangeCheck. +type PeerNetworkRangeCheck struct { + Action string + Ranges []netip.Prefix +} + +func (p *PeerNetworkRangeCheck) check(peer *Peer) (bool, error) { + peerPrefixes := make([]netip.Prefix, 0, len(peer.Meta.NetworkAddresses)+1) + for _, peerNetAddr := range peer.Meta.NetworkAddresses { + peerPrefixes = append(peerPrefixes, peerNetAddr.NetIP) + } + if connIP := peer.Location.ConnectionIP; len(connIP) > 0 { + if addr, ok := netip.AddrFromSlice(connIP); ok { + addr = addr.Unmap() + peerPrefixes = append(peerPrefixes, netip.PrefixFrom(addr, addr.BitLen())) + } + } + + if len(peerPrefixes) == 0 { + return false, fmt.Errorf("peer's does not contain peer network range addresses") + } + + for _, peerPrefix := range peerPrefixes { + for _, rangePrefix := range p.Ranges { + if !prefixContains(rangePrefix, peerPrefix) { + continue + } + switch p.Action { + case checkActionDeny: + return false, nil + case checkActionAllow: + return true, nil + default: + return false, fmt.Errorf("invalid peer network range check action: %s", p.Action) + } + } + } + + if p.Action == checkActionDeny { + return true, nil + } + if p.Action == checkActionAllow { + return false, nil + } + + return false, fmt.Errorf("invalid peer network range check action: %s", p.Action) +} + +func prefixContains(outer, inner netip.Prefix) bool { + outer = outer.Masked() + inner = inner.Masked() + return outer.Bits() <= inner.Bits() && + outer.Addr().BitLen() == inner.Addr().BitLen() && + outer.Contains(inner.Addr()) +} diff --git a/shared/management/networkmap/nmdata/posture_os_version.go b/shared/management/networkmap/nmdata/posture_os_version.go new file mode 100644 index 000000000..779bd2ac3 --- /dev/null +++ b/shared/management/networkmap/nmdata/posture_os_version.go @@ -0,0 +1,79 @@ +package nmdata + +import ( + "strings" + + "github.com/hashicorp/go-version" +) + +// MinVersionCheck is the slim twin of posture.MinVersionCheck. +type MinVersionCheck struct { + MinVersion string +} + +// MinKernelVersionCheck is the slim twin of posture.MinKernelVersionCheck. +type MinKernelVersionCheck struct { + MinKernelVersion string +} + +// OSVersionCheck is the slim twin of posture.OSVersionCheck. +type OSVersionCheck struct { + Android *MinVersionCheck + Darwin *MinVersionCheck + Ios *MinVersionCheck + Linux *MinKernelVersionCheck + Windows *MinKernelVersionCheck +} + +func (c *OSVersionCheck) check(peer *Peer) (bool, error) { + switch peer.Meta.GoOS { + case "android": + return checkMinVersion(peer.Meta.OSVersion, c.Android) + case "darwin": + return checkMinVersion(peer.Meta.OSVersion, c.Darwin) + case "ios": + return checkMinVersion(peer.Meta.OSVersion, c.Ios) + case "linux": + kernelVersion := strings.Split(peer.Meta.KernelVersion, "-")[0] + return checkMinKernelVersion(kernelVersion, c.Linux) + case "windows": + return checkMinKernelVersion(peer.Meta.KernelVersion, c.Windows) + } + return true, nil +} + +func checkMinVersion(peerVersion string, check *MinVersionCheck) (bool, error) { + if check == nil { + return false, nil + } + + peerNBVersion, err := version.NewVersion(peerVersion) + if err != nil { + return false, err + } + + constraints, err := version.NewConstraint(">= " + check.MinVersion) + if err != nil { + return false, err + } + + return constraints.Check(peerNBVersion), nil +} + +func checkMinKernelVersion(peerVersion string, check *MinKernelVersionCheck) (bool, error) { + if check == nil { + return false, nil + } + + peerNBVersion, err := version.NewVersion(peerVersion) + if err != nil { + return false, err + } + + constraints, err := version.NewConstraint(">= " + check.MinKernelVersion) + if err != nil { + return false, err + } + + return constraints.Check(peerNBVersion), nil +} diff --git a/shared/management/networkmap/nmdata/posture_process.go b/shared/management/networkmap/nmdata/posture_process.go new file mode 100644 index 000000000..3d35613b5 --- /dev/null +++ b/shared/management/networkmap/nmdata/posture_process.go @@ -0,0 +1,56 @@ +package nmdata + +import ( + "fmt" + "slices" +) + +// Process is the slim twin of posture.Process. +type Process struct { + LinuxPath string + MacPath string + WindowsPath string +} + +// ProcessCheck is the slim twin of posture.ProcessCheck. +type ProcessCheck struct { + Processes []Process +} + +func (p *ProcessCheck) check(peer *Peer) (bool, error) { + peerActiveProcesses := extractPeerActiveProcesses(peer.Meta.Files) + + var pathSelector func(Process) string + switch peer.Meta.GoOS { + case "linux": + pathSelector = func(process Process) string { return process.LinuxPath } + case "darwin": + pathSelector = func(process Process) string { return process.MacPath } + case "windows": + pathSelector = func(process Process) string { return process.WindowsPath } + default: + return false, fmt.Errorf("unsupported peer's operating system: %s", peer.Meta.GoOS) + } + + return p.areAllProcessesRunning(peerActiveProcesses, pathSelector), nil +} + +func (p *ProcessCheck) areAllProcessesRunning(activeProcesses []string, pathSelector func(Process) string) bool { + for _, process := range p.Processes { + path := pathSelector(process) + if path == "" || !slices.Contains(activeProcesses, path) { + return false + } + } + return true +} + +func extractPeerActiveProcesses(files []File) []string { + activeProcesses := make([]string, 0, len(files)) + for _, file := range files { + if file.ProcessIsRunning { + activeProcesses = append(activeProcesses, file.Path) + } + } + return activeProcesses +} diff --git a/shared/management/networkmap/nmdata/route.go b/shared/management/networkmap/nmdata/route.go new file mode 100644 index 000000000..e2301f094 --- /dev/null +++ b/shared/management/networkmap/nmdata/route.go @@ -0,0 +1,108 @@ +package nmdata + +import ( + "net/netip" + "slices" + "strings" + + "github.com/netbirdio/netbird/shared/management/domain" +) + +// NetworkType mirrors route.NetworkType iota values. +const ( + NetworkTypeInvalid = 0 + NetworkTypeIPv4 = 1 + NetworkTypeIPv6 = 2 + NetworkTypeDomain = 3 + + haSeparator = "|" +) + +// Route is the slim twin of route.Route. +type Route struct { + ID string + AccountID string + PublicID string + Network netip.Prefix + Domains domain.List + KeepRoute bool + NetID string + Description string + Peer string + PeerID string + PeerGroups []string + NetworkType int + Masquerade bool + Metric int + Enabled bool + Groups []string + AccessControlGroups []string + SkipAutoApply bool +} + +func (r *Route) Equal(other *Route) bool { + if r == nil && other == nil { + return true + } else if r == nil || other == nil { + return false + } + + return other.ID == r.ID && + other.Description == r.Description && + other.NetID == r.NetID && + other.Network == r.Network && + slices.Equal(r.Domains, other.Domains) && + other.KeepRoute == r.KeepRoute && + other.NetworkType == r.NetworkType && + other.Peer == r.Peer && + other.PeerID == r.PeerID && + other.Metric == r.Metric && + other.Masquerade == r.Masquerade && + other.Enabled == r.Enabled && + slices.Equal(r.Groups, other.Groups) && + slices.Equal(r.PeerGroups, other.PeerGroups) && + slices.Equal(r.AccessControlGroups, other.AccessControlGroups) && + other.SkipAutoApply == r.SkipAutoApply +} + +func (r *Route) IsDynamic() bool { + return r.NetworkType == NetworkTypeDomain +} + +func (r *Route) NetString() string { + if r.IsDynamic() && r.Domains != nil { + return r.Domains.SafeString() + } + return r.Network.String() +} + +func (r *Route) GetHAUniqueID() string { + return r.NetID + haSeparator + r.NetString() +} + +func (r *Route) GetResourceID() string { + return strings.Split(r.ID, ":")[0] +} + +func (r *Route) Copy() *Route { + return &Route{ + ID: r.ID, + AccountID: r.AccountID, + PublicID: r.PublicID, + Network: r.Network, + Domains: slices.Clone(r.Domains), + KeepRoute: r.KeepRoute, + NetID: r.NetID, + Description: r.Description, + Peer: r.Peer, + PeerID: r.PeerID, + PeerGroups: slices.Clone(r.PeerGroups), + NetworkType: r.NetworkType, + Masquerade: r.Masquerade, + Metric: r.Metric, + Enabled: r.Enabled, + Groups: slices.Clone(r.Groups), + AccessControlGroups: slices.Clone(r.AccessControlGroups), + SkipAutoApply: r.SkipAutoApply, + } +} diff --git a/shared/management/networkmap/nmdata/service.go b/shared/management/networkmap/nmdata/service.go new file mode 100644 index 000000000..63557c51e --- /dev/null +++ b/shared/management/networkmap/nmdata/service.go @@ -0,0 +1,25 @@ +package nmdata + +// Service is the slim twin of the reverse-proxy service.Service. It carries +// only the state proxy-policy injection reads: the persisted reverse-proxy +// services and the in-memory ones synthesised from agent-network state, which +// are never written to the database. +type Service struct { + ID string + Enabled bool + Private bool + Mode string + ProxyCluster string + AccessGroups []string + Targets []*ServiceTarget +} + +// ServiceTarget is the slim twin of service.Target. +type ServiceTarget struct { + Enabled bool + Path string + Port uint16 + Protocol string + TargetID string + TargetType string +} diff --git a/shared/management/networkmap/peers_custom_zone.go b/shared/management/networkmap/peers_custom_zone.go new file mode 100644 index 000000000..063844358 --- /dev/null +++ b/shared/management/networkmap/peers_custom_zone.go @@ -0,0 +1,111 @@ +package networkmap + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/go-multierror" + "github.com/miekg/dns" + log "github.com/sirupsen/logrus" + + nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" +) + +const peersZoneRecordTTL = 300 + +// PeersCustomZone builds the peers DNS zone from twin peer rows. It is the +// single source of the zone-record logic; Account.GetPeersCustomZone delegates +// here via twins. +func PeersCustomZone(ctx context.Context, accountID string, dnsDomain string, peers map[string]*nmdata.Peer, ipv6AllowedPeers map[string]struct{}) nmdata.CustomZone { + var merr *multierror.Error + + if dnsDomain == "" { + log.WithContext(ctx).Error("no dns domain is set, returning empty zone") + return nmdata.CustomZone{} + } + + customZone := nmdata.CustomZone{ + Domain: dns.Fqdn(dnsDomain), + Records: make([]nmdata.SimpleRecord, 0, len(peers)), + } + + domainSuffix := "." + dnsDomain + + var sb strings.Builder + for _, peer := range peers { + if peer == nil { + continue + } + if peer.DNSLabel == "" { + merr = multierror.Append(merr, fmt.Errorf("peer %s has an empty DNS label", peer.ID)) + continue + } + + sb.Grow(len(peer.DNSLabel) + len(domainSuffix)) + sb.WriteString(peer.DNSLabel) + sb.WriteString(domainSuffix) + + fqdn := sb.String() + customZone.Records = append(customZone.Records, nmdata.SimpleRecord{ + Name: fqdn, + Type: int(dns.TypeA), + Class: nbdns.DefaultClass, + TTL: peersZoneRecordTTL, + RData: peer.IP.String(), + }) + // Only advertise AAAA for peers that have a valid IPv6, whose client supports it, + // and that belong to an IPv6-enabled group. Old clients don't configure v6 on their + // WireGuard interface, so resolving their AAAA causes connections to hang. + // Capability changes (client upgrade/downgrade, --disable-ipv6 toggle) propagate + // to other peers via SyncPeer/LoginPeer regardless of version change, so AAAA + // records refresh when a peer first reports the IPv6 overlay capability. + _, peerAllowed := ipv6AllowedPeers[peer.ID] + hasIPv6 := peer.IPv6.IsValid() && peer.SupportsIPv6() && peerAllowed + if hasIPv6 { + customZone.Records = append(customZone.Records, nmdata.SimpleRecord{ + Name: fqdn, + Type: int(dns.TypeAAAA), + Class: nbdns.DefaultClass, + TTL: peersZoneRecordTTL, + RData: peer.IPv6.String(), + }) + } + sb.Reset() + + for _, extraLabel := range peer.ExtraDNSLabels { + sb.Grow(len(extraLabel) + len(domainSuffix)) + sb.WriteString(extraLabel) + sb.WriteString(domainSuffix) + + extraFqdn := sb.String() + customZone.Records = append(customZone.Records, nmdata.SimpleRecord{ + Name: extraFqdn, + Type: int(dns.TypeA), + Class: nbdns.DefaultClass, + TTL: peersZoneRecordTTL, + RData: peer.IP.String(), + }) + if hasIPv6 { + customZone.Records = append(customZone.Records, nmdata.SimpleRecord{ + Name: extraFqdn, + Type: int(dns.TypeAAAA), + Class: nbdns.DefaultClass, + TTL: peersZoneRecordTTL, + RData: peer.IPv6.String(), + }) + } + sb.Reset() + } + + } + + go func() { + if merr != nil { + log.WithContext(ctx).Errorf("error generating custom zone for account %s: %v", accountID, merr) + } + }() + + return customZone +} diff --git a/shared/management/networkmap/proxypolicies.go b/shared/management/networkmap/proxypolicies.go new file mode 100644 index 000000000..7a7c805a6 --- /dev/null +++ b/shared/management/networkmap/proxypolicies.go @@ -0,0 +1,209 @@ +package networkmap + +import ( + "fmt" + "slices" + "strings" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/netbirdio/netbird/shared/management/types" +) + +const ( + serviceModeUDP = "udp" + + privateServicePortHTTP = 80 + privateServicePortHTTPS = 443 +) + +// InjectProxyPolicies synthesises the in-memory ACLs that carry reverse-proxy +// traffic and appends them to the twin's policies. They are never persisted, +// so no builder can load them: a proxy-access policy lets a cluster's proxy +// peers reach each enabled target of a service, and a private-access policy +// lets a private service's AccessGroups reach those proxy peers on HTTP(S). +// +// GetPeerNetworkMapComponents calls it, so every caller of the twin gets the +// same policy set no matter which builder produced it. It runs at most once +// per twin, and is safe to call again to force the synthesis early. +func (nmd *NetworkMapData) InjectProxyPolicies() { + nmd.proxyPoliciesOnce.Do(nmd.injectProxyPolicies) +} + +func (nmd *NetworkMapData) injectProxyPolicies() { + if len(nmd.Services) == 0 { + return + } + + proxyPeersByCluster := nmd.proxyPeersByCluster() + if len(proxyPeersByCluster) == 0 { + return + } + + for _, svc := range nmd.Services { + if svc == nil || !svc.Enabled { + continue + } + + proxyPeers := proxyPeersByCluster[svc.ProxyCluster] + for _, target := range svc.Targets { + if target == nil || !target.Enabled { + continue + } + port, ok := resolveTargetPort(target) + if !ok { + continue + } + for _, proxyPeer := range proxyPeers { + nmd.addInjectedPolicy(proxyAccessPolicy(svc, target, proxyPeer, port)) + } + } + + nmd.injectPrivateServicePolicies(svc, proxyPeers) + } +} + +// injectPrivateServicePolicies synthesises AccessGroups → cluster proxy peers on TCP 80/443. +func (nmd *NetworkMapData) injectPrivateServicePolicies(svc *nmdata.Service, proxyPeers []*nmdata.Peer) { + if !svc.Private || len(svc.AccessGroups) == 0 || len(proxyPeers) == 0 { + return + } + + // A service's AccessGroups can name groups that no longer exist — persisted + // services and the agent-network synthesiser both carry the ids verbatim from + // their own state. An unresolvable source authorises nothing, so drop it here + // rather than let the network-map assembly resolve it to a nil group. + sources := nmd.existingGroupIDs(svc.AccessGroups) + if len(sources) == 0 { + return + } + + for _, proxyPeer := range proxyPeers { + nmd.addInjectedPolicy(privateAccessPolicy(svc, proxyPeer, sources)) + } +} + +// addInjectedPolicy appends the policy to the twin's policy set, and to the +// policies of the network resource it targets — mirroring the account path, +// where the resource-policy map was built after injection. +func (nmd *NetworkMapData) addInjectedPolicy(policy *nmdata.Policy) { + nmd.Policies = append(nmd.Policies, policy) + + resourceID := policy.Rules[0].DestinationResource.ID + if resourceID == "" { + return + } + for _, resource := range nmd.NetworkResources { + if resource == nil || !resource.Enabled || resource.ID != resourceID { + continue + } + if nmd.ResourcePolicies == nil { + nmd.ResourcePolicies = make(map[string][]*nmdata.Policy) + } + nmd.ResourcePolicies[resourceID] = append(nmd.ResourcePolicies[resourceID], policy) + return + } +} + +func proxyAccessPolicy(svc *nmdata.Service, target *nmdata.ServiceTarget, proxyPeer *nmdata.Peer, port uint16) *nmdata.Policy { + policyID := fmt.Sprintf("proxy-access-%s-%s-%s", svc.ID, proxyPeer.ID, target.Path) + + protocol := types.PolicyRuleProtocolTCP + if svc.Mode == serviceModeUDP { + protocol = types.PolicyRuleProtocolUDP + } + + return &nmdata.Policy{ + ID: policyID, + // The envelope encoder puts public ids on the wire and degrades to an + // empty one when a policy has none. A synthesised policy has no + // persisted row to take a public id from, and its own id is already + // stable and unique, so it serves as both. + PublicID: policyID, + Enabled: true, + Rules: []*nmdata.PolicyRule{ + { + ID: policyID, + PolicyID: policyID, + Enabled: true, + SourceResource: nmdata.Resource{ID: proxyPeer.ID, Type: string(types.ResourceTypePeer)}, + DestinationResource: nmdata.Resource{ID: target.TargetID, Type: target.TargetType}, + Bidirectional: false, + Protocol: string(protocol), + Action: string(types.PolicyTrafficActionAccept), + PortRanges: []nmdata.RulePortRange{{Start: port, End: port}}, + }, + }, + } +} + +func privateAccessPolicy(svc *nmdata.Service, proxyPeer *nmdata.Peer, accessGroups []string) *nmdata.Policy { + policyID := fmt.Sprintf("private-access-%s-%s", svc.ID, proxyPeer.ID) + + return &nmdata.Policy{ + ID: policyID, + PublicID: policyID, + Enabled: true, + Rules: []*nmdata.PolicyRule{ + { + ID: policyID, + PolicyID: policyID, + Enabled: true, + Sources: slices.Clone(accessGroups), + DestinationResource: nmdata.Resource{ID: proxyPeer.ID, Type: string(types.ResourceTypePeer)}, + Bidirectional: false, + Protocol: string(types.PolicyRuleProtocolTCP), + Action: string(types.PolicyTrafficActionAccept), + PortRanges: []nmdata.RulePortRange{ + {Start: privateServicePortHTTP, End: privateServicePortHTTP}, + {Start: privateServicePortHTTPS, End: privateServicePortHTTPS}, + }, + }, + }, + } +} + +func resolveTargetPort(target *nmdata.ServiceTarget) (uint16, bool) { + if target.Port != 0 { + return target.Port, true + } + + switch target.Protocol { + case "https", "tls": + return privateServicePortHTTPS, true + case "http": + return privateServicePortHTTP, true + default: + return 0, false + } +} + +// proxyPeersByCluster groups the account's embedded proxy peers by the cluster +// they serve. Sorted by peer ID so the synthesised policy order is stable. +func (nmd *NetworkMapData) proxyPeersByCluster() map[string][]*nmdata.Peer { + var proxyPeers map[string][]*nmdata.Peer + for _, peer := range nmd.Peers { + if peer == nil || !peer.ProxyMeta.Embedded { + continue + } + if proxyPeers == nil { + proxyPeers = make(map[string][]*nmdata.Peer) + } + proxyPeers[peer.ProxyMeta.Cluster] = append(proxyPeers[peer.ProxyMeta.Cluster], peer) + } + for _, peers := range proxyPeers { + slices.SortFunc(peers, func(a, b *nmdata.Peer) int { return strings.Compare(a.ID, b.ID) }) + } + return proxyPeers +} + +// existingGroupIDs returns the subset of groupIDs that resolve to a group, +// preserving the input order. +func (nmd *NetworkMapData) existingGroupIDs(groupIDs []string) []string { + out := make([]string, 0, len(groupIDs)) + for _, groupID := range groupIDs { + if _, ok := nmd.Groups[groupID]; ok { + out = append(out, groupID) + } + } + return out +} diff --git a/shared/management/proto/management.pb.go b/shared/management/proto/management.pb.go index 7d37df1de..bd3ec7120 100644 --- a/shared/management/proto/management.pb.go +++ b/shared/management/proto/management.pb.go @@ -5819,8 +5819,6 @@ func (x *PolicyCompact) GetSourcePostureCheckIds() []string { // ResourceCompact mirrors types.Resource. Used by PolicyCompact to carry // rule.SourceResource / rule.DestinationResource when the rule targets a // specific resource (typically a peer) rather than groups. -// peer_index_set tells whether peer_index is valid (proto3 uint32 cannot -// disambiguate "0" from "unset"); set only when type == "peer". type ResourceCompact struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -5829,6 +5827,7 @@ type ResourceCompact struct { Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` PeerIndexSet bool `protobuf:"varint,2,opt,name=peer_index_set,json=peerIndexSet,proto3" json:"peer_index_set,omitempty"` PeerIndex uint32 `protobuf:"varint,3,opt,name=peer_index,json=peerIndex,proto3" json:"peer_index,omitempty"` + Id string `protobuf:"bytes,5,opt,name=id,proto3" json:"id,omitempty"` // public id for domain/host/subnet resources } func (x *ResourceCompact) Reset() { @@ -5884,6 +5883,13 @@ func (x *ResourceCompact) GetPeerIndex() uint32 { return 0 } +func (x *ResourceCompact) GetId() string { + if x != nil { + return x.Id + } + return "" +} + // UserNameList is a list of local-user names — used as the value type in // PolicyCompact.authorized_groups. type UserNameList struct { @@ -5949,7 +5955,8 @@ type GroupCompact struct { // groups exactly like the server does; without this bit the decoded // groups lose that property and the two sides expand policy // destinations differently. - IsAll bool `protobuf:"varint,3,opt,name=is_all,json=isAll,proto3" json:"is_all,omitempty"` + IsAll bool `protobuf:"varint,3,opt,name=is_all,json=isAll,proto3" json:"is_all,omitempty"` + Resources []*ResourceCompact `protobuf:"bytes,4,rep,name=resources,proto3" json:"resources,omitempty"` } func (x *GroupCompact) Reset() { @@ -6005,6 +6012,13 @@ func (x *GroupCompact) GetIsAll() bool { return false } +func (x *GroupCompact) GetResources() []*ResourceCompact { + if x != nil { + return x.Resources + } + return nil +} + // DNSSettingsCompact mirrors types.DNSSettings. type DNSSettingsCompact struct { state protoimpl.MessageState @@ -7726,216 +7740,221 @@ var file_management_proto_rawDesc = []byte{ 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, - 0x22, 0x70, 0x0a, 0x0f, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x43, 0x6f, 0x6d, 0x70, - 0x61, 0x63, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x5f, - 0x69, 0x6e, 0x64, 0x65, 0x78, 0x5f, 0x73, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x0c, 0x70, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x65, 0x74, 0x12, 0x1d, 0x0a, - 0x0a, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x0d, 0x52, 0x09, 0x70, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x4a, 0x04, 0x08, 0x04, - 0x10, 0x05, 0x22, 0x24, 0x0a, 0x0c, 0x55, 0x73, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x4c, 0x69, - 0x73, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, - 0x09, 0x52, 0x05, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x22, 0x58, 0x0a, 0x0c, 0x47, 0x72, 0x6f, 0x75, - 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x70, 0x65, 0x65, 0x72, - 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x0b, - 0x70, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x12, 0x15, 0x0a, 0x06, 0x69, - 0x73, 0x5f, 0x61, 0x6c, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x69, 0x73, 0x41, - 0x6c, 0x6c, 0x22, 0x57, 0x0a, 0x12, 0x44, 0x4e, 0x53, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, - 0x73, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x12, 0x41, 0x0a, 0x1d, 0x64, 0x69, 0x73, 0x61, - 0x62, 0x6c, 0x65, 0x64, 0x5f, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x5f, - 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, - 0x1a, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x22, 0x8d, 0x04, 0x0a, 0x08, - 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x61, 0x77, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x15, 0x0a, 0x06, 0x6e, 0x65, 0x74, 0x5f, - 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6e, 0x65, 0x74, 0x49, 0x64, 0x12, - 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, - 0x6e, 0x12, 0x21, 0x0a, 0x0c, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x5f, 0x63, 0x69, 0x64, - 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, - 0x43, 0x69, 0x64, 0x72, 0x12, 0x18, 0x0a, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, - 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x1d, - 0x0a, 0x0a, 0x6b, 0x65, 0x65, 0x70, 0x5f, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x18, 0x06, 0x20, 0x01, - 0x28, 0x08, 0x52, 0x09, 0x6b, 0x65, 0x65, 0x70, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x24, 0x0a, - 0x0e, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x5f, 0x73, 0x65, 0x74, 0x18, - 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, - 0x53, 0x65, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65, - 0x78, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x70, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, - 0x65, 0x78, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, - 0x5f, 0x69, 0x64, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, - 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x6e, 0x65, 0x74, 0x77, - 0x6f, 0x72, 0x6b, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0b, - 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x54, 0x79, 0x70, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x6d, - 0x61, 0x73, 0x71, 0x75, 0x65, 0x72, 0x61, 0x64, 0x65, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x0a, 0x6d, 0x61, 0x73, 0x71, 0x75, 0x65, 0x72, 0x61, 0x64, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x6d, - 0x65, 0x74, 0x72, 0x69, 0x63, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x6d, 0x65, 0x74, - 0x72, 0x69, 0x63, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x0d, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x1b, 0x0a, - 0x09, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x0e, 0x20, 0x03, 0x28, 0x09, - 0x52, 0x08, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x37, 0x0a, 0x18, 0x61, 0x63, - 0x63, 0x65, 0x73, 0x73, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x67, 0x72, 0x6f, - 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x0f, 0x20, 0x03, 0x28, 0x09, 0x52, 0x15, 0x61, 0x63, - 0x63, 0x65, 0x73, 0x73, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x47, 0x72, 0x6f, 0x75, 0x70, - 0x49, 0x64, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x73, 0x6b, 0x69, 0x70, 0x5f, 0x61, 0x75, 0x74, 0x6f, - 0x5f, 0x61, 0x70, 0x70, 0x6c, 0x79, 0x18, 0x10, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x73, 0x6b, - 0x69, 0x70, 0x41, 0x75, 0x74, 0x6f, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x22, 0xff, 0x01, 0x0a, 0x12, - 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, - 0x61, 0x77, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, - 0x69, 0x64, 0x12, 0x38, 0x0a, 0x0b, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, - 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, - 0x0b, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x12, 0x1b, 0x0a, 0x09, - 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, - 0x08, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x70, 0x72, 0x69, - 0x6d, 0x61, 0x72, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x70, 0x72, 0x69, 0x6d, - 0x61, 0x72, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x05, - 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x18, 0x0a, - 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, - 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x34, 0x0a, 0x16, 0x73, 0x65, 0x61, 0x72, 0x63, - 0x68, 0x5f, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, - 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, - 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x87, 0x02, - 0x0a, 0x12, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x52, 0x61, 0x77, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x02, 0x69, 0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x5f, - 0x73, 0x65, 0x71, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6e, 0x65, 0x74, 0x77, 0x6f, - 0x72, 0x6b, 0x53, 0x65, 0x71, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, - 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, - 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x74, - 0x79, 0x70, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, - 0x18, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x64, 0x6f, 0x6d, - 0x61, 0x69, 0x6e, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x0b, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1f, 0x0a, 0x0b, - 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x18, 0x08, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x0a, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x43, 0x69, 0x64, 0x72, 0x12, 0x18, 0x0a, - 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, - 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x4d, 0x0a, 0x11, 0x4e, 0x65, 0x74, 0x77, 0x6f, - 0x72, 0x6b, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x38, 0x0a, 0x07, - 0x65, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, - 0x72, 0x6b, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x07, 0x65, - 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x22, 0xe1, 0x01, 0x0a, 0x12, 0x4e, 0x65, 0x74, 0x77, 0x6f, - 0x72, 0x6b, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x0e, 0x0a, - 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a, - 0x0a, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x0d, 0x52, 0x09, 0x70, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x24, 0x0a, 0x0e, - 0x70, 0x65, 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x5f, 0x73, 0x65, 0x74, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, - 0x65, 0x74, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, - 0x5f, 0x69, 0x64, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, - 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x1e, 0x0a, 0x0a, 0x6d, 0x61, 0x73, 0x71, - 0x75, 0x65, 0x72, 0x61, 0x64, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x6d, 0x61, + 0x22, 0x80, 0x01, 0x0a, 0x0f, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x43, 0x6f, 0x6d, + 0x70, 0x61, 0x63, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72, + 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x5f, 0x73, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, + 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x65, 0x74, 0x12, 0x1d, + 0x0a, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x0d, 0x52, 0x09, 0x70, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x0e, 0x0a, + 0x02, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x4a, 0x04, 0x08, + 0x04, 0x10, 0x05, 0x22, 0x24, 0x0a, 0x0c, 0x55, 0x73, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x4c, + 0x69, 0x73, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, + 0x28, 0x09, 0x52, 0x05, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x22, 0x93, 0x01, 0x0a, 0x0c, 0x47, 0x72, + 0x6f, 0x75, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x70, 0x65, + 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0d, + 0x52, 0x0b, 0x70, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x12, 0x15, 0x0a, + 0x06, 0x69, 0x73, 0x5f, 0x61, 0x6c, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x69, + 0x73, 0x41, 0x6c, 0x6c, 0x12, 0x39, 0x0a, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x43, 0x6f, 0x6d, + 0x70, 0x61, 0x63, 0x74, 0x52, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x22, + 0x57, 0x0a, 0x12, 0x44, 0x4e, 0x53, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x43, 0x6f, + 0x6d, 0x70, 0x61, 0x63, 0x74, 0x12, 0x41, 0x0a, 0x1d, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, + 0x64, 0x5f, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x67, 0x72, 0x6f, + 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x1a, 0x64, 0x69, + 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x22, 0x8d, 0x04, 0x0a, 0x08, 0x52, 0x6f, 0x75, + 0x74, 0x65, 0x52, 0x61, 0x77, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x15, 0x0a, 0x06, 0x6e, 0x65, 0x74, 0x5f, 0x69, 0x64, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6e, 0x65, 0x74, 0x49, 0x64, 0x12, 0x20, 0x0a, 0x0b, + 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x21, + 0x0a, 0x0c, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x43, 0x69, 0x64, + 0x72, 0x12, 0x18, 0x0a, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x05, 0x20, 0x03, + 0x28, 0x09, 0x52, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x6b, + 0x65, 0x65, 0x70, 0x5f, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x09, 0x6b, 0x65, 0x65, 0x70, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, + 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x5f, 0x73, 0x65, 0x74, 0x18, 0x07, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x65, 0x74, + 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x08, + 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x70, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12, + 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, + 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, + 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, + 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0b, 0x6e, 0x65, 0x74, + 0x77, 0x6f, 0x72, 0x6b, 0x54, 0x79, 0x70, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x6d, 0x61, 0x73, 0x71, + 0x75, 0x65, 0x72, 0x61, 0x64, 0x65, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x6d, 0x61, 0x73, 0x71, 0x75, 0x65, 0x72, 0x61, 0x64, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x6d, 0x65, 0x74, 0x72, - 0x69, 0x63, 0x18, 0x06, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, - 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, - 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x1d, 0x0a, 0x09, 0x50, 0x6f, - 0x6c, 0x69, 0x63, 0x79, 0x49, 0x64, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x69, 0x64, 0x73, 0x18, 0x01, - 0x20, 0x03, 0x28, 0x09, 0x52, 0x03, 0x69, 0x64, 0x73, 0x22, 0x27, 0x0a, 0x0a, 0x55, 0x73, 0x65, - 0x72, 0x49, 0x44, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x75, 0x73, 0x65, 0x72, 0x5f, - 0x69, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x75, 0x73, 0x65, 0x72, 0x49, - 0x64, 0x73, 0x22, 0x31, 0x0a, 0x0c, 0x50, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, - 0x65, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, - 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x0b, 0x70, 0x65, 0x65, 0x72, 0x49, 0x6e, - 0x64, 0x65, 0x78, 0x65, 0x73, 0x2a, 0x3a, 0x0a, 0x09, 0x4a, 0x6f, 0x62, 0x53, 0x74, 0x61, 0x74, - 0x75, 0x73, 0x12, 0x12, 0x0a, 0x0e, 0x75, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x5f, 0x73, 0x74, - 0x61, 0x74, 0x75, 0x73, 0x10, 0x00, 0x12, 0x0d, 0x0a, 0x09, 0x73, 0x75, 0x63, 0x63, 0x65, 0x65, - 0x64, 0x65, 0x64, 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x10, - 0x02, 0x2a, 0x93, 0x01, 0x0a, 0x0e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, - 0x6c, 0x69, 0x74, 0x79, 0x12, 0x19, 0x0a, 0x15, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, - 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x55, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x10, 0x00, 0x12, - 0x20, 0x0a, 0x1c, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, - 0x79, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x10, - 0x01, 0x12, 0x1d, 0x0a, 0x19, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, - 0x69, 0x74, 0x79, 0x49, 0x50, 0x76, 0x36, 0x4f, 0x76, 0x65, 0x72, 0x6c, 0x61, 0x79, 0x10, 0x02, - 0x12, 0x25, 0x0a, 0x21, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, - 0x74, 0x79, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, - 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x10, 0x03, 0x2a, 0x48, 0x0a, 0x09, 0x4c, 0x61, 0x7a, 0x79, 0x53, - 0x74, 0x61, 0x74, 0x65, 0x12, 0x14, 0x0a, 0x10, 0x4c, 0x61, 0x7a, 0x79, 0x53, 0x74, 0x61, 0x74, - 0x65, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x10, 0x00, 0x12, 0x11, 0x0a, 0x0d, 0x4c, 0x61, - 0x7a, 0x79, 0x53, 0x74, 0x61, 0x74, 0x65, 0x4c, 0x61, 0x7a, 0x79, 0x10, 0x01, 0x12, 0x12, 0x0a, - 0x0e, 0x4c, 0x61, 0x7a, 0x79, 0x53, 0x74, 0x61, 0x74, 0x65, 0x45, 0x61, 0x67, 0x65, 0x72, 0x10, - 0x02, 0x2a, 0x5d, 0x0a, 0x0c, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, - 0x6c, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x07, - 0x0a, 0x03, 0x41, 0x4c, 0x4c, 0x10, 0x01, 0x12, 0x07, 0x0a, 0x03, 0x54, 0x43, 0x50, 0x10, 0x02, - 0x12, 0x07, 0x0a, 0x03, 0x55, 0x44, 0x50, 0x10, 0x03, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x43, 0x4d, - 0x50, 0x10, 0x04, 0x12, 0x0a, 0x0a, 0x06, 0x43, 0x55, 0x53, 0x54, 0x4f, 0x4d, 0x10, 0x05, 0x12, - 0x0f, 0x0a, 0x0b, 0x4e, 0x45, 0x54, 0x42, 0x49, 0x52, 0x44, 0x5f, 0x53, 0x53, 0x48, 0x10, 0x06, - 0x2a, 0x20, 0x0a, 0x0d, 0x52, 0x75, 0x6c, 0x65, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, - 0x6e, 0x12, 0x06, 0x0a, 0x02, 0x49, 0x4e, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x4f, 0x55, 0x54, - 0x10, 0x01, 0x2a, 0x22, 0x0a, 0x0a, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, - 0x12, 0x0a, 0x0a, 0x06, 0x41, 0x43, 0x43, 0x45, 0x50, 0x54, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, - 0x44, 0x52, 0x4f, 0x50, 0x10, 0x01, 0x2a, 0x63, 0x0a, 0x0e, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, - 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x0f, 0x0a, 0x0b, 0x45, 0x58, 0x50, 0x4f, - 0x53, 0x45, 0x5f, 0x48, 0x54, 0x54, 0x50, 0x10, 0x00, 0x12, 0x10, 0x0a, 0x0c, 0x45, 0x58, 0x50, - 0x4f, 0x53, 0x45, 0x5f, 0x48, 0x54, 0x54, 0x50, 0x53, 0x10, 0x01, 0x12, 0x0e, 0x0a, 0x0a, 0x45, - 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x54, 0x43, 0x50, 0x10, 0x02, 0x12, 0x0e, 0x0a, 0x0a, 0x45, - 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x55, 0x44, 0x50, 0x10, 0x03, 0x12, 0x0e, 0x0a, 0x0a, 0x45, - 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x54, 0x4c, 0x53, 0x10, 0x04, 0x32, 0xd0, 0x07, 0x0a, 0x11, - 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, - 0x65, 0x12, 0x45, 0x0a, 0x05, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, - 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, - 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x46, 0x0a, 0x04, 0x53, 0x79, 0x6e, 0x63, - 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, - 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, - 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x30, 0x01, - 0x12, 0x42, 0x0a, 0x0c, 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, - 0x12, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, - 0x70, 0x74, 0x79, 0x1a, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, - 0x2e, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x22, 0x00, 0x12, 0x33, 0x0a, 0x09, 0x69, 0x73, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, - 0x79, 0x12, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, - 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x5a, 0x0a, 0x1a, 0x47, 0x65, 0x74, - 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, - 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x69, 0x63, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, + 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x0d, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x67, 0x72, + 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x0e, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x67, + 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x37, 0x0a, 0x18, 0x61, 0x63, 0x63, 0x65, 0x73, + 0x73, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, + 0x69, 0x64, 0x73, 0x18, 0x0f, 0x20, 0x03, 0x28, 0x09, 0x52, 0x15, 0x61, 0x63, 0x63, 0x65, 0x73, + 0x73, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, + 0x12, 0x26, 0x0a, 0x0f, 0x73, 0x6b, 0x69, 0x70, 0x5f, 0x61, 0x75, 0x74, 0x6f, 0x5f, 0x61, 0x70, + 0x70, 0x6c, 0x79, 0x18, 0x10, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x73, 0x6b, 0x69, 0x70, 0x41, + 0x75, 0x74, 0x6f, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x22, 0xff, 0x01, 0x0a, 0x12, 0x4e, 0x61, 0x6d, + 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x61, 0x77, 0x12, + 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, + 0x38, 0x0a, 0x0b, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x18, 0x02, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x0b, 0x6e, 0x61, + 0x6d, 0x65, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x12, 0x1b, 0x0a, 0x09, 0x67, 0x72, 0x6f, + 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x67, 0x72, + 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x70, 0x72, 0x69, 0x6d, 0x61, 0x72, + 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x70, 0x72, 0x69, 0x6d, 0x61, 0x72, 0x79, + 0x12, 0x18, 0x0a, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, + 0x09, 0x52, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, + 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, + 0x62, 0x6c, 0x65, 0x64, 0x12, 0x34, 0x0a, 0x16, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x5f, 0x64, + 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x07, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, + 0x69, 0x6e, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x87, 0x02, 0x0a, 0x12, 0x4e, + 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61, + 0x77, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, + 0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x5f, 0x73, 0x65, 0x71, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x53, + 0x65, 0x71, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, + 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, + 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, + 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x18, 0x0a, 0x07, + 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, + 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, + 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x6f, + 0x6d, 0x61, 0x69, 0x6e, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x70, 0x72, 0x65, + 0x66, 0x69, 0x78, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, + 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x43, 0x69, 0x64, 0x72, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, + 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, + 0x62, 0x6c, 0x65, 0x64, 0x22, 0x4d, 0x0a, 0x11, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52, + 0x6f, 0x75, 0x74, 0x65, 0x72, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x38, 0x0a, 0x07, 0x65, 0x6e, 0x74, + 0x72, 0x69, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52, + 0x6f, 0x75, 0x74, 0x65, 0x72, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x07, 0x65, 0x6e, 0x74, 0x72, + 0x69, 0x65, 0x73, 0x22, 0xe1, 0x01, 0x0a, 0x12, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52, + 0x6f, 0x75, 0x74, 0x65, 0x72, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x65, + 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, + 0x70, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, + 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x5f, 0x73, 0x65, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x65, 0x74, 0x12, + 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, + 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, + 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x1e, 0x0a, 0x0a, 0x6d, 0x61, 0x73, 0x71, 0x75, 0x65, 0x72, + 0x61, 0x64, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x6d, 0x61, 0x73, 0x71, 0x75, + 0x65, 0x72, 0x61, 0x64, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x18, + 0x06, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x12, 0x18, 0x0a, + 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, + 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x1d, 0x0a, 0x09, 0x50, 0x6f, 0x6c, 0x69, 0x63, + 0x79, 0x49, 0x64, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x69, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, + 0x09, 0x52, 0x03, 0x69, 0x64, 0x73, 0x22, 0x27, 0x0a, 0x0a, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, + 0x4c, 0x69, 0x73, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x73, + 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x73, 0x22, + 0x31, 0x0a, 0x0c, 0x50, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x65, 0x74, 0x12, + 0x21, 0x0a, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x18, + 0x01, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x0b, 0x70, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, + 0x65, 0x73, 0x2a, 0x3a, 0x0a, 0x09, 0x4a, 0x6f, 0x62, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, + 0x12, 0x0a, 0x0e, 0x75, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, + 0x73, 0x10, 0x00, 0x12, 0x0d, 0x0a, 0x09, 0x73, 0x75, 0x63, 0x63, 0x65, 0x65, 0x64, 0x65, 0x64, + 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x10, 0x02, 0x2a, 0x93, + 0x01, 0x0a, 0x0e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, + 0x79, 0x12, 0x19, 0x0a, 0x15, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, + 0x69, 0x74, 0x79, 0x55, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x10, 0x00, 0x12, 0x20, 0x0a, 0x1c, + 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x53, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x10, 0x01, 0x12, 0x1d, + 0x0a, 0x19, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, + 0x49, 0x50, 0x76, 0x36, 0x4f, 0x76, 0x65, 0x72, 0x6c, 0x61, 0x79, 0x10, 0x02, 0x12, 0x25, 0x0a, + 0x21, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x43, + 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, + 0x61, 0x70, 0x10, 0x03, 0x2a, 0x48, 0x0a, 0x09, 0x4c, 0x61, 0x7a, 0x79, 0x53, 0x74, 0x61, 0x74, + 0x65, 0x12, 0x14, 0x0a, 0x10, 0x4c, 0x61, 0x7a, 0x79, 0x53, 0x74, 0x61, 0x74, 0x65, 0x44, 0x65, + 0x66, 0x61, 0x75, 0x6c, 0x74, 0x10, 0x00, 0x12, 0x11, 0x0a, 0x0d, 0x4c, 0x61, 0x7a, 0x79, 0x53, + 0x74, 0x61, 0x74, 0x65, 0x4c, 0x61, 0x7a, 0x79, 0x10, 0x01, 0x12, 0x12, 0x0a, 0x0e, 0x4c, 0x61, + 0x7a, 0x79, 0x53, 0x74, 0x61, 0x74, 0x65, 0x45, 0x61, 0x67, 0x65, 0x72, 0x10, 0x02, 0x2a, 0x5d, + 0x0a, 0x0c, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x0b, + 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x41, + 0x4c, 0x4c, 0x10, 0x01, 0x12, 0x07, 0x0a, 0x03, 0x54, 0x43, 0x50, 0x10, 0x02, 0x12, 0x07, 0x0a, + 0x03, 0x55, 0x44, 0x50, 0x10, 0x03, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x43, 0x4d, 0x50, 0x10, 0x04, + 0x12, 0x0a, 0x0a, 0x06, 0x43, 0x55, 0x53, 0x54, 0x4f, 0x4d, 0x10, 0x05, 0x12, 0x0f, 0x0a, 0x0b, + 0x4e, 0x45, 0x54, 0x42, 0x49, 0x52, 0x44, 0x5f, 0x53, 0x53, 0x48, 0x10, 0x06, 0x2a, 0x20, 0x0a, + 0x0d, 0x52, 0x75, 0x6c, 0x65, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x06, + 0x0a, 0x02, 0x49, 0x4e, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x4f, 0x55, 0x54, 0x10, 0x01, 0x2a, + 0x22, 0x0a, 0x0a, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x0a, 0x0a, + 0x06, 0x41, 0x43, 0x43, 0x45, 0x50, 0x54, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x44, 0x52, 0x4f, + 0x50, 0x10, 0x01, 0x2a, 0x63, 0x0a, 0x0e, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x50, 0x72, 0x6f, + 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x0f, 0x0a, 0x0b, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, + 0x48, 0x54, 0x54, 0x50, 0x10, 0x00, 0x12, 0x10, 0x0a, 0x0c, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, + 0x5f, 0x48, 0x54, 0x54, 0x50, 0x53, 0x10, 0x01, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, + 0x53, 0x45, 0x5f, 0x54, 0x43, 0x50, 0x10, 0x02, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, + 0x53, 0x45, 0x5f, 0x55, 0x44, 0x50, 0x10, 0x03, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, + 0x53, 0x45, 0x5f, 0x54, 0x4c, 0x53, 0x10, 0x04, 0x32, 0xd0, 0x07, 0x0a, 0x11, 0x4d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x45, + 0x0a, 0x05, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, - 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x58, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x50, 0x4b, 0x43, 0x45, - 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, - 0x77, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, - 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, - 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, - 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, - 0x3d, 0x0a, 0x08, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1c, 0x2e, 0x6d, 0x61, + 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x46, 0x0a, 0x04, 0x53, 0x79, 0x6e, 0x63, 0x12, 0x1c, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, + 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, - 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x3b, - 0x0a, 0x06, 0x4c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x30, 0x01, 0x12, 0x42, 0x0a, + 0x0c, 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x12, 0x11, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, + 0x1a, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, + 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, + 0x00, 0x12, 0x33, 0x0a, 0x09, 0x69, 0x73, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x79, 0x12, 0x11, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, + 0x79, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, + 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x5a, 0x0a, 0x1a, 0x47, 0x65, 0x74, 0x44, 0x65, 0x76, + 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x22, 0x00, 0x12, 0x58, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74, + 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x1c, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, + 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, + 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x3d, 0x0a, 0x08, + 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x47, 0x0a, 0x03, 0x4a, - 0x6f, 0x62, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, - 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, - 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, - 0x28, 0x01, 0x30, 0x01, 0x12, 0x51, 0x0a, 0x11, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x64, 0x41, 0x75, - 0x74, 0x68, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, - 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, - 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x4c, 0x0a, 0x0c, 0x43, 0x72, 0x65, 0x61, 0x74, - 0x65, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, - 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x3b, 0x0a, 0x06, 0x4c, + 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, - 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x4b, 0x0a, 0x0b, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, + 0x61, 0x67, 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x47, 0x0a, 0x03, 0x4a, 0x6f, 0x62, 0x12, + 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, + 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, + 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x28, 0x01, 0x30, + 0x01, 0x12, 0x51, 0x0a, 0x11, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x64, 0x41, 0x75, 0x74, 0x68, 0x53, + 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x22, 0x00, 0x12, 0x4c, 0x0a, 0x0c, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, - 0x22, 0x00, 0x12, 0x4a, 0x0a, 0x0a, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, - 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, - 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, - 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x42, 0x08, - 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x22, 0x00, 0x12, 0x4b, 0x0a, 0x0b, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, + 0x65, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, + 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, + 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, + 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, + 0x4a, 0x0a, 0x0a, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x12, 0x1c, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, + 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, + 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x42, 0x08, 0x5a, 0x06, 0x2f, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -8149,45 +8168,46 @@ var file_management_proto_depIdxs = []int32{ 91, // 97: management.PolicyCompact.authorized_groups:type_name -> management.PolicyCompact.AuthorizedGroupsEntry 73, // 98: management.PolicyCompact.source_resource:type_name -> management.ResourceCompact 73, // 99: management.PolicyCompact.destination_resource:type_name -> management.ResourceCompact - 52, // 100: management.NameServerGroupRaw.nameservers:type_name -> management.NameServer - 81, // 101: management.NetworkRouterList.entries:type_name -> management.NetworkRouterEntry - 39, // 102: management.SSHAuth.MachineUsersEntry.value:type_name -> management.MachineUserIndexes - 80, // 103: management.NetworkMapComponentsFull.RoutersMapEntry.value:type_name -> management.NetworkRouterList - 82, // 104: management.NetworkMapComponentsFull.ResourcePoliciesMapEntry.value:type_name -> management.PolicyIds - 83, // 105: management.NetworkMapComponentsFull.GroupIdToUserIdsEntry.value:type_name -> management.UserIDList - 84, // 106: management.NetworkMapComponentsFull.PostureFailedPeersEntry.value:type_name -> management.PeerIndexSet - 74, // 107: management.PolicyCompact.AuthorizedGroupsEntry.value:type_name -> management.UserNameList - 9, // 108: management.ManagementService.Login:input_type -> management.EncryptedMessage - 9, // 109: management.ManagementService.Sync:input_type -> management.EncryptedMessage - 27, // 110: management.ManagementService.GetServerKey:input_type -> management.Empty - 27, // 111: management.ManagementService.isHealthy:input_type -> management.Empty - 9, // 112: management.ManagementService.GetDeviceAuthorizationFlow:input_type -> management.EncryptedMessage - 9, // 113: management.ManagementService.GetPKCEAuthorizationFlow:input_type -> management.EncryptedMessage - 9, // 114: management.ManagementService.SyncMeta:input_type -> management.EncryptedMessage - 9, // 115: management.ManagementService.Logout:input_type -> management.EncryptedMessage - 9, // 116: management.ManagementService.Job:input_type -> management.EncryptedMessage - 9, // 117: management.ManagementService.ExtendAuthSession:input_type -> management.EncryptedMessage - 9, // 118: management.ManagementService.CreateExpose:input_type -> management.EncryptedMessage - 9, // 119: management.ManagementService.RenewExpose:input_type -> management.EncryptedMessage - 9, // 120: management.ManagementService.StopExpose:input_type -> management.EncryptedMessage - 9, // 121: management.ManagementService.Login:output_type -> management.EncryptedMessage - 9, // 122: management.ManagementService.Sync:output_type -> management.EncryptedMessage - 26, // 123: management.ManagementService.GetServerKey:output_type -> management.ServerKeyResponse - 27, // 124: management.ManagementService.isHealthy:output_type -> management.Empty - 9, // 125: management.ManagementService.GetDeviceAuthorizationFlow:output_type -> management.EncryptedMessage - 9, // 126: management.ManagementService.GetPKCEAuthorizationFlow:output_type -> management.EncryptedMessage - 27, // 127: management.ManagementService.SyncMeta:output_type -> management.Empty - 27, // 128: management.ManagementService.Logout:output_type -> management.Empty - 9, // 129: management.ManagementService.Job:output_type -> management.EncryptedMessage - 9, // 130: management.ManagementService.ExtendAuthSession:output_type -> management.EncryptedMessage - 9, // 131: management.ManagementService.CreateExpose:output_type -> management.EncryptedMessage - 9, // 132: management.ManagementService.RenewExpose:output_type -> management.EncryptedMessage - 9, // 133: management.ManagementService.StopExpose:output_type -> management.EncryptedMessage - 121, // [121:134] is the sub-list for method output_type - 108, // [108:121] is the sub-list for method input_type - 108, // [108:108] is the sub-list for extension type_name - 108, // [108:108] is the sub-list for extension extendee - 0, // [0:108] is the sub-list for field type_name + 73, // 100: management.GroupCompact.resources:type_name -> management.ResourceCompact + 52, // 101: management.NameServerGroupRaw.nameservers:type_name -> management.NameServer + 81, // 102: management.NetworkRouterList.entries:type_name -> management.NetworkRouterEntry + 39, // 103: management.SSHAuth.MachineUsersEntry.value:type_name -> management.MachineUserIndexes + 80, // 104: management.NetworkMapComponentsFull.RoutersMapEntry.value:type_name -> management.NetworkRouterList + 82, // 105: management.NetworkMapComponentsFull.ResourcePoliciesMapEntry.value:type_name -> management.PolicyIds + 83, // 106: management.NetworkMapComponentsFull.GroupIdToUserIdsEntry.value:type_name -> management.UserIDList + 84, // 107: management.NetworkMapComponentsFull.PostureFailedPeersEntry.value:type_name -> management.PeerIndexSet + 74, // 108: management.PolicyCompact.AuthorizedGroupsEntry.value:type_name -> management.UserNameList + 9, // 109: management.ManagementService.Login:input_type -> management.EncryptedMessage + 9, // 110: management.ManagementService.Sync:input_type -> management.EncryptedMessage + 27, // 111: management.ManagementService.GetServerKey:input_type -> management.Empty + 27, // 112: management.ManagementService.isHealthy:input_type -> management.Empty + 9, // 113: management.ManagementService.GetDeviceAuthorizationFlow:input_type -> management.EncryptedMessage + 9, // 114: management.ManagementService.GetPKCEAuthorizationFlow:input_type -> management.EncryptedMessage + 9, // 115: management.ManagementService.SyncMeta:input_type -> management.EncryptedMessage + 9, // 116: management.ManagementService.Logout:input_type -> management.EncryptedMessage + 9, // 117: management.ManagementService.Job:input_type -> management.EncryptedMessage + 9, // 118: management.ManagementService.ExtendAuthSession:input_type -> management.EncryptedMessage + 9, // 119: management.ManagementService.CreateExpose:input_type -> management.EncryptedMessage + 9, // 120: management.ManagementService.RenewExpose:input_type -> management.EncryptedMessage + 9, // 121: management.ManagementService.StopExpose:input_type -> management.EncryptedMessage + 9, // 122: management.ManagementService.Login:output_type -> management.EncryptedMessage + 9, // 123: management.ManagementService.Sync:output_type -> management.EncryptedMessage + 26, // 124: management.ManagementService.GetServerKey:output_type -> management.ServerKeyResponse + 27, // 125: management.ManagementService.isHealthy:output_type -> management.Empty + 9, // 126: management.ManagementService.GetDeviceAuthorizationFlow:output_type -> management.EncryptedMessage + 9, // 127: management.ManagementService.GetPKCEAuthorizationFlow:output_type -> management.EncryptedMessage + 27, // 128: management.ManagementService.SyncMeta:output_type -> management.Empty + 27, // 129: management.ManagementService.Logout:output_type -> management.Empty + 9, // 130: management.ManagementService.Job:output_type -> management.EncryptedMessage + 9, // 131: management.ManagementService.ExtendAuthSession:output_type -> management.EncryptedMessage + 9, // 132: management.ManagementService.CreateExpose:output_type -> management.EncryptedMessage + 9, // 133: management.ManagementService.RenewExpose:output_type -> management.EncryptedMessage + 9, // 134: management.ManagementService.StopExpose:output_type -> management.EncryptedMessage + 122, // [122:135] is the sub-list for method output_type + 109, // [109:122] is the sub-list for method input_type + 109, // [109:109] is the sub-list for extension type_name + 109, // [109:109] is the sub-list for extension extendee + 0, // [0:109] is the sub-list for field type_name } func init() { file_management_proto_init() } diff --git a/shared/management/proto/management.proto b/shared/management/proto/management.proto index 355fc1ed7..24ff5bf37 100644 --- a/shared/management/proto/management.proto +++ b/shared/management/proto/management.proto @@ -1094,13 +1094,12 @@ message PolicyCompact { // ResourceCompact mirrors types.Resource. Used by PolicyCompact to carry // rule.SourceResource / rule.DestinationResource when the rule targets a // specific resource (typically a peer) rather than groups. -// peer_index_set tells whether peer_index is valid (proto3 uint32 cannot -// disambiguate "0" from "unset"); set only when type == "peer". message ResourceCompact { string type = 1; bool peer_index_set = 2; uint32 peer_index = 3; - reserved 4; // future: host/subnet/domain references when needed + reserved 4; + string id = 5; // public id for domain/host/subnet resources } // UserNameList is a list of local-user names — used as the value type in @@ -1124,6 +1123,8 @@ message GroupCompact { // groups lose that property and the two sides expand policy // destinations differently. bool is_all = 3; + + repeated ResourceCompact resources = 4; } // DNSSettingsCompact mirrors types.DNSSettings. diff --git a/shared/management/types/firewall_helpers.go b/shared/management/types/firewall_helpers.go index 6e43af33e..9357d24a9 100644 --- a/shared/management/types/firewall_helpers.go +++ b/shared/management/types/firewall_helpers.go @@ -3,6 +3,7 @@ package types import ( "strconv" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" "github.com/netbirdio/netbird/version" ) @@ -23,31 +24,9 @@ type supportedFeatures struct { type LookupMap map[string]struct{} -func PolicyRuleImpliesLegacySSH(rule *PolicyRule) bool { - return rule.Protocol == PolicyRuleProtocolALL || (rule.Protocol == PolicyRuleProtocolTCP && (portsIncludesSSH(rule.Ports) || portRangeIncludesSSH(rule.PortRanges))) -} - -func portRangeIncludesSSH(portRanges []RulePortRange) bool { - for _, pr := range portRanges { - if (pr.Start <= defaultSSHPortNumber && pr.End >= defaultSSHPortNumber) || (pr.Start <= nativeSSHPortNumber && pr.End >= nativeSSHPortNumber) { - return true - } - } - return false -} - -func portsIncludesSSH(ports []string) bool { - for _, port := range ports { - if port == defaultSSHPortString || port == nativeSSHPortString { - return true - } - } - return false -} - // ExpandPortsAndRanges expands Ports and PortRanges of a rule into individual firewall rules. -func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *ComponentPeer) []*FirewallRule { - features := peerSupportedFirewallFeatures(peer.AgentVersion) +func ExpandPortsAndRanges(base FirewallRule, rule *nmdata.PolicyRule, peer *nmdata.Peer) []*FirewallRule { + features := peerSupportedFirewallFeatures(peer.Meta.WtVersion) var expanded []*FirewallRule @@ -64,7 +43,7 @@ func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *ComponentPe fr := base if features.portRanges { - fr.PortRange = portRange + fr.PortRange = RulePortRange{Start: portRange.Start, End: portRange.End} } else { if portRange.Start != portRange.End { continue @@ -74,7 +53,7 @@ func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *ComponentPe expanded = append(expanded, &fr) } - if shouldCheckRulesForNativeSSH(features.nativeSSH, rule, peer) || rule.Protocol == PolicyRuleProtocolNetbirdSSH { + if shouldCheckRulesForNativeSSH(features.nativeSSH, rule, peer) || rule.Protocol == string(PolicyRuleProtocolNetbirdSSH) { expanded = addNativeSSHRule(base, expanded) } @@ -104,8 +83,8 @@ func isPortInRule(portString string, portInt uint16, rule *FirewallRule) bool { return rule.Port == portString || (rule.PortRange.Start <= portInt && portInt <= rule.PortRange.End) } -func shouldCheckRulesForNativeSSH(supportsNative bool, rule *PolicyRule, peer *ComponentPeer) bool { - return supportsNative && peer.SSHEnabled && peer.ServerSSHAllowed && rule.Protocol == PolicyRuleProtocolTCP +func shouldCheckRulesForNativeSSH(supportsNative bool, rule *nmdata.PolicyRule, peer *nmdata.Peer) bool { + return supportsNative && peer.SSHEnabled && peer.Meta.Flags.ServerSSHAllowed && rule.Protocol == string(PolicyRuleProtocolTCP) } func peerSupportedFirewallFeatures(peerVer string) supportedFeatures { diff --git a/shared/management/types/firewall_rule.go b/shared/management/types/firewall_rule.go index 67cb581a2..2efedf625 100644 --- a/shared/management/types/firewall_rule.go +++ b/shared/management/types/firewall_rule.go @@ -10,6 +10,7 @@ import ( log "github.com/sirupsen/logrus" nbroute "github.com/netbirdio/netbird/route" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" ) const ( @@ -50,7 +51,7 @@ func (r *FirewallRule) Equal(other *FirewallRule) bool { // For static routes, source ranges match the destination family (v4 or v6). // For dynamic routes (domain-based), separate v4 and v6 rules are generated // so the routing peer's forwarding chain allows both address families. -func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*ComponentPeer, direction int, includeIPv6 bool) []*RouteFirewallRule { +func GenerateRouteFirewallRules(ctx context.Context, route *nmdata.Route, rule *nmdata.PolicyRule, groupPeers []*nmdata.Peer, direction int, includeIPv6 bool) []*RouteFirewallRule { rulesExists := make(map[string]struct{}) rules := make([]*RouteFirewallRule, 0) @@ -71,11 +72,11 @@ func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule baseRule := RouteFirewallRule{ PolicyID: rule.PolicyID, - RouteID: route.ID, + RouteID: nbroute.ID(route.ID), SourceRanges: sourceRanges, - Action: string(rule.Action), + Action: rule.Action, Destination: route.Network.String(), - Protocol: string(rule.Protocol), + Protocol: rule.Protocol, Domains: route.Domains, IsDynamic: route.IsDynamic(), } @@ -93,7 +94,7 @@ func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule v6Rule.SourceRanges = v6Sources if isDefaultV4 { v6Rule.Destination = "::/0" - v6Rule.RouteID = route.ID + "-v6-default" + v6Rule.RouteID = nbroute.ID(route.ID + "-v6-default") } if len(rule.Ports) == 0 { rules = append(rules, generateRulesWithPortRanges(v6Rule, rule, rulesExists)...) @@ -106,7 +107,7 @@ func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule } // splitPeerSourcesByFamily separates peer IPs into v4 (/32) and v6 (/128) source ranges. -func splitPeerSourcesByFamily(groupPeers []*ComponentPeer) (v4, v6 []string) { +func splitPeerSourcesByFamily(groupPeers []*nmdata.Peer) (v4, v6 []string) { v4 = make([]string, 0, len(groupPeers)) v6 = make([]string, 0, len(groupPeers)) for _, peer := range groupPeers { @@ -122,7 +123,7 @@ func splitPeerSourcesByFamily(groupPeers []*ComponentPeer) (v4, v6 []string) { } // generateRulesForPeer generates rules for a given peer based on ports and port ranges. -func generateRulesWithPortRanges(baseRule RouteFirewallRule, rule *PolicyRule, rulesExists map[string]struct{}) []*RouteFirewallRule { +func generateRulesWithPortRanges(baseRule RouteFirewallRule, rule *nmdata.PolicyRule, rulesExists map[string]struct{}) []*RouteFirewallRule { rules := make([]*RouteFirewallRule, 0) ruleIDBase := generateRuleIDBase(rule, baseRule) @@ -138,7 +139,7 @@ func generateRulesWithPortRanges(baseRule RouteFirewallRule, rule *PolicyRule, r if _, ok := rulesExists[ruleID]; !ok { rulesExists[ruleID] = struct{}{} pr := baseRule - pr.PortRange = portRange + pr.PortRange = RulePortRange{Start: portRange.Start, End: portRange.End} rules = append(rules, &pr) } } @@ -150,7 +151,7 @@ func generateRulesWithPortRanges(baseRule RouteFirewallRule, rule *PolicyRule, r } // generateRulesWithPorts generates rules when specific ports are provided. -func generateRulesWithPorts(ctx context.Context, baseRule RouteFirewallRule, rule *PolicyRule, rulesExists map[string]struct{}) []*RouteFirewallRule { +func generateRulesWithPorts(ctx context.Context, baseRule RouteFirewallRule, rule *nmdata.PolicyRule, rulesExists map[string]struct{}) []*RouteFirewallRule { rules := make([]*RouteFirewallRule, 0) ruleIDBase := generateRuleIDBase(rule, baseRule) @@ -176,6 +177,6 @@ func generateRulesWithPorts(ctx context.Context, baseRule RouteFirewallRule, rul } // generateRuleIDBase generates the base rule ID for checking duplicates. -func generateRuleIDBase(rule *PolicyRule, baseRule RouteFirewallRule) string { +func generateRuleIDBase(rule *nmdata.PolicyRule, baseRule RouteFirewallRule) string { return rule.ID + strings.Join(baseRule.SourceRanges, ",") + strconv.Itoa(FirewallRuleDirectionIN) + baseRule.Protocol + baseRule.Action } diff --git a/shared/management/types/firewall_rule_test.go b/shared/management/types/firewall_rule_test.go index c21cfa2df..96fef3bd9 100644 --- a/shared/management/types/firewall_rule_test.go +++ b/shared/management/types/firewall_rule_test.go @@ -8,12 +8,12 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/domain" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" ) func TestSplitPeerSourcesByFamily(t *testing.T) { - peers := []*ComponentPeer{ + peers := []*nmdata.Peer{ { IP: netip.MustParseAddr("100.64.0.1"), IPv6: netip.MustParseAddr("fd00::1"), @@ -35,7 +35,7 @@ func TestSplitPeerSourcesByFamily(t *testing.T) { } func TestGenerateRouteFirewallRules_V4Route(t *testing.T) { - peers := []*ComponentPeer{ + peers := []*nmdata.Peer{ { IP: netip.MustParseAddr("100.64.0.1"), IPv6: netip.MustParseAddr("fd00::1"), @@ -45,15 +45,15 @@ func TestGenerateRouteFirewallRules_V4Route(t *testing.T) { }, } - r := &route.Route{ + r := &nmdata.Route{ ID: "route1", Network: netip.MustParsePrefix("10.0.0.0/24"), } - rule := &PolicyRule{ + rule := &nmdata.PolicyRule{ PolicyID: "policy1", ID: "rule1", - Action: PolicyTrafficActionAccept, - Protocol: PolicyRuleProtocolALL, + Action: string(PolicyTrafficActionAccept), + Protocol: string(PolicyRuleProtocolALL), } rules := GenerateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, true) @@ -64,7 +64,7 @@ func TestGenerateRouteFirewallRules_V4Route(t *testing.T) { } func TestGenerateRouteFirewallRules_V6Route(t *testing.T) { - peers := []*ComponentPeer{ + peers := []*nmdata.Peer{ { IP: netip.MustParseAddr("100.64.0.1"), IPv6: netip.MustParseAddr("fd00::1"), @@ -74,15 +74,15 @@ func TestGenerateRouteFirewallRules_V6Route(t *testing.T) { }, } - r := &route.Route{ + r := &nmdata.Route{ ID: "route1", Network: netip.MustParsePrefix("2001:db8::/32"), } - rule := &PolicyRule{ + rule := &nmdata.PolicyRule{ PolicyID: "policy1", ID: "rule1", - Action: PolicyTrafficActionAccept, - Protocol: PolicyRuleProtocolALL, + Action: string(PolicyTrafficActionAccept), + Protocol: string(PolicyRuleProtocolALL), } rules := GenerateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, true) @@ -92,7 +92,7 @@ func TestGenerateRouteFirewallRules_V6Route(t *testing.T) { } func TestGenerateRouteFirewallRules_DynamicRoute_DualStack(t *testing.T) { - peers := []*ComponentPeer{ + peers := []*nmdata.Peer{ { IP: netip.MustParseAddr("100.64.0.1"), IPv6: netip.MustParseAddr("fd00::1"), @@ -102,16 +102,16 @@ func TestGenerateRouteFirewallRules_DynamicRoute_DualStack(t *testing.T) { }, } - r := &route.Route{ + r := &nmdata.Route{ ID: "route1", - NetworkType: route.DomainNetwork, + NetworkType: nmdata.NetworkTypeDomain, Domains: domain.List{"example.com"}, } - rule := &PolicyRule{ + rule := &nmdata.PolicyRule{ PolicyID: "policy1", ID: "rule1", - Action: PolicyTrafficActionAccept, - Protocol: PolicyRuleProtocolALL, + Action: string(PolicyTrafficActionAccept), + Protocol: string(PolicyRuleProtocolALL), } rules := GenerateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, true) @@ -125,21 +125,21 @@ func TestGenerateRouteFirewallRules_DynamicRoute_DualStack(t *testing.T) { } func TestGenerateRouteFirewallRules_DynamicRoute_NoV6Peers(t *testing.T) { - peers := []*ComponentPeer{ + peers := []*nmdata.Peer{ {IP: netip.MustParseAddr("100.64.0.1")}, {IP: netip.MustParseAddr("100.64.0.2")}, } - r := &route.Route{ + r := &nmdata.Route{ ID: "route1", - NetworkType: route.DomainNetwork, + NetworkType: nmdata.NetworkTypeDomain, Domains: domain.List{"example.com"}, } - rule := &PolicyRule{ + rule := &nmdata.PolicyRule{ PolicyID: "policy1", ID: "rule1", - Action: PolicyTrafficActionAccept, - Protocol: PolicyRuleProtocolALL, + Action: string(PolicyTrafficActionAccept), + Protocol: string(PolicyRuleProtocolALL), } rules := GenerateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, true) @@ -149,7 +149,7 @@ func TestGenerateRouteFirewallRules_DynamicRoute_NoV6Peers(t *testing.T) { } func TestGenerateRouteFirewallRules_IncludeIPv6False(t *testing.T) { - peers := []*ComponentPeer{ + peers := []*nmdata.Peer{ { IP: netip.MustParseAddr("100.64.0.1"), IPv6: netip.MustParseAddr("fd00::1"), @@ -161,15 +161,15 @@ func TestGenerateRouteFirewallRules_IncludeIPv6False(t *testing.T) { } t.Run("v6 route excluded", func(t *testing.T) { - r := &route.Route{ + r := &nmdata.Route{ ID: "route1", Network: netip.MustParsePrefix("2001:db8::/32"), } - rule := &PolicyRule{ + rule := &nmdata.PolicyRule{ PolicyID: "policy1", ID: "rule1", - Action: PolicyTrafficActionAccept, - Protocol: PolicyRuleProtocolALL, + Action: string(PolicyTrafficActionAccept), + Protocol: string(PolicyRuleProtocolALL), } rules := GenerateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, false) @@ -177,16 +177,16 @@ func TestGenerateRouteFirewallRules_IncludeIPv6False(t *testing.T) { }) t.Run("dynamic route only v4", func(t *testing.T) { - r := &route.Route{ + r := &nmdata.Route{ ID: "route1", - NetworkType: route.DomainNetwork, + NetworkType: nmdata.NetworkTypeDomain, Domains: domain.List{"example.com"}, } - rule := &PolicyRule{ + rule := &nmdata.PolicyRule{ PolicyID: "policy1", ID: "rule1", - Action: PolicyTrafficActionAccept, - Protocol: PolicyRuleProtocolALL, + Action: string(PolicyTrafficActionAccept), + Protocol: string(PolicyRuleProtocolALL), } rules := GenerateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, false) diff --git a/shared/management/types/network.go b/shared/management/types/network.go index 34ce60436..1269bac4c 100644 --- a/shared/management/types/network.go +++ b/shared/management/types/network.go @@ -1,47 +1,28 @@ package types import ( - "encoding/binary" - "fmt" - "math/rand" "net" - "net/netip" - "slices" - "sync" - "time" - "github.com/c-robinson/iplib" - "github.com/rs/xid" "golang.org/x/exp/maps" nbdns "github.com/netbirdio/netbird/dns" - "github.com/netbirdio/netbird/route" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" "github.com/netbirdio/netbird/shared/management/proto" - "github.com/netbirdio/netbird/shared/management/status" ) const ( - // SubnetSize is a size of the subnet of the global network, e.g. 100.77.0.0/16 - SubnetSize = 16 - // NetSize is a global network size 100.64.0.0/10 - NetSize = 10 - // AllowedIPsFormat generates Wireguard AllowedIPs format (e.g. 100.64.30.1/32) AllowedIPsFormat = "%s/32" // AllowedIPsV6Format generates AllowedIPs format for v6 (e.g. fd12:3456:7890::1/128) AllowedIPsV6Format = "%s/128" - - // IPv6SubnetSize is the prefix length of per-account IPv6 subnets. - // Each account gets a /64 from its unique /48 ULA prefix. - IPv6SubnetSize = 64 ) type NetworkMap struct { - Peers []*ComponentPeer - Network *Network - Routes []*route.Route + Peers []*nmdata.Peer + Network *nmdata.Network + Routes []*nmdata.Route DNSConfig nbdns.Config - OfflinePeers []*ComponentPeer + OfflinePeers []*nmdata.Peer FirewallRules []*FirewallRule RoutesFirewallRules []*RouteFirewallRule ForwardingRules []*ForwardingRule @@ -63,39 +44,8 @@ func (nm *NetworkMap) Merge(other *NetworkMap) { nm.ForceRoutingPeerDNSResolution = nm.ForceRoutingPeerDNSResolution || other.ForceRoutingPeerDNSResolution } -type comparableObject[T any] interface { - Equal(other T) bool -} - -func mergeUnique[T comparableObject[T]](arr1, arr2 []T) []T { - var result []T - - for _, item := range arr1 { - if !containsEqual(result, item) { - result = append(result, item) - } - } - - for _, item := range arr2 { - if !containsEqual(result, item) { - result = append(result, item) - } - } - - return result -} - -func containsEqual[T comparableObject[T]](slice []T, element T) bool { - for _, item := range slice { - if item.Equal(element) { - return true - } - } - return false -} - -func mergeUniquePeersByID(peers1, peers2 []*ComponentPeer) []*ComponentPeer { - result := make(map[string]*ComponentPeer) +func mergeUniquePeersByID(peers1, peers2 []*nmdata.Peer) []*nmdata.Peer { + result := make(map[string]*nmdata.Peer) for _, peer := range peers1 { result[peer.ID] = peer } @@ -151,245 +101,33 @@ func ipToBytes(ip net.IP) []byte { return ip.To16() } -type Network struct { - Identifier string `json:"id"` - Net net.IPNet `gorm:"serializer:json"` - // NetV6 is the IPv6 ULA subnet for this account's overlay. Empty if not yet allocated. - NetV6 net.IPNet `gorm:"serializer:json"` - Dns string - // Serial is an ID that increments by 1 when any change to the network happened (e.g. new peer has been added). - // Used to synchronize state to the client apps. - Serial uint64 - - Mu sync.Mutex `json:"-" gorm:"-"` +type comparableObject[T any] interface { + Equal(other T) bool } -// NewNetwork creates a new Network initializing it with a Serial=0 -// It takes a random /16 subnet from 100.64.0.0/10 (64 different subnets) -// and a random /64 subnet from fd00:4e42::/32 for IPv6. -func NewNetwork() *Network { - n := iplib.NewNet4(net.ParseIP("100.64.0.0"), NetSize) - sub, _ := n.Subnet(SubnetSize) +func mergeUnique[T comparableObject[T]](arr1, arr2 []T) []T { + var result []T - s := rand.NewSource(time.Now().UnixNano()) - r := rand.New(s) - intn := r.Intn(len(sub)) - - return &Network{ - Identifier: xid.New().String(), - Net: sub[intn].IPNet, - NetV6: AllocateIPv6Subnet(r), - Dns: "", - Serial: 0, - } -} - -// AllocateIPv6Subnet generates a random RFC 4193 ULA /64 prefix. -// The format follows RFC 4193 section 3.1: fd + 40-bit Global ID + 16-bit Subnet ID. -// The Global ID and Subnet ID are randomized (simplified from the SHA-1 algorithm -// in section 3.2.2), giving 2^56 possible /64 subnets across all accounts. -func AllocateIPv6Subnet(r *rand.Rand) net.IPNet { - ip := make(net.IP, 16) - ip[0] = 0xfd - // Bytes 1-5: 40-bit random Global ID - ip[1] = byte(r.Intn(256)) - ip[2] = byte(r.Intn(256)) - ip[3] = byte(r.Intn(256)) - ip[4] = byte(r.Intn(256)) - ip[5] = byte(r.Intn(256)) - // Bytes 6-7: 16-bit random Subnet ID - ip[6] = byte(r.Intn(256)) - ip[7] = byte(r.Intn(256)) - - return net.IPNet{ - IP: ip, - Mask: net.CIDRMask(IPv6SubnetSize, 128), - } -} - -// IncSerial increments Serial by 1 reflecting that the network state has been changed -func (n *Network) IncSerial() { - n.Mu.Lock() - defer n.Mu.Unlock() - n.Serial++ -} - -// CurrentSerial returns the Network.Serial of the network (latest state id) -func (n *Network) CurrentSerial() uint64 { - n.Mu.Lock() - defer n.Mu.Unlock() - return n.Serial -} - -func (n *Network) Copy() *Network { - n.Mu.Lock() - defer n.Mu.Unlock() - return &Network{ - Identifier: n.Identifier, - Net: n.Net, - NetV6: n.NetV6, - Dns: n.Dns, - Serial: n.Serial, - } -} - -// AllocatePeerIP picks an available IP from a netip.Prefix. -// This method considers already taken IPs and reuses IPs if there are gaps in takenIps. -// E.g. if prefix=100.30.0.0/16 and takenIps=[100.30.0.1, 100.30.0.4] then the result would be 100.30.0.2 or 100.30.0.3. -func AllocatePeerIP(prefix netip.Prefix, takenIps []netip.Addr) (netip.Addr, error) { - b := prefix.Masked().Addr().As4() - baseIP := binary.BigEndian.Uint32(b[:]) - hostBits := 32 - prefix.Bits() - totalIPs := uint32(1 << hostBits) - - taken := make(map[uint32]struct{}, len(takenIps)+1) - taken[baseIP] = struct{}{} // reserve network IP - taken[baseIP+totalIPs-1] = struct{}{} // reserve broadcast IP - - for _, ip := range takenIps { - ab := ip.As4() - taken[binary.BigEndian.Uint32(ab[:])] = struct{}{} - } - - rng := rand.New(rand.NewSource(time.Now().UnixNano())) - maxAttempts := (int(totalIPs) - len(taken)) / 100 - - for i := 0; i < maxAttempts; i++ { - offset := uint32(rng.Intn(int(totalIPs-2))) + 1 - candidate := baseIP + offset - if _, exists := taken[candidate]; !exists { - return uint32ToIP(candidate), nil + for _, item := range arr1 { + if !containsEqual(result, item) { + result = append(result, item) } } - for offset := uint32(1); offset < totalIPs-1; offset++ { - candidate := baseIP + offset - if _, exists := taken[candidate]; !exists { - return uint32ToIP(candidate), nil + for _, item := range arr2 { + if !containsEqual(result, item) { + result = append(result, item) } } - return netip.Addr{}, status.Errorf(status.PreconditionFailed, "network %s is out of IPs", prefix.String()) + return result } -// AllocateRandomPeerIP picks a random available IP from a netip.Prefix. -func AllocateRandomPeerIP(prefix netip.Prefix) (netip.Addr, error) { - b := prefix.Masked().Addr().As4() - baseIP := binary.BigEndian.Uint32(b[:]) - hostBits := 32 - prefix.Bits() - totalIPs := uint32(1 << hostBits) - - rng := rand.New(rand.NewSource(time.Now().UnixNano())) - offset := uint32(rng.Intn(int(totalIPs-2))) + 1 - - candidate := baseIP + offset - return uint32ToIP(candidate), nil -} - -// AllocateRandomPeerIPv6 picks a random host address within the given IPv6 prefix. -// Only the host bits (after the prefix length) are randomized. -func AllocateRandomPeerIPv6(prefix netip.Prefix) (netip.Addr, error) { - ones := prefix.Bits() - if ones == 0 || ones > 126 || !prefix.Addr().Is6() { - return netip.Addr{}, fmt.Errorf("invalid IPv6 subnet: %s", prefix.String()) - } - - ip := prefix.Addr().As16() - - rng := rand.New(rand.NewSource(time.Now().UnixNano())) - - // Determine which byte the host bits start in - firstHostByte := ones / 8 - // If the prefix doesn't end on a byte boundary, handle the partial byte - partialBits := ones % 8 - - if partialBits > 0 { - // Keep the network bits in the partial byte, randomize the rest - hostMask := byte(0xff >> partialBits) - ip[firstHostByte] = (ip[firstHostByte] & ^hostMask) | (byte(rng.Intn(256)) & hostMask) - firstHostByte++ - } - - // Randomize remaining full host bytes - for i := firstHostByte; i < 16; i++ { - ip[i] = byte(rng.Intn(256)) - } - - // Avoid all-zeros and all-ones host parts by checking only host bits. - if isHostAllZeroOrOnes(ip[:], ones) { - ip = prefix.Masked().Addr().As16() - ip[15] |= 0x01 - } - - return netip.AddrFrom16(ip).Unmap(), nil -} - -// isHostAllZeroOrOnes checks whether all host bits (after prefixLen) are zero or all ones. -func isHostAllZeroOrOnes(ip []byte, prefixLen int) bool { - hostStart := prefixLen / 8 - partialBits := prefixLen % 8 - - hostSlice := slices.Clone(ip[hostStart:]) - if partialBits > 0 { - hostSlice[0] &= 0xff >> partialBits - } - - allZero := !slices.ContainsFunc(hostSlice, func(v byte) bool { return v != 0 }) - if allZero { - return true - } - - // Build the all-ones mask for host bits - onesMask := make([]byte, len(hostSlice)) - for i := range onesMask { - onesMask[i] = 0xff - } - if partialBits > 0 { - onesMask[0] = 0xff >> partialBits - } - - return slices.Equal(hostSlice, onesMask) -} - -func uint32ToIP(n uint32) netip.Addr { - var b [4]byte - binary.BigEndian.PutUint32(b[:], n) - return netip.AddrFrom4(b) -} - -// generateIPs generates a list of all possible IPs of the given network excluding IPs specified in the exclusion list -func generateIPs(ipNet *net.IPNet, exclusions map[string]struct{}) ([]net.IP, int) { - - var ips []net.IP - for ip := ipNet.IP.Mask(ipNet.Mask); ipNet.Contains(ip); incIP(ip) { - if _, ok := exclusions[ip.String()]; !ok && ip[3] != 0 { - ips = append(ips, copyIP(ip)) - } - } - - // remove network address, broadcast and Fake DNS resolver address - lenIPs := len(ips) - switch { - case lenIPs < 2: - return ips, lenIPs - case lenIPs < 3: - return ips[1 : len(ips)-1], lenIPs - 2 - default: - return ips[1 : len(ips)-2], lenIPs - 3 - } -} - -func copyIP(ip net.IP) net.IP { - dup := make(net.IP, len(ip)) - copy(dup, ip) - return dup -} - -func incIP(ip net.IP) { - for j := len(ip) - 1; j >= 0; j-- { - ip[j]++ - if ip[j] > 0 { - break +func containsEqual[T comparableObject[T]](slice []T, element T) bool { + for _, item := range slice { + if item.Equal(element) { + return true } } + return false } diff --git a/shared/management/types/network_merge_test.go b/shared/management/types/network_merge_test.go deleted file mode 100644 index a7ef24c1e..000000000 --- a/shared/management/types/network_merge_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package types - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -type testObject struct { - value int -} - -func (t testObject) Equal(other testObject) bool { - return t.value == other.value -} - -func Test_MergeUniqueArraysWithoutDuplicates(t *testing.T) { - arr1 := []testObject{{value: 1}, {value: 2}} - arr2 := []testObject{{value: 2}, {value: 3}} - result := mergeUnique(arr1, arr2) - assert.Len(t, result, 3) - assert.Contains(t, result, testObject{value: 1}) - assert.Contains(t, result, testObject{value: 2}) - assert.Contains(t, result, testObject{value: 3}) -} - -func Test_MergeUniqueHandlesEmptyArrays(t *testing.T) { - arr1 := []testObject{} - arr2 := []testObject{} - result := mergeUnique(arr1, arr2) - assert.Empty(t, result) -} - -func Test_MergeUniqueHandlesOneEmptyArray(t *testing.T) { - arr1 := []testObject{{value: 1}, {value: 2}} - arr2 := []testObject{} - result := mergeUnique(arr1, arr2) - assert.Len(t, result, 2) - assert.Contains(t, result, testObject{value: 1}) - assert.Contains(t, result, testObject{value: 2}) -} diff --git a/shared/management/types/network_test.go b/shared/management/types/network_test.go index d8a06dbbc..631f38836 100644 --- a/shared/management/types/network_test.go +++ b/shared/management/types/network_test.go @@ -1,264 +1,41 @@ package types import ( - "encoding/binary" - "net" - "net/netip" "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func TestNewNetwork(t *testing.T) { - network := NewNetwork() - - // generated net should be a subnet of a larger 100.64.0.0/10 net - ipNet := net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.IPMask{255, 192, 0, 0}} - assert.Equal(t, ipNet.Contains(network.Net.IP), true) +type mergeTestObject struct { + value int } -func TestAllocatePeerIP(t *testing.T) { - prefix := netip.MustParsePrefix("100.64.0.0/24") - var ips []netip.Addr - for i := 0; i < 252; i++ { - ip, err := AllocatePeerIP(prefix, ips) - if err != nil { - t.Fatal(err) - } - ips = append(ips, ip) - } - - assert.Len(t, ips, 252) - - uniq := make(map[string]struct{}) - for _, ip := range ips { - if _, ok := uniq[ip.String()]; !ok { - uniq[ip.String()] = struct{}{} - } else { - t.Errorf("found duplicate IP %s", ip.String()) - } - } +func (t mergeTestObject) Equal(other mergeTestObject) bool { + return t.value == other.value } -func TestAllocatePeerIPSmallSubnet(t *testing.T) { - // Test /27 network (10.0.0.0/27) - should only have 30 usable IPs (10.0.0.1 to 10.0.0.30) - prefix := netip.MustParsePrefix("10.0.0.0/27") - var ips []netip.Addr - - // Allocate all available IPs in the /27 network - for i := 0; i < 30; i++ { - ip, err := AllocatePeerIP(prefix, ips) - if err != nil { - t.Fatal(err) - } - - // Verify IP is within the correct range - if !prefix.Contains(ip) { - t.Errorf("allocated IP %s is not within network %s", ip.String(), prefix.String()) - } - - ips = append(ips, ip) - } - - assert.Len(t, ips, 30) - - // Verify all IPs are unique - uniq := make(map[string]struct{}) - for _, ip := range ips { - if _, ok := uniq[ip.String()]; !ok { - uniq[ip.String()] = struct{}{} - } else { - t.Errorf("found duplicate IP %s", ip.String()) - } - } - - // Try to allocate one more IP - should fail as network is full - _, err := AllocatePeerIP(prefix, ips) - if err == nil { - t.Error("expected error when network is full, but got none") - } +func Test_MergeUniqueArraysWithoutDuplicates(t *testing.T) { + arr1 := []mergeTestObject{{value: 1}, {value: 2}} + arr2 := []mergeTestObject{{value: 2}, {value: 3}} + result := mergeUnique(arr1, arr2) + assert.Len(t, result, 3) + assert.Contains(t, result, mergeTestObject{value: 1}) + assert.Contains(t, result, mergeTestObject{value: 2}) + assert.Contains(t, result, mergeTestObject{value: 3}) } -func TestAllocatePeerIPVariousCIDRs(t *testing.T) { - testCases := []struct { - name string - cidr string - expectedUsable int - }{ - {"/30 network", "192.168.1.0/30", 2}, // 4 total - 2 reserved = 2 usable - {"/29 network", "192.168.1.0/29", 6}, // 8 total - 2 reserved = 6 usable - {"/28 network", "192.168.1.0/28", 14}, // 16 total - 2 reserved = 14 usable - {"/27 network", "192.168.1.0/27", 30}, // 32 total - 2 reserved = 30 usable - {"/26 network", "192.168.1.0/26", 62}, // 64 total - 2 reserved = 62 usable - {"/25 network", "192.168.1.0/25", 126}, // 128 total - 2 reserved = 126 usable - {"/16 network", "10.0.0.0/16", 65534}, // 65536 total - 2 reserved = 65534 usable - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - prefix, err := netip.ParsePrefix(tc.cidr) - require.NoError(t, err) - prefix = prefix.Masked() - - var ips []netip.Addr - - // For larger networks, test only a subset to avoid long test runs - testCount := tc.expectedUsable - if testCount > 1000 { - testCount = 1000 - } - - // Allocate IPs and verify they're within the correct range - for i := 0; i < testCount; i++ { - ip, err := AllocatePeerIP(prefix, ips) - require.NoError(t, err, "failed to allocate IP %d", i) - - // Verify IP is within the correct range - assert.True(t, prefix.Contains(ip), "allocated IP %s is not within network %s", ip.String(), prefix.String()) - - // Verify IP is not network or broadcast address - networkAddr := prefix.Masked().Addr() - hostBits := 32 - prefix.Bits() - b := networkAddr.As4() - baseIP := binary.BigEndian.Uint32(b[:]) - broadcastIP := uint32ToIP(baseIP + (1 << hostBits) - 1) - - assert.NotEqual(t, networkAddr, ip, "allocated network address %s", ip.String()) - assert.NotEqual(t, broadcastIP, ip, "allocated broadcast address %s", ip.String()) - - ips = append(ips, ip) - } - - assert.Len(t, ips, testCount) - - // Verify all IPs are unique - uniq := make(map[string]struct{}) - for _, ip := range ips { - ipStr := ip.String() - assert.NotContains(t, uniq, ipStr, "found duplicate IP %s", ipStr) - uniq[ipStr] = struct{}{} - } - }) - } +func Test_MergeUniqueHandlesEmptyArrays(t *testing.T) { + arr1 := []mergeTestObject{} + arr2 := []mergeTestObject{} + result := mergeUnique(arr1, arr2) + assert.Empty(t, result) } -func TestGenerateIPs(t *testing.T) { - ipNet := net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.IPMask{255, 255, 255, 0}} - ips, ipsLen := generateIPs(&ipNet, map[string]struct{}{"100.64.0.0": {}}) - if ipsLen != 252 { - t.Errorf("expected 252 ips, got %d", len(ips)) - return - } - if ips[len(ips)-1].String() != "100.64.0.253" { - t.Errorf("expected last ip to be: 100.64.0.253, got %s", ips[len(ips)-1].String()) - } -} - -func TestNewNetworkHasIPv6(t *testing.T) { - network := NewNetwork() - - assert.NotNil(t, network.NetV6.IP, "v6 subnet should be allocated") - assert.True(t, network.NetV6.IP.To4() == nil, "v6 subnet should be IPv6") - assert.Equal(t, byte(0xfd), network.NetV6.IP[0], "v6 subnet should be ULA (fd prefix)") - - ones, bits := network.NetV6.Mask.Size() - assert.Equal(t, 64, ones, "v6 subnet should be /64") - assert.Equal(t, 128, bits) -} - -func TestAllocateIPv6SubnetUniqueness(t *testing.T) { - seen := make(map[string]struct{}) - for i := 0; i < 100; i++ { - network := NewNetwork() - key := network.NetV6.IP.String() - _, duplicate := seen[key] - assert.False(t, duplicate, "duplicate v6 subnet: %s", key) - seen[key] = struct{}{} - } -} - -func TestAllocateRandomPeerIPv6(t *testing.T) { - prefix := netip.MustParsePrefix("fd12:3456:7890:abcd::/64") - - ip, err := AllocateRandomPeerIPv6(prefix) - require.NoError(t, err) - - assert.True(t, ip.Is6(), "should be IPv6") - assert.True(t, prefix.Contains(ip), "should be within subnet") - // First 8 bytes (network prefix) should match - b := ip.As16() - prefixBytes := prefix.Addr().As16() - assert.Equal(t, prefixBytes[:8], b[:8], "prefix should match") - // Interface ID should not be all zeros - allZero := true - for _, v := range b[8:] { - if v != 0 { - allZero = false - break - } - } - assert.False(t, allZero, "interface ID should not be all zeros") -} - -func TestAllocateRandomPeerIPv6_VariousPrefixes(t *testing.T) { - tests := []struct { - name string - cidr string - prefix int - }{ - {"standard /64", "fd00:1234:5678:abcd::/64", 64}, - {"small /112", "fd00:1234:5678:abcd::/112", 112}, - {"large /48", "fd00:1234::/48", 48}, - {"non-boundary /60", "fd00:1234:5670::/60", 60}, - {"non-boundary /52", "fd00:1230::/52", 52}, - {"minimum /120", "fd00:1234:5678:abcd::100/120", 120}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - prefix, err := netip.ParsePrefix(tt.cidr) - require.NoError(t, err) - prefix = prefix.Masked() - - assert.Equal(t, tt.prefix, prefix.Bits()) - - for i := 0; i < 50; i++ { - ip, err := AllocateRandomPeerIPv6(prefix) - require.NoError(t, err) - assert.True(t, prefix.Contains(ip), "IP %s should be within %s", ip, prefix) - } - }) - } -} - -func TestAllocateRandomPeerIPv6_PreservesNetworkBits(t *testing.T) { - // For a /112, bytes 0-13 should be preserved, only bytes 14-15 should vary - prefix := netip.MustParsePrefix("fd00:1234:5678:abcd:ef01:2345:6789:0/112") - - prefixBytes := prefix.Addr().As16() - for i := 0; i < 20; i++ { - ip, err := AllocateRandomPeerIPv6(prefix) - require.NoError(t, err) - // First 14 bytes (112 bits = 14 bytes) must match the network - b := ip.As16() - assert.Equal(t, prefixBytes[:14], b[:14], "network bytes should be preserved for /112") - } -} - -func TestAllocateRandomPeerIPv6_NonByteBoundary(t *testing.T) { - // For a /60, the first 7.5 bytes are network, so byte 7 is partial - prefix := netip.MustParsePrefix("fd00:1234:5678:abc0::/60") - - prefixBytes := prefix.Addr().As16() - for i := 0; i < 50; i++ { - ip, err := AllocateRandomPeerIPv6(prefix) - require.NoError(t, err) - b := ip.As16() - assert.True(t, prefix.Contains(ip), "IP %s should be within %s", ip, prefix) - // First 7 bytes must match exactly - assert.Equal(t, prefixBytes[:7], b[:7], "full network bytes should match for /60") - // Byte 7: top 4 bits (0xc = 1100) must be preserved - assert.Equal(t, prefixBytes[7]&0xf0, b[7]&0xf0, "partial byte network bits should be preserved for /60") - } +func Test_MergeUniqueHandlesOneEmptyArray(t *testing.T) { + arr1 := []mergeTestObject{{value: 1}, {value: 2}} + arr2 := []mergeTestObject{} + result := mergeUnique(arr1, arr2) + assert.Len(t, result, 2) + assert.Contains(t, result, mergeTestObject{value: 1}) + assert.Contains(t, result, mergeTestObject{value: 2}) } diff --git a/shared/management/types/networkmap_components.go b/shared/management/types/networkmap_components.go index c4c437e4b..d008ece83 100644 --- a/shared/management/types/networkmap_components.go +++ b/shared/management/types/networkmap_components.go @@ -14,32 +14,33 @@ import ( nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/domain" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" ) type NetworkMapComponents struct { PeerID string - Network *Network - AccountSettings *AccountSettingsInfo - DNSSettings *DNSSettings + Network *nmdata.Network + AccountSettings *nmdata.AccountSettingsInfo + DNSSettings *nmdata.DNSSettings CustomZoneDomain string - Peers map[string]*ComponentPeer - Groups map[string]*ComponentGroup - Policies []*Policy - Routes []*route.Route - NameServerGroups []*nbdns.NameServerGroup - AllDNSRecords []nbdns.SimpleRecord - AccountZones []nbdns.CustomZone - ResourcePoliciesMap map[string][]*Policy - RoutersMap map[string]map[string]*ComponentRouter - NetworkResources []*ComponentResource + Peers map[string]*nmdata.Peer + Groups map[string]*nmdata.Group + Policies []*nmdata.Policy + Routes []*nmdata.Route + NameServerGroups []*nmdata.NameServerGroup + AllDNSRecords []nmdata.SimpleRecord + AccountZones []nmdata.CustomZone + ResourcePoliciesMap map[string][]*nmdata.Policy + RoutersMap map[string]map[string]*nmdata.NetworkRouter + NetworkResources []*nmdata.NetworkResource GroupIDToUserIDs map[string][]string AllowedUserIDs map[string]struct{} PostureFailedPeers map[string]map[string]struct{} - RouterPeers map[string]*ComponentPeer + RouterPeers map[string]*nmdata.Peer // NetworkXIDToPublicID maps Network.ID (xid) → PublicID. // Consumed by the envelope encoder to @@ -51,20 +52,21 @@ type NetworkMapComponents struct { // Same role as NetworkXIDToPublicID, used for PostureFailedPeers keys and // policy SourcePostureChecks references. PostureCheckXIDToPublicID map[string]string - routesByPeerOnce sync.Once - routesByPeerIdx map[string][]routeIndexEntry - - // true when returning an empty-like map (returned instead of nil) - empty bool // ForceRoutingPeerDNSResolution forces the peer to run/use routing-peer DNS // resolution regardless of the account-global setting, for reverse-proxy // domain targets. ForceRoutingPeerDNSResolution bool + + routesByPeerOnce sync.Once + routesByPeerIdx map[string][]routeIndexEntry + + // true when returning an empty-like map (returned instead of nil) + empty bool } type routeIndexEntry struct { - route *route.Route + route *nmdata.Route viaGroup bool } @@ -80,15 +82,15 @@ func EmptyNetworkMapComponents(nm *NetworkMapComponents) *NetworkMapComponents { return nm } -func (c *NetworkMapComponents) GetPeerInfo(peerID string) *ComponentPeer { +func (c *NetworkMapComponents) GetPeerInfo(peerID string) *nmdata.Peer { return c.Peers[peerID] } -func (c *NetworkMapComponents) GetRouterPeerInfo(peerID string) *ComponentPeer { +func (c *NetworkMapComponents) GetRouterPeerInfo(peerID string) *nmdata.Peer { return c.RouterPeers[peerID] } -func (c *NetworkMapComponents) GetGroupInfo(groupID string) *ComponentGroup { +func (c *NetworkMapComponents) GetGroupInfo(groupID string) *nmdata.Group { return c.Groups[groupID] } @@ -143,8 +145,8 @@ func (c *NetworkMapComponents) Calculate(ctx context.Context) *NetworkMap { peersToConnect, expiredPeers := c.filterPeersByLoginExpiration(aclPeers) includeIPv6 := false - if p := c.Peers[targetPeerID]; p != nil { - includeIPv6 = p.SupportsIPv6 && p.IPv6.IsValid() + if p := c.GetPeerInfo(targetPeerID); p != nil { + includeIPv6 = p.SupportsIPv6() && p.IPv6.IsValid() } routesUpdate := filterAndExpandRoutes(c.getRoutesToSync(targetPeerID, peersToConnect, peerGroups), includeIPv6) routesFirewallRules := c.getPeerRoutesFirewallRules(ctx, targetPeerID, includeIPv6) @@ -175,11 +177,11 @@ func (c *NetworkMapComponents) Calculate(ctx context.Context) *NetworkMap { if c.CustomZoneDomain != "" && len(c.AllDNSRecords) > 0 { customZones = append(customZones, nbdns.CustomZone{ Domain: c.CustomZoneDomain, - Records: c.AllDNSRecords, + Records: toRealRecords(c.AllDNSRecords), }) } - customZones = append(customZones, c.AccountZones...) + customZones = append(customZones, toRealZones(c.AccountZones)...) dnsUpdate.CustomZones = customZones dnsUpdate.NameServerGroups = c.getPeerNSGroupsFromGroups(targetPeerID, peerGroups) @@ -187,7 +189,7 @@ func (c *NetworkMapComponents) Calculate(ctx context.Context) *NetworkMap { return &NetworkMap{ Peers: peersToConnectIncludingRouters, - Network: c.Network.Copy(), + Network: c.Network, Routes: append(filterAndExpandRoutes(networkResourcesRoutes, includeIPv6), routesUpdate...), DNSConfig: dnsUpdate, OfflinePeers: expiredPeers, @@ -204,7 +206,7 @@ func (c *NetworkMapComponents) IsEmpty() bool { return c.empty } -func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) ([]*ComponentPeer, []*FirewallRule, map[string]map[string]struct{}, bool) { +func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) ([]*nmdata.Peer, []*FirewallRule, map[string]map[string]struct{}, bool) { targetPeer := c.GetPeerInfo(targetPeerID) if targetPeer == nil { return nil, nil, nil, false @@ -215,25 +217,25 @@ func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) ( sshEnabled := false for _, policy := range c.Policies { - if !policy.Enabled { + if policy == nil || !policy.Enabled { continue } for _, rule := range policy.Rules { - if !rule.Enabled { + if rule == nil || !rule.Enabled { continue } - var sourcePeers, destinationPeers []*ComponentPeer + var sourcePeers, destinationPeers []*nmdata.Peer var peerInSources, peerInDestinations bool - if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" { + if rule.SourceResource.Type == string(ResourceTypePeer) && rule.SourceResource.ID != "" { sourcePeers, peerInSources = c.getPeerFromResource(rule.SourceResource, targetPeerID) } else { sourcePeers, peerInSources = c.getAllPeersFromGroups(rule.Sources, targetPeerID, policy.SourcePostureChecks) } - if rule.DestinationResource.Type == ResourceTypePeer && rule.DestinationResource.ID != "" { + if rule.DestinationResource.Type == string(ResourceTypePeer) && rule.DestinationResource.ID != "" { destinationPeers, peerInDestinations = c.getPeerFromResource(rule.DestinationResource, targetPeerID) } else { destinationPeers, peerInDestinations = c.getAllPeersFromGroups(rule.Destinations, targetPeerID, nil) @@ -256,7 +258,7 @@ func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) ( generateResources(rule, sourcePeers, FirewallRuleDirectionIN) } - if peerInDestinations && rule.Protocol == PolicyRuleProtocolNetbirdSSH { + if peerInDestinations && rule.Protocol == string(PolicyRuleProtocolNetbirdSSH) { sshEnabled = true switch { case len(rule.AuthorizedGroups) > 0: @@ -287,7 +289,7 @@ func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) ( default: authorizedUsers[auth.Wildcard] = c.getAllowedUserIDs() } - } else if peerInDestinations && PolicyRuleImpliesLegacySSH(rule) && targetPeer.SSHEnabled { + } else if peerInDestinations && nmdata.PolicyRuleImpliesLegacySSH(rule) && targetPeer.SSHEnabled { sshEnabled = true authorizedUsers[auth.Wildcard] = c.getAllowedUserIDs() } @@ -307,19 +309,19 @@ func (c *NetworkMapComponents) getAllowedUserIDs() map[string]struct{} { return make(map[string]struct{}) } -func (c *NetworkMapComponents) connResourcesGenerator(targetPeer *ComponentPeer) (func(*PolicyRule, []*ComponentPeer, int), func() ([]*ComponentPeer, []*FirewallRule)) { +func (c *NetworkMapComponents) connResourcesGenerator(targetPeer *nmdata.Peer) (func(*nmdata.PolicyRule, []*nmdata.Peer, int), func() ([]*nmdata.Peer, []*FirewallRule)) { rulesExists := make(map[string]struct{}) peersExists := make(map[string]struct{}) rules := make([]*FirewallRule, 0) - peers := make([]*ComponentPeer, 0) + peers := make([]*nmdata.Peer, 0) - return func(rule *PolicyRule, groupPeers []*ComponentPeer, direction int) { + return func(rule *nmdata.PolicyRule, groupPeers []*nmdata.Peer, direction int) { protocol := rule.Protocol - if protocol == PolicyRuleProtocolNetbirdSSH { - protocol = PolicyRuleProtocolTCP + if protocol == string(PolicyRuleProtocolNetbirdSSH) { + protocol = string(PolicyRuleProtocolTCP) } - protocolStr := string(protocol) + protocolStr := protocol actionStr := string(rule.Action) dirStr := strconv.Itoa(direction) portsJoined := strings.Join(rule.Ports, ",") @@ -365,15 +367,15 @@ func (c *NetworkMapComponents) connResourcesGenerator(targetPeer *ComponentPeer) PortsJoined: portsJoined, }) } - }, func() ([]*ComponentPeer, []*FirewallRule) { + }, func() ([]*nmdata.Peer, []*FirewallRule) { return peers, rules } } -func (c *NetworkMapComponents) getAllPeersFromGroups(groups []string, peerID string, sourcePostureChecksIDs []string) ([]*ComponentPeer, bool) { +func (c *NetworkMapComponents) getAllPeersFromGroups(groups []string, peerID string, sourcePostureChecksIDs []string) ([]*nmdata.Peer, bool) { peerInGroups := false uniquePeerIDs := c.getUniquePeerIDsFromGroupsIDs(groups) - filteredPeers := make([]*ComponentPeer, 0, len(uniquePeerIDs)) + filteredPeers := make([]*nmdata.Peer, 0, len(uniquePeerIDs)) for _, p := range uniquePeerIDs { peerInfo := c.GetPeerInfo(p) @@ -425,22 +427,22 @@ func (c *NetworkMapComponents) getUniquePeerIDsFromGroupsIDs(groups []string) [] return ids } -func (c *NetworkMapComponents) getPeerFromResource(resource Resource, peerID string) ([]*ComponentPeer, bool) { +func (c *NetworkMapComponents) getPeerFromResource(resource nmdata.Resource, peerID string) ([]*nmdata.Peer, bool) { if resource.ID == peerID { - return []*ComponentPeer{}, true + return []*nmdata.Peer{}, true } peerInfo := c.GetPeerInfo(resource.ID) if peerInfo == nil { - return []*ComponentPeer{}, false + return []*nmdata.Peer{}, false } - return []*ComponentPeer{peerInfo}, false + return []*nmdata.Peer{peerInfo}, false } -func (c *NetworkMapComponents) filterPeersByLoginExpiration(aclPeers []*ComponentPeer) ([]*ComponentPeer, []*ComponentPeer) { - peersToConnect := make([]*ComponentPeer, 0, len(aclPeers)) - var expiredPeers []*ComponentPeer +func (c *NetworkMapComponents) filterPeersByLoginExpiration(aclPeers []*nmdata.Peer) ([]*nmdata.Peer, []*nmdata.Peer) { + peersToConnect := make([]*nmdata.Peer, 0, len(aclPeers)) + var expiredPeers []*nmdata.Peer for _, p := range aclPeers { expired, _ := p.LoginExpired(c.AccountSettings.PeerLoginExpiration) @@ -480,7 +482,7 @@ func (c *NetworkMapComponents) getPeerNSGroupsFromGroups(peerID string, groupLis for _, gID := range nsGroup.Groups { if _, found := groupList[gID]; found { if !c.peerIsNameserver(peerIPStr, nsGroup) { - peerNSGroups = append(peerNSGroups, nsGroup.Copy()) + peerNSGroups = append(peerNSGroups, toRealNSGroup(nsGroup)) } break } @@ -490,7 +492,7 @@ func (c *NetworkMapComponents) getPeerNSGroupsFromGroups(peerID string, groupLis return peerNSGroups } -func (c *NetworkMapComponents) peerIsNameserver(peerIPStr string, nsGroup *nbdns.NameServerGroup) bool { +func (c *NetworkMapComponents) peerIsNameserver(peerIPStr string, nsGroup *nmdata.NameServerGroup) bool { for _, ns := range nsGroup.NameServers { if peerIPStr == ns.IP.String() { return true @@ -502,8 +504,8 @@ func (c *NetworkMapComponents) peerIsNameserver(peerIPStr string, nsGroup *nbdns // filterAndExpandRoutes drops v6 routes for non-capable peers and duplicates // the default v4 route (0.0.0.0/0) as ::/0 for v6-capable peers. // TODO: the "-v6" suffix on IDs could collide with user-supplied route IDs. -func filterAndExpandRoutes(routes []*route.Route, includeIPv6 bool) []*route.Route { - filtered := make([]*route.Route, 0, len(routes)) +func filterAndExpandRoutes(routes []*nmdata.Route, includeIPv6 bool) []*nmdata.Route { + filtered := make([]*nmdata.Route, 0, len(routes)) for _, r := range routes { if !includeIPv6 && r.Network.Addr().Is6() { continue @@ -515,14 +517,14 @@ func filterAndExpandRoutes(routes []*route.Route, includeIPv6 bool) []*route.Rou v6.ID = r.ID + "-v6-default" v6.NetID = r.NetID + "-v6" v6.Network = netip.MustParsePrefix("::/0") - v6.NetworkType = route.IPv6Network + v6.NetworkType = nmdata.NetworkTypeIPv6 filtered = append(filtered, v6) } } return filtered } -func (c *NetworkMapComponents) getRoutesToSync(peerID string, aclPeers []*ComponentPeer, peerGroups LookupMap) []*route.Route { +func (c *NetworkMapComponents) getRoutesToSync(peerID string, aclPeers []*nmdata.Peer, peerGroups LookupMap) []*nmdata.Route { routes, peerDisabledRoutes := c.getRoutingPeerRoutes(peerID) peerRoutesMembership := make(LookupMap) for _, r := range append(routes, peerDisabledRoutes...) { @@ -539,7 +541,7 @@ func (c *NetworkMapComponents) getRoutesToSync(peerID string, aclPeers []*Compon return routes } -func (c *NetworkMapComponents) getRoutingPeerRoutes(peerID string) (enabledRoutes []*route.Route, disabledRoutes []*route.Route) { +func (c *NetworkMapComponents) getRoutingPeerRoutes(peerID string) (enabledRoutes []*nmdata.Route, disabledRoutes []*nmdata.Route) { peerInfo := c.GetPeerInfo(peerID) if peerInfo == nil { peerInfo = c.GetRouterPeerInfo(peerID) @@ -548,9 +550,9 @@ func (c *NetworkMapComponents) getRoutingPeerRoutes(peerID string) (enabledRoute return enabledRoutes, disabledRoutes } - seenRoute := make(map[route.ID]struct{}) + seenRoute := make(map[string]struct{}) - takeRoute := func(r *route.Route) { + takeRoute := func(r *nmdata.Route) { if _, ok := seenRoute[r.ID]; ok { return } @@ -569,7 +571,7 @@ func (c *NetworkMapComponents) getRoutingPeerRoutes(peerID string) (enabledRoute if entry.viaGroup { newPeerRoute := entry.route.Copy() newPeerRoute.PeerGroups = nil - newPeerRoute.ID = route.ID(string(entry.route.ID) + ":" + peerID) + newPeerRoute.ID = entry.route.ID + ":" + peerID takeRoute(newPeerRoute) continue } @@ -602,8 +604,8 @@ func (c *NetworkMapComponents) routesByPeer() map[string][]routeIndexEntry { return c.routesByPeerIdx } -func (c *NetworkMapComponents) filterRoutesByGroups(routes []*route.Route, groupListMap LookupMap) []*route.Route { - var filteredRoutes []*route.Route +func (c *NetworkMapComponents) filterRoutesByGroups(routes []*nmdata.Route, groupListMap LookupMap) []*nmdata.Route { + var filteredRoutes []*nmdata.Route for _, r := range routes { for _, groupID := range r.Groups { _, found := groupListMap[groupID] @@ -616,8 +618,8 @@ func (c *NetworkMapComponents) filterRoutesByGroups(routes []*route.Route, group return filteredRoutes } -func (c *NetworkMapComponents) filterRoutesFromPeersOfSameHAGroup(routes []*route.Route, peerMemberships LookupMap) []*route.Route { - var filteredRoutes []*route.Route +func (c *NetworkMapComponents) filterRoutesFromPeersOfSameHAGroup(routes []*nmdata.Route, peerMemberships LookupMap) []*nmdata.Route { + var filteredRoutes []*nmdata.Route for _, r := range routes { _, found := peerMemberships[string(r.GetHAUniqueID())] if !found { @@ -650,7 +652,7 @@ func (c *NetworkMapComponents) getPeerRoutesFirewallRules(ctx context.Context, p return routesFirewallRules } -func (c *NetworkMapComponents) getDefaultPermit(r *route.Route, includeIPv6 bool) []*RouteFirewallRule { +func (c *NetworkMapComponents) getDefaultPermit(r *nmdata.Route, includeIPv6 bool) []*RouteFirewallRule { if r.Network.Addr().Is6() && !includeIPv6 { return nil } @@ -667,7 +669,7 @@ func (c *NetworkMapComponents) getDefaultPermit(r *route.Route, includeIPv6 bool Protocol: string(PolicyRuleProtocolALL), Domains: r.Domains, IsDynamic: r.IsDynamic(), - RouteID: r.ID, + RouteID: route.ID(r.ID), } rules := []*RouteFirewallRule{&rule} @@ -678,7 +680,7 @@ func (c *NetworkMapComponents) getDefaultPermit(r *route.Route, includeIPv6 bool ruleV6.SourceRanges = []string{"::/0"} if isDefaultV4 { ruleV6.Destination = "::/0" - ruleV6.RouteID = r.ID + "-v6-default" + ruleV6.RouteID = route.ID(r.ID + "-v6-default") } rules = append(rules, &ruleV6) } @@ -686,7 +688,7 @@ func (c *NetworkMapComponents) getDefaultPermit(r *route.Route, includeIPv6 bool return rules } -func (c *NetworkMapComponents) getDistributionGroupsPeers(r *route.Route) map[string]struct{} { +func (c *NetworkMapComponents) getDistributionGroupsPeers(r *nmdata.Route) map[string]struct{} { distPeers := make(map[string]struct{}) for _, id := range r.Groups { group := c.GetGroupInfo(id) @@ -701,11 +703,17 @@ func (c *NetworkMapComponents) getDistributionGroupsPeers(r *route.Route) map[st return distPeers } -func (c *NetworkMapComponents) getAllRoutePoliciesFromGroups(accessControlGroups []string) []*Policy { - routePolicies := make([]*Policy, 0) +func (c *NetworkMapComponents) getAllRoutePoliciesFromGroups(accessControlGroups []string) []*nmdata.Policy { + routePolicies := make([]*nmdata.Policy, 0) for _, groupID := range accessControlGroups { for _, policy := range c.Policies { + if policy == nil { + continue + } for _, rule := range policy.Rules { + if rule == nil { + continue + } if slices.Contains(rule.Destinations, groupID) { routePolicies = append(routePolicies, policy) } @@ -716,15 +724,15 @@ func (c *NetworkMapComponents) getAllRoutePoliciesFromGroups(accessControlGroups return routePolicies } -func (c *NetworkMapComponents) getRouteFirewallRules(ctx context.Context, peerID string, policies []*Policy, route *route.Route, distributionPeers map[string]struct{}, includeIPv6 bool) []*RouteFirewallRule { +func (c *NetworkMapComponents) getRouteFirewallRules(ctx context.Context, peerID string, policies []*nmdata.Policy, route *nmdata.Route, distributionPeers map[string]struct{}, includeIPv6 bool) []*RouteFirewallRule { var fwRules []*RouteFirewallRule for _, policy := range policies { - if !policy.Enabled { + if policy == nil || !policy.Enabled { continue } for _, rule := range policy.Rules { - if !rule.Enabled { + if rule == nil || !rule.Enabled { continue } @@ -736,7 +744,7 @@ func (c *NetworkMapComponents) getRouteFirewallRules(ctx context.Context, peerID return fwRules } -func (c *NetworkMapComponents) getRulePeers(rule *PolicyRule, postureChecks []string, peerID string, distributionPeers map[string]struct{}) []*ComponentPeer { +func (c *NetworkMapComponents) getRulePeers(rule *nmdata.PolicyRule, postureChecks []string, peerID string, distributionPeers map[string]struct{}) []*nmdata.Peer { distPeersWithPolicy := make(map[string]struct{}) for _, id := range rule.Sources { group := c.GetGroupInfo(id) @@ -755,7 +763,7 @@ func (c *NetworkMapComponents) getRulePeers(rule *PolicyRule, postureChecks []st } } } - if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" { + if rule.SourceResource.Type == string(ResourceTypePeer) && rule.SourceResource.ID != "" { _, distPeer := distributionPeers[rule.SourceResource.ID] _, valid := c.Peers[rule.SourceResource.ID] if distPeer && valid && c.ValidatePostureChecksOnPeer(rule.SourceResource.ID, postureChecks) { @@ -763,7 +771,7 @@ func (c *NetworkMapComponents) getRulePeers(rule *PolicyRule, postureChecks []st } } - distributionGroupPeers := make([]*ComponentPeer, 0, len(distPeersWithPolicy)) + distributionGroupPeers := make([]*nmdata.Peer, 0, len(distPeersWithPolicy)) for pID := range distPeersWithPolicy { peerInfo := c.GetPeerInfo(pID) if peerInfo == nil { @@ -774,9 +782,9 @@ func (c *NetworkMapComponents) getRulePeers(rule *PolicyRule, postureChecks []st return distributionGroupPeers } -func (c *NetworkMapComponents) getNetworkResourcesRoutesToSync(peerID string) (bool, []*route.Route, map[string]struct{}) { +func (c *NetworkMapComponents) getNetworkResourcesRoutesToSync(peerID string) (bool, []*nmdata.Route, map[string]struct{}) { var isRoutingPeer bool - var routes []*route.Route + var routes []*nmdata.Route allSourcePeers := make(map[string]struct{}) for _, resource := range c.NetworkResources { @@ -803,14 +811,17 @@ func (c *NetworkMapComponents) getNetworkResourcesRoutesToSync(peerID string) (b func (c *NetworkMapComponents) processResourcePolicies( peerID string, - resource *ComponentResource, - networkRoutingPeers map[string]*ComponentRouter, + resource *nmdata.NetworkResource, + networkRoutingPeers map[string]*nmdata.NetworkRouter, addSourcePeers bool, allSourcePeers map[string]struct{}, -) []*route.Route { - var routes []*route.Route +) []*nmdata.Route { + var routes []*nmdata.Route for _, policy := range c.ResourcePoliciesMap[resource.ID] { + if policy == nil || !policy.Enabled || len(policy.Rules) == 0 || policy.Rules[0] == nil { + continue + } peers := c.getResourcePolicyPeers(policy) if addSourcePeers { for _, pID := range c.getPostureValidPeers(peers, policy.SourcePostureChecks) { @@ -830,17 +841,17 @@ func (c *NetworkMapComponents) processResourcePolicies( return routes } -func (c *NetworkMapComponents) getResourcePolicyPeers(policy *Policy) []string { - if policy.Rules[0].SourceResource.Type == ResourceTypePeer && policy.Rules[0].SourceResource.ID != "" { +func (c *NetworkMapComponents) getResourcePolicyPeers(policy *nmdata.Policy) []string { + if policy.Rules[0].SourceResource.Type == string(ResourceTypePeer) && policy.Rules[0].SourceResource.ID != "" { return []string{policy.Rules[0].SourceResource.ID} } return c.getUniquePeerIDsFromGroupsIDs(policy.SourceGroups()) } -func (c *NetworkMapComponents) getNetworkResourcesRoutes(resource *ComponentResource, peerID string, router *ComponentRouter) []*route.Route { +func (c *NetworkMapComponents) getNetworkResourcesRoutes(resource *nmdata.NetworkResource, peerID string, router *nmdata.NetworkRouter) []*nmdata.Route { resourceAppliedPolicies := c.ResourcePoliciesMap[resource.ID] - var routes []*route.Route + var routes []*nmdata.Route if len(resourceAppliedPolicies) > 0 { peerInfo := c.GetPeerInfo(peerID) if peerInfo != nil { @@ -851,9 +862,9 @@ func (c *NetworkMapComponents) getNetworkResourcesRoutes(resource *ComponentReso return routes } -func (c *NetworkMapComponents) networkResourceToRoute(resource *ComponentResource, peer *ComponentPeer, router *ComponentRouter) *route.Route { - r := &route.Route{ - ID: route.ID(resource.ID + ":" + peer.ID), +func (c *NetworkMapComponents) networkResourceToRoute(resource *nmdata.NetworkResource, peer *nmdata.Peer, router *nmdata.NetworkRouter) *nmdata.Route { + r := &nmdata.Route{ + ID: resource.ID + ":" + peer.ID, AccountID: resource.AccountID, Peer: peer.Key, PeerID: peer.ID, @@ -861,24 +872,24 @@ func (c *NetworkMapComponents) networkResourceToRoute(resource *ComponentResourc Masquerade: router.Masquerade, Enabled: resource.Enabled, KeepRoute: true, - NetID: route.NetID(resource.Name), + NetID: resource.Name, Description: resource.Description, } - if resource.Type == ComponentResourceHost || resource.Type == ComponentResourceSubnet { + if resource.Type == string(ResourceTypeHost) || resource.Type == string(ResourceTypeSubnet) { r.Network = resource.Prefix - r.NetworkType = route.IPv4Network + r.NetworkType = nmdata.NetworkTypeIPv4 if resource.Prefix.Addr().Is6() { - r.NetworkType = route.IPv6Network + r.NetworkType = nmdata.NetworkTypeIPv6 } } - if resource.Type == ComponentResourceDomain { + if resource.Type == string(ResourceTypeDomain) { domainList, err := domain.FromStringList([]string{resource.Domain}) if err == nil { r.Domains = domainList - r.NetworkType = route.DomainNetwork + r.NetworkType = nmdata.NetworkTypeDomain r.Network = netip.PrefixFrom(netip.AddrFrom4([4]byte{192, 0, 2, 0}), 32) } } @@ -896,7 +907,7 @@ func (c *NetworkMapComponents) getPostureValidPeers(inputPeers []string, posture return dest } -func (c *NetworkMapComponents) getPeerNetworkResourceFirewallRules(ctx context.Context, peerID string, routes []*route.Route, includeIPv6 bool) []*RouteFirewallRule { +func (c *NetworkMapComponents) getPeerNetworkResourceFirewallRules(ctx context.Context, peerID string, routes []*nmdata.Route, includeIPv6 bool) []*RouteFirewallRule { routesFirewallRules := make([]*RouteFirewallRule, 0) peerInfo := c.GetPeerInfo(peerID) @@ -924,11 +935,17 @@ func (c *NetworkMapComponents) getPeerNetworkResourceFirewallRules(ctx context.C return routesFirewallRules } -func (c *NetworkMapComponents) getPoliciesSourcePeers(policies []*Policy) map[string]struct{} { +func (c *NetworkMapComponents) getPoliciesSourcePeers(policies []*nmdata.Policy) map[string]struct{} { sourcePeers := make(map[string]struct{}) for _, policy := range policies { + if policy == nil { + continue + } for _, rule := range policy.Rules { + if rule == nil { + continue + } for _, sourceGroup := range rule.Sources { group := c.GetGroupInfo(sourceGroup) if group == nil { @@ -940,7 +957,7 @@ func (c *NetworkMapComponents) getPoliciesSourcePeers(policies []*Policy) map[st } } - if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" { + if rule.SourceResource.Type == string(ResourceTypePeer) && rule.SourceResource.ID != "" { sourcePeers[rule.SourceResource.ID] = struct{}{} } } @@ -950,13 +967,13 @@ func (c *NetworkMapComponents) getPoliciesSourcePeers(policies []*Policy) map[st } func (c *NetworkMapComponents) addNetworksRoutingPeers( - networkResourcesRoutes []*route.Route, + networkResourcesRoutes []*nmdata.Route, peerID string, - peersToConnect []*ComponentPeer, - expiredPeers []*ComponentPeer, + peersToConnect []*nmdata.Peer, + expiredPeers []*nmdata.Peer, isRouter bool, sourcePeers map[string]struct{}, -) []*ComponentPeer { +) []*nmdata.Peer { networkRoutesPeers := make(map[string]struct{}, len(networkResourcesRoutes)) for _, r := range networkResourcesRoutes { @@ -1006,8 +1023,8 @@ type FirewallRuleContext struct { PortsJoined string } -func AppendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *ComponentPeer, rule *PolicyRule, rc FirewallRuleContext) []*FirewallRule { - if !peer.IPv6.IsValid() || !targetPeer.SupportsIPv6 || !targetPeer.IPv6.IsValid() { +func AppendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *nmdata.Peer, rule *nmdata.PolicyRule, rc FirewallRuleContext) []*FirewallRule { + if !peer.IPv6.IsValid() || !targetPeer.SupportsIPv6() || !targetPeer.IPv6.IsValid() { return rules } diff --git a/shared/management/types/networkmap_components_compact.go b/shared/management/types/networkmap_components_compact.go index a1f53690d..b45bc3e40 100644 --- a/shared/management/types/networkmap_components_compact.go +++ b/shared/management/types/networkmap_components_compact.go @@ -1,8 +1,7 @@ package types import ( - nbdns "github.com/netbirdio/netbird/dns" - "github.com/netbirdio/netbird/route" + nmdata "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" ) type GroupCompact struct { @@ -13,26 +12,26 @@ type GroupCompact struct { type NetworkMapComponentsCompact struct { PeerID string - Network *Network - AccountSettings *AccountSettingsInfo - DNSSettings *DNSSettings + Network *nmdata.Network + AccountSettings *nmdata.AccountSettingsInfo + DNSSettings *nmdata.DNSSettings CustomZoneDomain string - AllPeers []*ComponentPeer + AllPeers []*nmdata.Peer PeerIndexes []int RouterPeerIndexes []int Groups map[string]*GroupCompact - AllPolicies []*Policy + AllPolicies []*nmdata.Policy PolicyIndexes []int ResourcePoliciesMap map[string][]int - Routes []*route.Route - NameServerGroups []*nbdns.NameServerGroup - AllDNSRecords []nbdns.SimpleRecord - AccountZones []nbdns.CustomZone + Routes []*nmdata.Route + NameServerGroups []*nmdata.NameServerGroup + AllDNSRecords []nmdata.SimpleRecord + AccountZones []nmdata.CustomZone - RoutersMap map[string]map[string]*ComponentRouter - NetworkResources []*ComponentResource + RoutersMap map[string]map[string]*nmdata.NetworkRouter + NetworkResources []*nmdata.NetworkResource GroupIDToUserIDs map[string][]string AllowedUserIDs map[string]struct{} @@ -41,7 +40,7 @@ type NetworkMapComponentsCompact struct { func (c *NetworkMapComponents) ToCompact() *NetworkMapComponentsCompact { peerToIndex := make(map[string]int) - var allPeers []*ComponentPeer + var allPeers []*nmdata.Peer for id, peer := range c.Peers { if _, exists := peerToIndex[id]; !exists { @@ -81,8 +80,8 @@ func (c *NetworkMapComponents) ToCompact() *NetworkMapComponentsCompact { } } - policyToIndex := make(map[*Policy]int) - var allPolicies []*Policy + policyToIndex := make(map[*nmdata.Policy]int) + var allPolicies []*nmdata.Policy for _, policy := range c.Policies { if _, exists := policyToIndex[policy]; !exists { @@ -147,7 +146,7 @@ func (c *NetworkMapComponents) ToCompact() *NetworkMapComponentsCompact { } func (c *NetworkMapComponentsCompact) ToFull() *NetworkMapComponents { - peers := make(map[string]*ComponentPeer, len(c.PeerIndexes)) + peers := make(map[string]*nmdata.Peer, len(c.PeerIndexes)) for _, idx := range c.PeerIndexes { if idx >= 0 && idx < len(c.AllPeers) { peer := c.AllPeers[idx] @@ -155,7 +154,7 @@ func (c *NetworkMapComponentsCompact) ToFull() *NetworkMapComponents { } } - routerPeers := make(map[string]*ComponentPeer, len(c.RouterPeerIndexes)) + routerPeers := make(map[string]*nmdata.Peer, len(c.RouterPeerIndexes)) for _, idx := range c.RouterPeerIndexes { if idx >= 0 && idx < len(c.AllPeers) { peer := c.AllPeers[idx] @@ -163,7 +162,7 @@ func (c *NetworkMapComponentsCompact) ToFull() *NetworkMapComponents { } } - groups := make(map[string]*ComponentGroup, len(c.Groups)) + groups := make(map[string]*nmdata.Group, len(c.Groups)) for id, gc := range c.Groups { peerIDs := make([]string, 0, len(gc.PeerIndexes)) for _, idx := range gc.PeerIndexes { @@ -171,25 +170,24 @@ func (c *NetworkMapComponentsCompact) ToFull() *NetworkMapComponents { peerIDs = append(peerIDs, c.AllPeers[idx].ID) } } - groups[id] = &ComponentGroup{ - ID: id, + groups[id] = &nmdata.Group{ Name: gc.Name, Peers: peerIDs, } } - policies := make([]*Policy, len(c.PolicyIndexes)) + policies := make([]*nmdata.Policy, len(c.PolicyIndexes)) for i, idx := range c.PolicyIndexes { if idx >= 0 && idx < len(c.AllPolicies) { policies[i] = c.AllPolicies[idx] } } - var resourcePoliciesMap map[string][]*Policy + var resourcePoliciesMap map[string][]*nmdata.Policy if len(c.ResourcePoliciesMap) > 0 { - resourcePoliciesMap = make(map[string][]*Policy, len(c.ResourcePoliciesMap)) + resourcePoliciesMap = make(map[string][]*nmdata.Policy, len(c.ResourcePoliciesMap)) for resID, indexes := range c.ResourcePoliciesMap { - pols := make([]*Policy, 0, len(indexes)) + pols := make([]*nmdata.Policy, 0, len(indexes)) for _, idx := range indexes { if idx >= 0 && idx < len(c.AllPolicies) { pols = append(pols, c.AllPolicies[idx]) diff --git a/shared/management/types/nmdata_convert.go b/shared/management/types/nmdata_convert.go new file mode 100644 index 000000000..2a7998773 --- /dev/null +++ b/shared/management/types/nmdata_convert.go @@ -0,0 +1,70 @@ +package types + +import ( + nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" +) + +// This file holds the twin→real converters that survive the twin-NetworkMap +// refactor: only the DNS materialization. NetworkMap.DNSConfig stays a real +// nbdns.Config (the client DNS type), so Calculate converts the twin DNS +// components to nbdns at the output boundary. Peers/Routes/Network flow as +// twins all the way through and need no conversion. + +func toRealNSGroup(n *nmdata.NameServerGroup) *nbdns.NameServerGroup { + if n == nil { + return nil + } + nameServers := make([]nbdns.NameServer, 0, len(n.NameServers)) + for _, ns := range n.NameServers { + nameServers = append(nameServers, nbdns.NameServer{ + IP: ns.IP, + NSType: nbdns.NameServerType(ns.NSType), + Port: ns.Port, + }) + } + return &nbdns.NameServerGroup{ + ID: n.ID, + Name: n.Name, + Description: n.Description, + NameServers: nameServers, + Groups: n.Groups, + Primary: n.Primary, + Domains: n.Domains, + Enabled: n.Enabled, + SearchDomainsEnabled: n.SearchDomainsEnabled, + } +} + +func toRealRecords(recs []nmdata.SimpleRecord) []nbdns.SimpleRecord { + if recs == nil { + return nil + } + out := make([]nbdns.SimpleRecord, len(recs)) + for i, r := range recs { + out[i] = nbdns.SimpleRecord{ + Name: r.Name, + Type: r.Type, + Class: r.Class, + TTL: r.TTL, + RData: r.RData, + } + } + return out +} + +func toRealZones(zones []nmdata.CustomZone) []nbdns.CustomZone { + if zones == nil { + return nil + } + out := make([]nbdns.CustomZone, len(zones)) + for i, z := range zones { + out[i] = nbdns.CustomZone{ + Domain: z.Domain, + Records: toRealRecords(z.Records), + SearchDomainDisabled: z.SearchDomainDisabled, + NonAuthoritative: z.NonAuthoritative, + } + } + return out +} diff --git a/shared/management/types/policyrule.go b/shared/management/types/policyrule.go index 52c494a6a..c951b1487 100644 --- a/shared/management/types/policyrule.go +++ b/shared/management/types/policyrule.go @@ -1,22 +1,39 @@ package types import ( - "slices" + "errors" + "fmt" + "strconv" + "strings" "github.com/netbirdio/netbird/shared/management/proto" ) -// PolicyUpdateOperationType operation type -type PolicyUpdateOperationType int - // PolicyTrafficActionType action type for the firewall type PolicyTrafficActionType string // PolicyRuleProtocolType type of traffic type PolicyRuleProtocolType string -// PolicyRuleDirection direction of traffic -type PolicyRuleDirection string +const ( + // PolicyTrafficActionAccept indicates that the traffic is accepted + PolicyTrafficActionAccept = PolicyTrafficActionType("accept") + // PolicyTrafficActionDrop indicates that the traffic is dropped + PolicyTrafficActionDrop = PolicyTrafficActionType("drop") +) + +const ( + // PolicyRuleProtocolALL type of traffic + PolicyRuleProtocolALL = PolicyRuleProtocolType("all") + // PolicyRuleProtocolTCP type of traffic + PolicyRuleProtocolTCP = PolicyRuleProtocolType("tcp") + // PolicyRuleProtocolUDP type of traffic + PolicyRuleProtocolUDP = PolicyRuleProtocolType("udp") + // PolicyRuleProtocolICMP type of traffic + PolicyRuleProtocolICMP = PolicyRuleProtocolType("icmp") + // PolicyRuleProtocolNetbirdSSH type of traffic + PolicyRuleProtocolNetbirdSSH = PolicyRuleProtocolType("netbird-ssh") +) // RulePortRange represents a range of ports for a firewall rule. type RulePortRange struct { @@ -39,187 +56,84 @@ func (r *RulePortRange) Equal(other *RulePortRange) bool { return r.Start == other.Start && r.End == other.End } -// PolicyRule is the metadata of the policy -type PolicyRule struct { - // ID of the policy rule - ID string `gorm:"primaryKey"` +func ParseRuleString(rule string) (PolicyRuleProtocolType, RulePortRange, error) { + rule = strings.TrimSpace(strings.ToLower(rule)) + if rule == "all" { + return PolicyRuleProtocolALL, RulePortRange{}, nil + } + if rule == "icmp" { + return PolicyRuleProtocolICMP, RulePortRange{}, nil + } - // PolicyID is a reference to Policy that this object belongs - PolicyID string `json:"-" gorm:"index"` + split := strings.Split(rule, "/") + if len(split) != 2 { + return "", RulePortRange{}, errors.New("invalid rule format: expected protocol/port or protocol/port-range") + } - // Name of the rule visible in the UI - Name string + protoStr := strings.TrimSpace(split[0]) + portStr := strings.TrimSpace(split[1]) - // Description of the rule visible in the UI - Description string + var protocol PolicyRuleProtocolType + switch protoStr { + case "tcp": + protocol = PolicyRuleProtocolTCP + case "udp": + protocol = PolicyRuleProtocolUDP + case "icmp": + return "", RulePortRange{}, errors.New("icmp does not accept ports; use 'icmp' without '/…'") + case "netbird-ssh": + return PolicyRuleProtocolNetbirdSSH, RulePortRange{Start: nativeSSHPortNumber, End: nativeSSHPortNumber}, nil + default: + return "", RulePortRange{}, fmt.Errorf("invalid protocol: %q", protoStr) + } - // Enabled status of rule in the system - Enabled bool + portRange, err := parsePortRange(portStr) + if err != nil { + return "", RulePortRange{}, err + } - // Action policy accept or drops packets - Action PolicyTrafficActionType - - // Destinations policy destination groups - Destinations []string `gorm:"serializer:json"` - - // DestinationResource policy destination resource that the rule is applied to - DestinationResource Resource `gorm:"serializer:json"` - - // Sources policy source groups - Sources []string `gorm:"serializer:json"` - - // SourceResource policy source resource that the rule is applied to - SourceResource Resource `gorm:"serializer:json"` - - // Bidirectional define if the rule is applicable in both directions, sources, and destinations - Bidirectional bool - - // Protocol type of the traffic - Protocol PolicyRuleProtocolType - - // Ports or it ranges list - Ports []string `gorm:"serializer:json"` - - // PortRanges a list of port ranges. - PortRanges []RulePortRange `gorm:"serializer:json"` - - // AuthorizedGroups is a map of groupIDs and their respective access to local users via ssh - AuthorizedGroups map[string][]string `gorm:"serializer:json"` - - // AuthorizedUser is a list of userIDs that are authorized to access local resources via ssh - AuthorizedUser string + return protocol, portRange, nil } -// Copy returns a copy of a policy rule -func (pm *PolicyRule) Copy() *PolicyRule { - rule := &PolicyRule{ - ID: pm.ID, - PolicyID: pm.PolicyID, - Name: pm.Name, - Description: pm.Description, - Enabled: pm.Enabled, - Action: pm.Action, - Destinations: make([]string, len(pm.Destinations)), - DestinationResource: pm.DestinationResource, - Sources: make([]string, len(pm.Sources)), - SourceResource: pm.SourceResource, - Bidirectional: pm.Bidirectional, - Protocol: pm.Protocol, - Ports: make([]string, len(pm.Ports)), - PortRanges: make([]RulePortRange, len(pm.PortRanges)), - AuthorizedGroups: make(map[string][]string, len(pm.AuthorizedGroups)), - AuthorizedUser: pm.AuthorizedUser, - } - copy(rule.Destinations, pm.Destinations) - copy(rule.Sources, pm.Sources) - copy(rule.Ports, pm.Ports) - copy(rule.PortRanges, pm.PortRanges) - for k, v := range pm.AuthorizedGroups { - rule.AuthorizedGroups[k] = make([]string, len(v)) - copy(rule.AuthorizedGroups[k], v) - } - return rule -} - -func (pm *PolicyRule) Equal(other *PolicyRule) bool { - if pm == nil || other == nil { - return pm == other - } - - if pm.ID != other.ID || - pm.PolicyID != other.PolicyID || - pm.Name != other.Name || - pm.Description != other.Description || - pm.Enabled != other.Enabled || - pm.Action != other.Action || - pm.Bidirectional != other.Bidirectional || - pm.Protocol != other.Protocol || - pm.SourceResource != other.SourceResource || - pm.DestinationResource != other.DestinationResource || - pm.AuthorizedUser != other.AuthorizedUser { - return false - } - - if !stringSlicesEqualUnordered(pm.Sources, other.Sources) { - return false - } - if !stringSlicesEqualUnordered(pm.Destinations, other.Destinations) { - return false - } - if !stringSlicesEqualUnordered(pm.Ports, other.Ports) { - return false - } - if !portRangeSlicesEqualUnordered(pm.PortRanges, other.PortRanges) { - return false - } - if !authorizedGroupsEqual(pm.AuthorizedGroups, other.AuthorizedGroups) { - return false - } - - return true -} - -func stringSlicesEqualUnordered(a, b []string) bool { - if len(a) != len(b) { - return false - } - if len(a) == 0 { - return true - } - sorted1 := make([]string, len(a)) - sorted2 := make([]string, len(b)) - copy(sorted1, a) - copy(sorted2, b) - slices.Sort(sorted1) - slices.Sort(sorted2) - return slices.Equal(sorted1, sorted2) -} - -func portRangeSlicesEqualUnordered(a, b []RulePortRange) bool { - if len(a) != len(b) { - return false - } - if len(a) == 0 { - return true - } - cmp := func(x, y RulePortRange) int { - if x.Start != y.Start { - if x.Start < y.Start { - return -1 - } - return 1 +func parsePortRange(portStr string) (RulePortRange, error) { + if strings.Contains(portStr, "-") { + rangeParts := strings.Split(portStr, "-") + if len(rangeParts) != 2 { + return RulePortRange{}, fmt.Errorf("invalid port range %q", portStr) } - if x.End != y.End { - if x.End < y.End { - return -1 - } - return 1 + start, err := parsePort(strings.TrimSpace(rangeParts[0])) + if err != nil { + return RulePortRange{}, err } - return 0 + end, err := parsePort(strings.TrimSpace(rangeParts[1])) + if err != nil { + return RulePortRange{}, err + } + if start > end { + return RulePortRange{}, fmt.Errorf("invalid port range: start %d > end %d", start, end) + } + return RulePortRange{Start: uint16(start), End: uint16(end)}, nil } - sorted1 := make([]RulePortRange, len(a)) - sorted2 := make([]RulePortRange, len(b)) - copy(sorted1, a) - copy(sorted2, b) - slices.SortFunc(sorted1, cmp) - slices.SortFunc(sorted2, cmp) - return slices.EqualFunc(sorted1, sorted2, func(x, y RulePortRange) bool { - return x.Start == y.Start && x.End == y.End - }) + + p, err := parsePort(portStr) + if err != nil { + return RulePortRange{}, err + } + + return RulePortRange{Start: uint16(p), End: uint16(p)}, nil } -func authorizedGroupsEqual(a, b map[string][]string) bool { - if len(a) != len(b) { - return false +func parsePort(portStr string) (int, error) { + + if portStr == "" { + return 0, errors.New("empty port") } - for k, va := range a { - vb, ok := b[k] - if !ok { - return false - } - if !stringSlicesEqualUnordered(va, vb) { - return false - } + p, err := strconv.Atoi(portStr) + if err != nil { + return 0, fmt.Errorf("invalid port %q: %w", portStr, err) } - return true + if p < 1 || p > 65535 { + return 0, fmt.Errorf("port out of range (1–65535): %d", p) + } + return p, nil } diff --git a/shared/management/types/resource.go b/shared/management/types/resource.go index 8347d8c03..87f27db49 100644 --- a/shared/management/types/resource.go +++ b/shared/management/types/resource.go @@ -1,9 +1,5 @@ package types -import ( - "github.com/netbirdio/netbird/shared/management/http/api" -) - type ResourceType string const ( @@ -13,27 +9,11 @@ const ( ResourceTypeSubnet ResourceType = "subnet" ) -type Resource struct { - ID string - Type ResourceType -} - -func (r *Resource) ToAPIResponse() *api.Resource { - if r.ID == "" && r.Type == "" { - return nil - } - - return &api.Resource{ - Id: r.ID, - Type: api.ResourceType(r.Type), +func (t ResourceType) Valid() bool { + switch t { + case ResourceTypePeer, ResourceTypeDomain, ResourceTypeHost, ResourceTypeSubnet: + return true + default: + return false } } - -func (r *Resource) FromAPIRequest(req *api.Resource) { - if req == nil { - return - } - - r.ID = req.Id - r.Type = ResourceType(req.Type) -} diff --git a/version/compare.go b/version/compare.go new file mode 100644 index 000000000..e7868f35a --- /dev/null +++ b/version/compare.go @@ -0,0 +1,31 @@ +package version + +import ( + "strings" + + v "github.com/hashicorp/go-version" +) + +// sanitizeVersion removes anything after the pre-release tag (e.g., "-dev", "-alpha", etc.) +func sanitizeVersion(version string) string { + parts := strings.Split(version, "-") + return parts[0] +} + +// MeetsMinVersion checks if the peer's version meets or exceeds the minimum required version +func MeetsMinVersion(minVer, peerVer string) (bool, error) { + peerVer = sanitizeVersion(peerVer) + minVer = sanitizeVersion(minVer) + + peerNBVer, err := v.NewVersion(peerVer) + if err != nil { + return false, err + } + + constraints, err := v.NewConstraint(">= " + minVer) + if err != nil { + return false, err + } + + return constraints.Check(peerNBVer), nil +} diff --git a/version/compare_test.go b/version/compare_test.go new file mode 100644 index 000000000..9f3c7f323 --- /dev/null +++ b/version/compare_test.go @@ -0,0 +1,72 @@ +package version + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMeetsMinVersion(t *testing.T) { + tests := []struct { + name string + minVer string + peerVer string + want bool + wantErr bool + }{ + { + name: "Peer version greater than min version", + minVer: "0.26.0", + peerVer: "0.60.1", + want: true, + wantErr: false, + }, + { + name: "Peer version equals min version", + minVer: "1.0.0", + peerVer: "1.0.0", + want: true, + wantErr: false, + }, + { + name: "Peer version less than min version", + minVer: "1.0.0", + peerVer: "0.9.9", + want: false, + wantErr: false, + }, + { + name: "Peer version with pre-release tag greater than min version", + minVer: "1.0.0", + peerVer: "1.0.1-alpha", + want: true, + wantErr: false, + }, + { + name: "Invalid peer version format", + minVer: "1.0.0", + peerVer: "dev", + want: false, + wantErr: true, + }, + { + name: "Invalid min version format", + minVer: "invalid.version", + peerVer: "1.0.0", + want: false, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := MeetsMinVersion(tt.minVer, tt.peerVer) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/version/version.go b/version/version.go index b92e5ac7e..074305bd6 100644 --- a/version/version.go +++ b/version/version.go @@ -71,30 +71,6 @@ func NetbirdCommit() string { return revision } -// sanitizeVersion removes anything after the pre-release tag (e.g., "-dev", "-alpha", etc.) -func sanitizeVersion(version string) string { - parts := strings.Split(version, "-") - return parts[0] -} - -// MeetsMinVersion checks if the peer's version meets or exceeds the minimum required version -func MeetsMinVersion(minVer, peerVer string) (bool, error) { - peerVer = sanitizeVersion(peerVer) - minVer = sanitizeVersion(minVer) - - peerNBVer, err := v.NewVersion(peerVer) - if err != nil { - return false, err - } - - constraints, err := v.NewConstraint(">= " + minVer) - if err != nil { - return false, err - } - - return constraints.Check(peerNBVer), nil -} - // IsDevelopmentVersion reports whether the given version string identifies // a non-release / development build. It is the single source of truth for // "is this a dev build" checks across the codebase; use it instead of diff --git a/version/version_test.go b/version/version_test.go index f05bcbd87..cdba6b804 100644 --- a/version/version_test.go +++ b/version/version_test.go @@ -1,10 +1,6 @@ package version -import ( - "testing" - - "github.com/stretchr/testify/assert" -) +import "testing" func TestIsDevelopmentVersion(t *testing.T) { tests := []struct { @@ -30,68 +26,3 @@ func TestIsDevelopmentVersion(t *testing.T) { }) } } - -func TestMeetsMinVersion(t *testing.T) { - tests := []struct { - name string - minVer string - peerVer string - want bool - wantErr bool - }{ - { - name: "Peer version greater than min version", - minVer: "0.26.0", - peerVer: "0.60.1", - want: true, - wantErr: false, - }, - { - name: "Peer version equals min version", - minVer: "1.0.0", - peerVer: "1.0.0", - want: true, - wantErr: false, - }, - { - name: "Peer version less than min version", - minVer: "1.0.0", - peerVer: "0.9.9", - want: false, - wantErr: false, - }, - { - name: "Peer version with pre-release tag greater than min version", - minVer: "1.0.0", - peerVer: "1.0.1-alpha", - want: true, - wantErr: false, - }, - { - name: "Invalid peer version format", - minVer: "1.0.0", - peerVer: "dev", - want: false, - wantErr: true, - }, - { - name: "Invalid min version format", - minVer: "invalid.version", - peerVer: "1.0.0", - want: false, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := MeetsMinVersion(tt.minVer, tt.peerVer) - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - assert.Equal(t, tt.want, got) - }) - } -}