diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 0661e0c71..9b6a0edfd 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.25-bookworm +FROM golang:1.26.7-bookworm RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ && apt-get -y install --no-install-recommends\ diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml index 004b78b3e..f24dfbe9d 100644 --- a/.github/workflows/golang-test-linux.yml +++ b/.github/workflows/golang-test-linux.yml @@ -233,7 +233,7 @@ jobs: -e GOCACHE=${CONTAINER_GOCACHE} \ -e GOMODCACHE=${CONTAINER_GOMODCACHE} \ -e CONTAINER=${CONTAINER} \ - golang:1.25-alpine \ + golang:1.26.7-alpine \ sh -c ' \ apk update; apk add --no-cache \ ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base; \ @@ -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/.github/workflows/release.yml b/.github/workflows/release.yml index 4d1945451..c1bbe9c44 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -215,7 +215,7 @@ jobs: echo "GPG_RPM_KEY_FILE=/tmp/gpg-rpm-signing-key.asc" >> $GITHUB_ENV - name: Install goversioninfo - run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e + run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@b66839b # v1.7.0 - name: Generate windows syso amd64 run: goversioninfo -icon client/ui/build/windows/icon.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_amd64.syso - name: Generate windows syso arm64 @@ -435,7 +435,7 @@ jobs: tar -xf llvm-mingw-20250709-ucrt-ubuntu-22.04-x86_64.tar.xz echo "/tmp/llvm-mingw-20250709-ucrt-ubuntu-22.04-x86_64/bin" >> $GITHUB_PATH - name: Install goversioninfo - run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e + run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@b66839b # v1.7.0 - name: Install wails3 CLI # Version derived from go.mod so the binding generator always matches # the wails runtime the binary links against. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9dea37ec8..aef749cfa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -192,7 +192,7 @@ dependencies are installed. Here is a short guide on how that can be done. ### Requirements -#### Go 1.25 +#### Go 1.26 Follow the installation guide from https://go.dev/ @@ -200,7 +200,7 @@ Follow the installation guide from https://go.dev/ The desktop UI client (`client/ui`) is built with [Wails v3](https://v3.wails.io/) and a React frontend rendered in a WebView. To build it you need: -- Go ≥ 1.25 +- Go ≥ 1.26 - Node ≥ 20 and **pnpm** (`corepack enable && corepack prepare pnpm@latest --activate`) - The `wails3` CLI: `go install github.com/wailsapp/wails/v3/cmd/wails3@latest` - The `task` runner: `go install github.com/go-task/task/v3/cmd/task@latest` diff --git a/client/android/split_tunnel.go b/client/android/split_tunnel.go new file mode 100644 index 000000000..59ac539f9 --- /dev/null +++ b/client/android/split_tunnel.go @@ -0,0 +1,106 @@ +package android + +// Split tunnelling modes, stored as strings so an unknown value written by a +// newer build degrades to "off" rather than to some other mode's behaviour. +const ( + SplitTunnelModeOff = "off" + SplitTunnelModeExclude = "exclude" + SplitTunnelModeInclude = "include" +) + +type splitTunnelSection struct { + Mode string `json:"mode"` + Excluded []string `json:"excluded"` + Included []string `json:"included"` +} + +// PackageList wraps []string for gomobile compatibility. +type PackageList struct { + items []string +} + +// NewPackageList creates an empty list to fill via Add. +func NewPackageList() *PackageList { + return &PackageList{} +} + +// Add appends a package name, ignoring empty ones. +func (l *PackageList) Add(s string) { + if s == "" { + return + } + l.items = append(l.items, s) +} + +// Size returns the number of entries. +func (l *PackageList) Size() int { + return len(l.items) +} + +// Get returns the entry at index i, or an empty string when out of range. +func (l *PackageList) Get(i int) string { + if i < 0 || i >= len(l.items) { + return "" + } + return l.items[i] +} + +// SplitTunnelSettings is one profile's choice of which applications the tunnel +// carries. The two selections are kept apart because the platform applies one +// or the other and never both, and so that switching mode does not throw away +// the picks made in the other one. +type SplitTunnelSettings struct { + Mode string + Excluded *PackageList + Included *PackageList +} + +// NewSplitTunnelSettings creates settings that carry every application. +func NewSplitTunnelSettings() *SplitTunnelSettings { + return &SplitTunnelSettings{ + Mode: SplitTunnelModeOff, + Excluded: NewPackageList(), + Included: NewPackageList(), + } +} + +func packagesOf(list *PackageList) []string { + if list == nil { + return nil + } + out := make([]string, 0, len(list.items)) + out = append(out, list.items...) + return out +} + +func normalizeSplitTunnelMode(mode string) string { + switch mode { + case SplitTunnelModeExclude, SplitTunnelModeInclude: + return mode + default: + return SplitTunnelModeOff + } +} + +func settingsFromSection(section splitTunnelSection) *SplitTunnelSettings { + out := NewSplitTunnelSettings() + out.Mode = normalizeSplitTunnelMode(section.Mode) + for _, pkg := range section.Excluded { + out.Excluded.Add(pkg) + } + for _, pkg := range section.Included { + out.Included.Add(pkg) + } + return out +} + +func sectionFromSettings(settings *SplitTunnelSettings) splitTunnelSection { + if settings == nil { + settings = NewSplitTunnelSettings() + } + return splitTunnelSection{ + Mode: normalizeSplitTunnelMode(settings.Mode), + Excluded: packagesOf(settings.Excluded), + Included: packagesOf(settings.Included), + } +} diff --git a/client/android/split_tunnel_store.go b/client/android/split_tunnel_store.go new file mode 100644 index 000000000..f54e0c8ef --- /dev/null +++ b/client/android/split_tunnel_store.go @@ -0,0 +1,34 @@ +//go:build android + +package android + +const splitTunnelNamespace = "split-tunnel" + +// SplitTunnelStore reads and writes a profile's split tunnelling settings. +type SplitTunnelStore struct { + prefs prefsStore +} + +// NewSplitTunnelStore opens the split tunnelling store of the given profile. +func NewSplitTunnelStore(configDir, profileID string) (*SplitTunnelStore, error) { + prefs, err := newProfilePrefs(configDir, profileID) + if err != nil { + return nil, err + } + return &SplitTunnelStore{prefs: prefs}, nil +} + +// Load returns the stored settings, or settings that carry every application +// when the profile has none saved. +func (s *SplitTunnelStore) Load() (*SplitTunnelSettings, error) { + var section splitTunnelSection + if _, err := s.prefs.Get(splitTunnelNamespace, §ion); err != nil { + return nil, err + } + return settingsFromSection(section), nil +} + +// Save replaces the stored settings. +func (s *SplitTunnelStore) Save(settings *SplitTunnelSettings) error { + return s.prefs.Put(splitTunnelNamespace, sectionFromSettings(settings)) +} diff --git a/client/android/split_tunnel_test.go b/client/android/split_tunnel_test.go new file mode 100644 index 000000000..b8465e8ef --- /dev/null +++ b/client/android/split_tunnel_test.go @@ -0,0 +1,109 @@ +package android + +import ( + "reflect" + "testing" +) + +func TestNormalizeSplitTunnelMode(t *testing.T) { + tests := []struct { + name string + mode string + want string + }{ + {name: "exclude is kept", mode: SplitTunnelModeExclude, want: SplitTunnelModeExclude}, + {name: "include is kept", mode: SplitTunnelModeInclude, want: SplitTunnelModeInclude}, + {name: "off is kept", mode: SplitTunnelModeOff, want: SplitTunnelModeOff}, + {name: "empty falls back to off", mode: "", want: SplitTunnelModeOff}, + {name: "a mode from a newer build falls back to off", mode: "only-work-apps", want: SplitTunnelModeOff}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := normalizeSplitTunnelMode(tt.mode); got != tt.want { + t.Errorf("normalizeSplitTunnelMode(%q) = %q, want %q", tt.mode, got, tt.want) + } + }) + } +} + +func TestSettingsFromSection(t *testing.T) { + got := settingsFromSection(splitTunnelSection{ + Mode: SplitTunnelModeExclude, + Excluded: []string{"com.example.a", "com.example.b"}, + Included: []string{"com.example.c"}, + }) + + if got.Mode != SplitTunnelModeExclude { + t.Errorf("mode = %q, want %q", got.Mode, SplitTunnelModeExclude) + } + if got.Excluded.Size() != 2 || got.Excluded.Get(0) != "com.example.a" { + t.Errorf("excluded = %v, want the two stored packages", packagesOf(got.Excluded)) + } + if got.Included.Size() != 1 || got.Included.Get(0) != "com.example.c" { + t.Errorf("included = %v, want the stored package", packagesOf(got.Included)) + } +} + +// A profile that has never stored anything decodes into an empty section, and +// must come back as settings that carry every application rather than as nil +// lists the caller would have to guard against. +func TestSettingsFromEmptySectionCarriesEverything(t *testing.T) { + got := settingsFromSection(splitTunnelSection{}) + + if got.Mode != SplitTunnelModeOff { + t.Errorf("mode = %q, want %q", got.Mode, SplitTunnelModeOff) + } + if got.Excluded == nil || got.Included == nil { + t.Fatal("both selections must be usable lists, not nil") + } + if got.Excluded.Size() != 0 || got.Included.Size() != 0 { + t.Errorf("selections = %v/%v, want both empty", packagesOf(got.Excluded), packagesOf(got.Included)) + } +} + +func TestSectionFromSettingsRoundTrip(t *testing.T) { + settings := NewSplitTunnelSettings() + settings.Mode = SplitTunnelModeInclude + settings.Included.Add("com.example.a") + settings.Excluded.Add("com.example.b") + + section := sectionFromSettings(settings) + back := settingsFromSection(section) + + if back.Mode != SplitTunnelModeInclude { + t.Errorf("mode = %q, want %q", back.Mode, SplitTunnelModeInclude) + } + if !reflect.DeepEqual(packagesOf(back.Included), []string{"com.example.a"}) { + t.Errorf("included = %v, want [com.example.a]", packagesOf(back.Included)) + } + // The inactive selection survives, so switching mode back does not make the + // user pick their applications again. + if !reflect.DeepEqual(packagesOf(back.Excluded), []string{"com.example.b"}) { + t.Errorf("excluded = %v, want [com.example.b]", packagesOf(back.Excluded)) + } +} + +func TestSectionFromNilSettings(t *testing.T) { + section := sectionFromSettings(nil) + + if section.Mode != SplitTunnelModeOff { + t.Errorf("mode = %q, want %q", section.Mode, SplitTunnelModeOff) + } + if len(section.Excluded) != 0 || len(section.Included) != 0 { + t.Errorf("selections = %v/%v, want both empty", section.Excluded, section.Included) + } +} + +func TestPackageListIgnoresEmptyAndBounds(t *testing.T) { + list := NewPackageList() + list.Add("com.example.a") + list.Add("") + + if list.Size() != 1 { + t.Errorf("size = %d, want 1", list.Size()) + } + if list.Get(-1) != "" || list.Get(5) != "" { + t.Error("out of range access must return an empty string") + } +} diff --git a/client/cmd/root.go b/client/cmd/root.go index ccad78942..be6479440 100644 --- a/client/cmd/root.go +++ b/client/cmd/root.go @@ -23,6 +23,7 @@ import ( "github.com/netbirdio/netbird/client/anonymize" daddr "github.com/netbirdio/netbird/client/internal/daemonaddr" + "github.com/netbirdio/netbird/client/internal/localmetrics" "github.com/netbirdio/netbird/client/internal/profilemanager" ) @@ -31,6 +32,8 @@ const ( dnsResolverAddress = "dns-resolver-address" enableRosenpassFlag = "enable-rosenpass" rosenpassPermissiveFlag = "rosenpass-permissive" + enableLocalMetricsFlag = "enable-local-metrics" + localMetricsAddressFlag = "local-metrics-address" preSharedKeyFlag = "preshared-key" interfaceNameFlag = "interface-name" wireguardPortFlag = "wireguard-port" @@ -80,6 +83,8 @@ var ( updateSettingsDisabled bool captureEnabled bool networksDisabled bool + localMetricsEnabled bool + localMetricsAddr string rootCmd = &cobra.Command{ Use: "netbird", @@ -215,6 +220,8 @@ func init() { upCmd.PersistentFlags().BoolVar(&rosenpassEnabled, enableRosenpassFlag, false, "[Experimental] Enable Rosenpass feature. If enabled, the connection will be post-quantum secured via Rosenpass.") upCmd.PersistentFlags().BoolVar(&rosenpassPermissive, rosenpassPermissiveFlag, false, "[Experimental] Enable Rosenpass in permissive mode to allow this peer to accept WireGuard connections without requiring Rosenpass functionality from peers that do not have Rosenpass enabled.") upCmd.PersistentFlags().BoolVar(&autoConnectDisabled, disableAutoConnectFlag, false, "Disables auto-connect feature. If enabled, then the client won't connect automatically when the service starts.") + upCmd.PersistentFlags().BoolVar(&localMetricsEnabled, enableLocalMetricsFlag, false, "Enables a local Prometheus /metrics endpoint exposing connection state (peers, latency, P2P vs relay).") + upCmd.PersistentFlags().StringVar(&localMetricsAddr, localMetricsAddressFlag, localmetrics.DefaultListenAddress, "Listen address of the local Prometheus /metrics endpoint.") upCmd.PersistentFlags().BoolVar(&lazyConnEnabled, enableLazyConnectionFlag, false, "Deprecated: no longer used. Lazy connections are controlled by the server and the NB_LAZY_CONN environment variable.") _ = upCmd.PersistentFlags().MarkDeprecated(enableLazyConnectionFlag, "no longer used; lazy connections are controlled by the server and the NB_LAZY_CONN environment variable") 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/cmd/up.go b/client/cmd/up.go index 9f4fa8c33..5bc41a964 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -398,26 +398,10 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ return nil } -func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, profileName, username string) *proto.SetConfigRequest { - var req proto.SetConfigRequest - req.ProfileName = profileName - req.Username = username - - req.ManagementUrl = managementURL - req.AdminURL = adminURL - req.NatExternalIPs = natExternalIPs - req.CustomDNSAddress = customDNSAddressConverted - req.ExtraIFaceBlacklist = extraIFaceBlackList - req.DnsLabels = dnsLabelsValidated.ToPunycodeList() - req.CleanDNSLabels = dnsLabels != nil && len(dnsLabels) == 0 - req.CleanNATExternalIPs = natExternalIPs != nil && len(natExternalIPs) == 0 - - if cmd.Flag(enableRosenpassFlag).Changed { - req.RosenpassEnabled = &rosenpassEnabled - } - if cmd.Flag(rosenpassPermissiveFlag).Changed { - req.RosenpassPermissive = &rosenpassPermissive - } +// setSSHSetConfigFields copies the SSH server flags the user actually +// passed into req, leaving the rest unset so the daemon keeps the +// persisted values. +func setSSHSetConfigFields(req *proto.SetConfigRequest, cmd *cobra.Command) { if cmd.Flag(serverSSHAllowedFlag).Changed { req.ServerSSHAllowed = &serverSSHAllowed } @@ -440,6 +424,30 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro sshJWTCacheTTL32 := int32(sshJWTCacheTTL) req.SshJWTCacheTTL = &sshJWTCacheTTL32 } +} + +func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, profileName, username string) *proto.SetConfigRequest { + var req proto.SetConfigRequest + req.ProfileName = profileName + req.Username = username + + req.ManagementUrl = managementURL + req.AdminURL = adminURL + req.NatExternalIPs = natExternalIPs + req.CustomDNSAddress = customDNSAddressConverted + req.ExtraIFaceBlacklist = extraIFaceBlackList + req.DnsLabels = dnsLabelsValidated.ToPunycodeList() + req.CleanDNSLabels = dnsLabels != nil && len(dnsLabels) == 0 + req.CleanNATExternalIPs = natExternalIPs != nil && len(natExternalIPs) == 0 + + if cmd.Flag(enableRosenpassFlag).Changed { + req.RosenpassEnabled = &rosenpassEnabled + } + if cmd.Flag(rosenpassPermissiveFlag).Changed { + req.RosenpassPermissive = &rosenpassPermissive + } + setSSHSetConfigFields(&req, cmd) + if cmd.Flag(interfaceNameFlag).Changed { if err := parseInterfaceName(interfaceName); err != nil { log.Errorf("parse interface name: %v", err) @@ -499,6 +507,13 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro req.DisableIpv6 = &disableIPv6 } + if cmd.Flag(enableLocalMetricsFlag).Changed { + req.EnableLocalMetrics = &localMetricsEnabled + } + if cmd.Flag(localMetricsAddressFlag).Changed { + req.LocalMetricsAddress = &localMetricsAddr + } + return &req } @@ -616,9 +631,45 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil ic.DisableIPv6 = &disableIPv6 } + if cmd.Flag(enableLocalMetricsFlag).Changed { + ic.LocalMetricsEnabled = &localMetricsEnabled + } + + if cmd.Flag(localMetricsAddressFlag).Changed { + ic.LocalMetricsAddress = &localMetricsAddr + } + return &ic, nil } +// setSSHLoginFields copies the SSH server flags the user actually passed +// into req, leaving the rest unset so the daemon keeps the persisted +// values. +func setSSHLoginFields(req *proto.LoginRequest, cmd *cobra.Command) { + if cmd.Flag(serverSSHAllowedFlag).Changed { + req.ServerSSHAllowed = &serverSSHAllowed + } + if cmd.Flag(enableSSHRootFlag).Changed { + req.EnableSSHRoot = &enableSSHRoot + } + if cmd.Flag(enableSSHSFTPFlag).Changed { + req.EnableSSHSFTP = &enableSSHSFTP + } + if cmd.Flag(enableSSHLocalPortForwardFlag).Changed { + req.EnableSSHLocalPortForwarding = &enableSSHLocalPortForward + } + if cmd.Flag(enableSSHRemotePortForwardFlag).Changed { + req.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward + } + if cmd.Flag(disableSSHAuthFlag).Changed { + req.DisableSSHAuth = &disableSSHAuth + } + if cmd.Flag(sshJWTCacheTTLFlag).Changed { + sshJWTCacheTTL32 := int32(sshJWTCacheTTL) + req.SshJWTCacheTTL = &sshJWTCacheTTL32 + } +} + func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte, cmd *cobra.Command) (*proto.LoginRequest, error) { loginRequest := proto.LoginRequest{ SetupKey: providedSetupKey, @@ -645,39 +696,20 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte loginRequest.RosenpassPermissive = &rosenpassPermissive } - if cmd.Flag(serverSSHAllowedFlag).Changed { - loginRequest.ServerSSHAllowed = &serverSSHAllowed - } - - if cmd.Flag(enableSSHRootFlag).Changed { - loginRequest.EnableSSHRoot = &enableSSHRoot - } - - if cmd.Flag(enableSSHSFTPFlag).Changed { - loginRequest.EnableSSHSFTP = &enableSSHSFTP - } - - if cmd.Flag(enableSSHLocalPortForwardFlag).Changed { - loginRequest.EnableSSHLocalPortForwarding = &enableSSHLocalPortForward - } - - if cmd.Flag(enableSSHRemotePortForwardFlag).Changed { - loginRequest.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward - } - - if cmd.Flag(disableSSHAuthFlag).Changed { - loginRequest.DisableSSHAuth = &disableSSHAuth - } - - if cmd.Flag(sshJWTCacheTTLFlag).Changed { - sshJWTCacheTTL32 := int32(sshJWTCacheTTL) - loginRequest.SshJWTCacheTTL = &sshJWTCacheTTL32 - } + setSSHLoginFields(&loginRequest, cmd) if cmd.Flag(disableAutoConnectFlag).Changed { loginRequest.DisableAutoConnect = &autoConnectDisabled } + if cmd.Flag(enableLocalMetricsFlag).Changed { + loginRequest.EnableLocalMetrics = &localMetricsEnabled + } + + if cmd.Flag(localMetricsAddressFlag).Changed { + loginRequest.LocalMetricsAddress = &localMetricsAddr + } + if cmd.Flag(interfaceNameFlag).Changed { if err := parseInterfaceName(interfaceName); err != nil { return nil, err 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/internal/debug/debug.go b/client/internal/debug/debug.go index 1d31c75ca..7bb71c53b 100644 --- a/client/internal/debug/debug.go +++ b/client/internal/debug/debug.go @@ -737,6 +737,8 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder) configContent.WriteString(fmt.Sprintf("BlockLANAccess: %v\n", g.internalConfig.BlockLANAccess)) configContent.WriteString(fmt.Sprintf("BlockInbound: %v\n", g.internalConfig.BlockInbound)) configContent.WriteString(fmt.Sprintf("DisableIPv6: %v\n", g.internalConfig.DisableIPv6)) + configContent.WriteString(fmt.Sprintf("LocalMetricsEnabled: %v\n", g.internalConfig.LocalMetricsEnabled)) + configContent.WriteString(fmt.Sprintf("LocalMetricsAddress: %s\n", g.internalConfig.LocalMetricsAddress)) configContent.WriteString(fmt.Sprintf("SyncMessageVersion: %v\n", g.internalConfig.SyncMessageVersion)) if g.internalConfig.DisableNotifications != nil { diff --git a/client/internal/dns/host_windows.go b/client/internal/dns/host_windows.go index 53380b2aa..948000a3d 100644 --- a/client/internal/dns/host_windows.go +++ b/client/internal/dns/host_windows.go @@ -6,8 +6,10 @@ import ( "fmt" "io" "net/netip" + "os" "os/exec" "slices" + "strconv" "strings" "syscall" "time" @@ -34,10 +36,16 @@ var ( // Registry locations of the host DNS configuration this package programs, // exported so a diagnostic reader reports the same locations that are written. const ( - // NRPTKeyPrefix starts the name of every NRPT rule key this client creates. - // Older versions used different layouts under the same prefix: a single - // unsuffixed key, then one key per domain, now one key per batch of domains. - NRPTKeyPrefix = "NetBird-Match" + // NRPTKeyPrefix starts the name of every NRPT rule key this client creates: + // the match rules, the catch-all, and the .local exemption. Cleanup + // enumerates by this prefix, so a new kind of rule is removed by existing + // code as long as its key starts here. + NRPTKeyPrefix = "NetBird-" + + // nrptMatchKeyName names the match-domain rules. Older versions used + // different layouts under the same name: a single unsuffixed key, then one + // key per domain, now one key per batch of domains. + nrptMatchKeyName = NRPTKeyPrefix + "Match" // DNSPolicyConfigRoot holds the NRPT rules of the local policy store. DNSPolicyConfigRoot = `SYSTEM\CurrentControlSet\Services\Dnscache\Parameters\DnsPolicyConfig` @@ -53,8 +61,24 @@ const ( ) const ( - dnsPolicyConfigMatchPath = DNSPolicyConfigRoot + `\` + NRPTKeyPrefix - gpoDnsPolicyConfigMatchPath = GPODNSPolicyConfigRoot + `\` + NRPTKeyPrefix + dnsPolicyConfigMatchPath = DNSPolicyConfigRoot + `\` + nrptMatchKeyName + gpoDnsPolicyConfigMatchPath = GPODNSPolicyConfigRoot + `\` + nrptMatchKeyName + + dnsPolicyConfigExemptLocalPath = DNSPolicyConfigRoot + `\` + NRPTKeyPrefix + `ExemptLocal` + gpoDnsPolicyConfigExemptLocalPath = GPODNSPolicyConfigRoot + `\` + NRPTKeyPrefix + `ExemptLocal` + + nrptCatchAllNamespace = "." + // nrptLocalNamespace is reserved for multicast DNS by RFC 6762: a unicast + // resolver must not answer for it. The catch-all rule would hand it to us + // anyway, so it gets an exemption rule of its own. + nrptLocalNamespace = ".local" + + // envLegacyDNSResolution restores the pre-catch-all behaviour: the adapter's + // NameServer alone, leaving the OS free to query other adapters' resolvers in + // parallel. An escape hatch for setups that depend on a resolver of theirs + // still being reachable while connected, at the cost of the leak and of the + // race the catch-all rule exists to close. + envLegacyDNSResolution = "NB_USE_LEGACY_DNS_RESOLUTION" dnsPolicyConfigVersionKey = "Version" dnsPolicyConfigVersionValue = 2 @@ -293,6 +317,13 @@ func (r *registryConfigurator) disableWINSForInterface() error { } func (r *registryConfigurator) applyDNSConfig(config HostDNSConfig, stateManager *statemanager.Manager) error { + // Clear every rule the previous apply installed before installing any new + // one, including a leftover catch-all: removal is unconditional so a rule + // from an earlier run cannot survive into a config that no longer wants it. + if err := r.removeDNSMatchPolicies(); err != nil { + log.Errorf("cleanup old dns match policies: %s", err) + } + if config.RouteAll { if err := r.addDNSSetupForAll(config.ServerIP); err != nil { return fmt.Errorf("add dns setup: %w", err) @@ -318,8 +349,22 @@ func (r *registryConfigurator) applyDNSConfig(config HostDNSConfig, stateManager matchDomains = append(matchDomains, "."+strings.TrimSuffix(dConf.Domain, ".")) } - if err := r.removeDNSMatchPolicies(); err != nil { - log.Errorf("cleanup old dns match policies: %s", err) + // The root namespace is a match domain like any other: it just happens to + // match every name. Without it the adapter's NameServer only adds one more + // resolver to the set Windows queries in parallel, keeping whichever answer + // comes back first — which leaks every query to the local network and lets a + // resolver other than ours answer for a name we are authoritative for. + if config.RouteAll { + if parseBoolEnv(envLegacyDNSResolution) { + log.Infof("%s is set, leaving DNS resolution shared with the other adapters' resolvers instead of forcing it through %s", envLegacyDNSResolution, config.ServerIP) + } else { + matchDomains = append(matchDomains, nrptCatchAllNamespace) + log.Infof("routing every namespace through %s: DNS resolution is now exclusive to NetBird", config.ServerIP) + + if err := r.addDNSExemptLocalPolicy(); err != nil { + return fmt.Errorf("add dns exempt policy: %w", err) + } + } } if len(matchDomains) != 0 { @@ -397,6 +442,42 @@ func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr return nil } +// addDNSExemptLocalPolicy carves .local back out of the catch-all. RFC 6762 +// reserves it for multicast DNS, so forwarding those names to a unicast +// upstream answers NXDOMAIN for hosts that do exist - printers, NAS boxes, and +// anything else announcing itself on the link - and the answer is authoritative +// enough that Windows stops looking. A rule naming the namespace with no +// servers hands it back to the DNS client untouched. A more specific rule still +// wins, so a match domain under .local keeps going through us. +func (r *registryConfigurator) addDNSExemptLocalPolicy() error { + var noServers netip.Addr + + if err := r.configureDNSPolicy(dnsPolicyConfigExemptLocalPath, []string{nrptLocalNamespace}, noServers); err != nil { + return fmt.Errorf("configure exempt policy for %s: %w", nrptLocalNamespace, err) + } + + if r.gpo { + if err := r.configureDNSPolicy(gpoDnsPolicyConfigExemptLocalPath, []string{nrptLocalNamespace}, noServers); err != nil { + return fmt.Errorf("configure gpo exempt policy for %s: %w", nrptLocalNamespace, err) + } + if err := refreshGroupPolicy(); err != nil { + log.Warnf("failed to refresh group policy: %v", err) + } + } + + log.Infof("added NRPT exemption for %s, leaving it to the OS resolver", nrptLocalNamespace) + return nil +} + +// configureDNSPolicy writes one NRPT rule. An invalid ip writes an exemption +// rule: the namespace with an empty server list, which tells the DNS client to +// resolve those names the way it would without any rule at all. +// +// The empty string is the whole difference, and it has to be written: dropping +// the value and clearing ConfigOptions instead produces a rule Windows treats +// as a no-op, keeps out of Get-DnsClientNrptPolicy -Effective, and ignores in +// favour of the catch-all. 0x8 says the server list is the meaningful part of +// the rule, and an empty list then means "no server, resolve normally". func (r *registryConfigurator) configureDNSPolicy(policyPath string, domains []string, ip netip.Addr) error { if err := removeRegistryKeyFromDNSPolicyConfig(policyPath); err != nil { return fmt.Errorf("remove existing dns policy: %w", err) @@ -416,7 +497,11 @@ func (r *registryConfigurator) configureDNSPolicy(policyPath string, domains []s return fmt.Errorf("set %s: %w", dnsPolicyConfigNameKey, err) } - if err := regKey.SetStringValue(dnsPolicyConfigGenericDNSServersKey, ip.String()); err != nil { + var servers string + if ip.IsValid() { + servers = ip.String() + } + if err := regKey.SetStringValue(dnsPolicyConfigGenericDNSServersKey, servers); err != nil { return fmt.Errorf("set %s: %w", dnsPolicyConfigGenericDNSServersKey, err) } @@ -514,8 +599,11 @@ func (r *registryConfigurator) getInterfaceRegistryKey() (registry.Key, error) { } func (r *registryConfigurator) restoreHostDNS() error { + // Propagated, unlike in applyDNSConfig: there we are about to write fresh + // rules over whatever survived, here we are leaving, and a rule left behind + // keeps sending every query to an address that is about to disappear. if err := r.removeDNSMatchPolicies(); err != nil { - log.Errorf("remove dns match policies: %s", err) + return fmt.Errorf("remove dns match policies: %w", err) } if err := r.deleteInterfaceRegistryKeyProperty(interfaceConfigSearchListKey); err != nil { @@ -598,9 +686,17 @@ func listNRPTRuleKeys(root string) ([]string, error) { func removeRegistryKeyFromDNSPolicyConfig(regKeyPath string) error { k, err := registry.OpenKey(registry.LOCAL_MACHINE, regKeyPath, registry.QUERY_VALUE) - if err != nil { - log.Debugf("failed to open HKEY_LOCAL_MACHINE\\%s: %v", regKeyPath, err) + switch { + case errors.Is(err, registry.ErrNotExist), errors.Is(err, syscall.ERROR_PATH_NOT_FOUND): + // nothing to remove, which is the normal case for a rule this config + // never installed + log.Debugf("HKEY_LOCAL_MACHINE\\%s does not exist", regKeyPath) return nil + case err != nil: + // anything else has to reach the caller: reporting success here would + // leave the rule in force while claiming it was removed, which is how a + // stale rule outlives the interface it points at + return fmt.Errorf("open HKEY_LOCAL_MACHINE\\%s: %w", regKeyPath, err) } closer(k) @@ -636,6 +732,20 @@ func refreshGroupPolicy() error { return nil } +func parseBoolEnv(key string) bool { + val := os.Getenv(key) + if val == "" { + return false + } + + parsed, err := strconv.ParseBool(val) + if err != nil { + log.Warnf("failed to parse %s=%q: %v", key, val, err) + return false + } + return parsed +} + func closer(closer io.Closer) { if err := closer.Close(); err != nil { log.Errorf("failed to close: %s", err) diff --git a/client/internal/dns/host_windows_test.go b/client/internal/dns/host_windows_test.go index 861613c95..7aef64590 100644 --- a/client/internal/dns/host_windows_test.go +++ b/client/internal/dns/host_windows_test.go @@ -94,6 +94,145 @@ func TestNRPTEntriesCleanupOnConfigChange(t *testing.T) { assert.False(t, exists, "NRPT rule 2 should NOT exist after reducing to 75 domains") } +// TestNRPTCatchAllRule verifies that RouteAll adds the root namespace to the +// match rule instead of a rule of its own, that .local is carved back out with +// an empty server list, and that both go away when RouteAll is cleared or the +// host DNS is restored. +func TestNRPTCatchAllRule(t *testing.T) { + if testing.Short() { + t.Skip("skipping registry integration test in short mode") + } + + defer cleanupRegistryKeys(t) + cleanupRegistryKeys(t) + + testIP := netip.MustParseAddr("100.64.0.1") + testGUID := "{12345678-1234-1234-1234-123456789ABC}" + interfacePath := InterfaceConfigPath + `\` + testGUID + testKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, interfacePath, registry.SET_VALUE) + require.NoError(t, err, "Should create test interface registry key") + require.NoError(t, testKey.Close(), "close test interface registry key") + defer func() { + assert.NoError(t, registry.DeleteKey(registry.LOCAL_MACHINE, interfacePath), "delete test interface registry key") + }() + + cfg := ®istryConfigurator{guid: testGUID} + + matchOnly := HostDNSConfig{ + ServerIP: testIP, + Domains: []DomainConfig{{Domain: "example.com", MatchOnly: true}}, + } + primary := HostDNSConfig{ + ServerIP: testIP, + RouteAll: true, + Domains: []DomainConfig{{Domain: "example.com", MatchOnly: true}}, + } + firstRule := fmt.Sprintf("%s-0", dnsPolicyConfigMatchPath) + + // The root namespace is not a rule of its own: it rides in the match rule, + // which is the point of it not being a special case. + require.NoError(t, cfg.applyDNSConfig(matchOnly, nil)) + names := ruleNamespaces(t, firstRule) + assert.Contains(t, names, ".example.com") + assert.NotContains(t, names, nrptCatchAllNamespace, "a match-only config must not claim every namespace") + + require.NoError(t, cfg.applyDNSConfig(primary, nil)) + names = ruleNamespaces(t, firstRule) + assert.Contains(t, names, ".example.com") + assert.Contains(t, names, nrptCatchAllNamespace, "RouteAll should add the root namespace to the match rule") + + k, err := registry.OpenKey(registry.LOCAL_MACHINE, firstRule, registry.QUERY_VALUE) + require.NoError(t, err) + servers, _, err := k.GetStringValue(dnsPolicyConfigGenericDNSServersKey) + require.NoError(t, err) + assert.Equal(t, testIP.String(), servers, "every namespace in the rule resolves through our resolver") + require.NoError(t, k.Close(), "close match rule key") + + // .local is carved back out: RFC 6762 reserves it for mDNS, so it needs a + // rule of its own — it is the one rule with a different server list. + ek, err := registry.OpenKey(registry.LOCAL_MACHINE, dnsPolicyConfigExemptLocalPath, registry.QUERY_VALUE) + require.NoError(t, err, "exemption rule should exist once the root namespace is claimed") + + exemptNames, _, err := ek.GetStringsValue(dnsPolicyConfigNameKey) + require.NoError(t, err) + assert.Equal(t, []string{nrptLocalNamespace}, exemptNames, "the exemption should name only the mDNS namespace") + + exemptServers, _, err := ek.GetStringValue(dnsPolicyConfigGenericDNSServersKey) + require.NoError(t, err, "the value has to be present, empty: without it Windows drops the rule") + assert.Empty(t, exemptServers, "an exemption rule lists no servers") + + exemptOpts, _, err := ek.GetIntegerValue(dnsPolicyConfigConfigOptionsKey) + require.NoError(t, err) + assert.EqualValues(t, dnsPolicyConfigConfigOptionsValue, exemptOpts, "same options as a normal rule; the empty server list is what makes it an exemption") + require.NoError(t, ek.Close(), "close exemption rule key") + + require.NoError(t, cfg.applyDNSConfig(matchOnly, nil)) + names = ruleNamespaces(t, firstRule) + assert.NotContains(t, names, nrptCatchAllNamespace, "clearing RouteAll should drop the root namespace") + + exists, err := registryKeyExists(dnsPolicyConfigExemptLocalPath) + require.NoError(t, err) + assert.False(t, exists, "exemption rule should go with the namespace it carves out of") + + require.NoError(t, cfg.applyDNSConfig(primary, nil)) + require.NoError(t, cfg.restoreHostDNS()) + exists, err = registryKeyExists(firstRule) + require.NoError(t, err) + assert.False(t, exists, "restore should leave no rule behind") +} + +// ruleNamespaces returns the namespaces an NRPT rule key claims. +func ruleNamespaces(t *testing.T, path string) []string { + t.Helper() + k, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.QUERY_VALUE) + require.NoError(t, err, "rule key %s should exist", path) + defer k.Close() + + names, _, err := k.GetStringsValue(dnsPolicyConfigNameKey) + require.NoError(t, err) + return names +} + +// TestNRPTCatchAllRuleLegacyEnv verifies that NB_USE_LEGACY_DNS_RESOLUTION +// leaves the root namespace unclaimed, so no rule is written for a RouteAll +// config that carries no match domains. +func TestNRPTCatchAllRuleLegacyEnv(t *testing.T) { + if testing.Short() { + t.Skip("skipping registry integration test in short mode") + } + + defer cleanupRegistryKeys(t) + cleanupRegistryKeys(t) + + t.Setenv(envLegacyDNSResolution, "true") + + testGUID := "{12345678-1234-1234-1234-123456789ABC}" + interfacePath := InterfaceConfigPath + `\` + testGUID + testKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, interfacePath, registry.SET_VALUE) + require.NoError(t, err, "Should create test interface registry key") + require.NoError(t, testKey.Close(), "close test interface registry key") + defer func() { + assert.NoError(t, registry.DeleteKey(registry.LOCAL_MACHINE, interfacePath), "delete test interface registry key") + }() + + cfg := ®istryConfigurator{guid: testGUID} + config := HostDNSConfig{ + ServerIP: netip.MustParseAddr("100.64.0.1"), + RouteAll: true, + } + + require.NoError(t, cfg.applyDNSConfig(config, nil)) + + // RouteAll with no match domains and the switch set leaves nothing to write. + exists, err := registryKeyExists(fmt.Sprintf("%s-0", dnsPolicyConfigMatchPath)) + require.NoError(t, err) + assert.False(t, exists, "no rule should be written when the legacy env var is set") + + exists, err = registryKeyExists(dnsPolicyConfigExemptLocalPath) + require.NoError(t, err) + assert.False(t, exists, "no exemption without a claimed root namespace") +} + func registryKeyExists(path string) (bool, error) { k, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.QUERY_VALUE) if err != nil { 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/localmetrics/localmetrics.go b/client/internal/localmetrics/localmetrics.go new file mode 100644 index 000000000..f829fa132 --- /dev/null +++ b/client/internal/localmetrics/localmetrics.go @@ -0,0 +1,274 @@ +// Package localmetrics exposes client connection state as a local +// Prometheus /metrics endpoint. +package localmetrics + +import ( + "context" + "errors" + "net" + "net/http" + "net/netip" + "sync" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" + dto "github.com/prometheus/client_model/go" + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/peer" +) + +// DefaultListenAddress is used when local metrics are enabled without an explicit address. +const DefaultListenAddress = "127.0.0.1:9191" + +const ( + shutdownTimeout = 3 * time.Second + readHeaderTimeout = 5 * time.Second + readTimeout = 10 * time.Second + writeTimeout = 30 * time.Second + idleTimeout = time.Minute +) + +// statusSource provides the connection state snapshots the collector reads on scrape. +type statusSource interface { + GetPeerStates() []peer.State + GetManagementState() peer.ManagementState + GetSignalState() peer.SignalState +} + +// GathererProvider returns the current client metrics gatherer, or nil when +// no engine is running. It is called on every scrape. +type GathererProvider func() prometheus.Gatherer + +// Manager runs the local /metrics HTTP endpoint according to the active +// client configuration. Reconcile is safe to call on every config change. +type Manager struct { + status statusSource + clientMetrics GathererProvider + + mu sync.Mutex + srv *http.Server + addr string +} + +// NewManager creates a manager that serves metrics from status and +// clientMetrics and shuts down when ctx is canceled. +func NewManager(ctx context.Context, status statusSource, clientMetrics GathererProvider) *Manager { + m := &Manager{status: status, clientMetrics: clientMetrics} + go func() { + <-ctx.Done() + m.Stop() + }() + return m +} + +// Reconcile starts, stops, or restarts the metrics endpoint to match the +// desired state. An empty addr falls back to DefaultListenAddress. +func (m *Manager) Reconcile(enabled bool, addr string) { + if addr == "" { + addr = DefaultListenAddress + } + warnIfNotLoopback(addr) + + m.mu.Lock() + defer m.mu.Unlock() + + if !enabled { + m.stop() + return + } + if m.srv != nil && m.addr == addr { + return + } + m.stop() + + registry := prometheus.NewRegistry() + registry.MustRegister(newCollector(m.status)) + + gatherers := prometheus.Gatherers{registry, prometheus.GathererFunc(func() ([]*dto.MetricFamily, error) { + if m.clientMetrics == nil { + return nil, nil + } + g := m.clientMetrics() + if g == nil { + return nil, nil + } + return g.Gather() + })} + + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.HandlerFor(gatherers, promhttp.HandlerOpts{})) + + srv := &http.Server{ + Addr: addr, + Handler: mux, + ReadHeaderTimeout: readHeaderTimeout, + ReadTimeout: readTimeout, + WriteTimeout: writeTimeout, + IdleTimeout: idleTimeout, + } + m.srv = srv + m.addr = addr + + log.Infof("serving local metrics on http://%s/metrics", addr) + go func() { + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Errorf("failed to serve local metrics on %s: %v", addr, err) + m.clear(srv) + } + }() +} + +// clear drops the reference to srv so a later Reconcile with the same +// address restarts it. A newer server may already have replaced it, in +// which case the reference must stay. +func (m *Manager) clear(srv *http.Server) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.srv != srv { + return + } + m.srv = nil + m.addr = "" +} + +// Stop shuts down the metrics endpoint if it is running. +func (m *Manager) Stop() { + m.mu.Lock() + defer m.mu.Unlock() + m.stop() +} + +// stop shuts down the running server. Callers must hold m.mu. +func (m *Manager) stop() { + if m.srv == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + if err := m.srv.Shutdown(ctx); err != nil { + log.Debugf("failed to shut down local metrics server: %v", err) + } + m.srv = nil + m.addr = "" +} + +// collector converts status recorder snapshots into Prometheus metrics at scrape time. +type collector struct { + status statusSource + + managementConnected *prometheus.Desc + signalConnected *prometheus.Desc + peersTotal *prometheus.Desc + peersConnected *prometheus.Desc + peerLatency *prometheus.Desc +} + +func newCollector(status statusSource) *collector { + return &collector{ + status: status, + managementConnected: prometheus.NewDesc( + "netbird_management_connected", + "Whether the client is connected to the management service (1 connected, 0 disconnected).", + nil, nil, + ), + signalConnected: prometheus.NewDesc( + "netbird_signal_connected", + "Whether the client is connected to the signal service (1 connected, 0 disconnected).", + nil, nil, + ), + peersTotal: prometheus.NewDesc( + "netbird_peers", + "Number of peers known to this client.", + nil, nil, + ), + peersConnected: prometheus.NewDesc( + "netbird_peers_connected", + "Number of connected peers by connection type.", + []string{"connection_type"}, nil, + ), + peerLatency: prometheus.NewDesc( + "netbird_peer_latency_seconds", + "Round-trip latency per directly connected peer; relayed connections have no latency measurement.", + []string{"peer"}, nil, + ), + } +} + +// Describe implements prometheus.Collector. +func (c *collector) Describe(ch chan<- *prometheus.Desc) { + ch <- c.managementConnected + ch <- c.signalConnected + ch <- c.peersTotal + ch <- c.peersConnected + ch <- c.peerLatency +} + +// Collect implements prometheus.Collector. +func (c *collector) Collect(ch chan<- prometheus.Metric) { + ch <- prometheus.MustNewConstMetric(c.managementConnected, prometheus.GaugeValue, boolToFloat(c.status.GetManagementState().Connected)) + ch <- prometheus.MustNewConstMetric(c.signalConnected, prometheus.GaugeValue, boolToFloat(c.status.GetSignalState().Connected)) + + peers := c.status.GetPeerStates() + ch <- prometheus.MustNewConstMetric(c.peersTotal, prometheus.GaugeValue, float64(len(peers))) + + var p2p, relayed float64 + for _, p := range peers { + if p.ConnStatus != peer.StatusConnected { + continue + } + if p.Relayed { + relayed++ + continue + } + p2p++ + + if latency := p.Latency.Seconds(); latency > 0 { + ch <- prometheus.MustNewConstMetric(c.peerLatency, prometheus.GaugeValue, latency, p.FQDN) + } + } + ch <- prometheus.MustNewConstMetric(c.peersConnected, prometheus.GaugeValue, p2p, "p2p") + ch <- prometheus.MustNewConstMetric(c.peersConnected, prometheus.GaugeValue, relayed, "relay") +} + +func boolToFloat(b bool) float64 { + if b { + return 1 + } + return 0 +} + +// IsLoopback reports whether addr binds the endpoint to the local host only. +// An empty address means DefaultListenAddress. It fails closed: an address +// that cannot be confirmed loopback, including an unparseable one, is not. +func IsLoopback(addr string) bool { + if addr == "" { + addr = DefaultListenAddress + } + + host, _, err := net.SplitHostPort(addr) + if err != nil { + return false + } + if host == "localhost" { + return true + } + + ip, err := netip.ParseAddr(host) + if err != nil { + return false + } + return ip.Unmap().IsLoopback() +} + +// warnIfNotLoopback logs a warning when the listen address cannot be +// confirmed to be local-only, since the endpoint exposes peer and +// connectivity details without authentication. +func warnIfNotLoopback(addr string) { + if IsLoopback(addr) { + return + } + log.Warnf("local metrics endpoint listens on non-loopback address %s and is reachable from the network without authentication", addr) +} diff --git a/client/internal/localmetrics/localmetrics_test.go b/client/internal/localmetrics/localmetrics_test.go new file mode 100644 index 000000000..727137077 --- /dev/null +++ b/client/internal/localmetrics/localmetrics_test.go @@ -0,0 +1,151 @@ +package localmetrics + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "strings" + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/peer" +) + +type stubStatus struct { + peers []peer.State + management peer.ManagementState + signal peer.SignalState +} + +func (s *stubStatus) GetPeerStates() []peer.State { return s.peers } +func (s *stubStatus) GetManagementState() peer.ManagementState { return s.management } +func (s *stubStatus) GetSignalState() peer.SignalState { return s.signal } + +func testStatus() *stubStatus { + return &stubStatus{ + management: peer.ManagementState{Connected: true}, + signal: peer.SignalState{Connected: true}, + peers: []peer.State{ + {FQDN: "peer-a.netbird.cloud", IP: "100.90.0.1", ConnStatus: peer.StatusConnected, Relayed: false, Latency: 12 * time.Millisecond}, + {FQDN: "peer-b.netbird.cloud", IP: "100.90.0.2", ConnStatus: peer.StatusConnected, Relayed: false, Latency: 36 * time.Millisecond}, + {FQDN: "peer-c.netbird.cloud", IP: "100.90.0.3", ConnStatus: peer.StatusConnected, Relayed: true}, + {FQDN: "peer-d.netbird.cloud", IP: "100.90.0.4", ConnStatus: peer.StatusIdle}, + }, + } +} + +func TestCollector(t *testing.T) { + c := newCollector(testStatus()) + + expected := ` +# HELP netbird_management_connected Whether the client is connected to the management service (1 connected, 0 disconnected). +# TYPE netbird_management_connected gauge +netbird_management_connected 1 +# HELP netbird_peer_latency_seconds Round-trip latency per directly connected peer; relayed connections have no latency measurement. +# TYPE netbird_peer_latency_seconds gauge +netbird_peer_latency_seconds{peer="peer-a.netbird.cloud"} 0.012 +netbird_peer_latency_seconds{peer="peer-b.netbird.cloud"} 0.036 +# HELP netbird_peers Number of peers known to this client. +# TYPE netbird_peers gauge +netbird_peers 4 +# HELP netbird_peers_connected Number of connected peers by connection type. +# TYPE netbird_peers_connected gauge +netbird_peers_connected{connection_type="p2p"} 2 +netbird_peers_connected{connection_type="relay"} 1 +# HELP netbird_signal_connected Whether the client is connected to the signal service (1 connected, 0 disconnected). +# TYPE netbird_signal_connected gauge +netbird_signal_connected 1 +` + require.NoError(t, testutil.CollectAndCompare(c, strings.NewReader(expected))) +} + +func TestServe(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err, "must find a free port") + addr := ln.Addr().String() + require.NoError(t, ln.Close()) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + m := NewManager(ctx, testStatus(), nil) + m.Reconcile(true, addr) + + var body string + require.Eventually(t, func() bool { + resp, err := http.Get(fmt.Sprintf("http://%s/metrics", addr)) + if err != nil { + return false + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil || resp.StatusCode != http.StatusOK { + return false + } + body = string(data) + return true + }, 2*time.Second, 50*time.Millisecond, "metrics endpoint should come up") + + assert.Contains(t, body, "netbird_peers 4") + assert.Contains(t, body, `netbird_peers_connected{connection_type="relay"} 1`) + assert.Contains(t, body, `netbird_peer_latency_seconds{peer="peer-a.netbird.cloud"} 0.012`) +} + +// A server that never came up must not be remembered, otherwise reconciling the +// same address again is a no-op and the endpoint never recovers. +func TestReconcileForgetsAFailedServer(t *testing.T) { + blocker, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err, "must find a free port") + t.Cleanup(func() { _ = blocker.Close() }) + addr := blocker.Addr().String() + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + m := NewManager(ctx, testStatus(), nil) + m.Reconcile(true, addr) + + require.Eventually(t, func() bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.srv == nil && m.addr == "" + }, 2*time.Second, 20*time.Millisecond, "the failed server should be dropped") + + require.NoError(t, blocker.Close()) + m.Reconcile(true, addr) + + require.Eventually(t, func() bool { + resp, err := http.Get(fmt.Sprintf("http://%s/metrics", addr)) + if err != nil { + return false + } + defer resp.Body.Close() + return resp.StatusCode == http.StatusOK + }, 2*time.Second, 50*time.Millisecond, "reconciling the same address should retry the bind") +} + +func TestIsLoopback(t *testing.T) { + tests := map[string]bool{ + "": true, + "127.0.0.1:9191": true, + "127.9.9.9:9191": true, + "[::1]:9191": true, + "[::ffff:127.0.0.1]:9191": true, + "localhost:9191": true, + "0.0.0.0:9191": false, + "[::]:9191": false, + "192.168.1.10:9191": false, + "not-an-address": false, + "example.com:9191": false, + } + + for addr, want := range tests { + t.Run(addr, func(t *testing.T) { + assert.Equal(t, want, IsLoopback(addr), "loopback verdict for %q", addr) + }) + } +} diff --git a/client/internal/metrics/influxdb.go b/client/internal/metrics/influxdb.go index 4ba14bf44..717544f6a 100644 --- a/client/internal/metrics/influxdb.go +++ b/client/internal/metrics/influxdb.go @@ -45,30 +45,13 @@ func (m *influxDBMetrics) RecordConnectionStages( isReconnection bool, timestamps ConnectionStageTimestamps, ) { - var signalingReceivedToConnection, connectionToWgHandshake, totalDuration float64 - - if !timestamps.SignalingReceived.IsZero() && !timestamps.ConnectionReady.IsZero() { - signalingReceivedToConnection = timestamps.ConnectionReady.Sub(timestamps.SignalingReceived).Seconds() - } - - if !timestamps.ConnectionReady.IsZero() && !timestamps.WgHandshakeSuccess.IsZero() { - connectionToWgHandshake = timestamps.WgHandshakeSuccess.Sub(timestamps.ConnectionReady).Seconds() - } - - if !timestamps.SignalingReceived.IsZero() && !timestamps.WgHandshakeSuccess.IsZero() { - totalDuration = timestamps.WgHandshakeSuccess.Sub(timestamps.SignalingReceived).Seconds() - } - - attemptType := "initial" - if isReconnection { - attemptType = "reconnection" - } + signalingReceivedToConnection, connectionToWgHandshake, totalDuration := timestamps.Durations() connTypeStr := connectionType.String() tags := fmt.Sprintf("deployment_type=%s,connection_type=%s,attempt_type=%s,version=%s,os=%s,arch=%s,peer_id=%s,connection_pair_id=%s", agentInfo.DeploymentType.String(), connTypeStr, - attemptType, + attemptType(isReconnection), agentInfo.Version, agentInfo.OS, agentInfo.Arch, @@ -94,7 +77,7 @@ func (m *influxDBMetrics) RecordConnectionStages( m.trimLocked() log.Tracef("peer connection metrics [%s, %s, %s]: signalingReceived→connection: %.3fs, connection→wg_handshake: %.3fs, total: %.3fs", - agentInfo.DeploymentType.String(), connTypeStr, attemptType, signalingReceivedToConnection, connectionToWgHandshake, totalDuration) + agentInfo.DeploymentType.String(), connTypeStr, attemptType(isReconnection), signalingReceivedToConnection, connectionToWgHandshake, totalDuration) } func (m *influxDBMetrics) RecordSyncDuration(_ context.Context, agentInfo AgentInfo, duration time.Duration) { diff --git a/client/internal/metrics/metrics.go b/client/internal/metrics/metrics.go index cfe477107..5edf1d9c7 100644 --- a/client/internal/metrics/metrics.go +++ b/client/internal/metrics/metrics.go @@ -89,6 +89,21 @@ type ConnectionStageTimestamps struct { WgHandshakeSuccess time.Time } +// Durations returns the stage durations in seconds. A duration is zero when +// either of its timestamps is missing. +func (c ConnectionStageTimestamps) Durations() (signalingToConnection, connectionToWgHandshake, total float64) { + if !c.SignalingReceived.IsZero() && !c.ConnectionReady.IsZero() { + signalingToConnection = c.ConnectionReady.Sub(c.SignalingReceived).Seconds() + } + if !c.ConnectionReady.IsZero() && !c.WgHandshakeSuccess.IsZero() { + connectionToWgHandshake = c.WgHandshakeSuccess.Sub(c.ConnectionReady).Seconds() + } + if !c.SignalingReceived.IsZero() && !c.WgHandshakeSuccess.IsZero() { + total = c.WgHandshakeSuccess.Sub(c.SignalingReceived).Seconds() + } + return signalingToConnection, connectionToWgHandshake, total +} + // String returns a human-readable representation of the connection stage timestamps func (c ConnectionStageTimestamps) String() string { return fmt.Sprintf("ConnectionStageTimestamps{SignalingReceived=%v, ConnectionReady=%v, WgHandshakeSuccess=%v}", @@ -279,3 +294,11 @@ func (c *ClientMetrics) stopPushLocked() { c.wg.Wait() c.push.Store(nil) } + +// attemptType returns the metric label for an initial vs reconnection attempt. +func attemptType(isReconnection bool) string { + if isReconnection { + return "reconnection" + } + return "initial" +} diff --git a/client/internal/metrics/metrics_default.go b/client/internal/metrics/metrics_default.go index 927ab51d1..3798adab6 100644 --- a/client/internal/metrics/metrics_default.go +++ b/client/internal/metrics/metrics_default.go @@ -2,10 +2,24 @@ package metrics +import "github.com/prometheus/client_golang/prometheus" + // NewClientMetrics creates a new ClientMetrics instance func NewClientMetrics(agentInfo AgentInfo) *ClientMetrics { return &ClientMetrics{ - impl: newInfluxDBMetrics(), + impl: newPrometheusMetrics(newInfluxDBMetrics()), agentInfo: agentInfo, } } + +// PrometheusGatherer returns the registry with the mirrored Prometheus +// metrics, or nil when unavailable. +func (c *ClientMetrics) PrometheusGatherer() prometheus.Gatherer { + if c == nil { + return nil + } + if pm, ok := c.impl.(*prometheusMetrics); ok { + return pm.Gatherer() + } + return nil +} diff --git a/client/internal/metrics/prometheus.go b/client/internal/metrics/prometheus.go new file mode 100644 index 000000000..7f5020ea9 --- /dev/null +++ b/client/internal/metrics/prometheus.go @@ -0,0 +1,119 @@ +//go:build !js + +package metrics + +import ( + "context" + "io" + "strconv" + "time" + + "github.com/prometheus/client_golang/prometheus" +) + +// prometheusMetrics mirrors recorded client metrics into a Prometheus +// registry for the local /metrics endpoint, then delegates to the wrapped +// implementation. Export and Reset pass through untouched: Prometheus +// metrics are cumulative and pull-based. +type prometheusMetrics struct { + next metricsImplementation + registry *prometheus.Registry + + connectionStages *prometheus.HistogramVec + syncDuration prometheus.Histogram + syncPhaseDuration *prometheus.HistogramVec + loginDuration *prometheus.HistogramVec +} + +func newPrometheusMetrics(next metricsImplementation) *prometheusMetrics { + connectionBuckets := []float64{.05, .1, .25, .5, 1, 2.5, 5, 10, 30, 60} + + m := &prometheusMetrics{ + next: next, + registry: prometheus.NewRegistry(), + connectionStages: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: "netbird_peer_connection_stage_duration_seconds", + Help: "Duration of peer connection establishment stages.", + Buckets: connectionBuckets, + }, []string{"stage", "connection_type", "attempt_type"}), + syncDuration: prometheus.NewHistogram(prometheus.HistogramOpts{ + Name: "netbird_sync_duration_seconds", + Help: "Duration of management sync message processing.", + Buckets: prometheus.DefBuckets, + }), + syncPhaseDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: "netbird_sync_phase_duration_seconds", + Help: "Duration of individual sync processing phases.", + Buckets: prometheus.DefBuckets, + }, []string{"phase"}), + loginDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: "netbird_login_duration_seconds", + Help: "Duration of logins to the management service.", + Buckets: prometheus.DefBuckets, + }, []string{"success"}), + } + + m.registry.MustRegister(m.connectionStages, m.syncDuration, m.syncPhaseDuration, m.loginDuration) + return m +} + +// Gatherer returns the registry holding the mirrored metrics. +func (m *prometheusMetrics) Gatherer() prometheus.Gatherer { + return m.registry +} + +// RecordConnectionStages implements metricsImplementation. +func (m *prometheusMetrics) RecordConnectionStages( + ctx context.Context, + agentInfo AgentInfo, + connectionPairID string, + connectionType ConnectionType, + isReconnection bool, + timestamps ConnectionStageTimestamps, +) { + attempt := attemptType(isReconnection) + connType := connectionType.String() + + signalingToConnection, connectionToWgHandshake, total := timestamps.Durations() + if signalingToConnection > 0 { + m.connectionStages.WithLabelValues("signaling_to_connection", connType, attempt).Observe(signalingToConnection) + } + if connectionToWgHandshake > 0 { + m.connectionStages.WithLabelValues("connection_to_wg_handshake", connType, attempt).Observe(connectionToWgHandshake) + } + if total > 0 { + m.connectionStages.WithLabelValues("total", connType, attempt).Observe(total) + } + + m.next.RecordConnectionStages(ctx, agentInfo, connectionPairID, connectionType, isReconnection, timestamps) +} + +// RecordSyncDuration implements metricsImplementation. +func (m *prometheusMetrics) RecordSyncDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration) { + m.syncDuration.Observe(duration.Seconds()) + m.next.RecordSyncDuration(ctx, agentInfo, duration) +} + +// RecordSyncPhase implements metricsImplementation. +func (m *prometheusMetrics) RecordSyncPhase(ctx context.Context, agentInfo AgentInfo, phase string, duration time.Duration) { + m.syncPhaseDuration.WithLabelValues(phase).Observe(duration.Seconds()) + m.next.RecordSyncPhase(ctx, agentInfo, phase, duration) +} + +// RecordLoginDuration implements metricsImplementation. +func (m *prometheusMetrics) RecordLoginDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration, success bool) { + m.loginDuration.WithLabelValues(strconv.FormatBool(success)).Observe(duration.Seconds()) + m.next.RecordLoginDuration(ctx, agentInfo, duration, success) +} + +// Export implements metricsImplementation by delegating to the wrapped +// implementation; Prometheus metrics are pulled via the registry instead. +func (m *prometheusMetrics) Export(w io.Writer) error { + return m.next.Export(w) +} + +// Reset implements metricsImplementation by delegating to the wrapped +// implementation; Prometheus metrics must not be cleared on push. +func (m *prometheusMetrics) Reset() { + m.next.Reset() +} diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go index 24e3e7fac..bf36b944b 100644 --- a/client/internal/peer/status.go +++ b/client/internal/peer/status.go @@ -1167,6 +1167,18 @@ func (d *Status) GetResolvedDomainsStates() map[domain.Domain]ResolvedDomainInfo return maps.Clone(d.resolvedDomainsStates) } +// GetPeerStates returns a snapshot of all known peer states, including offline peers. +func (d *Status) GetPeerStates() []State { + d.mux.RLock() + defer d.mux.RUnlock() + + states := make([]State, 0, d.numOfPeers()) + for _, state := range d.peers { + states = append(states, state) + } + return append(states, d.offlinePeers...) +} + // GetFullStatus gets full status func (d *Status) GetFullStatus() FullStatus { fullStatus := FullStatus{ diff --git a/client/internal/peer/status_test.go b/client/internal/peer/status_test.go index 29404d413..82dff0d6f 100644 --- a/client/internal/peer/status_test.go +++ b/client/internal/peer/status_test.go @@ -129,6 +129,28 @@ func TestStatus_PeerStateByIP_RemovedPeer(t *testing.T) { req.False(ok, "removed peer must not resolve by IPv6 tunnel address") } +// TestStatus_GetPeerStates_IncludesOfflinePeers keeps the snapshot in line with +// GetFullStatus: offline peers are known peers, so a consumer counting peers +// must see the same total the status command reports. +func TestStatus_GetPeerStates_IncludesOfflinePeers(t *testing.T) { + status := NewRecorder("https://mgm") + req := require.New(t) + + req.NoError(status.AddPeer("pk-online", "online.netbird", "100.64.0.10", "fd00::1")) + status.ReplaceOfflinePeers([]State{ + {PubKey: "pk-offline", FQDN: "offline.netbird", IP: "100.64.0.20", ConnStatus: StatusIdle}, + }) + + states := status.GetPeerStates() + req.Len(states, 2, "snapshot must carry both the online and the offline peer") + + keys := make([]string, 0, len(states)) + for _, s := range states { + keys = append(keys, s.PubKey) + } + req.ElementsMatch([]string{"pk-online", "pk-offline"}, keys, "snapshot must carry both peers") +} + func TestStatus_UpdatePeerFQDN(t *testing.T) { key := "abc" fqdn := "peer-a.netbird.local" diff --git a/client/internal/peer/worker_ice.go b/client/internal/peer/worker_ice.go index 83cac13f5..d17f6e693 100644 --- a/client/internal/peer/worker_ice.go +++ b/client/internal/peer/worker_ice.go @@ -64,6 +64,9 @@ type WorkerICE struct { // portForwardAttempted tracks if we've already tried port forwarding this session portForwardAttempted bool + + // dialFunc, when non-nil, replaces agentDial in connect(). Only for tests. + dialFunc func(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) (net.Conn, error) } func NewWorkerICE(ctx context.Context, log *log.Entry, config ConnConfig, conn *Conn, signaler *Signaler, ifaceDiscover stdnet.ExternalIFaceDiscover, statusRecorder *Status, hasRelayOnLocally bool) (*WorkerICE, error) { @@ -123,7 +126,7 @@ func (w *WorkerICE) OnNewOffer(remoteOfferAnswer *OfferAnswer) { w.log.Errorf("failed to create new session ID: %s", err) } w.sessionID = sessionID - w.agent = nil + w.abandonNegotiation() } var preferredCandidateTypes []ice.CandidateType @@ -151,7 +154,9 @@ func (w *WorkerICE) OnNewOffer(remoteOfferAnswer *OfferAnswer) { w.remoteSessionID = "" } - go w.connect(dialerCtx, agent, remoteOfferAnswer) + // Capture the cancel func at spawn time: connect reads it from the argument + // instead of the field, which a newer OnNewOffer may already have replaced. + go w.connect(dialerCtx, dialerCancel, agent, remoteOfferAnswer) } // OnRemoteCandidate Handles ICE connection Candidate provided by the remote peer. @@ -200,16 +205,16 @@ func (w *WorkerICE) Close() { w.muxAgent.Lock() defer w.muxAgent.Unlock() - if w.agent == nil { - return + if w.agent != nil { + w.agentDialerCancel() + if err := w.agent.Close(); err != nil { + w.log.Warnf("failed to close ICE agent: %s", err) + } } - - w.agentDialerCancel() - if err := w.agent.Close(); err != nil { - w.log.Warnf("failed to close ICE agent: %s", err) - } - - w.agent = nil + // Unconditional: a dial goroutine racing this Close skips its own cleanup + // (closeAgent finds a nil agent), so the flags must be dropped here too or + // the reconnection guard reads the stale state as Connected forever. + w.abandonNegotiation() } func (w *WorkerICE) reCreateAgent(dialerCancel context.CancelFunc, candidates []ice.CandidateType) (*icemaker.ThreadSafeAgent, error) { @@ -247,31 +252,52 @@ func (w *WorkerICE) SessionID() ICESessionID { // will block until connection succeeded // but it won't release if ICE Agent went into Disconnected or Failed state, // so we have to cancel it with the provided context once agent detected a broken connection -func (w *WorkerICE) connect(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) { +func (w *WorkerICE) connect(ctx context.Context, dialerCancel context.CancelFunc, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) { w.log.Debugf("gather candidates") if err := agent.GatherCandidates(); err != nil { w.log.Warnf("failed to gather candidates: %s", err) - w.closeAgent(agent, w.agentDialerCancel) + w.closeAgent(agent, dialerCancel) return } w.log.Debugf("agent dial") - remoteConn, err := w.agentDial(ctx, agent, remoteOfferAnswer) + dial := func(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) (net.Conn, error) { + return w.agentDial(ctx, agent, remoteOfferAnswer) + } + if w.dialFunc != nil { + dial = w.dialFunc + } + remoteConn, err := dial(ctx, agent, remoteOfferAnswer) if err != nil { w.log.Debugf("failed to dial the remote peer: %s", err) - w.closeAgent(agent, w.agentDialerCancel) + w.closeAgent(agent, dialerCancel) return } w.log.Debugf("agent dial succeeded") + // A newer negotiation may have replaced our agent while agentDial was + // blocked. Drop the dead connection before running pair retrieval, port + // punching or candidate work against a closed agent. The commit-point + // check below still guards a replacement arriving after this point. + w.muxAgent.Lock() + stale := w.agent != agent + w.muxAgent.Unlock() + if stale { + if err := remoteConn.Close(); err != nil { + w.log.Warnf("failed to close stale ICE connection: %s", err) + } + w.log.Warnf("discarding connection from a stale ICE negotiation") + return + } + pair, err := agent.GetSelectedCandidatePair() if err != nil { - w.closeAgent(agent, w.agentDialerCancel) + w.closeAgent(agent, dialerCancel) return } if pair == nil { w.log.Warnf("selected candidate pair is nil, cannot proceed") - w.closeAgent(agent, w.agentDialerCancel) + w.closeAgent(agent, dialerCancel) return } @@ -301,11 +327,27 @@ func (w *WorkerICE) connect(ctx context.Context, agent *icemaker.ThreadSafeAgent w.log.Infof("connection succeeded with offer session: %s", remoteOfferAnswer.SessionIDString()) w.muxAgent.Lock() + // Authoritative ownership guard: a negotiation that lost w.agent to a newer + // one between the post-dial check and the commit must not clear agentConnecting, + // record lastSuccess or report the connection, so the state commit has to be + // atomic with the check. + if w.agent != agent { + w.muxAgent.Unlock() + if err := remoteConn.Close(); err != nil { + w.log.Warnf("failed to close stale ICE connection: %s", err) + } + w.log.Warnf("discarding connection from a stale ICE negotiation") + return + } w.agentConnecting = false w.lastSuccess = time.Now() w.muxAgent.Unlock() // todo: the potential problem is a race between the onConnectionStateChange + // and the delivery below: after this unlock, a newer offer can replace + // w.agent before onICEConnectionIsReady runs, delivering this (now stale) + // connection. The newer negotiation overwrites it with its own delivery, + // so the window only ever downgrades an endpoint transiently. w.conn.onICEConnectionIsReady(selectedPriority(pair), ci) } @@ -321,20 +363,32 @@ func (w *WorkerICE) closeAgent(agent *icemaker.ThreadSafeAgent, cancel context.C sessionChanged := w.remoteSessionChanged w.remoteSessionChanged = false + // Only the owner of the current session may reset its state: a stale dial + // goroutine waking after a newer attempt must not clobber it. if w.agent == agent { - // consider to remove from here and move to the OnNewOffer sessionID, err := NewICESessionID() if err != nil { w.log.Errorf("failed to create new session ID: %s", err) } w.sessionID = sessionID - w.agent = nil - w.agentConnecting = false - w.remoteSessionID = "" + w.abandonNegotiation() } return sessionChanged } +// abandonNegotiation drops all recorded ICE session state so the worker treats the +// next offer as a fresh start instead of a duplicate of a dead negotiation. The +// agent and agentConnecting flags must change together: leaving one stale wedges +// the reconnection guard into reporting Connected forever. It neither cancels an +// in-flight dial nor closes an agent — callers dispose of those themselves first, +// so a stale goroutine can never cancel another session's dial through this path. +// Caller must hold muxAgent. +func (w *WorkerICE) abandonNegotiation() { + w.agent = nil + w.agentConnecting = false + w.remoteSessionID = "" +} + func (w *WorkerICE) punchRemoteWGPort(pair *ice.CandidatePair, remoteWgPort int) { // wait local endpoint configuration time.Sleep(time.Second) diff --git a/client/internal/peer/worker_ice_close_test.go b/client/internal/peer/worker_ice_close_test.go new file mode 100644 index 000000000..834a4dd6d --- /dev/null +++ b/client/internal/peer/worker_ice_close_test.go @@ -0,0 +1,257 @@ +package peer + +import ( + "context" + "net" + "sync/atomic" + "testing" + "time" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + + icemaker "github.com/netbirdio/netbird/client/internal/peer/ice" + signal "github.com/netbirdio/netbird/shared/signal/client" + sProto "github.com/netbirdio/netbird/shared/signal/proto" +) + +// stubSignalClient satisfies signal.Client as a no-op so the candidate +// goroutine spawned by a real GatherCandidates never dereferences a nil +// signaler in tests. +type stubSignalClient struct{} + +func (stubSignalClient) Close() error { return nil } +func (stubSignalClient) StreamConnected() bool { return false } +func (stubSignalClient) GetStatus() signal.Status { return signal.StreamDisconnected } +func (stubSignalClient) Receive(context.Context, func(*sProto.Message) error) error { return nil } +func (stubSignalClient) Ready() bool { return false } +func (stubSignalClient) IsHealthy() bool { return false } +func (stubSignalClient) WaitStreamConnected(context.Context) {} +func (stubSignalClient) SendToStream(*sProto.EncryptedMessage) error { return nil } +func (stubSignalClient) Send(*sProto.Message) error { return nil } +func (stubSignalClient) SetOnReconnectedListener(func()) {} + +// newTestWorkerICE builds a worker with real pion plumbing and no-op signaling. +func newTestWorkerICE(t *testing.T) *WorkerICE { + t.Helper() + + config := connConf + stunTurn := &icemaker.StunTurn{} + stunTurn.Store(nil) + config.ICEConfig.StunTurn = stunTurn + + w, err := NewWorkerICE(context.Background(), log.WithField("test", t.Name()), config, nil, + NewSignaler(stubSignalClient{}, wgtypes.Key{}), nil, nil, false) + require.NoError(t, err, "worker setup must succeed") + return w +} + +// TestWorkerICE_CloseDuringDial_ClearsConnectingFlag drives the teardown race +// through the real dial goroutine instead of simulating its cleanup. +// +// The real-world sequence this models: +// 1. OnNewOffer starts a negotiation: agent set, agentConnecting = true, +// go connect() +// 2. The network dies and connect() stays blocked inside GatherCandidates/Dial +// 3. A WG handshake timeout calls Close(): the agent is released and the dial +// context cancelled, but agentConnecting is not reset +// 4. The real goroutine wakes with an error and runs its own cleanup +// (closeAgent), where `w.agent == agent` is now false, so the flag reset +// is skipped +// +// There is no remote responder, so Dial can never succeed: whatever point the +// goroutine is at, closing first forces it down the error path. Before the fix +// the flag stays true forever and the deadline below expires. +func TestWorkerICE_CloseDuringDial_ClearsConnectingFlag(t *testing.T) { + w := newTestWorkerICE(t) + + sid := ICESessionID("test-session-id") + w.OnNewOffer(&OfferAnswer{ + IceCredentials: IceCredentials{ + UFrag: "testufrag", + Pwd: "testpwdtestpwdtestpwd12", + }, + SessionID: &sid, + }) + require.True(t, w.InProgress(), "OnNewOffer must mark the negotiation as in progress") + + // Teardown wins the race while connect() is still running. + w.Close() + + // Close drops the flags synchronously, so the assertion below does not + // converge on the goroutine: the deadline only absorbs the dial goroutine + // waking up in the background, proving nothing re-wedges it afterwards. + require.Eventually(t, func() bool { + return !w.InProgress() + }, 10*time.Second, 50*time.Millisecond, + "Close must leave the negotiation idle even while the dial goroutine is still winding down") + + // abandonNegotiation owns these three fields together; the worker is idle + // only when all of them are dropped. + w.muxAgent.Lock() + defer w.muxAgent.Unlock() + assert.Nil(t, w.agent, "no agent may survive the teardown") + assert.False(t, w.agentConnecting, "the connecting flag must match the nil agent") + assert.Empty(t, w.remoteSessionID, "a dead session's remote ID must not linger") +} + +// TestWorkerICE_CloseClearsResidualConnectingState covers Close on a worker whose +// agent is already gone but whose flag is stuck on true, e.g. after an aborted +// recreate in OnNewOffer or after a first Close raced a dial goroutine. +func TestWorkerICE_CloseClearsResidualConnectingState(t *testing.T) { + w := newTestWorkerICE(t) + + w.muxAgent.Lock() + w.agentConnecting = true + w.muxAgent.Unlock() + + w.Close() + + assert.False(t, w.InProgress(), "Close must drop residual connecting state even without a live agent") + + w.muxAgent.Lock() + defer w.muxAgent.Unlock() + assert.Nil(t, w.agent) + assert.False(t, w.agentConnecting) + assert.Empty(t, w.remoteSessionID) +} + +// TestWorkerICE_StaleCloseAgentKeepsCurrentSession pins the ownership guard in +// closeAgent: a late-waking dial goroutine from an older session must not reset +// the state of a newer negotiation that reused the worker. The newer session +// must survive wholesale - agent, flag and remote session identity alike. +func TestWorkerICE_StaleCloseAgentKeepsCurrentSession(t *testing.T) { + w := newTestWorkerICE(t) + t.Cleanup(w.Close) + + sidA := ICESessionID("session-a") + w.OnNewOffer(&OfferAnswer{ + IceCredentials: IceCredentials{UFrag: "ufragaaaa", Pwd: "pwdpwdpwdpwdpwdpwdpwdp1"}, + SessionID: &sidA, + }) + w.muxAgent.Lock() + oldAgent := w.agent + oldCancel := w.agentDialerCancel + w.muxAgent.Unlock() + require.NotNil(t, oldAgent, "OnNewOffer must have created an ICE agent") + + w.Close() + + sidB := ICESessionID("session-b") + w.OnNewOffer(&OfferAnswer{ + IceCredentials: IceCredentials{UFrag: "ufragbbbb", Pwd: "pwdpwdpwdpwdpwdpwdpwdp2"}, + SessionID: &sidB, + }) + require.True(t, w.InProgress(), "the second negotiation must be in flight") + + w.muxAgent.Lock() + newAgent := w.agent + w.muxAgent.Unlock() + + // The old dial goroutine finally wakes and cleans up its captured agent. + w.closeAgent(oldAgent, oldCancel) + + w.muxAgent.Lock() + defer w.muxAgent.Unlock() + assert.Same(t, newAgent, w.agent, "the current agent must be untouched by the stale cleanup") + assert.True(t, w.agentConnecting, "the current negotiation must stay in flight") + // Read live under the lock: a snapshot captured before the stale cleanup + // would pass even if the cleanup wiped current state. + assert.Equal(t, sidB, w.remoteSessionID, "the remote session identity must be preserved") +} + +// closeTrackConn records Close calls so a test can assert that a discarded +// connection was actually released. +type closeTrackConn struct { + net.Conn + closed atomic.Bool +} + +func (c *closeTrackConn) Close() error { + c.closed.Store(true) + return c.Conn.Close() +} + +// TestWorkerICE_StaleDialSuccessKeepsNewerNegotiation pins the ownership guard +// in connect()'s success path: a dial that came back after a newer negotiation +// replaced the agent must discard its connection and leave the newer session's +// state - agent, agentConnecting, remoteSessionID, lastSuccess - intact. +// +// The dial hook holds session A's goroutine open until session B is installed, +// then returns a live connection, mimicking the vendored pion dial which hands +// out a live *ice.Conn when a pair is selected without checking afterwards +// whether the agent was replaced meanwhile. Releasing A's dial therefore +// exercises the stale-success commit path deterministically instead of racing +// real ICE. +func TestWorkerICE_StaleDialSuccessKeepsNewerNegotiation(t *testing.T) { + w := newTestWorkerICE(t) + t.Cleanup(w.Close) + + dialStarted := make(chan struct{}) + releaseDial := make(chan struct{}) + staleConn := &closeTrackConn{} + + var calls atomic.Int32 + w.dialFunc = func(ctx context.Context, _ *icemaker.ThreadSafeAgent, _ *OfferAnswer) (net.Conn, error) { + if calls.Add(1) == 1 { + // Session A: hold the goroutine open until session B is installed, + // then return a live connection, mimicking the vendored pion dial + // which hands out a live *ice.Conn once a pair is selected without + // re-checking whether the agent was replaced meanwhile. Releasing + // the dial therefore exercises the stale-success commit path + // deterministically instead of racing real ICE. + close(dialStarted) + <-releaseDial + client, _ := net.Pipe() + staleConn.Conn = client + return staleConn, nil + } + // A newer negotiation parks on its dialer context, cancelled by the + // t.Cleanup Close at test end. + <-ctx.Done() + return nil, ctx.Err() + } + + sidA := ICESessionID("session-a") + w.OnNewOffer(&OfferAnswer{ + IceCredentials: IceCredentials{UFrag: "ufragaaaa", Pwd: "pwdpwdpwdpwdpwdpwdpwdp1"}, + SessionID: &sidA, + }) + require.True(t, w.InProgress(), "session A must be in flight") + + // Session A's goroutine is now parked in the dial hook. + <-dialStarted + + sidB := ICESessionID("session-b") + w.OnNewOffer(&OfferAnswer{ + IceCredentials: IceCredentials{UFrag: "ufragbbbb", Pwd: "pwdpwdpwdpwdpwdpwdpwdp2"}, + SessionID: &sidB, + }) + + w.muxAgent.Lock() + agentB := w.agent + w.lastSuccess = time.Time{} + w.muxAgent.Unlock() + require.NotNil(t, agentB, "session B must have created an ICE agent") + require.True(t, w.InProgress(), "session B must be in flight") + + // Release session A's dial: it must be recognized as stale and discarded. + close(releaseDial) + require.Eventually(t, func() bool { + return staleConn.closed.Load() + }, 10*time.Second, 10*time.Millisecond, + "the stale connection must be closed by the ownership guard") + + w.muxAgent.Lock() + defer w.muxAgent.Unlock() + assert.Same(t, agentB, w.agent, "session A must not uninstall session B's agent") + assert.True(t, w.agentConnecting, "session A must not clear session B's connecting flag") + assert.Equal(t, sidB, w.remoteSessionID, "session A must not clear session B's remote session identity") + assert.True(t, w.lastSuccess.IsZero(), "session A must not record a success for session B") + // The commit block guards agentConnecting, lastSuccess and + // onICEConnectionIsReady together, so the state assertions above imply the + // callback never ran for session A; the nil conn would have panicked the + // stale goroutine on any invocation. +} diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go index e1668238e..eacc6fd5f 100644 --- a/client/internal/profilemanager/config.go +++ b/client/internal/profilemanager/config.go @@ -103,6 +103,9 @@ type ConfigInput struct { DNSLabels domain.List MTU *uint16 + + LocalMetricsEnabled *bool + LocalMetricsAddress *string } // Config Configuration type @@ -144,6 +147,11 @@ type Config struct { DNSLabels domain.List + // LocalMetricsEnabled enables the local Prometheus /metrics endpoint. + LocalMetricsEnabled bool + // LocalMetricsAddress is the listen address of the local /metrics endpoint. + LocalMetricsAddress string + // SSHKey is a private SSH key in a PEM format SSHKey string @@ -388,6 +396,18 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } + if input.LocalMetricsEnabled != nil && *input.LocalMetricsEnabled != config.LocalMetricsEnabled { + log.Infof("switching local metrics to %t", *input.LocalMetricsEnabled) + config.LocalMetricsEnabled = *input.LocalMetricsEnabled + updated = true + } + + if input.LocalMetricsAddress != nil && *input.LocalMetricsAddress != config.LocalMetricsAddress { + log.Infof("switching local metrics address to %s", *input.LocalMetricsAddress) + config.LocalMetricsAddress = *input.LocalMetricsAddress + updated = true + } + if input.NetworkMonitor != nil && (config.NetworkMonitor == nil || *input.NetworkMonitor != *config.NetworkMonitor) { log.Infof("switching Network Monitor to %t", *input.NetworkMonitor) config.NetworkMonitor = input.NetworkMonitor @@ -718,6 +738,12 @@ func (config *Config) applyMDMPolicy(policy *mdm.Policy) { applyBool(mdm.KeyDisableAutoConnect, func(v bool) { config.DisableAutoConnect = v }) applyBool(mdm.KeyRosenpassEnabled, func(v bool) { config.RosenpassEnabled = v }) applyBool(mdm.KeyRosenpassPermissive, func(v bool) { config.RosenpassPermissive = v }) + applyBool(mdm.KeyEnableLocalMetrics, func(v bool) { config.LocalMetricsEnabled = v }) + + if v, ok := policy.GetString(mdm.KeyLocalMetricsAddress); ok { + config.LocalMetricsAddress = v + logApplied(mdm.KeyLocalMetricsAddress, v) + } if v, ok := policy.GetInt(mdm.KeyWireguardPort); ok { // REG_DWORD is 32-bit; UDP port range is 1-65535. Clamp at the diff --git a/client/internal/profilemanager/config_mdm_test.go b/client/internal/profilemanager/config_mdm_test.go index c6a688ab2..f8dfddb33 100644 --- a/client/internal/profilemanager/config_mdm_test.go +++ b/client/internal/profilemanager/config_mdm_test.go @@ -130,6 +130,32 @@ func TestApply_MDMBoolKeysOverrideOnDiskValue(t *testing.T) { assert.True(t, cfg.Policy().HasKey(mdm.KeyRosenpassEnabled)) } +func TestApply_MDMLocalMetrics(t *testing.T) { + tmp := filepath.Join(t.TempDir(), "config.json") + + // Seed without MDM. + withMDMPolicy(t, mdm.NewPolicy(nil)) + _, err := UpdateOrCreateConfig(ConfigInput{ + ConfigPath: tmp, + LocalMetricsEnabled: boolPtr(false), + }) + require.NoError(t, err) + + withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + mdm.KeyEnableLocalMetrics: true, + mdm.KeyLocalMetricsAddress: "127.0.0.1:9292", + })) + + cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: tmp}) + require.NoError(t, err) + require.NotNil(t, cfg) + + assert.True(t, cfg.LocalMetricsEnabled, "MDM override should flip on-disk false to true") + assert.Equal(t, "127.0.0.1:9292", cfg.LocalMetricsAddress) + assert.True(t, cfg.Policy().HasKey(mdm.KeyEnableLocalMetrics)) + assert.True(t, cfg.Policy().HasKey(mdm.KeyLocalMetricsAddress)) +} + func TestApply_MDMLazyConnection(t *testing.T) { cases := []struct { name string diff --git a/client/internal/routemanager/selection.go b/client/internal/routemanager/selection.go index 6d5feec79..b81d51b67 100644 --- a/client/internal/routemanager/selection.go +++ b/client/internal/routemanager/selection.go @@ -17,23 +17,30 @@ import ( // are mutually exclusive: if the selection activates an exit node, every other // available exit node is deselected so two can't be active at once. With // appendRoute=false the previous selection is replaced instead of extended. +// A partial failure (e.g. an unknown ID mixed with valid ones) still applies +// the valid IDs to the routing table; the unknown ones are reported in the +// returned error. func (m *DefaultManager) SelectRoutes(ids []route.NetID, appendRoute bool) error { - if err := m.selectRoutes(ids, appendRoute); err != nil { - return err - } + err := m.selectRoutes(ids, appendRoute) + // Apply regardless of err: selectRoutes already selects the valid part of a + // partial request, and skipping this on error would leave those routes + // selected in the selector but never installed in the routing table. m.TriggerSelection(m.GetClientRoutes()) - return nil + return err } // DeselectRoutes removes the routes with the given network IDs from the // selection and applies the change. V4/v6 exit-node pairs are expanded -// automatically. +// automatically. A partial failure (e.g. an unknown ID mixed with valid ones) +// still applies the valid IDs to the routing table; the unknown ones are +// reported in the returned error. func (m *DefaultManager) DeselectRoutes(ids []route.NetID) error { - if err := m.deselectRoutes(ids); err != nil { - return err - } + err := m.deselectRoutes(ids) + // Apply regardless of err: deselectRoutes already deselects the valid part + // of a partial request, and skipping this on error would leave those routes + // installed in the routing table despite being marked deselected. m.TriggerSelection(m.GetClientRoutes()) - return nil + return err } func (m *DefaultManager) deselectRoutes(ids []route.NetID) error { diff --git a/client/internal/routemanager/selection_test.go b/client/internal/routemanager/selection_test.go index 6066b5661..4ef9ddb88 100644 --- a/client/internal/routemanager/selection_test.go +++ b/client/internal/routemanager/selection_test.go @@ -1,12 +1,17 @@ package routemanager import ( + "context" "net/netip" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/exp/maps" + "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/internal/routemanager/client" + "github.com/netbirdio/netbird/client/internal/routemanager/notifier" "github.com/netbirdio/netbird/client/internal/routeselector" "github.com/netbirdio/netbird/route" ) @@ -112,6 +117,75 @@ func TestSelectRoutes_UnknownRoute(t *testing.T) { assert.Error(t, m.deselectRoutes([]route.NetID{"missing"}), "deselecting an unavailable route must fail") } +// newPartialFailureTestManager exercises the real install/remove path without +// touching the system: the noop refcounter absorbs the route changes, and every +// route already has a watcher, so none is started. +func newPartialFailureTestManager() *DefaultManager { + ctx := context.Background() + + m := &DefaultManager{ + ctx: ctx, + clientRoutes: route.HAMap{ + "lan|192.168.1.0/24": {{NetID: "lan", Network: netip.MustParsePrefix("192.168.1.0/24"), Peer: "p1"}}, + "other|10.1.2.0/24": {{NetID: "other", Network: netip.MustParsePrefix("10.1.2.0/24"), Peer: "p2"}}, + }, + routeSelector: routeselector.NewRouteSelector(), + notifier: notifier.NewNotifier(), + statusRecorder: peer.NewRecorder("https://mgm"), + activeRoutes: make(map[route.HAUniqueID]client.RouteHandler), + clientNetworks: map[route.HAUniqueID]*client.Watcher{ + "lan|192.168.1.0/24": client.NewWatcher(client.WatcherConfig{Context: ctx}), + "other|10.1.2.0/24": client.NewWatcher(client.WatcherConfig{Context: ctx}), + }, + } + m.setupRefCounters(true) + return m +} + +// Regression for the reported symptom: a partial failure returned before +// TriggerSelection ran, so the valid route was marked selected while never +// reaching the routing table (activeRoutes/ip route). +func TestSelectRoutes_PartialFailureStillInstallsValidRoute(t *testing.T) { + m := newPartialFailureTestManager() + + err := m.SelectRoutes([]route.NetID{"missing", "lan"}, false) + + assert.Error(t, err, "the unknown id must still be reported") + assert.Contains(t, m.activeRoutes, route.HAUniqueID("lan|192.168.1.0/24"), "the valid route must be installed despite the error") + assert.NotContains(t, m.activeRoutes, route.HAUniqueID("other|10.1.2.0/24"), "the deselected route must not be installed") +} + +// Mirror of the case above: a partial failure must remove the valid route from +// the routing table, not just mark it deselected in the selector. +func TestDeselectRoutes_PartialFailureStillRemovesValidRoute(t *testing.T) { + m := newPartialFailureTestManager() + + require.NoError(t, m.SelectRoutes([]route.NetID{"lan", "other"}, false)) + require.Contains(t, m.activeRoutes, route.HAUniqueID("lan|192.168.1.0/24")) + require.Contains(t, m.activeRoutes, route.HAUniqueID("other|10.1.2.0/24")) + + err := m.DeselectRoutes([]route.NetID{"missing", "other"}) + + assert.Error(t, err, "the unknown id must still be reported") + assert.NotContains(t, m.activeRoutes, route.HAUniqueID("other|10.1.2.0/24"), "the deselected route must be removed") + assert.Contains(t, m.activeRoutes, route.HAUniqueID("lan|192.168.1.0/24"), "the untouched route stays installed") +} + +// The selection now runs on every request, including one where no ID is known +// and the selector stays untouched. Nothing may be torn down or reinstalled on +// that path. +func TestSelectRoutes_TotalFailureLeavesInstalledRoutesAlone(t *testing.T) { + m := newPartialFailureTestManager() + + require.NoError(t, m.SelectRoutes([]route.NetID{"lan", "other"}, false)) + installed := maps.Keys(m.activeRoutes) + + err := m.SelectRoutes([]route.NetID{"missing"}, false) + + assert.Error(t, err, "the unknown id must still be reported") + assert.ElementsMatch(t, installed, maps.Keys(m.activeRoutes), "a fully invalid request must not disturb the routing table") +} + func TestExitNodeSelectionHelpers(t *testing.T) { routesMap := map[route.NetID][]*route.Route{ "exitA": {{Network: netip.MustParsePrefix("0.0.0.0/0")}}, diff --git a/client/internal/routeselector/routeselector.go b/client/internal/routeselector/routeselector.go index 1254b384d..8a64ad316 100644 --- a/client/internal/routeselector/routeselector.go +++ b/client/internal/routeselector/routeselector.go @@ -32,6 +32,22 @@ func (rs *RouteSelector) SelectRoutes(routes []route.NetID, appendRoute bool, al rs.mu.Lock() defer rs.mu.Unlock() + // Validate before mutating: a non-append selection wipes the current selection + // first, so a request of only unavailable routes would deselect everything and + // put nothing back. An empty request means deselect all, so it still goes through. + var err *multierror.Error + available := make([]route.NetID, 0, len(routes)) + for _, r := range routes { + if !slices.Contains(allRoutes, r) { + err = multierror.Append(err, fmt.Errorf("route '%s' is not available", r)) + continue + } + available = append(available, r) + } + if len(available) == 0 && err != nil { + return errors.FormatErrorOrNil(err) + } + if !appendRoute || rs.deselectAll { if rs.deselectedRoutes == nil { rs.deselectedRoutes = map[route.NetID]struct{}{} @@ -46,14 +62,9 @@ func (rs *RouteSelector) SelectRoutes(routes []route.NetID, appendRoute bool, al } } - var err *multierror.Error - for _, route := range routes { - if !slices.Contains(allRoutes, route) { - err = multierror.Append(err, fmt.Errorf("route '%s' is not available", route)) - continue - } - delete(rs.deselectedRoutes, route) - rs.selectedRoutes[route] = struct{}{} + for _, r := range available { + delete(rs.deselectedRoutes, r) + rs.selectedRoutes[r] = struct{}{} } rs.deselectAll = false diff --git a/client/internal/routeselector/routeselector_test.go b/client/internal/routeselector/routeselector_test.go index 2b1ba3fb9..f26d022e9 100644 --- a/client/internal/routeselector/routeselector_test.go +++ b/client/internal/routeselector/routeselector_test.go @@ -887,3 +887,70 @@ func TestRouteSelector_EnableExitNodeKeepsOtherRoutes(t *testing.T) { assert.True(t, rs.IsSelected("lan1"), "non-exit route must stay selected") assert.True(t, rs.IsSelected("lan2"), "non-exit route must stay selected") } + +// A non-append selection clears the current selection before applying the requested +// one, so an all-unavailable request used to leave nothing selected while returning +// an error. Requests with at least one available route are unaffected. +func TestRouteSelector_SelectRoutes_AllUnavailableKeepsSelection(t *testing.T) { + allRoutes := []route.NetID{"route1", "route2", "route3"} + + rs := routeselector.NewRouteSelector() + require.NoError(t, rs.SelectRoutes([]route.NetID{"route1"}, false, allRoutes)) + + err := rs.SelectRoutes([]route.NetID{"Route1", "route4"}, false, allRoutes) + + assert.Error(t, err, "an unavailable route ID must still be reported") + assert.True(t, rs.IsSelected("route1"), "the previous selection must survive a fully invalid request") + for _, id := range []route.NetID{"route2", "route3"} { + assert.False(t, rs.IsSelected(id), "no other route may become selected") + } +} + +// Boundary of the check above: an empty request is the caller deselecting everything, +// not a failed lookup, so it must keep working. +func TestRouteSelector_SelectRoutes_EmptyRequestStillDeselectsAll(t *testing.T) { + allRoutes := []route.NetID{"route1", "route2", "route3"} + + rs := routeselector.NewRouteSelector() + require.NoError(t, rs.SelectRoutes([]route.NetID{"route1"}, false, allRoutes)) + + require.NoError(t, rs.SelectRoutes(nil, false, allRoutes)) + + for _, id := range allRoutes { + assert.False(t, rs.IsSelected(id), "an empty selection request must deselect everything") + } +} + +// Mobile clients always call SelectRoutes with append=true. On that path an +// all-unavailable request was never destructive to begin with (append skips the +// wipe regardless of the guard above), but the behavior has no coverage yet. +func TestRouteSelector_SelectRoutes_AppendAllUnavailableKeepsSelection(t *testing.T) { + allRoutes := []route.NetID{"route1", "route2", "route3"} + + rs := routeselector.NewRouteSelector() + require.NoError(t, rs.SelectRoutes([]route.NetID{"route1"}, false, allRoutes)) + + err := rs.SelectRoutes([]route.NetID{"missing"}, true, allRoutes) + + assert.Error(t, err, "an unavailable route ID must still be reported") + assert.True(t, rs.IsSelected("route1"), "the previous selection must survive a fully invalid request") + for _, id := range []route.NetID{"route2", "route3"} { + assert.False(t, rs.IsSelected(id), "no other route may become selected") + } +} + +// The early return for an all-unavailable request must not clear deselectAll, +// or a typo'd network ID would silently drop the "nothing selected, including +// future networks" policy. +func TestRouteSelector_SelectRoutes_AllUnavailableAfterDeselectAllKeepsPolicy(t *testing.T) { + allRoutes := []route.NetID{"route1", "route2"} + + rs := routeselector.NewRouteSelector() + rs.DeselectAllRoutes() + + err := rs.SelectRoutes([]route.NetID{"missing"}, false, allRoutes) + + assert.Error(t, err, "an unavailable route ID must still be reported") + assert.True(t, rs.IsDeselectAll(), "deselect-all policy must survive a fully invalid request") + assert.False(t, rs.IsSelected("route3"), "deselect-all must still cover networks not present in allRoutes yet") +} diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index 8373e498a..bbbb969c9 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -4,12 +4,14 @@ package NetBirdSDK import ( "context" + "errors" "fmt" "net/netip" "os" "sort" "strings" "sync" + "sync/atomic" "time" log "github.com/sirupsen/logrus" @@ -37,6 +39,8 @@ const ( AnonymizeLevelStrict = nbAnonymize.LevelStrictString ) +var errClientAlreadyRunning = errors.New("client is already running") + // RouteListener export internal RouteListener for mobile type NetworkChangeListener interface { listener.NetworkChangeListener @@ -74,15 +78,13 @@ type Client struct { cacheDir string logFilePath string recorder *peer.Status - ctxCancel context.CancelFunc - ctxCancelLock *sync.Mutex deviceName string osName string osVersion string networkChangeListener listener.NetworkChangeListener onHostDnsFn func([]string) dnsManager dns.IosDnsManager - loginComplete bool + loginComplete atomic.Bool // netMgr outlives engine restarts: it mirrors the OS connectivity, not // the engine lifecycle. Run injects its state and sweeper into each new // ConnectClient. @@ -90,9 +92,16 @@ type Client struct { // preloadedConfig holds config loaded from JSON (used on tvOS where file writes are blocked) preloadedConfig *profilemanager.Config + // stateMu guards the run lifecycle as one unit: the cancel installed by + // the current run, the channel it closes on exit, and the state it + // published. One run at a time: startRun refuses a second Run while the + // previous one has not exited, and the platform serializes Stop before + // Start, so no generation tracking is needed. stateMu sync.RWMutex connectClient *internal.ConnectClient config *profilemanager.Config + runDone chan struct{} + ctxCancel context.CancelFunc } // NewClient instantiate a new Client @@ -107,7 +116,6 @@ func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osV osName: osName, osVersion: osVersion, recorder: recorder, - ctxCancelLock: &sync.Mutex{}, networkChangeListener: networkChangeListener, dnsManager: dnsManager, netMgr: netevents.NewManager(recorder), @@ -156,17 +164,21 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error { c.recorder.UpdateManagementAddress(cfg.ManagementURL.String()) c.recorder.UpdateRosenpass(cfg.RosenpassEnabled, cfg.RosenpassPermissive) - var ctx context.Context //nolint ctxWithValues := context.WithValue(context.Background(), system.DeviceNameCtxKey, c.deviceName) //nolint ctxWithValues = context.WithValue(ctxWithValues, system.OsNameCtxKey, c.osName) //nolint ctxWithValues = context.WithValue(ctxWithValues, system.OsVersionCtxKey, c.osVersion) - c.ctxCancelLock.Lock() - ctx, c.ctxCancel = context.WithCancel(ctxWithValues) - defer c.ctxCancel() - c.ctxCancelLock.Unlock() + runCtx, runCancel := context.WithCancel(ctxWithValues) + defer runCancel() + + done, err := c.startRun(runCancel) + if err != nil { + return err + } + defer c.finishRun(done) + ctx := runCtx // No login pre-flight here. The engine's own loginToManagement (connect.go) performs // the authoritative Login immediately before the first Sync, so a LoginSync() call at @@ -215,16 +227,40 @@ func (c *Client) NotifyNetworkChange() { c.netMgr.NotifyNetworkChange() } -// Stop the internal client and free the resources +// Stop cancels the running client and waits for the run loop to exit, so a +// caller that restarts immediately cannot race the outgoing teardown. func (c *Client) Stop() { - c.ctxCancelLock.Lock() - defer c.ctxCancelLock.Unlock() - if c.ctxCancel == nil { + done := c.cancelRun() + if done == nil { return } - c.ctxCancel() - c.setState(nil, nil) + select { + case <-done: + case <-time.After(stopRunWaitTimeout): + log.Warnf("Stop: timed out waiting for the run loop to exit") + } +} + +// StopWithoutWait cancels the running client without waiting for the run loop. +// Use it where the caller is on a deadline the wait could overrun, such as +// NEPacketTunnelProvider.stopTunnel, which iOS gives only a few seconds +// before it kills the extension. +func (c *Client) StopWithoutWait() { + c.cancelRun() +} + +func (c *Client) cancelRun() chan struct{} { + c.stateMu.RLock() + done := c.runDone + cancel := c.ctxCancel + c.stateMu.RUnlock() + + if cancel != nil { + cancel() + } + + return done } // DebugBundle generates a debug bundle, uploads it and returns the upload key. @@ -376,16 +412,14 @@ func (c *Client) IsLoginRequiredCached() bool { } func (c *Client) IsLoginRequired() bool { - var ctx context.Context //nolint ctxWithValues := context.WithValue(context.Background(), system.DeviceNameCtxKey, c.deviceName) //nolint ctxWithValues = context.WithValue(ctxWithValues, system.OsNameCtxKey, c.osName) //nolint ctxWithValues = context.WithValue(ctxWithValues, system.OsVersionCtxKey, c.osVersion) - c.ctxCancelLock.Lock() - defer c.ctxCancelLock.Unlock() - ctx, c.ctxCancel = context.WithCancel(ctxWithValues) + ctx, cancel := context.WithCancel(ctxWithValues) + defer cancel() var cfg *profilemanager.Config var err error @@ -433,17 +467,22 @@ func (c *Client) IsLoginRequired() bool { // loginForMobileAuthTimeout is the timeout for requesting auth info from the server const loginForMobileAuthTimeout = 30 * time.Second +const stopRunWaitTimeout = 20 * time.Second + func (c *Client) LoginForMobile() string { - var ctx context.Context //nolint ctxWithValues := context.WithValue(context.Background(), system.DeviceNameCtxKey, c.deviceName) //nolint ctxWithValues = context.WithValue(ctxWithValues, system.OsNameCtxKey, c.osName) //nolint ctxWithValues = context.WithValue(ctxWithValues, system.OsVersionCtxKey, c.osVersion) - c.ctxCancelLock.Lock() - defer c.ctxCancelLock.Unlock() - ctx, c.ctxCancel = context.WithCancel(ctxWithValues) + ctx, cancel := context.WithCancel(ctxWithValues) + loginDone := false + defer func() { + if !loginDone { + cancel() + } + }() // Use DirectUpdateOrCreateConfig to avoid atomic file operations (temp file + rename) // which are blocked by the tvOS sandbox in App Group containers @@ -470,7 +509,9 @@ func (c *Client) LoginForMobile() string { } // This could cause a potential race condition with loading the extension which need to be handled on swift side + loginDone = true go func() { + defer cancel() tokenInfo, err := oAuthFlow.WaitToken(ctx, flowInfo) if err != nil { log.Errorf("LoginForMobile: WaitToken failed: %v", err) @@ -487,18 +528,18 @@ func (c *Client) LoginForMobile() string { log.Errorf("LoginForMobile: Login failed: %v", err) return } - c.loginComplete = true + c.loginComplete.Store(true) }() return flowInfo.VerificationURIComplete } func (c *Client) IsLoginComplete() bool { - return c.loginComplete + return c.loginComplete.Load() } func (c *Client) ClearLoginComplete() { - c.loginComplete = false + c.loginComplete.Store(false) } func (c *Client) GetRoutesSelectionDetails() (*RoutesSelectionDetails, error) { @@ -718,13 +759,36 @@ func (c *Client) DeselectRoute(id string) error { return nil } -// setState stores the running engine state so DebugBundle can reuse the live -// config and ConnectClient. It is cleared on Stop. -func (c *Client) setState(cfg *profilemanager.Config, cc *internal.ConnectClient) { +func (c *Client) startRun(cancel context.CancelFunc) (chan struct{}, error) { c.stateMu.Lock() defer c.stateMu.Unlock() + + if c.runDone != nil { + return nil, errClientAlreadyRunning + } + + done := make(chan struct{}) + c.runDone = done + c.ctxCancel = cancel + return done, nil +} + +func (c *Client) finishRun(done chan struct{}) { + c.stateMu.Lock() + c.connectClient = nil + c.config = nil + c.runDone = nil + c.ctxCancel = nil + c.stateMu.Unlock() + + close(done) +} + +func (c *Client) setState(cfg *profilemanager.Config, cc *internal.ConnectClient) { + c.stateMu.Lock() c.config = cfg c.connectClient = cc + c.stateMu.Unlock() } // stateSnapshot returns the current config and ConnectClient under the lock. diff --git a/client/mdm/canonical_loaders.go b/client/mdm/canonical_loaders.go index 29288b511..eb9db07c4 100644 --- a/client/mdm/canonical_loaders.go +++ b/client/mdm/canonical_loaders.go @@ -27,6 +27,8 @@ var allKeys = []string{ KeyRosenpassEnabled, KeyRosenpassPermissive, KeyWireguardPort, + KeyEnableLocalMetrics, + KeyLocalMetricsAddress, KeySplitTunnelMode, KeySplitTunnelApps, KeyLazyConnection, diff --git a/client/mdm/canonical_loaders_test.go b/client/mdm/canonical_loaders_test.go new file mode 100644 index 000000000..330a15c47 --- /dev/null +++ b/client/mdm/canonical_loaders_test.go @@ -0,0 +1,52 @@ +//go:build windows || darwin + +package mdm + +import ( + "go/ast" + "go/parser" + "go/token" + "slices" + "strconv" + "testing" +) + +// TestAllKeysCoversEveryPolicyKey guards against the drift that adding a Key* +// constant without listing it in allKeys causes: the desktop loaders resolve +// value names through canonicalKey, so an unlisted key is silently discarded as +// unknown. policy.go is parsed rather than hand-mirrored so the test cannot go +// stale in the same way. +func TestAllKeysCoversEveryPolicyKey(t *testing.T) { + file, err := parser.ParseFile(token.NewFileSet(), "policy.go", nil, 0) + if err != nil { + t.Fatalf("parse policy.go: %v", err) + } + + for _, decl := range file.Decls { + gen, ok := decl.(*ast.GenDecl) + if !ok || gen.Tok != token.CONST { + continue + } + for _, spec := range gen.Specs { + value, ok := spec.(*ast.ValueSpec) + if !ok || len(value.Names) != 1 || len(value.Values) != 1 { + continue + } + name := value.Names[0].Name + if len(name) < 4 || name[:3] != "Key" { + continue + } + lit, ok := value.Values[0].(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + continue + } + key, err := strconv.Unquote(lit.Value) + if err != nil { + t.Fatalf("unquote %s: %v", name, err) + } + if !slices.Contains(allKeys, key) { + t.Errorf("%s (%q) is missing from allKeys, so the desktop loaders discard it as unknown", name, key) + } + } + } +} diff --git a/client/mdm/policy.go b/client/mdm/policy.go index 1feff28f8..6c64acfc8 100644 --- a/client/mdm/policy.go +++ b/client/mdm/policy.go @@ -47,6 +47,8 @@ const ( KeyRosenpassEnabled = "rosenpassEnabled" KeyRosenpassPermissive = "rosenpassPermissive" KeyWireguardPort = "wireguardPort" + KeyEnableLocalMetrics = "enableLocalMetrics" + KeyLocalMetricsAddress = "localMetricsAddress" // Split tunnel is modeled as a single conceptual policy with two // registry/plist values. KeySplitTunnelMode is the discriminator diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go index b438a310a..089f3b95b 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -343,6 +343,8 @@ type LoginRequest struct { DisableSSHAuth *bool `protobuf:"varint,38,opt,name=disableSSHAuth,proto3,oneof" json:"disableSSHAuth,omitempty"` SshJWTCacheTTL *int32 `protobuf:"varint,39,opt,name=sshJWTCacheTTL,proto3,oneof" json:"sshJWTCacheTTL,omitempty"` DisableIpv6 *bool `protobuf:"varint,40,opt,name=disable_ipv6,json=disableIpv6,proto3,oneof" json:"disable_ipv6,omitempty"` + EnableLocalMetrics *bool `protobuf:"varint,41,opt,name=enable_local_metrics,json=enableLocalMetrics,proto3,oneof" json:"enable_local_metrics,omitempty"` + LocalMetricsAddress *string `protobuf:"bytes,42,opt,name=local_metrics_address,json=localMetricsAddress,proto3,oneof" json:"local_metrics_address,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -658,6 +660,20 @@ func (x *LoginRequest) GetDisableIpv6() bool { return false } +func (x *LoginRequest) GetEnableLocalMetrics() bool { + if x != nil && x.EnableLocalMetrics != nil { + return *x.EnableLocalMetrics + } + return false +} + +func (x *LoginRequest) GetLocalMetricsAddress() string { + if x != nil && x.LocalMetricsAddress != nil { + return *x.LocalMetricsAddress + } + return "" +} + type LoginResponse struct { state protoimpl.MessageState `protogen:"open.v1"` NeedsSSOLogin bool `protobuf:"varint,1,opt,name=needsSSOLogin,proto3" json:"needsSSOLogin,omitempty"` @@ -4233,6 +4249,8 @@ type SetConfigRequest struct { DisableSSHAuth *bool `protobuf:"varint,33,opt,name=disableSSHAuth,proto3,oneof" json:"disableSSHAuth,omitempty"` SshJWTCacheTTL *int32 `protobuf:"varint,34,opt,name=sshJWTCacheTTL,proto3,oneof" json:"sshJWTCacheTTL,omitempty"` DisableIpv6 *bool `protobuf:"varint,35,opt,name=disable_ipv6,json=disableIpv6,proto3,oneof" json:"disable_ipv6,omitempty"` + EnableLocalMetrics *bool `protobuf:"varint,36,opt,name=enable_local_metrics,json=enableLocalMetrics,proto3,oneof" json:"enable_local_metrics,omitempty"` + LocalMetricsAddress *string `protobuf:"bytes,37,opt,name=local_metrics_address,json=localMetricsAddress,proto3,oneof" json:"local_metrics_address,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -4512,6 +4530,20 @@ func (x *SetConfigRequest) GetDisableIpv6() bool { return false } +func (x *SetConfigRequest) GetEnableLocalMetrics() bool { + if x != nil && x.EnableLocalMetrics != nil { + return *x.EnableLocalMetrics + } + return false +} + +func (x *SetConfigRequest) GetLocalMetricsAddress() string { + if x != nil && x.LocalMetricsAddress != nil { + return *x.LocalMetricsAddress + } + return "" +} + type SetConfigResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -7032,7 +7064,7 @@ var File_daemon_proto protoreflect.FileDescriptor const file_daemon_proto_rawDesc = "" + "\n" + "\fdaemon.proto\x12\x06daemon\x1a google/protobuf/descriptor.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/duration.proto\"\x0e\n" + - "\fEmptyRequest\"\xef\x12\n" + + "\fEmptyRequest\"\x92\x14\n" + "\fLoginRequest\x12\x1a\n" + "\bsetupKey\x18\x01 \x01(\tR\bsetupKey\x12&\n" + "\fpreSharedKey\x18\x02 \x01(\tB\x02\x18\x01R\fpreSharedKey\x12$\n" + @@ -7077,7 +7109,9 @@ const file_daemon_proto_rawDesc = "" + "\x1denableSSHRemotePortForwarding\x18% \x01(\bH\x18R\x1denableSSHRemotePortForwarding\x88\x01\x01\x12+\n" + "\x0edisableSSHAuth\x18& \x01(\bH\x19R\x0edisableSSHAuth\x88\x01\x01\x12+\n" + "\x0esshJWTCacheTTL\x18' \x01(\x05H\x1aR\x0esshJWTCacheTTL\x88\x01\x01\x12&\n" + - "\fdisable_ipv6\x18( \x01(\bH\x1bR\vdisableIpv6\x88\x01\x01B\x13\n" + + "\fdisable_ipv6\x18( \x01(\bH\x1bR\vdisableIpv6\x88\x01\x01\x125\n" + + "\x14enable_local_metrics\x18) \x01(\bH\x1cR\x12enableLocalMetrics\x88\x01\x01\x127\n" + + "\x15local_metrics_address\x18* \x01(\tH\x1dR\x13localMetricsAddress\x88\x01\x01B\x13\n" + "\x11_rosenpassEnabledB\x10\n" + "\x0e_interfaceNameB\x10\n" + "\x0e_wireguardPortB\x17\n" + @@ -7105,7 +7139,9 @@ const file_daemon_proto_rawDesc = "" + "\x1e_enableSSHRemotePortForwardingB\x11\n" + "\x0f_disableSSHAuthB\x11\n" + "\x0f_sshJWTCacheTTLB\x0f\n" + - "\r_disable_ipv6\"\xb5\x01\n" + + "\r_disable_ipv6B\x17\n" + + "\x15_enable_local_metricsB\x18\n" + + "\x16_local_metrics_address\"\xb5\x01\n" + "\rLoginResponse\x12$\n" + "\rneedsSSOLogin\x18\x01 \x01(\bR\rneedsSSOLogin\x12\x1a\n" + "\buserCode\x18\x02 \x01(\tR\buserCode\x12(\n" + @@ -7400,7 +7436,7 @@ const file_daemon_proto_rawDesc = "" + "\f_profileNameB\v\n" + "\t_username\"'\n" + "\x15SwitchProfileResponse\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\"\x98\x11\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"\xbb\x12\n" + "\x10SetConfigRequest\x12\x1a\n" + "\busername\x18\x01 \x01(\tR\busername\x12 \n" + "\vprofileName\x18\x02 \x01(\tR\vprofileName\x12$\n" + @@ -7440,7 +7476,9 @@ const file_daemon_proto_rawDesc = "" + "\x1denableSSHRemotePortForwarding\x18 \x01(\bH\x15R\x1denableSSHRemotePortForwarding\x88\x01\x01\x12+\n" + "\x0edisableSSHAuth\x18! \x01(\bH\x16R\x0edisableSSHAuth\x88\x01\x01\x12+\n" + "\x0esshJWTCacheTTL\x18\" \x01(\x05H\x17R\x0esshJWTCacheTTL\x88\x01\x01\x12&\n" + - "\fdisable_ipv6\x18# \x01(\bH\x18R\vdisableIpv6\x88\x01\x01B\x13\n" + + "\fdisable_ipv6\x18# \x01(\bH\x18R\vdisableIpv6\x88\x01\x01\x125\n" + + "\x14enable_local_metrics\x18$ \x01(\bH\x19R\x12enableLocalMetrics\x88\x01\x01\x127\n" + + "\x15local_metrics_address\x18% \x01(\tH\x1aR\x13localMetricsAddress\x88\x01\x01B\x13\n" + "\x11_rosenpassEnabledB\x10\n" + "\x0e_interfaceNameB\x10\n" + "\x0e_wireguardPortB\x17\n" + @@ -7465,7 +7503,9 @@ const file_daemon_proto_rawDesc = "" + "\x1e_enableSSHRemotePortForwardingB\x11\n" + "\x0f_disableSSHAuthB\x11\n" + "\x0f_sshJWTCacheTTLB\x0f\n" + - "\r_disable_ipv6\"\x13\n" + + "\r_disable_ipv6B\x17\n" + + "\x15_enable_local_metricsB\x18\n" + + "\x16_local_metrics_address\"\x13\n" + "\x11SetConfigResponse\"Q\n" + "\x11AddProfileRequest\x12\x1a\n" + "\busername\x18\x01 \x01(\tR\busername\x12 \n" + diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto index a3e3f4500..ad59a78f8 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -242,6 +242,9 @@ message LoginRequest { optional bool disableSSHAuth = 38; optional int32 sshJWTCacheTTL = 39; optional bool disable_ipv6 = 40; + + optional bool enable_local_metrics = 41; + optional string local_metrics_address = 42; } message LoginResponse { @@ -766,6 +769,9 @@ message SetConfigRequest { optional bool disableSSHAuth = 33; optional int32 sshJWTCacheTTL = 34; optional bool disable_ipv6 = 35; + + optional bool enable_local_metrics = 36; + optional string local_metrics_address = 37; } message SetConfigResponse{} diff --git a/client/server/mdm.go b/client/server/mdm.go index 9836c6bea..552fba94f 100644 --- a/client/server/mdm.go +++ b/client/server/mdm.go @@ -233,6 +233,24 @@ func conflictString(key, got string) conflictCheck { } } +// conflictStringPtr is conflictString for optional proto fields, where an +// explicit empty value is still a request to change the setting. If p is +// nil the field is treated as matching (no override requested); otherwise +// the check returns true only when the policy contains the key and its +// value equals *p. +func conflictStringPtr(key string, p *string) conflictCheck { + return conflictCheck{ + key: key, + check: func(pol *mdm.Policy) bool { + if p == nil { + return true + } + want, ok := pol.GetString(key) + return ok && want == *p + }, + } +} + // conflictInt64 builds a conflictCheck for an integer MDM key. If p is // nil the field is treated as matching; otherwise the check returns // true only when the policy contains the key and its int value equals *p. @@ -301,6 +319,8 @@ func mdmManagedFieldConflicts(msg *proto.SetConfigRequest, policy *mdm.Policy) [ conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes), conflictBool(mdm.KeyBlockInbound, msg.BlockInbound), conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort), + conflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics), + conflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress), }) } @@ -346,7 +366,9 @@ func setConfigRequestHasConfigOverrides(msg *proto.SetConfigRequest) bool { msg.EnableSSHLocalPortForwarding != nil || msg.EnableSSHRemotePortForwarding != nil || msg.DisableSSHAuth != nil || - msg.SshJWTCacheTTL != nil + msg.SshJWTCacheTTL != nil || + msg.EnableLocalMetrics != nil || + msg.LocalMetricsAddress != nil } // loginRequestHasConfigOverrides reports whether the LoginRequest @@ -381,7 +403,9 @@ func loginRequestHasConfigOverrides(msg *proto.LoginRequest) bool { msg.BlockLanAccess != nil || msg.DisableNotifications != nil || len(msg.DnsLabels) > 0 || msg.CleanDNSLabels || - msg.BlockInbound != nil + msg.BlockInbound != nil || + msg.EnableLocalMetrics != nil || + msg.LocalMetricsAddress != nil } // loginRequestMDMConflicts mirrors mdmManagedFieldConflicts but for the @@ -422,6 +446,8 @@ func loginRequestMDMConflicts(msg *proto.LoginRequest, policy *mdm.Policy) []str conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes), conflictBool(mdm.KeyBlockInbound, msg.BlockInbound), conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort), + conflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics), + conflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress), }) } 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.go b/client/server/server.go index f33e19075..23dccc9b1 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -23,6 +23,9 @@ import ( "github.com/netbirdio/netbird/client/internal/auth" "github.com/netbirdio/netbird/client/internal/expose" + "github.com/prometheus/client_golang/prometheus" + + "github.com/netbirdio/netbird/client/internal/localmetrics" "github.com/netbirdio/netbird/client/internal/profilemanager" sleephandler "github.com/netbirdio/netbird/client/internal/sleep/handler" "github.com/netbirdio/netbird/client/mdm" @@ -108,6 +111,7 @@ type Server struct { statusRecorder *peer.Status sessionWatcher *internal.SessionWatcher + localMetrics *localmetrics.Manager probeThrottle *probeThrottle persistSyncResponse bool @@ -171,9 +175,28 @@ func New(ctx context.Context, logFile string, configFile string, profilesDisable s.sleepHandler = sleephandler.New(agent) s.startSleepDetector() + s.localMetrics = localmetrics.NewManager(ctx, s.statusRecorder, s.clientMetricsGatherer) + return s } +// clientMetricsGatherer returns the Prometheus gatherer of the running +// engine's client metrics, or nil when no engine is running. +func (s *Server) clientMetricsGatherer() prometheus.Gatherer { + s.mutex.Lock() + connectClient := s.connectClient + s.mutex.Unlock() + + if connectClient == nil { + return nil + } + engine := connectClient.Engine() + if engine == nil { + return nil + } + return engine.GetClientMetrics().PrometheusGatherer() +} + func (s *Server) Start() error { s.mutex.Lock() defer s.mutex.Unlock() @@ -254,6 +277,7 @@ func (s *Server) Start() error { s.statusRecorder.UpdateManagementAddress(config.ManagementURL.String()) s.statusRecorder.UpdateRosenpass(config.RosenpassEnabled, config.RosenpassPermissive) + s.localMetrics.Reconcile(config.LocalMetricsEnabled, config.LocalMetricsAddress) if s.sessionWatcher == nil { s.sessionWatcher = internal.NewSessionWatcher(s.rootCtx, s.statusRecorder) @@ -477,11 +501,18 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques return nil, err } - if _, err := profilemanager.UpdateConfig(config); err != nil { + updatedConf, err := profilemanager.UpdateConfig(config) + if err != nil { log.Errorf("failed to update profile config: %v", err) return nil, fmt.Errorf("failed to update profile config: %w", err) } + if activeProf, err := s.profileManager.GetActiveProfileState(); err == nil { + if activePath, err := activeProf.FilePath(); err == nil && activePath == config.ConfigPath { + s.localMetrics.Reconcile(updatedConf.LocalMetricsEnabled, updatedConf.LocalMetricsAddress) + } + } + return &proto.SetConfigResponse{}, nil } @@ -551,6 +582,8 @@ func (s *Server) setConfigInputFromRequest(msg *proto.SetConfigRequest) (profile config.RosenpassEnabled = msg.RosenpassEnabled config.RosenpassPermissive = msg.RosenpassPermissive + config.LocalMetricsEnabled = msg.EnableLocalMetrics + config.LocalMetricsAddress = msg.LocalMetricsAddress config.DisableAutoConnect = msg.DisableAutoConnect config.ServerSSHAllowed = msg.ServerSSHAllowed config.NetworkMonitor = msg.NetworkMonitor @@ -657,6 +690,8 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro s.config = config s.mutex.Unlock() + s.localMetrics.Reconcile(config.LocalMetricsEnabled, config.LocalMetricsAddress) + // A probe that errors leaves the login undecided: Management unreachable, a // restart mid-request, an internal error. Those are returned for the caller // to retry, because turning them into an SSO prompt asks the user to solve @@ -1007,6 +1042,7 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR s.statusRecorder.UpdateManagementAddress(s.config.ManagementURL.String()) s.statusRecorder.UpdateRosenpass(s.config.RosenpassEnabled, s.config.RosenpassPermissive) + s.localMetrics.Reconcile(s.config.LocalMetricsEnabled, s.config.LocalMetricsAddress) s.clientRunning = true s.clientRunningChan = make(chan struct{}) @@ -1184,6 +1220,7 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi } s.config = config + s.localMetrics.Reconcile(config.LocalMetricsEnabled, config.LocalMetricsAddress) if msg != nil && msg.ProfileName != nil { s.publishProfileListChanged(*msg.ProfileName) 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/client/server/setconfig_mdm_test.go b/client/server/setconfig_mdm_test.go index ae323ea8c..ad3b7ade7 100644 --- a/client/server/setconfig_mdm_test.go +++ b/client/server/setconfig_mdm_test.go @@ -136,6 +136,51 @@ func TestSetConfig_MDMReject_MultipleFields(t *testing.T) { }, v.GetFields()) } +func TestSetConfig_MDMReject_LocalMetrics(t *testing.T) { + withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + mdm.KeyEnableLocalMetrics: true, + mdm.KeyLocalMetricsAddress: "127.0.0.1:9191", + })) + + s, ctx, profName, username, _ := setupServerWithProfile(t) + + enabled := false + addr := "0.0.0.0:9999" + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + EnableLocalMetrics: &enabled, + LocalMetricsAddress: &addr, + }) + + v := extractViolation(t, err) + assert.ElementsMatch(t, []string{ + mdm.KeyEnableLocalMetrics, + mdm.KeyLocalMetricsAddress, + }, v.GetFields()) +} + +// An explicitly empty address still changes the effective listen address +// (the manager falls back to the default), so presence must be honored +// rather than collapsed to "field not set". +func TestSetConfig_MDMReject_LocalMetricsEmptyAddress(t *testing.T) { + withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + mdm.KeyLocalMetricsAddress: "127.0.0.1:9999", + })) + + s, ctx, profName, username, _ := setupServerWithProfile(t) + + addr := "" + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + LocalMetricsAddress: &addr, + }) + + v := extractViolation(t, err) + assert.ElementsMatch(t, []string{mdm.KeyLocalMetricsAddress}, v.GetFields()) +} + func TestSetConfig_MDMReject_AllOrNothing(t *testing.T) { // MDM enforces ManagementURL only; user request touches both the // enforced field AND a non-enforced field (RosenpassEnabled). diff --git a/client/server/setconfig_test.go b/client/server/setconfig_test.go index db7a26f03..d8309f519 100644 --- a/client/server/setconfig_test.go +++ b/client/server/setconfig_test.go @@ -76,6 +76,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { disableIPv6 := true mtu := int64(1280) sshJWTCacheTTL := int32(300) + enableLocalMetrics := true + localMetricsAddress := "127.0.0.1:9292" req := &proto.SetConfigRequest{ ProfileName: profName, @@ -107,6 +109,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { DnsRouteInterval: durationpb.New(2 * time.Minute), Mtu: &mtu, SshJWTCacheTTL: &sshJWTCacheTTL, + EnableLocalMetrics: &enableLocalMetrics, + LocalMetricsAddress: &localMetricsAddress, } _, err = s.SetConfig(ctx, req) @@ -153,6 +157,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { require.Equal(t, uint16(mtu), cfg.MTU) require.NotNil(t, cfg.SSHJWTCacheTTL) require.Equal(t, int(sshJWTCacheTTL), *cfg.SSHJWTCacheTTL) + require.Equal(t, enableLocalMetrics, cfg.LocalMetricsEnabled) + require.Equal(t, localMetricsAddress, cfg.LocalMetricsAddress) verifyAllFieldsCovered(t, req) } @@ -205,6 +211,8 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) { "EnableSSHRemotePortForwarding": true, "DisableSSHAuth": true, "SshJWTCacheTTL": true, + "EnableLocalMetrics": true, + "LocalMetricsAddress": true, } val := reflect.ValueOf(req).Elem() @@ -264,6 +272,8 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) { "enable-ssh-remote-port-forwarding": "EnableSSHRemotePortForwarding", "disable-ssh-auth": "DisableSSHAuth", "ssh-jwt-cache-ttl": "SshJWTCacheTTL", + "enable-local-metrics": "EnableLocalMetrics", + "local-metrics-address": "LocalMetricsAddress", } // SetConfigRequest fields that don't have CLI flags (settable only via UI or other means). diff --git a/client/server/ssh_gate.go b/client/server/ssh_gate.go index ca1b4c4ee..3b62f5e56 100644 --- a/client/server/ssh_gate.go +++ b/client/server/ssh_gate.go @@ -14,6 +14,7 @@ import ( "github.com/netbirdio/netbird/client/internal/daemonaddr" "github.com/netbirdio/netbird/client/internal/ipcauth" + "github.com/netbirdio/netbird/client/internal/localmetrics" "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/util" @@ -30,6 +31,8 @@ import ( // management identity hands SSH authorization decisions, including which // keys and users are accepted, to whoever controls that identity. Changing // the management URL and deregistering the peer are both ways to do that. +// - Binding the local metrics endpoint to a non-loopback address publishes +// peer names and connectivity state to the network without authentication. // // Everything else stays unauthenticated, so this is not an authorization model: // it only refuses the changes that would let a local user become root. A caller @@ -39,27 +42,33 @@ import ( // user-to-root boundary. Fields are nil or empty when the request leaves them // untouched. type privilegedConfigChange struct { - managementURL string - serverSSHAllowed *bool - enableSSHRoot *bool - disableSSHAuth *bool + managementURL string + serverSSHAllowed *bool + enableSSHRoot *bool + disableSSHAuth *bool + enableLocalMetrics *bool + localMetricsAddress *string } func privilegedChangeFromSetConfig(msg *proto.SetConfigRequest) privilegedConfigChange { return privilegedConfigChange{ - managementURL: msg.GetManagementUrl(), - serverSSHAllowed: msg.ServerSSHAllowed, - enableSSHRoot: msg.EnableSSHRoot, - disableSSHAuth: msg.DisableSSHAuth, + managementURL: msg.GetManagementUrl(), + serverSSHAllowed: msg.ServerSSHAllowed, + enableSSHRoot: msg.EnableSSHRoot, + disableSSHAuth: msg.DisableSSHAuth, + enableLocalMetrics: msg.EnableLocalMetrics, + localMetricsAddress: msg.LocalMetricsAddress, } } func privilegedChangeFromLogin(msg *proto.LoginRequest) privilegedConfigChange { return privilegedConfigChange{ - managementURL: msg.GetManagementUrl(), - serverSSHAllowed: msg.ServerSSHAllowed, - enableSSHRoot: msg.EnableSSHRoot, - disableSSHAuth: msg.DisableSSHAuth, + managementURL: msg.GetManagementUrl(), + serverSSHAllowed: msg.ServerSSHAllowed, + enableSSHRoot: msg.EnableSSHRoot, + disableSSHAuth: msg.DisableSSHAuth, + enableLocalMetrics: msg.EnableLocalMetrics, + localMetricsAddress: msg.LocalMetricsAddress, } } @@ -83,6 +92,12 @@ func requirePrivilegeForConfigChange(ctx context.Context, stored *profilemanager return denyPrivileged(ctx, "enabling the NetBird SSH server", ipcauth.UpCommand("--allow-server-ssh")) } + if addr, exposes := exposesLocalMetrics(stored, change); exposes { + return denyPrivileged(ctx, + "exposing the local metrics endpoint on a non-loopback address", + ipcauth.UpCommand("--enable-local-metrics --local-metrics-address "+addr)) + } + // Only guard the management binding while the SSH server is enabled: that is // when the management identity decides who may open a shell here. if !sshServerEnabled(stored) { @@ -245,6 +260,48 @@ func sshServerCurrentlyAllowed(cfg *profilemanager.Config) *bool { return &enabled } +// exposesLocalMetrics reports whether the change would leave the metrics +// endpoint enabled on an address that is not confirmed loopback, and returns +// that address. A request that restates the stored state is not a change, so a +// settings form resubmitted after an administrator opened the endpoint is not +// refused. +func exposesLocalMetrics(stored *profilemanager.Config, change privilegedConfigChange) (string, bool) { + storedEnabled, storedAddr := storedLocalMetrics(stored) + + enabled := storedEnabled + if change.enableLocalMetrics != nil { + enabled = *change.enableLocalMetrics + } + addr := storedAddr + if change.localMetricsAddress != nil { + addr = metricsAddrOrDefault(*change.localMetricsAddress) + } + + if !enabled || localmetrics.IsLoopback(addr) { + return "", false + } + if storedEnabled && storedAddr == addr { + return "", false + } + return addr, true +} + +// storedLocalMetrics reads the metrics settings from the stored config, +// tolerating a config that does not exist yet. +func storedLocalMetrics(cfg *profilemanager.Config) (bool, string) { + if cfg == nil { + return false, localmetrics.DefaultListenAddress + } + return cfg.LocalMetricsEnabled, metricsAddrOrDefault(cfg.LocalMetricsAddress) +} + +func metricsAddrOrDefault(addr string) string { + if addr == "" { + return localmetrics.DefaultListenAddress + } + return addr +} + // sameManagementURL reports whether requested addresses the same management // server as stored, comparing scheme, host and effective port so that an // equivalent spelling ("https://api.netbird.io" for a stored diff --git a/client/server/ssh_gate_test.go b/client/server/ssh_gate_test.go index cbd345f16..d71cd86ef 100644 --- a/client/server/ssh_gate_test.go +++ b/client/server/ssh_gate_test.go @@ -61,6 +61,8 @@ func noIdentityCtx() context.Context { return context.Background() } func boolPtr(v bool) *bool { return &v } +func strPtr(v string) *string { return &v } + func mustURL(t *testing.T, raw string) *url.URL { t.Helper() u, err := url.Parse(raw) @@ -194,6 +196,102 @@ func TestRequirePrivilegeForConfigChange_SSHFlags(t *testing.T) { } } +func TestRequirePrivilegeForConfigChange_LocalMetrics(t *testing.T) { + exposed := &profilemanager.Config{LocalMetricsEnabled: true, LocalMetricsAddress: "0.0.0.0:9191"} + + tests := []struct { + name string + stored *profilemanager.Config + change privilegedConfigChange + privileged bool + wantDeny bool + }{ + { + name: "binding a non-loopback address unprivileged is refused", + stored: &profilemanager.Config{}, + change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("0.0.0.0:9191")}, + wantDeny: true, + }, + { + name: "binding a non-loopback address as root is allowed", + stored: &profilemanager.Config{}, + change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("0.0.0.0:9191")}, + privileged: true, + }, + { + name: "enabling on the default loopback address is not guarded", + stored: &profilemanager.Config{}, + change: privilegedConfigChange{enableLocalMetrics: boolPtr(true)}, + }, + { + name: "enabling on an explicit loopback address is not guarded", + stored: &profilemanager.Config{}, + change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("127.0.0.1:9999")}, + }, + { + name: "enabling on the IPv6 loopback address is not guarded", + stored: &profilemanager.Config{}, + change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("[::1]:9191")}, + }, + { + // The address alone does nothing while the endpoint stays off. + name: "a non-loopback address without enabling is not guarded", + stored: &profilemanager.Config{}, + change: privilegedConfigChange{localMetricsAddress: strPtr("0.0.0.0:9191")}, + }, + { + name: "widening an already enabled loopback endpoint is refused", + stored: &profilemanager.Config{LocalMetricsEnabled: true, LocalMetricsAddress: "127.0.0.1:9191"}, + change: privilegedConfigChange{localMetricsAddress: strPtr("0.0.0.0:9191")}, + wantDeny: true, + }, + { + name: "restating an already exposed endpoint is not a change", + stored: exposed, + change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("0.0.0.0:9191")}, + }, + { + name: "turning an exposed endpoint off is not guarded", + stored: exposed, + change: privilegedConfigChange{enableLocalMetrics: boolPtr(false)}, + }, + { + name: "re-enabling an exposed endpoint that was turned off is refused", + stored: &profilemanager.Config{LocalMetricsEnabled: false, LocalMetricsAddress: "0.0.0.0:9191"}, + change: privilegedConfigChange{enableLocalMetrics: boolPtr(true)}, + wantDeny: true, + }, + { + // Fail closed: an address that cannot be parsed is not confirmed loopback. + name: "an unparseable address is refused", + stored: &profilemanager.Config{}, + change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("not-an-address")}, + wantDeny: true, + }, + { + name: "a profile with no config yet counts as off, so exposing is refused", + stored: nil, + change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("0.0.0.0:9191")}, + wantDeny: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := userCtx() + if tt.privileged { + ctx = rootCtx() + } + err := requirePrivilegeForConfigChange(ctx, tt.stored, tt.change) + if tt.wantDeny { + assertDenied(t, err) + return + } + assertAllowed(t, err) + }) + } +} + func TestRequirePrivilegeForConfigChange_ManagementURL(t *testing.T) { sshOn := func(raw string) *profilemanager.Config { return &profilemanager.Config{ServerSSHAllowed: boolPtr(true), ManagementURL: mustURL(t, raw)} diff --git a/client/testutil/privileged/runner_test.go b/client/testutil/privileged/runner_test.go index d1945894d..157005d3e 100644 --- a/client/testutil/privileged/runner_test.go +++ b/client/testutil/privileged/runner_test.go @@ -25,7 +25,7 @@ import ( // (.github/workflows/golang-test-linux.yml, test_client_on_docker). const ( containerImage = "golang" - containerTag = "1.25-alpine" + containerTag = "1.26.7-alpine" ) const ( diff --git a/client/ui/build/docker/Dockerfile.cross b/client/ui/build/docker/Dockerfile.cross index a487b8db0..55c0d69e1 100644 --- a/client/ui/build/docker/Dockerfile.cross +++ b/client/ui/build/docker/Dockerfile.cross @@ -13,7 +13,7 @@ # docker run --rm -v $(pwd):/app wails-cross windows amd64 # docker run --rm -v $(pwd):/app wails-cross windows arm64 -FROM golang:1.25-bookworm +FROM golang:1.26.7-bookworm ARG TARGETARCH diff --git a/client/ui/build/docker/Dockerfile.server b/client/ui/build/docker/Dockerfile.server index 58fb64f76..57183f1d2 100644 --- a/client/ui/build/docker/Dockerfile.server +++ b/client/ui/build/docker/Dockerfile.server @@ -2,7 +2,7 @@ # Multi-stage build for minimal image size # Build stage -FROM golang:alpine AS builder +FROM golang:1.26.7-alpine AS builder WORKDIR /app diff --git a/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx b/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx index ef8d6862f..e57040a7a 100644 --- a/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx +++ b/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx @@ -18,6 +18,11 @@ import { formatRemaining } from "@/lib/formatters"; const DEFAULT_SECONDS = 360; const WINDOW_WIDTH = 360; const SOON_THRESHOLD_SECONDS = 60 * 60; +const DEADLINE_TOLERANCE_MS = 5 * 1000; +// The final-warning deadline reaches the Go side as RFC3339 truncated to whole +// seconds, while the status snapshot carries millisecond precision, so an +// unchanged deadline can look up to 999 ms newer than the exact URL value. +const EXACT_DEADLINE_TOLERANCE_MS = 999; export default function SessionExpirationDialog() { const { t } = useTranslation(); @@ -29,11 +34,19 @@ export default function SessionExpirationDialog() { const n = Number.parseInt(raw, 10); return Number.isFinite(n) && n > 0 ? n : DEFAULT_SECONDS; }, [params]); + const initialDeadline = useMemo(() => { + const raw = params.get("deadline"); + if (!raw) return null; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) && n > 0 ? n : null; + }, [params]); const [remaining, setRemaining] = useState(initialSeconds); const [busy, setBusy] = useState(false); const busyRef = useRef(busy); busyRef.current = busy; + const openedDeadlineRef = useRef(initialDeadline ?? Date.now() + initialSeconds * 1000); + const exactDeadlineRef = useRef(initialDeadline !== null); const expired = remaining <= 0; const expiredRef = useRef(expired); expiredRef.current = expired; @@ -45,23 +58,45 @@ export default function SessionExpirationDialog() { useEffect(() => { setRemaining(initialSeconds); - }, [initialSeconds]); + openedDeadlineRef.current = initialDeadline ?? Date.now() + initialSeconds * 1000; + exactDeadlineRef.current = initialDeadline !== null; + }, [initialSeconds, initialDeadline]); + // Recompute from the absolute deadline instead of decrementing per tick: webview + // timers get suspended for tens of seconds (App Nap / hidden-window throttling), + // so a tick counter drifts behind the wall clock by the suspended time. useEffect(() => { const id = globalThis.setInterval(() => { - setRemaining((s) => (s <= 1 ? 0 : s - 1)); + setRemaining(Math.max(0, Math.ceil((openedDeadlineRef.current - Date.now()) / 1000))); }, 1000); return () => globalThis.clearInterval(id); }, [initialSeconds]); + // Auto-close only when the session was actually renewed elsewhere (tray action, CLI, + // main window): the daemon keeps emitting Connected snapshots regardless of session + // state, so the signal is the deadline jumping past the one this dialog was opened for. + // With the exact deadline from the URL any jump past its sub-second precision loss + // counts; the seconds-derived fallback needs a wider tolerance for the Go-side + // truncation and mount latency. // Don't auto-close while busy (aborts our WaitExtend) or expired (hides the state). useEffect(() => { - const off = Events.On("netbird:status", (ev: { data: { status?: string } }) => { - if (busyRef.current || expiredRef.current) return; - if (ev?.data?.status === "Connected") { - WindowManager.CloseSessionExpiration().catch(console.error); - } - }); + const off = Events.On( + "netbird:status", + (ev: { data: { status?: string; sessionExpiresAt?: string | null } }) => { + if (busyRef.current || expiredRef.current) return; + if (ev?.data?.status !== "Connected") return; + const raw = ev?.data?.sessionExpiresAt; + if (!raw) return; + const renewed = Date.parse(raw); + if (!Number.isFinite(renewed)) return; + const tolerance = exactDeadlineRef.current + ? EXACT_DEADLINE_TOLERANCE_MS + : DEADLINE_TOLERANCE_MS; + if (renewed - openedDeadlineRef.current > tolerance) { + WindowManager.CloseSessionExpiration().catch(console.error); + } + }, + ); return () => { off(); }; diff --git a/client/ui/i18n/locales/_index.json b/client/ui/i18n/locales/_index.json index 419358d36..17fb1d8ea 100644 --- a/client/ui/i18n/locales/_index.json +++ b/client/ui/i18n/locales/_index.json @@ -1,6 +1,7 @@ { "languages": [ {"code": "en", "displayName": "English (US)", "englishName": "English (US)"}, + {"code": "uk", "displayName": "Українська", "englishName": "Ukrainian"}, {"code": "de", "displayName": "Deutsch", "englishName": "German"}, {"code": "hu", "displayName": "Magyar", "englishName": "Hungarian"}, {"code": "ru", "displayName": "Русский", "englishName": "Russian"}, diff --git a/client/ui/i18n/locales/uk/common.json b/client/ui/i18n/locales/uk/common.json new file mode 100644 index 000000000..4e3f24102 --- /dev/null +++ b/client/ui/i18n/locales/uk/common.json @@ -0,0 +1,1376 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "Відключено" + }, + "tray.status.daemonUnavailable": { + "message": "Не запущено" + }, + "tray.status.error": { + "message": "Помилка" + }, + "tray.status.connected": { + "message": "Підключено" + }, + "tray.status.connecting": { + "message": "Підключення" + }, + "tray.status.needsLogin": { + "message": "Потрібно ввійти" + }, + "tray.status.loginFailed": { + "message": "Помилка входу" + }, + "tray.status.sessionExpired": { + "message": "Сеанс закінчився" + }, + "tray.session.expiresIn": { + "message": "До завершення сеансу: {remaining}" + }, + "tray.session.unit.lessThanMinute": { + "message": "менше хвилини" + }, + "tray.session.unit.minute": { + "message": "1 хв." + }, + "tray.session.unit.minutes": { + "message": "{count} хв." + }, + "tray.session.unit.hour": { + "message": "1 год." + }, + "tray.session.unit.hours": { + "message": "{count} год." + }, + "tray.session.unit.day": { + "message": "1 дн." + }, + "tray.session.unit.days": { + "message": "{count} дн." + }, + "tray.menu.open": { + "message": "Відкрити NetBird" + }, + "tray.menu.connect": { + "message": "Підключитися" + }, + "tray.menu.disconnect": { + "message": "Відключитися" + }, + "tray.menu.exitNode": { + "message": "Вихідний вузол" + }, + "tray.menu.networks": { + "message": "Ресурси" + }, + "tray.menu.profiles": { + "message": "Профілі" + }, + "tray.menu.manageProfiles": { + "message": "Керування профілями" + }, + "tray.menu.settings": { + "message": "Налаштування…" + }, + "tray.menu.debugBundle": { + "message": "Створити архів діагностики" + }, + "tray.menu.about": { + "message": "Допомога та підтримка" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "Документація" + }, + "tray.menu.troubleshoot": { + "message": "Діагностика" + }, + "tray.menu.downloadLatest": { + "message": "Завантажити останню версію" + }, + "tray.menu.installVersion": { + "message": "Встановити версію {version}" + }, + "tray.menu.guiVersion": { + "message": "Графічний інтерфейс: {version}" + }, + "tray.menu.daemonVersion": { + "message": "Служба: {version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "Вийти з NetBird" + }, + "notify.daemonOutdated.title": { + "message": "Служба NetBird застаріла" + }, + "notify.daemonOutdated.body": { + "message": "Оновіть службу NetBird, щоб користуватися застосунком." + }, + "notify.update.title": { + "message": "Доступне оновлення NetBird" + }, + "notify.update.body": { + "message": "Доступна версія NetBird {version}." + }, + "notify.update.enforcedSuffix": { + "message": " Ваш адміністратор вимагає встановити це оновлення." + }, + "notify.error.title": { + "message": "Помилка" + }, + "notify.error.connect": { + "message": "Не вдалося підключитися" + }, + "notify.error.disconnect": { + "message": "Не вдалося відключитися" + }, + "notify.error.switchProfile": { + "message": "Не вдалося перемкнутися на {profile}" + }, + "notify.error.exitNode": { + "message": "Не вдалося оновити вихідний вузол {name}" + }, + "notify.sessionExpired.title": { + "message": "Сеанс NetBird закінчився" + }, + "notify.sessionExpired.body": { + "message": "Ваш сеанс NetBird закінчився. Будь ласка, увійдіть знову." + }, + "notify.sessionWarning.title": { + "message": "Сеанс невдовзі закінчиться" + }, + "notify.sessionWarning.body": { + "message": "Ваш сеанс NetBird закінчиться через {remaining}. Натисніть «Продовжити зараз», щоб оновити його." + }, + "notify.sessionWarning.bodyGeneric": { + "message": "Ваш сеанс NetBird невдовзі закінчиться. Натисніть «Продовжити зараз», щоб оновити його." + }, + "notify.sessionWarning.extend": { + "message": "Продовжити зараз" + }, + "notify.sessionWarning.dismiss": { + "message": "Закрити" + }, + "notify.sessionWarning.failed": { + "message": "Не вдалося продовжити сеанс NetBird" + }, + "notify.sessionWarning.successTitle": { + "message": "Сеанс NetBird продовжено" + }, + "notify.sessionWarning.successBody": { + "message": "Ваш сеанс успішно продовжено." + }, + "notify.sessionDeadlineRejected.title": { + "message": "Недійсний термін дії сеансу" + }, + "notify.sessionDeadlineRejected.body": { + "message": "Сервер надіслав недійсний термін дії сеансу. Будь ласка, увійдіть знову." + }, + "notify.mdm.policyApplied.title": { + "message": "Налаштування NetBird оновлено" + }, + "notify.mdm.policyApplied.body": { + "message": "Конфігурацію NetBird оновлено відповідно до політики вашої організації." + }, + "common.cancel": { + "message": "Скасувати" + }, + "common.save": { + "message": "Зберегти" + }, + "common.saveChanges": { + "message": "Зберегти зміни" + }, + "common.saving": { + "message": "Збереження…" + }, + "common.close": { + "message": "Закрити" + }, + "common.copy": { + "message": "Копіювати" + }, + "common.togglePasswordVisibility": { + "message": "Показати/сховати пароль" + }, + "common.increase": { + "message": "Збільшити" + }, + "common.decrease": { + "message": "Зменшити" + }, + "common.delete": { + "message": "Видалити" + }, + "common.create": { + "message": "Створити" + }, + "common.add": { + "message": "Додати" + }, + "common.remove": { + "message": "Вилучити" + }, + "common.refresh": { + "message": "Оновити" + }, + "common.loading": { + "message": "Завантаження…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "Результатів не знайдено" + }, + "common.noResults.description": { + "message": "Ми не змогли нічого знайти. Спробуйте змінити пошуковий запит або налаштування фільтрів." + }, + "notConnected.title": { + "message": "Відключено" + }, + "notConnected.description": { + "message": "Спочатку підключіться до NetBird, щоб переглянути детальну інформацію про піри, мережеві ресурси та вихідні вузли." + }, + "connect.status.disconnected": { + "message": "Відключено" + }, + "connect.status.connecting": { + "message": "Підключення…" + }, + "connect.status.connected": { + "message": "Підключено" + }, + "connect.status.disconnecting": { + "message": "Відключення…" + }, + "connect.status.daemonUnavailable": { + "message": "Служба недоступна" + }, + "connect.status.loginRequired": { + "message": "Потрібно ввійти" + }, + "connect.error.loginTitle": { + "message": "Помилка входу" + }, + "connect.error.connectTitle": { + "message": "Помилка підключення" + }, + "connect.error.disconnectTitle": { + "message": "Помилка відключення" + }, + "nav.peers.title": { + "message": "Піри" + }, + "nav.peers.description": { + "message": "Підключено {connected} з {total}" + }, + "nav.resources.title": { + "message": "Ресурси" + }, + "nav.resources.description": { + "message": "Активно {active} з {total}" + }, + "nav.exitNode.title": { + "message": "Вихідні вузли" + }, + "nav.exitNode.none": { + "message": "Неактивний" + }, + "nav.exitNode.using": { + "message": "Через {name}" + }, + "header.openSettings": { + "message": "Відкрити налаштування" + }, + "header.togglePanel": { + "message": "Показати/сховати бічну панель" + }, + "profile.selector.loading": { + "message": "Завантаження…" + }, + "profile.selector.noProfile": { + "message": "Немає профілю" + }, + "profile.selector.searchPlaceholder": { + "message": "Пошук профілю за назвою…" + }, + "profile.selector.emptyTitle": { + "message": "Профілів не знайдено" + }, + "profile.selector.emptyDescription": { + "message": "Спробуйте змінити пошуковий запит або створіть новий профіль." + }, + "profile.selector.newProfile": { + "message": "Новий профіль" + }, + "profile.selector.moreOptions": { + "message": "Додаткові параметри" + }, + "profile.selector.deregister": { + "message": "Вийти з профілю" + }, + "profile.selector.delete": { + "message": "Видалити" + }, + "profile.selector.switchTo": { + "message": "Перемкнутися на цей профіль" + }, + "profile.selector.edit": { + "message": "Редагувати" + }, + "profile.edit.title": { + "message": "Редагувати профіль" + }, + "profile.edit.submit": { + "message": "Зберегти зміни" + }, + "profile.dialog.title": { + "message": "Введіть назву профілю" + }, + "profile.dialog.nameLabel": { + "message": "Назва профілю" + }, + "profile.dialog.description": { + "message": "Вкажіть зрозумілу назву для вашого профілю." + }, + "profile.dialog.placeholder": { + "message": "наприклад, Робота" + }, + "profile.dialog.submit": { + "message": "Додати профіль" + }, + "profile.dialog.required": { + "message": "Будь ласка, введіть назву профілю, наприклад, «Робота» або «Дім»." + }, + "profile.dialog.managementHelp": { + "message": "Використовуйте NetBird Cloud або власний сервер." + }, + "profile.dialog.urlUnreachable": { + "message": "Не вдалося підключитися до цього сервера. Перевірте URL-адресу або додайте профіль, якщо ви впевнені, що вона правильна." + }, + "header.menu.settings": { + "message": "Налаштування…" + }, + "header.menu.defaultView": { + "message": "Стандартний вигляд" + }, + "header.menu.advancedView": { + "message": "Розширений вигляд" + }, + "header.menu.updateAvailable": { + "message": "Доступне оновлення" + }, + "header.menu.open": { + "message": "Відкрити меню" + }, + "header.profile.switch": { + "message": "Змінити профіль" + }, + "connect.toggle.label": { + "message": "Перемкнути підключення NetBird" + }, + "connect.localIp.label": { + "message": "Локальні IP-адреси" + }, + "common.search": { + "message": "Пошук" + }, + "common.filter": { + "message": "Фільтр" + }, + "exitNodes.dropdown.trigger": { + "message": "Вибрати вихідний вузол" + }, + "peers.row.label": { + "message": "Відкрити деталі для {name}, {status}" + }, + "peers.dialog.title": { + "message": "Деталі піра" + }, + "networks.row.toggle": { + "message": "Перемкнути {name}" + }, + "networks.bulk.label": { + "message": "Перемкнути всі видимі ресурси" + }, + "profile.switch.title": { + "message": "Перемкнутися на профіль «{name}»?" + }, + "profile.switch.message": { + "message": "Ви впевнені, що хочете змінити профіль?\nВаш поточний профіль буде відключено." + }, + "profile.switch.confirm": { + "message": "Підтвердити" + }, + "profile.deregister.title": { + "message": "Вийти з профілю «{name}»?" + }, + "profile.deregister.message": { + "message": "Ви впевнені, що хочете вийти з цього профілю?\nВам доведеться увійти знову, щоб використовувати його." + }, + "profile.deregister.confirm": { + "message": "Вийти" + }, + "profile.delete.title": { + "message": "Видалити профіль «{name}»?" + }, + "profile.delete.message": { + "message": "Ви впевнені, що хочете видалити цей профіль?\nЦю дію неможливо скасувати." + }, + "profile.delete.disabledActive": { + "message": "Активні профілі не можна видаляти. Перемкніться на інший профіль перед видаленням цього." + }, + "profile.delete.disabledDefault": { + "message": "Профіль за замовчуванням не можна видалити." + }, + "profile.error.switchTitle": { + "message": "Помилка зміни профілю" + }, + "profile.error.deregisterTitle": { + "message": "Помилка виходу з профілю" + }, + "profile.error.deleteTitle": { + "message": "Помилка видалення профілю" + }, + "profile.error.createTitle": { + "message": "Помилка створення профілю" + }, + "profile.error.editTitle": { + "message": "Помилка редагування профілю" + }, + "profile.error.loadTitle": { + "message": "Помилка завантаження профілів" + }, + "profile.dropdown.activeProfile": { + "message": "Активний профіль" + }, + "profile.dropdown.switchProfile": { + "message": "Змінити профіль" + }, + "profile.dropdown.noEmail": { + "message": "Інше" + }, + "profile.dropdown.addProfile": { + "message": "Додати профіль" + }, + "profile.dropdown.manageProfiles": { + "message": "Керування профілями" + }, + "profile.dropdown.settings": { + "message": "Налаштування" + }, + "settings.profiles.section.profiles": { + "message": "Профілі" + }, + "settings.profiles.intro": { + "message": "Використовуйте кілька профілів NetBird одночасно, наприклад, робочий та особистий облікові записи або різні сервери керування. Додавайте профілі, виходьте з них або видаляйте їх нижче." + }, + "settings.profiles.addProfile": { + "message": "Додати профіль" + }, + "settings.profiles.active": { + "message": "Активний" + }, + "settings.profiles.emptyTitle": { + "message": "Немає профілів" + }, + "settings.profiles.emptyDescription": { + "message": "Створіть профіль, щоб підключитися до сервера керування NetBird." + }, + "settings.error.loadTitle": { + "message": "Помилка завантаження налаштувань" + }, + "settings.error.saveTitle": { + "message": "Помилка збереження налаштувань" + }, + "settings.error.debugBundleTitle": { + "message": "Помилка створення архіву діагностики" + }, + "settings.nav.label": { + "message": "Розділи налаштувань" + }, + "settings.tabs.general": { + "message": "Загальні" + }, + "settings.tabs.network": { + "message": "Мережа" + }, + "settings.tabs.security": { + "message": "Безпека" + }, + "settings.tabs.profiles": { + "message": "Профілі" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "Розширені" + }, + "settings.tabs.troubleshooting": { + "message": "Діагностика" + }, + "settings.tabs.about": { + "message": "Про програму" + }, + "settings.tabs.updateAvailable": { + "message": "Доступне оновлення" + }, + "settings.general.section.general": { + "message": "Загальні" + }, + "settings.general.section.connection": { + "message": "Підключення" + }, + "settings.general.connectOnStartup.label": { + "message": "Підключитися під час запуску" + }, + "settings.general.connectOnStartup.help": { + "message": "Автоматично встановлювати підключення під час запуску служби." + }, + "settings.general.notifications.label": { + "message": "Сповіщення на робочому столі" + }, + "settings.general.notifications.help": { + "message": "Показувати сповіщення на робочому столі про нові оновлення та події підключення." + }, + "settings.general.autostart.label": { + "message": "Запускати інтерфейс NetBird під час входу" + }, + "settings.general.autostart.help": { + "message": "Автоматично запускати інтерфейс NetBird під час входу в систему. Це стосується лише графічного інтерфейсу, а не фонової служби." + }, + "settings.general.autostart.errorTitle": { + "message": "Помилка зміни автозапуску" + }, + "settings.general.keepConnectedOnQuit.label": { + "message": "Залишатися підключеним після виходу" + }, + "settings.general.keepConnectedOnQuit.help": { + "message": "Підключення залишатиметься активним у фоновому режимі після закриття NetBird. Воно буде розірвано лише тоді, коли ви відключите його самостійно." + }, + "settings.general.language.label": { + "message": "Мова інтерфейсу" + }, + "settings.general.language.help": { + "message": "Виберіть мову для інтерфейсу NetBird." + }, + "settings.general.language.search": { + "message": "Пошук мови…" + }, + "settings.general.language.empty": { + "message": "Не знайдено жодної мови." + }, + "settings.general.management.label": { + "message": "Сервер керування" + }, + "settings.general.management.help": { + "message": "Підключайтеся до NetBird Cloud або власного сервера керування. Зміни призведуть до перепідключення клієнта." + }, + "settings.general.management.cloud": { + "message": "Cloud" + }, + "settings.general.management.selfHosted": { + "message": "Власний сервер" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "Будь ласка, введіть дійсну URL-адресу, наприклад: https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlUnreachable": { + "message": "Не вдалося підключитися до цього сервера. Перевірте URL-адресу або все одно збережіть зміни, якщо ви впевнені, що вона правильна." + }, + "settings.general.management.switchCloudTitle": { + "message": "Перемкнутися на NetBird Cloud?" + }, + "settings.general.management.switchCloudMessage": { + "message": "Це відключить вас від власного сервера.\nВам може знадобитися увійти знову." + }, + "settings.general.management.switchCloudConfirm": { + "message": "Перемкнутися на Cloud" + }, + "settings.network.section.connectivity": { + "message": "Підключення" + }, + "settings.network.section.routingDns": { + "message": "Маршрутизація та DNS" + }, + "settings.network.monitor.label": { + "message": "Перепідключатися при зміні мережі" + }, + "settings.network.monitor.help": { + "message": "Відстежувати мережу й автоматично перепідключатися у разі таких змін, як перемикання Wi-Fi, зміна Ethernet-підключення або вихід із режиму сну." + }, + "settings.network.dns.label": { + "message": "Увімкнути DNS" + }, + "settings.network.dns.help": { + "message": "Застосовувати налаштування DNS, якими керує NetBird, до локального DNS-розв’язувача хоста." + }, + "settings.network.clientRoutes.label": { + "message": "Увімкнути клієнтські маршрути" + }, + "settings.network.clientRoutes.help": { + "message": "Приймати маршрути від інших пірів для доступу до їхніх мереж." + }, + "settings.network.serverRoutes.label": { + "message": "Увімкнути серверні маршрути" + }, + "settings.network.serverRoutes.help": { + "message": "Анонсувати локальні маршрути цього хоста іншим пірам." + }, + "settings.network.ipv6.label": { + "message": "Увімкнути IPv6" + }, + "settings.network.ipv6.help": { + "message": "Використовувати адресацію IPv6 для оверлейної мережі NetBird." + }, + "settings.security.section.firewall": { + "message": "Брандмауер" + }, + "settings.security.section.encryption": { + "message": "Шифрування" + }, + "settings.security.blockInbound.label": { + "message": "Блокувати вхідний трафік" + }, + "settings.security.blockInbound.help": { + "message": "Відхиляти небажані підключення від пірів до цього пристрою та будь-яких мереж, які він маршрутизує. Вихідний трафік не обмежується." + }, + "settings.security.blockLan.label": { + "message": "Блокувати доступ до LAN" + }, + "settings.security.blockLan.help": { + "message": "Заборонити пірам отримувати доступ до вашої локальної мережі або її пристроїв, коли цей пристрій маршрутизує їхній трафік." + }, + "settings.security.rosenpass.label": { + "message": "Увімкнути постквантову стійкість" + }, + "settings.security.rosenpass.help": { + "message": "Додати постквантовий обмін ключами через Rosenpass поверх WireGuard®." + }, + "settings.security.rosenpassPermissive.label": { + "message": "Увімкнути дозвільний режим" + }, + "settings.security.rosenpassPermissive.help": { + "message": "Дозволити підключення до пірів без підтримки постквантової стійкості." + }, + "settings.ssh.section.server": { + "message": "Сервер" + }, + "settings.ssh.section.capabilities": { + "message": "Можливості" + }, + "settings.ssh.section.authentication": { + "message": "Автентифікація" + }, + "settings.ssh.server.label": { + "message": "Увімкнути SSH-сервер" + }, + "settings.ssh.server.help": { + "message": "Запустити SSH-сервер NetBird на цьому хості, щоб інші піри могли підключатися до нього." + }, + "settings.ssh.root.label": { + "message": "Дозволити вхід як root" + }, + "settings.ssh.root.help": { + "message": "Дозволити пірам входити як користувач root. Вимкніть, щоб вимагати непривілейований обліковий запис." + }, + "settings.ssh.sftp.label": { + "message": "Дозволити SFTP" + }, + "settings.ssh.sftp.help": { + "message": "Безпечно передавати файли за допомогою нативних клієнтів SFTP або SCP." + }, + "settings.ssh.localForward.label": { + "message": "Локальне переспрямування портів" + }, + "settings.ssh.localForward.help": { + "message": "Дозволити пірам, що підключаються, переспрямовувати локальні порти до сервісів, доступних із цього хоста." + }, + "settings.ssh.remoteForward.label": { + "message": "Віддалене переспрямування портів" + }, + "settings.ssh.remoteForward.help": { + "message": "Дозволити підключеним пірам відкривати порти на цьому хості з переспрямуванням на свої машини." + }, + "settings.ssh.jwt.label": { + "message": "Увімкнути JWT-автентифікацію" + }, + "settings.ssh.jwt.help": { + "message": "Перевіряти кожен сеанс SSH через ваш IdP для ідентифікації користувачів та аудиту. Вимкніть, щоб покладатися лише на політики мережевих ACL, що корисно, коли IdP недоступний." + }, + "settings.ssh.jwtTtl.label": { + "message": "Час кешування JWT (TTL)" + }, + "settings.ssh.jwtTtl.help": { + "message": "Як довго цей клієнт кешує JWT перед повторним запитом для вихідних SSH-з’єднань. Встановіть 0, щоб вимкнути кешування та проходити автентифікацію при кожному підключенні." + }, + "settings.ssh.jwtTtl.suffix": { + "message": "сек." + }, + "settings.advanced.section.interface": { + "message": "Інтерфейс" + }, + "settings.advanced.section.security": { + "message": "Безпека" + }, + "settings.advanced.interfaceName.label": { + "message": "Назва" + }, + "settings.advanced.interfaceName.error": { + "message": "Використовуйте 1-15 літер, цифр, крапок, дефісів або підкреслень." + }, + "settings.advanced.interfaceName.errorMac": { + "message": "Повинно починатися з «utun», після якого має йти число (наприклад, utun100)." + }, + "settings.advanced.port.label": { + "message": "Порт" + }, + "settings.advanced.port.error": { + "message": "Введіть порт між {min} та {max}." + }, + "settings.advanced.port.help": { + "message": "Якщо встановлено 0, буде використано випадковий вільний порт." + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "Введіть значення MTU між {min} та {max}." + }, + "settings.advanced.psk.label": { + "message": "Попередньо узгоджений ключ" + }, + "settings.advanced.psk.help": { + "message": "Додатковий PSK WireGuard для симетричного шифрування. Це не те саме, що NetBird Setup Key. Ви зможете обмінюватися даними лише з тими пірами, які використовують такий самий попередньо узгоджений ключ." + }, + "settings.troubleshooting.section.title": { + "message": "Архів діагностики" + }, + "settings.troubleshooting.anonymize.label": { + "message": "Анонімізувати чутливу інформацію" + }, + "settings.troubleshooting.anonymize.help": { + "message": "Приховує IP-адреси, домени та інші конфіденційні дані." + }, + "settings.troubleshooting.anonymize.info": { + "message": "«Стандартний» залишає внутрішні адреси IPv4 та імена пірів читабельними для служби підтримки. «Суворий» додатково анонімізує приватні (RFC 1918), CGNAT- та link-local-адреси, імена пірів і публічні ключі WireGuard. Однакові значення замінюються тим самим псевдонімом, тож піри залишаються розрізнюваними. Використовуйте «Суворий», якщо ділитеся архівом за межами організації." + }, + "settings.troubleshooting.anonymize.none": { + "message": "Вимкнено" + }, + "settings.troubleshooting.anonymize.default": { + "message": "Стандартний" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "Суворий" + }, + "settings.troubleshooting.systemInfo.label": { + "message": "Додати інформацію про систему" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "Додати дані про ОС, ядро, мережеві інтерфейси та таблиці маршрутизації." + }, + "settings.troubleshooting.upload.label": { + "message": "Завантажити архів на сервери NetBird" + }, + "settings.troubleshooting.upload.help": { + "message": "Створює ключ завантаження, який можна передати службі підтримки NetBird." + }, + "settings.troubleshooting.trace.label": { + "message": "Увімкнути журнали рівня TRACE" + }, + "settings.troubleshooting.trace.help": { + "message": "Підвищує рівень журналювання до TRACE на час створення архіву та відновлює його після завершення." + }, + "settings.troubleshooting.capture.label": { + "message": "Запис сеансу" + }, + "settings.troubleshooting.capture.help": { + "message": "Перепідключає NetBird і чекає, щоб ви могли відтворити проблему." + }, + "settings.troubleshooting.packets.label": { + "message": "Захоплювати мережеві пакети" + }, + "settings.troubleshooting.packets.help": { + "message": "Зберігає файл .pcap із мережевим трафіком протягом сеансу захоплення." + }, + "settings.troubleshooting.duration.label": { + "message": "Тривалість захоплення" + }, + "settings.troubleshooting.duration.help": { + "message": "Скільки часу триває сеанс захоплення." + }, + "settings.troubleshooting.duration.suffix": { + "message": "хв." + }, + "settings.troubleshooting.create": { + "message": "Створити архів" + }, + "settings.troubleshooting.progress.description": { + "message": "Збір журналів, даних про систему та інформації про стан підключення. Зазвичай це займає хвилину. Ви можете продовжувати використовувати NetBird або закрити вікно налаштувань, поки процес триває." + }, + "settings.troubleshooting.cancelling": { + "message": "Скасування…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "Архів діагностики успішно завантажено!" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "Архів збережено" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "Поділіться ключем завантаження нижче зі службою підтримки NetBird. Локальну копію також збережено на вашому пристрої." + }, + "settings.troubleshooting.done.savedDescription": { + "message": "Ваш архів діагностики збережено локально." + }, + "settings.troubleshooting.done.copyKey": { + "message": "Копіювати ключ" + }, + "settings.troubleshooting.done.openFolder": { + "message": "Відкрити папку" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "Відкрити розташування файлу" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "Помилка завантаження: {reason} Архів все одно збережено локально" + }, + "settings.troubleshooting.uploadFailed": { + "message": "Помилка завантаження. Архів все одно збережено локально." + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "Перепідключення NetBird…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "Запис журналів діагностики" + }, + "settings.troubleshooting.stage.bundling": { + "message": "Створення архіву діагностики…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "Завантаження на сервери NetBird…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "Скасування…" + }, + "settings.about.client": { + "message": "NetBird Client v{version}" + }, + "settings.about.clientName": { + "message": "NetBird Client" + }, + "settings.about.development": { + "message": "[Розробка]" + }, + "settings.about.gui": { + "message": "Графічний інтерфейс v{version}" + }, + "settings.about.guiName": { + "message": "Графічний інтерфейс" + }, + "settings.about.copyright": { + "message": "© {year} NetBird. Усі права захищено." + }, + "settings.about.links.imprint": { + "message": "Реквізити" + }, + "settings.about.links.privacy": { + "message": "Конфіденційність" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "Умови використання" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "Форум" + }, + "settings.about.community.documentation": { + "message": "Документація" + }, + "settings.about.community.feedback": { + "message": "Зворотний зв’язок" + }, + "update.banner.message": { + "message": "NetBird {version} готовий до встановлення." + }, + "update.banner.later": { + "message": "Пізніше" + }, + "update.banner.installNow": { + "message": "Встановити зараз" + }, + "update.card.versionAvailableDownload": { + "message": "Версія {version} доступна для завантаження." + }, + "update.card.versionAvailableInstall": { + "message": "Версія {version} доступна для встановлення." + }, + "update.card.whatsNew": { + "message": "Що нового?" + }, + "update.card.installNow": { + "message": "Встановити зараз" + }, + "update.card.getInstaller": { + "message": "Завантажити" + }, + "update.card.autoCheckInterval": { + "message": "NetBird перевіряє наявність оновлень у фоновому режимі." + }, + "update.card.changelog": { + "message": "Список змін" + }, + "update.card.onLatestVersion": { + "message": "Ви використовуєте останню версію" + }, + "update.header.tooltip": { + "message": "Доступне оновлення" + }, + "update.overlay.updatingVersion": { + "message": "Оновлення NetBird до v{version}" + }, + "update.overlay.updating": { + "message": "Оновлення NetBird" + }, + "update.overlay.description": { + "message": "Доступна новіша версія, яка зараз встановлюється. NetBird автоматично перезапуститься після завершення оновлення." + }, + "update.overlay.error.timeoutTitle": { + "message": "Оновлення триває занадто довго" + }, + "update.overlay.error.timeoutDescription": { + "message": "Встановлення {target} тривало занадто довго і не завершилося." + }, + "update.overlay.error.canceledTitle": { + "message": "Оновлення зупинено" + }, + "update.overlay.error.canceledDescription": { + "message": "Оновлення до {target} було скасовано до його завершення." + }, + "update.overlay.error.failTitle": { + "message": "Не вдалося встановити оновлення" + }, + "update.overlay.error.failDescription": { + "message": "Не вдалося встановити оновлення до {target}." + }, + "update.overlay.error.unknownMessage": { + "message": "Невідома помилка" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "нової версії" + }, + "update.error.loadStateTitle": { + "message": "Помилка завантаження стану оновлення" + }, + "update.error.triggerTitle": { + "message": "Помилка запуску оновлення" + }, + "update.page.versionLine": { + "message": "Оновлення клієнта до версії {version}." + }, + "update.page.versionLineGeneric": { + "message": "Оновлення клієнта." + }, + "update.page.outdated": { + "message": "Ваша версія клієнта старіша за версію для автооновлення, задану в Management." + }, + "update.page.status.running": { + "message": "Оновлення" + }, + "update.page.status.timeout": { + "message": "Час очікування оновлення минув. Будь ласка, спробуйте ще раз." + }, + "update.page.status.canceled": { + "message": "Оновлення скасовано." + }, + "update.page.status.failed": { + "message": "Помилка оновлення: {message}" + }, + "update.page.status.unknownError": { + "message": "невідома помилка оновлення" + }, + "update.page.failedTitle": { + "message": "Помилка оновлення" + }, + "update.page.timeoutMessage": { + "message": "Час очікування оновлення минув." + }, + "update.page.dontClose": { + "message": "Будь ласка, не закривайте це вікно." + }, + "update.page.updating": { + "message": "Оновлення…" + }, + "update.page.complete": { + "message": "Оновлення завершено" + }, + "update.page.failed": { + "message": "Помилка оновлення" + }, + "window.title.settings": { + "message": "Налаштування" + }, + "window.title.signIn": { + "message": "Вхід" + }, + "window.title.sessionExpiration": { + "message": "Термін дії сеансу закінчується" + }, + "window.title.updating": { + "message": "Оновлення" + }, + "window.title.welcome": { + "message": "Ласкаво просимо до NetBird" + }, + "window.title.error": { + "message": "Помилка" + }, + "welcome.title": { + "message": "Знайдіть NetBird в області сповіщень" + }, + "welcome.titleMac": { + "message": "Знайдіть NetBird у рядку меню" + }, + "welcome.description": { + "message": "NetBird працює в області сповіщень. Натисніть на іконку, щоб підключитися, змінити профіль або відкрити налаштування." + }, + "welcome.descriptionMac": { + "message": "NetBird працює в рядку меню. Натисніть на іконку, щоб підключитися, змінити профіль або відкрити налаштування." + }, + "welcome.continue": { + "message": "Продовжити" + }, + "welcome.back": { + "message": "Назад" + }, + "welcome.management.title": { + "message": "Налаштування NetBird" + }, + "welcome.management.description": { + "message": "Натисніть «Продовжити», щоб розпочати, або виберіть Власний сервер, якщо у вас є власний сервер NetBird." + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "Використовуйте наш хмарний сервіс. Налаштування не потрібне." + }, + "welcome.management.selfHosted.title": { + "message": "Власний сервер" + }, + "welcome.management.selfHosted.description": { + "message": "Підключіться до власного сервера керування." + }, + "welcome.management.urlLabel": { + "message": "URL-адреса сервера керування" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "Будь ласка, введіть дійсну URL-адресу, наприклад: https://netbird.selfhosted.com:443" + }, + "welcome.management.urlUnreachable": { + "message": "Не вдалося підключитися до цього сервера. Перевірте URL-адресу або вашу мережу, а потім продовжуйте, якщо ви впевнені, що вона правильна." + }, + "welcome.management.checking": { + "message": "Перевірка…" + }, + "browserLogin.title": { + "message": "Завершіть вхід у браузері" + }, + "browserLogin.notSeeing": { + "message": "Ми відкрили вкладку браузера, щоб ви могли завершити вхід. Не бачите її?" + }, + "browserLogin.tryAgain": { + "message": "Спробувати ще раз" + }, + "browserLogin.openFailedTitle": { + "message": "Помилка відкриття браузера" + }, + "sessionExpiration.title": { + "message": "Термін дії сеансу невдовзі закінчиться" + }, + "sessionExpiration.titleLater": { + "message": "Термін дії вашого сеансу закінчиться" + }, + "sessionExpiration.description": { + "message": "Цей пристрій невдовзі буде відключено. Поновіть сеанс, увійшовши через браузер." + }, + "sessionExpiration.descriptionLater": { + "message": "Вхід через браузер підтримує підключення цього пристрою до вашої мережі." + }, + "sessionExpiration.stay": { + "message": "Продовжити сеанс" + }, + "sessionExpiration.authenticate": { + "message": "Увійти" + }, + "sessionExpiration.logout": { + "message": "Вийти" + }, + "sessionExpiration.expired": { + "message": "Термін дії сеансу закінчився" + }, + "sessionExpiration.expiredDescription": { + "message": "Пристрій відключено. Пройдіть автентифікацію у браузері, щоб перепідключитися." + }, + "sessionExpiration.close": { + "message": "Закрити" + }, + "sessionExpiration.extendFailedTitle": { + "message": "Помилка продовження сеансу" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "Помилка виходу" + }, + "peers.search.placeholder": { + "message": "Пошук за ім’ям або IP" + }, + "peers.filter.all": { + "message": "Усі" + }, + "peers.filter.online": { + "message": "Онлайн" + }, + "peers.filter.offline": { + "message": "Офлайн" + }, + "peers.empty.title": { + "message": "Немає доступних пірів" + }, + "peers.empty.description": { + "message": "У вас немає доступних пірів або доступу до жодного з них." + }, + "peers.details.domain": { + "message": "Домен" + }, + "peers.details.netbirdIp": { + "message": "NetBird IP" + }, + "peers.details.netbirdIpv6": { + "message": "NetBird IPv6" + }, + "peers.details.publicKey": { + "message": "Публічний ключ" + }, + "peers.details.connection": { + "message": "Підключення" + }, + "peers.details.latency": { + "message": "Затримка" + }, + "peers.details.lastHandshake": { + "message": "Останнє рукостискання" + }, + "peers.details.statusSince": { + "message": "Останнє оновлення підключення" + }, + "peers.details.bytes": { + "message": "Байти" + }, + "peers.details.bytesSent": { + "message": "Надіслано" + }, + "peers.details.bytesReceived": { + "message": "Отримано" + }, + "peers.details.localIce": { + "message": "Локальний ICE" + }, + "peers.details.remoteIce": { + "message": "Віддалений ICE" + }, + "peers.details.never": { + "message": "Ніколи" + }, + "peers.details.justNow": { + "message": "Щойно" + }, + "peers.details.refresh": { + "message": "Оновити" + }, + "peers.status.connected": { + "message": "Підключено" + }, + "peers.status.connecting": { + "message": "Підключення" + }, + "peers.status.disconnected": { + "message": "Відключено" + }, + "peers.details.relayAddress": { + "message": "Ретранслятор" + }, + "peers.details.networks": { + "message": "Ресурси" + }, + "peers.details.relayed": { + "message": "Через ретранслятор" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "Rosenpass увімкнено" + }, + "networks.search.placeholder": { + "message": "Пошук за мережею або доменом" + }, + "networks.filter.all": { + "message": "Усі" + }, + "networks.filter.active": { + "message": "Активні" + }, + "networks.filter.overlapping": { + "message": "Перетинаються" + }, + "networks.empty.title": { + "message": "Немає доступних ресурсів" + }, + "networks.empty.description": { + "message": "У вас немає доступних мережевих ресурсів або доступу до жодного з них." + }, + "networks.selected": { + "message": "Вибрано" + }, + "networks.unselected": { + "message": "Не вибрано" + }, + "networks.ips.heading": { + "message": "Визначені IP-адреси" + }, + "networks.bulk.selectionCount": { + "message": "Активні: {selected} з {total}" + }, + "networks.bulk.enableAll": { + "message": "Увімкнути всі" + }, + "networks.bulk.disableAll": { + "message": "Вимкнути всі" + }, + "exitNodes.search.placeholder": { + "message": "Пошук вихідних вузлів" + }, + "exitNodes.none": { + "message": "Немає" + }, + "exitNodes.empty.title": { + "message": "Немає доступних вихідних вузлів" + }, + "exitNodes.empty.description": { + "message": "Цьому піру не надано жодного вихідного вузла." + }, + "exitNodes.card.title": { + "message": "Вихідний вузол" + }, + "exitNodes.card.statusActive": { + "message": "Активний" + }, + "exitNodes.card.statusInactive": { + "message": "Неактивний" + }, + "exitNodes.dropdown.noneTitle": { + "message": "Немає" + }, + "exitNodes.dropdown.noneDescription": { + "message": "Пряме підключення без вихідного вузла" + }, + "quickActions.connect": { + "message": "Підключитися" + }, + "quickActions.disconnect": { + "message": "Відключитися" + }, + "daemon.unavailable.title": { + "message": "Служба NetBird не запущена" + }, + "daemon.unavailable.description": { + "message": "Програма перепідключиться автоматично, щойно служба запрацює." + }, + "daemon.unavailable.docsLink": { + "message": "Документація" + }, + "daemon.outdated.title": { + "message": "Клієнт NetBird застарів" + }, + "daemon.outdated.description": { + "message": "Новий графічний інтерфейс несумісний зі старою версією клієнта NetBird. Оновіть клієнт, щоб використовувати нову програму." + }, + "daemon.outdated.download": { + "message": "Завантажити останню версію" + }, + "error.jwt_clock_skew": { + "message": "Помилка входу: годинник цього пристрою не синхронізовано із сервером. Будь ласка, синхронізуйте системний годинник і спробуйте знову." + }, + "error.jwt_expired": { + "message": "Термін дії вашого токена входу закінчився. Будь ласка, увійдіть знову." + }, + "error.jwt_signature_invalid": { + "message": "Помилка входу: недійсний підпис токена. Будь ласка, зверніться до адміністратора." + }, + "error.session_expired": { + "message": "Термін дії вашого сеансу закінчився. Будь ласка, увійдіть знову." + }, + "error.invalid_setup_key": { + "message": "Setup Key відсутній або недійсний." + }, + "error.permission_denied": { + "message": "Вхід відхилено сервером." + }, + "error.daemon_unreachable": { + "message": "Служба NetBird не відповідає. Будь ласка, перевірте, чи запущена служба." + }, + "error.unknown": { + "message": "Помилка операції." + }, + "error.elevation_unavailable": { + "message": "NetBird не зміг запросити в системи привілеї, необхідні для внесення змін. Замість цього виконайте:" + }, + "error.elevation_failed": { + "message": "Не вдалося застосувати зміни з підвищеними привілеями. Замість цього виконайте:" + }, + "settings.ssh.privilege.actorRoot": { + "message": "прав root" + }, + "settings.ssh.privilege.actorAdministrator": { + "message": "прав адміністратора" + }, + "settings.ssh.privilege.hint": { + "message": "Потребує {actor}. Замість цього виконайте:" + }, + "settings.ssh.privilege.oneWay": { + "message": "Ви можете вимкнути це, але щоб увімкнути знову, знадобиться {actor}:" + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "Ви можете увімкнути це, але щоб вимкнути знову, знадобиться {actor}:" + }, + "settings.ssh.privilege.authorizePending": { + "message": "Очікування авторизації…" + } +} diff --git a/client/ui/services/windowmanager.go b/client/ui/services/windowmanager.go index 4930ce22b..94dba6038 100644 --- a/client/ui/services/windowmanager.go +++ b/client/ui/services/windowmanager.go @@ -292,11 +292,15 @@ func (s *WindowManager) CloseBrowserLogin() { } // OpenSessionExpiration shows the countdown warning on the cursor's display; seconds seeds -// the countdown. Singleton, destroyed on close. -func (s *WindowManager) OpenSessionExpiration(seconds int) { +// the countdown and deadlineUnixMilli (0 when unknown) is the absolute deadline the dialog +// compares renewal snapshots against. Singleton, destroyed on close. +func (s *WindowManager) OpenSessionExpiration(seconds int, deadlineUnixMilli int64) { s.mu.Lock() defer s.mu.Unlock() startURL := "/#/dialog/session-expiration?seconds=" + strconv.Itoa(seconds) + if deadlineUnixMilli > 0 { + startURL += "&deadline=" + strconv.FormatInt(deadlineUnixMilli, 10) + } if s.sessionExpiration == nil { opts := DialogWindowOptions("session-expiration", s.title("window.title.sessionExpiration"), startURL, s.linuxIcon) opts.Screen = s.getScreenBasedOnCursorPosition() diff --git a/client/ui/tray_events.go b/client/ui/tray_events.go index 12da68a5c..f23b5d715 100644 --- a/client/ui/tray_events.go +++ b/client/ui/tray_events.go @@ -76,7 +76,8 @@ func (t *Tray) onSystemEvent(ev *application.CustomEvent) { if se.Metadata != nil && se.Metadata[authsession.MetaWarning] == "true" { if se.Metadata[authsession.MetaFinal] == "true" { - t.openSessionExpiration() + deadline, _ := authsession.ParseExpiresAt(se.Metadata[authsession.MetaExpiresAt]) + t.openSessionExpiration(deadline) return } t.notifySessionWarning( diff --git a/client/ui/tray_session.go b/client/ui/tray_session.go index 6e5d07740..91c38be08 100644 --- a/client/ui/tray_session.go +++ b/client/ui/tray_session.go @@ -284,12 +284,23 @@ func (t *Tray) dismissSessionWarning() { } // openSessionExpiration fires the fallback dialog when the earlier warning notification wasn't dismissed. -// Idempotent on the WindowManager side. -func (t *Tray) openSessionExpiration() { +// deadline is the absolute expiry from the warning event's metadata; when zero (older daemon, +// malformed metadata) the cached status-snapshot deadline fills in. Idempotent on the +// WindowManager side. +func (t *Tray) openSessionExpiration(deadline time.Time) { if t.svc.WindowManager == nil { return } - t.svc.WindowManager.OpenSessionExpiration(finalWarningCountdownSeconds) + if deadline.IsZero() { + t.sessionMu.Lock() + deadline = t.sessionExpiresAt + t.sessionMu.Unlock() + } + var deadlineMs int64 + if !deadline.IsZero() { + deadlineMs = deadline.UnixMilli() + } + t.svc.WindowManager.OpenSessionExpiration(finalWarningCountdownSeconds, deadlineMs) } // openSessionExtendFlow opens the SessionExpiration window seeded with the cached deadline's remaining time, @@ -310,5 +321,5 @@ func (t *Tray) openSessionExtendFlow() { if t.svc.WindowManager == nil { return } - t.svc.WindowManager.OpenSessionExpiration(seconds) + t.svc.WindowManager.OpenSessionExpiration(seconds, deadline.UnixMilli()) } diff --git a/combined/Dockerfile.multistage b/combined/Dockerfile.multistage index 79746819d..011379c2f 100644 --- a/combined/Dockerfile.multistage +++ b/combined/Dockerfile.multistage @@ -1,4 +1,4 @@ -FROM golang:1.25-bookworm AS builder +FROM golang:1.26.7-bookworm AS builder WORKDIR /app # Install build dependencies diff --git a/docs/testing-privileged.md b/docs/testing-privileged.md index cf2f23171..72e8a0f8f 100644 --- a/docs/testing-privileged.md +++ b/docs/testing-privileged.md @@ -32,7 +32,7 @@ list; both are optional and default to the full privileged suite. 1. Skips immediately when it detects it is already inside the container (`DOCKER_CI=true`), so the privileged tests run in place instead of recursing. -2. Otherwise spins up a `golang:1.25-alpine` container (matching CI), +2. Otherwise spins up a `golang:1.26.7-alpine` container (matching CI), bind-mounts the repo and the host Go build/module caches, installs the required packages, and runs `go test -tags 'devcert privileged'` over the client packages. diff --git a/e2e/harness/Dockerfile.client b/e2e/harness/Dockerfile.client index 74a3ec245..4c76b95c6 100644 --- a/e2e/harness/Dockerfile.client +++ b/e2e/harness/Dockerfile.client @@ -3,7 +3,7 @@ # artifact), so this mirrors its alpine runtime + entrypoint while compiling the # CGO-free client inline. BuildKit cache mounts keep rebuilds incremental. -FROM golang:1.25-bookworm AS builder +FROM golang:1.26.7-bookworm AS builder WORKDIR /src COPY go.mod go.sum ./ RUN --mount=type=cache,target=/go/pkg/mod go mod download diff --git a/go.mod b/go.mod index efec8c94d..a2fe1e55b 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,10 @@ module github.com/netbirdio/netbird -go 1.25.5 +go 1.26.0 -toolchain go1.25.12 +// Pin the toolchain to a patch release >= go1.26.2 +// See https://go.dev/issue/77875. +toolchain go1.26.7 require ( cunicu.li/go-rosenpass v0.5.42 @@ -71,17 +73,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 @@ -99,13 +102,14 @@ require ( github.com/pires/go-proxyproto v0.11.0 github.com/pkg/sftp v1.13.9 github.com/prometheus/client_golang v1.23.2 - github.com/quic-go/quic-go v0.59.1 + github.com/prometheus/client_model v0.6.2 + github.com/quic-go/quic-go v0.62.0 github.com/redis/go-redis/v9 v9.7.3 github.com/rs/xid v1.3.0 github.com/shirou/gopsutil/v4 v4.25.8 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 - github.com/stretchr/testify v1.11.1 + github.com/stretchr/testify v1.12.1 github.com/testcontainers/testcontainers-go v0.37.0 github.com/testcontainers/testcontainers-go/modules/mysql v0.37.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0 @@ -236,8 +240,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 @@ -249,6 +253,7 @@ require ( github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/koron/go-ssdp v0.0.4 // indirect github.com/kr/fs v0.1.0 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect github.com/lib/pq v1.12.3 // indirect github.com/libdns/libdns v0.2.2 // indirect github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae // indirect @@ -286,10 +291,8 @@ require ( github.com/pion/transport/v2 v2.2.4 // indirect github.com/pion/turn/v4 v4.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/pquerna/otp v1.5.0 // indirect - github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/otlptranslator v1.0.0 // indirect github.com/prometheus/procfs v0.19.2 // indirect @@ -297,7 +300,7 @@ require ( github.com/ryanuber/go-glob v1.0.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/spf13/cast v1.10.0 // indirect - github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/objx v0.5.3 // indirect github.com/tinylib/msgp v1.6.3 // indirect github.com/tklauser/go-sysconf v0.3.15 // indirect github.com/tklauser/numcpus v0.10.0 // indirect @@ -313,6 +316,7 @@ require ( go.opentelemetry.io/otel/trace v1.43.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/text v0.41.0 // indirect golang.org/x/tools v0.49.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect diff --git a/go.sum b/go.sum index da68b6458..3e0b4f5dc 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= @@ -580,8 +582,10 @@ github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEo github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= -github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic= -github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= +github.com/quic-go/quic-go v0.62.0 h1:ZHDjCk5OacATwGvs8PWE97CTvX7AqZiVoW7++ZOXTf8= +github.com/quic-go/quic-go v0.62.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w= github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -617,8 +621,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= @@ -628,8 +632,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/testcontainers/testcontainers-go v0.37.0 h1:L2Qc0vkTw2EHWQ08djon0D2uw7Z/PtHS/QzZZ5Ra/hg= github.com/testcontainers/testcontainers-go v0.37.0/go.mod h1:QPzbxZhQ6Bclip9igjLFj6z0hs01bU8lrl2dHQmgFGM= github.com/testcontainers/testcontainers-go/modules/mysql v0.37.0 h1:LqUos1oR5iuuzorFnSvxsHNdYdCHB/DfI82CuT58wbI= @@ -715,6 +719,8 @@ go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= goauthentik.io/api/v3 v3.2023051.3 h1:NebAhD/TeTWNo/9X3/Uj+rM5fG1HaiLOlKTNLQv9Qq4= goauthentik.io/api/v3 v3.2023051.3/go.mod h1:nYECml4jGbp/541hj8GcylKQG1gVBsKppHy4+7G8u4U= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= diff --git a/infrastructure_files/getting-started-enterprise.sh b/infrastructure_files/getting-started-enterprise.sh index 7418cb8e8..3f7cf6357 100755 --- a/infrastructure_files/getting-started-enterprise.sh +++ b/infrastructure_files/getting-started-enterprise.sh @@ -15,16 +15,25 @@ NETBIRD_EULA_URL="https://netbird.io/self-hosted-EULA" # server trusts X-Forwarded-* headers from this address only. TRAEFIK_IP="172.30.0.10" +LICENSE_VERDICT="unknown" +LICENSE_LOG_LINES="" + check_docker_compose() { - if command -v docker-compose &> /dev/null; then - echo "docker-compose" - return + if ! command -v docker &> /dev/null && ! command -v docker-compose &> /dev/null; then + echo "Docker is not installed or not in PATH. Please follow the steps from the official guide: https://docs.docker.com/engine/install/" > /dev/stderr + exit 1 fi - if docker compose --help &> /dev/null; then + + if docker compose version &> /dev/null; then echo "docker compose" return fi - echo "docker-compose is not installed or not in PATH. See https://docs.docker.com/engine/install/" > /dev/stderr + if command -v docker-compose &> /dev/null && docker-compose version &> /dev/null; then + echo "docker-compose" + return + fi + + echo "Docker Compose is not installed or not in PATH. Please follow the steps from the official guide: https://docs.docker.com/compose/install/" > /dev/stderr exit 1 } @@ -221,6 +230,90 @@ wait_postgres() { set -e } +wait_for_license_verdict() { + local counter=0 + local logs="" + + echo -n "Waiting for the server to validate the license" + while [[ $counter -lt 60 ]]; do + logs=$($DOCKER_COMPOSE_COMMAND logs --no-color --tail=all netbird-server 2>/dev/null || true) + + if grep -qi "license invalidated" <<< "$logs"; then + echo " rejected" + LICENSE_VERDICT="rejected" + LICENSE_LOG_LINES=$(grep -i "license" <<< "$logs" | tail -n 5 || true) + return 0 + fi + + if grep -qi "license validated" <<< "$logs"; then + echo " ok" + LICENSE_VERDICT="ok" + return 0 + fi + + echo -n " ." + sleep 2 + counter=$((counter + 1)) + done + + echo " no verdict in 120s" + LICENSE_VERDICT="unknown" + LICENSE_LOG_LINES=$(grep -iE "failed to validate license|error validating license" <<< "$logs" | tail -n 3 || true) + return 0 +} + +report_license_verdict() { + if [[ "$LICENSE_VERDICT" == "ok" ]]; then + return 0 + fi + + if [[ "$LICENSE_VERDICT" == "unknown" ]]; then + echo "" + echo " ⚠ The server logged no license verdict within 120s." + if [[ -n "$LICENSE_LOG_LINES" ]]; then + echo " It was still reporting validation errors:" + while IFS= read -r line; do + [[ -n "$line" ]] && echo " $line" + done <<< "$LICENSE_LOG_LINES" + fi + echo "" + echo " Check the verdict with:" + echo "" + echo " $DOCKER_COMPOSE_COMMAND logs netbird-server | grep -i license" + return 0 + fi + + local unreachable="false" + if grep -qi "couldn't be validated with the license server" <<< "$LICENSE_LOG_LINES"; then + unreachable="true" + fi + + echo "" + if [[ "$unreachable" == "true" ]]; then + echo " ⚠ The server could not validate the license:" + else + echo " ⚠ The server rejected the license key:" + fi + while IFS= read -r line; do + [[ -n "$line" ]] && echo " $line" + done <<< "$LICENSE_LOG_LINES" + echo "" + echo " The stack is up, and only the license check did not pass." + echo "" + if [[ "$unreachable" == "true" ]]; then + echo " The license server could not be reached, so the key itself was" + echo " never checked. Confirm this host has outbound access to the" + echo " license server, then restart:" + else + echo " Check the reason the server gave above, verify that" + echo " NETBIRD_LICENSE_KEY in .env matches the key you were issued," + echo " then restart:" + fi + echo "" + echo " $DOCKER_COMPOSE_COMMAND up -d" + return 0 +} + init_environment() { check_openssl DOCKER_COMPOSE_COMMAND=$(check_docker_compose) @@ -299,6 +392,9 @@ init_environment() { echo "Starting remaining services ..." $DOCKER_COMPOSE_COMMAND up -d + echo "" + wait_for_license_verdict + echo "" echo "Done." echo "" @@ -309,6 +405,12 @@ init_environment() { echo "" echo "Tail logs:" echo " cd $(pwd) && $DOCKER_COMPOSE_COMMAND logs -f netbird-server traefik" + + report_license_verdict + + if [[ "$LICENSE_VERDICT" == "rejected" ]]; then + exit 1 + fi } # ------------------------------------------------------------------ diff --git a/infrastructure_files/getting-started.sh b/infrastructure_files/getting-started.sh index 0fc5b23c5..5efc0181e 100755 --- a/infrastructure_files/getting-started.sh +++ b/infrastructure_files/getting-started.sh @@ -60,18 +60,21 @@ check_docker_sock_perms() { } check_docker_compose() { - if command -v docker-compose &> /dev/null - then - echo "docker-compose" - return - fi - if docker compose --help &> /dev/null - then - echo "docker compose" - return + if ! command -v docker &> /dev/null && ! command -v docker-compose &> /dev/null; then + echo "Docker is not installed or not in PATH. Please follow the steps from the official guide: https://docs.docker.com/engine/install/" > /dev/stderr + exit 1 fi - echo "docker-compose is not installed or not in PATH. Please follow the steps from the official guide: https://docs.docker.com/engine/install/" > /dev/stderr + if docker compose version &> /dev/null; then + echo "docker compose" + return + fi + if command -v docker-compose &> /dev/null && docker-compose version &> /dev/null; then + echo "docker-compose" + return + fi + + echo "Docker Compose is not installed or not in PATH. Please follow the steps from the official guide: https://docs.docker.com/compose/install/" > /dev/stderr exit 1 } @@ -98,19 +101,39 @@ get_main_ip_address() { } check_nb_domain() { - DOMAIN=$1 - if [[ "$DOMAIN-x" == "-x" ]]; then + local domain="$1" + + if [[ -z "$domain" ]]; then echo "The NETBIRD_DOMAIN variable cannot be empty." > /dev/stderr return 1 fi - - if [[ "$DOMAIN" == "netbird.example.com" ]]; then + if [[ "$domain" == "use-ip" ]]; then + return 0 + fi + if [[ "$domain" == "netbird.example.com" ]]; then echo "The NETBIRD_DOMAIN cannot be netbird.example.com" > /dev/stderr return 1 fi + if [[ "$domain" =~ ^[0-9.]+$ ]]; then + echo "'$domain' is an IP address. Use 'use-ip' to install on this host's IP over HTTP, or an FQDN to get a TLS certificate." > /dev/stderr + return 1 + fi + if [[ ! "$domain" =~ ^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$ ]]; then + echo "'$domain' is not a valid FQDN. It needs at least one dot (e.g. netbird.my-domain.com), with no scheme, port or trailing dot." > /dev/stderr + return 1 + fi return 0 } +check_domain_resolves() { + local domain="$1" + if command -v getent &> /dev/null && getent hosts "$domain" &> /dev/null; then return 0; fi + if command -v host &> /dev/null && host "$domain" &> /dev/null; then return 0; fi + if command -v dig &> /dev/null && [[ -n "$(dig +short "$domain" 2>/dev/null)" ]]; then return 0; fi + if command -v nslookup &> /dev/null && nslookup "$domain" &> /dev/null; then return 0; fi + return 1 +} + # Non-interactive configuration # ------------------------------ # Every prompt below can be pre-answered with an environment variable, so the @@ -170,7 +193,22 @@ read_nb_domain() { read -r READ_NETBIRD_DOMAIN < /dev/tty if ! check_nb_domain "$READ_NETBIRD_DOMAIN"; then read_nb_domain + return fi + + if [[ "$READ_NETBIRD_DOMAIN" != "use-ip" ]] && ! check_domain_resolves "$READ_NETBIRD_DOMAIN"; then + local confirm="" + echo "" > /dev/stderr + echo "Warning: '$READ_NETBIRD_DOMAIN' does not resolve via DNS from this host." > /dev/stderr + echo "TLS certificate issuance and client connections will fail until it does." > /dev/stderr + echo -n "Continue anyway? [y/N]: " > /dev/stderr + read -r confirm < /dev/tty + if [[ ! "$confirm" =~ ^[Yy]$ ]]; then + read_nb_domain + return + fi + fi + echo "$READ_NETBIRD_DOMAIN" return 0 } @@ -439,12 +477,23 @@ configure_domain() { # Domain is validated (not a free-form value), so it keeps its own guard # rather than going through resolve(): a valid NETBIRD_DOMAIN is used as-is, # otherwise we prompt, or abort when there is no terminal to prompt on. + local prompted="false" if ! check_nb_domain "$NETBIRD_DOMAIN"; then if ! tty_available; then - echo "NETBIRD_DOMAIN is required for a non-interactive install." > /dev/stderr + if [[ -n "$NETBIRD_DOMAIN" ]]; then + echo "NETBIRD_DOMAIN='$NETBIRD_DOMAIN' cannot be used for a non-interactive install." > /dev/stderr + else + echo "NETBIRD_DOMAIN is required for a non-interactive install." > /dev/stderr + fi exit 1 fi NETBIRD_DOMAIN=$(read_nb_domain) + prompted="true" + fi + + if [[ "$prompted" == "false" && "$NETBIRD_DOMAIN" != "use-ip" ]] && ! check_domain_resolves "$NETBIRD_DOMAIN"; then + echo "Warning: '$NETBIRD_DOMAIN' does not resolve via DNS from this host." > /dev/stderr + echo "TLS certificate issuance and client connections will fail until it does." > /dev/stderr fi if [[ "$NETBIRD_DOMAIN" == "use-ip" ]]; then diff --git a/infrastructure_files/migrate-to-enterprise.sh b/infrastructure_files/migrate-to-enterprise.sh index 744ba5375..2b10250c9 100755 --- a/infrastructure_files/migrate-to-enterprise.sh +++ b/infrastructure_files/migrate-to-enterprise.sh @@ -40,6 +40,10 @@ ENTERPRISE_CONFIG_FILE="config.yaml.enterprise" # completed successfully. ROLLBACK_STATE="disarmed" ENV_EXISTED="unknown" +# Verdict the server logs about the license key on startup: ok, rejected, or +# unknown when neither line appeared before the timeout. +LICENSE_VERDICT="unknown" +LICENSE_LOG_LINES="" ENV_BACKUP="" PG_VOLUME_NAME="" BACKUP_DIR="" @@ -59,15 +63,21 @@ ENTERPRISE_CONFIG="no" NETBIRD_EULA_URL="https://netbird.io/self-hosted-EULA" check_docker_compose() { - if command -v docker-compose &> /dev/null; then - echo "docker-compose" - return + if ! command -v docker &> /dev/null && ! command -v docker-compose &> /dev/null; then + echo "Docker is not installed or not in PATH. Please follow the steps from the official guide: https://docs.docker.com/engine/install/" > /dev/stderr + exit 1 fi - if docker compose --help &> /dev/null; then + + if docker compose version &> /dev/null; then echo "docker compose" return fi - echo "docker-compose is not installed or not in PATH." > /dev/stderr + if command -v docker-compose &> /dev/null && docker-compose version &> /dev/null; then + echo "docker-compose" + return + fi + + echo "Docker Compose is not installed or not in PATH. Please follow the steps from the official guide: https://docs.docker.com/compose/install/" > /dev/stderr exit 1 } @@ -1000,6 +1010,39 @@ init_migration() { check_stale_postgres_volume } +wait_for_license_verdict() { + local counter=0 + local logs="" + + echo -n "Waiting for the server to validate the license" + while [[ $counter -lt 60 ]]; do + + logs=$($DOCKER_COMPOSE_COMMAND logs --no-color --tail=all "$COMBINED_SERVICE" 2>/dev/null || true) + + if grep -qi "license invalidated" <<< "$logs"; then + echo " rejected" + LICENSE_VERDICT="rejected" + LICENSE_LOG_LINES=$(grep -i "license" <<< "$logs" | tail -n 5 || true) + return 0 + fi + + if grep -qi "license validated" <<< "$logs"; then + echo " ok" + LICENSE_VERDICT="ok" + return 0 + fi + + echo -n " ." + sleep 2 + counter=$((counter + 1)) + done + + echo " no verdict in 120s" + LICENSE_VERDICT="unknown" + LICENSE_LOG_LINES=$(grep -iE "failed to validate license|error validating license" <<< "$logs" | tail -n 3 || true) + return 0 +} + apply_changes() { # From here on a failure must roll the deployment back. ROLLBACK_STATE="armed" @@ -1100,9 +1143,57 @@ apply_changes() { echo "Bringing up all services ..." $DOCKER_COMPOSE_COMMAND up -d + echo "" + wait_for_license_verdict + echo "" echo "Migration complete." + if [[ "$LICENSE_VERDICT" == "rejected" ]]; then + local unreachable="false" + if grep -qi "couldn't be validated with the license server" <<< "$LICENSE_LOG_LINES"; then + unreachable="true" + fi + + echo "" + if [[ "$unreachable" == "true" ]]; then + echo " ⚠ The server could not validate the license:" + else + echo " ⚠ The server rejected the license key:" + fi + while IFS= read -r line; do + [[ -n "$line" ]] && echo " $line" + done <<< "$LICENSE_LOG_LINES" + echo "" + echo " The migration itself completed: the images and any migrated data" + echo " are in place, and only the license check did not pass." + echo "" + if [[ "$unreachable" == "true" ]]; then + echo " The license server could not be reached, so the key itself was" + echo " never checked. Confirm this host has outbound access to the" + echo " license server, then restart:" + else + echo " Check the reason the server gave above, verify that" + echo " NB_LICENSE_KEY in .env matches the key you were issued, then" + echo " restart:" + fi + echo "" + echo " $DOCKER_COMPOSE_COMMAND up -d" + elif [[ "$LICENSE_VERDICT" == "unknown" ]]; then + echo "" + echo " ⚠ The server logged no license verdict within 120s." + if [[ -n "$LICENSE_LOG_LINES" ]]; then + echo " It was still reporting validation errors:" + while IFS= read -r line; do + [[ -n "$line" ]] && echo " $line" + done <<< "$LICENSE_LOG_LINES" + fi + echo "" + echo " Check the verdict with:" + echo "" + echo " $DOCKER_COMPOSE_COMMAND logs $COMBINED_SERVICE | grep -i license" + fi + # Nothing left to undo. ROLLBACK_STATE="disarmed" } @@ -1122,6 +1213,11 @@ print_summary() { fi [[ "$ENABLE_FLOW" == "yes" ]] && echo " Traffic flow: enabled" [[ "$ENABLE_FLOW" != "yes" ]] && echo " Traffic flow: disabled" + case "$LICENSE_VERDICT" in + ok) echo " License: validated by the server" ;; + rejected) echo " License: REJECTED - see above, the install is not usable yet" ;; + *) echo " License: not confirmed (no verdict in the logs yet)" ;; + esac echo "" echo " Generated files (next to your docker-compose.yml):" echo " $OVERRIDE_FILE" @@ -1176,3 +1272,10 @@ trap 'exit 130' INT TERM init_migration apply_changes print_summary + +# A rejected license leaves a migrated but unusable install. Say so in the exit +# code too, or a wrapper script reads this run as a clean success. +if [[ "$LICENSE_VERDICT" == "rejected" ]]; then + exit 1 +fi +exit 0 diff --git a/infrastructure_files/observability/grafana/dashboards/client.json b/infrastructure_files/observability/grafana/dashboards/client.json new file mode 100644 index 000000000..05306a972 --- /dev/null +++ b/infrastructure_files/observability/grafana/dashboards/client.json @@ -0,0 +1,1107 @@ +{ + "__inputs": [ + { + "name": "DS_PROMETHEUS", + "label": "Prometheus", + "description": "", + "type": "datasource", + "pluginId": "prometheus", + "pluginName": "Prometheus" + } + ], + "__elements": {}, + "__requires": [ + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "11.1.1" + }, + { + "type": "datasource", + "id": "prometheus", + "name": "Prometheus", + "version": "1.0.0" + }, + { + "type": "panel", + "id": "stat", + "name": "Stat", + "version": "" + }, + { + "type": "panel", + "id": "timeseries", + "name": "Time series", + "version": "" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "panels": [], + "title": "Connection state", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 1 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 0, + "y": 1 + }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "netbird_management_connected{job=~\"$job\",instance=~\"$instance\"}", + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Management connected", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 1 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 6, + "y": 1 + }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "netbird_signal_connected{job=~\"$job\",instance=~\"$instance\"}", + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Signal connected", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 1 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 12, + "y": 1 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "netbird_peers{job=~\"$job\",instance=~\"$instance\"}", + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Known peers", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 1 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 18, + "y": 1 + }, + "id": 5, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(netbird_peers_connected{job=~\"$job\",instance=~\"$instance\"})", + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Connected peers", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 8, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 5 + }, + "id": 6, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "netbird_peers_connected{job=~\"$job\",instance=~\"$instance\"}", + "instant": false, + "legendFormat": "{{connection_type}}", + "range": true, + "refId": "A" + } + ], + "title": "Connected peers by connection type", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 8, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 5 + }, + "id": 7, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "netbird_peer_latency_seconds{job=~\"$job\",instance=~\"$instance\"}", + "instant": false, + "legendFormat": "{{peer}}", + "range": true, + "refId": "A" + } + ], + "title": "Peer latency", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 13 + }, + "id": 8, + "panels": [], + "title": "Peer connection establishment", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 8, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 14 + }, + "id": 9, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.5,sum(increase(netbird_peer_connection_stage_duration_seconds_bucket{job=~\"$job\",instance=~\"$instance\",stage=\"total\"}[$__rate_interval])) by (le,connection_type))", + "instant": false, + "legendFormat": "{{connection_type}}", + "range": true, + "refId": "A" + } + ], + "title": "Connection establishment duration (p50)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 8, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 14 + }, + "id": 10, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.5,sum(increase(netbird_peer_connection_stage_duration_seconds_bucket{job=~\"$job\",instance=~\"$instance\"}[$__rate_interval])) by (le,stage))", + "instant": false, + "legendFormat": "{{stage}}", + "range": true, + "refId": "A" + } + ], + "title": "Connection establishment stages (p50)", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 22 + }, + "id": 11, + "panels": [], + "title": "Management interactions", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 8, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 23 + }, + "id": 12, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.5,sum(increase(netbird_sync_duration_seconds_bucket{job=~\"$job\",instance=~\"$instance\"}[$__rate_interval])) by (le))", + "instant": false, + "legendFormat": "sync", + "range": true, + "refId": "A" + } + ], + "title": "Sync processing duration (p50)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 8, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 23 + }, + "id": 13, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.5,sum(increase(netbird_sync_phase_duration_seconds_bucket{job=~\"$job\",instance=~\"$instance\"}[$__rate_interval])) by (le,phase))", + "instant": false, + "legendFormat": "{{phase}}", + "range": true, + "refId": "A" + } + ], + "title": "Sync phase duration (p50)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 8, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 23 + }, + "id": 14, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.5,sum(increase(netbird_login_duration_seconds_bucket{job=~\"$job\",instance=~\"$instance\"}[$__rate_interval])) by (le,success))", + "instant": false, + "legendFormat": "success={{success}}", + "range": true, + "refId": "A" + } + ], + "title": "Login duration (p50)", + "type": "timeseries" + } + ], + "schemaVersion": 39, + "tags": [ + "netbird", + "client" + ], + "templating": { + "list": [ + { + "current": {}, + "hide": 0, + "includeAll": false, + "multi": false, + "name": "datasource", + "options": [], + "query": "prometheus", + "queryValue": "", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "type": "datasource" + }, + { + "current": {}, + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "definition": "label_values(netbird_management_connected,job)", + "hide": 0, + "includeAll": true, + "multi": true, + "name": "job", + "options": [], + "query": { + "qryType": 1, + "query": "label_values(netbird_management_connected,job)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + }, + { + "current": {}, + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "definition": "label_values(netbird_management_connected{job=~\"$job\"},instance)", + "hide": 0, + "includeAll": true, + "multi": true, + "name": "instance", + "options": [], + "query": { + "qryType": 1, + "query": "label_values(netbird_management_connected{job=~\"$job\"},instance)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + } + ] + }, + "time": { + "from": "now-24h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Netbird / Client", + "uid": "netbird-client-v001", + "version": 1, + "weekStart": "" +} 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/Dockerfile.multistage b/management/Dockerfile.multistage
index 619f84615..5d037f1b1 100644
--- a/management/Dockerfile.multistage
+++ b/management/Dockerfile.multistage
@@ -1,4 +1,4 @@
-FROM golang:1.25-bookworm AS builder
+FROM golang:1.26.7-bookworm AS builder
 WORKDIR /app
 
 # Install build dependencies
diff --git a/management/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go
index 30de974a1..d72ba439d 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,277 @@ 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.
+func peerPostureChecksFromData(nmData *networkmap.NetworkMapData, peerID string) []*nmdata.PostureChecks {
+	if len(nmData.PostureChecks) == 0 {
+		return nil
+	}
+
+	peerPostureChecks := make(map[string]*nmdata.PostureChecks)
+	for _, policy := range nmData.Policies {
+		if policy == nil || !policy.Enabled || len(policy.SourcePostureChecks) == 0 {
+			continue
+		}
+		if !isPeerInPolicySourcesFromData(nmData, peerID, policy) {
+			continue
+		}
+		for _, checkID := range policy.SourcePostureChecks {
+			if twin := nmData.PostureChecks[checkID]; twin != nil {
+				peerPostureChecks[checkID] = twin
+			}
+		}
+	}
+
+	return maps.Values(peerPostureChecks)
+}
+
+func isPeerInPolicySourcesFromData(nmData *networkmap.NetworkMapData, peerID string, policy *nmdata.Policy) bool {
+	for _, rule := range policy.Rules {
+		if rule == nil || !rule.Enabled {
+			continue
+		}
+		if rule.SourceResource.Type == string(types.ResourceTypePeer) && rule.SourceResource.ID == peerID {
+			return true
+		}
+		for _, groupID := range rule.Sources {
+			if group := nmData.Groups[groupID]; group != nil && slices.Contains(group.Peers, peerID) {
+				return true
+			}
+		}
+	}
+	return false
+}
+
 func (c *Controller) perAccountOrGlobalSupportedSyncMessageVersions(accountId string) sharedgrpc.SyncMessageVersion {
 	if perAccount, ok := c.perAccountServerSupportedSyncMessageVersions[accountId]; ok {
 		return perAccount
@@ -326,6 +639,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 +658,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 +745,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 +766,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 +823,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 +883,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 +900,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,
@@ -637,13 +954,17 @@ func (c *Controller) BufferUpdateAccountPeers(ctx context.Context, accountID str
 // data the legacy server folds in via NetworkMap.Merge). The gRPC layer
 // encodes both into the wire envelope. Callers must gate on capability
 // themselves before dispatching here — this method does NOT branch on it.
-func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, peer *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*posture.Checks, int64, error) {
+func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, peer *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) {
 	if isRequiresApproval {
 		network, err := c.repo.GetAccountNetwork(ctx, accountID)
 		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 +979,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 +1016,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, []*nmdata.PostureChecks, 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 {
@@ -793,7 +1129,7 @@ func (b *bufferAffectedUpdate) setTimer(d time.Duration, f func()) {
 	b.next.Reset(d)
 }
 
-func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, peerID string) (*types.NetworkMap, []*posture.Checks, int64, error) {
+func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, peerID string) (*types.NetworkMap, []*nmdata.PostureChecks, int64, error) {
 	if isRequiresApproval {
 		network, err := c.repo.GetAccountNetwork(ctx, accountID)
 		if err != nil {
@@ -801,11 +1137,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 +1153,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 +1193,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, []*nmdata.PostureChecks, 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 {
@@ -866,7 +1221,7 @@ func (c *Controller) GetDNSDomain(settings *types.Settings) string {
 }
 
 // getPeerPostureChecks returns the posture checks applied for a given peer.
-func (c *Controller) getPeerPostureChecks(account *types.Account, peerID string) ([]*posture.Checks, error) {
+func (c *Controller) getPeerPostureChecks(account *types.Account, peerID string) ([]*nmdata.PostureChecks, error) {
 	peerPostureChecks := make(map[string]*posture.Checks)
 
 	if len(account.PostureChecks) == 0 {
@@ -883,7 +1238,7 @@ func (c *Controller) getPeerPostureChecks(account *types.Account, peerID string)
 		}
 	}
 
-	return maps.Values(peerPostureChecks), nil
+	return types.TwinPostureChecksList(maps.Values(peerPostureChecks)), nil
 }
 
 func (c *Controller) StartWarmup(ctx context.Context) {
@@ -915,20 +1270,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)
@@ -946,7 +1317,7 @@ func computeForwarderPort(peers []*nbpeer.Peer, requiredVersion string) int64 {
 
 // addPolicyPostureChecks adds posture checks from a policy to the peer posture checks map if the peer is in the policy's source groups.
 func addPolicyPostureChecks(account *types.Account, peerID string, policy *types.Policy, peerPostureChecks map[string]*posture.Checks) error {
-	isInGroup, err := isPeerInPolicySourceGroups(account, peerID, policy)
+	isInGroup, err := isPeerInPolicySources(account, peerID, policy)
 	if err != nil {
 		return err
 	}
@@ -966,13 +1337,17 @@ func addPolicyPostureChecks(account *types.Account, peerID string, policy *types
 	return nil
 }
 
-// isPeerInPolicySourceGroups checks if a peer is present in any of the policy rule source groups.
-func isPeerInPolicySourceGroups(account *types.Account, peerID string, policy *types.Policy) (bool, error) {
+// isPeerInPolicySources checks if a peer is a source of the policy, directly or through a source group.
+func isPeerInPolicySources(account *types.Account, peerID string, policy *types.Policy) (bool, error) {
 	for _, rule := range policy.Rules {
 		if !rule.Enabled {
 			continue
 		}
 
+		if rule.SourceResource.Type == types.ResourceTypePeer && rule.SourceResource.ID == peerID {
+			return true, nil
+		}
+
 		for _, sourceGroup := range rule.Sources {
 			group := account.GetGroup(sourceGroup)
 			if group == nil {
@@ -1062,7 +1437,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/posture_twin_test.go b/management/internals/controllers/network_map/controller/posture_twin_test.go
new file mode 100644
index 000000000..98e0991d0
--- /dev/null
+++ b/management/internals/controllers/network_map/controller/posture_twin_test.go
@@ -0,0 +1,68 @@
+package controller
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+	"github.com/netbirdio/netbird/shared/management/types"
+)
+
+func postureSelectionData(policies ...*nmdata.Policy) *networkmap.NetworkMapData {
+	return &networkmap.NetworkMapData{
+		Groups:   map[string]*nmdata.Group{"g-src": {ID: "g-src", Peers: []string{"peer-group"}}},
+		Policies: policies,
+		PostureChecks: map[string]*nmdata.PostureChecks{
+			"pc1": {ID: "pc1", Checks: nmdata.ChecksDefinition{NBVersionCheck: &nmdata.NBVersionCheck{MinVersion: "0.30.0"}}},
+		},
+	}
+}
+
+func gatedPolicy(id string, rule *nmdata.PolicyRule, checkIDs ...string) *nmdata.Policy {
+	return &nmdata.Policy{ID: id, Enabled: true, SourcePostureChecks: checkIDs, Rules: []*nmdata.PolicyRule{rule}}
+}
+
+func checkIDs(checks []*nmdata.PostureChecks) []string {
+	ids := make([]string, 0, len(checks))
+	for _, c := range checks {
+		ids = append(ids, c.ID)
+	}
+	return ids
+}
+
+func TestPeerPostureChecksFromData_SelectsPolicySourcePeers(t *testing.T) {
+	groupRule := &nmdata.PolicyRule{ID: "r-group", Enabled: true, Sources: []string{"g-src"}, Destinations: []string{"g-dst"}}
+	directRule := &nmdata.PolicyRule{ID: "r-direct", Enabled: true, SourceResource: nmdata.Resource{ID: "peer-direct", Type: string(types.ResourceTypePeer)}, Destinations: []string{"g-dst"}}
+
+	t.Run("source group member and direct source peer both get the checks", func(t *testing.T) {
+		nmData := postureSelectionData(gatedPolicy("p1", groupRule, "pc1"), gatedPolicy("p2", directRule, "pc1"))
+
+		assert.Equal(t, []string{"pc1"}, checkIDs(peerPostureChecksFromData(nmData, "peer-group")))
+		assert.Equal(t, []string{"pc1"}, checkIDs(peerPostureChecksFromData(nmData, "peer-direct")))
+		assert.Empty(t, peerPostureChecksFromData(nmData, "peer-elsewhere"))
+	})
+
+	t.Run("source resource of a non-peer type never matches a peer", func(t *testing.T) {
+		hostRule := &nmdata.PolicyRule{ID: "r-host", Enabled: true, SourceResource: nmdata.Resource{ID: "peer-direct", Type: string(types.ResourceTypeHost)}, Destinations: []string{"g-dst"}}
+		nmData := postureSelectionData(gatedPolicy("p1", hostRule, "pc1"))
+
+		assert.Empty(t, peerPostureChecksFromData(nmData, "peer-direct"))
+	})
+
+	t.Run("same check through two policies is returned once", func(t *testing.T) {
+		nmData := postureSelectionData(gatedPolicy("p1", groupRule, "pc1"), gatedPolicy("p2", groupRule, "pc1"))
+
+		assert.Equal(t, []string{"pc1"}, checkIDs(peerPostureChecksFromData(nmData, "peer-group")))
+	})
+
+	t.Run("disabled policy, disabled rule and dangling check are ignored", func(t *testing.T) {
+		disabledPolicy := gatedPolicy("p-off", groupRule, "pc1")
+		disabledPolicy.Enabled = false
+		disabledRule := &nmdata.PolicyRule{ID: "r-off", Enabled: false, Sources: []string{"g-src"}}
+		nmData := postureSelectionData(disabledPolicy, gatedPolicy("p-rule-off", disabledRule, "pc1"), gatedPolicy("p-dangling", groupRule, "pc-missing"))
+
+		assert.Empty(t, peerPostureChecksFromData(nmData, "peer-group"))
+	})
+}
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/interface.go b/management/internals/controllers/network_map/interface.go
index b535321d1..1e8c219b3 100644
--- a/management/internals/controllers/network_map/interface.go
+++ b/management/internals/controllers/network_map/interface.go
@@ -7,8 +7,8 @@ import (
 
 	nbdns "github.com/netbirdio/netbird/dns"
 	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/nmdata"
 )
 
 const (
@@ -23,8 +23,8 @@ type Controller interface {
 	BufferUpdateAffectedPeers(ctx context.Context, accountID string, peerIDs []string, reason types.UpdateReason) error
 	UpdateAccountPeer(ctx context.Context, accountId string, peerId string) error
 	BufferUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error
-	GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, peerID string) (*types.NetworkMap, []*posture.Checks, int64, error)
-	GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, p *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*posture.Checks, int64, error)
+	GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, peerID string) (*types.NetworkMap, []*nmdata.PostureChecks, int64, error)
+	GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, p *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*nmdata.PostureChecks, int64, error)
 	GetDNSDomain(settings *types.Settings) string
 	StartWarmup(context.Context)
 	GetNetworkMap(ctx context.Context, peerID string) (*types.NetworkMap, error)
diff --git a/management/internals/controllers/network_map/interface_mock.go b/management/internals/controllers/network_map/interface_mock.go
index 42051f172..8b104dfa0 100644
--- a/management/internals/controllers/network_map/interface_mock.go
+++ b/management/internals/controllers/network_map/interface_mock.go
@@ -14,8 +14,8 @@ import (
 	reflect "reflect"
 
 	peer "github.com/netbirdio/netbird/management/server/peer"
-	posture "github.com/netbirdio/netbird/management/server/posture"
 	types "github.com/netbirdio/netbird/management/server/types"
+	nmdata "github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	gomock "go.uber.org/mock/gomock"
 )
 
@@ -127,13 +127,13 @@ func (mr *MockControllerMockRecorder) GetNetworkMap(ctx, peerID any) *gomock.Cal
 }
 
 // GetValidatedPeerWithComponents mocks base method.
-func (m *MockController) GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, p *peer.Peer) (*peer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*posture.Checks, int64, error) {
+func (m *MockController) GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, p *peer.Peer) (*peer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) {
 	m.ctrl.T.Helper()
 	ret := m.ctrl.Call(m, "GetValidatedPeerWithComponents", ctx, isRequiresApproval, accountID, p)
 	ret0, _ := ret[0].(*peer.Peer)
 	ret1, _ := ret[1].(*types.NetworkMapComponents)
 	ret2, _ := ret[2].(*types.NetworkMap)
-	ret3, _ := ret[3].([]*posture.Checks)
+	ret3, _ := ret[3].([]*nmdata.PostureChecks)
 	ret4, _ := ret[4].(int64)
 	ret5, _ := ret[5].(error)
 	return ret0, ret1, ret2, ret3, ret4, ret5
@@ -146,11 +146,11 @@ func (mr *MockControllerMockRecorder) GetValidatedPeerWithComponents(ctx, isRequ
 }
 
 // GetValidatedPeerWithMap mocks base method.
-func (m *MockController) GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID, peerID string) (*types.NetworkMap, []*posture.Checks, int64, error) {
+func (m *MockController) GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID, peerID string) (*types.NetworkMap, []*nmdata.PostureChecks, int64, error) {
 	m.ctrl.T.Helper()
 	ret := m.ctrl.Call(m, "GetValidatedPeerWithMap", ctx, isRequiresApproval, accountID, peerID)
 	ret0, _ := ret[0].(*types.NetworkMap)
-	ret1, _ := ret[1].([]*posture.Checks)
+	ret1, _ := ret[1].([]*nmdata.PostureChecks)
 	ret2, _ := ret[2].(int64)
 	ret3, _ := ret[3].(error)
 	return ret0, ret1, ret2, ret3
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-direct-peer-unvalidated/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-direct-peer-unvalidated/case.json
new file mode 100644
index 000000000..cdf31c413
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-direct-peer-unvalidated/case.json
@@ -0,0 +1,5 @@
+{
+  "description": "A peer named directly as a rule source or destination is subject to approval exactly like a group member: unvalidated peer-b is neither a source for peer-c nor a destination for peer-a, while the validated direct source peer-a reaches peer-c. Legacy mode is excluded: the frozen legacynmap copy still carries the direct-peer bypass.",
+  "peers": ["peer-a", "peer-c"],
+  "modes": ["full", "envelope"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-direct-peer-unvalidated/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-direct-peer-unvalidated/golden/peer-a.json
new file mode 100644
index 000000000..e0605525f
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-direct-peer-unvalidated/golden/peer-a.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "22",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "4deEImv8zGvsyBmmfC2G0eQkbyMzyGuz/YK7pcYETwM=",
+      "allowedIps": [
+        "100.64.0.3/32"
+      ],
+      "sshConfig": {},
+      "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": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRpcmVjdC1vaw=="
+    },
+    {
+      "PeerIP": "100.64.0.3",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRpcmVjdC1vaw=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-direct-peer-unvalidated/golden/peer-c.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-direct-peer-unvalidated/golden/peer-c.json
new file mode 100644
index 000000000..f2b3e9357
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-direct-peer-unvalidated/golden/peer-c.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "22",
+  "peerConfig": {
+    "address": "100.64.0.3/10",
+    "sshConfig": {},
+    "fqdn": "peer-c.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-c.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.3"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRpcmVjdC1vaw=="
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRpcmVjdC1vaw=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-direct-peer-unvalidated/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-direct-peer-unvalidated/nmdata.json
new file mode 100644
index 000000000..283df304c
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-direct-peer-unvalidated/nmdata.json
@@ -0,0 +1,63 @@
+{
+  "Network": {"Serial": 22},
+  "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"}}
+  },
+  "ValidatedPeers": {"peer-a": {}, "peer-c": {}},
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a"]},
+    "grp-ops": {"Peers": ["peer-c"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-direct-ok",
+      "PublicID": "pol-direct-ok-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["443"],
+          "Bidirectional": true,
+          "SourceResource": {"ID": "peer-a", "Type": "peer"},
+          "Destinations": ["grp-ops"]
+        }
+      ]
+    },
+    {
+      "ID": "pol-src-unval",
+      "PublicID": "pol-src-unval-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["8443"],
+          "Bidirectional": true,
+          "SourceResource": {"ID": "peer-b", "Type": "peer"},
+          "Destinations": ["grp-ops"]
+        }
+      ]
+    },
+    {
+      "ID": "pol-dst-unval",
+      "PublicID": "pol-dst-unval-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["9443"],
+          "Bidirectional": true,
+          "Sources": ["grp-dev"],
+          "DestinationResource": {"ID": "peer-b", "Type": "peer"}
+        }
+      ]
+    }
+  ]
+}
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-direct-source/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-direct-source/case.json
new file mode 100644
index 000000000..8d7460721
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-direct-source/case.json
@@ -0,0 +1,5 @@
+{
+  "description": "A peer named directly as a rule source is gated by the policy's posture checks exactly like a group member: peer-b (0.40.0) fails the 0.45.0 minimum, so it gets no connectivity and peer-c must not see it, while the compliant direct source peer-a reaches peer-c. Legacy mode is excluded: the frozen legacynmap copy still carries the direct-peer bypass.",
+  "peers": ["peer-b", "peer-c"],
+  "modes": ["full", "envelope"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-direct-source/golden/peer-b.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-direct-source/golden/peer-b.json
new file mode 100644
index 000000000..240358e40
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-direct-source/golden/peer-b.json
@@ -0,0 +1,33 @@
+{
+  "Serial": "21",
+  "peerConfig": {
+    "address": "100.64.0.2/10",
+    "sshConfig": {},
+    "fqdn": "peer-b.netbird.test",
+    "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-direct-source/golden/peer-c.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-direct-source/golden/peer-c.json
new file mode 100644
index 000000000..85573ed35
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-direct-source/golden/peer-c.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "21",
+  "peerConfig": {
+    "address": "100.64.0.3/10",
+    "sshConfig": {},
+    "fqdn": "peer-c.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-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": "cG9sLWRpcmVjdC1vaw=="
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRpcmVjdC1vaw=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-direct-source/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-direct-source/nmdata.json
new file mode 100644
index 000000000..e6b99bfdd
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-direct-source/nmdata.json
@@ -0,0 +1,51 @@
+{
+  "Network": {"Serial": 21},
+  "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-ops": {"Peers": ["peer-c"]}
+  },
+  "PostureChecks": {
+    "chk-ver": {"Checks": {"NBVersionCheck": {"MinVersion": "0.45.0"}}}
+  },
+  "PostureCheckXIDToPublicID": {"chk-ver": "chk-ver-pub"},
+  "Policies": [
+    {
+      "ID": "pol-direct-ok",
+      "PublicID": "pol-direct-ok-pub",
+      "Enabled": true,
+      "SourcePostureChecks": ["chk-ver"],
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["443"],
+          "Bidirectional": true,
+          "SourceResource": {"ID": "peer-a", "Type": "peer"},
+          "Destinations": ["grp-ops"]
+        }
+      ]
+    },
+    {
+      "ID": "pol-direct-denied",
+      "PublicID": "pol-direct-denied-pub",
+      "Enabled": true,
+      "SourcePostureChecks": ["chk-ver"],
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["8443"],
+          "Bidirectional": true,
+          "SourceResource": {"ID": "peer-b", "Type": "peer"},
+          "Destinations": ["grp-ops"]
+        }
+      ]
+    }
+  ]
+}
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..cdd2a7f37 100644
--- a/management/internals/shared/grpc/components_envelope_response.go
+++ b/management/internals/shared/grpc/components_envelope_response.go
@@ -7,11 +7,10 @@ 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 +30,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,
+	checks []*nmdata.PostureChecks,
+	settings *nmdata.AccountSettingsInfo,
 	extraSettings *types.ExtraSettings,
 	peerGroups []string,
 	dnsFwdPort int64,
@@ -145,7 +144,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 +169,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..96bd9f1f4 100644
--- a/management/internals/shared/grpc/conversion.go
+++ b/management/internals/shared/grpc/conversion.go
@@ -18,10 +18,9 @@ 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 +46,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 +118,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 +153,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 []*nmdata.PostureChecks, 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..240243497 100644
--- a/management/internals/shared/grpc/server.go
+++ b/management/internals/shared/grpc/server.go
@@ -42,10 +42,10 @@ import (
 	"github.com/netbirdio/netbird/management/server/auth"
 	nbContext "github.com/netbirdio/netbird/management/server/context"
 	nbpeer "github.com/netbirdio/netbird/management/server/peer"
-	"github.com/netbirdio/netbird/management/server/posture"
 	"github.com/netbirdio/netbird/management/server/settings"
 	"github.com/netbirdio/netbird/management/server/telemetry"
 	"github.com/netbirdio/netbird/management/server/types"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	"github.com/netbirdio/netbird/shared/management/proto"
 	internalStatus "github.com/netbirdio/netbird/shared/management/status"
 )
@@ -902,7 +902,7 @@ func (s *Server) ExtendAuthSession(ctx context.Context, req *proto.EncryptedMess
 	}, nil
 }
 
-func (s *Server) prepareLoginResponse(ctx context.Context, peer *nbpeer.Peer, network *types.Network, postureChecks []*posture.Checks, enableSSH bool) (*proto.LoginResponse, error) {
+func (s *Server) prepareLoginResponse(ctx context.Context, peer *nbpeer.Peer, network *types.Network, postureChecks []*nmdata.PostureChecks, enableSSH bool) (*proto.LoginResponse, error) {
 	var relayToken *Token
 	var err error
 	if s.config.Relay != nil && len(s.config.Relay.Addresses) > 0 {
@@ -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),
 	}
 
@@ -990,7 +990,7 @@ func (s *Server) IsHealthy(ctx context.Context, req *proto.Empty) (*proto.Empty,
 }
 
 // sendInitialSync sends initial proto.SyncResponse to the peer requesting synchronization
-func (s *Server) sendInitialSync(ctx context.Context, peerKey wgtypes.Key, peer *nbpeer.Peer, networkMap *types.NetworkMap, postureChecks []*posture.Checks, srv proto.ManagementService_SyncServer, dnsFwdPort int64) error {
+func (s *Server) sendInitialSync(ctx context.Context, peerKey wgtypes.Key, peer *nbpeer.Peer, networkMap *types.NetworkMap, postureChecks []*nmdata.PostureChecks, srv proto.ManagementService_SyncServer, dnsFwdPort int64) error {
 	var err error
 	var turnToken *Token
 
@@ -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()
@@ -1301,7 +1301,7 @@ func (s *Server) Logout(ctx context.Context, req *proto.EncryptedMessage) (*prot
 }
 
 // toProtocolChecks converts posture checks to protocol checks.
-func toProtocolChecks(ctx context.Context, postureChecks []*posture.Checks) []*proto.Checks {
+func toProtocolChecks(ctx context.Context, postureChecks []*nmdata.PostureChecks) []*proto.Checks {
 	protoChecks := make([]*proto.Checks, 0, len(postureChecks))
 	for _, postureCheck := range postureChecks {
 		check := toProtocolCheck(postureCheck)
@@ -1313,8 +1313,8 @@ func toProtocolChecks(ctx context.Context, postureChecks []*posture.Checks) []*p
 	return protoChecks
 }
 
-// toProtocolCheck converts a posture.Checks to a proto.Checks.
-func toProtocolCheck(postureCheck *posture.Checks) *proto.Checks {
+// toProtocolCheck converts posture checks to a proto.Checks.
+func toProtocolCheck(postureCheck *nmdata.PostureChecks) *proto.Checks {
 	protoCheck := &proto.Checks{}
 
 	if check := postureCheck.Checks.ProcessCheck; check != nil {
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.go b/management/server/account.go
index 700dfa04d..4fe0e5338 100644
--- a/management/server/account.go
+++ b/management/server/account.go
@@ -52,6 +52,7 @@ import (
 	"github.com/netbirdio/netbird/management/server/util"
 	"github.com/netbirdio/netbird/route"
 	nbdomain "github.com/netbirdio/netbird/shared/management/domain"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	"github.com/netbirdio/netbird/shared/management/status"
 )
 
@@ -1920,7 +1921,7 @@ func domainIsUpToDate(domain string, domainCategory string, userAuth auth.UserAu
 // derived from syncTime (the moment the gRPC stream opened). Any
 // concurrent stream that started earlier loses the optimistic-lock race
 // in MarkPeerConnected and bails without writing.
-func (am *DefaultAccountManager) SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) {
+func (am *DefaultAccountManager) SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) {
 	peer, netMap, postureChecks, dnsfwdPort, err := am.SyncPeer(ctx, types.PeerSync{WireGuardPubKey: peerPubKey, Meta: meta, RealIP: realIP}, accountID)
 	if err != nil {
 		return nil, nil, nil, 0, fmt.Errorf("error syncing peer: %w", err)
diff --git a/management/server/account/manager.go b/management/server/account/manager.go
index f4b0408cf..154c9ab18 100644
--- a/management/server/account/manager.go
+++ b/management/server/account/manager.go
@@ -23,6 +23,7 @@ import (
 	"github.com/netbirdio/netbird/management/server/users"
 	"github.com/netbirdio/netbird/route"
 	"github.com/netbirdio/netbird/shared/management/domain"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 )
 
 type ExternalCacheManager nbcache.UserDataCache
@@ -70,7 +71,7 @@ type Manager interface {
 	UpdatePeerIPv6(ctx context.Context, accountID, userID, peerID string, newIPv6 netip.Addr) error
 	GetNetworkMap(ctx context.Context, peerID string) (*types.NetworkMap, error)
 	GetPeerNetwork(ctx context.Context, peerID string) (*types.Network, error)
-	AddPeer(ctx context.Context, accountID, setupKey, userID string, p *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error)
+	AddPeer(ctx context.Context, accountID, setupKey, userID string, p *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.Network, []*nmdata.PostureChecks, bool, error)
 	CreatePAT(ctx context.Context, accountID string, initiatorUserID string, targetUserID string, tokenName string, expiresIn int) (*types.PersonalAccessTokenGenerated, error)
 	DeletePAT(ctx context.Context, accountID string, initiatorUserID string, targetUserID string, tokenID string) error
 	GetPAT(ctx context.Context, accountID string, initiatorUserID string, targetUserID string, tokenID string) (*types.PersonalAccessToken, error)
@@ -109,9 +110,9 @@ type Manager interface {
 	GetPeer(ctx context.Context, accountID, peerID, userID string) (*nbpeer.Peer, error)
 	UpdateAccountSettings(ctx context.Context, accountID, userID string, newSettings *types.Settings) (*types.Settings, error)
 	UpdateAccountOnboarding(ctx context.Context, accountID, userID string, newOnboarding *types.AccountOnboarding) (*types.AccountOnboarding, error)
-	LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error)                    // used by peer gRPC API
-	ExtendPeerSession(ctx context.Context, peerPubKey, userID string) (time.Time, error)                                                    // used by peer gRPC API for ExtendAuthSession
-	SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) // used by peer gRPC API
+	LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*nmdata.PostureChecks, bool, error)                    // used by peer gRPC API
+	ExtendPeerSession(ctx context.Context, peerPubKey, userID string) (time.Time, error)                                                          // used by peer gRPC API for ExtendAuthSession
+	SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) // used by peer gRPC API
 	GetExternalCacheManager() ExternalCacheManager
 	GetPostureChecks(ctx context.Context, accountID, postureChecksID, userID string) (*posture.Checks, error)
 	SavePostureChecks(ctx context.Context, accountID, userID string, postureChecks *posture.Checks, create bool) (*posture.Checks, error)
@@ -121,7 +122,7 @@ type Manager interface {
 	UpdateIntegratedValidator(ctx context.Context, accountID, userID, validator string, groups []string) error
 	GroupValidation(ctx context.Context, accountId string, groups []string) (bool, error)
 	GetValidatedPeers(ctx context.Context, accountID string) (map[string]struct{}, map[string]string, error)
-	SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error)
+	SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*nmdata.PostureChecks, int64, error)
 	OnPeerDisconnected(ctx context.Context, accountID string, peerPubKey string, streamStartTime time.Time) error
 	SyncPeerMeta(ctx context.Context, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP) error
 	FindExistingPostureCheck(accountID string, checks *posture.ChecksDefinition) (*posture.Checks, error)
diff --git a/management/server/account/manager_mock.go b/management/server/account/manager_mock.go
index 9ac10cba0..f31f63d0e 100644
--- a/management/server/account/manager_mock.go
+++ b/management/server/account/manager_mock.go
@@ -29,6 +29,7 @@ import (
 	route "github.com/netbirdio/netbird/route"
 	auth "github.com/netbirdio/netbird/shared/auth"
 	domain "github.com/netbirdio/netbird/shared/management/domain"
+	nmdata "github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	gomock "go.uber.org/mock/gomock"
 )
 
@@ -86,12 +87,12 @@ func (mr *MockManagerMockRecorder) AccountExists(ctx, accountID any) *gomock.Cal
 }
 
 // AddPeer mocks base method.
-func (m *MockManager) AddPeer(ctx context.Context, accountID, setupKey, userID string, p *peer.Peer, temporary bool) (*peer.Peer, *types.Network, []*posture.Checks, bool, error) {
+func (m *MockManager) AddPeer(ctx context.Context, accountID, setupKey, userID string, p *peer.Peer, temporary bool) (*peer.Peer, *types.Network, []*nmdata.PostureChecks, bool, error) {
 	m.ctrl.T.Helper()
 	ret := m.ctrl.Call(m, "AddPeer", ctx, accountID, setupKey, userID, p, temporary)
 	ret0, _ := ret[0].(*peer.Peer)
 	ret1, _ := ret[1].(*types.Network)
-	ret2, _ := ret[2].([]*posture.Checks)
+	ret2, _ := ret[2].([]*nmdata.PostureChecks)
 	ret3, _ := ret[3].(bool)
 	ret4, _ := ret[4].(error)
 	return ret0, ret1, ret2, ret3, ret4
@@ -1323,12 +1324,12 @@ func (mr *MockManagerMockRecorder) ListUsers(ctx, accountID any) *gomock.Call {
 }
 
 // LoginPeer mocks base method.
-func (m *MockManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*peer.Peer, *types.Network, []*posture.Checks, bool, error) {
+func (m *MockManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*peer.Peer, *types.Network, []*nmdata.PostureChecks, bool, error) {
 	m.ctrl.T.Helper()
 	ret := m.ctrl.Call(m, "LoginPeer", ctx, login)
 	ret0, _ := ret[0].(*peer.Peer)
 	ret1, _ := ret[1].(*types.Network)
-	ret2, _ := ret[2].([]*posture.Checks)
+	ret2, _ := ret[2].([]*nmdata.PostureChecks)
 	ret3, _ := ret[3].(bool)
 	ret4, _ := ret[4].(error)
 	return ret0, ret1, ret2, ret3, ret4
@@ -1568,12 +1569,12 @@ func (mr *MockManagerMockRecorder) StoreEvent(ctx, initiatorID, targetID, accoun
 }
 
 // SyncAndMarkPeer mocks base method.
-func (m *MockManager) SyncAndMarkPeer(ctx context.Context, accountID, peerPubKey string, meta peer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*peer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) {
+func (m *MockManager) SyncAndMarkPeer(ctx context.Context, accountID, peerPubKey string, meta peer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*peer.Peer, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) {
 	m.ctrl.T.Helper()
 	ret := m.ctrl.Call(m, "SyncAndMarkPeer", ctx, accountID, peerPubKey, meta, realIP, syncTime)
 	ret0, _ := ret[0].(*peer.Peer)
 	ret1, _ := ret[1].(*types.NetworkMap)
-	ret2, _ := ret[2].([]*posture.Checks)
+	ret2, _ := ret[2].([]*nmdata.PostureChecks)
 	ret3, _ := ret[3].(int64)
 	ret4, _ := ret[4].(error)
 	return ret0, ret1, ret2, ret3, ret4
@@ -1586,12 +1587,12 @@ func (mr *MockManagerMockRecorder) SyncAndMarkPeer(ctx, accountID, peerPubKey, m
 }
 
 // SyncPeer mocks base method.
-func (m *MockManager) SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*peer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) {
+func (m *MockManager) SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*peer.Peer, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) {
 	m.ctrl.T.Helper()
 	ret := m.ctrl.Call(m, "SyncPeer", ctx, sync, accountID)
 	ret0, _ := ret[0].(*peer.Peer)
 	ret1, _ := ret[1].(*types.NetworkMap)
-	ret2, _ := ret[2].([]*posture.Checks)
+	ret2, _ := ret[2].([]*nmdata.PostureChecks)
 	ret3, _ := ret[3].(int64)
 	ret4, _ := ret[4].(error)
 	return ret0, ret1, ret2, ret3, ret4
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..b462cc2a6 100644
--- a/management/server/account_test.go
+++ b/management/server/account_test.go
@@ -10,16 +10,17 @@ import (
 	"os"
 	"reflect"
 	"strconv"
+	"strings"
 	"sync"
 	"testing"
 	"time"
 
-	"go.uber.org/mock/gomock"
 	"github.com/prometheus/client_golang/prometheus/push"
 	log "github.com/sirupsen/logrus"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 	"go.opentelemetry.io/otel/metric/noop"
+	"go.uber.org/mock/gomock"
 	"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
 
 	"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
@@ -37,6 +38,8 @@ import (
 	"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
 	reverseproxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service/manager"
 	"github.com/netbirdio/netbird/management/internals/modules/zones"
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	networkmapdbfactory "github.com/netbirdio/netbird/management/internals/network_map_db/factory"
 	"github.com/netbirdio/netbird/management/internals/server/config"
 	nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
 	nbAccount "github.com/netbirdio/netbird/management/server/account"
@@ -3293,13 +3296,33 @@ func createManager(t testing.TB) (*DefaultAccountManager, *update_channel.PeersU
 	if err != nil {
 		return nil, nil, err
 	}
-	eventStore := &activity.InMemoryEventStore{}
+	return buildTestManager(t, store, nil)
+}
 
-	metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
-	if err != nil {
-		return nil, nil, err
+// createManagerWithNetworkMapStore builds a manager whose network map controller
+// reads the twin (nmdata) store, the production path on sqlite and postgres.
+func createManagerWithNetworkMapStore(t testing.TB) (*DefaultAccountManager, *update_channel.PeersUpdateManager) {
+	t.Helper()
+
+	if engine := os.Getenv("NETBIRD_STORE_ENGINE"); engine != "" && !strings.EqualFold(engine, string(types.SqliteStoreEngine)) {
+		t.Skipf("network map store test needs the sqlite engine, got %s", engine)
 	}
 
+	dataDir := t.TempDir()
+	store, err := createStoreAt(t, dataDir)
+	require.NoError(t, err)
+
+	nmdataStore, err := networkmapdbfactory.NewNetworkMapDBStore(context.Background(), types.SqliteStoreEngine, dataDir, MockIntegratedValidator{}, newSettingsMockManager(t))
+	require.NoError(t, err)
+
+	manager, updateManager, err := buildTestManager(t, store, nmdataStore)
+	require.NoError(t, err)
+	return manager, updateManager
+}
+
+func newSettingsMockManager(t testing.TB) *settings.MockManager {
+	t.Helper()
+
 	ctrl := gomock.NewController(t)
 	t.Cleanup(ctrl.Finish)
 
@@ -3312,6 +3335,23 @@ func createManager(t testing.TB) (*DefaultAccountManager, *update_channel.PeersU
 		UpdateExtraSettings(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
 		Return(false, nil).
 		AnyTimes()
+	return settingsMockManager
+}
+
+func buildTestManager(t testing.TB, store store.Store, nmdataStore *networkmapdb.NetworkMapDBStoreImpl) (*DefaultAccountManager, *update_channel.PeersUpdateManager, error) {
+	t.Helper()
+
+	eventStore := &activity.InMemoryEventStore{}
+
+	metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
+	if err != nil {
+		return nil, nil, err
+	}
+
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	settingsMockManager := newSettingsMockManager(t)
 
 	permissionsManager := permissions.NewManager(store)
 	peersManager := peers.NewManager(store, permissionsManager)
@@ -3331,7 +3371,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{}, nmdataStore)
 	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
@@ -3349,7 +3389,11 @@ func createManager(t testing.TB) (*DefaultAccountManager, *update_channel.PeersU
 
 func createStore(t testing.TB) (store.Store, error) {
 	t.Helper()
-	dataDir := t.TempDir()
+	return createStoreAt(t, t.TempDir())
+}
+
+func createStoreAt(t testing.TB, dataDir string) (store.Store, error) {
+	t.Helper()
 	store, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "", dataDir)
 	if err != nil {
 		return 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/affected_peers_router_paths_test.go b/management/server/affected_peers_router_paths_test.go
index 5d83367fd..d5868a5c1 100644
--- a/management/server/affected_peers_router_paths_test.go
+++ b/management/server/affected_peers_router_paths_test.go
@@ -12,6 +12,7 @@ import (
 	resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
 	routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
 	"github.com/netbirdio/netbird/management/server/posture"
+	"github.com/netbirdio/netbird/management/server/store"
 	"github.com/netbirdio/netbird/management/server/types"
 )
 
@@ -145,7 +146,7 @@ func TestAffectedPeers_GroupAddResource_RefreshesRoutingPeer(t *testing.T) {
 	assert.NotContains(t, affected, s.unrelatedPeerID, "unrelated peer must not be affected")
 }
 
-func (s *routerScenario) createPostureCheckGatedPolicy(t *testing.T, ctx context.Context) string {
+func (s *routerScenario) createPostureCheckGatedPolicy(t *testing.T, ctx context.Context, policy *types.Policy) string {
 	t.Helper()
 
 	check, err := s.manager.SavePostureChecks(ctx, s.accountID, userID, &posture.Checks{
@@ -156,7 +157,6 @@ func (s *routerScenario) createPostureCheckGatedPolicy(t *testing.T, ctx context
 	}, true)
 	require.NoError(t, err)
 
-	policy := peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID)
 	policy.SourcePostureChecks = []string{check.ID}
 	_, err = s.manager.SavePolicy(ctx, s.accountID, userID, policy, true)
 	require.NoError(t, err)
@@ -168,7 +168,7 @@ func TestAffectedPeers_E2E_SavePostureCheck_RefreshesRoutingPeer(t *testing.T) {
 	s := setupRouterScenario(t, true)
 	ctx := context.Background()
 
-	checkID := s.createPostureCheckGatedPolicy(t, ctx)
+	checkID := s.createPostureCheckGatedPolicy(t, ctx, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID))
 
 	srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID)
 	routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID)
@@ -338,3 +338,61 @@ func TestAffectedPeers_PeerChange_RouterInOtherNetworkNotAffected(t *testing.T)
 	assert.NotContains(t, affected, second.routerPeerID,
 		"a router in an unrelated network must not be affected by a source-peer change for another resource")
 }
+
+// TestAffectedPeers_E2E_PostureFlip_RefreshesRoutingPeer drives the customer path
+// on the twin store: the source peer's metadata flips a posture verdict on sync,
+// and the routing peer serving the gated resource must be refreshed in both
+// directions. Without the flip detection the deny direction takes the nmap
+// shortcut (the denied peer's map holds no router) and the allow direction
+// depends on which meta field moved, leaving the routers with a stale map.
+func TestAffectedPeers_E2E_PostureFlip_RefreshesRoutingPeer(t *testing.T) {
+	runPostureFlipRefreshesRoutingPeer(t, func(s *routerScenario) *types.Policy {
+		return peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID)
+	})
+}
+
+// TestAffectedPeers_E2E_PostureFlip_DirectSourcePeer_RefreshesRoutingPeer is the same
+// scenario with the source peer named directly in the rule: it must receive its posture
+// checks and have its flips detected exactly like a group member.
+func TestAffectedPeers_E2E_PostureFlip_DirectSourcePeer_RefreshesRoutingPeer(t *testing.T) {
+	runPostureFlipRefreshesRoutingPeer(t, func(s *routerScenario) *types.Policy {
+		return peerToResourcePolicyByPeer(s.sourcePeerID, s.resourceGroupID)
+	})
+}
+
+func runPostureFlipRefreshesRoutingPeer(t *testing.T, policyFor func(s *routerScenario) *types.Policy) {
+	t.Helper()
+
+	manager, updateManager := createManagerWithNetworkMapStore(t)
+	s := buildRouterScenario(t, manager, updateManager, true)
+	ctx := context.Background()
+
+	s.createPostureCheckGatedPolicy(t, ctx, policyFor(s))
+
+	source, err := s.manager.Store.GetPeerByID(ctx, store.LockingStrengthNone, s.accountID, s.sourcePeerID)
+	require.NoError(t, err)
+
+	syncWithVersion := func(version string) {
+		meta := source.Meta
+		meta.WtVersion = version
+		_, _, _, _, err := s.manager.SyncPeer(ctx, types.PeerSync{WireGuardPubKey: source.Key, Meta: meta}, s.accountID)
+		require.NoError(t, err)
+	}
+	syncWithVersion("0.31.0")
+
+	routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID)
+	unrelatedCh := s.updateManager.CreateChannel(ctx, s.unrelatedPeerID)
+	t.Cleanup(func() {
+		s.updateManager.CloseChannel(ctx, s.routerPeerID)
+		s.updateManager.CloseChannel(ctx, s.unrelatedPeerID)
+	})
+	settleAffectedUpdates(routerCh, unrelatedCh)
+
+	syncWithVersion("0.29.0")
+	peerShouldReceiveUpdate(t, routerCh)
+	peerShouldNotReceiveUpdate(t, unrelatedCh)
+
+	syncWithVersion("0.31.0")
+	peerShouldReceiveUpdate(t, routerCh)
+	peerShouldNotReceiveUpdate(t, unrelatedCh)
+}
diff --git a/management/server/affected_peers_router_test.go b/management/server/affected_peers_router_test.go
index cc9df0a6a..7e3f02b27 100644
--- a/management/server/affected_peers_router_test.go
+++ b/management/server/affected_peers_router_test.go
@@ -60,6 +60,12 @@ func setupRouterScenario(t *testing.T, directRouterPeer bool) *routerScenario {
 	manager, updateManager, err := createManager(t)
 	require.NoError(t, err)
 
+	return buildRouterScenario(t, manager, updateManager, directRouterPeer)
+}
+
+func buildRouterScenario(t *testing.T, manager *DefaultAccountManager, updateManager *update_channel.PeersUpdateManager, directRouterPeer bool) *routerScenario {
+	t.Helper()
+
 	ctx := context.Background()
 
 	account, err := createAccount(manager, "router_scenario", userID, "")
@@ -167,6 +173,23 @@ func peerToResourcePolicyByGroup(sourceGroupID, resourceGroupID string) *types.P
 	}
 }
 
+// peerToResourcePolicyByPeer builds a policy naming the source peer directly via
+// SourceResource rather than through a group.
+func peerToResourcePolicyByPeer(sourcePeerID, resourceGroupID string) *types.Policy {
+	return &types.Policy{
+		Enabled: true,
+		Name:    "peer-to-resource-by-peer",
+		Rules: []*types.PolicyRule{
+			{
+				Enabled:        true,
+				SourceResource: types.Resource{ID: sourcePeerID, Type: types.ResourceTypePeer},
+				Destinations:   []string{resourceGroupID},
+				Action:         types.PolicyTrafficActionAccept,
+			},
+		},
+	}
+}
+
 // peerToResourcePolicyByResource builds a policy referencing the resource
 // directly via DestinationResource rather than its group.
 func peerToResourcePolicyByResource(sourceGroupID, resourceID string) *types.Policy {
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/mock_server/account_mock.go b/management/server/mock_server/account_mock.go
index 071e3771b..2f871c3e2 100644
--- a/management/server/mock_server/account_mock.go
+++ b/management/server/mock_server/account_mock.go
@@ -24,6 +24,7 @@ import (
 	"github.com/netbirdio/netbird/management/server/users"
 	"github.com/netbirdio/netbird/route"
 	"github.com/netbirdio/netbird/shared/management/domain"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 )
 
 var _ account.Manager = (*MockAccountManager)(nil)
@@ -41,11 +42,11 @@ type MockAccountManager struct {
 	GetPeersFunc                          func(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error)
 	MarkPeerConnectedFunc                 func(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error
 	MarkPeerDisconnectedFunc              func(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64) error
-	SyncAndMarkPeerFunc                   func(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error)
+	SyncAndMarkPeerFunc                   func(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*nmdata.PostureChecks, int64, error)
 	DeletePeerFunc                        func(ctx context.Context, accountID, peerKey, userID string) error
 	GetNetworkMapFunc                     func(ctx context.Context, peerKey string) (*types.NetworkMap, error)
 	GetPeerNetworkFunc                    func(ctx context.Context, peerKey string) (*types.Network, error)
-	AddPeerFunc                           func(ctx context.Context, accountID string, setupKey string, userId string, peer *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error)
+	AddPeerFunc                           func(ctx context.Context, accountID string, setupKey string, userId string, peer *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.Network, []*nmdata.PostureChecks, bool, error)
 	GetGroupFunc                          func(ctx context.Context, accountID, groupID, userID string) (*types.Group, error)
 	GetAllGroupsFunc                      func(ctx context.Context, accountID, userID string) ([]*types.Group, error)
 	GetGroupByNameFunc                    func(ctx context.Context, groupName, accountID, userID string) (*types.Group, error)
@@ -98,9 +99,9 @@ type MockAccountManager struct {
 	SaveDNSSettingsFunc                   func(ctx context.Context, accountID, userID string, dnsSettingsToSave *types.DNSSettings) error
 	GetPeerFunc                           func(ctx context.Context, accountID, peerID, userID string) (*nbpeer.Peer, error)
 	UpdateAccountSettingsFunc             func(ctx context.Context, accountID, userID string, newSettings *types.Settings) (*types.Settings, error)
-	LoginPeerFunc                         func(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error)
+	LoginPeerFunc                         func(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*nmdata.PostureChecks, bool, error)
 	ExtendPeerSessionFunc                 func(ctx context.Context, peerPubKey, userID string) (time.Time, error)
-	SyncPeerFunc                          func(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error)
+	SyncPeerFunc                          func(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*nmdata.PostureChecks, int64, error)
 	InviteUserFunc                        func(ctx context.Context, accountID string, initiatorUserID string, targetUserEmail string) error
 	ApproveUserFunc                       func(ctx context.Context, accountID, initiatorUserID, targetUserID string) (*types.UserInfo, error)
 	RejectUserFunc                        func(ctx context.Context, accountID, initiatorUserID, targetUserID string) error
@@ -230,7 +231,7 @@ func (am *MockAccountManager) DeleteSetupKey(ctx context.Context, accountID, use
 	return status.Errorf(codes.Unimplemented, "method DeleteSetupKey is not implemented")
 }
 
-func (am *MockAccountManager) SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) {
+func (am *MockAccountManager) SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) {
 	if am.SyncAndMarkPeerFunc != nil {
 		return am.SyncAndMarkPeerFunc(ctx, accountID, peerPubKey, meta, realIP, syncTime)
 	}
@@ -424,7 +425,7 @@ func (am *MockAccountManager) AddPeer(
 	userId string,
 	peer *nbpeer.Peer,
 	temporary bool,
-) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) {
+) (*nbpeer.Peer, *types.Network, []*nmdata.PostureChecks, bool, error) {
 	if am.AddPeerFunc != nil {
 		return am.AddPeerFunc(ctx, accountID, setupKey, userId, peer, temporary)
 	}
@@ -862,7 +863,7 @@ func (am *MockAccountManager) UpdateAccountSettings(ctx context.Context, account
 }
 
 // LoginPeer mocks LoginPeer of the AccountManager interface
-func (am *MockAccountManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) {
+func (am *MockAccountManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*nmdata.PostureChecks, bool, error) {
 	if am.LoginPeerFunc != nil {
 		return am.LoginPeerFunc(ctx, login)
 	}
@@ -878,7 +879,7 @@ func (am *MockAccountManager) ExtendPeerSession(ctx context.Context, peerPubKey,
 }
 
 // SyncPeer mocks SyncPeer of the AccountManager interface
-func (am *MockAccountManager) SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) {
+func (am *MockAccountManager) SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) {
 	if am.SyncPeerFunc != nil {
 		return am.SyncPeerFunc(ctx, sync, accountID)
 	}
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..07619f51e 100644
--- a/management/server/peer.go
+++ b/management/server/peer.go
@@ -21,8 +21,8 @@ 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"
 	"github.com/netbirdio/netbird/management/server/types"
 
@@ -740,7 +740,7 @@ func (am *DefaultAccountManager) handleSetupKeyAddedPeer(ctx context.Context, en
 // to it. We also add the User ID to the peer metadata to identify registrant. If no userID provided, then fail with status.PermissionDenied
 // Each new Peer will be assigned a new next net.IP from the Account.Network and Account.Network.LastIP will be updated (IP's are not reused).
 // The peer property is just a placeholder for the Peer properties to pass further
-func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKey, userID string, peer *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) {
+func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKey, userID string, peer *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.Network, []*nmdata.PostureChecks, bool, error) {
 	if setupKey == "" && userID == "" && !peer.ProxyMeta.Embedded {
 		// no auth method provided => reject access
 		return nil, nil, nil, false, status.ErrNoAuthMethodProvided
@@ -1000,7 +1000,7 @@ func getPeerIPDNSLabel(ip netip.Addr, peerHostName string) (string, error) {
 }
 
 // SyncPeer checks whether peer is eligible for receiving NetworkMap (authenticated) and returns its NetworkMap if eligible
-func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) {
+func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) {
 	var peer *nbpeer.Peer
 	var ipv6CapabilityChanged bool
 	var metaDiff nbpeer.MetaDiff
@@ -1064,7 +1064,7 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy
 		return nil, nil, nil, 0, err
 	}
 
-	metaDiffAffectsPosture := posture.AffectsPosture(ctx, &metaDiff, resPostureChecks)
+	metaDiffAffectsPosture := metaDiffAffectsPosture(&metaDiff, resPostureChecks)
 	if requiresPeerUpdate(ctx, isStatusChanged, sync.UpdateAccountPeers, ipv6CapabilityChanged, metaDiffAffectsPosture, metaDiff.VersionChanged(), metaDiff.HostnameChanged()) {
 		changedPeerIDs := []string{peer.ID}
 		affectedPeerIDs := am.syncPeerAffectedPeers(ctx, accountID, peer.ID, nmap, peerNotValid, metaDiffAffectsPosture)
@@ -1076,6 +1076,14 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy
 	return peer, nmap, resPostureChecks, dnsFwdPort, nil
 }
 
+// metaDiffAffectsPosture reports whether the meta change flips the verdict of any of
+// the peer's posture checks, replaying them against the old and new state.
+func metaDiffAffectsPosture(diff *nbpeer.MetaDiff, checks []*nmdata.PostureChecks) bool {
+	oldPeer := types.TwinPeer(&nbpeer.Peer{Meta: diff.OldMeta, Location: diff.OldLocation})
+	newPeer := types.TwinPeer(&nbpeer.Peer{Meta: diff.NewMeta, Location: diff.NewLocation})
+	return nmdata.PostureVerdictChanged(checks, oldPeer, newPeer)
+}
+
 func requiresPeerUpdate(ctx context.Context, isStatusChanged, updateAccountPeers, ipv6CapabilityChanged, metaDiffAffectsPosture, versionChanged, hostname bool) bool {
 	var reason string
 	switch {
@@ -1127,7 +1135,7 @@ func (am *DefaultAccountManager) markConnectedAffectedPeers(ctx context.Context,
 	return affectedPeerIDsFromNetworkMap(nmap, peerID)
 }
 
-func (am *DefaultAccountManager) handlePeerLoginNotFound(ctx context.Context, login types.PeerLogin, err error) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) {
+func (am *DefaultAccountManager) handlePeerLoginNotFound(ctx context.Context, login types.PeerLogin, err error) (*nbpeer.Peer, *types.Network, []*nmdata.PostureChecks, bool, error) {
 	if errStatus, ok := status.FromError(err); ok && errStatus.Type() == status.NotFound {
 		// we couldn't find this peer by its public key which can mean that peer hasn't been registered yet.
 		// Try registering it.
@@ -1148,7 +1156,7 @@ func (am *DefaultAccountManager) handlePeerLoginNotFound(ctx context.Context, lo
 
 // LoginPeer logs in or registers a peer.
 // If peer doesn't exist the function checks whether a setup key or a user is present and registers a new peer if so.
-func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) {
+func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*nmdata.PostureChecks, bool, error) {
 	accountID, err := am.Store.GetAccountIDByPeerPubKey(ctx, login.WireGuardPubKey)
 	if err != nil {
 		return am.handlePeerLoginNotFound(ctx, login, err)
@@ -1321,7 +1329,7 @@ func (am *DefaultAccountManager) ExtendPeerSession(ctx context.Context, peerPubK
 
 // getPeerLoginInfo computes the login/register response data (network, posture
 // checks, SSH) from the store without building the peer's full network map.
-func getPeerLoginInfo(ctx context.Context, transaction store.Store, accountID string, peer *nbpeer.Peer, isValid bool) (*types.Network, []*posture.Checks, bool, error) {
+func getPeerLoginInfo(ctx context.Context, transaction store.Store, accountID string, peer *nbpeer.Peer, isValid bool) (*types.Network, []*nmdata.PostureChecks, bool, error) {
 	network, err := transaction.GetAccountNetwork(ctx, store.LockingStrengthNone, accountID)
 	if err != nil {
 		return nil, nil, false, fmt.Errorf("get account network: %w", err)
@@ -1341,7 +1349,7 @@ func getPeerLoginInfo(ctx context.Context, transaction store.Store, accountID st
 		return nil, nil, false, err
 	}
 
-	postureChecks, err := getPeerPostureChecks(ctx, transaction, accountID, peerGroupIDs, policies)
+	postureChecks, err := getPeerPostureChecks(ctx, transaction, accountID, peer.ID, peerGroupIDs, policies)
 	if err != nil {
 		return nil, nil, false, err
 	}
@@ -1363,7 +1371,7 @@ func isPeerSSHEnabled(ctx context.Context, peer *nbpeer.Peer, policies []*types.
 }
 
 // getPeerPostureChecks returns the posture checks for the peer.
-func getPeerPostureChecks(ctx context.Context, transaction store.Store, accountID string, peerGroupIDs []string, policies []*types.Policy) ([]*posture.Checks, error) {
+func getPeerPostureChecks(ctx context.Context, transaction store.Store, accountID, peerID string, peerGroupIDs []string, policies []*types.Policy) ([]*nmdata.PostureChecks, error) {
 	if len(policies) == 0 {
 		return nil, nil
 	}
@@ -1375,7 +1383,7 @@ func getPeerPostureChecks(ctx context.Context, transaction store.Store, accountI
 			continue
 		}
 
-		postureChecksIDs := processPeerPostureChecks(policy, peerGroupIDs)
+		postureChecksIDs := processPeerPostureChecks(policy, peerID, peerGroupIDs)
 		peerPostureChecksIDs = append(peerPostureChecksIDs, postureChecksIDs...)
 	}
 
@@ -1384,16 +1392,20 @@ func getPeerPostureChecks(ctx context.Context, transaction store.Store, accountI
 		return nil, err
 	}
 
-	return maps.Values(peerPostureChecks), nil
+	return types.TwinPostureChecksList(maps.Values(peerPostureChecks)), nil
 }
 
-// processPeerPostureChecks checks if the peer is in the source group of the policy and returns the posture checks.
-func processPeerPostureChecks(policy *types.Policy, peerGroupIDs []string) []string {
+// processPeerPostureChecks returns the policy's posture checks when the peer is a source of the policy, directly or through a source group.
+func processPeerPostureChecks(policy *types.Policy, peerID string, peerGroupIDs []string) []string {
 	for _, rule := range policy.Rules {
 		if !rule.Enabled {
 			continue
 		}
 
+		if rule.SourceResource.Type == types.ResourceTypePeer && rule.SourceResource.ID == peerID {
+			return policy.SourcePostureChecks
+		}
+
 		for _, sourceGroup := range rule.Sources {
 			if slices.Contains(peerGroupIDs, sourceGroup) {
 				return policy.SourcePostureChecks
@@ -1588,7 +1600,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_posture_test.go b/management/server/peer_posture_test.go
new file mode 100644
index 000000000..88662e2fa
--- /dev/null
+++ b/management/server/peer_posture_test.go
@@ -0,0 +1,203 @@
+package server
+
+import (
+	"net"
+	"net/netip"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	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/nmdata"
+)
+
+func diffFrom(oldMeta, newMeta nbpeer.PeerSystemMeta, oldLoc, newLoc nbpeer.Location) *nbpeer.MetaDiff {
+	return &nbpeer.MetaDiff{
+		OldMeta:     oldMeta,
+		NewMeta:     newMeta,
+		OldLocation: oldLoc,
+		NewLocation: newLoc,
+	}
+}
+
+func postureBundle(def nmdata.ChecksDefinition) []*nmdata.PostureChecks {
+	return []*nmdata.PostureChecks{{Checks: def}}
+}
+
+func TestMetaDiffAffectsPosture_NBVersion(t *testing.T) {
+	c := postureBundle(nmdata.ChecksDefinition{NBVersionCheck: &nmdata.NBVersionCheck{MinVersion: "1.2.0"}})
+
+	tests := []struct {
+		name           string
+		oldVer, newVer string
+		want           bool
+	}{
+		{"both above min, no flip", "1.3.0", "1.4.0", false},
+		{"both below min, no flip", "1.0.0", "1.1.0", false},
+		{"crosses up below->above", "1.1.0", "1.3.0", true},
+		{"crosses down above->below", "1.3.0", "1.1.0", true},
+		{"unparsable old only -> flip", "garbage", "1.3.0", true},
+		{"unparsable both -> no flip", "garbage", "junk", false},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			diff := diffFrom(
+				nbpeer.PeerSystemMeta{WtVersion: tt.oldVer},
+				nbpeer.PeerSystemMeta{WtVersion: tt.newVer},
+				nbpeer.Location{}, nbpeer.Location{},
+			)
+			assert.Equal(t, tt.want, metaDiffAffectsPosture(diff, c))
+		})
+	}
+}
+
+func TestMetaDiffAffectsPosture_OSVersion_KernelBumpWithinMin(t *testing.T) {
+	c := postureBundle(nmdata.ChecksDefinition{OSVersionCheck: &nmdata.OSVersionCheck{
+		Linux: &nmdata.MinKernelVersionCheck{MinKernelVersion: "5.0.0"},
+	}})
+
+	withinMin := diffFrom(
+		nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "5.10.0-arch1"},
+		nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "5.15.0-arch2"},
+		nbpeer.Location{}, nbpeer.Location{},
+	)
+	assert.False(t, metaDiffAffectsPosture(withinMin, c))
+
+	crossesDown := diffFrom(
+		nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "5.10.0-arch1"},
+		nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "4.19.0-arch1"},
+		nbpeer.Location{}, nbpeer.Location{},
+	)
+	assert.True(t, metaDiffAffectsPosture(crossesDown, c))
+}
+
+func TestMetaDiffAffectsPosture_OSVersion_GoOSSwitchFlipsVerdict(t *testing.T) {
+	c := postureBundle(nmdata.ChecksDefinition{OSVersionCheck: &nmdata.OSVersionCheck{
+		Linux: &nmdata.MinKernelVersionCheck{MinKernelVersion: "6.0.0"},
+	}})
+
+	diff := diffFrom(
+		nbpeer.PeerSystemMeta{GoOS: "freebsd"},
+		nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "4.19.0"},
+		nbpeer.Location{}, nbpeer.Location{},
+	)
+	assert.True(t, metaDiffAffectsPosture(diff, c))
+}
+
+func TestMetaDiffAffectsPosture_Process_GoOSSwitchFlipsVerdict(t *testing.T) {
+	c := postureBundle(nmdata.ChecksDefinition{ProcessCheck: &nmdata.ProcessCheck{
+		Processes: []nmdata.Process{{LinuxPath: "/usr/bin/foo"}},
+	}})
+
+	files := []nbpeer.File{{Path: "/usr/bin/foo", ProcessIsRunning: true}}
+	diff := diffFrom(
+		nbpeer.PeerSystemMeta{GoOS: "linux", Files: files},
+		nbpeer.PeerSystemMeta{GoOS: "windows", Files: files},
+		nbpeer.Location{}, nbpeer.Location{},
+	)
+	assert.True(t, metaDiffAffectsPosture(diff, c))
+}
+
+func TestMetaDiffAffectsPosture_Process_UnrelatedFileChange(t *testing.T) {
+	c := postureBundle(nmdata.ChecksDefinition{ProcessCheck: &nmdata.ProcessCheck{
+		Processes: []nmdata.Process{{LinuxPath: "/usr/bin/foo"}},
+	}})
+
+	diff := diffFrom(
+		nbpeer.PeerSystemMeta{GoOS: "linux", Files: []nbpeer.File{
+			{Path: "/usr/bin/foo", ProcessIsRunning: true},
+		}},
+		nbpeer.PeerSystemMeta{GoOS: "linux", Files: []nbpeer.File{
+			{Path: "/usr/bin/foo", ProcessIsRunning: true},
+			{Path: "/usr/bin/bar", ProcessIsRunning: true},
+		}},
+		nbpeer.Location{}, nbpeer.Location{},
+	)
+	assert.False(t, metaDiffAffectsPosture(diff, c))
+}
+
+func TestMetaDiffAffectsPosture_GeoLocation(t *testing.T) {
+	c := postureBundle(nmdata.ChecksDefinition{GeoLocationCheck: &nmdata.GeoLocationCheck{
+		Action:    posture.CheckActionAllow,
+		Locations: []nmdata.GeoLocation{{CountryCode: "DE"}},
+	}})
+
+	stayAllowed := diffFrom(
+		nbpeer.PeerSystemMeta{}, nbpeer.PeerSystemMeta{},
+		nbpeer.Location{CountryCode: "DE", CityName: "Berlin"},
+		nbpeer.Location{CountryCode: "DE", CityName: "Munich"},
+	)
+	assert.False(t, metaDiffAffectsPosture(stayAllowed, c))
+
+	moveOut := diffFrom(
+		nbpeer.PeerSystemMeta{}, nbpeer.PeerSystemMeta{},
+		nbpeer.Location{CountryCode: "DE"},
+		nbpeer.Location{CountryCode: "FR"},
+	)
+	assert.True(t, metaDiffAffectsPosture(moveOut, c))
+}
+
+func TestMetaDiffAffectsPosture_PeerNetworkRange_ConnectionIP(t *testing.T) {
+	c := postureBundle(nmdata.ChecksDefinition{PeerNetworkRangeCheck: &nmdata.PeerNetworkRangeCheck{
+		Action: posture.CheckActionAllow,
+		Ranges: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
+	}})
+
+	movesOutOfRange := diffFrom(
+		nbpeer.PeerSystemMeta{}, nbpeer.PeerSystemMeta{},
+		nbpeer.Location{ConnectionIP: net.ParseIP("10.1.2.3")},
+		nbpeer.Location{ConnectionIP: net.ParseIP("8.8.8.8")},
+	)
+	assert.True(t, metaDiffAffectsPosture(movesOutOfRange, c))
+
+	staysInRange := diffFrom(
+		nbpeer.PeerSystemMeta{}, nbpeer.PeerSystemMeta{},
+		nbpeer.Location{ConnectionIP: net.ParseIP("10.1.2.3")},
+		nbpeer.Location{ConnectionIP: net.ParseIP("10.9.9.9")},
+	)
+	assert.False(t, metaDiffAffectsPosture(staysInRange, c))
+}
+
+func TestMetaDiffAffectsPosture_IrrelevantFieldChange(t *testing.T) {
+	c := postureBundle(nmdata.ChecksDefinition{
+		NBVersionCheck:   &nmdata.NBVersionCheck{MinVersion: "1.0.0"},
+		GeoLocationCheck: &nmdata.GeoLocationCheck{Action: posture.CheckActionAllow, Locations: []nmdata.GeoLocation{{CountryCode: "DE"}}},
+	})
+
+	diff := diffFrom(
+		nbpeer.PeerSystemMeta{Hostname: "old", WtVersion: "1.5.0"},
+		nbpeer.PeerSystemMeta{Hostname: "new", WtVersion: "1.5.0"},
+		nbpeer.Location{CountryCode: "DE"}, nbpeer.Location{CountryCode: "DE"},
+	)
+	assert.False(t, metaDiffAffectsPosture(diff, c))
+}
+
+func TestMetaDiffAffectsPosture_NoChecks(t *testing.T) {
+	diff := diffFrom(
+		nbpeer.PeerSystemMeta{WtVersion: "1.0.0"},
+		nbpeer.PeerSystemMeta{WtVersion: "2.0.0"},
+		nbpeer.Location{}, nbpeer.Location{},
+	)
+	assert.False(t, metaDiffAffectsPosture(diff, nil))
+}
+
+func TestProcessPeerPostureChecks(t *testing.T) {
+	policy := &types.Policy{
+		Enabled:             true,
+		SourcePostureChecks: []string{"pc1"},
+		Rules: []*types.PolicyRule{
+			{Enabled: false, Sources: []string{"g-disabled"}, SourceResource: types.Resource{ID: "peer-disabled", Type: types.ResourceTypePeer}},
+			{Enabled: true, Sources: []string{"g-src"}, Destinations: []string{"g-dst"}},
+			{Enabled: true, SourceResource: types.Resource{ID: "peer-direct", Type: types.ResourceTypePeer}, Destinations: []string{"g-dst"}},
+			{Enabled: true, SourceResource: types.Resource{ID: "peer-as-host", Type: types.ResourceTypeHost}, Destinations: []string{"g-dst"}},
+		},
+	}
+
+	assert.Equal(t, []string{"pc1"}, processPeerPostureChecks(policy, "peer-in-group", []string{"g-src"}), "source group member")
+	assert.Equal(t, []string{"pc1"}, processPeerPostureChecks(policy, "peer-direct", nil), "direct source peer")
+	assert.Empty(t, processPeerPostureChecks(policy, "peer-elsewhere", []string{"g-dst"}), "destination-only peer")
+	assert.Empty(t, processPeerPostureChecks(policy, "peer-disabled", []string{"g-disabled"}), "disabled rule")
+	assert.Empty(t, processPeerPostureChecks(policy, "peer-as-host", nil), "source resource of a non-peer type")
+}
diff --git a/management/server/peer_test.go b/management/server/peer_test.go
index 80d270e98..22f2b9b6f 100644
--- a/management/server/peer_test.go
+++ b/management/server/peer_test.go
@@ -16,11 +16,11 @@ import (
 	"testing"
 	"time"
 
-	"go.uber.org/mock/gomock"
 	"github.com/rs/xid"
 	log "github.com/sirupsen/logrus"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+	"go.uber.org/mock/gomock"
 	"golang.org/x/exp/maps"
 	"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
 
@@ -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"),
@@ -1169,18 +1170,18 @@ func TestToSyncResponse(t *testing.T) {
 		},
 	}
 	dnsName := "example.com"
-	checks := []*posture.Checks{
+	checks := []*nmdata.PostureChecks{
 		{
-			Checks: posture.ChecksDefinition{
-				ProcessCheck: &posture.ProcessCheck{
-					Processes: []posture.Process{{LinuxPath: "/usr/bin/netbird"}},
+			Checks: nmdata.ChecksDefinition{
+				ProcessCheck: &nmdata.ProcessCheck{
+					Processes: []nmdata.Process{{LinuxPath: "/usr/bin/netbird"}},
 				},
 			},
 		},
 	}
 	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/posture/affects_posture_test.go b/management/server/posture/affects_posture_test.go
deleted file mode 100644
index 6aa54d892..000000000
--- a/management/server/posture/affects_posture_test.go
+++ /dev/null
@@ -1,202 +0,0 @@
-package posture
-
-import (
-	"context"
-	"net"
-	"net/netip"
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-
-	nbpeer "github.com/netbirdio/netbird/management/server/peer"
-)
-
-// diffFrom builds a MetaDiff from the old/new snapshots AffectsPosture replays against.
-func diffFrom(oldMeta, newMeta nbpeer.PeerSystemMeta, oldLoc, newLoc nbpeer.Location) *nbpeer.MetaDiff {
-	return &nbpeer.MetaDiff{
-		OldMeta:     oldMeta,
-		NewMeta:     newMeta,
-		OldLocation: oldLoc,
-		NewLocation: newLoc,
-	}
-}
-
-func checks(def ChecksDefinition) []*Checks {
-	return []*Checks{{Checks: def}}
-}
-
-func TestAffectsPosture_NilDiff(t *testing.T) {
-	assert.False(t, AffectsPosture(context.Background(), nil, checks(ChecksDefinition{
-		NBVersionCheck: &NBVersionCheck{MinVersion: "1.0.0"},
-	})))
-}
-
-func TestAffectsPosture_NBVersion(t *testing.T) {
-	c := checks(ChecksDefinition{NBVersionCheck: &NBVersionCheck{MinVersion: "1.2.0"}})
-
-	tests := []struct {
-		name           string
-		oldVer, newVer string
-		want           bool
-	}{
-		{"both above min, no flip", "1.3.0", "1.4.0", false},
-		{"both below min, no flip", "1.0.0", "1.1.0", false},
-		{"crosses up below->above", "1.1.0", "1.3.0", true},
-		{"crosses down above->below", "1.3.0", "1.1.0", true},
-		{"unparsable old only -> flip", "garbage", "1.3.0", true},
-		{"unparsable both -> no flip", "garbage", "junk", false},
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			diff := diffFrom(
-				nbpeer.PeerSystemMeta{WtVersion: tt.oldVer},
-				nbpeer.PeerSystemMeta{WtVersion: tt.newVer},
-				nbpeer.Location{}, nbpeer.Location{},
-			)
-			assert.Equal(t, tt.want, AffectsPosture(context.Background(), diff, c))
-		})
-	}
-}
-
-func TestAffectsPosture_OSVersion_KernelBumpWithinMin(t *testing.T) {
-	c := checks(ChecksDefinition{OSVersionCheck: &OSVersionCheck{
-		Linux: &MinKernelVersionCheck{MinKernelVersion: "5.0.0"},
-	}})
-
-	// Kernel moves but stays above the minimum: verdict stays pass -> not affected.
-	withinMin := diffFrom(
-		nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "5.10.0-arch1"},
-		nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "5.15.0-arch2"},
-		nbpeer.Location{}, nbpeer.Location{},
-	)
-	assert.False(t, AffectsPosture(context.Background(), withinMin, c))
-
-	// Kernel drops below the minimum: verdict flips pass -> fail -> affected.
-	crossesDown := diffFrom(
-		nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "5.10.0-arch1"},
-		nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "4.19.0-arch1"},
-		nbpeer.Location{}, nbpeer.Location{},
-	)
-	assert.True(t, AffectsPosture(context.Background(), crossesDown, c))
-}
-
-func TestAffectsPosture_OSVersion_GoOSSwitchFlipsVerdict(t *testing.T) {
-	// Only Linux is constrained. An OS outside the switch (freebsd) passes; switching to a
-	// failing linux kernel flips the verdict pass -> fail.
-	c := checks(ChecksDefinition{OSVersionCheck: &OSVersionCheck{
-		Linux: &MinKernelVersionCheck{MinKernelVersion: "6.0.0"},
-	}})
-
-	diff := diffFrom(
-		nbpeer.PeerSystemMeta{GoOS: "freebsd"},
-		nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "4.19.0"},
-		nbpeer.Location{}, nbpeer.Location{},
-	)
-	assert.True(t, AffectsPosture(context.Background(), diff, c))
-}
-
-func TestAffectsPosture_Process_GoOSSwitchFlipsVerdict(t *testing.T) {
-	// Process runs at a linux path. Switching GoOS to windows (no WindowsPath configured)
-	// flips the verdict.
-	c := checks(ChecksDefinition{ProcessCheck: &ProcessCheck{
-		Processes: []Process{{LinuxPath: "/usr/bin/foo"}},
-	}})
-
-	files := []nbpeer.File{{Path: "/usr/bin/foo", ProcessIsRunning: true}}
-	diff := diffFrom(
-		nbpeer.PeerSystemMeta{GoOS: "linux", Files: files},
-		nbpeer.PeerSystemMeta{GoOS: "windows", Files: files},
-		nbpeer.Location{}, nbpeer.Location{},
-	)
-	assert.True(t, AffectsPosture(context.Background(), diff, c))
-}
-
-func TestAffectsPosture_Process_UnrelatedFileChange(t *testing.T) {
-	// A tracked process stays running while an unrelated file is added: the verdict does
-	// not move, so posture is not affected.
-	c := checks(ChecksDefinition{ProcessCheck: &ProcessCheck{
-		Processes: []Process{{LinuxPath: "/usr/bin/foo"}},
-	}})
-
-	diff := diffFrom(
-		nbpeer.PeerSystemMeta{GoOS: "linux", Files: []nbpeer.File{
-			{Path: "/usr/bin/foo", ProcessIsRunning: true},
-		}},
-		nbpeer.PeerSystemMeta{GoOS: "linux", Files: []nbpeer.File{
-			{Path: "/usr/bin/foo", ProcessIsRunning: true},
-			{Path: "/usr/bin/bar", ProcessIsRunning: true},
-		}},
-		nbpeer.Location{}, nbpeer.Location{},
-	)
-	assert.False(t, AffectsPosture(context.Background(), diff, c))
-}
-
-func TestAffectsPosture_GeoLocation(t *testing.T) {
-	c := checks(ChecksDefinition{GeoLocationCheck: &GeoLocationCheck{
-		Action:    CheckActionAllow,
-		Locations: []Location{{CountryCode: "DE"}},
-	}})
-
-	// Moving within allowed countries keeps the verdict; moving out flips it.
-	stayAllowed := diffFrom(
-		nbpeer.PeerSystemMeta{}, nbpeer.PeerSystemMeta{},
-		nbpeer.Location{CountryCode: "DE", CityName: "Berlin"},
-		nbpeer.Location{CountryCode: "DE", CityName: "Munich"},
-	)
-	assert.False(t, AffectsPosture(context.Background(), stayAllowed, c))
-
-	moveOut := diffFrom(
-		nbpeer.PeerSystemMeta{}, nbpeer.PeerSystemMeta{},
-		nbpeer.Location{CountryCode: "DE"},
-		nbpeer.Location{CountryCode: "FR"},
-	)
-	assert.True(t, AffectsPosture(context.Background(), moveOut, c))
-}
-
-func TestAffectsPosture_PeerNetworkRange_ConnectionIP(t *testing.T) {
-	// The check reads the connection IP. Moving out of the allowed range flips the verdict;
-	// moving within it does not.
-	_, allowed, _ := net.ParseCIDR("10.0.0.0/8")
-	c := checks(ChecksDefinition{PeerNetworkRangeCheck: &PeerNetworkRangeCheck{
-		Action: CheckActionAllow,
-		Ranges: []netip.Prefix{netip.MustParsePrefix(allowed.String())},
-	}})
-
-	movesOutOfRange := diffFrom(
-		nbpeer.PeerSystemMeta{}, nbpeer.PeerSystemMeta{},
-		nbpeer.Location{ConnectionIP: net.ParseIP("10.1.2.3")},
-		nbpeer.Location{ConnectionIP: net.ParseIP("8.8.8.8")},
-	)
-	assert.True(t, AffectsPosture(context.Background(), movesOutOfRange, c))
-
-	staysInRange := diffFrom(
-		nbpeer.PeerSystemMeta{}, nbpeer.PeerSystemMeta{},
-		nbpeer.Location{ConnectionIP: net.ParseIP("10.1.2.3")},
-		nbpeer.Location{ConnectionIP: net.ParseIP("10.9.9.9")},
-	)
-	assert.False(t, AffectsPosture(context.Background(), staysInRange, c))
-}
-
-func TestAffectsPosture_IrrelevantFieldChange(t *testing.T) {
-	// Hostname changes but no check reads it: not affected even with checks present.
-	c := checks(ChecksDefinition{
-		NBVersionCheck:   &NBVersionCheck{MinVersion: "1.0.0"},
-		GeoLocationCheck: &GeoLocationCheck{Action: CheckActionAllow, Locations: []Location{{CountryCode: "DE"}}},
-	})
-
-	diff := diffFrom(
-		nbpeer.PeerSystemMeta{Hostname: "old", WtVersion: "1.5.0"},
-		nbpeer.PeerSystemMeta{Hostname: "new", WtVersion: "1.5.0"},
-		nbpeer.Location{CountryCode: "DE"}, nbpeer.Location{CountryCode: "DE"},
-	)
-	assert.False(t, AffectsPosture(context.Background(), diff, c))
-}
-
-func TestAffectsPosture_NoChecks(t *testing.T) {
-	diff := diffFrom(
-		nbpeer.PeerSystemMeta{WtVersion: "1.0.0"},
-		nbpeer.PeerSystemMeta{WtVersion: "2.0.0"},
-		nbpeer.Location{}, nbpeer.Location{},
-	)
-	assert.False(t, AffectsPosture(context.Background(), diff, nil))
-}
diff --git a/management/server/posture/checks.go b/management/server/posture/checks.go
index 72b719252..c38136d1c 100644
--- a/management/server/posture/checks.go
+++ b/management/server/posture/checks.go
@@ -7,7 +7,6 @@ import (
 	"regexp"
 
 	"github.com/hashicorp/go-version"
-	log "github.com/sirupsen/logrus"
 
 	nbpeer "github.com/netbirdio/netbird/management/server/peer"
 	"github.com/netbirdio/netbird/shared/management/http/api"
@@ -55,46 +54,6 @@ type Checks struct {
 	Checks ChecksDefinition `gorm:"serializer:json"`
 }
 
-// AffectsPosture reports whether the change in diff flips the verdict of any check. It
-// replays each check against the peer's old and new state and compares verdicts, so a
-// change that moves a field but stays the right side of a threshold (e.g. a kernel bump
-// still above the minimum) does not force a re-evaluation. See verdictChanged for how an
-// evaluation error counts.
-func AffectsPosture(ctx context.Context, diff *nbpeer.MetaDiff, checks []*Checks) bool {
-	if diff == nil {
-		return false
-	}
-
-	oldPeer := nbpeer.Peer{Meta: diff.OldMeta, Location: diff.OldLocation}
-	newPeer := nbpeer.Peer{Meta: diff.NewMeta, Location: diff.NewLocation}
-
-	for _, c := range checks {
-		for _, check := range c.GetChecks() {
-			if verdictChanged(ctx, check, oldPeer, newPeer) {
-				return true
-			}
-		}
-	}
-	return false
-}
-
-// verdictChanged replays check against old and new state and reports whether the verdict
-// differs. Like callers, it treats an evaluation error as deny: two errors are the same
-// verdict (no change), an error on one side only is a flip.
-func verdictChanged(ctx context.Context, check Check, oldPeer, newPeer nbpeer.Peer) bool {
-	oldPass, oldErr := check.Check(ctx, oldPeer)
-	newPass, newErr := check.Check(ctx, newPeer)
-
-	oldVerdict := oldPass && (oldErr == nil)
-	newVerdict := newPass && (newErr == nil)
-	changed := oldVerdict != newVerdict
-
-	log.WithContext(ctx).Tracef("posture check %s replay: verdict %t -> %t (changed=%t), errs: %v -> %v",
-		check.Name(), oldVerdict, newVerdict, changed, oldErr, newErr)
-
-	return changed
-}
-
 // ChecksDefinition contains definition of actual check
 type ChecksDefinition struct {
 	NBVersionCheck        *NBVersionCheck        `json:",omitempty"`
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..d689b0175 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
@@ -994,13 +909,13 @@ func (a *Account) GetPeerConnectionResources(ctx context.Context, peer *nbpeer.P
 			var peerInSources, peerInDestinations bool
 
 			if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" {
-				sourcePeers, peerInSources = a.getPeerFromResource(rule.SourceResource, peer.ID)
+				sourcePeers, peerInSources = a.getPeerFromResource(ctx, rule.SourceResource, peer.ID, policy.SourcePostureChecks, validatedPeersMap)
 			} else {
 				sourcePeers, peerInSources = a.getAllPeersFromGroups(ctx, rule.Sources, peer.ID, policy.SourcePostureChecks, validatedPeersMap)
 			}
 
 			if rule.DestinationResource.Type == ResourceTypePeer && rule.DestinationResource.ID != "" {
-				destinationPeers, peerInDestinations = a.getPeerFromResource(rule.DestinationResource, peer.ID)
+				destinationPeers, peerInDestinations = a.getPeerFromResource(ctx, rule.DestinationResource, peer.ID, nil, validatedPeersMap)
 			} else {
 				destinationPeers, peerInDestinations = a.getAllPeersFromGroups(ctx, rule.Destinations, peer.ID, nil, validatedPeersMap)
 			}
@@ -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),
@@ -1186,8 +1120,17 @@ func ruleHasDestination(rule *PolicyRule, peerID string, peerGroupIDs map[string
 // Important: Posture checks are applicable only to source group peers,
 // for destination group peers, call this method with an empty list of sourcePostureChecksIDs
 func (a *Account) getAllPeersFromGroups(ctx context.Context, groups []string, peerID string, sourcePostureChecksIDs []string, validatedPeersMap map[string]struct{}) ([]*nbpeer.Peer, bool) {
+	return a.filterPolicyPeers(ctx, a.getUniquePeerIDsFromGroupsIDs(ctx, groups), peerID, sourcePostureChecksIDs, validatedPeersMap)
+}
+
+// getPeerFromResource resolves a rule side that names a peer directly, admitting it
+// like a member of a group holding only that peer.
+func (a *Account) getPeerFromResource(ctx context.Context, resource Resource, peerID string, sourcePostureChecksIDs []string, validatedPeersMap map[string]struct{}) ([]*nbpeer.Peer, bool) {
+	return a.filterPolicyPeers(ctx, []string{resource.ID}, peerID, sourcePostureChecksIDs, validatedPeersMap)
+}
+
+func (a *Account) filterPolicyPeers(ctx context.Context, uniquePeerIDs []string, peerID string, sourcePostureChecksIDs []string, validatedPeersMap map[string]struct{}) ([]*nbpeer.Peer, bool) {
 	peerInGroups := false
-	uniquePeerIDs := a.getUniquePeerIDsFromGroupsIDs(ctx, groups)
 	filteredPeers := make([]*nbpeer.Peer, 0, len(uniquePeerIDs))
 	for _, p := range uniquePeerIDs {
 		peer, ok := a.Peers[p]
@@ -1216,19 +1159,6 @@ func (a *Account) getAllPeersFromGroups(ctx context.Context, groups []string, pe
 	return filteredPeers, peerInGroups
 }
 
-func (a *Account) getPeerFromResource(resource Resource, peerID string) ([]*nbpeer.Peer, bool) {
-	peer := a.GetPeer(resource.ID)
-	if peer == nil {
-		return []*nbpeer.Peer{}, false
-	}
-
-	if peer.ID == peerID {
-		return []*nbpeer.Peer{}, true
-	}
-
-	return []*nbpeer.Peer{peer}, false
-}
-
 // validatePostureChecksOnPeer validates the posture checks on a peer
 func (a *Account) validatePostureChecksOnPeer(ctx context.Context, sourcePostureChecksID []string, peerID string) bool {
 	peer, ok := a.Peers[peerID]
@@ -1284,7 +1214,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 +1241,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 +1450,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 +1550,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 +1582,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..d554bfe80
--- /dev/null
+++ b/management/server/types/account_networkmapdata.go
@@ -0,0 +1,623 @@
+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),
+	}
+}
+
+// TwinPostureChecksList converts posture checks to their slim nmdata twins.
+func TwinPostureChecksList(checks []*posture.Checks) []*nmdata.PostureChecks {
+	out := make([]*nmdata.PostureChecks, 0, len(checks))
+	for _, pc := range checks {
+		out = append(out, TwinPostureChecks(pc))
+	}
+	return out
+}
+
+// TwinPostureChecks converts posture checks to their slim nmdata twin.
+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..35b5f7149 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)
 			}
 		})
 	}
@@ -875,6 +875,89 @@ func TestComponents_PeerAsSourceResource(t *testing.T) {
 	assert.True(t, has443, "peer-0 as source resource should have port 443 rule")
 }
 
+func hasFirewallRuleTo(nm *types.NetworkMap, peerIP, port string) bool {
+	for _, rule := range nm.FirewallRules {
+		if rule.PeerIP == peerIP && rule.Port == port {
+			return true
+		}
+	}
+	return false
+}
+
+// TestComponents_PeerAsSourceResource_PostureChecks verifies that a directly referenced
+// source peer is gated by the policy's posture checks like a member of a group holding only
+// that peer: peer-1 (0.25.0) fails the 0.26.0 minimum, peer-2 (0.40.0) passes.
+func TestComponents_PeerAsSourceResource_PostureChecks(t *testing.T) {
+	account, validatedPeers := scalableTestAccountWithoutDefaultPolicy(20, 2)
+
+	for _, sourcePeerID := range []string{"peer-1", "peer-2"} {
+		account.Policies = append(account.Policies, &types.Policy{
+			ID: "policy-peer-src-" + sourcePeerID, Name: "Peer Source " + sourcePeerID, Enabled: true, AccountID: "test-account",
+			SourcePostureChecks: []string{"posture-check-ver"},
+			Rules: []*types.PolicyRule{{
+				ID: "rule-peer-src-" + sourcePeerID, Enabled: true,
+				Action:         types.PolicyTrafficActionAccept,
+				Protocol:       types.PolicyRuleProtocolTCP,
+				Bidirectional:  true,
+				Ports:          []string{"9443"},
+				SourceResource: types.Resource{ID: sourcePeerID, Type: types.ResourceTypePeer},
+				Destinations:   []string{"group-0"},
+			}},
+		})
+	}
+
+	nm0 := componentsNetworkMap(account, "peer-0", validatedPeers)
+	require.NotNil(t, nm0)
+	assert.False(t, hasFirewallRuleTo(nm0, "100.64.0.1", "9443"), "destination must not see the direct source peer failing the posture check")
+	assert.True(t, hasFirewallRuleTo(nm0, "100.64.0.2", "9443"), "destination must see the direct source peer passing the posture check")
+
+	nm1 := componentsNetworkMap(account, "peer-1", validatedPeers)
+	require.NotNil(t, nm1)
+	assert.False(t, hasFirewallRuleTo(nm1, "100.64.0.0", "9443"), "a direct source peer failing the posture check gets no policy connectivity")
+
+	nm2 := componentsNetworkMap(account, "peer-2", validatedPeers)
+	require.NotNil(t, nm2)
+	assert.True(t, hasFirewallRuleTo(nm2, "100.64.0.0", "9443"), "a direct source peer passing the posture check gets policy connectivity")
+}
+
+// TestComponents_PeerAsResource_Unvalidated verifies that a directly referenced peer is
+// subject to approval like a group member, whether it is the rule's source or destination.
+func TestComponents_PeerAsResource_Unvalidated(t *testing.T) {
+	account, validatedPeers := scalableTestAccountWithoutDefaultPolicy(20, 2)
+	delete(validatedPeers, "peer-2")
+
+	account.Policies = append(account.Policies,
+		&types.Policy{
+			ID: "policy-unval-src", Name: "Unvalidated Source", Enabled: true, AccountID: "test-account",
+			Rules: []*types.PolicyRule{{
+				ID: "rule-unval-src", Enabled: true,
+				Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolTCP, Bidirectional: true,
+				Ports:          []string{"9443"},
+				SourceResource: types.Resource{ID: "peer-2", Type: types.ResourceTypePeer},
+				Destinations:   []string{"group-0"},
+			}},
+		},
+		&types.Policy{
+			ID: "policy-unval-dst", Name: "Unvalidated Destination", Enabled: true, AccountID: "test-account",
+			Rules: []*types.PolicyRule{{
+				ID: "rule-unval-dst", Enabled: true,
+				Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolTCP, Bidirectional: true,
+				Ports:               []string{"9444"},
+				Sources:             []string{"group-0"},
+				DestinationResource: types.Resource{ID: "peer-2", Type: types.ResourceTypePeer},
+			}},
+		},
+	)
+
+	nm0 := componentsNetworkMap(account, "peer-0", validatedPeers)
+	require.NotNil(t, nm0)
+	assert.False(t, hasFirewallRuleTo(nm0, "100.64.0.2", "9443"), "an unvalidated direct source peer must not be admitted")
+	assert.False(t, hasFirewallRuleTo(nm0, "100.64.0.2", "9444"), "an unvalidated direct destination peer must not be admitted")
+	for _, p := range nm0.Peers {
+		assert.NotEqual(t, "peer-2", p.ID, "an unvalidated direct peer must not be shipped as a remote peer")
+	}
+}
+
 // TestComponents_PeerAsDestinationResource verifies that a policy with DestinationResource.Type=Peer
 // targets only that specific peer as the destination.
 func TestComponents_PeerAsDestinationResource(t *testing.T) {
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 07ec39d0d..02358ebc2 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/proxy/Dockerfile b/proxy/Dockerfile
index 22c4cbfaa..5944d944e 100644
--- a/proxy/Dockerfile
+++ b/proxy/Dockerfile
@@ -1,4 +1,4 @@
-FROM golang:1.25-alpine AS builder
+FROM golang:1.26.7-alpine AS builder
 WORKDIR /app
 
 RUN echo "netbird:x:1000:1000:netbird:/var/lib/netbird:/sbin/nologin" > /tmp/passwd && \
diff --git a/proxy/Dockerfile.multistage b/proxy/Dockerfile.multistage
index 4f360a811..d1db32296 100644
--- a/proxy/Dockerfile.multistage
+++ b/proxy/Dockerfile.multistage
@@ -1,4 +1,4 @@
-FROM golang:1.25-alpine AS builder
+FROM golang:1.26.7-alpine AS builder
 WORKDIR /app
 
 COPY go.mod go.sum ./
diff --git a/shared/auth/jwt/validator.go b/shared/auth/jwt/validator.go
index cf18b2cf6..62e127751 100644
--- a/shared/auth/jwt/validator.go
+++ b/shared/auth/jwt/validator.go
@@ -289,36 +289,64 @@ func getPublicKey(token *jwt.Token, jwks *Jwks) (interface{}, error) {
 	return nil, errKeyNotFound
 }
 
-func getPublicKeyFromECDSA(jwk JSONWebKey) (publicKey *ecdsa.PublicKey, err error) {
+func curveFromName(crv string) (elliptic.Curve, error) {
+	switch crv {
+	case p256:
+		return elliptic.P256(), nil
+	case p384:
+		return elliptic.P384(), nil
+	case p521:
+		return elliptic.P521(), nil
+	default:
+		return nil, fmt.Errorf("unsupported elliptic curve %q", crv)
+	}
+}
+
+func getPublicKeyFromECDSA(jwk JSONWebKey) (*ecdsa.PublicKey, error) {
 	if jwk.X == "" || jwk.Y == "" || jwk.Crv == "" {
 		return nil, fmt.Errorf("ecdsa key incomplete")
 	}
 
-	var xCoordinate []byte
-	if xCoordinate, err = base64.RawURLEncoding.DecodeString(jwk.X); err != nil {
+	curve, err := curveFromName(jwk.Crv)
+	if err != nil {
 		return nil, err
 	}
 
-	var yCoordinate []byte
-	if yCoordinate, err = base64.RawURLEncoding.DecodeString(jwk.Y); err != nil {
-		return nil, err
+	xCoordinate, err := base64.RawURLEncoding.DecodeString(jwk.X)
+	if err != nil {
+		return nil, fmt.Errorf("decode ecdsa x coordinate: %w", err)
 	}
 
-	publicKey = &ecdsa.PublicKey{}
-
-	var curve elliptic.Curve
-	switch jwk.Crv {
-	case p256:
-		curve = elliptic.P256()
-	case p384:
-		curve = elliptic.P384()
-	case p521:
-		curve = elliptic.P521()
+	yCoordinate, err := base64.RawURLEncoding.DecodeString(jwk.Y)
+	if err != nil {
+		return nil, fmt.Errorf("decode ecdsa y coordinate: %w", err)
 	}
 
-	publicKey.Curve = curve
-	publicKey.X = big.NewInt(0).SetBytes(xCoordinate)
-	publicKey.Y = big.NewInt(0).SetBytes(yCoordinate)
+	var x, y big.Int
+	x.SetBytes(xCoordinate)
+	y.SetBytes(yCoordinate)
+
+	bits := curve.Params().BitSize
+	if x.BitLen() > bits {
+		return nil, fmt.Errorf("ecdsa x coordinate is %d bits, exceeds curve %s field size of %d bits", x.BitLen(), jwk.Crv, bits)
+	}
+	if y.BitLen() > bits {
+		return nil, fmt.Errorf("ecdsa y coordinate is %d bits, exceeds curve %s field size of %d bits", y.BitLen(), jwk.Crv, bits)
+	}
+
+	// Round up: P-521's field is 521 bits, so a coordinate needs 66 bytes, not 65.
+	size := (bits + 7) / 8
+
+	// Assemble the SEC 1 uncompressed point (0x04 || X || Y)
+	point := make([]byte, 1+2*size)
+	point[0] = 4
+	x.FillBytes(point[1 : 1+size])
+	y.FillBytes(point[1+size:])
+
+	publicKey, err := ecdsa.ParseUncompressedPublicKey(curve, point)
+	if err != nil {
+		return nil, fmt.Errorf("parse ecdsa public key: %w", err)
+	}
 
 	return publicKey, nil
 }
diff --git a/shared/auth/jwt/validator_test.go b/shared/auth/jwt/validator_test.go
new file mode 100644
index 000000000..a5b3f4a39
--- /dev/null
+++ b/shared/auth/jwt/validator_test.go
@@ -0,0 +1,214 @@
+package jwt
+
+import (
+	"bytes"
+	"context"
+	"crypto/ecdsa"
+	"crypto/elliptic"
+	"crypto/rand"
+	"encoding/base64"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+
+	"github.com/golang-jwt/jwt/v5"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// ecdsaJWK builds a JWK for pub using uncompressed-point encoding
+func ecdsaJWK(t *testing.T, kid string, pub *ecdsa.PublicKey, crv string, size int) JSONWebKey {
+	t.Helper()
+
+	point, err := pub.Bytes()
+	require.NoError(t, err)
+	require.Len(t, point, 1+2*size)
+	require.Equal(t, byte(4), point[0], "expected uncompressed point")
+
+	return JSONWebKey{
+		Kty: "EC",
+		Kid: kid,
+		Use: "sig",
+		Crv: crv,
+		X:   base64.RawURLEncoding.EncodeToString(point[1 : 1+size]),
+		Y:   base64.RawURLEncoding.EncodeToString(point[1+size:]),
+	}
+}
+
+func TestGetPublicKeyFromECDSA_RoundTrip(t *testing.T) {
+	tests := []struct {
+		crv   string
+		curve elliptic.Curve
+		size  int
+	}{
+		{p256, elliptic.P256(), 32},
+		{p384, elliptic.P384(), 48},
+		{p521, elliptic.P521(), 66},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.crv, func(t *testing.T) {
+			priv, err := ecdsa.GenerateKey(tc.curve, rand.Reader)
+			require.NoError(t, err)
+
+			got, err := getPublicKeyFromECDSA(ecdsaJWK(t, "kid", &priv.PublicKey, tc.crv, tc.size))
+			require.NoError(t, err)
+			assert.True(t, priv.PublicKey.Equal(got), "parsed key differs from the original")
+		})
+	}
+}
+
+// TestGetPublicKeyFromECDSA_ShortCoordinate covers IdPs that strip leading zero
+// bytes from a coordinate instead of padding to the curve's field size.
+func TestGetPublicKeyFromECDSA_ShortCoordinate(t *testing.T) {
+	var (
+		priv  *ecdsa.PrivateKey
+		point []byte
+	)
+	for i := 0; i < 10000; i++ {
+		key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+		require.NoError(t, err)
+
+		p, err := key.PublicKey.Bytes()
+		require.NoError(t, err)
+
+		if p[1] == 0 || p[33] == 0 {
+			priv, point = key, p
+			break
+		}
+	}
+	require.NotNil(t, priv, "no key with a leading zero coordinate byte was generated")
+
+	jwk := JSONWebKey{
+		Kty: "EC",
+		Kid: "kid",
+		Crv: p256,
+		X:   base64.RawURLEncoding.EncodeToString(bytes.TrimLeft(point[1:33], "\x00")),
+		Y:   base64.RawURLEncoding.EncodeToString(bytes.TrimLeft(point[33:], "\x00")),
+	}
+
+	got, err := getPublicKeyFromECDSA(jwk)
+	require.NoError(t, err)
+	assert.True(t, priv.PublicKey.Equal(got))
+}
+
+func TestGetPublicKeyFromECDSA_Invalid(t *testing.T) {
+	priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	require.NoError(t, err)
+	valid := ecdsaJWK(t, "kid", &priv.PublicKey, p256, 32)
+
+	offCurve := valid
+	x, err := base64.RawURLEncoding.DecodeString(valid.X)
+	require.NoError(t, err)
+	x[31] ^= 0xff
+	offCurve.X = base64.RawURLEncoding.EncodeToString(x)
+
+	// 33 non-zero bytes is 264 bits, past P-256's 256-bit field.
+	oversized := valid
+	oversized.X = base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{0xff}, 33))
+
+	// P-521 coordinates occupy 66 bytes but only 521 bits, so a full 66-byte
+	// 0xff value (528 bits) is over the field size without being over the byte
+	// length. Only a bit-length bound catches this.
+	overP521 := JSONWebKey{
+		Kty: "EC",
+		Crv: p521,
+		X:   base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{0xff}, 66)),
+		Y:   base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{0xff}, 66)),
+	}
+
+	zeroPoint := valid
+	zeroPoint.X = base64.RawURLEncoding.EncodeToString(make([]byte, 32))
+	zeroPoint.Y = base64.RawURLEncoding.EncodeToString(make([]byte, 32))
+
+	tests := []struct {
+		name        string
+		jwk         JSONWebKey
+		errContains string
+	}{
+		{name: "missing crv", jwk: JSONWebKey{Kty: "EC", X: valid.X, Y: valid.Y}},
+		{name: "missing x", jwk: JSONWebKey{Kty: "EC", Crv: p256, Y: valid.Y}},
+		{name: "unsupported curve", jwk: JSONWebKey{Kty: "EC", Crv: "P-224", X: valid.X, Y: valid.Y}, errContains: "unsupported elliptic curve"},
+		{name: "undecodable x", jwk: JSONWebKey{Kty: "EC", Crv: p256, X: "!!not base64!!!", Y: valid.Y}, errContains: "decode ecdsa x coordinate"},
+		{name: "coordinate over field size", jwk: oversized, errContains: "exceeds curve P-256 field size of 256 bits"},
+		{name: "p521 coordinate over field size", jwk: overP521, errContains: "exceeds curve P-521 field size of 521 bits"},
+		{name: "off-curve point", jwk: offCurve, errContains: "parse ecdsa public key"},
+		{name: "point at infinity", jwk: zeroPoint, errContains: "parse ecdsa public key"},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			key, err := getPublicKeyFromECDSA(tc.jwk)
+			require.Error(t, err)
+			assert.Nil(t, key)
+			if tc.errContains != "" {
+				assert.ErrorContains(t, err, tc.errContains)
+			}
+		})
+	}
+}
+
+// TestValidateAndParse_ECDSA verifies an ES256-signed token end to end, proving
+// the parsed key actually validates signatures.
+func TestValidateAndParse_ECDSA(t *testing.T) {
+	const (
+		kid      = "es256-kid"
+		issuer   = "https://issuer.example.com/"
+		audience = "netbird"
+	)
+
+	priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	require.NoError(t, err)
+
+	jwks, err := json.Marshal(Jwks{Keys: []JSONWebKey{ecdsaJWK(t, kid, &priv.PublicKey, p256, 32)}})
+	require.NoError(t, err)
+
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		_, _ = w.Write(jwks)
+	}))
+	defer srv.Close()
+
+	token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{
+		"iss": issuer,
+		"aud": audience,
+		"sub": "user-1",
+		"iat": time.Now().Add(-time.Minute).Unix(),
+		"exp": time.Now().Add(time.Hour).Unix(),
+	})
+	token.Header["kid"] = kid
+
+	signed, err := token.SignedString(priv)
+	require.NoError(t, err)
+
+	v := NewValidator(issuer, []string{audience}, srv.URL, false)
+
+	parsed, err := v.ValidateAndParse(context.Background(), signed)
+	require.NoError(t, err)
+	require.True(t, parsed.Valid)
+
+	claims, ok := parsed.Claims.(jwt.MapClaims)
+	require.True(t, ok)
+	assert.Equal(t, "user-1", claims["sub"])
+
+	// A token signed by a different key of the same curve must be rejected.
+	other, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	require.NoError(t, err)
+
+	forged := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{
+		"iss": issuer,
+		"aud": audience,
+		"sub": "user-1",
+		"iat": time.Now().Add(-time.Minute).Unix(),
+		"exp": time.Now().Add(time.Hour).Unix(),
+	})
+	forged.Header["kid"] = kid
+
+	forgedSigned, err := forged.SignedString(other)
+	require.NoError(t, err)
+
+	_, err = v.ValidateAndParse(context.Background(), forgedSigned)
+	require.Error(t, err)
+}
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..65e76d097
--- /dev/null
+++ b/shared/management/networkmap/networkmapcompute.go
@@ -0,0 +1,815 @@
+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, peerInSources = nmd.getPeerFromResource(rule.SourceResource, peerID, policy.SourcePostureChecks, postureFailedPeers)
+			} else {
+				sourcePeers, peerInSources = nmd.getPeersFromGroups(rule.Sources, peerID, policy.SourcePostureChecks, postureFailedPeers)
+			}
+
+			if rule.DestinationResource.Type == string(types.ResourceTypePeer) && rule.DestinationResource.ID != "" {
+				destinationPeers, peerInDestinations = nmd.getPeerFromResource(rule.DestinationResource, peerID, nil, postureFailedPeers)
+			} 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 {
+				if !nmd.admitPolicyPeer(pid, sourcePostureChecksIDs, postureFailedPeers) {
+					continue
+				}
+
+				if pid == peerID {
+					peerInGroups = true
+					continue
+				}
+
+				filteredPeerIDs = append(filteredPeerIDs, pid)
+			}
+			return filteredPeerIDs, peerInGroups
+		}
+
+		for _, pid := range group.Peers {
+			if _, seen := seenPeerIds[pid]; seen {
+				continue
+			}
+			seenPeerIds[pid] = struct{}{}
+			if !nmd.admitPolicyPeer(pid, sourcePostureChecksIDs, postureFailedPeers) {
+				continue
+			}
+
+			if pid == peerID {
+				peerInGroups = true
+				continue
+			}
+
+			filteredPeerIDs = append(filteredPeerIDs, pid)
+		}
+	}
+
+	return filteredPeerIDs, peerInGroups
+}
+
+// getPeerFromResource resolves a rule side that names a peer directly, admitting it
+// like a member of a group holding only that peer.
+func (nmd *NetworkMapData) getPeerFromResource(resource nmdata.Resource, peerID string, sourcePostureChecksIDs []string,
+	postureFailedPeers *map[string]map[string]struct{}) ([]string, bool) {
+	if !nmd.admitPolicyPeer(resource.ID, sourcePostureChecksIDs, postureFailedPeers) {
+		return nil, false
+	}
+	if resource.ID == peerID {
+		return nil, true
+	}
+	return []string{resource.ID}, false
+}
+
+// admitPolicyPeer applies the per-peer admission of a rule side: the peer must exist,
+// be validated and pass the rule's posture checks. A failed check is recorded in
+// postureFailedPeers.
+func (nmd *NetworkMapData) admitPolicyPeer(pid string, sourcePostureChecksIDs []string, postureFailedPeers *map[string]map[string]struct{}) bool {
+	peer, ok := nmd.Peers[pid]
+	if !ok || peer == nil {
+		return false
+	}
+
+	if _, ok := nmd.ValidatedPeers[pid]; !ok {
+		return false
+	}
+
+	isValid, pname := nmd.validatePostureChecksOnPeerGetFailed(sourcePostureChecksIDs, pid)
+	if !isValid && len(pname) > 0 {
+		if _, ok := (*postureFailedPeers)[pname]; !ok {
+			(*postureFailedPeers)[pname] = make(map[string]struct{})
+		}
+		(*postureFailedPeers)[pname][pid] = struct{}{}
+		return false
+	}
+	return true
+}
+
+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..ad21fd70f
--- /dev/null
+++ b/shared/management/networkmap/networkmapcompute_test.go
@@ -0,0 +1,1664 @@
+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))
+	})
+
+	// A directly referenced peer is admitted like a member of a group holding only
+	// that peer: the ValidatedPeers gate and the posture checks apply equally.
+	t.Run("unvalidated source resource peer is excluded", 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}, peerIDSet(c.Peers))
+	})
+
+	t.Run("source resource peer failing posture checks is excluded", 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}, peerIDSet(c.Peers))
+		assert.Empty(t, c.PostureFailedPeers)
+	})
+
+	t.Run("direct source peer failure recorded when 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-dst", targetID)
+		checkedRule := newRule(nil, []string{"g-dst"})
+		checkedRule.SourceResource = peerResource(failing.ID)
+		checked := newPolicy("p-checked", checkedRule)
+		checked.SourcePostureChecks = []string{"pc-1"}
+		openRule := newRule(nil, []string{"g-dst"})
+		openRule.SourceResource = peerResource(failing.ID)
+		nmd.Policies = []*nmdata.Policy{checked, newPolicy("p-open", openRule)}
+
+		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("target as source resource failing posture checks gets no 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-dst", dst.ID)
+		rule := newRule(nil, []string{"g-dst"})
+		rule.SourceResource = peerResource(targetID)
+		p := newPolicy("p-1", rule)
+		p.SourcePostureChecks = []string{"pc-1"}
+		nmd.Policies = []*nmdata.Policy{p}
+
+		c := compute(nmd, targetID)
+
+		assert.Empty(t, policyIDs(c.Policies))
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+	})
+
+	t.Run("unvalidated destination resource peer is excluded", 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-src", targetID)
+		rule := newRule([]string{"g-src"}, nil)
+		rule.DestinationResource = peerResource(unval.ID)
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-1", rule)}
+
+		c := compute(nmd, targetID)
+
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+	})
+
+	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..6a6b028c7
--- /dev/null
+++ b/shared/management/networkmap/nmdata/posture.go
@@ -0,0 +1,83 @@
+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
+}
+
+// PostureVerdictChanged reports whether any check in the bundles gives a different
+// verdict for newPeer than for oldPeer. Checks are replayed one by one, so a change
+// that moves a field but stays on the same side of a threshold does not count. An
+// evaluation error is a deny, like in PassesChecks.
+func PostureVerdictChanged(checks []*PostureChecks, oldPeer, newPeer *Peer) bool {
+	for _, pc := range checks {
+		for _, c := range pc.GetChecks() {
+			single := []Check{c}
+			if PassesChecks(single, oldPeer) != PassesChecks(single, newPeer) {
+				return true
+			}
+		}
+	}
+	return false
+}
+
+// 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/posture_test.go b/shared/management/networkmap/nmdata/posture_test.go
new file mode 100644
index 000000000..13e5f268e
--- /dev/null
+++ b/shared/management/networkmap/nmdata/posture_test.go
@@ -0,0 +1,54 @@
+package nmdata
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func bundle(def ChecksDefinition) []*PostureChecks {
+	return []*PostureChecks{{Checks: def}}
+}
+
+func TestPostureVerdictChanged_ErrorCountsAsDeny(t *testing.T) {
+	c := bundle(ChecksDefinition{NBVersionCheck: &NBVersionCheck{MinVersion: "1.2.0"}})
+
+	tests := []struct {
+		name           string
+		oldVer, newVer string
+		want           bool
+	}{
+		{"both above min, no flip", "1.3.0", "1.4.0", false},
+		{"crosses up below->above", "1.1.0", "1.3.0", true},
+		{"unparsable old only -> flip", "garbage", "1.3.0", true},
+		{"unparsable both -> no flip", "garbage", "junk", false},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			oldPeer := &Peer{Meta: PeerSystemMeta{WtVersion: tt.oldVer}}
+			newPeer := &Peer{Meta: PeerSystemMeta{WtVersion: tt.newVer}}
+			assert.Equal(t, tt.want, PostureVerdictChanged(c, oldPeer, newPeer))
+		})
+	}
+}
+
+func TestPostureVerdictChanged_ReplaysEachCheck(t *testing.T) {
+	// Old fails the version check, new fails the kernel check: the bundle denies on
+	// both sides, yet every single check flipped, so the posture must be re-evaluated.
+	c := bundle(ChecksDefinition{
+		NBVersionCheck: &NBVersionCheck{MinVersion: "1.0.0"},
+		OSVersionCheck: &OSVersionCheck{Linux: &MinKernelVersionCheck{MinKernelVersion: "5.0.0"}},
+	})
+	oldPeer := &Peer{Meta: PeerSystemMeta{WtVersion: "0.9.0", GoOS: "linux", KernelVersion: "6.0.0"}}
+	newPeer := &Peer{Meta: PeerSystemMeta{WtVersion: "1.1.0", GoOS: "linux", KernelVersion: "4.0.0"}}
+
+	assert.False(t, c[0].Passes(oldPeer))
+	assert.False(t, c[0].Passes(newPeer))
+	assert.True(t, PostureVerdictChanged(c, oldPeer, newPeer))
+}
+
+func TestPostureVerdictChanged_NoChecks(t *testing.T) {
+	oldPeer := &Peer{Meta: PeerSystemMeta{WtVersion: "1.0.0"}}
+	newPeer := &Peer{Meta: PeerSystemMeta{WtVersion: "2.0.0"}}
+	assert.False(t, PostureVerdictChanged(nil, oldPeer, newPeer))
+}
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..e18db4ec0 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,26 +217,26 @@ 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 != "" {
-				sourcePeers, peerInSources = c.getPeerFromResource(rule.SourceResource, targetPeerID)
+			if rule.SourceResource.Type == string(ResourceTypePeer) && rule.SourceResource.ID != "" {
+				sourcePeers, peerInSources = c.getPeerFromResource(rule.SourceResource, targetPeerID, policy.SourcePostureChecks)
 			} 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)
+			if rule.DestinationResource.Type == string(ResourceTypePeer) && rule.DestinationResource.ID != "" {
+				destinationPeers, peerInDestinations = c.getPeerFromResource(rule.DestinationResource, targetPeerID, nil)
 			} 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,28 @@ 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) {
+	return c.filterPolicyPeers(c.getUniquePeerIDsFromGroupsIDs(groups), peerID, sourcePostureChecksIDs)
+}
+
+// getPeerFromResource resolves a rule side that names a peer directly. The peer is
+// subject to the same admission as a group member, so a direct peer behaves exactly
+// like a group holding only that peer.
+func (c *NetworkMapComponents) getPeerFromResource(resource nmdata.Resource, peerID string, sourcePostureChecksIDs []string) ([]*nmdata.Peer, bool) {
+	return c.filterPolicyPeers([]string{resource.ID}, peerID, sourcePostureChecksIDs)
+}
+
+// filterPolicyPeers admits the peers of one rule side: known to the components and
+// passing the rule's posture checks. It reports the admitted peers other than peerID
+// and whether peerID itself is admitted on that side.
+func (c *NetworkMapComponents) filterPolicyPeers(uniquePeerIDs []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 +440,9 @@ func (c *NetworkMapComponents) getUniquePeerIDsFromGroupsIDs(groups []string) []
 	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
+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)
-		})
-	}
-}