diff --git a/.github/workflows/buf.yml b/.github/workflows/buf.yml new file mode 100644 index 000000000..a993293d4 --- /dev/null +++ b/.github/workflows/buf.yml @@ -0,0 +1,33 @@ +name: protobuf checks +on: + push: + branches: + - main + - "release-*" + pull_request: + paths: + - ".github/workflows/buf.yml" + - "**/buf.yaml" + - "**/buf.lock" + - "**/buf.gen.yaml" + - "**.proto" +permissions: + contents: read + pull-requests: read +jobs: + buf: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: bufbuild/buf-action@8c6a16e16f12ba20b6470afa9c2ba9b5ba8c97c3 # v1.5.0 + with: + push: false + archive: false + pr_comment: false + build: false + lint: false + format: false + breaking: true diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml index 004b78b3e..c93e36e4e 100644 --- a/.github/workflows/golang-test-linux.yml +++ b/.github/workflows/golang-test-linux.yml @@ -730,6 +730,11 @@ jobs: - name: Install modules run: go mod tidy + - name: Run Mage + uses: magefile/mage-action@a662bd8c29d8106879588cfff83b2faf6e6f59db # v4.0.0 + with: + install-only: true + - name: check git status run: git --no-pager diff --exit-code @@ -738,9 +743,7 @@ jobs: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \ NETBIRD_STORE_ENGINE=${{ matrix.store }} \ CI=true \ - go test -tags=integration -coverprofile=coverage.txt \ - -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' \ - -timeout 20m ./management/server/http/... + mage integrationtest:all -gotestflags="-coverprofile=coverage.txt" - name: Upload coverage reports to Codecov if: matrix.arch == 'amd64' diff --git a/.github/workflows/sync-tag.yml b/.github/workflows/sync-tag.yml index 088e538d5..608f3c6d7 100644 --- a/.github/workflows/sync-tag.yml +++ b/.github/workflows/sync-tag.yml @@ -37,3 +37,16 @@ jobs: repo: netbirdio/ios-client token: ${{ secrets.NC_GITHUB_TOKEN }} inputs: '{ "tag": "${{ github.ref_name }}" }' + + trigger_dashboard_bump: + runs-on: ubuntu-latest + if: github.event.created && !github.event.deleted && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-') + steps: + - name: Trigger dashboard wasm client bump + uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2 + with: + workflow: bump-netbird.yml + ref: main + repo: netbirdio/dashboard + token: ${{ secrets.NC_GITHUB_TOKEN }} + inputs: '{ "tag": "${{ github.ref_name }}" }' diff --git a/.goreleaser_ui.yaml b/.goreleaser_ui.yaml index 1c5bc41ac..24903188f 100644 --- a/.goreleaser_ui.yaml +++ b/.goreleaser_ui.yaml @@ -92,6 +92,11 @@ nfpms: dst: /usr/share/applications/org.wails.netbird.desktop - src: client/ui/build/appicon.png dst: /usr/share/pixmaps/netbird.png + # Names the polkit action for the elevation prompt the app raises when an + # unprivileged user changes a privileged setting; without it the dialog + # shows a raw command line. + - src: client/ui/build/linux/polkit/io.netbird.settings.policy + dst: /usr/share/polkit-1/actions/io.netbird.settings.policy dependencies: - netbird (>= 0.75.0) - libgtk-4-1 (>= 4.14) @@ -116,6 +121,11 @@ nfpms: dst: /usr/share/applications/org.wails.netbird.desktop - src: client/ui/build/appicon.png dst: /usr/share/pixmaps/netbird.png + # Names the polkit action for the elevation prompt the app raises when an + # unprivileged user changes a privileged setting; without it the dialog + # shows a raw command line. + - src: client/ui/build/linux/polkit/io.netbird.settings.policy + dst: /usr/share/polkit-1/actions/io.netbird.settings.policy dependencies: - netbird >= 0.75.0 - (gtk4 >= 4.14 or libgtk-4-1 >= 4.14) diff --git a/client/android/client.go b/client/android/client.go index 7eea83dc0..5bd0d1e10 100644 --- a/client/android/client.go +++ b/client/android/client.go @@ -26,8 +26,7 @@ import ( "github.com/netbirdio/netbird/client/internal/routemanager" "github.com/netbirdio/netbird/client/internal/stdnet" "github.com/netbirdio/netbird/client/net" - "github.com/netbirdio/netbird/client/netstate" - "github.com/netbirdio/netbird/client/netsweep" + "github.com/netbirdio/netbird/client/netevents" "github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/formatter" "github.com/netbirdio/netbird/route" @@ -82,13 +81,10 @@ type Client struct { deviceName string uiVersion string networkChangeListener listener.NetworkChangeListener - // netState outlives engine restarts: it mirrors the OS connectivity, not - // the engine lifecycle. Run and RunWithoutLogin inject it into each new - // ConnectClient, which distributes it to every reconnection loop. - netState *netstate.State - - // sweeper also outlives engine restarts; NotifyNetworkChange sweeps it. - sweeper *netsweep.Sweeper + // netMgr outlives engine restarts: it mirrors the OS connectivity, not + // the engine lifecycle. Run and RunWithoutLogin inject its state and + // sweeper into each new ConnectClient. + netMgr *netevents.Manager stateMu sync.RWMutex connectClient *internal.ConnectClient @@ -153,16 +149,16 @@ func NewClient(androidSDKVersion int, deviceName string, uiVersion string, tunAd net.SetAndroidProtectSocketFn(tunAdapter.ProtectSocket) system.SetIFaceDiscover(iFaceDiscover) + recorder := peer.NewRecorder("") return &Client{ deviceName: deviceName, uiVersion: uiVersion, tunAdapter: tunAdapter, iFaceDiscover: iFaceDiscover, - recorder: peer.NewRecorder(""), + recorder: recorder, ctxCancelLock: &sync.Mutex{}, networkChangeListener: networkChangeListener, - netState: netstate.New(), - sweeper: netsweep.New(), + netMgr: netevents.NewManager(recorder), } } @@ -203,8 +199,9 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid } // todo do not throw error in case of cancelled context ctx = internal.CtxInitState(ctx) + connectClient := internal.NewConnectClient(ctx, cfg, c.recorder, - internal.WithNetworkState(c.netState), internal.WithSweeper(c.sweeper)) + internal.WithNetEvents(c.netMgr)) c.setState(cfg, cacheDir, cfgFile, connectClient) // This path runs the interactive SSO flow, so reaching here means the peer // is authenticated again — release the latch Status() reports from. Clear @@ -246,7 +243,7 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR // todo do not throw error in case of cancelled context ctx = internal.CtxInitState(ctx) connectClient := internal.NewConnectClient(ctx, cfg, c.recorder, - internal.WithNetworkState(c.netState), internal.WithSweeper(c.sweeper)) + internal.WithNetEvents(c.netMgr)) c.setState(cfg, cacheDir, cfgFile, connectClient) return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir) } @@ -298,9 +295,12 @@ func (c *Client) GetTunSettings() (*TunSettings, error) { // While unavailable, the internal reconnect loops suspend their attempts and // the connection listener reports NoNetwork instead of Connecting; when // availability returns, the loops resume immediately with a fresh backoff. +// Losing the last network also sweeps the registered connections: nothing can +// redial while offline, so the stale sockets would otherwise stay silently +// "connected" until their own timeouts and the client would keep reporting +// Connected with no network at all. func (c *Client) SetNetworkAvailable(available bool) { - c.netState.Set(available) - c.recorder.SetNetworkAvailable(available) + c.netMgr.SetNetworkAvailable(available) } // NotifyNetworkChange marks the management, signal and relay connections @@ -308,8 +308,7 @@ func (c *Client) SetNetworkAvailable(available bool) { // whatever has not redialed on the new network by then. The engine and the // TUN device stay untouched. func (c *Client) NotifyNetworkChange() { - c.sweeper.MarkNetworkChange() - log.Infof("network change: connections marked stale") + c.netMgr.NotifyNetworkChange() } // DebugBundle generates a debug bundle, uploads it, and returns the upload key. diff --git a/client/android/login.go b/client/android/login.go index 24c911eb5..3742e01a5 100644 --- a/client/android/login.go +++ b/client/android/login.go @@ -8,6 +8,7 @@ import ( "github.com/netbirdio/netbird/client/internal/auth" "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mobile" "github.com/netbirdio/netbird/client/system" ) @@ -181,7 +182,7 @@ func (a *Auth) login(urlOpener URLOpener, isAndroidTV bool) error { // Stored after Login, not before: a rejected token must not leave a hint // pointing at an account that cannot be used. if email != "" && a.cfgPath != "" { - if err := writeProfileEmail(a.cfgPath, email); err != nil { + if err := mobile.WriteProfileEmail(a.cfgPath, email); err != nil { log.Warnf("failed to store profile account email: %v", err) } } @@ -208,7 +209,7 @@ func profileLoginHint(cfgPath string) string { if cfgPath == "" { return "" } - return readProfileEmail(cfgPath) + return mobile.ReadProfileEmail(cfgPath) } // runOAuthFlow drives an already acquired OAuth flow to a token: requests the diff --git a/client/android/profile_manager.go b/client/android/profile_manager.go index 20d585d6a..557c837a7 100644 --- a/client/android/profile_manager.go +++ b/client/android/profile_manager.go @@ -3,42 +3,37 @@ package android import ( - "fmt" - "os" - "path/filepath" - - log "github.com/sirupsen/logrus" - - "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mobile" ) const ( - // Android uses a single user context per app (non-empty username required by ServiceManager) + // Android uses a single user context per app. androidUsername = "android" ) -// Profile represents a profile for gomobile +// Profile represents a profile for gomobile. type Profile struct { ID string Name string // Email is the account this profile last logged in with, "" if it never // completed an SSO login. Kept across logouts; cleared when the profile is - // removed. See profile_state.go. + // removed. See client/mobile/profile_state.go. Email string IsActive bool } -// ProfileArray wraps profiles for gomobile compatibility +// ProfileArray wraps profiles for gomobile compatibility (gomobile cannot +// bind Go slices directly). type ProfileArray struct { items []*Profile } -// Length returns the number of profiles +// Length returns the number of profiles. func (p *ProfileArray) Length() int { return len(p.items) } -// Get returns the profile at index i +// Get returns the profile at index i, or nil if out of range. func (p *ProfileArray) Get(i int) *Profile { if i < 0 || i >= len(p.items) { return nil @@ -46,259 +41,98 @@ func (p *ProfileArray) Get(i int) *Profile { return p.items[i] } -/* - -/data/data/io.netbird.client/files/ ← configDir parameter -├── netbird.cfg ← Default profile config -├── state.json ← Default profile state -├── active_profile.json ← Active profile tracker (JSON with Name + Username) -└── profiles/ ← Subdirectory for non-default profiles - ├── work.json ← Legacy work profile config - ├── work.state.json ← Legacy work profile state - ├── 4c5f5c8198c3989cffb5b5394f5a7ae0.json ← ID profile config - ├── 4c5f5c8198c3989cffb5b5394f5a7ae0.state.json ← ID profile state -*/ - -// ProfileManager manages profiles for Android -// It wraps the internal profilemanager to provide Android-specific behavior +// ProfileManager adapts the shared mobile profile manager (client/mobile) to +// gomobile-friendly types. See that package for the on-disk layout and +// semantics. type ProfileManager struct { - configDir string - serviceMgr *profilemanager.ServiceManager + impl *mobile.ProfileManager } -// NewProfileManager creates a new profile manager for Android +// NewProfileManager creates a new profile manager for Android. configDir is +// the app's files directory. func NewProfileManager(configDir string) *ProfileManager { - // Set the default config path for Android (stored in root configDir, not profiles/) - defaultConfigPath := filepath.Join(configDir, defaultConfigFilename) - - // Set global paths for Android - profilemanager.DefaultConfigPathDir = configDir - profilemanager.DefaultConfigPath = defaultConfigPath - profilemanager.ActiveProfileStatePath = filepath.Join(configDir, "active_profile.json") - - // Create ServiceManager with profiles/ subdirectory - // This avoids modifying the global ConfigDirOverride for profile listing - profilesDir := filepath.Join(configDir, profilesSubdir) - serviceMgr := profilemanager.NewServiceManagerWithProfilesDir(defaultConfigPath, profilesDir) - - return &ProfileManager{ - configDir: configDir, - serviceMgr: serviceMgr, - } + return &ProfileManager{impl: mobile.NewProfileManager(configDir, androidUsername)} } -// ListProfiles returns all available profiles +// ListProfiles returns all available profiles, including the default profile, +// with their active status set. func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) { - // Use ServiceManager (looks in profiles/ directory, checks active_profile.json for IsActive) - internalProfiles, err := pm.serviceMgr.ListProfiles(androidUsername) + profiles, err := pm.impl.ListProfiles() if err != nil { - return nil, fmt.Errorf("failed to list profiles: %w", err) + return nil, err } - // Convert internal profiles to Android Profile type - var profiles []*Profile - for _, p := range internalProfiles { - profiles = append(profiles, &Profile{ - ID: p.ID.String(), - Name: p.Name, - Email: pm.profileEmail(p.ID.String()), - IsActive: p.IsActive, - }) + items := make([]*Profile, 0, len(profiles)) + for i := range profiles { + items = append(items, fromMobileProfile(&profiles[i])) } - - return &ProfileArray{items: profiles}, nil + return &ProfileArray{items: items}, nil } -// GetActiveProfile returns the currently active profile name +// GetActiveProfile returns the currently active profile. func (pm *ProfileManager) GetActiveProfile() (*Profile, error) { - // Use ServiceManager to stay consistent with ListProfiles - // ServiceManager uses active_profile.json - activeState, err := pm.serviceMgr.GetActiveProfileState() + p, err := pm.impl.GetActiveProfile() if err != nil { - return nil, fmt.Errorf("failed to get active profile: %w", err) + return nil, err } - - // ActiveProfileState only stores the ID (and username), not the display - // name. Resolve the ID to the full profile so callers get the real Name. - prof, err := pm.serviceMgr.ResolveProfile(activeState.ID.String(), androidUsername) - if err != nil { - return nil, fmt.Errorf("failed to resolve active profile %q: %w", activeState.ID, err) - } - return &Profile{ - ID: prof.ID.String(), - Name: prof.Name, - Email: pm.profileEmail(prof.ID.String()), - IsActive: true, - }, nil + return fromMobileProfile(p), nil } -// profileEmail returns the account email recorded for a profile. Display-only, so -// an unresolvable path degrades to "" rather than an error. -func (pm *ProfileManager) profileEmail(id string) string { - configPath, err := pm.getProfileConfigPath(id) - if err != nil { - return "" - } - return readProfileEmail(configPath) -} - -// SwitchProfile switches to a different profile +// SwitchProfile records the given profile ID as the active profile. The caller +// must stop the VPN tunnel before switching. func (pm *ProfileManager) SwitchProfile(id string) error { - // Use ServiceManager to stay consistent with ListProfiles - // ServiceManager uses active_profile.json - err := pm.serviceMgr.SetActiveProfileState(&profilemanager.ActiveProfileState{ - ID: profilemanager.ID(id), - Username: androidUsername, - }) - if err != nil { - return fmt.Errorf("failed to switch profile: %w", err) - } - - log.Infof("switched to profile: %s", id) - return nil + return pm.impl.SwitchProfile(id) } -// AddProfile creates a new profile +// AddProfile creates a new profile with the given display name and a +// generated ID. func (pm *ProfileManager) AddProfile(profileName string) error { - // Use ServiceManager (creates profile in profiles/ directory) - profile, err := pm.serviceMgr.AddProfile(profileName, androidUsername) - if err != nil { - return fmt.Errorf("failed to add profile: %w", err) - } - - log.Infof("created new profile: %s", profile.ID) - return nil + _, err := pm.impl.AddProfile(profileName) + return err } -// LogoutProfile logs out from a profile (clears authentication) -func (pm *ProfileManager) LogoutProfile(id string) error { - configPath, err := pm.getProfileConfigPath(id) - if err != nil { - return err - } - - if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) { - return fmt.Errorf("id '%s' is not valid", id) - } - - // Check if profile exists - if _, err := os.Stat(configPath); os.IsNotExist(err) { - return fmt.Errorf("profile '%s' does not exist", id) - } - - // Read current config using internal profilemanager - config, err := profilemanager.ReadConfig(configPath) - if err != nil { - return fmt.Errorf("failed to read profile config: %w", err) - } - - // Clear authentication by removing private key and SSH key - config.PrivateKey = "" - config.SSHKey = "" - - // Save config using internal profilemanager - if err := profilemanager.WriteOutConfig(configPath, config); err != nil { - return fmt.Errorf("failed to save config: %w", err) - } - - // The stored account email is kept on purpose, matching the desktop and CLI - // logout semantics: the next login passes it as the login_hint so the IdP - // preselects the account. Removing the profile is what deletes it. - log.Infof("logged out from profile: %s", id) - return nil -} - -// RenameProfile changes a profile's display name. The profile ID, and therefore -// its on-disk filename, is left untouched: only the "name" field of the config -// is rewritten. This works for the default profile too, whose config lives in -// netbird.cfg rather than under profiles/. +// RenameProfile changes the display name of the profile identified by id. The +// on-disk filename (the ID) is left unchanged. func (pm *ProfileManager) RenameProfile(id string, newName string) error { - if err := pm.serviceMgr.RenameProfile(profilemanager.ID(id), androidUsername, newName); err != nil { - return fmt.Errorf("failed to rename profile: %w", err) - } - - log.Infof("renamed profile %s to: %s", id, newName) - return nil + return pm.impl.RenameProfile(id, newName) } -// RemoveProfile deletes a profile +// LogoutProfile clears authentication data for a profile, forcing a re-login. +// The management URL and other settings are preserved. +func (pm *ProfileManager) LogoutProfile(id string) error { + return pm.impl.LogoutProfile(id) +} + +// RemoveProfile deletes a profile. The default profile and the active profile +// cannot be removed. func (pm *ProfileManager) RemoveProfile(id string) error { - configPath, err := pm.getProfileConfigPath(id) - if err != nil { - return err - } - - // Use ServiceManager (removes profile from profiles/ directory) - if err := pm.serviceMgr.RemoveProfile(profilemanager.ID(id), androidUsername); err != nil { - return fmt.Errorf("failed to remove profile: %w", err) - } - - // The account file is this package's, not the ServiceManager's, so it must - // go here. The default profile has a fixed filename, so a recreated one - // would otherwise inherit the deleted profile's email as its login_hint. - // Not fatal: the profile itself is gone. - if err := removeProfileEmail(configPath); err != nil { - log.Warnf("failed to remove stored account email for profile %s: %v", id, err) - } - - log.Infof("removed profile: %s", id) - return nil + return pm.impl.RemoveProfile(id) } -// getProfileConfigPath returns the config file path for a profile -// This is needed for Android-specific path handling (netbird.cfg for default profile) -func (pm *ProfileManager) getProfileConfigPath(id string) (string, error) { - if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) { - return "", fmt.Errorf("id %q is not valid", id) - } - - if id == profilemanager.DefaultProfileName { - // Android uses netbird.cfg for default profile instead of default.json - // Default profile is stored in root configDir, not in profiles/ - return filepath.Join(pm.configDir, defaultConfigFilename), nil - } - - profilesDir := filepath.Join(pm.configDir, profilesSubdir) - return filepath.Join(profilesDir, id+".json"), nil -} - -// GetConfigPath returns the config file path for a given profile id -// Java should call this instead of constructing paths with Preferences.configFile() +// GetConfigPath returns the config file path for the given profile ID. Java +// should call this instead of constructing paths with Preferences.configFile(). func (pm *ProfileManager) GetConfigPath(id string) (string, error) { - return pm.getProfileConfigPath(id) + return pm.impl.GetConfigPath(id) } -// GetStateFilePath returns the state file path for a given profile -// Java should call this instead of constructing paths with Preferences.stateFile() +// GetStateFilePath returns the state file path for the given profile ID. Java +// should call this instead of constructing paths with Preferences.stateFile(). func (pm *ProfileManager) GetStateFilePath(id string) (string, error) { - if id == "" || id == profilemanager.DefaultProfileName { - return filepath.Join(pm.configDir, "state.json"), nil - } - - if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) { - return "", fmt.Errorf("id %q is not valid", id) - } - - profilesDir := filepath.Join(pm.configDir, profilesSubdir) - return filepath.Join(profilesDir, id+".state.json"), nil + return pm.impl.GetStateFilePath(id) } -// GetActiveConfigPath returns the config file path for the currently active profile -// Java should call this instead of Preferences.getActiveProfileName() + Preferences.configFile() +// GetActiveConfigPath returns the config file path for the currently active +// profile. func (pm *ProfileManager) GetActiveConfigPath() (string, error) { - activeProfile, err := pm.GetActiveProfile() - if err != nil { - return "", fmt.Errorf("failed to get active profile: %w", err) - } - return pm.GetConfigPath(activeProfile.ID) + return pm.impl.GetActiveConfigPath() } -// GetActiveStateFilePath returns the state file path for the currently active profile -// Java should call this instead of Preferences.getActiveProfileName() + Preferences.stateFile() +// GetActiveStateFilePath returns the state file path for the currently active +// profile. func (pm *ProfileManager) GetActiveStateFilePath() (string, error) { - activeProfile, err := pm.GetActiveProfile() - if err != nil { - return "", fmt.Errorf("failed to get active profile: %w", err) - } - return pm.GetStateFilePath(activeProfile.ID) + return pm.impl.GetActiveStateFilePath() +} + +func fromMobileProfile(p *mobile.Profile) *Profile { + return &Profile{ID: p.ID, Name: p.Name, Email: p.Email, IsActive: p.IsActive} } diff --git a/client/android/profile_prefs.go b/client/android/profile_prefs.go index 9c1fd307b..a761ebbcf 100644 --- a/client/android/profile_prefs.go +++ b/client/android/profile_prefs.go @@ -21,10 +21,9 @@ func newProfilePrefs(configDir, profileID string) (*profilePrefs, error) { if configDir == "" || profileID == "" { return nil, fmt.Errorf("profile prefs require a config dir and profile ID") } - pm := NewProfileManager(configDir) - prefs, err := pm.serviceMgr.ProfilePrefs(profilemanager.ID(profileID), androidUsername) + prefs, err := NewProfileManager(configDir).impl.ProfilePrefs(profileID) if err != nil { - return nil, fmt.Errorf("resolve profile prefs: %w", err) + return nil, err } return &profilePrefs{prefs: prefs}, nil } 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 e81441791..d6d8773c5 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 } @@ -446,6 +430,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) @@ -505,6 +513,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 } @@ -606,6 +621,14 @@ 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 } @@ -630,7 +653,19 @@ func applySSHFlagsToConfig(cmd *cobra.Command, ic *profilemanager.ConfigInput) { } } -func applySSHFlagsToLogin(cmd *cobra.Command, req *proto.LoginRequest) { +// setSSHLoginFields copies the SSH and VNC 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(serverVNCAllowedFlag).Changed { + req.ServerVNCAllowed = &serverVNCAllowed + } + if cmd.Flag(disableVNCApprovalFlag).Changed { + req.DisableVNCApproval = &disableVNCApproval + } if cmd.Flag(enableSSHRootFlag).Changed { req.EnableSSHRoot = &enableSSHRoot } @@ -647,8 +682,8 @@ func applySSHFlagsToLogin(cmd *cobra.Command, req *proto.LoginRequest) { req.DisableSSHAuth = &disableSSHAuth } if cmd.Flag(sshJWTCacheTTLFlag).Changed { - ttl := int32(sshJWTCacheTTL) - req.SshJWTCacheTTL = &ttl + sshJWTCacheTTL32 := int32(sshJWTCacheTTL) + req.SshJWTCacheTTL = &sshJWTCacheTTL32 } } @@ -678,22 +713,20 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte loginRequest.RosenpassPermissive = &rosenpassPermissive } - if cmd.Flag(serverSSHAllowedFlag).Changed { - loginRequest.ServerSSHAllowed = &serverSSHAllowed - } - if cmd.Flag(serverVNCAllowedFlag).Changed { - loginRequest.ServerVNCAllowed = &serverVNCAllowed - } - if cmd.Flag(disableVNCApprovalFlag).Changed { - loginRequest.DisableVNCApproval = &disableVNCApproval - } - - applySSHFlagsToLogin(cmd, &loginRequest) + 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.go b/client/embed/embed.go index 079e03c63..5a3d11f24 100644 --- a/client/embed/embed.go +++ b/client/embed/embed.go @@ -85,6 +85,11 @@ type Options struct { DisableIPv6 bool // BlockInbound blocks all inbound connections from peers BlockInbound bool + // EnableRosenpass enables the Rosenpass post-quantum key exchange. + EnableRosenpass bool + // RosenpassPermissive lets a Rosenpass-enabled peer still connect to peers + // that do not run Rosenpass (falling back to the plain WireGuard PSK). + RosenpassPermissive bool // BlockLANAccess blocks the embedded peer from reaching the host's // LAN (RFC 1918, link-local, loopback) when it's used as a routing // peer. Mirrors profilemanager.ConfigInput.BlockLANAccess. Useful @@ -210,6 +215,8 @@ func New(opts Options) (*Client, error) { DisableIPv6: &opts.DisableIPv6, BlockInbound: &opts.BlockInbound, BlockLANAccess: &opts.BlockLANAccess, + RosenpassEnabled: &opts.EnableRosenpass, + RosenpassPermissive: &opts.RosenpassPermissive, WireguardPort: opts.WireguardPort, MTU: opts.MTU, DNSLabels: parsedLabels, diff --git a/client/embed/embed_test.go b/client/embed/embed_test.go index 27beb8934..4ff5c9978 100644 --- a/client/embed/embed_test.go +++ b/client/embed/embed_test.go @@ -146,7 +146,7 @@ func startManagement(t *testing.T, signalAddr string) string { updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := mgmt.NewAccountRequestBuffer(context.Background(), testStore) - networkMapController := controller.NewController(context.Background(), testStore, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(testStore, peersManager), cfg) + networkMapController := controller.NewController(context.Background(), testStore, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(testStore, peersManager), cfg, nil) accountManager, err := mgmt.BuildManager(context.Background(), cfg, testStore, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) require.NoError(t, err) diff --git a/client/firewall/nftables/router_linux.go b/client/firewall/nftables/router_linux.go index d3e031c5f..c79f9b8c2 100644 --- a/client/firewall/nftables/router_linux.go +++ b/client/firewall/nftables/router_linux.go @@ -763,7 +763,7 @@ func (r *router) addNatRule(pair firewall.RouterPair) error { exprs = append(exprs, sourceExp...) exprs = append(exprs, destExp...) - var markValue uint32 = nbnet.PreroutingFwmarkMasquerade + markValue := nbnet.PreroutingFwmarkMasquerade if pair.Inverse { markValue = nbnet.PreroutingFwmarkMasqueradeReturn } diff --git a/client/grpc/dialer_generic.go b/client/grpc/dialer_generic.go index 8a80525e9..737787223 100644 --- a/client/grpc/dialer_generic.go +++ b/client/grpc/dialer_generic.go @@ -16,9 +16,14 @@ import ( "google.golang.org/grpc" nbnet "github.com/netbirdio/netbird/client/net" - "github.com/netbirdio/netbird/client/netsweep" + "github.com/netbirdio/netbird/client/netevents/sweep" ) +// Sweeper registers in-flight dials for the network change sweep. +type Sweeper interface { + StartDial(ctx context.Context) *sweep.Dial +} + func WithCustomDialer(_ bool, _ string) grpc.DialOption { return grpc.WithContextDialer(dialContext) } @@ -26,7 +31,7 @@ func WithCustomDialer(_ bool, _ string) grpc.DialOption { // WithSweeper dials like WithCustomDialer but registers connections and // dials with the sweeper. Append it after WithCustomDialer: gRPC applies // dial options in order, so the later context dialer wins. -func WithSweeper(sweeper *netsweep.Sweeper) grpc.DialOption { +func WithSweeper(sweeper Sweeper) grpc.DialOption { return grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) { dial := sweeper.StartDial(ctx) defer dial.Release() diff --git a/client/grpc/dialer_js.go b/client/grpc/dialer_js.go index 8863756d7..4ff4ceb20 100644 --- a/client/grpc/dialer_js.go +++ b/client/grpc/dialer_js.go @@ -1,12 +1,19 @@ package grpc import ( + "context" + "google.golang.org/grpc" - "github.com/netbirdio/netbird/client/netsweep" + "github.com/netbirdio/netbird/client/netevents/sweep" "github.com/netbirdio/netbird/util/wsproxy/client" ) +// Sweeper registers in-flight dials for the network change sweep. +type Sweeper interface { + StartDial(ctx context.Context) *sweep.Dial +} + // WithCustomDialer returns a gRPC dial option that uses WebSocket transport for WASM/JS environments. // The component parameter specifies the WebSocket proxy component path (e.g., "/management", "/signal"). func WithCustomDialer(tlsEnabled bool, component string) grpc.DialOption { @@ -14,6 +21,6 @@ func WithCustomDialer(tlsEnabled bool, component string) grpc.DialOption { } // WithSweeper is a no-op on WASM/JS: there is no network change signal. -func WithSweeper(_ *netsweep.Sweeper) grpc.DialOption { +func WithSweeper(_ Sweeper) grpc.DialOption { return grpc.EmptyDialOption{} } diff --git a/client/grpc/retry.go b/client/grpc/retry.go index 754ffa341..0bb6037bf 100644 --- a/client/grpc/retry.go +++ b/client/grpc/retry.go @@ -6,16 +6,19 @@ import ( "time" "github.com/cenkalti/backoff/v4" - - "github.com/netbirdio/netbird/client/netstate" ) +// ChangeWatcher exposes OS network availability transitions. +type ChangeWatcher interface { + Changed() <-chan struct{} +} + // Retry mirrors backoff.Retry, but the sleep between attempts also wakes on // OS network availability transitions: an operation cut down by a network // change retries the moment the network settles instead of sleeping through -// the recovery. A nil netState never fires, leaving plain backoff.Retry +// the recovery. A nil watcher never fires, leaving plain backoff.Retry // behavior. -func Retry(ctx context.Context, operation backoff.Operation, bo backoff.BackOff, netState *netstate.State) error { +func Retry(ctx context.Context, operation backoff.Operation, bo backoff.BackOff, watcher ChangeWatcher) error { bo.Reset() for { err := operation() @@ -36,10 +39,14 @@ func Retry(ctx context.Context, operation backoff.Operation, bo backoff.BackOff, return err } + var changed <-chan struct{} + if watcher != nil { + changed = watcher.Changed() + } timer := time.NewTimer(next) select { case <-timer.C: - case <-netState.Changed(): + case <-changed: timer.Stop() case <-ctx.Done(): timer.Stop() diff --git a/client/grpc/retry_test.go b/client/grpc/retry_test.go index 4edca47b6..266bb93e5 100644 --- a/client/grpc/retry_test.go +++ b/client/grpc/retry_test.go @@ -10,7 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/netbirdio/netbird/client/netstate" + "github.com/netbirdio/netbird/client/netevents/netstate" ) func TestRetryWakesOnNetworkChange(t *testing.T) { diff --git a/client/iface/configurer/usp.go b/client/iface/configurer/usp.go index 0a25c55bc..2be1b861e 100644 --- a/client/iface/configurer/usp.go +++ b/client/iface/configurer/usp.go @@ -502,7 +502,7 @@ func toBytes(s string) (int64, error) { func getFwmark() int { if nbnet.AdvancedRouting() && runtime.GOOS == "linux" { - return nbnet.ControlPlaneMark + return int(nbnet.ControlPlaneMark) } return 0 } diff --git a/client/iface/wgproxy/rawsocket/rawsocket.go b/client/iface/wgproxy/rawsocket/rawsocket.go index bc785b43a..37aaa160f 100644 --- a/client/iface/wgproxy/rawsocket/rawsocket.go +++ b/client/iface/wgproxy/rawsocket/rawsocket.go @@ -10,8 +10,6 @@ import ( log "github.com/sirupsen/logrus" "golang.org/x/sys/unix" - - nbnet "github.com/netbirdio/netbird/client/net" ) // PrepareSenderRawSocketIPv4 creates and configures a raw socket for sending IPv4 packets @@ -60,14 +58,12 @@ func prepareSenderRawSocket(family int, isIPv4 bool) (net.PacketConn, error) { return nil, fmt.Errorf("binding to lo interface failed: %w", err) } - // Set the fwmark on the socket. - err = nbnet.SetSocketOpt(fd) - if err != nil { - if closeErr := syscall.Close(fd); closeErr != nil { - log.Warnf("failed to close raw socket fd: %v", closeErr) - } - return nil, fmt.Errorf("setting fwmark failed: %w", err) - } + // The socket is bound to lo and only ever sends to the local WireGuard + // instance, a destination the local routing table resolves without help, so + // it carries no fwmark. Staying unmarked also keeps these packets out of + // third-party NAT rules that match on marks: such a rule rewriting the + // source would make WireGuard adopt the rewritten address as the peer + // endpoint. // Convert the file descriptor to a PacketConn. file := os.NewFile(uintptr(fd), fmt.Sprintf("fd %d", fd)) diff --git a/client/iface/wgproxy/rawsocket/rawsocket_privileged_test.go b/client/iface/wgproxy/rawsocket/rawsocket_privileged_test.go new file mode 100644 index 000000000..03748c6f9 --- /dev/null +++ b/client/iface/wgproxy/rawsocket/rawsocket_privileged_test.go @@ -0,0 +1,77 @@ +//go:build linux && !android && privileged + +package rawsocket + +import ( + "net" + "syscall" + "testing" + + "golang.org/x/sys/unix" + + nbnet "github.com/netbirdio/netbird/client/net" +) + +// The sender sockets must stay unmarked: a NAT rule matching on fwmark that +// rewrites the source of an injected packet makes WireGuard adopt the rewritten +// address as the peer endpoint. +func TestSenderRawSocketsCarryNoFwmark(t *testing.T) { + // the mark is only ever applied when advanced routing is available, so + // without it the assertion below would hold for the wrong reason + nbnet.Init() + if !nbnet.AdvancedRouting() { + t.Skip("advanced routing unsupported, the sockets carry no mark either way") + } + + tests := []struct { + name string + prepare func() (net.PacketConn, error) + // the proxy treats the IPv6 socket as optional, so a host without IPv6 + // is a reason to skip rather than to fail + optional bool + }{ + {name: "IPv4", prepare: PrepareSenderRawSocketIPv4}, + {name: "IPv6", prepare: PrepareSenderRawSocketIPv6, optional: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + conn, err := tc.prepare() + if err != nil { + if tc.optional { + t.Skipf("prepare raw socket: %v", err) + } + t.Fatalf("prepare raw socket: %v", err) + } + defer func() { + if err := conn.Close(); err != nil { + t.Logf("close raw socket: %v", err) + } + }() + + syscallConn, ok := conn.(syscall.Conn) + if !ok { + t.Fatalf("raw socket %T does not expose a syscall conn", conn) + } + raw, err := syscallConn.SyscallConn() + if err != nil { + t.Fatalf("syscall conn: %v", err) + } + + var mark int + var markErr error + if err := raw.Control(func(fd uintptr) { + mark, markErr = unix.GetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_MARK) + }); err != nil { + t.Fatalf("control: %v", err) + } + if markErr != nil { + t.Fatalf("get SO_MARK: %v", markErr) + } + + if mark != 0 { + t.Errorf("SO_MARK = %#x, want 0", mark) + } + }) + } +} diff --git a/client/internal/conn_mgr.go b/client/internal/conn_mgr.go index ad0f00c5d..8b01eabcf 100644 --- a/client/internal/conn_mgr.go +++ b/client/internal/conn_mgr.go @@ -2,6 +2,7 @@ package internal import ( "context" + "maps" "os" "strconv" "sync" @@ -14,6 +15,7 @@ import ( "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/peerstore" "github.com/netbirdio/netbird/route" + mgmProto "github.com/netbirdio/netbird/shared/management/proto" ) // lazyForce is the resolved local decision for lazy connections, layered above the @@ -37,11 +39,13 @@ const ( // The only exception is ActivatePeer, which is safe for concurrent use so the // DNS warm-up path can call it without contending on the engine mutex. type ConnMgr struct { - peerStore *peerstore.Store - statusRecorder *peer.Status - iface lazyconn.WGIface - force lazyForce - rosenpassEnabled bool + peerStore *peerstore.Store + statusRecorder *peer.Status + iface lazyconn.WGIface + force lazyForce + // remoteLazyEnabled caches the account-wide lazy feature flag from management. + // It is the default for peers that do not carry a per-peer lazy hint. + remoteLazyEnabled bool lazyConnMgr *manager.Manager // lazyConnMgrMu guards the lazyConnMgr pointer for readers outside the @@ -53,6 +57,10 @@ type ConnMgr struct { // (re)armed (Mode A at arm time). Injected by the engine; nil disables the reconcile. reconcileRoutedIPs func(peerKey string) error + // appliedExcludeList is the exclude set last handed to the lazy manager, kept so an + // unchanged set on the next sync skips the O(n) reconciliation. + appliedExcludeList map[string]bool + wg sync.WaitGroup lazyCtx context.Context lazyCtxCancel context.CancelFunc @@ -66,78 +74,59 @@ func (e *ConnMgr) SetRoutedIPsReconciler(fn func(peerKey string) error) { func NewConnMgr(engineConfig *EngineConfig, statusRecorder *peer.Status, peerStore *peerstore.Store, iface lazyconn.WGIface) *ConnMgr { e := &ConnMgr{ - peerStore: peerStore, - statusRecorder: statusRecorder, - iface: iface, - force: resolveLazyForce(engineConfig.LazyConnection), - rosenpassEnabled: engineConfig.RosenpassEnabled, + peerStore: peerStore, + statusRecorder: statusRecorder, + iface: iface, + force: resolveLazyForce(engineConfig.LazyConnection), } return e } -// Start initializes the connection manager. It starts the lazy connection manager when a -// local override forces it on; with no local override it waits for the management feature flag. +// Start initializes the connection manager. The lazy connection manager always runs so that +// per-peer lazy defaults (e.g. proxy peers) work even when the account flag is off; the +// account flag and the local override decide the default lazy state per peer (see +// PeerLazyDefault). Rosenpass peers stay lazy-capable too: their connections just never idle +// on their own, since rosenpass rekey traffic keeps them active. func (e *ConnMgr) Start(ctx context.Context) { if e.lazyConnMgr != nil { log.Errorf("lazy connection manager is already started") return } - switch e.force { - case lazyForceOff: - log.Infof("lazy connection manager is disabled by local override (%s or MDM policy)", lazyconn.EnvLazyConn) - e.statusRecorder.UpdateLazyConnection(false) - return - case lazyForceNone: - log.Infof("lazy connection manager is managed by the management feature flag") - e.statusRecorder.UpdateLazyConnection(false) - return - } - - if e.rosenpassEnabled { - log.Warnf("rosenpass connection manager is enabled, lazy connection manager will not be started") - e.statusRecorder.UpdateLazyConnection(false) - return - } - e.initLazyManager(ctx) - e.statusRecorder.UpdateLazyConnection(true) + e.statusRecorder.UpdateLazyConnection(e.PeerLazyDefault(mgmProto.LazyState_LazyStateDefault)) } -// UpdatedRemoteFeatureFlag is called when the remote feature flag is updated. -// If enabled, it initializes the lazy connection manager and start it. Do not need to call Start() again. -// If disabled, then it closes the lazy connection manager and open the connections to all peers. -func (e *ConnMgr) UpdatedRemoteFeatureFlag(ctx context.Context, enabled bool) error { - // a local override (NB_LAZY_CONN or local config) takes precedence over management - if e.force != lazyForceNone { - return nil +// UpdatedRemoteFeatureFlag caches the account-wide lazy feature flag. The manager itself is +// not started or stopped here; the per-sync exclude-list reconciliation moves normal peers +// between the lazy and always-active sets when the flag flips. +func (e *ConnMgr) UpdatedRemoteFeatureFlag(_ context.Context, enabled bool) error { + e.remoteLazyEnabled = enabled + if e.isStartedWithLazyMgr() { + e.statusRecorder.UpdateLazyConnection(e.PeerLazyDefault(mgmProto.LazyState_LazyStateDefault)) + } + return nil +} + +// PeerLazyDefault reports whether a peer should be lazy. The local override +// (NB_LAZY_CONN/MDM) wins over everything; without a local override the +// management per-peer state applies (LazyStateLazy/Eager force the decision), +// and LazyStateDefault follows the account-wide flag. +func (e *ConnMgr) PeerLazyDefault(state mgmProto.LazyState) bool { + switch e.force { + case lazyForceOn: + return true + case lazyForceOff: + return false } - if enabled { - // if the lazy connection manager is already started, do not start it again - if e.lazyConnMgr != nil { - return nil - } - - if e.rosenpassEnabled { - log.Infof("rosenpass connection manager is enabled, lazy connection manager will not be started") - e.statusRecorder.UpdateLazyConnection(false) - return nil - } - - log.Infof("lazy connection manager is enabled by the management feature flag") - e.initLazyManager(ctx) - e.statusRecorder.UpdateLazyConnection(true) - return e.addPeersToLazyConnManager() - } else { - if e.lazyConnMgr == nil { - e.statusRecorder.UpdateLazyConnection(false) - return nil - } - log.Infof("lazy connection manager is disabled by management feature flag") - e.closeManager(ctx) - e.statusRecorder.UpdateLazyConnection(false) - return nil + switch state { + case mgmProto.LazyState_LazyStateLazy: + return true + case mgmProto.LazyState_LazyStateEager: + return false + default: + return e.remoteLazyEnabled } } @@ -157,6 +146,13 @@ func (e *ConnMgr) SetExcludeList(ctx context.Context, peerIDs map[string]bool) { return } + // The exclude set is recomputed every sync but rarely changes; skip the O(n) + // store lookups and reconciliation when it matches what was already applied. + if maps.Equal(peerIDs, e.appliedExcludeList) { + return + } + e.appliedExcludeList = maps.Clone(peerIDs) + excludedPeers := make([]lazyconn.PeerConfig, 0, len(peerIDs)) for peerID := range peerIDs { @@ -192,12 +188,16 @@ func (e *ConnMgr) SetExcludeList(ctx context.Context, peerIDs map[string]bool) { } } -func (e *ConnMgr) AddPeerConn(ctx context.Context, peerKey string, conn *peer.Conn) (exists bool) { +// AddPeerConn registers a peer connection. permanent requests an always-active connection +// (the peer belongs to the exclude set: a forwarder, or a peer that is not lazy by policy). +// Non-permanent peers are handed to the lazy manager. The subsequent SetExcludeList call +// reconciles membership for existing peers across flag flips. +func (e *ConnMgr) AddPeerConn(ctx context.Context, peerKey string, conn *peer.Conn, permanent bool) (exists bool) { if success := e.peerStore.AddPeerConn(peerKey, conn); !success { return true } - if !e.isStartedWithLazyMgr() { + if !e.isStartedWithLazyMgr() || permanent { if err := conn.Open(ctx); err != nil { conn.Log.Errorf("failed to open connection: %v", err) } @@ -296,6 +296,8 @@ func (e *ConnMgr) Close() { e.lazyConnMgrMu.Lock() e.lazyConnMgr = nil e.lazyConnMgrMu.Unlock() + + e.appliedExcludeList = nil } func (e *ConnMgr) initLazyManager(engineCtx context.Context) { @@ -309,6 +311,8 @@ func (e *ConnMgr) initLazyManager(engineCtx context.Context) { e.lazyCtx, e.lazyCtxCancel = context.WithCancel(engineCtx) e.lazyConnMgrMu.Unlock() + e.appliedExcludeList = nil + e.wg.Add(1) go func() { defer e.wg.Done() @@ -316,46 +320,6 @@ func (e *ConnMgr) initLazyManager(engineCtx context.Context) { }() } -func (e *ConnMgr) addPeersToLazyConnManager() error { - peers := e.peerStore.PeersPubKey() - lazyPeerCfgs := make([]lazyconn.PeerConfig, 0, len(peers)) - for _, peerID := range peers { - var peerConn *peer.Conn - var exists bool - if peerConn, exists = e.peerStore.PeerConn(peerID); !exists { - log.Warnf("failed to find peer conn for peerID: %s", peerID) - continue - } - - lazyPeerCfg := lazyconn.PeerConfig{ - PublicKey: peerID, - AllowedIPs: peerConn.WgConfig().AllowedIps, - PeerConnID: peerConn.ConnID(), - Log: peerConn.Log, - } - lazyPeerCfgs = append(lazyPeerCfgs, lazyPeerCfg) - } - - return e.lazyConnMgr.AddActivePeers(lazyPeerCfgs) -} - -func (e *ConnMgr) closeManager(ctx context.Context) { - if e.lazyConnMgr == nil { - return - } - - e.lazyCtxCancel() - e.wg.Wait() - - e.lazyConnMgrMu.Lock() - e.lazyConnMgr = nil - e.lazyConnMgrMu.Unlock() - - for _, peerID := range e.peerStore.PeersPubKey() { - e.peerStore.PeerConnOpen(ctx, peerID) - } -} - func (e *ConnMgr) isStartedWithLazyMgr() bool { return e.lazyConnMgr != nil && e.lazyCtxCancel != nil } diff --git a/client/internal/conn_mgr_test.go b/client/internal/conn_mgr_test.go index ac5d6f2c8..e3723b5ff 100644 --- a/client/internal/conn_mgr_test.go +++ b/client/internal/conn_mgr_test.go @@ -16,6 +16,7 @@ import ( "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/peerstore" "github.com/netbirdio/netbird/monotime" + mgmProto "github.com/netbirdio/netbird/shared/management/proto" ) func TestResolveLazyForce(t *testing.T) { @@ -138,4 +139,91 @@ func TestInactivityThresholdEnv(t *testing.T) { } } +func TestPeerLazyDefault(t *testing.T) { + tests := []struct { + name string + force lazyForce + remoteEnabled bool + state mgmProto.LazyState + want bool + }{ + {name: "force on wins over eager state", force: lazyForceOn, state: mgmProto.LazyState_LazyStateEager, want: true}, + {name: "force off wins over lazy state", force: lazyForceOff, remoteEnabled: true, state: mgmProto.LazyState_LazyStateLazy, want: false}, + {name: "none, default, account off -> active", force: lazyForceNone, state: mgmProto.LazyState_LazyStateDefault, want: false}, + {name: "none, default, account on -> lazy", force: lazyForceNone, remoteEnabled: true, state: mgmProto.LazyState_LazyStateDefault, want: true}, + {name: "none, lazy state, account off -> lazy", force: lazyForceNone, state: mgmProto.LazyState_LazyStateLazy, want: true}, + {name: "none, eager state, account on -> active", force: lazyForceNone, remoteEnabled: true, state: mgmProto.LazyState_LazyStateEager, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := &ConnMgr{force: tt.force, remoteLazyEnabled: tt.remoteEnabled} + if got := e.PeerLazyDefault(tt.state); got != tt.want { + t.Fatalf("PeerLazyDefault(%v) = %v, want %v", tt.state, got, tt.want) + } + }) + } +} + func durPtr(d time.Duration) *time.Duration { return &d } + +// TestToExcludedLazyPeers covers the per-peer lazy classification (proxy vs +// normal, across the force/account-flag matrix). Forwarder-target exclusion is +// covered by TestToExcludedLazyPeers_ForwardTarget. +func TestToExcludedLazyPeers(t *testing.T) { + const ( + normalKey = "normal" + lazyKey = "lazy-state" + eagerKey = "eager-state" + ) + + peers := []*mgmProto.RemotePeerConfig{ + {WgPubKey: normalKey, AllowedIps: []string{"100.64.0.1/32"}}, + {WgPubKey: lazyKey, AllowedIps: []string{"100.64.0.2/32"}, LazyState: mgmProto.LazyState_LazyStateLazy}, + {WgPubKey: eagerKey, AllowedIps: []string{"100.64.0.3/32"}, LazyState: mgmProto.LazyState_LazyStateEager}, + } + + tests := []struct { + name string + force lazyForce + remoteEnabled bool + want map[string]bool + }{ + { + name: "account off: lazy-state peer lazy, normal + eager active", + force: lazyForceNone, remoteEnabled: false, + want: map[string]bool{normalKey: true, eagerKey: true}, + }, + { + name: "account on: only eager-state peer active", + force: lazyForceNone, remoteEnabled: true, + want: map[string]bool{eagerKey: true}, + }, + { + name: "force off: everything active", + force: lazyForceOff, remoteEnabled: true, + want: map[string]bool{normalKey: true, lazyKey: true, eagerKey: true}, + }, + { + name: "force on: nothing active", + force: lazyForceOn, remoteEnabled: false, + want: map[string]bool{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := &Engine{connMgr: &ConnMgr{force: tt.force, remoteLazyEnabled: tt.remoteEnabled}} + got := e.toExcludedLazyPeers(peers) + + if len(got) != len(tt.want) { + t.Fatalf("toExcludedLazyPeers() = %v, want %v", got, tt.want) + } + for k := range tt.want { + if !got[k] { + t.Fatalf("expected peer %s excluded, got %v", k, got) + } + } + }) + } +} diff --git a/client/internal/connect.go b/client/internal/connect.go index 60f82d53f..e5dff06cc 100644 --- a/client/internal/connect.go +++ b/client/internal/connect.go @@ -38,8 +38,7 @@ import ( "github.com/netbirdio/netbird/client/internal/updater" "github.com/netbirdio/netbird/client/internal/updater/installer" nbnet "github.com/netbirdio/netbird/client/net" - "github.com/netbirdio/netbird/client/netstate" - "github.com/netbirdio/netbird/client/netsweep" + "github.com/netbirdio/netbird/client/netevents" cProto "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/ssh" sshconfig "github.com/netbirdio/netbird/client/ssh/config" @@ -73,28 +72,17 @@ type ConnectClient struct { persistSyncResponse bool - // netState gates every reconnection loop on OS-reported network - // availability. Nil (the default) disables gating; mobile platforms - // inject it via WithNetworkState. - netState *netstate.State - - // sweeper cuts the management, signal and relay connections on network - // change; nil disables it. - sweeper *netsweep.Sweeper + // netMgr gates every reconnection loop on OS-reported network + // availability and sweeps connections on network change. + netMgr *netevents.Manager } // ConnectClientOption configures optional ConnectClient behavior. type ConnectClientOption func(*ConnectClient) -// WithNetworkState injects the OS network availability state that gates every -// reconnection loop; without it gating is disabled. -func WithNetworkState(netState *netstate.State) ConnectClientOption { - return func(c *ConnectClient) { c.netState = netState } -} - -// WithSweeper injects the network change sweeper. -func WithSweeper(sweeper *netsweep.Sweeper) ConnectClientOption { - return func(c *ConnectClient) { c.sweeper = sweeper } +// WithNetEvents injects the OS network event handling. +func WithNetEvents(events *netevents.Manager) ConnectClientOption { + return func(c *ConnectClient) { c.netMgr = events } } func NewConnectClient( @@ -305,7 +293,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan } // suspend connection attempts while the OS reports no usable network - if waited, err := c.netState.Wait(c.ctx); err != nil { + if waited, err := c.netMgr.Wait(c.ctx); err != nil { return nil } else if waited { backOff.Reset() @@ -323,7 +311,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan log.Debugf("connecting to the Management service %s", c.config.ManagementURL.Host) mgmClient, err := mgm.NewClient(engineCtx, c.config.ManagementURL.Host, myPrivateKey, mgmTlsEnabled, - mgm.WithNetworkState(c.netState), mgm.WithSweeper(c.sweeper)) + mgm.WithNetEvents(c.netMgr)) if err != nil { // On daemon shutdown / Down() the parent context is cancelled // and the dial fails with "context canceled". Wrapping that @@ -398,7 +386,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan }() // with the global Netbird config in hand connect (just a connection, no stream yet) Signal - signalClient, err := connectToSignal(engineCtx, loginResp.GetNetbirdConfig(), myPrivateKey, c.netState, c.sweeper) + signalClient, err := connectToSignal(engineCtx, loginResp.GetNetbirdConfig(), myPrivateKey, c.netMgr) if err != nil { log.Error(err) return wrapErr(err) @@ -435,7 +423,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan } relayManager := relayClient.NewManager(engineCtx, relayURLs, myPrivateKey.PublicKey().String(), engineConfig.MTU, - relayClient.WithNetworkState(c.netState), relayClient.WithSweeper(c.sweeper)) + relayClient.WithNetEvents(c.netMgr)) c.statusRecorder.SetRelayMgr(relayManager) if len(relayURLs) > 0 { if token != nil { @@ -463,7 +451,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan UpdateManager: c.updateManager, ClientMetrics: c.clientMetrics, MetricsCtx: c.ctx, - NetState: c.netState, + NetMgr: c.netMgr, }, mobileDependency) engine.SetSyncResponsePersistence(c.persistSyncResponse) c.engine = engine @@ -725,7 +713,7 @@ func selectMTU(localMTU uint16, peerMTU int32) uint16 { } // connectToSignal creates Signal Service client and established a connection -func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourPrivateKey wgtypes.Key, netState *netstate.State, sweeper *netsweep.Sweeper) (*signal.GrpcClient, error) { +func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourPrivateKey wgtypes.Key, netMgr *netevents.Manager) (*signal.GrpcClient, error) { var sigTLSEnabled bool if wtConfig.Signal.Protocol == mgmProto.HostConfig_HTTPS { sigTLSEnabled = true @@ -734,7 +722,7 @@ func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourP } signalClient, err := signal.NewClient(ctx, wtConfig.Signal.Uri, ourPrivateKey, sigTLSEnabled, - signal.WithNetworkState(netState), signal.WithSweeper(sweeper)) + signal.WithNetEvents(netMgr)) if err != nil { log.Errorf("error while connecting to the Signal Exchange Service %s: %s", wtConfig.Signal.Uri, err) return nil, gstatus.Errorf(codes.FailedPrecondition, "failed connecting to Signal Service : %s", err) diff --git a/client/internal/daemonaddr/identity.go b/client/internal/daemonaddr/identity.go new file mode 100644 index 000000000..b6af515b7 --- /dev/null +++ b/client/internal/daemonaddr/identity.go @@ -0,0 +1,17 @@ +package daemonaddr + +import "strings" + +// CarriesIdentity reports whether the control channel at addr conveys the +// connecting process's identity to the daemon. A Unix socket carries peer +// credentials and a named pipe carries the client's token. Nothing else does, TCP +// included, and there the daemon can authorize a privileged operation for nobody +// at all: see ResolveDaemonAddr, which says as much to anyone still reaching the +// Windows daemon on the address it served before it had a pipe. +// +// A client uses this to tell whether becoming privileged would get it anywhere. +// It answers from the scheme and nothing else, so an address it does not +// recognise counts as carrying no identity. +func CarriesIdentity(addr string) bool { + return strings.HasPrefix(addr, "unix://") || strings.HasPrefix(addr, pipeScheme) +} diff --git a/client/internal/daemonaddr/identity_test.go b/client/internal/daemonaddr/identity_test.go new file mode 100644 index 000000000..2808b5017 --- /dev/null +++ b/client/internal/daemonaddr/identity_test.go @@ -0,0 +1,29 @@ +package daemonaddr + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCarriesIdentity(t *testing.T) { + tests := []struct { + addr string + want bool + }{ + {"unix:///var/run/netbird.sock", true}, + {"unix:///var/run/netbird/default.sock", true}, + {"npipe://netbird", true}, + {`npipe://\\.\pipe\ProtectedPrefix\Administrators\netbird`, true}, + {"tcp://127.0.0.1:41731", false}, + {"tcp://localhost:41731", false}, + {"", false}, + {"/var/run/netbird.sock", false}, + } + + for _, tt := range tests { + t.Run(tt.addr, func(t *testing.T) { + assert.Equal(t, tt.want, CarriesIdentity(tt.addr), "address %q", tt.addr) + }) + } +} diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go index c38fbc24b..7c5cbec33 100644 --- a/client/internal/debug/debug.go +++ b/client/internal/debug/debug.go @@ -743,6 +743,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/mgmt/mgmt_refresh_test.go b/client/internal/dns/mgmt/mgmt_refresh_test.go index 64a5342e2..0e3e6ab36 100644 --- a/client/internal/dns/mgmt/mgmt_refresh_test.go +++ b/client/internal/dns/mgmt/mgmt_refresh_test.go @@ -224,6 +224,7 @@ func TestResolver_StaleTriggersAsyncRefresh(t *testing.T) { } func TestResolver_ConcurrentStaleHitsCollapseRefresh(t *testing.T) { + semaphore := make(chan struct{}) r := NewResolver() chain := newFakeChain() chain.setAnswer("mgmt.example.com.", dns.TypeA, "10.0.0.2") @@ -239,7 +240,7 @@ func TestResolver_ConcurrentStaleHitsCollapseRefresh(t *testing.T) { break } } - time.Sleep(50 * time.Millisecond) // hold inflight long enough to collide + <-semaphore // block the call to force request collision } r.SetChainResolver(chain, 50) @@ -255,17 +256,17 @@ func TestResolver_ConcurrentStaleHitsCollapseRefresh(t *testing.T) { var wg sync.WaitGroup for i := 0; i < 50; i++ { - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { queryA(t, r, "mgmt.example.com.") - }() + }) } + + assert.Eventually(t, func() bool { return inflight.Load() >= 1 }, 2*time.Second, 100*time.Millisecond) + + close(semaphore) wg.Wait() - waitFor(t, 2*time.Second, func() bool { - return inflight.Load() == 0 - }) + assert.Eventually(t, func() bool { return inflight.Load() == 0 }, 2*time.Second, 100*time.Millisecond) calls := chain.callCount("mgmt.example.com.", dns.TypeA) assert.LessOrEqual(t, calls, 2, "singleflight must collapse concurrent refreshes (got %d)", calls) 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/elevate/elevate.go b/client/internal/elevate/elevate.go new file mode 100644 index 000000000..aa5a5da78 --- /dev/null +++ b/client/internal/elevate/elevate.go @@ -0,0 +1,74 @@ +// Package elevate re-runs this very executable under the operating system's own +// privilege-elevation mechanism and waits for it to finish. +// +// It exists so that a change the daemon restricts to root/administrator can be +// authorized from the GUI, by the user, at the moment they ask for it: Windows +// shows the UAC consent dialog, macOS the system authentication dialog, and +// Linux/FreeBSD the session's polkit agent. The credentials, where any are +// asked for, are collected by the operating system and never pass through +// NetBird. +// +// What the elevated process then does is the caller's business: it is the same +// binary, in a one-shot mode, and it is authorized by the daemon exactly like +// any other privileged caller, from the identity the kernel reports on the +// control channel. Nothing here grants privilege, and the daemon gains no new +// way to be talked into something: elevation only changes who is calling it. +package elevate + +import ( + "context" + "errors" + + log "github.com/sirupsen/logrus" +) + +// AppliedMarker is what the elevated process prints on standard output once it has +// done what it was run for. +// +// macOS's AuthorizationExecuteWithPrivileges reports no exit status and does not +// say which process it started, so there this line is the only evidence that the +// change was applied. The other platforms have an exit code and ignore it. +const AppliedMarker = "netbird-elevated: applied" + +var ( + // ErrDeclined reports that the user dismissed the prompt or did not + // authenticate. Nothing happened and nothing is wrong: a caller undoes its + // optimistic update and stays quiet. + ErrDeclined = errors.New("authorization declined") + + // ErrUnavailable reports that this host has no elevation mechanism we can + // drive: no polkit on a Unix desktop, or an executable we decline to run as + // root. A caller falls back to telling the user which command to run. + ErrUnavailable = errors.New("no privilege elevation mechanism available") +) + +// Run runs this executable with args under the platform's elevation mechanism +// and waits for it to exit. A non-zero exit is returned as an error, so the +// caller can treat a completed Run as the operation having succeeded. +// +// The args are the caller's own command line, so they cross no privilege +// boundary: only a user who has just authenticated as an administrator can get +// them run at all. +func Run(ctx context.Context, args ...string) error { + self, err := trustedSelf() + if err != nil { + return err + } + return run(ctx, self, args) +} + +// Available reports whether Run has a mechanism to use on this host, so a caller +// can offer the prompt only when there is one and otherwise fall back to +// guidance the user can act on. It answers from what is installed, not from what +// the user is allowed to do: an administrator's password may still be required +// and may still not be given, which is ErrDeclined from Run. +func Available() bool { + if _, err := trustedSelf(); err != nil { + // Worth a line: this is also what a build run from a group-writable + // directory hits, and there is nothing in the UI to say why the offer is + // missing. + log.Debugf("not offering privilege elevation: %v", err) + return false + } + return mechanismAvailable() +} diff --git a/client/internal/elevate/output.go b/client/internal/elevate/output.go new file mode 100644 index 000000000..6e1646bd3 --- /dev/null +++ b/client/internal/elevate/output.go @@ -0,0 +1,18 @@ +package elevate + +import "strings" + +// noOutput stands in for a process that said nothing, so that a report of what it +// said still reads as a sentence. +const noOutput = "no output" + +func firstLine(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return noOutput + } + if i := strings.IndexByte(s, '\n'); i >= 0 { + return s[:i] + } + return s +} diff --git a/client/internal/elevate/output_test.go b/client/internal/elevate/output_test.go new file mode 100644 index 000000000..3faacf53a --- /dev/null +++ b/client/internal/elevate/output_test.go @@ -0,0 +1,21 @@ +package elevate + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFirstLine(t *testing.T) { + tests := []struct{ in, want string }{ + {in: "", want: noOutput}, + {in: " \n ", want: noOutput}, + {in: "one line", want: "one line"}, + {in: "first\nsecond", want: "first"}, + {in: "\nsecond\n", want: "second"}, + } + + for _, tt := range tests { + assert.Equal(t, tt.want, firstLine(tt.in), "input %q", tt.in) + } +} diff --git a/client/internal/elevate/run_darwin.go b/client/internal/elevate/run_darwin.go new file mode 100644 index 000000000..6b0e4fc0d --- /dev/null +++ b/client/internal/elevate/run_darwin.go @@ -0,0 +1,359 @@ +package elevate + +import ( + "context" + "errors" + "fmt" + "os" + "runtime" + "strings" + "sync" + "syscall" + "unsafe" + + "github.com/ebitengine/purego" + log "github.com/sirupsen/logrus" +) + +// Authorization Services, reached through purego rather than cgo so the released +// binaries keep building with CGO_ENABLED=0. +// +// The prompt belongs to this process, which is what makes it carry the +// application's name and our own explanation. Going through osascript instead puts +// the very same trampoline behind a dialog attributed to osascript, and means +// handing a shell a command line to re-parse. +// +// # On AuthorizationExecuteWithPrivileges +// +// It is deprecated, and Apple's guidance (Quinn, "BSD Privilege Escalation on +// macOS", developer.apple.com/forums/thread/708765) is "while it still works, it's +// been deprecated for many years. Do not use it in a widely distributed product." +// It is used here anyway, knowingly, because the alternatives Apple offers are for +// *obtaining* ongoing privileges — an installer package, SMAppService, SMJobBless — +// and NetBird already has what they would install: a launchd daemon running as +// root. What is missing is only a way for an unprivileged client to ask it to act. +// +// The way to that without a deprecated call is to authorize the client instead of +// elevating one: the app takes the right with AuthorizationCreate, passes the +// AuthorizationExternalForm to the daemon, and the daemon checks it with +// AuthorizationCopyRights before acting — none of which is deprecated. It is the +// better design and it is where this should end up. It also means the daemon +// accepting an authorization over its control socket, which is a new way to be +// asked for privileged work and wants reviewing as such, so it is deliberately not +// bundled in with the rest of this. +// +// Until then, three things keep the deprecation from being a trap. Every symbol is +// resolved with an error rather than a panic, so a macOS that has dropped this +// function leaves the app offering the user a command instead of crashing on the +// way to a prompt. A failure to run the tool is reported as ErrUnavailable, so the +// fallback is the same one an agent-less Linux session gets. And the whole path +// runs under guard, which turns a panic out of the FFI layer into that same +// fallback. +// +// The trampoline passes on the environment it was given, so what it starts as root +// must be an executable this user's peers cannot influence: that is what +// trustedSelf refuses, and what signing the binary settles for the loader. + +const ( + securityFramework = "/System/Library/Frameworks/Security.framework/Security" + libSystem = "/usr/lib/libSystem.B.dylib" + + // trampoline is what the framework hands the tool to. Present on every macOS, + // and worth confirming before offering a prompt rather than mid-prompt. + trampoline = "/usr/libexec/security_authtrampoline" +) + +// rightExecute is the right an administrator holds, and what +// AuthorizationExecuteWithPrivileges requires of us. +const rightExecute = "system.privilege.admin" + +// promptKey is kAuthorizationEnvironmentPrompt, which puts a sentence of ours above +// the system's in the dialog. It is about the change rather than the mechanism. +const ( + promptKey = "prompt" + promptText = "NetBird needs to change a setting that grants SSH access to this computer." +) + +// OSStatus values from SecBase.h that mean something to us; anything else is +// reported as it comes. +const ( + errAuthorizationSuccess = 0 + errAuthorizationDenied = -60005 + errAuthorizationCanceled = -60006 + errAuthorizationInteractionNotAllowed = -60007 + errAuthorizationToolExecuteFailure = -60031 + errAuthorizationToolEnvironmentError = -60032 +) + +// AuthorizationFlags from Authorization.h. +const ( + flagDefaults = 0 + flagInteractionAllowed = 1 << 0 + flagExtendRights = 1 << 1 + flagDestroyRights = 1 << 3 + flagPreAuthorize = 1 << 4 +) + +// authorizationItem mirrors AuthorizationItem: a name, and a value the name gives +// meaning to. 32 bytes on both amd64 and arm64. +type authorizationItem struct { + name *byte + valueLength uintptr + value unsafe.Pointer + // flags is reserved by the API and always zero. Declared because the layout + // is the contract: without it the struct is 24 bytes where C reads 32. + flags uint32 //nolint:unused // part of the C layout +} + +// authorizationItemSet mirrors AuthorizationItemSet, which serves as both an +// AuthorizationRights and an AuthorizationEnvironment. +type authorizationItemSet struct { + count uint32 + items *authorizationItem +} + +var ( + authorizationCreate func(rights, environment *authorizationItemSet, flags uint32, authorization *uintptr) int32 + authorizationExecuteWithPrivileges func(authorization uintptr, pathToTool string, options uint32, arguments *uintptr, communicationsPipe *uintptr) int32 + authorizationFree func(authorization uintptr, flags uint32) int32 + fileno func(stream uintptr) int32 + fclose func(stream uintptr) int32 + + loadOnce sync.Once + loadErr error +) + +// load resolves the functions once. A framework that cannot be opened, or a symbol +// that is no longer there, leaves the host without a mechanism rather than taking +// the process down with it: see the note on deprecation above. +func load() error { + loadOnce.Do(func() { loadErr = guard("loading Security.framework", resolve) }) + return loadErr +} + +// guard turns a panic out of the FFI layer into an error, so an API that has +// changed under us costs the user a prompt rather than the window they were +// clicking in. purego panics on a signature it cannot map, and this is the one +// place in the client that calls a deprecated system function. +// +// It catches Go panics, which is what purego raises. A fault inside the framework +// itself is not a panic and not recoverable; the layout the tests pin down is what +// stands between us and that. +func guard(what string, fn func() error) (err error) { + defer func() { + r := recover() + if r == nil { + return + } + log.Errorf("%s panicked: %v", what, r) + err = fmt.Errorf("%w: %s: %v", ErrUnavailable, what, r) + }() + return fn() +} + +func resolve() error { + security, err := purego.Dlopen(securityFramework, purego.RTLD_LAZY|purego.RTLD_GLOBAL) + if err != nil { + return fmt.Errorf("open %s: %w", securityFramework, err) + } + system, err := purego.Dlopen(libSystem, purego.RTLD_LAZY|purego.RTLD_GLOBAL) + if err != nil { + return fmt.Errorf("open %s: %w", libSystem, err) + } + + // purego.RegisterLibFunc panics on a symbol it cannot find, which is not how a + // deprecated function's disappearance should reach the user. + for _, fn := range []struct { + ptr any + handle uintptr + name string + }{ + {&authorizationCreate, security, "AuthorizationCreate"}, + {&authorizationExecuteWithPrivileges, security, "AuthorizationExecuteWithPrivileges"}, + {&authorizationFree, security, "AuthorizationFree"}, + {&fileno, system, "fileno"}, + {&fclose, system, "fclose"}, + } { + symbol, err := purego.Dlsym(fn.handle, fn.name) + if err != nil { + return fmt.Errorf("resolve %s: %w", fn.name, err) + } + if symbol == 0 { + return fmt.Errorf("resolve %s: not present on this system", fn.name) + } + purego.RegisterFunc(fn.ptr, symbol) + } + return nil +} + +// run asks the system to run self as root: first for the right, which is what puts +// up the authentication dialog and collects the password or takes the Touch ID, +// then for the tool. The credentials go to the system's authorization trampoline +// and never to us. +// +// The context bounds only our own waiting; the dialog belongs to the system and +// closes when the user answers it. +func run(ctx context.Context, self string, args []string) error { + if err := load(); err != nil { + return fmt.Errorf("%w: %v", ErrUnavailable, err) + } + + return guard("asking for privileges", func() error { + authorization, err := authorize() + if err != nil { + return err + } + defer authorizationFree(authorization, flagDestroyRights) + + return execute(ctx, authorization, self, args) + }) +} + +func mechanismAvailable() bool { + if err := load(); err != nil { + return false + } + info, err := os.Stat(trampoline) + return err == nil && !info.IsDir() +} + +// authorize obtains the right, prompting for it. A dismissed dialog comes back as +// errAuthorizationCanceled and a password given up on as errAuthorizationDenied; +// both are the user's answer rather than a failure. +func authorize() (uintptr, error) { + var pinner runtime.Pinner + defer pinner.Unpin() + + rights := itemSet(&pinner, authorizationItem{name: cString(&pinner, rightExecute)}) + environment := itemSet(&pinner, promptItem(&pinner)) + + var authorization uintptr + status := authorizationCreate(rights, environment, + flagDefaults|flagInteractionAllowed|flagPreAuthorize|flagExtendRights, &authorization) + + switch status { + case errAuthorizationSuccess: + return authorization, nil + case errAuthorizationCanceled, errAuthorizationDenied: + return 0, ErrDeclined + case errAuthorizationInteractionNotAllowed: + // Nowhere to put a dialog, so there is nobody to ask: a launch daemon, or + // a session with no window server. + return 0, fmt.Errorf("%w: this session cannot show an authorization prompt", ErrUnavailable) + default: + return 0, fmt.Errorf("request %s: OSStatus %d", rightExecute, status) + } +} + +// execute runs the tool with the right in hand and waits for it by reading the pipe +// it is given until the tool closes it. +// +// AuthorizationExecuteWithPrivileges reports no exit status and does not say what +// process it started, which is why the one-shot says so itself: what it prints is +// the only evidence that the change was applied. +func execute(ctx context.Context, authorization uintptr, self string, args []string) error { + var pinner runtime.Pinner + defer pinner.Unpin() + + argv := make([]uintptr, 0, len(args)+1) + for _, arg := range args { + argv = append(argv, uintptr(unsafe.Pointer(cString(&pinner, arg)))) + } + argv = append(argv, 0) + pinner.Pin(&argv[0]) + + var pipe uintptr + status := authorizationExecuteWithPrivileges(authorization, self, flagDefaults, &argv[0], &pipe) + switch status { + case errAuthorizationSuccess: + case errAuthorizationCanceled: + return ErrDeclined + case errAuthorizationToolExecuteFailure, errAuthorizationToolEnvironmentError: + // The right was granted and the tool still did not start. Nothing the user + // can do about it from here, so point them at the command instead. + return fmt.Errorf("%w: the system would not run %s elevated (OSStatus %d)", ErrUnavailable, self, status) + default: + return fmt.Errorf("run %s elevated: OSStatus %d", self, status) + } + + out, err := readPipe(ctx, pipe) + if err != nil { + return err + } + return checkApplied(out) +} + +// checkApplied reads the one-shot's report, which stands in for the exit status +// there is no way to ask for here. A run that said nothing did not apply the +// change, whatever else went on. +func checkApplied(out string) error { + if !strings.Contains(out, AppliedMarker) { + return fmt.Errorf("elevated netbird did not report the change as applied: %s", firstLine(out)) + } + return nil +} + +// readPipe drains the tool's output, which ends when the tool exits and is +// therefore also how we wait for it. +func readPipe(ctx context.Context, pipe uintptr) (string, error) { + if pipe == 0 { + return "", nil + } + defer fclose(pipe) + + fd := int(fileno(pipe)) + if fd < 0 { + return "", nil + } + + var out strings.Builder + buf := make([]byte, 4096) + for { + if err := ctx.Err(); err != nil { + return out.String(), err + } + n, err := syscall.Read(fd, buf) + if n > 0 { + out.Write(buf[:n]) + } + switch { + case errors.Is(err, syscall.EINTR): + // A signal landed mid-read, which says nothing about the tool. + continue + case err != nil: + log.Debugf("read the elevated process's output: %v", err) + return out.String(), nil + case n <= 0: + // End of file: the tool closed the pipe, which is how it exiting + // reaches us. + return out.String(), nil + } + } +} + +// itemSet builds an AuthorizationItemSet over items, pinned for the call. +func itemSet(pinner *runtime.Pinner, items ...authorizationItem) *authorizationItemSet { + pinner.Pin(&items[0]) + set := &authorizationItemSet{count: uint32(len(items)), items: &items[0]} + pinner.Pin(set) + return set +} + +// promptItem is the environment entry carrying our sentence for the dialog. +func promptItem(pinner *runtime.Pinner) authorizationItem { + value := []byte(promptText) + pinner.Pin(&value[0]) + return authorizationItem{ + name: cString(pinner, promptKey), + valueLength: uintptr(len(value)), + value: unsafe.Pointer(&value[0]), + } +} + +// cString returns a NUL-terminated copy of s, pinned so the C side may hold it for +// the duration of the call. +func cString(pinner *runtime.Pinner, s string) *byte { + b := append([]byte(s), 0) + pinner.Pin(&b[0]) + return &b[0] +} diff --git a/client/internal/elevate/run_darwin_test.go b/client/internal/elevate/run_darwin_test.go new file mode 100644 index 000000000..f6c58c8cb --- /dev/null +++ b/client/internal/elevate/run_darwin_test.go @@ -0,0 +1,111 @@ +package elevate + +import ( + "errors" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The framework has to load and the symbols have to resolve, or nothing else here +// means anything. +func TestSecurityFrameworkLoads(t *testing.T) { + require.NoError(t, load(), "Security.framework must open") + + for name, fn := range map[string]any{ + "AuthorizationCreate": authorizationCreate, + "AuthorizationExecuteWithPrivileges": authorizationExecuteWithPrivileges, + "AuthorizationFree": authorizationFree, + "fileno": fileno, + "fclose": fclose, + } { + assert.NotNil(t, fn, "%s must resolve", name) + } +} + +// A request with no interaction allowed exercises the whole call — the rights and +// environment structs, and the OSStatus that comes back — without a dialog anybody +// has to answer. What the system decides is its business; that it decides at all is +// what this asserts. +func TestAuthorizationCreateWithoutInteraction(t *testing.T) { + if err := load(); err != nil { + t.Skipf("Security.framework did not open: %v", err) + } + + var pinner runtime.Pinner + defer pinner.Unpin() + + rights := itemSet(&pinner, authorizationItem{name: cString(&pinner, rightExecute)}) + environment := itemSet(&pinner, promptItem(&pinner)) + require.EqualValues(t, 1, rights.count, "the rights struct layout must match the C one") + + var authorization uintptr + status := authorizationCreate(rights, environment, flagDefaults|flagExtendRights, &authorization) + + switch status { + case errAuthorizationSuccess: + // Credentials were already cached for this session. + authorizationFree(authorization, flagDestroyRights) + case errAuthorizationDenied, errAuthorizationInteractionNotAllowed: + // The expected answers when nobody may be asked. + default: + require.Failf(t, "unknown OSStatus", "AuthorizationCreate returned %d, want a status we recognise", status) + } +} + +// Asking with a right nobody has must not be mistaken for a declined prompt: the +// caller would report nothing at all. +func TestAuthorizeUnknownRightIsNotDeclined(t *testing.T) { + if err := load(); err != nil { + t.Skipf("Security.framework did not open: %v", err) + } + + var pinner runtime.Pinner + defer pinner.Unpin() + + rights := itemSet(&pinner, authorizationItem{name: cString(&pinner, "io.netbird.right.that.does.not.exist")}) + + var authorization uintptr + status := authorizationCreate(rights, nil, flagDefaults|flagExtendRights, &authorization) + if status == errAuthorizationSuccess { + authorizationFree(authorization, flagDestroyRights) + } + assert.NotEqual(t, int32(errAuthorizationSuccess), status, "a right that does not exist must not be granted") +} + +func TestMechanismAvailable(t *testing.T) { + assert.True(t, mechanismAvailable(), "the trampoline exists on every macOS") +} + +// The one-shot's report is what stands in for an exit status here, so a run that +// says nothing must not read as success. +func TestCheckApplied(t *testing.T) { + require.NoError(t, checkApplied(AppliedMarker+"\n"), "the report the one-shot prints") + require.NoError(t, checkApplied("some warning\n"+AppliedMarker+"\n"), "the report after other output") + + assert.Error(t, checkApplied(""), "a run that printed nothing did not apply the change") + assert.Error(t, checkApplied("dyld: library not loaded\n"), "output that is not the report") +} + +// A panic out of the FFI layer has to reach the caller as "no mechanism", which is +// the outcome that offers the user the command instead of taking the window down. +func TestGuardTurnsAPanicIntoUnavailable(t *testing.T) { + err := guard("pretending to call something", func() error { + panic("purego: signature it cannot map") + }) + + require.ErrorIs(t, err, ErrUnavailable, "a panic must read as a missing mechanism") + assert.Contains(t, err.Error(), "pretending to call something", "what panicked") +} + +// guard wraps every darwin path, so what a caller switches on has to survive it. +func TestGuardPassesErrorsThrough(t *testing.T) { + sentinel := errors.New("the call itself failed") + assert.ErrorIs(t, guard("calling", func() error { return sentinel }), sentinel, + "the error it was given") + assert.ErrorIs(t, guard("calling", func() error { return ErrDeclined }), ErrDeclined, + "a declined prompt stays declined") + assert.NoError(t, guard("calling", func() error { return nil }), "a call that worked") +} diff --git a/client/internal/elevate/run_unix.go b/client/internal/elevate/run_unix.go new file mode 100644 index 000000000..b2de09a49 --- /dev/null +++ b/client/internal/elevate/run_unix.go @@ -0,0 +1,117 @@ +//go:build linux + +package elevate + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" +) + +// pkexec exit codes that are about the authorization rather than about the program +// we asked it to run. The manual page reserves both. +const ( + // exitDismissed is returned when the user dismissed the authentication + // dialog. + exitDismissed = 126 + // exitNotAuthorized is returned when the authorization was not obtained. That + // covers the user saying no as well as pkexec having had nobody to ask: see + // noAgentMarkers. + exitNotAuthorized = 127 +) + +// exitNotAuthorized covers three different endings that only pkexec's own words +// tell apart, so they are matched here. Read with LC_ALL=C so the words are the +// ones written below. +// +// refusedMarker is a refusal: the user said no, gave up on the password, or holds +// an account that may not elevate at all. +const refusedMarker = "Not authorized" + +// noAgentMarkers say pkexec had no way to ask: no agent registered for the +// session, and no controlling terminal for the textual agent it falls back to. +var noAgentMarkers = []string{"authentication agent", "controlling terminal"} + +// run asks polkit to run self as root. pkexec hands the request to the session's +// polkit agent, which is what prompts and what collects any password; we see only +// its verdict. +// +// The environment is otherwise deliberately not passed through: pkexec clears it +// bar a small allowlist, and the one-shot needs nothing from it. +func run(ctx context.Context, self string, args []string) error { + pkexec, err := exec.LookPath("pkexec") + if err != nil { + return fmt.Errorf("%w: pkexec is not installed", ErrUnavailable) + } + + cmd := exec.CommandContext(ctx, pkexec, append([]string{self}, args...)...) + // C locale so pkexec's own diagnostics are the ones noAgentMarkers knows. + cmd.Env = append(os.Environ(), "LC_ALL=C") + var stderr strings.Builder + cmd.Stderr = &stderr + // The one-shot reports itself on stdout for macOS's sake, where there is no + // exit status to read. Here there is one, so that line is noise. + cmd.Stdout = io.Discard + + err = cmd.Run() + if err == nil { + return nil + } + + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) { + return fmt.Errorf("run pkexec: %w", err) + } + + // Matched against everything pkexec said, reported as one line: a complaint + // that is not the first thing printed still has to be recognised, and reading + // it as a refusal would swallow it. + full := stderr.String() + out := firstLine(full) + + switch exitErr.ExitCode() { + case exitDismissed: + return ErrDeclined + case exitNotAuthorized: + return notAuthorized(full, out) + default: + return fmt.Errorf("elevated netbird exited with %d: %s", exitErr.ExitCode(), out) + } +} + +// notAuthorized sorts out the three endings pkexec reports as exitNotAuthorized. +// +// It also returns that code when the authorization succeeded and it then could +// not run the program, so a refusal has to be recognised rather than assumed: +// reading every one of these as "the user said no" would revert the control in +// silence on a host where elevation is broken. +func notAuthorized(full, out string) error { + switch { + case hasAny(full, noAgentMarkers): + return fmt.Errorf("%w: polkit had no way to ask: %s", ErrUnavailable, out) + case out == noOutput, strings.Contains(full, refusedMarker): + // The user said no, which needs no message; that an account barred from + // elevating altogether lands here too is why the reason is kept. + return fmt.Errorf("%w: %s", ErrDeclined, out) + default: + return fmt.Errorf("pkexec could not run elevated netbird: %s", out) + } +} + +func hasAny(s string, markers []string) bool { + for _, marker := range markers { + if strings.Contains(s, marker) { + return true + } + } + return false +} + +func mechanismAvailable() bool { + _, err := exec.LookPath("pkexec") + return err == nil +} diff --git a/client/internal/elevate/run_unix_test.go b/client/internal/elevate/run_unix_test.go new file mode 100644 index 000000000..c868f9a74 --- /dev/null +++ b/client/internal/elevate/run_unix_test.go @@ -0,0 +1,110 @@ +//go:build linux + +package elevate + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakePkexec puts a pkexec on PATH that exits with the given code, so the +// mapping from polkit's exit codes onto our errors can be exercised without a +// polkit agent. +func fakePkexec(t *testing.T, exitCode int, stderr string) { + t.Helper() + + dir := t.TempDir() + script := fmt.Sprintf("#!/bin/sh\necho %s >&2\nexit %d\n", shellQuote(stderr), exitCode) + require.NoError(t, os.WriteFile(filepath.Join(dir, "pkexec"), []byte(script), 0o700), "write the fake pkexec") + t.Setenv("PATH", dir) +} + +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +func TestRunMapsPkexecExitCodes(t *testing.T) { + tests := []struct { + name string + exitCode int + stderr string + wantErr error + }{ + {name: "applied", exitCode: 0}, + { + name: "dialog dismissed", + exitCode: exitDismissed, + stderr: "Error executing command as another user: Request dismissed", + wantErr: ErrDeclined, + }, + { + // What a graphical agent reports for a cancelled prompt. Not a + // failure: the user was asked and answered. + name: "prompt cancelled", + exitCode: exitNotAuthorized, + stderr: "Error executing command as another user: Not authorized", + wantErr: ErrDeclined, + }, + { + // The same status, but pkexec never got to ask anybody. + name: "no agent and no terminal to fall back on", + exitCode: exitNotAuthorized, + stderr: "Error creating textual authentication agent: Error opening current controlling terminal for the process (`/dev/tty'): No such device or address", + wantErr: ErrUnavailable, + }, + { + // And the same status again once the authorization succeeded and + // pkexec could not run what it had been authorized to run. Reading + // that as a refusal would revert the control in silence on a host + // where elevation is broken. + name: "authorized but not runnable", + exitCode: exitNotAuthorized, + stderr: "Error executing command as another user: No such file or directory", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fakePkexec(t, tt.exitCode, tt.stderr) + + err := run(context.Background(), "/nonexistent/netbird-ui", []string{"--flag"}) + switch { + case tt.wantErr != nil: + require.ErrorIs(t, err, tt.wantErr, "exit %d said %q", tt.exitCode, tt.stderr) + case tt.exitCode == 0: + require.NoError(t, err, "a pkexec that exited cleanly applied the change") + default: + require.Error(t, err, "exit %d said %q", tt.exitCode, tt.stderr) + assert.NotErrorIs(t, err, ErrDeclined, "not the user's answer") + assert.NotErrorIs(t, err, ErrUnavailable, "not a missing mechanism") + } + }) + } +} + +// An exit code that is not polkit's is the one-shot's own failure, and has to +// stay distinguishable from a declined prompt: the caller reports it. +func TestRunReportsOneShotFailure(t *testing.T) { + fakePkexec(t, 3, "the one-shot said no") + + err := run(context.Background(), "/nonexistent/netbird-ui", nil) + + require.Error(t, err, "a one-shot that failed is not a prompt that was answered") + assert.NotErrorIs(t, err, ErrDeclined, "not the user's answer") + assert.NotErrorIs(t, err, ErrUnavailable, "not a missing mechanism") +} + +func TestRunWithoutPkexecIsUnavailable(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + + err := run(context.Background(), "/nonexistent/netbird-ui", nil) + require.ErrorIs(t, err, ErrUnavailable, "no pkexec means no mechanism") + assert.False(t, mechanismAvailable(), "mechanismAvailable without pkexec on PATH") +} diff --git a/client/internal/elevate/run_unsupported.go b/client/internal/elevate/run_unsupported.go new file mode 100644 index 000000000..d1daf3184 --- /dev/null +++ b/client/internal/elevate/run_unsupported.go @@ -0,0 +1,19 @@ +//go:build !windows && !darwin && !linux + +package elevate + +import "context" + +// run reports that this platform has no elevation prompt to drive. +// +// The desktop app is the only caller and is not built for any of these: mobile +// and WASM have no local user to ask, and the FreeBSD client ships without a UI. +// pkexec would be the mechanism there, and run_unix.go is what to widen if that +// changes. +func run(context.Context, string, []string) error { + return ErrUnavailable +} + +func mechanismAvailable() bool { + return false +} diff --git a/client/internal/elevate/run_windows.go b/client/internal/elevate/run_windows.go new file mode 100644 index 000000000..eef4c23ce --- /dev/null +++ b/client/internal/elevate/run_windows.go @@ -0,0 +1,187 @@ +package elevate + +import ( + "context" + "errors" + "fmt" + "runtime" + "unsafe" + + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" +) + +const ( + // seeMaskNoCloseProcess keeps the started process's handle open in + // hProcess so we can wait for it. + seeMaskNoCloseProcess = 0x00000040 + // seeMaskNoAsync makes ShellExecuteExW finish its work before returning, + // which it must when the calling thread does not pump messages. + seeMaskNoAsync = 0x00000100 + // seeMaskFlagNoUI suppresses the shell's own error dialogs; the UAC consent + // dialog is not one of them and still appears. + seeMaskFlagNoUI = 0x00000400 + + // swHide: the one-shot has no window to show. + swHide = 0 +) + +// shellExecuteInfoW mirrors SHELLEXECUTEINFOW. The field order and Go's own +// padding match the C layout on both 386 and amd64. +type shellExecuteInfoW struct { + cbSize uint32 + fMask uint32 + hwnd windows.HWND + lpVerb *uint16 + lpFile *uint16 + lpParameters *uint16 + lpDirectory *uint16 + nShow int32 + hInstApp windows.Handle + lpIDList uintptr + lpClass *uint16 + hkeyClass windows.Handle + dwHotKey uint32 + hIconOrMonitor windows.Handle + hProcess windows.Handle +} + +var ( + shell32 = windows.NewLazySystemDLL("shell32.dll") + procShellExecuteEx = shell32.NewProc("ShellExecuteExW") +) + +// run starts self elevated with the "runas" verb, which is what raises the UAC +// consent dialog, and waits for it to finish. Windows decides whether consent is +// enough or an administrator's credentials are needed, and collects them itself. +func run(ctx context.Context, self string, args []string) error { + verb, err := windows.UTF16PtrFromString("runas") + if err != nil { + return fmt.Errorf("encode verb: %w", err) + } + file, err := windows.UTF16PtrFromString(self) + if err != nil { + return fmt.Errorf("encode %s: %w", self, err) + } + params, err := windows.UTF16PtrFromString(windows.ComposeCommandLine(args)) + if err != nil { + return fmt.Errorf("encode arguments: %w", err) + } + + info := shellExecuteInfoW{ + fMask: seeMaskNoCloseProcess | seeMaskNoAsync | seeMaskFlagNoUI, + hwnd: ownerWindow(), + lpVerb: verb, + lpFile: file, + lpParameters: params, + nShow: swHide, + } + info.cbSize = uint32(unsafe.Sizeof(info)) + + process, err := shellExecute(&info) + if err != nil { + return err + } + defer func() { + if err := windows.CloseHandle(process); err != nil { + log.Debugf("close elevated process handle: %v", err) + } + }() + + return waitForProcess(ctx, process) +} + +// shellExecute performs the call itself. ShellExecuteExW wants COM initialised on +// the calling thread, so the goroutine is pinned to one for the duration and COM +// is set up on it; an "already initialised, different mode" answer is fine, +// because then somebody else has done it for us. +func shellExecute(info *shellExecuteInfoW) (windows.Handle, error) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + switch err := windows.CoInitializeEx(0, windows.COINIT_APARTMENTTHREADED); { + case err == nil, isHResult(err, windows.S_FALSE): + // Ours, or already initialised in the same mode: either way this call + // counts and has to be balanced. + defer windows.CoUninitialize() + case isHResult(err, windows.RPC_E_CHANGED_MODE): + // The thread is already in the other apartment model. ShellExecuteExW + // works there too, and there is nothing of ours to balance. + default: + return 0, fmt.Errorf("initialise COM: %w", err) + } + + ret, _, lastErr := procShellExecuteEx.Call(uintptr(unsafe.Pointer(info))) + if ret != 0 { + return info.hProcess, nil + } + + if errors.Is(lastErr, windows.ERROR_CANCELLED) { + return 0, ErrDeclined + } + return 0, fmt.Errorf("run elevated: %w", lastErr) +} + +// ownerWindow returns this process's foreground window, and 0 when the window in +// front belongs to somebody else or cannot be attributed. ShellExecuteExW takes it +// as the parent for the UI it raises, which is what keeps the consent dialog in +// front of the window the user was just clicking in instead of behind it. It is +// also what a remote-desktop session needs to place the dialog at all when the +// secure desktop is switched off. +func ownerWindow() windows.HWND { + hwnd := windows.GetForegroundWindow() + if hwnd == 0 { + return 0 + } + + var pid uint32 + if _, err := windows.GetWindowThreadProcessId(hwnd, &pid); err != nil { + log.Debugf("cannot attribute the foreground window, raising the prompt without an owner: %v", err) + return 0 + } + if pid != windows.GetCurrentProcessId() { + return 0 + } + return hwnd +} + +// isHResult reports whether err carries the given HRESULT. CoInitializeEx +// returns its HRESULT as an Errno, so the comparison is on the raw value. +func isHResult(err error, hresult windows.Handle) bool { + var errno windows.Errno + return errors.As(err, &errno) && uintptr(errno) == uintptr(hresult) +} + +func waitForProcess(ctx context.Context, process windows.Handle) error { + // The wait is interruptible so a cancelled context stops us waiting on a + // consent dialog nobody is going to answer. The elevated process is not + // ours to kill, and it either applies the change or does not. + for { + event, err := windows.WaitForSingleObject(process, 250) + if err != nil { + return fmt.Errorf("wait for the elevated process: %w", err) + } + if event == uint32(windows.WAIT_OBJECT_0) { + break + } + if err := ctx.Err(); err != nil { + return err + } + } + + var code uint32 + if err := windows.GetExitCodeProcess(process, &code); err != nil { + return fmt.Errorf("read the elevated process's exit code: %w", err) + } + if code != 0 { + return fmt.Errorf("elevated netbird exited with %d", code) + } + return nil +} + +// mechanismAvailable is true on Windows: UAC prompts for consent when the user +// is an administrator and for an administrator's credentials when they are not, +// so there is always something to ask. +func mechanismAvailable() bool { + return true +} diff --git a/client/internal/elevate/trusted.go b/client/internal/elevate/trusted.go new file mode 100644 index 000000000..c11054c45 --- /dev/null +++ b/client/internal/elevate/trusted.go @@ -0,0 +1,40 @@ +package elevate + +import ( + "fmt" + "os" + "path/filepath" +) + +// trustedSelf returns the path of this executable, provided it is one we are +// willing to have run as root. +// +// The check is what keeps elevation from becoming a way to launder someone +// else's code into a root process: the user consents to NetBird being elevated, +// having been shown NetBird's name, so what runs must be the file NetBird was +// installed as and not something a third party could have swapped for it. An +// executable only its owner can write is that; anything wider is refused, and +// the caller falls back to showing the command instead. +// +// The owner writing to their own executable is not part of that threat: code +// running as the user can already prompt them for anything, and could just as +// well ask them to run the command by hand. What matters is that no *other* +// unprivileged account can reach it. +func trustedSelf() (string, error) { + exe, err := os.Executable() + if err != nil { + return "", fmt.Errorf("locate this executable: %w", err) + } + + // Resolve symlinks so the checks below apply to the file that would actually + // be executed, not to a link somebody else may control. + resolved, err := filepath.EvalSymlinks(exe) + if err != nil { + return "", fmt.Errorf("resolve %s: %w", exe, err) + } + + if err := checkOnlyOwnerWritable(resolved); err != nil { + return "", fmt.Errorf("%w: %s cannot be trusted to run as root: %w", ErrUnavailable, resolved, err) + } + return resolved, nil +} diff --git a/client/internal/elevate/trusted_group_darwin.go b/client/internal/elevate/trusted_group_darwin.go new file mode 100644 index 000000000..a4b387ec4 --- /dev/null +++ b/client/internal/elevate/trusted_group_darwin.go @@ -0,0 +1,10 @@ +package elevate + +// adminWriteGIDs are the groups whose write access to an executable does not +// widen who could authorize elevating it. +// +// macOS installs applications as root:admin, mode 0775, /Applications included, +// so requiring owner-only write would reject every normal install. Group admin +// (gid 80) is exactly the set of accounts that can answer the authentication +// dialog, so its write access grants nothing the prompt would not. +var adminWriteGIDs = []uint32{0, 80} diff --git a/client/internal/elevate/trusted_group_unix.go b/client/internal/elevate/trusted_group_unix.go new file mode 100644 index 000000000..7aa336423 --- /dev/null +++ b/client/internal/elevate/trusted_group_unix.go @@ -0,0 +1,9 @@ +//go:build !windows && !darwin + +package elevate + +// adminWriteGIDs are the groups whose write access to an executable does not +// widen who could authorize elevating it. Only root's own group qualifies here: +// a distribution installs into root-owned directories, and there is no +// system-wide administrators group that both writes them and answers polkit. +var adminWriteGIDs = []uint32{0} diff --git a/client/internal/elevate/trusted_unix.go b/client/internal/elevate/trusted_unix.go new file mode 100644 index 000000000..f9d1a1b7e --- /dev/null +++ b/client/internal/elevate/trusted_unix.go @@ -0,0 +1,119 @@ +//go:build !windows + +package elevate + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "strconv" + "syscall" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/getent" +) + +// checkOnlyOwnerWritable reports an error unless path, and every directory leading +// to it, is owned by either root or this user and writable by nobody who could not +// already act as its owner. A writable directory is as good as a writable file, +// since anything in it can be replaced, so the whole chain is checked. +func checkOnlyOwnerWritable(path string) error { + self := uint32(os.Getuid()) + + for dir := path; ; dir = filepath.Dir(dir) { + info, err := os.Lstat(dir) + if err != nil { + return fmt.Errorf("stat %s: %w", dir, err) + } + + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return errors.New("file ownership is unavailable on this platform") + } + if stat.Uid != 0 && stat.Uid != self { + return fmt.Errorf("%s is owned by uid %d, neither root nor this user", dir, stat.Uid) + } + + if err := checkWriteBits(dir, info, stat.Uid, stat.Gid); err != nil { + return err + } + + if parent := filepath.Dir(dir); parent == dir { + return nil + } + } +} + +func checkWriteBits(path string, info os.FileInfo, uid, gid uint32) error { + // On a directory the sticky bit stands in for the write bits: whoever may + // write there still cannot replace an entry they do not own, which is the + // only thing that would matter to us. /tmp is the usual example. + sticky := info.IsDir() && info.Mode()&os.ModeSticky != 0 + + return writeBitsAllow(path, info.Mode().Perm(), sticky, groupWriteAllowed(uid, gid)) +} + +// writeBitsAllow decides on the permission bits alone, given whether the group's +// write access has been vouched for. +func writeBitsAllow(path string, perm os.FileMode, sticky, groupAllowed bool) error { + if sticky { + return nil + } + if perm&0o020 != 0 && !groupAllowed { + return fmt.Errorf("%s is writable by a group with members other than its owner (%v)", path, perm) + } + if perm&0o002 != 0 { + return fmt.Errorf("%s is world-writable (%v)", path, perm) + } + return nil +} + +// groupWriteAllowed reports whether a group's write access to a file owned by uid +// puts it in reach of anyone who could not already act as that owner. +// +// Two ways it does not. A group in adminWriteGIDs holds the accounts that can +// answer the elevation prompt anyway. And a user private group is how Debian, +// Ubuntu and Fedora ship: their umask of 002 makes a home directory and +// everything built in it group-writable, so refusing that would refuse every +// build not installed from a package. +func groupWriteAllowed(uid, gid uint32) bool { + if slices.Contains(adminWriteGIDs, gid) { + return true + } + + group, err := getent.LookupGroupID(strconv.FormatUint(uint64(gid), 10)) + if err != nil { + log.Debugf("cannot look up group %d, treating it as shared: %v", gid, err) + return false + } + owner, err := getent.LookupUserID(strconv.FormatUint(uint64(uid), 10)) + if err != nil { + log.Debugf("cannot look up uid %d, treating its group as shared: %v", uid, err) + return false + } + + if group.Name != owner.Username { + return false + } + return !groupHasOtherMembers(group.Name, owner.Username) +} + +// groupHasOtherMembers reports whether the group lists a member besides owner. +// +// Sharing the owner's name is what a user private group is recognised by, and it +// says nothing about who is in it: a group that has since gained a member is +// still named that way, and that member can write whatever the group can. So the +// membership is read rather than assumed. A group whose members cannot be +// listed, because no source on this host describes it, is treated as shared: +// the name alone cannot vouch for who writes through it. +func groupHasOtherMembers(name, owner string) bool { + members, err := getent.GroupMembers(name) + if err != nil { + log.Debugf("cannot list the members of group %q, treating it as shared: %v", name, err) + return true + } + return slices.ContainsFunc(members, func(member string) bool { return member != owner }) +} diff --git a/client/internal/elevate/trusted_unix_test.go b/client/internal/elevate/trusted_unix_test.go new file mode 100644 index 000000000..7c0c5a966 --- /dev/null +++ b/client/internal/elevate/trusted_unix_test.go @@ -0,0 +1,148 @@ +//go:build !windows + +package elevate + +import ( + "os" + "os/user" + "path/filepath" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ownerOnlyDir is t.TempDir() with the write bits tightened. testing creates its +// numbered directory with 0777 minus the umask, so under the common 002 umask it +// is group-writable and would fail the check under test on its own. +func ownerOnlyDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.Chmod(dir, 0o755), "tighten the temporary directory") + return dir +} + +// writeExecutable creates a plain executable file, the shape trustedSelf checks. +func writeExecutable(t *testing.T, dir string) string { + t.Helper() + path := filepath.Join(dir, "netbird-ui") + require.NoError(t, os.WriteFile(path, []byte("#!/bin/sh\n"), 0o755), "write the executable") + require.NoError(t, os.Chmod(path, 0o755), "set the executable's mode") + return path +} + +func TestCheckOnlyOwnerWritableAcceptsOwnerOnly(t *testing.T) { + err := checkOnlyOwnerWritable(writeExecutable(t, ownerOnlyDir(t))) + assert.NoError(t, err, "an owner-only writable executable is trustworthy") +} + +func TestCheckOnlyOwnerWritableRejectsWorldWritableFile(t *testing.T) { + path := writeExecutable(t, ownerOnlyDir(t)) + require.NoError(t, os.Chmod(path, 0o777), "make the executable world-writable") + + assert.Error(t, checkOnlyOwnerWritable(path), "a world-writable executable must be refused") +} + +// The permission policy on its own, without a filesystem to arrange: whether the +// group has been vouched for is the only thing that makes group write acceptable. +func TestWriteBitsAllow(t *testing.T) { + tests := []struct { + name string + perm os.FileMode + sticky bool + groupAllowed bool + wantErr bool + }{ + {name: "owner only", perm: 0o755}, + {name: "group write in a private group", perm: 0o775, groupAllowed: true}, + {name: "group write in a shared group", perm: 0o775, wantErr: true}, + {name: "world write", perm: 0o777, groupAllowed: true, wantErr: true}, + {name: "world write on a sticky directory", perm: 0o777, sticky: true}, + {name: "group write on a sticky directory", perm: 0o775, sticky: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := writeBitsAllow("/path", tt.perm, tt.sticky, tt.groupAllowed) + if tt.wantErr { + assert.Error(t, err, "perm %v, sticky %v, group allowed %v", tt.perm, tt.sticky, tt.groupAllowed) + return + } + assert.NoError(t, err, "perm %v, sticky %v, group allowed %v", tt.perm, tt.sticky, tt.groupAllowed) + }) + } +} + +// A build under a home directory on a distribution with a 002 umask, which is what +// a locally built or tarball-installed binary looks like. Its group has no members +// but its owner, so it is as good as owner-only. +// +// Whether this host is such a distribution is read from the environment rather than +// from groupWriteAllowed: asking the function under test whether to run would let +// it skip its own coverage away if it regressed to refusing everything. +func TestCheckOnlyOwnerWritableAcceptsOwnPrivateGroup(t *testing.T) { + requirePrivatePrimaryGroup(t) + + dir := ownerOnlyDir(t) + path := writeExecutable(t, dir) + require.NoError(t, os.Chmod(dir, 0o775), "make the directory group-writable") + require.NoError(t, os.Chmod(path, 0o775), "make the executable group-writable") + + err := checkOnlyOwnerWritable(path) + assert.NoError(t, err, "group write in the owner's own private group reaches nobody else") +} + +// A group whose membership no source can answer for is treated as shared: the +// private-group allowance must not stand on a name nobody can vouch for. The +// membership listing itself lives in the getent package and is tested there. +func TestGroupHasOtherMembersRejectsAnUnknownGroup(t *testing.T) { + assert.True(t, groupHasOtherMembers("nonexistent_group_xyzzy_12345", "vma"), + "a group no source describes") +} + +// A writable directory is as good as a writable file: whoever can write the +// directory can put a different binary at the same path. +func TestCheckOnlyOwnerWritableRejectsWritableDirectory(t *testing.T) { + dir := filepath.Join(ownerOnlyDir(t), "bin") + require.NoError(t, os.Mkdir(dir, 0o755), "create the directory") + path := writeExecutable(t, dir) + require.NoError(t, os.Chmod(dir, 0o777), "make the directory world-writable") + + assert.Error(t, checkOnlyOwnerWritable(path), "an executable in a world-writable directory must be refused") +} + +// A sticky world-writable directory is exempt: the sticky bit is what stops one +// user replacing another's entries. /tmp is why this matters. +func TestCheckOnlyOwnerWritableAcceptsStickyDirectory(t *testing.T) { + dir := filepath.Join(ownerOnlyDir(t), "sticky") + require.NoError(t, os.Mkdir(dir, 0o755), "create the directory") + path := writeExecutable(t, dir) + require.NoError(t, os.Chmod(dir, 0o777|os.ModeSticky), "make the directory sticky and world-writable") + + err := checkOnlyOwnerWritable(path) + assert.NoError(t, err, "the sticky bit stops another user replacing the executable") +} + +func TestCheckOnlyOwnerWritableRejectsMissingFile(t *testing.T) { + err := checkOnlyOwnerWritable(filepath.Join(ownerOnlyDir(t), "absent")) + assert.Error(t, err, "an executable that is not there must be refused") +} + +// requirePrivatePrimaryGroup skips unless this user's primary group is their own, +// which is what the user-private-group allowance is about. +func requirePrivatePrimaryGroup(t *testing.T) { + t.Helper() + + self, err := user.Current() + require.NoError(t, err, "look up the test user") + group, err := user.LookupGroupId(strconv.Itoa(os.Getgid())) + require.NoError(t, err, "look up the test user's primary group") + + if group.Name != self.Username { + t.Skipf("the test user's primary group is %q, not their own, so there is nothing to assert here", group.Name) + } + if groupHasOtherMembers(group.Name, self.Username) { + t.Skipf("group %q has other members, so it is not a private group", group.Name) + } +} diff --git a/client/internal/elevate/trusted_windows.go b/client/internal/elevate/trusted_windows.go new file mode 100644 index 000000000..8fb05fd88 --- /dev/null +++ b/client/internal/elevate/trusted_windows.go @@ -0,0 +1,215 @@ +package elevate + +import ( + "errors" + "fmt" + "path/filepath" + "slices" + "unsafe" + + "golang.org/x/sys/windows" +) + +const ( + // fileDeleteChild is FILE_DELETE_CHILD, which x/sys does not define: the + // right to delete an entry of a directory without holding DELETE on it. + fileDeleteChild = 0x00000040 + + // accessAllowedCallbackACEType is an allow ACE with a condition appended to + // the ACCESS_ALLOWED_ACE layout, so its trustee is still at SidStart. + accessAllowedCallbackACEType = 0x9 + + // The allow ACE types that carry object GUIDs ahead of the trustee, so the + // SID is not at SidStart. They occur on directory-service objects rather + // than files, and are refused rather than skipped: see aceTrustee. + accessAllowedObjectACEType = 0x5 + accessAllowedCallbackObjectACEType = 0xB +) + +// fileWriteAccess are the rights that let a trustee rewrite or replace a file, +// or take it over and then do so. +const fileWriteAccess = windows.FILE_WRITE_DATA | windows.FILE_APPEND_DATA | + windows.DELETE | windows.WRITE_DAC | windows.WRITE_OWNER | + windows.GENERIC_WRITE | windows.GENERIC_ALL + +// dirWriteAccess are the rights over a directory that let a trustee replace an +// entry somebody else owns. Creating a new entry is not one of them, which is +// what the Unix sticky bit says in one bit: the root of every volume grants +// BUILTIN\Users the right to add directories under it, and that reaches nothing +// already there. +const dirWriteAccess = fileDeleteChild | windows.DELETE | + windows.WRITE_DAC | windows.WRITE_OWNER | windows.GENERIC_ALL + +// trustedInstallerSID owns much of what Windows itself installs. x/sys has no +// well-known constant for it. +const trustedInstallerSID = "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464" + +// checkOnlyOwnerWritable reports an error unless path, and every directory +// leading to it, is owned by an account that can elevate (or by this user) and +// grants write access to nobody else. A writable directory is as good as a +// writable file, since an entry in it can be replaced, so the whole chain is +// checked. +func checkOnlyOwnerWritable(path string) error { + owners, err := trustedOwners() + if err != nil { + return err + } + writers, err := trustedWriters(owners) + if err != nil { + return err + } + + writeAccess := windows.ACCESS_MASK(fileWriteAccess) + for target := path; ; target = filepath.Dir(target) { + if err := checkSecurity(target, writeAccess, owners, writers); err != nil { + return err + } + if parent := filepath.Dir(target); parent == target { + return nil + } + writeAccess = dirWriteAccess + } +} + +// trustedOwners are the accounts we accept as the owner of the executable and of +// the directories above it: the ones that can already answer the UAC prompt, +// plus this user, whose own executable is theirs to write. Code running as the +// user could prompt them for anything anyway; what matters is that no *other* +// unprivileged account can reach it. +func trustedOwners() ([]*windows.SID, error) { + self, err := currentUserSID() + if err != nil { + return nil, err + } + + owners := []*windows.SID{self} + for _, wellKnown := range []windows.WELL_KNOWN_SID_TYPE{ + windows.WinLocalSystemSid, + windows.WinBuiltinAdministratorsSid, + } { + sid, err := windows.CreateWellKnownSid(wellKnown) + if err != nil { + return nil, fmt.Errorf("build well-known SID %d: %w", wellKnown, err) + } + owners = append(owners, sid) + } + + installer, err := windows.StringToSid(trustedInstallerSID) + if err != nil { + return nil, fmt.Errorf("parse TrustedInstaller SID: %w", err) + } + return append(owners, installer), nil +} + +// trustedWriters are the trustees whose write access does not widen who could +// decide what runs behind the prompt. The owners, and CREATOR OWNER, which +// resolves to the object's owner and is therefore already vetted. +func trustedWriters(owners []*windows.SID) ([]*windows.SID, error) { + creatorOwner, err := windows.CreateWellKnownSid(windows.WinCreatorOwnerSid) + if err != nil { + return nil, fmt.Errorf("build the CREATOR OWNER SID: %w", err) + } + return append(slices.Clone(owners), creatorOwner), nil +} + +func checkSecurity(path string, writeAccess windows.ACCESS_MASK, owners, writers []*windows.SID) error { + sd, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, + windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION) + if err != nil { + return fmt.Errorf("read security descriptor of %s: %w", path, err) + } + + owner, _, err := sd.Owner() + if err != nil { + return fmt.Errorf("read owner of %s: %w", path, err) + } + if !containsSID(owners, owner) { + return fmt.Errorf("%s is owned by %s, which is neither this user nor an account that can elevate", path, owner) + } + + dacl, _, err := sd.DACL() + if err != nil { + return fmt.Errorf("read DACL of %s: %w", path, err) + } + // A NULL DACL grants everyone everything; only an absent security + // descriptor would have got us here without one, and neither is trustworthy. + if dacl == nil { + return fmt.Errorf("%s has no DACL, so it grants write access to everyone", path) + } + + return checkDACL(path, dacl, writeAccess, writers) +} + +// checkDACL refuses an ACL that grants write access to a trustee outside +// writers. +// +// An allowlist, because the trustees that must not have it cannot be listed: an +// ACE naming an ordinary user account hands that account the same power as one +// naming Everyone, and only the accounts that may hold it are knowable. +func checkDACL(path string, dacl *windows.ACL, writeAccess windows.ACCESS_MASK, writers []*windows.SID) error { + for i := uint32(0); i < uint32(dacl.AceCount); i++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(dacl, i, &ace); err != nil { + return fmt.Errorf("read ACE %d of %s: %w", i, path, err) + } + // An inherit-only ACE says what children of this object get, not what + // this object grants. + if ace.Header.AceFlags&windows.INHERIT_ONLY_ACE != 0 { + continue + } + if ace.Mask&writeAccess == 0 { + continue + } + // Only an allow ACE grants anything; a deny ACE narrows what one gave. + if !isAllowACE(ace.Header.AceType) { + continue + } + + trustee, err := aceTrustee(ace) + if err != nil { + return fmt.Errorf("read the trustee of ACE %d of %s: %w", i, path, err) + } + if !containsSID(writers, trustee) { + return fmt.Errorf("%s grants write access to %s", path, trustee) + } + } + return nil +} + +// isAllowACE reports whether an ACE type grants rights, rather than denying, +// auditing or labelling them. +func isAllowACE(aceType uint8) bool { + switch aceType { + case windows.ACCESS_ALLOWED_ACE_TYPE, accessAllowedCallbackACEType, + accessAllowedObjectACEType, accessAllowedCallbackObjectACEType: + return true + default: + return false + } +} + +// aceTrustee returns who an allow ACE grants its rights to. An ACE whose trustee +// cannot be located is an error rather than something to skip past: being unable +// to read who is being given write access is a refusal. +func aceTrustee(ace *windows.ACCESS_ALLOWED_ACE) (*windows.SID, error) { + switch ace.Header.AceType { + case windows.ACCESS_ALLOWED_ACE_TYPE, accessAllowedCallbackACEType: + //nolint:gosec // SidStart is the first uint32 of the variable-length SID that follows the ACE header. + return (*windows.SID)(unsafe.Pointer(&ace.SidStart)), nil + default: + return nil, errors.New("an object-type allow ACE does not carry its trustee where we can read it") + } +} + +func containsSID(sids []*windows.SID, sid *windows.SID) bool { + return slices.ContainsFunc(sids, sid.Equals) +} + +func currentUserSID() (*windows.SID, error) { + token := windows.GetCurrentProcessToken() + user, err := token.GetTokenUser() + if err != nil { + return nil, fmt.Errorf("read this process's user: %w", err) + } + return user.User.Sid, nil +} diff --git a/client/internal/elevate/trusted_windows_test.go b/client/internal/elevate/trusted_windows_test.go new file mode 100644 index 000000000..946e7b7c8 --- /dev/null +++ b/client/internal/elevate/trusted_windows_test.go @@ -0,0 +1,126 @@ +package elevate + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows" +) + +// A file the test user created under their own profile, which is what a per-user +// install looks like. The whole chain up to the volume root is walked, so this is +// also what says the walk does not refuse an ordinary Windows installation: the +// root of every volume grants BUILTIN\Users rights that are not ours to worry +// about. +func TestCheckOnlyOwnerWritableAcceptsOwnFile(t *testing.T) { + err := checkOnlyOwnerWritable(writeExecutable(t)) + assert.NoError(t, err, "a file the test user owns, under directories only administrators can write") +} + +// Write access held by an account that cannot answer the UAC prompt means that +// account decides what runs behind it, whoever the ACE names. The trustees that +// must not have it cannot be listed, so the check names the ones that may. +func TestCheckOnlyOwnerWritableRejectsUntrustedWriters(t *testing.T) { + tests := []struct { + name string + wellKnown windows.WELL_KNOWN_SID_TYPE + }{ + {name: "everyone", wellKnown: windows.WinWorldSid}, + {name: "authenticated users", wellKnown: windows.WinAuthenticatedUserSid}, + {name: "builtin users", wellKnown: windows.WinBuiltinUsersSid}, + // A service account, which no denylist of the obvious groups would name + // and which cannot elevate any more than Everyone can. + {name: "local service", wellKnown: windows.WinLocalServiceSid}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := writeExecutable(t) + grantWrite(t, path, tt.wellKnown) + + assert.Error(t, checkOnlyOwnerWritable(path), + "write access for %s must be refused", tt.name) + }) + } +} + +// The masks are the policy: on a file any write reaches its contents, while on a +// directory only deleting or taking over an entry reaches something already +// there. Adding an entry does not, which is why the walk survives a volume root. +func TestWriteAccessMasks(t *testing.T) { + assert.NotZero(t, fileWriteAccess&windows.FILE_WRITE_DATA, "writing a file's data reaches its contents") + assert.NotZero(t, fileWriteAccess&windows.FILE_APPEND_DATA, "appending to a file reaches its contents") + + assert.Zero(t, dirWriteAccess&windows.FILE_WRITE_DATA, "adding a file to a directory replaces nothing") + assert.Zero(t, dirWriteAccess&windows.FILE_APPEND_DATA, "adding a subdirectory replaces nothing") + assert.NotZero(t, dirWriteAccess&fileDeleteChild, "deleting an entry replaces it") + assert.NotZero(t, dirWriteAccess&windows.DELETE, "deleting the directory takes its entries with it") +} + +func TestIsAllowACE(t *testing.T) { + tests := []struct { + name string + aceType uint8 + want bool + }{ + {name: "allowed", aceType: windows.ACCESS_ALLOWED_ACE_TYPE, want: true}, + {name: "allowed callback", aceType: accessAllowedCallbackACEType, want: true}, + {name: "allowed object", aceType: accessAllowedObjectACEType, want: true}, + {name: "allowed callback object", aceType: accessAllowedCallbackObjectACEType, want: true}, + {name: "denied", aceType: windows.ACCESS_DENIED_ACE_TYPE}, + // SYSTEM_AUDIT_ACE_TYPE, which x/sys does not define: an ACE that records + // access rather than granting it. + {name: "audit", aceType: 0x2}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, isAllowACE(tt.aceType), "ACE type %#x", tt.aceType) + }) + } +} + +// writeExecutable creates a plain file under the test's own directory, the shape +// trustedSelf checks. +func writeExecutable(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "netbird-ui.exe") + require.NoError(t, os.WriteFile(path, []byte("MZ"), 0o755), "write the executable") + return path +} + +// grantWrite replaces the file's DACL with one that grants a well-known trustee +// everything, keeping the test user's own access so the file stays deletable. +func grantWrite(t *testing.T, path string, wellKnown windows.WELL_KNOWN_SID_TYPE) { + t.Helper() + + trustee, err := windows.CreateWellKnownSid(wellKnown) + require.NoError(t, err, "build the trustee SID") + self, err := currentUserSID() + require.NoError(t, err, "read the test user's SID") + + acl, err := windows.ACLFromEntries([]windows.EXPLICIT_ACCESS{ + fullControl(self, windows.TRUSTEE_IS_USER), + fullControl(trustee, windows.TRUSTEE_IS_WELL_KNOWN_GROUP), + }, nil) + require.NoError(t, err, "build the ACL") + + require.NoError(t, windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, + windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, + nil, nil, acl, nil), "set the DACL") +} + +func fullControl(sid *windows.SID, trusteeType uint32) windows.EXPLICIT_ACCESS { + return windows.EXPLICIT_ACCESS{ + AccessPermissions: windows.GENERIC_ALL, + AccessMode: windows.GRANT_ACCESS, + Trustee: windows.TRUSTEE{ + TrusteeForm: windows.TRUSTEE_IS_SID, + TrusteeType: windows.TRUSTEE_TYPE(trusteeType), + TrusteeValue: windows.TrusteeValueFromSID(sid), + }, + } +} diff --git a/client/internal/engine.go b/client/internal/engine.go index 5cd6c3012..d7dfcced6 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -60,7 +60,7 @@ import ( "github.com/netbirdio/netbird/client/internal/syncstore" "github.com/netbirdio/netbird/client/internal/updater" "github.com/netbirdio/netbird/client/jobexec" - "github.com/netbirdio/netbird/client/netstate" + "github.com/netbirdio/netbird/client/netevents" cProto "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/system" nbdns "github.com/netbirdio/netbird/dns" @@ -185,9 +185,9 @@ type EngineServices struct { UpdateManager *updater.Manager ClientMetrics *metrics.ClientMetrics MetricsCtx context.Context - // NetState gates the reconnection loops on OS-reported network + // NetMgr gates the reconnection loops on OS-reported network // availability; nil disables gating. - NetState *netstate.State + NetMgr *netevents.Manager } // Engine is a mechanism responsible for reacting on Signal and Management stream events and managing connections to the remote peers. @@ -211,9 +211,9 @@ type Engine struct { config *EngineConfig mobileDep MobileDependency - // netState gates the peer reconnection guards on OS-reported network + // netMgr gates the peer reconnection guards on OS-reported network // availability; nil disables gating. - netState *netstate.State + netMgr *netevents.Manager // STUNs is a list of STUN servers used by ICE STUNs []*stun.URI @@ -350,7 +350,7 @@ func NewEngine( syncMsgMux: &sync.Mutex{}, config: config, mobileDep: mobileDep, - netState: services.NetState, + netMgr: services.NetMgr, STUNs: []*stun.URI{}, TURNs: []*stun.URI{}, networkSerial: 0, @@ -882,8 +882,7 @@ func (e *Engine) modifyPeers(peersUpdate []*mgmProto.RemotePeerConfig) error { } // third, add the peer connections again for _, p := range modified { - err := e.addNewPeer(p) - if err != nil { + if err := e.addNewPeer(p); err != nil { return err } } @@ -1510,8 +1509,12 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error { return nil } - if err := e.connMgr.UpdatedRemoteFeatureFlag(e.ctx, networkMap.GetPeerConfig().GetLazyConnectionEnabled()); err != nil { - log.Errorf("failed to update lazy connection feature flag: %v", err) + // Only update the flag when the sync carries a peer config; a nil peer config + // (e.g. a partial update) must not reset the cached flag to false. + if peerConfig := networkMap.GetPeerConfig(); peerConfig != nil { + if err := e.connMgr.UpdatedRemoteFeatureFlag(e.ctx, peerConfig.GetLazyConnectionEnabled()); err != nil { + log.Errorf("failed to update lazy connection feature flag: %v", err) + } } if e.firewall != nil { @@ -1577,8 +1580,7 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error { // Ingress forward rules done = e.phase("forward_rules") - forwardingRules, err := e.updateForwardRules(networkMap.GetForwardingRules()) - if err != nil { + if _, err := e.updateForwardRules(networkMap.GetForwardingRules()); err != nil { log.Errorf("failed to update forward rules, err: %v", err) } done() @@ -1596,8 +1598,7 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error { // must set the exclude list after the peers are added. Without it the manager can not figure out the peers parameters from the store done = e.phase("lazy_exclude") - excludedLazyPeers := e.toExcludedLazyPeers(forwardingRules, remotePeers) - e.connMgr.SetExcludeList(e.ctx, excludedLazyPeers) + e.connMgr.SetExcludeList(e.ctx, e.toExcludedLazyPeers(remotePeers)) done() e.networkSerial = serial @@ -1846,15 +1847,15 @@ func addrToString(addr netip.Addr) string { // addNewPeers adds peers that were not know before but arrived from the Management service with the update func (e *Engine) addNewPeers(peersUpdate []*mgmProto.RemotePeerConfig) error { for _, p := range peersUpdate { - err := e.addNewPeer(p) - if err != nil { + if err := e.addNewPeer(p); err != nil { return err } } return nil } -// addNewPeer add peer if connection doesn't exist +// addNewPeer add peer if connection doesn't exist. A peer that is not lazy by +// policy gets an always-active connection instead. func (e *Engine) addNewPeer(peerConfig *mgmProto.RemotePeerConfig) error { peerKey := peerConfig.GetWgPubKey() peerIPs := make([]netip.Prefix, 0, len(peerConfig.GetAllowedIps())) @@ -1889,7 +1890,8 @@ func (e *Engine) addNewPeer(peerConfig *mgmProto.RemotePeerConfig) error { log.Warnf("error adding peer %s to status recorder, got error: %v", peerKey, err) } - if exists := e.connMgr.AddPeerConn(e.ctx, peerKey, conn); exists { + permanent := !e.connMgr.PeerLazyDefault(peerConfig.GetLazyState()) + if exists := e.connMgr.AddPeerConn(e.ctx, peerKey, conn, permanent); exists { conn.Close(false) return fmt.Errorf("peer already exists: %s", peerKey) } @@ -1922,8 +1924,8 @@ func (e *Engine) createPeerConn(pubKey string, allowedIPs []netip.Prefix, agentV Addr: e.getRosenpassAddr(), PermissiveMode: e.config.RosenpassPermissive, }, - ICEConfig: e.createICEConfig(), - NetworkState: e.netState, + ICEConfig: e.createICEConfig(), + NetMgr: e.netMgr, } serviceDependencies := peer.ServiceDependencies{ @@ -2681,46 +2683,19 @@ func (e *Engine) updateForwardRules(rules []*mgmProto.ForwardingRule) ([]firewal return forwardingRules, nberrors.FormatErrorOrNil(merr) } -func (e *Engine) toExcludedLazyPeers(rules []firewallManager.ForwardRule, peers []*mgmProto.RemotePeerConfig) map[string]bool { +// toExcludedLazyPeers returns the peers that must have an always-active +// connection: those that are not lazy by policy (the per-peer lazy state or the +// account flag, subject to the local override). +func (e *Engine) toExcludedLazyPeers(peers []*mgmProto.RemotePeerConfig) map[string]bool { excludedPeers := make(map[string]bool) - - // Ingress forward targets: inbound forwarded traffic is initiated remotely and - // cannot wake a lazy connection, so the peer routing the target must stay - // permanently connected. AllowedIPs are already parsed on the peer conn, so - // reuse those typed prefixes instead of re-parsing the network map strings. - for _, r := range rules { - for _, p := range peers { - if e.peerRoutesAddr(p, r.TranslatedAddress) { - log.Infof("exclude forwarder peer from lazy connection: %s", p.GetWgPubKey()) - excludedPeers[p.GetWgPubKey()] = true - } + for _, p := range peers { + if !e.connMgr.PeerLazyDefault(p.GetLazyState()) { + excludedPeers[p.GetWgPubKey()] = true } } - return excludedPeers } -// peerRoutesAddr reports whether the peer is a router for addr, matched against -// the peer's already-parsed AllowedIPs from the store (the same typed value the -// lazy manager consumes) rather than re-parsing the network map strings. -func (e *Engine) peerRoutesAddr(p *mgmProto.RemotePeerConfig, addr netip.Addr) bool { - prefixes, ok := e.peerStore.AllowedIPs(p.GetWgPubKey()) - if !ok { - return false - } - return prefixesContain(prefixes, addr) -} - -// prefixesContain reports whether addr falls within any of the prefixes. -func prefixesContain(prefixes []netip.Prefix, addr netip.Addr) bool { - for _, prefix := range prefixes { - if prefix.Contains(addr) { - return true - } - } - return false -} - // isChecksEqual checks if two slices of checks are equal. func isChecksEqual(checks1, checks2 []*mgmProto.Checks) bool { normalize := func(checks []*mgmProto.Checks) []string { diff --git a/client/internal/engine_lazy_exclude_test.go b/client/internal/engine_lazy_exclude_test.go deleted file mode 100644 index b5ef16c3b..000000000 --- a/client/internal/engine_lazy_exclude_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package internal - -import ( - "net/netip" - "testing" - - "github.com/stretchr/testify/require" - - firewallManager "github.com/netbirdio/netbird/client/firewall/manager" - "github.com/netbirdio/netbird/client/internal/peer" - "github.com/netbirdio/netbird/client/internal/peerstore" - mgmProto "github.com/netbirdio/netbird/shared/management/proto" -) - -func TestPrefixesContain(t *testing.T) { - tests := []struct { - name string - prefixes []string - addr string - want bool - }{ - {name: "own overlay /32 matches", prefixes: []string{"100.110.8.145/32"}, addr: "100.110.8.145", want: true}, - {name: "addr inside routed subnet", prefixes: []string{"10.121.0.0/16"}, addr: "10.121.208.4", want: true}, - {name: "addr outside subnet", prefixes: []string{"10.121.0.0/16"}, addr: "10.122.0.1", want: false}, - {name: "different /32", prefixes: []string{"100.110.8.145/32"}, addr: "100.110.8.146", want: false}, - {name: "ipv6 /128 matches", prefixes: []string{"fd00::1/128"}, addr: "fd00::1", want: true}, - {name: "no prefixes", prefixes: nil, addr: "10.121.208.4", want: false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - prefixes := make([]netip.Prefix, 0, len(tt.prefixes)) - for _, p := range tt.prefixes { - prefixes = append(prefixes, netip.MustParsePrefix(p)) - } - require.Equal(t, tt.want, prefixesContain(prefixes, netip.MustParseAddr(tt.addr))) - }) - } -} - -// TestToExcludedLazyPeers_ForwardTarget guards a regression: the forward-target -// peer (the peer routing a ForwardRule.TranslatedAddress) must be excluded from -// lazy connections, matched via the peer's already-parsed AllowedIPs. -func TestToExcludedLazyPeers_ForwardTarget(t *testing.T) { - const targetPeerKey = "cccccccccccccccccccccccccccccccccccccccccc0=" - const otherPeerKey = "dddddddddddddddddddddddddddddddddddddddddd0=" - - store := peerstore.NewConnStore() - store.AddPeerConn(targetPeerKey, newTestConn(t, targetPeerKey, "100.110.8.145/32")) - store.AddPeerConn(otherPeerKey, newTestConn(t, otherPeerKey, "100.110.9.10/32")) - - e := &Engine{peerStore: store} - - peers := []*mgmProto.RemotePeerConfig{ - {WgPubKey: targetPeerKey, AllowedIps: []string{"100.110.8.145/32"}}, - {WgPubKey: otherPeerKey, AllowedIps: []string{"100.110.9.10/32"}}, - } - rules := []firewallManager.ForwardRule{ - {TranslatedAddress: netip.MustParseAddr("100.110.8.145")}, - } - - excluded := e.toExcludedLazyPeers(rules, peers) - - require.True(t, excluded[targetPeerKey], "forward-target peer must be excluded from lazy connections") - require.False(t, excluded[otherPeerKey], "non-target peer must not be excluded") - require.Len(t, excluded, 1) -} - -func TestToExcludedLazyPeers_NoRules(t *testing.T) { - e := &Engine{peerStore: peerstore.NewConnStore()} - - peers := []*mgmProto.RemotePeerConfig{ - {WgPubKey: "peer-a", AllowedIps: []string{"100.110.8.145/32"}}, - } - - require.Empty(t, e.toExcludedLazyPeers(nil, peers)) -} - -func newTestConn(t *testing.T, key, allowedIP string) *peer.Conn { - t.Helper() - conn, err := peer.NewConn(peer.ConnConfig{ - Key: key, - WgConfig: peer.WgConfig{AllowedIps: []netip.Prefix{netip.MustParsePrefix(allowedIP)}}, - }, peer.ServiceDependencies{}) - require.NoError(t, err) - return conn -} 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/engine_test.go b/client/internal/engine_test.go index fbd47ed74..4e9faa437 100644 --- a/client/internal/engine_test.go +++ b/client/internal/engine_test.go @@ -279,7 +279,8 @@ func TestEngine_UpdateNetworkMap(t *testing.T) { }, MobileDependency{}) wgIface := &MockWGIface{ - NameFunc: func() string { return "utun102" }, + NameFunc: func() string { return "utun102" }, + IsUserspaceBindFunc: func() bool { return true }, RemovePeerFunc: func(peerKey string) error { return nil }, diff --git a/client/internal/getent/cgo_unix.go b/client/internal/getent/cgo_unix.go new file mode 100644 index 000000000..2853aafff --- /dev/null +++ b/client/internal/getent/cgo_unix.go @@ -0,0 +1,36 @@ +//go:build cgo && !osusergo && !windows + +package getent + +import "os/user" + +// Built with cgo, os/user resolves through libc (getpwnam_r and friends), +// which goes through the host's NSS stack natively. Whatever it fails to +// find, the getent command would not find either, so there is nothing to +// fall back to. + +// LookupUser looks up a user by name. +func LookupUser(username string) (*user.User, error) { + return user.Lookup(username) +} + +// LookupUserID looks up a user by UID. +func LookupUserID(uid string) (*user.User, error) { + return user.LookupId(uid) +} + +// CurrentUser returns the user this process runs as. +func CurrentUser() (*user.User, error) { + return user.Current() +} + +// LookupGroupID looks up a group by GID. +func LookupGroupID(gid string) (*user.Group, error) { + return user.LookupGroupId(gid) +} + +// GroupIDs returns the IDs of the groups the user is a member of; libc's +// getgrouplist handles NSS groups natively. +func GroupIDs(u *user.User) ([]string, error) { + return u.GroupIds() +} diff --git a/client/internal/getent/getent.go b/client/internal/getent/getent.go new file mode 100644 index 000000000..9cfebe64b --- /dev/null +++ b/client/internal/getent/getent.go @@ -0,0 +1,6 @@ +// Package getent resolves users and groups through the host's NSS stack. +// Built without cgo, os/user reads /etc/passwd and /etc/group alone and misses +// anything LDAP, SSSD or winbind provide; the getent and id commands resolve +// through NSS whatever the build. The lookups here try the standard library +// first, which needs no subprocess, and fall back to those commands. +package getent diff --git a/client/ssh/server/getent_test.go b/client/internal/getent/getent_test.go similarity index 53% rename from client/ssh/server/getent_test.go rename to client/internal/getent/getent_test.go index 5eac2fdbe..8176eba36 100644 --- a/client/ssh/server/getent_test.go +++ b/client/internal/getent/getent_test.go @@ -1,4 +1,4 @@ -package server +package getent import ( "os/user" @@ -10,38 +10,48 @@ import ( "github.com/stretchr/testify/require" ) -func TestLookupWithGetent_CurrentUser(t *testing.T) { +func TestLookupUser_CurrentUser(t *testing.T) { // The current user should always be resolvable on any platform current, err := user.Current() require.NoError(t, err) - u, err := lookupWithGetent(current.Username) + u, err := LookupUser(current.Username) require.NoError(t, err) assert.Equal(t, current.Username, u.Username) assert.Equal(t, current.Uid, u.Uid) assert.Equal(t, current.Gid, u.Gid) } -func TestLookupWithGetent_NonexistentUser(t *testing.T) { - _, err := lookupWithGetent("nonexistent_user_xyzzy_12345") +func TestLookupUser_NonexistentUser(t *testing.T) { + _, err := LookupUser("nonexistent_user_xyzzy_12345") require.Error(t, err, "should fail for nonexistent user") } -func TestCurrentUserWithGetent(t *testing.T) { +func TestLookupUserID_CurrentUser(t *testing.T) { + current, err := user.Current() + require.NoError(t, err) + + u, err := LookupUserID(current.Uid) + require.NoError(t, err) + assert.Equal(t, current.Username, u.Username) + assert.Equal(t, current.Uid, u.Uid) +} + +func TestCurrentUser(t *testing.T) { stdUser, err := user.Current() require.NoError(t, err) - u, err := currentUserWithGetent() + u, err := CurrentUser() require.NoError(t, err) assert.Equal(t, stdUser.Uid, u.Uid) assert.Equal(t, stdUser.Username, u.Username) } -func TestGroupIdsWithFallback_CurrentUser(t *testing.T) { +func TestGroupIDs_CurrentUser(t *testing.T) { current, err := user.Current() require.NoError(t, err) - groups, err := groupIdsWithFallback(current) + groups, err := GroupIDs(current) require.NoError(t, err) require.NotEmpty(t, groups, "current user should have at least one group") @@ -53,32 +63,30 @@ func TestGroupIdsWithFallback_CurrentUser(t *testing.T) { } } -func TestGetShellFromGetent_CurrentUser(t *testing.T) { - if runtime.GOOS == "windows" { - // Windows stub always returns empty, which is correct - shell := getShellFromGetent("1000") - assert.Empty(t, shell, "Windows stub should return empty") - return - } - +func TestUserShell_CurrentUser(t *testing.T) { current, err := user.Current() require.NoError(t, err) - // getent may not be available on all systems (e.g., macOS without Homebrew getent) - shell := getShellFromGetent(current.Uid) + // getent may not be available on all systems (e.g., macOS without + // Homebrew getent), and Windows has no login shells at all. + shell, err := UserShell(current.Uid) + if err != nil { + t.Logf("UserShell failed, getent may not be available: %v", err) + return + } if shell == "" { - t.Log("getShellFromGetent returned empty, getent may not be available") + t.Log("UserShell returned empty, the user has no shell set") return } assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell) } -func TestLookupWithGetent_RootUser(t *testing.T) { +func TestLookupUser_RootUser(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("no root user on Windows") } - u, err := lookupWithGetent("root") + u, err := LookupUser("root") if err != nil { t.Skip("root user not available on this system") } @@ -86,25 +94,25 @@ func TestLookupWithGetent_RootUser(t *testing.T) { } // TestIntegration_FullLookupChain exercises the complete user lookup chain -// against the real system, testing that all wrappers (lookupWithGetent, -// currentUserWithGetent, groupIdsWithFallback, getShellFromGetent) produce -// consistent and correct results when composed together. +// against the real system, testing that all wrappers (LookupUser, +// CurrentUser, GroupIDs, UserShell) produce consistent and correct results +// when composed together. func TestIntegration_FullLookupChain(t *testing.T) { - // Step 1: currentUserWithGetent must resolve the running user. - current, err := currentUserWithGetent() - require.NoError(t, err, "currentUserWithGetent must resolve the running user") + // Step 1: CurrentUser must resolve the running user. + current, err := CurrentUser() + require.NoError(t, err, "CurrentUser must resolve the running user") require.NotEmpty(t, current.Uid) require.NotEmpty(t, current.Username) - // Step 2: lookupWithGetent by the same username must return matching identity. - byName, err := lookupWithGetent(current.Username) + // Step 2: LookupUser by the same username must return matching identity. + byName, err := LookupUser(current.Username) require.NoError(t, err) assert.Equal(t, current.Uid, byName.Uid, "lookup by name should return same UID") assert.Equal(t, current.Gid, byName.Gid, "lookup by name should return same GID") assert.Equal(t, current.HomeDir, byName.HomeDir, "lookup by name should return same home") - // Step 3: groupIdsWithFallback must return at least the primary GID. - groups, err := groupIdsWithFallback(current) + // Step 3: GroupIDs must return at least the primary GID. + groups, err := GroupIDs(current) require.NoError(t, err) require.NotEmpty(t, groups, "user must have at least one group") @@ -119,29 +127,20 @@ func TestIntegration_FullLookupChain(t *testing.T) { } } assert.True(t, foundPrimary, "primary GID %s should appear in supplementary groups", current.Gid) - - // Step 4: getShellFromGetent should either return a valid shell path or empty - // (empty is OK when getent is not available, e.g. macOS without Homebrew getent). - if runtime.GOOS != "windows" { - shell := getShellFromGetent(current.Uid) - if shell != "" { - assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell) - } - } } // TestIntegration_LookupAndGroupsConsistency verifies that a user resolved via -// lookupWithGetent can have their groups resolved via groupIdsWithFallback, -// testing the handoff between the two functions as used by the SSH server. +// LookupUser can have their groups resolved via GroupIDs, testing the handoff +// between the two functions as used by the SSH server. func TestIntegration_LookupAndGroupsConsistency(t *testing.T) { current, err := user.Current() require.NoError(t, err) // Simulate the SSH server flow: lookup user, then get their groups. - resolved, err := lookupWithGetent(current.Username) + resolved, err := LookupUser(current.Username) require.NoError(t, err) - groups, err := groupIdsWithFallback(resolved) + groups, err := GroupIDs(resolved) require.NoError(t, err) require.NotEmpty(t, groups, "resolved user must have groups") @@ -154,19 +153,3 @@ func TestIntegration_LookupAndGroupsConsistency(t *testing.T) { } } } - -// TestIntegration_ShellLookupChain tests the full shell resolution chain -// (getShellFromPasswd -> getShellFromGetent -> $SHELL -> default) on Unix. -func TestIntegration_ShellLookupChain(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("Unix shell lookup not applicable on Windows") - } - - current, err := user.Current() - require.NoError(t, err) - - // getUserShell is the top-level function used by the SSH server. - shell := getUserShell(current.Uid) - require.NotEmpty(t, shell, "getUserShell must always return a shell") - assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell) -} diff --git a/client/internal/getent/nocgo_unix.go b/client/internal/getent/nocgo_unix.go new file mode 100644 index 000000000..94d8ea6a9 --- /dev/null +++ b/client/internal/getent/nocgo_unix.go @@ -0,0 +1,110 @@ +//go:build (!cgo || osusergo) && !windows + +package getent + +import ( + "os" + "os/user" + "strconv" + + log "github.com/sirupsen/logrus" +) + +// Without cgo, os/user only reads /etc/passwd and /etc/group and misses +// NSS-provided users and groups; the getent and id commands go through the +// host's NSS stack. + +// LookupUser looks up a user by name, falling back to getent if os/user fails. +func LookupUser(username string) (*user.User, error) { + u, err := user.Lookup(username) + if err == nil { + return u, nil + } + + stdErr := err + log.Debugf("os/user.Lookup(%q) failed, trying getent: %v", username, err) + + u, _, getentErr := passwdLookup(username) + if getentErr != nil { + log.Debugf("getent fallback for %q also failed: %v", username, getentErr) + return nil, stdErr + } + return u, nil +} + +// LookupUserID looks up a user by UID, falling back to getent if os/user fails. +func LookupUserID(uid string) (*user.User, error) { + u, err := user.LookupId(uid) + if err == nil { + return u, nil + } + + stdErr := err + log.Debugf("os/user.LookupId(%q) failed, trying getent: %v", uid, err) + + u, _, getentErr := passwdLookup(uid) + if getentErr != nil { + log.Debugf("getent fallback for uid %s also failed: %v", uid, getentErr) + return nil, stdErr + } + return u, nil +} + +// CurrentUser returns the user this process runs as, falling back to getent +// if os/user fails. +func CurrentUser() (*user.User, error) { + u, err := user.Current() + if err == nil { + return u, nil + } + + stdErr := err + uid := strconv.Itoa(os.Getuid()) + log.Debugf("os/user.Current() failed, trying getent with UID %s: %v", uid, err) + + u, _, getentErr := passwdLookup(uid) + if getentErr != nil { + return nil, stdErr + } + return u, nil +} + +// LookupGroupID looks up a group by GID, falling back to getent if os/user +// fails. +func LookupGroupID(gid string) (*user.Group, error) { + g, err := user.LookupGroupId(gid) + if err == nil { + return g, nil + } + + stdErr := err + log.Debugf("os/user.LookupGroupId(%q) failed, trying getent: %v", gid, err) + + g, _, getentErr := groupLookup(gid) + if getentErr != nil { + log.Debugf("getent fallback for gid %s also failed: %v", gid, getentErr) + return nil, stdErr + } + return g, nil +} + +// GroupIDs returns the IDs of the groups the user is a member of. +// NOTE: unlike the lookups above, which try the standard library first, this +// intentionally tries `id -G` first because without cgo, user.GroupIds only +// reads /etc/group and silently returns incomplete results for NSS users +// (no error, just missing groups). The id command goes through NSS and +// returns the full set. +func GroupIDs(u *user.User) ([]string, error) { + ids, err := idGroups(u.Username) + if err == nil { + return ids, nil + } + + log.Debugf("id -G %q failed, falling back to user.GroupIds(): %v", u.Username, err) + + ids, stdErr := u.GroupIds() + if stdErr != nil { + return nil, stdErr + } + return ids, nil +} diff --git a/client/internal/getent/unix.go b/client/internal/getent/unix.go new file mode 100644 index 000000000..7d29810f5 --- /dev/null +++ b/client/internal/getent/unix.go @@ -0,0 +1,224 @@ +//go:build !windows + +package getent + +import ( + "bufio" + "context" + "fmt" + "os" + "os/exec" + "os/user" + "runtime" + "strings" + "time" + + log "github.com/sirupsen/logrus" +) + +const commandTimeout = 5 * time.Second + +// groupFile lists which accounts are in which group, for hosts where the +// getent command is not available (macOS ships without it). +const groupFile = "/etc/group" + +// UserShell returns the login shell getent reports for the user with this UID. +// It reaches shells that /etc/passwd does not list, because getent resolves +// through the host's NSS stack. +func UserShell(uid string) (string, error) { + _, shell, err := passwdLookup(uid) + if err != nil { + return "", err + } + return shell, nil +} + +// GroupMembers returns the names of the group's members: from getent, which +// resolves through NSS, or from /etc/group where getent is not available. A +// group neither source describes is an error; an empty member list is not, +// since accounts with the group as their primary one are not listed in it. +func GroupMembers(name string) ([]string, error) { + _, members, err := groupLookup(name) + if err == nil { + return members, nil + } + log.Debugf("getent cannot list group %q, reading %s: %v", name, groupFile, err) + return groupMembersFromFile(groupFile, name) +} + +// passwdLookup executes `getent passwd `, where query is a username or +// UID, and returns the user and login shell. +func passwdLookup(query string) (*user.User, string, error) { + out, err := run("passwd", query) + if err != nil { + return nil, "", err + } + return parsePasswd(string(out)) +} + +// groupLookup executes `getent group `, where query is a group name or +// GID, and returns the group and its member names. +func groupLookup(query string) (*user.Group, []string, error) { + out, err := run("group", query) + if err != nil { + return nil, nil, err + } + return parseGroup(string(out)) +} + +// run executes `getent ` with a timeout. +func run(database, key string) ([]byte, error) { + if !validateInput(key) { + return nil, fmt.Errorf("invalid getent input: %q", key) + } + + ctx, cancel := context.WithTimeout(context.Background(), commandTimeout) + defer cancel() + + out, err := exec.CommandContext(ctx, "getent", database, key).Output() + if err != nil { + return nil, fmt.Errorf("getent %s %s: %w", database, key, err) + } + return out, nil +} + +// parsePasswd parses getent passwd output: "name:x:uid:gid:gecos:home:shell" +func parsePasswd(output string) (*user.User, string, error) { + fields := strings.SplitN(strings.TrimSpace(output), ":", 8) + if len(fields) < 6 { + return nil, "", fmt.Errorf("unexpected getent output (need 6+ fields): %q", output) + } + + if fields[0] == "" || fields[2] == "" || fields[3] == "" { + return nil, "", fmt.Errorf("missing required fields in getent output: %q", output) + } + + var shell string + if len(fields) >= 7 { + shell = fields[6] + } + + return &user.User{ + Username: fields[0], + Uid: fields[2], + Gid: fields[3], + Name: fields[4], + HomeDir: fields[5], + }, shell, nil +} + +// parseGroup parses getent group output: "name:x:gid:member,member" +func parseGroup(output string) (*user.Group, []string, error) { + fields := strings.SplitN(strings.TrimSpace(output), ":", 4) + if len(fields) < 3 { + return nil, nil, fmt.Errorf("unexpected getent output (need 3+ fields): %q", output) + } + + if fields[0] == "" || fields[2] == "" { + return nil, nil, fmt.Errorf("missing required fields in getent output: %q", output) + } + + var members []string + if len(fields) >= 4 { + members = splitMembers(fields[3]) + } + return &user.Group{Name: fields[0], Gid: fields[2]}, members, nil +} + +func splitMembers(list string) []string { + var members []string + for member := range strings.SplitSeq(list, ",") { + if member != "" { + members = append(members, member) + } + } + return members +} + +// groupMembersFromFile finds the group's member list in a file of /etc/group's +// format. A group the file does not describe, because it comes from LDAP or +// another NSS source, is an error rather than an empty list. +func groupMembersFromFile(path, name string) ([]string, error) { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open %s: %w", path, err) + } + defer func() { + if err := file.Close(); err != nil { + log.Debugf("close %s: %v", path, err) + } + }() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + // name:password:gid:member,member + fields := strings.Split(scanner.Text(), ":") + if len(fields) < 4 || fields[0] != name { + continue + } + return splitMembers(fields[3]), nil + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + return nil, fmt.Errorf("%s does not describe group %q", path, name) +} + +// validateInput checks that the input is safe to pass to getent or id. +// Allows POSIX usernames, numeric IDs, and common NSS extensions +// (@ for Kerberos, $ for Samba, + for NIS compat). A leading hyphen is +// rejected so the input can never be parsed as a command-line flag. +func validateInput(input string) bool { + maxLen := 32 + if runtime.GOOS == "linux" { + maxLen = 256 + } + + if len(input) == 0 || len(input) > maxLen { + return false + } + + if input[0] == '-' { + return false + } + + for _, r := range input { + if isAllowedChar(r) { + continue + } + return false + } + return true +} + +func isAllowedChar(r rune) bool { + if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' { + return true + } + switch r { + case '.', '_', '-', '@', '+', '$': + return true + } + return false +} + +// idGroups runs `id -G ` and returns the space-separated group IDs. +func idGroups(username string) ([]string, error) { + if !validateInput(username) { + return nil, fmt.Errorf("invalid username for id command: %q", username) + } + + ctx, cancel := context.WithTimeout(context.Background(), commandTimeout) + defer cancel() + + out, err := exec.CommandContext(ctx, "id", "-G", username).Output() + if err != nil { + return nil, fmt.Errorf("id -G %s: %w", username, err) + } + + trimmed := strings.TrimSpace(string(out)) + if trimmed == "" { + return nil, fmt.Errorf("id -G %s: empty output", username) + } + return strings.Fields(trimmed), nil +} diff --git a/client/ssh/server/getent_unix_test.go b/client/internal/getent/unix_test.go similarity index 63% rename from client/ssh/server/getent_unix_test.go rename to client/internal/getent/unix_test.go index a73214e17..5ab100ce5 100644 --- a/client/ssh/server/getent_unix_test.go +++ b/client/internal/getent/unix_test.go @@ -1,10 +1,12 @@ //go:build !windows -package server +package getent import ( + "os" "os/exec" "os/user" + "path/filepath" "runtime" "strconv" "testing" @@ -13,7 +15,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestParseGetentPasswd(t *testing.T) { +func TestParsePasswd(t *testing.T) { tests := []struct { name string input string @@ -128,7 +130,7 @@ func TestParseGetentPasswd(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - u, shell, err := parseGetentPasswd(tt.input) + u, shell, err := parsePasswd(tt.input) if tt.wantErr { require.Error(t, err) if tt.errContains != "" { @@ -147,7 +149,120 @@ func TestParseGetentPasswd(t *testing.T) { } } -func TestValidateGetentInput(t *testing.T) { +func TestParseGroup(t *testing.T) { + tests := []struct { + name string + input string + wantGroup *user.Group + wantMembers []string + wantErr bool + }{ + { + name: "no members", + input: "vma:x:1000:\n", + wantGroup: &user.Group{Name: "vma", Gid: "1000"}, + }, + { + name: "one member", + input: "sudo:x:27:alice", + wantGroup: &user.Group{Name: "sudo", Gid: "27"}, + wantMembers: []string{"alice"}, + }, + { + name: "several members", + input: "docker:x:998:alice,bob\n", + wantGroup: &user.Group{Name: "docker", Gid: "998"}, + wantMembers: []string{"alice", "bob"}, + }, + { + name: "too few fields", + input: "bad:x", + wantErr: true, + }, + { + name: "empty group name", + input: ":x:1000:alice", + wantErr: true, + }, + { + name: "empty GID", + input: "vma:x::alice", + wantErr: true, + }, + { + name: "empty input", + input: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g, members, err := parseGroup(tt.input) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantGroup.Name, g.Name, "group name") + assert.Equal(t, tt.wantGroup.Gid, g.Gid, "GID") + assert.Equal(t, tt.wantMembers, members, "members") + }) + } +} + +func TestGroupMembersFromFile(t *testing.T) { + tests := []struct { + name string + entry string + want []string + }{ + {name: "no members", entry: "vma:x:1000:"}, + {name: "only the owner", entry: "vma:x:1000:vma", want: []string{"vma"}}, + {name: "two members", entry: "vma:x:1000:vma,bob", want: []string{"vma", "bob"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "group") + body := "root:x:0:\n" + tt.entry + "\nsudo:x:27:vma\n" + require.NoError(t, os.WriteFile(path, []byte(body), 0o644), "write the group file") + + members, err := groupMembersFromFile(path, "vma") + require.NoError(t, err, "entry %q", tt.entry) + assert.Equal(t, tt.want, members, "entry %q", tt.entry) + }) + } +} + +// A group the file does not describe, because it comes from LDAP or another +// NSS source, is an error rather than an empty member list: the caller must +// be able to tell "no members" from "no answer". +func TestGroupMembersFromFileUnknownGroup(t *testing.T) { + path := filepath.Join(t.TempDir(), "group") + require.NoError(t, os.WriteFile(path, []byte("root:x:0:\n"), 0o644), "write the group file") + + _, err := groupMembersFromFile(path, "vma") + assert.Error(t, err, "a group the file does not describe") + + _, err = groupMembersFromFile(filepath.Join(t.TempDir(), "absent"), "vma") + assert.Error(t, err, "no group file at all") +} + +// GroupMembers on the root group, which every Unix has, whichever source +// answers for it. +func TestGroupMembers_RootGroup(t *testing.T) { + rootGroup := "root" + switch runtime.GOOS { + case "darwin", "dragonfly", "freebsd", "netbsd", "openbsd": + rootGroup = "wheel" + } + + _, err := GroupMembers(rootGroup) + assert.NoError(t, err, "the %s group must be describable", rootGroup) +} + +func TestValidateInput(t *testing.T) { tests := []struct { name string input string @@ -180,7 +295,7 @@ func TestValidateGetentInput(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, validateGetentInput(tt.input)) + assert.Equal(t, tt.want, validateInput(tt.input)) }) } } @@ -193,12 +308,12 @@ func makeLongString(n int) string { return string(b) } -func TestRunGetent_RootUser(t *testing.T) { +func TestPasswdLookup_RootUser(t *testing.T) { if _, err := exec.LookPath("getent"); err != nil { t.Skip("getent not available on this system") } - u, shell, err := runGetent("root") + u, shell, err := passwdLookup("root") require.NoError(t, err) assert.Equal(t, "root", u.Username) assert.Equal(t, "0", u.Uid) @@ -206,44 +321,55 @@ func TestRunGetent_RootUser(t *testing.T) { assert.NotEmpty(t, shell, "root should have a shell") } -func TestRunGetent_ByUID(t *testing.T) { +func TestPasswdLookup_ByUID(t *testing.T) { if _, err := exec.LookPath("getent"); err != nil { t.Skip("getent not available on this system") } - u, _, err := runGetent("0") + u, _, err := passwdLookup("0") require.NoError(t, err) assert.Equal(t, "root", u.Username) assert.Equal(t, "0", u.Uid) } -func TestRunGetent_NonexistentUser(t *testing.T) { +func TestPasswdLookup_NonexistentUser(t *testing.T) { if _, err := exec.LookPath("getent"); err != nil { t.Skip("getent not available on this system") } - _, _, err := runGetent("nonexistent_user_xyzzy_12345") + _, _, err := passwdLookup("nonexistent_user_xyzzy_12345") assert.Error(t, err) } -func TestRunGetent_InvalidInput(t *testing.T) { - _, _, err := runGetent("") +func TestPasswdLookup_InvalidInput(t *testing.T) { + _, _, err := passwdLookup("") assert.Error(t, err) - _, _, err = runGetent("user\x00name") + _, _, err = passwdLookup("user\x00name") assert.Error(t, err) } -func TestRunGetent_NotAvailable(t *testing.T) { +func TestPasswdLookup_NotAvailable(t *testing.T) { if _, err := exec.LookPath("getent"); err == nil { t.Skip("getent is available, can't test missing case") } - _, _, err := runGetent("root") + _, _, err := passwdLookup("root") assert.Error(t, err, "should fail when getent is not installed") } -func TestRunIdGroups_CurrentUser(t *testing.T) { +func TestGroupLookup_RootGroup(t *testing.T) { + if _, err := exec.LookPath("getent"); err != nil { + t.Skip("getent not available on this system") + } + + g, _, err := groupLookup("0") + require.NoError(t, err) + assert.Equal(t, "0", g.Gid, "GID 0 resolves to the root group") + assert.NotEmpty(t, g.Name, "the root group has a name") +} + +func TestIdGroups_CurrentUser(t *testing.T) { if _, err := exec.LookPath("id"); err != nil { t.Skip("id not available on this system") } @@ -251,7 +377,7 @@ func TestRunIdGroups_CurrentUser(t *testing.T) { current, err := user.Current() require.NoError(t, err) - groups, err := runIdGroups(current.Username) + groups, err := idGroups(current.Username) require.NoError(t, err) require.NotEmpty(t, groups, "current user should have at least one group") @@ -261,20 +387,20 @@ func TestRunIdGroups_CurrentUser(t *testing.T) { } } -func TestRunIdGroups_NonexistentUser(t *testing.T) { +func TestIdGroups_NonexistentUser(t *testing.T) { if _, err := exec.LookPath("id"); err != nil { t.Skip("id not available on this system") } - _, err := runIdGroups("nonexistent_user_xyzzy_12345") + _, err := idGroups("nonexistent_user_xyzzy_12345") assert.Error(t, err) } -func TestRunIdGroups_InvalidInput(t *testing.T) { - _, err := runIdGroups("") +func TestIdGroups_InvalidInput(t *testing.T) { + _, err := idGroups("") assert.Error(t, err) - _, err = runIdGroups("user\x00name") + _, err = idGroups("user\x00name") assert.Error(t, err) } @@ -286,7 +412,7 @@ func TestGetentResultsMatchStdlib(t *testing.T) { current, err := user.Current() require.NoError(t, err) - getentUser, _, err := runGetent(current.Username) + getentUser, _, err := passwdLookup(current.Username) require.NoError(t, err) assert.Equal(t, current.Username, getentUser.Username, "username should match") @@ -303,7 +429,7 @@ func TestGetentResultsMatchStdlib_ByUID(t *testing.T) { current, err := user.Current() require.NoError(t, err) - getentUser, _, err := runGetent(current.Uid) + getentUser, _, err := passwdLookup(current.Uid) require.NoError(t, err) assert.Equal(t, current.Username, getentUser.Username, "username should match when looked up by UID") @@ -323,12 +449,12 @@ func TestIdGroupsMatchStdlib(t *testing.T) { t.Skip("os/user.GroupIds() not working, likely CGO_ENABLED=0") } - idGroups, err := runIdGroups(current.Username) + idGroupIDs, err := idGroups(current.Username) require.NoError(t, err) // Deduplicate both lists: id -G can return duplicates (e.g., root in Docker) // and ElementsMatch treats duplicates as distinct. - assert.ElementsMatch(t, uniqueStrings(stdGroups), uniqueStrings(idGroups), "id -G should return same groups as os/user") + assert.ElementsMatch(t, uniqueStrings(stdGroups), uniqueStrings(idGroupIDs), "id -G should return same groups as os/user") } func uniqueStrings(ss []string) []string { @@ -343,71 +469,3 @@ func uniqueStrings(ss []string) []string { } return out } - -// TestGetShellFromPasswd_CurrentUser verifies that getShellFromPasswd correctly -// reads the current user's shell from /etc/passwd by comparing it against what -// getent reports (which goes through NSS). -func TestGetShellFromPasswd_CurrentUser(t *testing.T) { - current, err := user.Current() - require.NoError(t, err) - - shell := getShellFromPasswd(current.Uid) - if shell == "" { - t.Skip("current user not found in /etc/passwd (may be an NSS-only user)") - } - - assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell) - - if _, err := exec.LookPath("getent"); err == nil { - _, getentShell, getentErr := runGetent(current.Uid) - if getentErr == nil && getentShell != "" { - assert.Equal(t, getentShell, shell, "shell from /etc/passwd should match getent") - } - } -} - -// TestGetShellFromPasswd_RootUser verifies that getShellFromPasswd can read -// root's shell from /etc/passwd. Root is guaranteed to be in /etc/passwd on -// any standard Unix system. -func TestGetShellFromPasswd_RootUser(t *testing.T) { - shell := getShellFromPasswd("0") - require.NotEmpty(t, shell, "root (UID 0) must be in /etc/passwd") - assert.True(t, shell[0] == '/', "root shell should be an absolute path, got %q", shell) -} - -// TestGetShellFromPasswd_NonexistentUID verifies that getShellFromPasswd -// returns empty for a UID that doesn't exist in /etc/passwd. -func TestGetShellFromPasswd_NonexistentUID(t *testing.T) { - shell := getShellFromPasswd("4294967294") - assert.Empty(t, shell, "nonexistent UID should return empty shell") -} - -// TestGetShellFromPasswd_MatchesGetentForKnownUsers reads /etc/passwd directly -// and cross-validates every entry against getent to ensure parseGetentPasswd -// and getShellFromPasswd agree on shell values. -func TestGetShellFromPasswd_MatchesGetentForKnownUsers(t *testing.T) { - if _, err := exec.LookPath("getent"); err != nil { - t.Skip("getent not available") - } - - // Pick a few well-known system UIDs that are virtually always in /etc/passwd. - uids := []string{"0"} // root - - current, err := user.Current() - require.NoError(t, err) - uids = append(uids, current.Uid) - - for _, uid := range uids { - passwdShell := getShellFromPasswd(uid) - if passwdShell == "" { - continue - } - - _, getentShell, err := runGetent(uid) - if err != nil { - continue - } - - assert.Equal(t, getentShell, passwdShell, "shell mismatch for UID %s", uid) - } -} diff --git a/client/internal/getent/windows.go b/client/internal/getent/windows.go new file mode 100644 index 000000000..61881d162 --- /dev/null +++ b/client/internal/getent/windows.go @@ -0,0 +1,36 @@ +//go:build windows + +package getent + +import ( + "errors" + "os/user" +) + +// Windows does not use NSS or getent; os/user resolves accounts there +// without cgo, so everything delegates to it. + +// LookupUser looks up a user by name. +func LookupUser(username string) (*user.User, error) { + return user.Lookup(username) +} + +// LookupUserID looks up a user by UID. +func LookupUserID(uid string) (*user.User, error) { + return user.LookupId(uid) +} + +// CurrentUser returns the user this process runs as. +func CurrentUser() (*user.User, error) { + return user.Current() +} + +// GroupIDs returns the IDs of the groups the user is a member of. +func GroupIDs(u *user.User) ([]string, error) { + return u.GroupIds() +} + +// UserShell is unanswerable on Windows, which has no login-shell database. +func UserShell(string) (string, error) { + return "", errors.ErrUnsupported +} diff --git a/client/internal/ipcauth/privileged.go b/client/internal/ipcauth/privileged.go index 95f2a50e9..3c2e68432 100644 --- a/client/internal/ipcauth/privileged.go +++ b/client/internal/ipcauth/privileged.go @@ -91,6 +91,12 @@ func SelfDelegatesTo() (Identity, bool) { return selfIdentity, true } +// The values PrivilegedActorKey returns. +const ( + ActorKeyAdministrator = "administrator" + ActorKeyRoot = "root" +) + // PrivilegedActor names the principal a privileged operation requires, for use // in messages shown to the user. func PrivilegedActor() string { @@ -100,6 +106,16 @@ func PrivilegedActor() string { return "root" } +// PrivilegedActorKey identifies that principal without wording it, for a client +// that writes its own message in the user's language. The words PrivilegedActor +// returns are English, and a translated sentence cannot borrow them. +func PrivilegedActorKey() string { + if runtime.GOOS == "windows" { + return ActorKeyAdministrator + } + return ActorKeyRoot +} + // ElevatedCommand renders a command so that running it grants the privileges the // operation needs. Windows has no in-line equivalent of sudo, so the command is // returned unchanged and the user is expected to run it from an elevated 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 ec9c8b6ec..3e3c6a82b 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 891da478e..5deb48815 100644 --- a/client/internal/metrics/metrics.go +++ b/client/internal/metrics/metrics.go @@ -109,6 +109,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}", @@ -311,3 +326,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..03af56be6 --- /dev/null +++ b/client/internal/metrics/prometheus.go @@ -0,0 +1,125 @@ +//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) +} + +// RecordVNCSessionTick implements metricsImplementation by delegating to the +// wrapped implementation; there is no local Prometheus series for VNC sessions. +func (m *prometheusMetrics) RecordVNCSessionTick(ctx context.Context, agentInfo AgentInfo, tick VNCSessionTick) { + m.next.RecordVNCSessionTick(ctx, agentInfo, tick) +} + +// 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/conn.go b/client/internal/peer/conn.go index b84b05671..83089606f 100644 --- a/client/internal/peer/conn.go +++ b/client/internal/peer/conn.go @@ -26,7 +26,7 @@ import ( "github.com/netbirdio/netbird/client/internal/portforward" "github.com/netbirdio/netbird/client/internal/rosenpass" "github.com/netbirdio/netbird/client/internal/stdnet" - "github.com/netbirdio/netbird/client/netstate" + "github.com/netbirdio/netbird/client/netevents" "github.com/netbirdio/netbird/route" relayClient "github.com/netbirdio/netbird/shared/relay/client" ) @@ -95,9 +95,9 @@ type ConnConfig struct { // ICEConfig ICE protocol configuration ICEConfig icemaker.Config - // NetworkState gates the reconnection guard on OS-reported network + // NetMgr gates the reconnection guard on OS-reported network // availability; nil disables gating. - NetworkState *netstate.State + NetMgr *netevents.Manager } type Conn struct { @@ -259,7 +259,7 @@ func (conn *Conn) open(engineCtx context.Context, firstPacket []byte) error { conn.handshaker.AddICEListener(conn.workerICE.OnNewOffer) } - conn.guard = guard.NewGuard(conn.Log, conn.isConnectedOnAllWay, conn.config.Timeout, conn.srWatcher, conn.config.NetworkState) + conn.guard = guard.NewGuard(conn.Log, conn.isConnectedOnAllWay, conn.config.Timeout, conn.srWatcher, conn.config.NetMgr) conn.wg.Add(1) go func() { diff --git a/client/internal/peer/guard/guard.go b/client/internal/peer/guard/guard.go index 68d77d318..73bab2a89 100644 --- a/client/internal/peer/guard/guard.go +++ b/client/internal/peer/guard/guard.go @@ -6,8 +6,6 @@ import ( "github.com/cenkalti/backoff/v4" log "github.com/sirupsen/logrus" - - "github.com/netbirdio/netbird/client/netstate" ) // ConnStatus represents the connection state as seen by the guard. @@ -24,6 +22,12 @@ const ( type connStatusFunc func() ConnStatus +// NetworkWatcher is the availability view the guard gates reconnects on. +type NetworkWatcher interface { + IsOnline() bool + Changed() <-chan struct{} +} + // Guard is responsible for the reconnection logic. // It will trigger to send an offer to the peer then has connection issues. // Watch these events: @@ -37,22 +41,22 @@ type Guard struct { isConnectedOnAllWay connStatusFunc timeout time.Duration srWatcher *SRWatcher - // netState gates reconnect attempts on OS-reported network availability; + // netWatcher gates reconnect attempts on OS-reported network availability; // nil disables gating. - netState *netstate.State + netWatcher NetworkWatcher relayedConnDisconnected chan struct{} iCEConnDisconnected chan struct{} } -// NewGuard creates a reconnection guard for a peer connection. A nil netState +// NewGuard creates a reconnection guard for a peer connection. A nil netWatcher // disables network availability gating. -func NewGuard(log *log.Entry, isConnectedFn connStatusFunc, timeout time.Duration, srWatcher *SRWatcher, netState *netstate.State) *Guard { +func NewGuard(log *log.Entry, isConnectedFn connStatusFunc, timeout time.Duration, srWatcher *SRWatcher, netWatcher NetworkWatcher) *Guard { return &Guard{ log: log, isConnectedOnAllWay: isConnectedFn, timeout: timeout, srWatcher: srWatcher, - netState: netState, + netWatcher: netWatcher, relayedConnDisconnected: make(chan struct{}, 1), iCEConnDisconnected: make(chan struct{}, 1), } @@ -104,14 +108,17 @@ func (g *Guard) reconnectLoopWithRetry(ctx context.Context, callback func()) { iceState := &iceRetryState{log: g.log} defer iceState.reset() - netChanged := g.netState.Changed() + var netChanged <-chan struct{} + if g.netWatcher != nil { + netChanged = g.netWatcher.Changed() + } for { select { case <-tickerChannel: // skip attempts while the OS reports no usable network; the // netChanged case below resumes the loop once it returns - if !g.netState.IsOnline() { + if g.netWatcher != nil && !g.netWatcher.IsOnline() { continue } switch g.isConnectedOnAllWay() { @@ -152,8 +159,8 @@ func (g *Guard) reconnectLoopWithRetry(ctx context.Context, callback func()) { case <-netChanged: // Re-arm for the next transition before acting on this one. - netChanged = g.netState.Changed() - if !g.netState.IsOnline() { + netChanged = g.netWatcher.Changed() + if !g.netWatcher.IsOnline() { continue } // Ticks skipped while offline drove the backoff towards its diff --git a/client/internal/peer/guard/guard_netstate_test.go b/client/internal/peer/guard/guard_netstate_test.go index 2ab736428..44999cae1 100644 --- a/client/internal/peer/guard/guard_netstate_test.go +++ b/client/internal/peer/guard/guard_netstate_test.go @@ -9,7 +9,7 @@ import ( log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/client/internal/peer/ice" - "github.com/netbirdio/netbird/client/netstate" + "github.com/netbirdio/netbird/client/netevents/netstate" ) // newTestGuardWithNetState builds a guard with a realistic MaxInterval: the diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go index c849a00a8..f72fc0bc4 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/profilemanager/config.go b/client/internal/profilemanager/config.go index a0052d2ad..0dcdff06a 100644 --- a/client/internal/profilemanager/config.go +++ b/client/internal/profilemanager/config.go @@ -105,6 +105,9 @@ type ConfigInput struct { DNSLabels domain.List MTU *uint16 + + LocalMetricsEnabled *bool + LocalMetricsAddress *string } // Config Configuration type @@ -148,6 +151,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 @@ -392,6 +400,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 @@ -751,6 +771,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 eef495559..7f13d5cd5 100644 --- a/client/internal/profilemanager/config_mdm_test.go +++ b/client/internal/profilemanager/config_mdm_test.go @@ -160,6 +160,32 @@ func TestApply_MDMVNCKeys(t *testing.T) { assert.True(t, cfg.Policy().HasKey(mdm.KeyDisableVNCApproval)) } +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/updater/installer/doc.go b/client/internal/updater/installer/doc.go index 11b0512ac..aff0f24f7 100644 --- a/client/internal/updater/installer/doc.go +++ b/client/internal/updater/installer/doc.go @@ -109,6 +109,10 @@ // - Does NOT remove result.json (cleaned by ResultHandler after read) // - Does NOT remove msi.log (kept for debugging) // +// On Windows the updater copy is often still locked when the daemon it restarted +// runs cleanup, so removing it is retried briefly and otherwise left in place for +// the next update to overwrite rather than reported as a failure. +// // # Dry-Run Mode // // Dry-run mode allows testing the update process without actually installing: diff --git a/client/internal/updater/installer/installer_cleanup_windows_test.go b/client/internal/updater/installer/installer_cleanup_windows_test.go new file mode 100644 index 000000000..aab16dc93 --- /dev/null +++ b/client/internal/updater/installer/installer_cleanup_windows_test.go @@ -0,0 +1,67 @@ +package installer + +import ( + "os" + "path/filepath" + "testing" + "time" + + "golang.org/x/sys/windows" +) + +// lockFile opens path without FILE_SHARE_DELETE, so os.Remove fails the way it does +// while the updater process still holds its own image. +func lockFile(t *testing.T, path string) windows.Handle { + t.Helper() + + p, err := windows.UTF16PtrFromString(path) + if err != nil { + t.Fatalf("convert path: %v", err) + } + + handle, err := windows.CreateFile(p, windows.GENERIC_READ, windows.FILE_SHARE_READ, nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0) + if err != nil { + t.Fatalf("lock %s: %v", path, err) + } + return handle +} + +// releaseAfter closes the handle once the delay has passed, standing in for the +// updater process finally exiting. +func releaseAfter(t *testing.T, handle windows.Handle, delay time.Duration) { + t.Helper() + + released := make(chan struct{}) + t.Cleanup(func() { <-released }) + + go func() { + defer close(released) + time.Sleep(delay) + if err := windows.CloseHandle(handle); err != nil { + t.Errorf("close handle: %v", err) + } + }() +} + +// TestCleanUpInstallerFilesLockedUpdater covers the post-update cleanup race: the +// daemon cleans up at startup while the updater that restarted it is still exiting, +// so the updater image is locked and Windows refuses the delete. Cleanup must wait +// the lock out instead of reporting a failure and leaving the binary behind. +func TestCleanUpInstallerFilesLockedUpdater(t *testing.T) { + tempDir := t.TempDir() + path := filepath.Join(tempDir, updaterBinary) + if err := os.WriteFile(path, []byte("x"), 0o600); err != nil { + t.Fatalf("write updater: %v", err) + } + + releaseAfter(t, lockFile(t, path), 300*time.Millisecond) + + u := NewWithDir(tempDir) + if err := u.CleanUpInstallerFiles(); err != nil { + t.Fatalf("cleanup must tolerate a still-locked updater: %v", err) + } + + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("updater binary still present (stat err: %v)", err) + } +} diff --git a/client/internal/updater/installer/installer_common.go b/client/internal/updater/installer/installer_common.go index 17566f7de..f917424b8 100644 --- a/client/internal/updater/installer/installer_common.go +++ b/client/internal/updater/installer/installer_common.go @@ -152,8 +152,8 @@ func (u *Installer) CleanUpInstallerFiles() error { var merr *multierror.Error - if err := os.Remove(filepath.Join(u.tempDir, updaterBinary)); err != nil && !os.IsNotExist(err) { - merr = multierror.Append(merr, fmt.Errorf("failed to remove updater binary: %w", err)) + if err := removeUpdaterBinary(filepath.Join(u.tempDir, updaterBinary)); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove updater binary: %w", err)) } entries, err := os.ReadDir(u.tempDir) @@ -167,10 +167,16 @@ func (u *Installer) CleanUpInstallerFiles() error { } name := entry.Name() + // The updater copy is handled above; on Windows its name also matches the + // extension sweep, which would report the same file twice. + if strings.EqualFold(name, updaterBinary) { + continue + } + for _, ext := range binaryExtensions { if strings.HasSuffix(strings.ToLower(name), strings.ToLower(ext)) { if err := os.Remove(filepath.Join(u.tempDir, name)); err != nil { - merr = multierror.Append(merr, fmt.Errorf("failed to remove %s: %w", name, err)) + merr = multierror.Append(merr, fmt.Errorf("remove %s: %w", name, err)) } break } diff --git a/client/internal/updater/installer/installer_common_test.go b/client/internal/updater/installer/installer_common_test.go new file mode 100644 index 000000000..c1556c828 --- /dev/null +++ b/client/internal/updater/installer/installer_common_test.go @@ -0,0 +1,52 @@ +//go:build windows || darwin + +package installer + +import ( + "os" + "path/filepath" + "testing" +) + +// TestCleanUpInstallerFiles checks that cleanup removes the updater copy and the +// downloaded installer while leaving the logs and the result file for the daemon. +func TestCleanUpInstallerFiles(t *testing.T) { + tempDir := t.TempDir() + + installers := make([]string, 0, len(binaryExtensions)) + for _, ext := range binaryExtensions { + installers = append(installers, "netbird_installer."+ext) + } + + kept := []string{"installer.log", "result.json"} + + for _, name := range append(append([]string{updaterBinary}, installers...), kept...) { + if err := os.WriteFile(filepath.Join(tempDir, name), []byte("x"), 0o600); err != nil { + t.Fatalf("write %s: %v", name, err) + } + } + + u := NewWithDir(tempDir) + if err := u.CleanUpInstallerFiles(); err != nil { + t.Fatalf("CleanUpInstallerFiles: %v", err) + } + + for _, name := range append([]string{updaterBinary}, installers...) { + if _, err := os.Stat(filepath.Join(tempDir, name)); !os.IsNotExist(err) { + t.Errorf("%s was not removed (stat err: %v)", name, err) + } + } + + for _, name := range kept { + if _, err := os.Stat(filepath.Join(tempDir, name)); err != nil { + t.Errorf("%s should have been kept: %v", name, err) + } + } +} + +func TestCleanUpInstallerFilesMissingTempDir(t *testing.T) { + u := NewWithDir(filepath.Join(t.TempDir(), "does-not-exist")) + if err := u.CleanUpInstallerFiles(); err != nil { + t.Errorf("a missing temp dir is not a cleanup failure, got: %v", err) + } +} diff --git a/client/internal/updater/installer/remove_updater_darwin.go b/client/internal/updater/installer/remove_updater_darwin.go new file mode 100644 index 000000000..4d4a0be60 --- /dev/null +++ b/client/internal/updater/installer/remove_updater_darwin.go @@ -0,0 +1,12 @@ +package installer + +import "os" + +// removeUpdaterBinary deletes the updater copy left in the temp dir. On darwin a +// running binary can be unlinked, so no retry is needed. +func removeUpdaterBinary(path string) error { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} diff --git a/client/internal/updater/installer/remove_updater_windows.go b/client/internal/updater/installer/remove_updater_windows.go new file mode 100644 index 000000000..0e23b1644 --- /dev/null +++ b/client/internal/updater/installer/remove_updater_windows.go @@ -0,0 +1,45 @@ +package installer + +import ( + "errors" + "os" + "time" + + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" +) + +const ( + // The updater is the process that restarted the daemon, so when the daemon + // cleans up at startup the updater is often still exiting and Windows refuses + // to delete its locked image. These bound how long cleanup waits for it. + updaterRemoveAttempts = 5 + updaterRemoveDelay = 200 * time.Millisecond +) + +// removeUpdaterBinary deletes the updater copy left in the temp dir, retrying +// while the still-exiting updater process holds its image. A binary that stays +// locked for the whole window is left in place and reported at info level: the +// next update overwrites it, so it is not worth failing cleanup over. +func removeUpdaterBinary(path string) error { + for attempt := 0; attempt < updaterRemoveAttempts; attempt++ { + if attempt > 0 { + time.Sleep(updaterRemoveDelay) + } + + err := os.Remove(path) + if err == nil || os.IsNotExist(err) { + return nil + } + if !isFileLocked(err) { + return err + } + } + + log.Infof("updater binary %s is still locked, leaving it for the next update to overwrite", path) + return nil +} + +func isFileLocked(err error) bool { + return errors.Is(err, windows.ERROR_ACCESS_DENIED) || errors.Is(err, windows.ERROR_SHARING_VIOLATION) +} diff --git a/client/internal/updater/installer/remove_updater_windows_test.go b/client/internal/updater/installer/remove_updater_windows_test.go new file mode 100644 index 000000000..09910d034 --- /dev/null +++ b/client/internal/updater/installer/remove_updater_windows_test.go @@ -0,0 +1,59 @@ +package installer + +import ( + "os" + "path/filepath" + "testing" + "time" + + "golang.org/x/sys/windows" +) + +func TestRemoveUpdaterBinaryRetriesWhileLocked(t *testing.T) { + path := filepath.Join(t.TempDir(), updaterBinary) + if err := os.WriteFile(path, []byte("x"), 0o600); err != nil { + t.Fatalf("write updater: %v", err) + } + + releaseAfter(t, lockFile(t, path), updaterRemoveDelay+50*time.Millisecond) + + if err := removeUpdaterBinary(path); err != nil { + t.Fatalf("removeUpdaterBinary: %v", err) + } + + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("updater binary still present (stat err: %v)", err) + } +} + +// TestRemoveUpdaterBinaryStaysLocked covers an updater that never releases its +// image within the retry window. Cleanup gives up quietly and leaves the file +// behind rather than reporting a failure. +func TestRemoveUpdaterBinaryStaysLocked(t *testing.T) { + path := filepath.Join(t.TempDir(), updaterBinary) + if err := os.WriteFile(path, []byte("x"), 0o600); err != nil { + t.Fatalf("write updater: %v", err) + } + + handle := lockFile(t, path) + t.Cleanup(func() { + if err := windows.CloseHandle(handle); err != nil { + t.Errorf("close handle: %v", err) + } + }) + + if err := removeUpdaterBinary(path); err != nil { + t.Fatalf("a permanently locked updater is not a cleanup failure, got: %v", err) + } + + if _, err := os.Stat(path); err != nil { + t.Errorf("locked updater binary should be left in place, stat: %v", err) + } +} + +func TestRemoveUpdaterBinaryMissingFile(t *testing.T) { + path := filepath.Join(t.TempDir(), updaterBinary) + if err := removeUpdaterBinary(path); err != nil { + t.Errorf("a missing updater binary is not a failure, got: %v", err) + } +} diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index f92f085ab..8373e498a 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -22,8 +22,7 @@ import ( "github.com/netbirdio/netbird/client/internal/listener" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/profilemanager" - "github.com/netbirdio/netbird/client/netstate" - "github.com/netbirdio/netbird/client/netsweep" + "github.com/netbirdio/netbird/client/netevents" "github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/formatter" "github.com/netbirdio/netbird/route" @@ -84,12 +83,10 @@ type Client struct { onHostDnsFn func([]string) dnsManager dns.IosDnsManager loginComplete bool - // netState outlives engine restarts: it mirrors the OS connectivity, not - // the engine lifecycle. Run injects it into each new ConnectClient, which - // distributes it to every reconnection loop. - netState *netstate.State - // sweeper also outlives engine restarts; NotifyNetworkChange sweeps it. - sweeper *netsweep.Sweeper + // netMgr outlives engine restarts: it mirrors the OS connectivity, not + // the engine lifecycle. Run injects its state and sweeper into each new + // ConnectClient. + netMgr *netevents.Manager // preloadedConfig holds config loaded from JSON (used on tvOS where file writes are blocked) preloadedConfig *profilemanager.Config @@ -100,6 +97,7 @@ type Client struct { // NewClient instantiate a new Client func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osVersion string, osName string, networkChangeListener NetworkChangeListener, dnsManager DnsManager) *Client { + recorder := peer.NewRecorder("") return &Client{ cfgFile: cfgFile, stateFile: stateFile, @@ -108,12 +106,11 @@ func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osV deviceName: deviceName, osName: osName, osVersion: osVersion, - recorder: peer.NewRecorder(""), + recorder: recorder, ctxCancelLock: &sync.Mutex{}, networkChangeListener: networkChangeListener, dnsManager: dnsManager, - netState: netstate.New(), - sweeper: netsweep.New(), + netMgr: netevents.NewManager(recorder), } } @@ -190,7 +187,7 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error { cfg.WgIface = interfaceName connectClient := internal.NewConnectClient(ctx, cfg, c.recorder, - internal.WithNetworkState(c.netState), internal.WithSweeper(c.sweeper)) + internal.WithNetEvents(c.netMgr)) c.setState(cfg, connectClient) // Persist the latest sync response so DebugBundle can include the network // map. On iOS this is backed by disk to keep it out of the constrained @@ -203,10 +200,11 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error { // (e.g. from NWPathMonitor). While unavailable, the internal reconnect loops // suspend their attempts and the connection listener reports NoNetwork // instead of Connecting; when availability returns, the loops resume -// immediately with a fresh backoff. +// immediately with a fresh backoff. Losing the last network also sweeps the +// registered connections, so the client does not keep reporting Connected +// over stale sockets with no network at all. func (c *Client) SetNetworkAvailable(available bool) { - c.netState.Set(available) - c.recorder.SetNetworkAvailable(available) + c.netMgr.SetNetworkAvailable(available) } // NotifyNetworkChange marks the management, signal and relay connections @@ -214,8 +212,7 @@ func (c *Client) SetNetworkAvailable(available bool) { // whatever has not redialed on the new network by then. The engine and the // TUN device stay untouched. func (c *Client) NotifyNetworkChange() { - c.sweeper.MarkNetworkChange() - log.Infof("network change: connections marked stale") + c.netMgr.NotifyNetworkChange() } // Stop the internal client and free the resources diff --git a/client/ios/NetBirdSDK/profile_manager.go b/client/ios/NetBirdSDK/profile_manager.go new file mode 100644 index 000000000..139521c7f --- /dev/null +++ b/client/ios/NetBirdSDK/profile_manager.go @@ -0,0 +1,138 @@ +//go:build ios + +package NetBirdSDK + +import ( + "github.com/netbirdio/netbird/client/mobile" +) + +const ( + // iOS uses a single user context per app. + iosUsername = "ios" +) + +// Profile represents a profile for gomobile. +type Profile struct { + ID string + Name string + Email string + IsActive bool +} + +// ProfileArray wraps profiles for gomobile compatibility (gomobile cannot +// bind Go slices directly). +type ProfileArray struct { + items []*Profile +} + +// Length returns the number of profiles. +func (p *ProfileArray) Length() int { + return len(p.items) +} + +// Get returns the profile at index i, or nil if out of range. +func (p *ProfileArray) Get(i int) *Profile { + if i < 0 || i >= len(p.items) { + return nil + } + return p.items[i] +} + +// ProfileManager adapts the shared mobile profile manager (client/mobile) to +// gomobile-friendly types. See that package for the on-disk layout and +// semantics. +type ProfileManager struct { + impl *mobile.ProfileManager +} + +// NewProfileManager creates a new profile manager for iOS. configDir is the +// App Group shared container path that both the app and the network extension +// can reach. +func NewProfileManager(configDir string) *ProfileManager { + return &ProfileManager{impl: mobile.NewProfileManager(configDir, iosUsername)} +} + +// ListProfiles returns all available profiles, including the default profile, +// with their active status set. +func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) { + profiles, err := pm.impl.ListProfiles() + if err != nil { + return nil, err + } + + items := make([]*Profile, 0, len(profiles)) + for i := range profiles { + items = append(items, fromMobileProfile(&profiles[i])) + } + return &ProfileArray{items: items}, nil +} + +// GetActiveProfile returns the currently active profile. +func (pm *ProfileManager) GetActiveProfile() (*Profile, error) { + p, err := pm.impl.GetActiveProfile() + if err != nil { + return nil, err + } + return fromMobileProfile(p), nil +} + +// SwitchProfile records the given profile ID as the active profile. The caller +// must stop the VPN tunnel before switching. +func (pm *ProfileManager) SwitchProfile(id string) error { + return pm.impl.SwitchProfile(id) +} + +// AddProfile creates a new profile with the given display name and a +// generated ID. It returns the created profile so the caller learns the ID. +func (pm *ProfileManager) AddProfile(displayName string) (*Profile, error) { + p, err := pm.impl.AddProfile(displayName) + if err != nil { + return nil, err + } + return fromMobileProfile(p), nil +} + +// RenameProfile changes the display name of the profile identified by id. The +// on-disk filename (the ID) is left unchanged. +func (pm *ProfileManager) RenameProfile(id string, newName string) error { + return pm.impl.RenameProfile(id, newName) +} + +// LogoutProfile clears authentication data for a profile, forcing a re-login. +// The management URL and other settings are preserved. +func (pm *ProfileManager) LogoutProfile(id string) error { + return pm.impl.LogoutProfile(id) +} + +// RemoveProfile deletes a profile. The default profile and the active profile +// cannot be removed. +func (pm *ProfileManager) RemoveProfile(id string) error { + return pm.impl.RemoveProfile(id) +} + +// GetConfigPath returns the config file path for the given profile ID. Swift +// should call this instead of constructing paths itself. +func (pm *ProfileManager) GetConfigPath(id string) (string, error) { + return pm.impl.GetConfigPath(id) +} + +// GetStateFilePath returns the state file path for the given profile ID. +func (pm *ProfileManager) GetStateFilePath(id string) (string, error) { + return pm.impl.GetStateFilePath(id) +} + +// GetActiveConfigPath returns the config file path for the currently active +// profile. +func (pm *ProfileManager) GetActiveConfigPath() (string, error) { + return pm.impl.GetActiveConfigPath() +} + +// GetActiveStateFilePath returns the state file path for the currently active +// profile. +func (pm *ProfileManager) GetActiveStateFilePath() (string, error) { + return pm.impl.GetActiveStateFilePath() +} + +func fromMobileProfile(p *mobile.Profile) *Profile { + return &Profile{ID: p.ID, Name: p.Name, Email: p.Email, IsActive: p.IsActive} +} diff --git a/client/mdm/canonical_loaders.go b/client/mdm/canonical_loaders.go index 6760d4c71..fceb6a7fb 100644 --- a/client/mdm/canonical_loaders.go +++ b/client/mdm/canonical_loaders.go @@ -29,6 +29,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 2813be5bd..0e116bfd6 100644 --- a/client/mdm/policy.go +++ b/client/mdm/policy.go @@ -49,6 +49,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/mobile/profile_manager.go b/client/mobile/profile_manager.go new file mode 100644 index 000000000..1ddabf0a9 --- /dev/null +++ b/client/mobile/profile_manager.go @@ -0,0 +1,294 @@ +// Package mobile holds the profile manager implementation shared by the +// Android and iOS gomobile bindings. The platform packages (client/android, +// client/ios/NetBirdSDK) only adapt this API to gomobile-friendly types. +package mobile + +import ( + "fmt" + "os" + "path/filepath" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/profilemanager" +) + +const ( + // Config filename of the default profile, stored at the configDir root. + // Both platforms use netbird.cfg (matching the desktop netbird.cfg rather + // than default.json); the app-side path constants must match. + defaultConfigFilename = "netbird.cfg" + // Subdirectory of configDir holding non-default profiles. + profilesSubdir = "profiles" +) + +/* + +/ ← app-writable config root +├── netbird.cfg ← Default profile config +├── netbird.account.json ← Default profile account email (see profile_state.go) +├── state.json ← Default profile state +├── active_profile.json ← Active profile tracker (JSON with ID + Username) +└── profiles/ ← Subdirectory for non-default profiles + ├── 4c5f5c8198c3989cffb5b5394f5a7ae0.json ← Profile config (filename = ID) + ├── 4c5f5c8198c3989cffb5b5394f5a7ae0.state.json ← Profile state + ├── 4c5f5c8198c3989cffb5b5394f5a7ae0.account.json ← Profile account email + └── 4c5f5c8198c3989cffb5b5394f5a7ae0.prefs.json ← Profile preferences +*/ + +// Profile is the platform-independent profile view handed to the bindings. +type Profile struct { + ID string + Name string + // Email is the account this profile last logged in with, "" if it never + // completed an SSO login. Kept across logouts; cleared when the profile is + // removed. See profile_state.go. + Email string + IsActive bool +} + +// ProfileManager manages profiles for the mobile platforms. It wraps the +// internal profilemanager.ServiceManager with mobile-specific path handling. +// All profile identity is ID-based; the human-readable name lives inside the +// profile config's Name field. +type ProfileManager struct { + configDir string + username string + serviceMgr *profilemanager.ServiceManager +} + +// NewProfileManager creates a profile manager rooted at configDir, the +// app-writable directory that every process of the app can reach. username is +// the platform's fixed single-user context (a non-empty username is required +// by ServiceManager for non-default profiles). +func NewProfileManager(configDir, username string) *ProfileManager { + // The default profile is stored in the root configDir, not under profiles/. + defaultConfigPath := filepath.Join(configDir, defaultConfigFilename) + + // Point the package globals at the app-provided directory, overriding the + // desktop defaults set in profilemanager's init(). + profilemanager.DefaultConfigPathDir = configDir + profilemanager.DefaultConfigPath = defaultConfigPath + profilemanager.ActiveProfileStatePath = filepath.Join(configDir, "active_profile.json") + + // Non-default profiles live in the profiles/ subdirectory. Passing it + // explicitly avoids touching the global config-dir override. + profilesDir := filepath.Join(configDir, profilesSubdir) + serviceMgr := profilemanager.NewServiceManagerWithProfilesDir(defaultConfigPath, profilesDir) + + return &ProfileManager{ + configDir: configDir, + username: username, + serviceMgr: serviceMgr, + } +} + +// ListProfiles returns all available profiles, including the default profile, +// with their active status set. +func (pm *ProfileManager) ListProfiles() ([]Profile, error) { + internalProfiles, err := pm.serviceMgr.ListProfiles(pm.username) + if err != nil { + return nil, fmt.Errorf("list profiles: %w", err) + } + + profiles := make([]Profile, 0, len(internalProfiles)) + for _, p := range internalProfiles { + profiles = append(profiles, Profile{ + ID: p.ID.String(), + Name: p.Name, + Email: pm.profileEmail(p.ID.String()), + IsActive: p.IsActive, + }) + } + + return profiles, nil +} + +// GetActiveProfile returns the currently active profile, resolving its ID to +// the full profile so callers get the real display name. +func (pm *ProfileManager) GetActiveProfile() (*Profile, error) { + activeState, err := pm.serviceMgr.GetActiveProfileState() + if err != nil { + return nil, fmt.Errorf("get active profile: %w", err) + } + + prof, err := pm.serviceMgr.ResolveProfile(activeState.ID.String(), pm.username) + if err != nil { + return nil, fmt.Errorf("resolve active profile %q: %w", activeState.ID, err) + } + return &Profile{ + ID: prof.ID.String(), + Name: prof.Name, + Email: pm.profileEmail(prof.ID.String()), + IsActive: true, + }, nil +} + +// SwitchProfile records the given profile ID as the active profile. The caller +// must stop the VPN tunnel before switching. +func (pm *ProfileManager) SwitchProfile(id string) error { + if err := pm.serviceMgr.SetActiveProfileState(&profilemanager.ActiveProfileState{ + ID: profilemanager.ID(id), + Username: pm.username, + }); err != nil { + return fmt.Errorf("switch profile: %w", err) + } + + log.Infof("switched to profile: %s", id) + return nil +} + +// AddProfile creates a new profile with the given display name and a +// generated ID. It returns the created profile so the caller learns the ID. +func (pm *ProfileManager) AddProfile(displayName string) (*Profile, error) { + profile, err := pm.serviceMgr.AddProfile(displayName, pm.username) + if err != nil { + return nil, fmt.Errorf("add profile: %w", err) + } + + log.Infof("created new profile: %s", profile.ID) + return &Profile{ID: profile.ID.String(), Name: profile.Name, IsActive: false}, nil +} + +// RenameProfile changes the display name of the profile identified by id. The +// on-disk filename (the ID) is left unchanged. +func (pm *ProfileManager) RenameProfile(id string, newName string) error { + if err := pm.serviceMgr.RenameProfile(profilemanager.ID(id), pm.username, newName); err != nil { + return fmt.Errorf("rename profile: %w", err) + } + + log.Infof("renamed profile %s to %q", id, newName) + return nil +} + +// LogoutProfile clears authentication data for a profile by removing its +// private key and SSH key from the config, forcing a re-login. The management +// URL and other settings are preserved. +func (pm *ProfileManager) LogoutProfile(id string) error { + configPath, err := pm.getProfileConfigPath(id) + if err != nil { + return err + } + + if _, err := os.Stat(configPath); os.IsNotExist(err) { + return fmt.Errorf("profile %q does not exist", id) + } + + config, err := profilemanager.ReadConfig(configPath) + if err != nil { + return fmt.Errorf("read profile config: %w", err) + } + + config.PrivateKey = "" + config.SSHKey = "" + + if err := profilemanager.WriteOutConfig(configPath, config); err != nil { + return fmt.Errorf("save config: %w", err) + } + + // The stored account email is kept on purpose, matching the desktop and CLI + // logout semantics: the next login passes it as the login_hint so the IdP + // preselects the account. Removing the profile is what deletes it. + log.Infof("logged out from profile: %s", id) + return nil +} + +// RemoveProfile deletes a profile. The default profile and the active profile +// cannot be removed. +func (pm *ProfileManager) RemoveProfile(id string) error { + configPath, err := pm.getProfileConfigPath(id) + if err != nil { + return err + } + + if err := pm.serviceMgr.RemoveProfile(profilemanager.ID(id), pm.username); err != nil { + return fmt.Errorf("remove profile: %w", err) + } + + // The account file is this package's, not the ServiceManager's, so it must + // go here. The default profile has a fixed filename, so a recreated one + // would otherwise inherit the deleted profile's email as its login_hint. + // Not fatal: the profile itself is gone. + if err := removeProfileEmail(configPath); err != nil { + log.Warnf("failed to remove stored account email for profile %s: %v", id, err) + } + + log.Infof("removed profile: %s", id) + return nil +} + +// ProfilePrefs returns the namespaced per-profile preference store of the +// profile identified by id. +func (pm *ProfileManager) ProfilePrefs(id string) (*profilemanager.Prefs, error) { + prefs, err := pm.serviceMgr.ProfilePrefs(profilemanager.ID(id), pm.username) + if err != nil { + return nil, fmt.Errorf("resolve profile prefs: %w", err) + } + return prefs, nil +} + +// GetConfigPath returns the config file path for the given profile ID. The +// platform code should call this instead of constructing paths itself. +func (pm *ProfileManager) GetConfigPath(id string) (string, error) { + return pm.getProfileConfigPath(id) +} + +// GetStateFilePath returns the state file path for the given profile ID. +func (pm *ProfileManager) GetStateFilePath(id string) (string, error) { + if id == "" || id == profilemanager.DefaultProfileName { + return filepath.Join(pm.configDir, "state.json"), nil + } + + if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) { + return "", fmt.Errorf("id %q is not valid", id) + } + + profilesDir := filepath.Join(pm.configDir, profilesSubdir) + return filepath.Join(profilesDir, id+".state.json"), nil +} + +// GetActiveConfigPath returns the config file path for the currently active +// profile. +func (pm *ProfileManager) GetActiveConfigPath() (string, error) { + activeProfile, err := pm.GetActiveProfile() + if err != nil { + return "", fmt.Errorf("get active profile: %w", err) + } + return pm.GetConfigPath(activeProfile.ID) +} + +// GetActiveStateFilePath returns the state file path for the currently active +// profile. +func (pm *ProfileManager) GetActiveStateFilePath() (string, error) { + activeProfile, err := pm.GetActiveProfile() + if err != nil { + return "", fmt.Errorf("get active profile: %w", err) + } + return pm.GetStateFilePath(activeProfile.ID) +} + +// profileEmail returns the account email recorded for a profile. Display-only, +// so an unresolvable path degrades to "" rather than an error. +func (pm *ProfileManager) profileEmail(id string) string { + configPath, err := pm.getProfileConfigPath(id) + if err != nil { + return "" + } + return ReadProfileEmail(configPath) +} + +// getProfileConfigPath returns the config file path for a profile ID. The +// default profile uses netbird.cfg in the root configDir; other profiles use +// .json in the profiles/ subdirectory. +func (pm *ProfileManager) getProfileConfigPath(id string) (string, error) { + if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) { + return "", fmt.Errorf("id %q is not valid", id) + } + + if id == profilemanager.DefaultProfileName { + return filepath.Join(pm.configDir, defaultConfigFilename), nil + } + + profilesDir := filepath.Join(pm.configDir, profilesSubdir) + return filepath.Join(profilesDir, id+".json"), nil +} diff --git a/client/android/profile_state.go b/client/mobile/profile_state.go similarity index 69% rename from client/android/profile_state.go rename to client/mobile/profile_state.go index 0063b587f..bb983ec1d 100644 --- a/client/android/profile_state.go +++ b/client/mobile/profile_state.go @@ -1,4 +1,4 @@ -package android +package mobile import ( "context" @@ -14,17 +14,13 @@ import ( ) const ( - // Android-specific config filename (different from desktop default.json) - defaultConfigFilename = "netbird.cfg" - // Subdirectory for non-default profiles (must match Java Preferences.java) - profilesSubdir = "profiles" // profileAccountSuffix names the file holding the profile's account email. // Deliberately not ".state.json", which desktop uses for the same data: // there the email and the engine's state manager live in different - // directories, but on Android both resolve under files/, so sharing the name - // would have the two overwrite each other — the state manager rewrites the - // whole file from its own keys (see statemanager.Manager.PersistState), and - // this package's writer does the same in reverse. + // directories, but on mobile both resolve under configDir, so sharing the + // name would have the two overwrite each other — the state manager rewrites + // the whole file from its own keys (see statemanager.Manager.PersistState), + // and this package's writer does the same in reverse. profileAccountSuffix = ".account.json" ) @@ -32,7 +28,7 @@ const ( // path: netbird.cfg -> netbird.account.json, .json -> .account.json. // // Deriving from the config path rather than resolving the active profile keeps -// the write on the profile the login actually ran for: Auth.login runs in a +// the write on the profile the login actually ran for: login flows run in a // goroutine, so the active profile can change under a flow already in flight. func profileAccountPathFor(configPath string) (string, error) { if configPath == "" { @@ -48,10 +44,10 @@ func profileAccountPathFor(configPath string) (string, error) { return filepath.Join(filepath.Dir(configPath), stem+profileAccountSuffix), nil } -// readProfileEmail returns the account email stored for the profile whose config -// lives at configPath. A missing or unreadable file yields "", which leaves the -// account choice to the IdP. -func readProfileEmail(configPath string) string { +// ReadProfileEmail returns the account email stored for the profile whose +// config lives at configPath. A missing or unreadable file yields "", which +// leaves the account choice to the IdP. +func ReadProfileEmail(configPath string) string { accountPath, err := profileAccountPathFor(configPath) if err != nil { log.Debugf("no profile account path for login hint: %v", err) @@ -69,10 +65,10 @@ func readProfileEmail(configPath string) string { return state.Email } -// writeProfileEmail records the account email for the profile whose config lives -// at configPath, so later logins can pass it as an OIDC login_hint. An empty -// email is ignored rather than blanking what is already stored. -func writeProfileEmail(configPath string, email string) error { +// WriteProfileEmail records the account email for the profile whose config +// lives at configPath, so later logins can pass it as an OIDC login_hint. An +// empty email is ignored rather than blanking what is already stored. +func WriteProfileEmail(configPath string, email string) error { if email == "" { return nil } diff --git a/client/android/profile_state_test.go b/client/mobile/profile_state_test.go similarity index 73% rename from client/android/profile_state_test.go rename to client/mobile/profile_state_test.go index 82a1c2a87..99cba15de 100644 --- a/client/android/profile_state_test.go +++ b/client/mobile/profile_state_test.go @@ -1,4 +1,4 @@ -package android +package mobile import ( "os" @@ -15,18 +15,18 @@ func TestProfileAccountPathFor(t *testing.T) { }{ { name: "default profile", - configPath: "/data/data/io.netbird.client/files/netbird.cfg", - want: filepath.FromSlash("/data/data/io.netbird.client/files/netbird.account.json"), + configPath: "/data/netbird/files/netbird.cfg", + want: filepath.FromSlash("/data/netbird/files/netbird.account.json"), }, { name: "id profile", - configPath: "/data/data/io.netbird.client/files/profiles/4c5f5c8198c3989cffb5b5394f5a7ae0.json", - want: filepath.FromSlash("/data/data/io.netbird.client/files/profiles/4c5f5c8198c3989cffb5b5394f5a7ae0.account.json"), + configPath: "/data/netbird/files/profiles/4c5f5c8198c3989cffb5b5394f5a7ae0.json", + want: filepath.FromSlash("/data/netbird/files/profiles/4c5f5c8198c3989cffb5b5394f5a7ae0.account.json"), }, { name: "legacy name-keyed profile is handled the same way", - configPath: "/data/data/io.netbird.client/files/profiles/work.json", - want: filepath.FromSlash("/data/data/io.netbird.client/files/profiles/work.account.json"), + configPath: "/data/netbird/files/profiles/work.json", + want: filepath.FromSlash("/data/netbird/files/profiles/work.account.json"), }, { name: "empty path is rejected", @@ -55,7 +55,7 @@ func TestProfileAccountPathFor(t *testing.T) { } func TestProfileAccountPathForDefaultDoesNotCollide(t *testing.T) { - root := "/data/data/io.netbird.client/files" + root := "/data/netbird/files" defaultAccount, err := profileAccountPathFor(filepath.Join(root, defaultConfigFilename)) if err != nil { @@ -72,12 +72,12 @@ func TestProfileAccountPathForDefaultDoesNotCollide(t *testing.T) { } } -// The account file must never land on the engine state file: on Android both -// resolve under files/, and the state manager rewrites the whole file from its -// own keys, so sharing a path would have the two overwrite each other. The +// The account file must never land on the engine state file: on mobile both +// resolve under configDir, and the state manager rewrites the whole file from +// its own keys, so sharing a path would have the two overwrite each other. The // expected names here mirror ProfileManager.GetStateFilePath. func TestProfileAccountPathAvoidsEngineStateFile(t *testing.T) { - root := "/data/data/io.netbird.client/files" + root := "/data/netbird/files" cases := []struct { configPath string @@ -110,23 +110,23 @@ func TestWriteThenReadProfileEmail(t *testing.T) { t.Fatalf("prepare dir: %v", err) } - if got := readProfileEmail(configPath); got != "" { + if got := ReadProfileEmail(configPath); got != "" { t.Errorf("expected no email before a login, got %q", got) } const email = "user@example.com" - if err := writeProfileEmail(configPath, email); err != nil { + if err := WriteProfileEmail(configPath, email); err != nil { t.Fatalf("write: %v", err) } - if got := readProfileEmail(configPath); got != email { + if got := ReadProfileEmail(configPath); got != email { t.Errorf("got %q, want %q", got, email) } if err := removeProfileEmail(configPath); err != nil { t.Fatalf("remove: %v", err) } - if got := readProfileEmail(configPath); got != "" { + if got := ReadProfileEmail(configPath); got != "" { t.Errorf("expected no email after removal, got %q", got) } @@ -143,14 +143,14 @@ func TestWriteProfileEmailIgnoresEmpty(t *testing.T) { } const email = "user@example.com" - if err := writeProfileEmail(configPath, email); err != nil { + if err := WriteProfileEmail(configPath, email); err != nil { t.Fatalf("write: %v", err) } - if err := writeProfileEmail(configPath, ""); err != nil { + if err := WriteProfileEmail(configPath, ""); err != nil { t.Fatalf("write empty: %v", err) } - if got := readProfileEmail(configPath); got != email { + if got := ReadProfileEmail(configPath); got != email { t.Errorf("empty write clobbered the stored email: got %q, want %q", got, email) } } diff --git a/client/net/fwmark.go b/client/net/fwmark.go new file mode 100644 index 000000000..b526feee4 --- /dev/null +++ b/client/net/fwmark.go @@ -0,0 +1,110 @@ +package net + +import ( + "fmt" + "os" + "strconv" + "strings" + + log "github.com/sirupsen/logrus" +) + +const ( + // envFwmarkBase overrides the base of the fwmark range. Container network + // plugins, CNIs and other VPNs claim bits of the mark space for themselves, + // and a rule of theirs matching one of our bits acts on our traffic, so + // hosts running such software may need to move the range out of the way. + envFwmarkBase = "NB_FWMARK_BASE" + + // defaultFwmarkBase is the base of the fwmark range used when the + // environment does not override it. + defaultFwmarkBase uint32 = 0x1BD00 + + // fwmarkOffsetMask is the part of a mark that identifies the individual mark + // within the range, so the base occupies everything above it. + fwmarkOffsetMask uint32 = 0xFF +) + +// Offsets of the individual marks within the range. +const ( + offsetControlPlane uint32 = 0x00 + offsetDataPlaneIn uint32 = 0x10 + offsetDataPlaneOut uint32 = 0x11 + offsetRedirected uint32 = 0x20 + offsetMasquerade uint32 = 0x21 + offsetMasqueradeReturn uint32 = 0x22 + offsetDataPlaneLower uint32 = 0x10 + offsetDataPlaneUpper uint32 = fwmarkOffsetMask +) + +var ( + fwmarkBase = loadFwmarkBase() + + // ControlPlaneMark is the fwmark value used to mark packets that should not be routed through the NetBird interface to + // avoid routing loops. + // This includes all control plane traffic (mgmt, signal, flows), relay, ICE/stun/turn and everything that is emitted by the wireguard socket. + // It doesn't collide with the other marks, as the others are used for data plane traffic only. + ControlPlaneMark = fwmarkBase | offsetControlPlane + + // DataPlaneMarkLower is the lowest value for the data plane range + DataPlaneMarkLower = fwmarkBase | offsetDataPlaneLower + // DataPlaneMarkUpper is the highest value for the data plane range + DataPlaneMarkUpper = fwmarkBase | offsetDataPlaneUpper + + // DataPlaneMarkIn is the mark for inbound data plane traffic. + DataPlaneMarkIn = fwmarkBase | offsetDataPlaneIn + + // DataPlaneMarkOut is the mark for outbound data plane traffic. + DataPlaneMarkOut = fwmarkBase | offsetDataPlaneOut + + // PreroutingFwmarkRedirected is applied to packets that were redirected (input -> forward, e.g. by Docker or Podman) for special handling. + PreroutingFwmarkRedirected = fwmarkBase | offsetRedirected + + // PreroutingFwmarkMasquerade is applied to packets that arrive from the NetBird interface and should be masqueraded. + PreroutingFwmarkMasquerade = fwmarkBase | offsetMasquerade + + // PreroutingFwmarkMasqueradeReturn is applied to packets that will leave through the NetBird interface and should be masqueraded. + PreroutingFwmarkMasqueradeReturn = fwmarkBase | offsetMasqueradeReturn +) + +// IsDataPlaneMark determines if a fwmark is in the data plane range. +func IsDataPlaneMark(fwmark uint32) bool { + return fwmark >= DataPlaneMarkLower && fwmark <= DataPlaneMarkUpper +} + +func loadFwmarkBase() uint32 { + val := os.Getenv(envFwmarkBase) + if val == "" { + return defaultFwmarkBase + } + + base, err := parseFwmarkBase(val) + if err != nil { + log.Warnf("failed to parse %s=%q, using the default range: %v", envFwmarkBase, val, err) + return defaultFwmarkBase + } + + log.Infof("using fwmark range %#x-%#x from %s", base, base|fwmarkOffsetMask, envFwmarkBase) + return base +} + +// parseFwmarkBase reads a mark range base. The low byte of a mark identifies the +// individual mark within the range, so a base has to leave it free. +func parseFwmarkBase(val string) (uint32, error) { + val = strings.TrimSpace(val) + + base, err := strconv.ParseUint(val, 0, 32) + if err != nil { + return 0, fmt.Errorf("not a 32 bit number: %w", err) + } + + if base == 0 { + return 0, fmt.Errorf("base must not be zero") + } + + if uint32(base)&fwmarkOffsetMask != 0 { + return 0, fmt.Errorf("base %#x must leave the low byte free", base) + } + + return uint32(base), nil +} diff --git a/client/net/fwmark_test.go b/client/net/fwmark_test.go new file mode 100644 index 000000000..2dbebec2a --- /dev/null +++ b/client/net/fwmark_test.go @@ -0,0 +1,111 @@ +package net + +import ( + "testing" +) + +func TestParseFwmarkBase(t *testing.T) { + tests := []struct { + name string + val string + want uint32 + wantErr bool + }{ + {name: "hex", val: "0x5A000", want: 0x5A000}, + {name: "hex upper case", val: "0X5A000", want: 0x5A000}, + {name: "decimal", val: "65536", want: 65536}, + {name: "octal", val: "0o400", want: 0o400}, + {name: "surrounding space", val: " 0x5A000 ", want: 0x5A000}, + {name: "highest usable base", val: "0xFFFFFF00", want: 0xFFFFFF00}, + {name: "low byte in use", val: "0x1BD01", wantErr: true}, + {name: "zero", val: "0", wantErr: true}, + {name: "not a number", val: "wireguard", wantErr: true}, + {name: "wider than 32 bit", val: "0x1FFFFFFFF", wantErr: true}, + {name: "negative", val: "-0x100", wantErr: true}, + {name: "empty", val: "", wantErr: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := parseFwmarkBase(tc.val) + if tc.wantErr { + if err == nil { + t.Fatalf("parseFwmarkBase(%q) = %#x, want an error", tc.val, got) + } + return + } + if err != nil { + t.Fatalf("parseFwmarkBase(%q): %v", tc.val, err) + } + if got != tc.want { + t.Errorf("parseFwmarkBase(%q) = %#x, want %#x", tc.val, got, tc.want) + } + }) + } +} + +// The marks have to stay inside the range the base defines, otherwise a host +// that moved the range to dodge a collision would still emit the old values. +func TestMarksStayWithinTheRange(t *testing.T) { + lower, upper := fwmarkBase, fwmarkBase|fwmarkOffsetMask + + marks := map[string]uint32{ + "ControlPlaneMark": ControlPlaneMark, + "DataPlaneMarkLower": DataPlaneMarkLower, + "DataPlaneMarkUpper": DataPlaneMarkUpper, + "DataPlaneMarkIn": DataPlaneMarkIn, + "DataPlaneMarkOut": DataPlaneMarkOut, + "PreroutingFwmarkRedirected": PreroutingFwmarkRedirected, + "PreroutingFwmarkMasquerade": PreroutingFwmarkMasquerade, + "PreroutingFwmarkMasqueradeReturn": PreroutingFwmarkMasqueradeReturn, + } + + for name, mark := range marks { + if mark < lower || mark > upper { + t.Errorf("%s = %#x, outside the range %#x-%#x", name, mark, lower, upper) + } + } + + // the control plane mark must stay out of the data plane range, the netflow + // conntrack path tells them apart by it + if IsDataPlaneMark(ControlPlaneMark) { + t.Errorf("ControlPlaneMark %#x is inside the data plane range", ControlPlaneMark) + } + for name, mark := range map[string]uint32{ + "DataPlaneMarkIn": DataPlaneMarkIn, + "DataPlaneMarkOut": DataPlaneMarkOut, + "PreroutingFwmarkRedirected": PreroutingFwmarkRedirected, + "PreroutingFwmarkMasquerade": PreroutingFwmarkMasquerade, + "PreroutingFwmarkMasqueradeReturn": PreroutingFwmarkMasqueradeReturn, + } { + if !IsDataPlaneMark(mark) { + t.Errorf("%s = %#x is outside the data plane range %#x-%#x", name, mark, DataPlaneMarkLower, DataPlaneMarkUpper) + } + } +} + +func TestDefaultMarksAreUnchanged(t *testing.T) { + tests := map[string]struct { + got uint32 + want uint32 + }{ + "ControlPlaneMark": {ControlPlaneMark, 0x1BD00}, + "DataPlaneMarkLower": {DataPlaneMarkLower, 0x1BD10}, + "DataPlaneMarkUpper": {DataPlaneMarkUpper, 0x1BDFF}, + "DataPlaneMarkIn": {DataPlaneMarkIn, 0x1BD10}, + "DataPlaneMarkOut": {DataPlaneMarkOut, 0x1BD11}, + "PreroutingFwmarkRedirected": {PreroutingFwmarkRedirected, 0x1BD20}, + "PreroutingFwmarkMasquerade": {PreroutingFwmarkMasquerade, 0x1BD21}, + "PreroutingFwmarkMasqueradeReturn": {PreroutingFwmarkMasqueradeReturn, 0x1BD22}, + } + + if fwmarkBase != defaultFwmarkBase { + t.Skipf("%s is set, the defaults do not apply", envFwmarkBase) + } + + for name, tc := range tests { + if tc.got != tc.want { + t.Errorf("%s = %#x, want %#x", name, tc.got, tc.want) + } + } +} diff --git a/client/net/net.go b/client/net/net.go index a97de9d59..77fba36d1 100644 --- a/client/net/net.go +++ b/client/net/net.go @@ -7,41 +7,6 @@ import ( "net/netip" ) -const ( - // ControlPlaneMark is the fwmark value used to mark packets that should not be routed through the NetBird interface to - // avoid routing loops. - // This includes all control plane traffic (mgmt, signal, flows), relay, ICE/stun/turn and everything that is emitted by the wireguard socket. - // It doesn't collide with the other marks, as the others are used for data plane traffic only. - ControlPlaneMark = 0x1BD00 - - // Data plane marks (0x1BD10 - 0x1BDFF) - - // DataPlaneMarkLower is the lowest value for the data plane range - DataPlaneMarkLower = 0x1BD10 - // DataPlaneMarkUpper is the highest value for the data plane range - DataPlaneMarkUpper = 0x1BDFF - - // DataPlaneMarkIn is the mark for inbound data plane traffic. - DataPlaneMarkIn = 0x1BD10 - - // DataPlaneMarkOut is the mark for outbound data plane traffic. - DataPlaneMarkOut = 0x1BD11 - - // PreroutingFwmarkRedirected is applied to packets that are were redirected (input -> forward, e.g. by Docker or Podman) for special handling. - PreroutingFwmarkRedirected = 0x1BD20 - - // PreroutingFwmarkMasquerade is applied to packets that arrive from the NetBird interface and should be masqueraded. - PreroutingFwmarkMasquerade = 0x1BD21 - - // PreroutingFwmarkMasqueradeReturn is applied to packets that will leave through the NetBird interface and should be masqueraded. - PreroutingFwmarkMasqueradeReturn = 0x1BD22 -) - -// IsDataPlaneMark determines if a fwmark is in the data plane range (0x1BD10-0x1BDFF) -func IsDataPlaneMark(fwmark uint32) bool { - return fwmark >= DataPlaneMarkLower && fwmark <= DataPlaneMarkUpper -} - func GetLastIPFromNetwork(network netip.Prefix, fromEnd int) (netip.Addr, error) { var endIP net.IP addr := network.Addr().AsSlice() diff --git a/client/net/net_linux.go b/client/net/net_linux.go index 9e7d13702..8ed8a1944 100644 --- a/client/net/net_linux.go +++ b/client/net/net_linux.go @@ -21,15 +21,6 @@ func SetSocketMark(conn syscall.Conn) error { return setRawSocketMark(sysconn) } -// SetSocketOpt sets the SO_MARK option on the given file descriptor -func SetSocketOpt(fd int) error { - if !AdvancedRouting() { - return nil - } - - return setSocketOptInt(fd) -} - func setRawSocketMark(conn syscall.RawConn) error { var setErr error @@ -51,5 +42,5 @@ func setRawSocketMark(conn syscall.RawConn) error { } func setSocketOptInt(fd int) error { - return syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_MARK, ControlPlaneMark) + return syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_MARK, int(ControlPlaneMark)) } diff --git a/client/netevents/netevents.go b/client/netevents/netevents.go new file mode 100644 index 000000000..474cbfa22 --- /dev/null +++ b/client/netevents/netevents.go @@ -0,0 +1,173 @@ +// Package netevents owns the OS network event handling shared by the mobile +// bindings: availability changes park or wake the reconnection loops and drive +// the NoNetwork listener state, and both losing the last network and switching +// networks sweep the stale connections so their owners redial immediately. +package netevents + +import ( + "context" + "sync" + "time" + + "github.com/cenkalti/backoff/v4" + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/netevents/netstate" + "github.com/netbirdio/netbird/client/netevents/sweep" +) + +// Recorder receives the availability changes for listener state reporting. +type Recorder interface { + SetNetworkAvailable(available bool) +} + +// Manager ties the network availability state, the connection sweeper and the +// status recorder together; it outlives engine restarts. A nil *Manager is +// the valid no-events value for consumers: the read methods report +// always-online and never sweep. Only the event sources hold a real Manager, +// so the write methods do not tolerate a nil receiver. +type Manager struct { + // mu serializes availability transitions: the IsOnline check and the + // state update must be atomic, or a racing offline flip can skip the sweep + // and leave netState and the recorder disagreeing. + mu sync.Mutex + netState *netstate.State + sweeper *sweep.Sweeper + recorder Recorder +} + +// NewManager creates a Manager reporting into recorder, starting online. +func NewManager(recorder Recorder) *Manager { + return &Manager{ + netState: netstate.New(), + sweeper: sweep.New(), + recorder: recorder, + } +} + +// SetNetworkAvailable records OS-reported network availability. While +// unavailable, the reconnection loops suspend their attempts and the +// connection listener reports NoNetwork instead of Connecting; when +// availability returns, the loops resume immediately with a fresh backoff. +// Losing the last network also sweeps the registered connections: nothing can +// redial while offline, so the stale sockets would otherwise stay silently +// "connected" until their own timeouts and the client would keep reporting +// Connected with no network at all. +// +// Panics on a nil receiver: only the mobile bindings that own a Manager +// report availability. +func (m *Manager) SetNetworkAvailable(available bool) { + m.mu.Lock() + defer m.mu.Unlock() + + if !available && m.netState.IsOnline() { + m.sweeper.MarkNetworkChange() + } + m.netState.Set(available) + m.recorder.SetNetworkAvailable(available) +} + +// NotifyNetworkChange marks the management, signal and relay connections +// stale after the OS switched networks and schedules a sweep that cuts +// whatever has not redialed on the new network by then. The engine and the +// TUN device stay untouched. +// +// Panics on a nil receiver: only the mobile bindings that own a Manager +// report network changes. +func (m *Manager) NotifyNetworkChange() { + m.sweeper.MarkNetworkChange() + log.Infof("network change: connections marked stale") +} + +// IsOnline reports whether the OS reports at least one usable network. +func (m *Manager) IsOnline() bool { + if m == nil { + return true + } + return m.netState.IsOnline() +} + +// Changed returns a channel closed on the next availability transition. +func (m *Manager) Changed() <-chan struct{} { + if m == nil { + return nil + } + return m.netState.Changed() +} + +// Wait blocks while the network is offline; see netstate.State.Wait. +func (m *Manager) Wait(ctx context.Context) (bool, error) { + if m == nil { + return false, nil + } + return m.netState.Wait(ctx) +} + +// WaitSettled waits until an online verdict holds for a full settleWindow, or +// while offline until the budget runs out. Returns false when ctx is +// cancelled. The settle window exists because a disconnect often precedes the +// OS offline flag by a few milliseconds, so a fresh online verdict cannot be +// trusted immediately. A nil Manager has no events to watch: it degrades to a +// fixed budget-long sleep. +func (m *Manager) WaitSettled(ctx context.Context, budget, settleWindow time.Duration) bool { + if m == nil { + select { + case <-time.After(budget): + return true + case <-ctx.Done(): + return false + } + } + + budgetTimer := time.NewTimer(budget) + defer budgetTimer.Stop() + + settle := time.NewTimer(settleWindow) + defer settle.Stop() + + for { + // Channel first, flag second: a flip in between still fires the channel. + changedCh := m.netState.Changed() + if m.netState.IsOnline() { + select { + case <-settle.C: + return true + case <-changedCh: + case <-ctx.Done(): + return false + } + } else { + select { + case <-budgetTimer.C: + return true + case <-changedCh: + case <-ctx.Done(): + return false + } + } + if !settle.Stop() { + select { + case <-settle.C: + default: + } + } + settle.Reset(settleWindow) + } +} + +// StartDial registers an in-flight dial with the sweeper; see sweep.Sweeper.StartDial. +func (m *Manager) StartDial(ctx context.Context) *sweep.Dial { + if m == nil { + return (*sweep.Sweeper)(nil).StartDial(ctx) + } + return m.sweeper.StartDial(ctx) +} + +// QuickRetryBackoff wraps bo for a quick retry after a network change; see +// sweep.Sweeper.QuickRetryBackoff. +func (m *Manager) QuickRetryBackoff(ctx context.Context, bo backoff.BackOff) backoff.BackOff { + if m == nil { + return bo + } + return m.sweeper.QuickRetryBackoff(ctx, bo, m.netState) +} diff --git a/client/netevents/netevents_test.go b/client/netevents/netevents_test.go new file mode 100644 index 000000000..a62ddc270 --- /dev/null +++ b/client/netevents/netevents_test.go @@ -0,0 +1,34 @@ +package netevents + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +type recorderStub struct{} + +func (recorderStub) SetNetworkAvailable(bool) {} + +func TestWaitSettledAfterOutage(t *testing.T) { + const budget = 1500 * time.Millisecond + const settleWindow = 200 * time.Millisecond + const outage = 2 * settleWindow + + m := NewManager(recorderStub{}) + m.SetNetworkAvailable(false) + + start := time.Now() + go func() { + time.Sleep(outage) + m.SetNetworkAvailable(true) + }() + + ok := m.WaitSettled(context.Background(), budget, settleWindow) + elapsed := time.Since(start) + + assert.True(t, ok, "recovered network must let the caller proceed") + assert.GreaterOrEqual(t, elapsed, outage+settleWindow, "an online verdict must hold a full settle window before it is trusted") +} diff --git a/client/netstate/netstate.go b/client/netevents/netstate/netstate.go similarity index 100% rename from client/netstate/netstate.go rename to client/netevents/netstate/netstate.go diff --git a/client/netstate/netstate_test.go b/client/netevents/netstate/netstate_test.go similarity index 100% rename from client/netstate/netstate_test.go rename to client/netevents/netstate/netstate_test.go diff --git a/client/netsweep/quick_retry.go b/client/netevents/sweep/quick_retry.go similarity index 90% rename from client/netsweep/quick_retry.go rename to client/netevents/sweep/quick_retry.go index 524a5c50c..1e174b20a 100644 --- a/client/netsweep/quick_retry.go +++ b/client/netevents/sweep/quick_retry.go @@ -1,11 +1,11 @@ -package netsweep +package sweep import ( "time" "github.com/cenkalti/backoff/v4" - "github.com/netbirdio/netbird/client/netstate" + "github.com/netbirdio/netbird/client/netevents/netstate" ) const quickRetryDelay = 200 * time.Millisecond diff --git a/client/netsweep/quick_retry_test.go b/client/netevents/sweep/quick_retry_test.go similarity index 99% rename from client/netsweep/quick_retry_test.go rename to client/netevents/sweep/quick_retry_test.go index 5505862c5..3dadd951c 100644 --- a/client/netsweep/quick_retry_test.go +++ b/client/netevents/sweep/quick_retry_test.go @@ -1,4 +1,4 @@ -package netsweep +package sweep import ( "context" diff --git a/client/netsweep/netsweep.go b/client/netevents/sweep/sweep.go similarity index 96% rename from client/netsweep/netsweep.go rename to client/netevents/sweep/sweep.go index 46bc0a709..52dce92be 100644 --- a/client/netsweep/netsweep.go +++ b/client/netevents/sweep/sweep.go @@ -1,10 +1,10 @@ -// Package netsweep cuts network-bound activity when the OS switches networks: +// Package sweep cuts network-bound activity when the OS switches networks: // a sweep closes the registered connections and aborts the in-flight dials, so // their owners redial immediately instead of waiting for the old sockets to // time out. // // A nil *Sweeper disables everything: all methods are nil-safe no-ops. -package netsweep +package sweep import ( "context" @@ -16,7 +16,7 @@ import ( "github.com/cenkalti/backoff/v4" log "github.com/sirupsen/logrus" - "github.com/netbirdio/netbird/client/netstate" + "github.com/netbirdio/netbird/client/netevents/netstate" ) // DefaultSweepDelay absorbs network flapping while the OS settles on a @@ -34,7 +34,7 @@ type Config struct { // ErrSwept reports that a dial finished after a network change swept its // registration. The connection is already closed; the caller must treat it // as a failed dial and redial on the new network. -var ErrSwept = errors.New("netsweep: connection swept by network change") +var ErrSwept = errors.New("sweep: connection swept by network change") // sweepID identifies one registration in a sweeper. Connections and dials // draw from the same counter, so an id is unique across both registries. diff --git a/client/netsweep/netsweep_test.go b/client/netevents/sweep/sweep_test.go similarity index 99% rename from client/netsweep/netsweep_test.go rename to client/netevents/sweep/sweep_test.go index 88d660c2d..c162d4c0f 100644 --- a/client/netsweep/netsweep_test.go +++ b/client/netevents/sweep/sweep_test.go @@ -1,4 +1,4 @@ -package netsweep +package sweep import ( "context" diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go index eff2a2015..4a2f8cb0a 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.6 -// protoc v6.33.1 +// protoc v7.34.1 // source: daemon.proto package proto @@ -346,8 +346,10 @@ 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"` - ServerVNCAllowed *bool `protobuf:"varint,41,opt,name=serverVNCAllowed,proto3,oneof" json:"serverVNCAllowed,omitempty"` - DisableVNCApproval *bool `protobuf:"varint,42,opt,name=disableVNCApproval,proto3,oneof" json:"disableVNCApproval,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"` + ServerVNCAllowed *bool `protobuf:"varint,43,opt,name=serverVNCAllowed,proto3,oneof" json:"serverVNCAllowed,omitempty"` + DisableVNCApproval *bool `protobuf:"varint,44,opt,name=disableVNCApproval,proto3,oneof" json:"disableVNCApproval,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -663,6 +665,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 "" +} + func (x *LoginRequest) GetServerVNCAllowed() bool { if x != nil && x.ServerVNCAllowed != nil { return *x.ServerVNCAllowed @@ -1234,14 +1250,14 @@ type GetConfigResponse struct { DisableSSHAuth bool `protobuf:"varint,25,opt,name=disableSSHAuth,proto3" json:"disableSSHAuth,omitempty"` SshJWTCacheTTL int32 `protobuf:"varint,26,opt,name=sshJWTCacheTTL,proto3" json:"sshJWTCacheTTL,omitempty"` DisableIpv6 bool `protobuf:"varint,27,opt,name=disable_ipv6,json=disableIpv6,proto3" json:"disable_ipv6,omitempty"` - ServerVNCAllowed bool `protobuf:"varint,28,opt,name=serverVNCAllowed,proto3" json:"serverVNCAllowed,omitempty"` - DisableVNCApproval bool `protobuf:"varint,29,opt,name=disableVNCApproval,proto3" json:"disableVNCApproval,omitempty"` + ServerVNCAllowed bool `protobuf:"varint,29,opt,name=serverVNCAllowed,proto3" json:"serverVNCAllowed,omitempty"` + DisableVNCApproval bool `protobuf:"varint,30,opt,name=disableVNCApproval,proto3" json:"disableVNCApproval,omitempty"` // mDMManagedFields lists the names of configuration keys whose value is // currently enforced by an MDM policy. Names match mdm.Key* constants // (e.g. "managementURL", "disableClientRoutes"). UI/CLI clients should // render the corresponding inputs as read-only and display a "managed // by MDM" indicator. - MDMManagedFields []string `protobuf:"bytes,30,rep,name=mDMManagedFields,proto3" json:"mDMManagedFields,omitempty"` + MDMManagedFields []string `protobuf:"bytes,28,rep,name=mDMManagedFields,proto3" json:"mDMManagedFields,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -4410,8 +4426,10 @@ 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"` - ServerVNCAllowed *bool `protobuf:"varint,36,opt,name=serverVNCAllowed,proto3,oneof" json:"serverVNCAllowed,omitempty"` - DisableVNCApproval *bool `protobuf:"varint,37,opt,name=disableVNCApproval,proto3,oneof" json:"disableVNCApproval,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"` + ServerVNCAllowed *bool `protobuf:"varint,38,opt,name=serverVNCAllowed,proto3,oneof" json:"serverVNCAllowed,omitempty"` + DisableVNCApproval *bool `protobuf:"varint,39,opt,name=disableVNCApproval,proto3,oneof" json:"disableVNCApproval,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -4691,6 +4709,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 "" +} + func (x *SetConfigRequest) GetServerVNCAllowed() bool { if x != nil && x.ServerVNCAllowed != nil { return *x.ServerVNCAllowed @@ -7328,7 +7360,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\"\x81\x14\n" + + "\fEmptyRequest\"\xa4\x15\n" + "\fLoginRequest\x12\x1a\n" + "\bsetupKey\x18\x01 \x01(\tR\bsetupKey\x12&\n" + "\fpreSharedKey\x18\x02 \x01(\tB\x02\x18\x01R\fpreSharedKey\x12$\n" + @@ -7373,9 +7405,11 @@ 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\x01\x12/\n" + - "\x10serverVNCAllowed\x18) \x01(\bH\x1cR\x10serverVNCAllowed\x88\x01\x01\x123\n" + - "\x12disableVNCApproval\x18* \x01(\bH\x1dR\x12disableVNCApproval\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\x01\x12/\n" + + "\x10serverVNCAllowed\x18+ \x01(\bH\x1eR\x10serverVNCAllowed\x88\x01\x01\x123\n" + + "\x12disableVNCApproval\x18, \x01(\bH\x1fR\x12disableVNCApproval\x88\x01\x01B\x13\n" + "\x11_rosenpassEnabledB\x10\n" + "\x0e_interfaceNameB\x10\n" + "\x0e_wireguardPortB\x17\n" + @@ -7403,7 +7437,9 @@ const file_daemon_proto_rawDesc = "" + "\x1e_enableSSHRemotePortForwardingB\x11\n" + "\x0f_disableSSHAuthB\x11\n" + "\x0f_sshJWTCacheTTLB\x0f\n" + - "\r_disable_ipv6B\x13\n" + + "\r_disable_ipv6B\x17\n" + + "\x15_enable_local_metricsB\x18\n" + + "\x16_local_metrics_addressB\x13\n" + "\x11_serverVNCAllowedB\x15\n" + "\x13_disableVNCApproval\"\xb5\x01\n" + "\rLoginResponse\x12$\n" + @@ -7474,9 +7510,9 @@ const file_daemon_proto_rawDesc = "" + "\x0edisableSSHAuth\x18\x19 \x01(\bR\x0edisableSSHAuth\x12&\n" + "\x0esshJWTCacheTTL\x18\x1a \x01(\x05R\x0esshJWTCacheTTL\x12!\n" + "\fdisable_ipv6\x18\x1b \x01(\bR\vdisableIpv6\x12*\n" + - "\x10serverVNCAllowed\x18\x1c \x01(\bR\x10serverVNCAllowed\x12.\n" + - "\x12disableVNCApproval\x18\x1d \x01(\bR\x12disableVNCApproval\x12*\n" + - "\x10mDMManagedFields\x18\x1e \x03(\tR\x10mDMManagedFields\"\x92\x06\n" + + "\x10serverVNCAllowed\x18\x1d \x01(\bR\x10serverVNCAllowed\x12.\n" + + "\x12disableVNCApproval\x18\x1e \x01(\bR\x12disableVNCApproval\x12*\n" + + "\x10mDMManagedFields\x18\x1c \x03(\tR\x10mDMManagedFields\"\x92\x06\n" + "\tPeerState\x12\x0e\n" + "\x02IP\x18\x01 \x01(\tR\x02IP\x12\x16\n" + "\x06pubKey\x18\x02 \x01(\tR\x06pubKey\x12\x1e\n" + @@ -7714,7 +7750,7 @@ const file_daemon_proto_rawDesc = "" + "\f_profileNameB\v\n" + "\t_username\"'\n" + "\x15SwitchProfileResponse\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\"\xaa\x12\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"\xcd\x13\n" + "\x10SetConfigRequest\x12\x1a\n" + "\busername\x18\x01 \x01(\tR\busername\x12 \n" + "\vprofileName\x18\x02 \x01(\tR\vprofileName\x12$\n" + @@ -7754,9 +7790,11 @@ 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\x01\x12/\n" + - "\x10serverVNCAllowed\x18$ \x01(\bH\x19R\x10serverVNCAllowed\x88\x01\x01\x123\n" + - "\x12disableVNCApproval\x18% \x01(\bH\x1aR\x12disableVNCApproval\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\x01\x12/\n" + + "\x10serverVNCAllowed\x18& \x01(\bH\x1bR\x10serverVNCAllowed\x88\x01\x01\x123\n" + + "\x12disableVNCApproval\x18' \x01(\bH\x1cR\x12disableVNCApproval\x88\x01\x01B\x13\n" + "\x11_rosenpassEnabledB\x10\n" + "\x0e_interfaceNameB\x10\n" + "\x0e_wireguardPortB\x17\n" + @@ -7781,7 +7819,9 @@ const file_daemon_proto_rawDesc = "" + "\x1e_enableSSHRemotePortForwardingB\x11\n" + "\x0f_disableSSHAuthB\x11\n" + "\x0f_sshJWTCacheTTLB\x0f\n" + - "\r_disable_ipv6B\x13\n" + + "\r_disable_ipv6B\x17\n" + + "\x15_enable_local_metricsB\x18\n" + + "\x16_local_metrics_addressB\x13\n" + "\x11_serverVNCAllowedB\x15\n" + "\x13_disableVNCApproval\"\x13\n" + "\x11SetConfigResponse\"Q\n" + diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto index 0eb23ae02..c38ec6547 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -251,9 +251,12 @@ message LoginRequest { optional int32 sshJWTCacheTTL = 39; optional bool disable_ipv6 = 40; - optional bool serverVNCAllowed = 41; + optional bool enable_local_metrics = 41; + optional string local_metrics_address = 42; - optional bool disableVNCApproval = 42; + optional bool serverVNCAllowed = 43; + + optional bool disableVNCApproval = 44; } message LoginResponse { @@ -374,16 +377,16 @@ message GetConfigResponse { bool disable_ipv6 = 27; - bool serverVNCAllowed = 28; + bool serverVNCAllowed = 29; - bool disableVNCApproval = 29; + bool disableVNCApproval = 30; // mDMManagedFields lists the names of configuration keys whose value is // currently enforced by an MDM policy. Names match mdm.Key* constants // (e.g. "managementURL", "disableClientRoutes"). UI/CLI clients should // render the corresponding inputs as read-only and display a "managed // by MDM" indicator. - repeated string mDMManagedFields = 30; + repeated string mDMManagedFields = 28; } // PeerState contains the latest state of a peer @@ -804,9 +807,12 @@ message SetConfigRequest { optional int32 sshJWTCacheTTL = 34; optional bool disable_ipv6 = 35; - optional bool serverVNCAllowed = 36; + optional bool enable_local_metrics = 36; + optional string local_metrics_address = 37; - optional bool disableVNCApproval = 37; + optional bool serverVNCAllowed = 38; + + optional bool disableVNCApproval = 39; } message SetConfigResponse{} diff --git a/client/proto/daemon_grpc.pb.go b/client/proto/daemon_grpc.pb.go index 53ff3e87b..67e003246 100644 --- a/client/proto/daemon_grpc.pb.go +++ b/client/proto/daemon_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.1 -// - protoc v6.33.1 +// - protoc v7.34.1 // source: daemon.proto package proto diff --git a/client/server/mdm.go b/client/server/mdm.go index b82aa8712..15da16e51 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. @@ -303,6 +321,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), }) } @@ -350,7 +370,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 @@ -387,7 +409,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 @@ -430,6 +454,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 db74a621d..ee6cbd587 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 @@ -175,9 +179,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() @@ -258,6 +281,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) @@ -481,11 +505,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 } @@ -555,6 +586,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.ServerVNCAllowed = msg.ServerVNCAllowed @@ -663,6 +696,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 @@ -1013,6 +1048,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{}) @@ -1190,6 +1226,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 6e3195a42..a56a26550 100644 --- a/client/server/setconfig_mdm_test.go +++ b/client/server/setconfig_mdm_test.go @@ -160,6 +160,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 d86e00d59..afe7486fa 100644 --- a/client/server/setconfig_test.go +++ b/client/server/setconfig_test.go @@ -78,6 +78,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, @@ -111,6 +113,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) @@ -161,6 +165,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) } @@ -215,6 +221,8 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) { "EnableSSHRemotePortForwarding": true, "DisableSSHAuth": true, "SshJWTCacheTTL": true, + "EnableLocalMetrics": true, + "LocalMetricsAddress": true, } val := reflect.ValueOf(req).Elem() @@ -276,6 +284,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 d1bce25de..f9b75a521 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" @@ -34,6 +35,8 @@ import ( // 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, or reach @@ -44,33 +47,39 @@ 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 - serverVNCAllowed *bool - disableVNCApproval *bool + managementURL string + serverSSHAllowed *bool + enableSSHRoot *bool + disableSSHAuth *bool + serverVNCAllowed *bool + disableVNCApproval *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, - serverVNCAllowed: msg.ServerVNCAllowed, - disableVNCApproval: msg.DisableVNCApproval, + managementURL: msg.GetManagementUrl(), + serverSSHAllowed: msg.ServerSSHAllowed, + enableSSHRoot: msg.EnableSSHRoot, + disableSSHAuth: msg.DisableSSHAuth, + serverVNCAllowed: msg.ServerVNCAllowed, + disableVNCApproval: msg.DisableVNCApproval, + 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, - serverVNCAllowed: msg.ServerVNCAllowed, - disableVNCApproval: msg.DisableVNCApproval, + managementURL: msg.GetManagementUrl(), + serverSSHAllowed: msg.ServerSSHAllowed, + enableSSHRoot: msg.EnableSSHRoot, + disableSSHAuth: msg.DisableSSHAuth, + serverVNCAllowed: msg.ServerVNCAllowed, + disableVNCApproval: msg.DisableVNCApproval, + enableLocalMetrics: msg.EnableLocalMetrics, + localMetricsAddress: msg.LocalMetricsAddress, } } @@ -98,6 +107,12 @@ func requirePrivilegeForConfigChange(ctx context.Context, stored *profilemanager return err } + 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 a remote-access server is enabled: // that is when the management identity decides who may open a shell or reach // the desktop here. @@ -263,6 +278,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/ssh/server/getent_cgo_unix.go b/client/ssh/server/getent_cgo_unix.go deleted file mode 100644 index 4afbfc627..000000000 --- a/client/ssh/server/getent_cgo_unix.go +++ /dev/null @@ -1,24 +0,0 @@ -//go:build cgo && !osusergo && !windows - -package server - -import "os/user" - -// lookupWithGetent with CGO delegates directly to os/user.Lookup. -// When CGO is enabled, os/user uses libc (getpwnam_r) which goes through -// the NSS stack natively. If it fails, the user truly doesn't exist and -// getent would also fail. -func lookupWithGetent(username string) (*user.User, error) { - return user.Lookup(username) -} - -// currentUserWithGetent with CGO delegates directly to os/user.Current. -func currentUserWithGetent() (*user.User, error) { - return user.Current() -} - -// groupIdsWithFallback with CGO delegates directly to user.GroupIds. -// libc's getgrouplist handles NSS groups natively. -func groupIdsWithFallback(u *user.User) ([]string, error) { - return u.GroupIds() -} diff --git a/client/ssh/server/getent_nocgo_unix.go b/client/ssh/server/getent_nocgo_unix.go deleted file mode 100644 index 314daae4c..000000000 --- a/client/ssh/server/getent_nocgo_unix.go +++ /dev/null @@ -1,74 +0,0 @@ -//go:build (!cgo || osusergo) && !windows - -package server - -import ( - "os" - "os/user" - "strconv" - - log "github.com/sirupsen/logrus" -) - -// lookupWithGetent looks up a user by name, falling back to getent if os/user fails. -// Without CGO, os/user only reads /etc/passwd and misses NSS-provided users. -// getent goes through the host's NSS stack. -func lookupWithGetent(username string) (*user.User, error) { - u, err := user.Lookup(username) - if err == nil { - return u, nil - } - - stdErr := err - log.Debugf("os/user.Lookup(%q) failed, trying getent: %v", username, err) - - u, _, getentErr := runGetent(username) - if getentErr != nil { - log.Debugf("getent fallback for %q also failed: %v", username, getentErr) - return nil, stdErr - } - - return u, nil -} - -// currentUserWithGetent gets the current user, falling back to getent if os/user fails. -func currentUserWithGetent() (*user.User, error) { - u, err := user.Current() - if err == nil { - return u, nil - } - - stdErr := err - uid := strconv.Itoa(os.Getuid()) - log.Debugf("os/user.Current() failed, trying getent with UID %s: %v", uid, err) - - u, _, getentErr := runGetent(uid) - if getentErr != nil { - return nil, stdErr - } - - return u, nil -} - -// groupIdsWithFallback gets group IDs for a user via the id command first, -// falling back to user.GroupIds(). -// NOTE: unlike lookupWithGetent/currentUserWithGetent which try stdlib first, -// this intentionally tries `id -G` first because without CGO, user.GroupIds() -// only reads /etc/group and silently returns incomplete results for NSS users -// (no error, just missing groups). The id command goes through NSS and returns -// the full set. -func groupIdsWithFallback(u *user.User) ([]string, error) { - ids, err := runIdGroups(u.Username) - if err == nil { - return ids, nil - } - - log.Debugf("id -G %q failed, falling back to user.GroupIds(): %v", u.Username, err) - - ids, stdErr := u.GroupIds() - if stdErr != nil { - return nil, stdErr - } - - return ids, nil -} diff --git a/client/ssh/server/getent_unix.go b/client/ssh/server/getent_unix.go deleted file mode 100644 index a3a9641f8..000000000 --- a/client/ssh/server/getent_unix.go +++ /dev/null @@ -1,127 +0,0 @@ -//go:build !windows - -package server - -import ( - "context" - "fmt" - "os/exec" - "os/user" - "runtime" - "strings" - "time" -) - -const getentTimeout = 5 * time.Second - -// getShellFromGetent gets a user's login shell via getent by UID. -// This is needed even with CGO because getShellFromPasswd reads /etc/passwd -// directly and won't find NSS-provided users there. -func getShellFromGetent(userID string) string { - _, shell, err := runGetent(userID) - if err != nil { - return "" - } - return shell -} - -// runGetent executes `getent passwd ` and returns the user and login shell. -func runGetent(query string) (*user.User, string, error) { - if !validateGetentInput(query) { - return nil, "", fmt.Errorf("invalid getent input: %q", query) - } - - ctx, cancel := context.WithTimeout(context.Background(), getentTimeout) - defer cancel() - - out, err := exec.CommandContext(ctx, "getent", "passwd", query).Output() - if err != nil { - return nil, "", fmt.Errorf("getent passwd %s: %w", query, err) - } - - return parseGetentPasswd(string(out)) -} - -// parseGetentPasswd parses getent passwd output: "name:x:uid:gid:gecos:home:shell" -func parseGetentPasswd(output string) (*user.User, string, error) { - fields := strings.SplitN(strings.TrimSpace(output), ":", 8) - if len(fields) < 6 { - return nil, "", fmt.Errorf("unexpected getent output (need 6+ fields): %q", output) - } - - if fields[0] == "" || fields[2] == "" || fields[3] == "" { - return nil, "", fmt.Errorf("missing required fields in getent output: %q", output) - } - - var shell string - if len(fields) >= 7 { - shell = fields[6] - } - - return &user.User{ - Username: fields[0], - Uid: fields[2], - Gid: fields[3], - Name: fields[4], - HomeDir: fields[5], - }, shell, nil -} - -// validateGetentInput checks that the input is safe to pass to getent or id. -// Allows POSIX usernames, numeric UIDs, and common NSS extensions -// (@ for Kerberos, $ for Samba, + for NIS compat). A leading hyphen is -// rejected so the input can never be parsed as a command-line flag. -func validateGetentInput(input string) bool { - maxLen := 32 - if runtime.GOOS == "linux" { - maxLen = 256 - } - - if len(input) == 0 || len(input) > maxLen { - return false - } - - if input[0] == '-' { - return false - } - - for _, r := range input { - if isAllowedGetentChar(r) { - continue - } - return false - } - return true -} - -func isAllowedGetentChar(r rune) bool { - if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' { - return true - } - switch r { - case '.', '_', '-', '@', '+', '$': - return true - } - return false -} - -// runIdGroups runs `id -G ` and returns the space-separated group IDs. -func runIdGroups(username string) ([]string, error) { - if !validateGetentInput(username) { - return nil, fmt.Errorf("invalid username for id command: %q", username) - } - - ctx, cancel := context.WithTimeout(context.Background(), getentTimeout) - defer cancel() - - out, err := exec.CommandContext(ctx, "id", "-G", username).Output() - if err != nil { - return nil, fmt.Errorf("id -G %s: %w", username, err) - } - - trimmed := strings.TrimSpace(string(out)) - if trimmed == "" { - return nil, fmt.Errorf("id -G %s: empty output", username) - } - return strings.Fields(trimmed), nil -} diff --git a/client/ssh/server/getent_windows.go b/client/ssh/server/getent_windows.go deleted file mode 100644 index 3e76b3e8e..000000000 --- a/client/ssh/server/getent_windows.go +++ /dev/null @@ -1,26 +0,0 @@ -//go:build windows - -package server - -import "os/user" - -// lookupWithGetent on Windows just delegates to os/user.Lookup. -// Windows does not use NSS/getent; its user lookup works without CGO. -func lookupWithGetent(username string) (*user.User, error) { - return user.Lookup(username) -} - -// currentUserWithGetent on Windows just delegates to os/user.Current. -func currentUserWithGetent() (*user.User, error) { - return user.Current() -} - -// getShellFromGetent is a no-op on Windows; shell resolution uses PowerShell detection. -func getShellFromGetent(_ string) string { - return "" -} - -// groupIdsWithFallback on Windows just delegates to u.GroupIds(). -func groupIdsWithFallback(u *user.User) ([]string, error) { - return u.GroupIds() -} diff --git a/client/ssh/server/shell.go b/client/ssh/server/shell.go index 1e8ff5e31..7b356b2a0 100644 --- a/client/ssh/server/shell.go +++ b/client/ssh/server/shell.go @@ -13,6 +13,8 @@ import ( "github.com/gliderlabs/ssh" log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/getent" ) const ( @@ -56,7 +58,11 @@ func getUnixUserShell(userID string) string { return shell } - if shell := getShellFromGetent(userID); shell != "" { + shell, err := getent.UserShell(userID) + if err != nil { + log.Debugf("look up the shell for uid %s through getent: %v", userID, err) + } + if shell != "" { return shell } diff --git a/client/ssh/server/shell_unix_test.go b/client/ssh/server/shell_unix_test.go new file mode 100644 index 000000000..c5e65e535 --- /dev/null +++ b/client/ssh/server/shell_unix_test.go @@ -0,0 +1,94 @@ +//go:build !windows + +package server + +import ( + "os/exec" + "os/user" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/getent" +) + +// TestGetShellFromPasswd_CurrentUser verifies that getShellFromPasswd correctly +// reads the current user's shell from /etc/passwd by comparing it against what +// getent reports (which goes through NSS). +func TestGetShellFromPasswd_CurrentUser(t *testing.T) { + current, err := user.Current() + require.NoError(t, err) + + shell := getShellFromPasswd(current.Uid) + if shell == "" { + t.Skip("current user not found in /etc/passwd (may be an NSS-only user)") + } + + assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell) + + if _, err := exec.LookPath("getent"); err == nil { + getentShell, getentErr := getent.UserShell(current.Uid) + if getentErr == nil && getentShell != "" { + assert.Equal(t, getentShell, shell, "shell from /etc/passwd should match getent") + } + } +} + +// TestGetShellFromPasswd_RootUser verifies that getShellFromPasswd can read +// root's shell from /etc/passwd. Root is guaranteed to be in /etc/passwd on +// any standard Unix system. +func TestGetShellFromPasswd_RootUser(t *testing.T) { + shell := getShellFromPasswd("0") + require.NotEmpty(t, shell, "root (UID 0) must be in /etc/passwd") + assert.True(t, shell[0] == '/', "root shell should be an absolute path, got %q", shell) +} + +// TestGetShellFromPasswd_NonexistentUID verifies that getShellFromPasswd +// returns empty for a UID that doesn't exist in /etc/passwd. +func TestGetShellFromPasswd_NonexistentUID(t *testing.T) { + shell := getShellFromPasswd("4294967294") + assert.Empty(t, shell, "nonexistent UID should return empty shell") +} + +// TestGetShellFromPasswd_MatchesGetentForKnownUsers reads /etc/passwd directly +// and cross-validates every entry against getent to ensure the two shell +// sources agree. +func TestGetShellFromPasswd_MatchesGetentForKnownUsers(t *testing.T) { + if _, err := exec.LookPath("getent"); err != nil { + t.Skip("getent not available") + } + + // Pick a few well-known system UIDs that are virtually always in /etc/passwd. + uids := []string{"0"} // root + + current, err := user.Current() + require.NoError(t, err) + uids = append(uids, current.Uid) + + for _, uid := range uids { + passwdShell := getShellFromPasswd(uid) + if passwdShell == "" { + continue + } + + getentShell, err := getent.UserShell(uid) + if err != nil { + continue + } + + assert.Equal(t, getentShell, passwdShell, "shell mismatch for UID %s", uid) + } +} + +// TestIntegration_ShellLookupChain tests the full shell resolution chain +// (getShellFromPasswd -> getent -> $SHELL -> default). +func TestIntegration_ShellLookupChain(t *testing.T) { + current, err := user.Current() + require.NoError(t, err) + + // getUserShell is the top-level function used by the SSH server. + shell := getUserShell(current.Uid) + require.NotEmpty(t, shell, "getUserShell must always return a shell") + assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell) +} diff --git a/client/ssh/server/user_utils.go b/client/ssh/server/user_utils.go index 6c8142b30..f2f33b3d7 100644 --- a/client/ssh/server/user_utils.go +++ b/client/ssh/server/user_utils.go @@ -9,6 +9,8 @@ import ( "strings" log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/getent" ) var ( @@ -18,8 +20,8 @@ var ( // Dependency injection variables for testing - allows mocking dynamic runtime checks var ( - getCurrentUser = currentUserWithGetent - lookupUser = lookupWithGetent + getCurrentUser = getent.CurrentUser + lookupUser = getent.LookupUser getCurrentOS = func() string { return runtime.GOOS } getIsProcessPrivileged = isCurrentProcessPrivileged diff --git a/client/ssh/server/userswitching_unix.go b/client/ssh/server/userswitching_unix.go index 220e2240f..ae60ec64c 100644 --- a/client/ssh/server/userswitching_unix.go +++ b/client/ssh/server/userswitching_unix.go @@ -16,6 +16,8 @@ import ( "github.com/gliderlabs/ssh" log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/getent" ) // POSIX portable filename character set regex: [a-zA-Z0-9._-] @@ -160,7 +162,7 @@ func (s *Server) parseUserCredentials(localUser *user.User) (uint32, uint32, []u // getSupplementaryGroups retrieves supplementary group IDs for a user. // Uses id/getent fallback for NSS users in CGO_ENABLED=0 builds. func (s *Server) getSupplementaryGroups(u *user.User) ([]uint32, error) { - groupIDStrings, err := groupIdsWithFallback(u) + groupIDStrings, err := getent.GroupIDs(u) if err != nil { return nil, fmt.Errorf("get group IDs for user %s: %w", u.Username, err) } diff --git a/client/ui/build/linux/netbird.desktop b/client/ui/build/linux/netbird.desktop index a81f3698a..0d43b62a2 100644 --- a/client/ui/build/linux/netbird.desktop +++ b/client/ui/build/linux/netbird.desktop @@ -1,5 +1,6 @@ [Desktop Entry] -Name=Netbird +Name=NetBird +Comment=NetBird desktop client Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 /usr/bin/netbird-ui Icon=netbird Type=Application diff --git a/client/ui/build/linux/polkit/io.netbird.settings.policy b/client/ui/build/linux/polkit/io.netbird.settings.policy new file mode 100644 index 000000000..e12f1ddc7 --- /dev/null +++ b/client/ui/build/linux/polkit/io.netbird.settings.policy @@ -0,0 +1,47 @@ + + + + + + NetBird + https://netbird.io + + + Change privileged NetBird settings + Authentication is required to change NetBird settings that grant SSH access to this computer. + netbird + + auth_admin + auth_admin + auth_admin + + /usr/bin/netbird-ui + --apply-privileged-settings + + + + Change privileged NetBird settings + Authentication is required to change NetBird settings that grant SSH access to this computer. + netbird + + auth_admin + auth_admin + auth_admin + + /usr/local/bin/netbird-ui + --apply-privileged-settings + + diff --git a/client/ui/frontend/src/contexts/SettingsContext.tsx b/client/ui/frontend/src/contexts/SettingsContext.tsx index 3f4b2d0d2..1de591836 100644 --- a/client/ui/frontend/src/contexts/SettingsContext.tsx +++ b/client/ui/frontend/src/contexts/SettingsContext.tsx @@ -22,12 +22,23 @@ const logSaveError = (err: unknown) => console.error("[SettingsContext] save fai export type AutostartState = { supported: boolean; enabled: boolean }; +// GuardedField is a setting the daemon only accepts from root/administrator. +// Turning one on goes through saveGuardedField, which asks the operating system +// for the privileges rather than sending a request that would be refused. +export type GuardedField = + | "serverSshAllowed" + | "enableSshRoot" + | "disableSshAuth" + | "serverVncAllowed" + | "disableVncApproval"; + type SettingsContextValue = { config: Config; guiVersion: string; setField: (k: K, v: Config[K]) => void; saveField: (k: K, v: Config[K]) => Promise; saveFields: (partial: Partial, opts?: { preSharedKey?: string }) => Promise; + saveGuardedField: (k: GuardedField, v: boolean) => Promise; saveNow: () => Promise; }; @@ -63,6 +74,12 @@ const useSettingsState = () => { const [guiVersion, setGuiVersion] = useState("—"); const saveTimer = useRef | null>(null); const loadedRef = useRef(null); + // Set when the daemon's config changed while a save was pending, so the read + // that was skipped to protect the pending edit happens once it is through. + // Without it the form keeps values the daemon no longer has and the next save + // submits them, which for a guarded setting means asking the user to authorize + // a change they never made. + const reloadOwed = useRef(false); useEffect(() => { loadedRef.current = loaded; @@ -73,6 +90,7 @@ const useSettingsState = () => { // update the daemon then rejected. const reload = useCallback( async (profileName: string) => { + reloadOwed.current = false; try { const data = await SettingsSvc.GetConfig({ profileName, username }); setLoaded({ profileName, data }); @@ -94,7 +112,12 @@ const useSettingsState = () => { username, }); if (cancelled) return; - if (saveTimer.current) return; + // A pending edit outranks the daemon's copy until it is saved, so + // the read is owed rather than dropped: see reloadOwed. + if (saveTimer.current) { + reloadOwed.current = true; + return; + } setLoaded({ profileName: activeProfileId, data }); } catch (e) { if (cancelled || !showError) return; @@ -141,12 +164,17 @@ const useSettingsState = () => { async (profileName: string, next: Config, preSharedKey?: string) => { const preSharedKeyWrite = preSharedKey === undefined ? {} : { preSharedKey }; try { - await SettingsSvc.SetConfig({ + const { declined } = await SettingsSvc.SetConfig({ ...next, ...preSharedKeyWrite, profileName, username, }); + // The change needed authorization and the user said no, so the + // optimistic update is wrong. Nothing to report: they know. + if (declined || reloadOwed.current) { + await reload(profileName); + } } catch (e) { // The optimistic update is wrong now: the daemon refused it // (a change that needs elevated privileges, an MDM-managed @@ -206,6 +234,59 @@ const useSettingsState = () => { [loaded, save], ); + // saveGuardedField applies a setting the daemon restricts to + // root/administrator by having the Go side run the app again under the + // platform's elevation prompt (UAC, the macOS authentication dialog, polkit). + // The prompt is the user's, so the call is made straight from their gesture + // and never from the debounce. + const saveGuardedField = useCallback( + async (k: GuardedField, v: boolean) => { + const cur = loadedRef.current; + if (!cur) return; + + // Flush what the debounce still owes, before the optimistic update + // below joins it: a later save carrying the guarded value would be + // refused, and its error dialog would be the second one for a change + // the user already authorized. + if (saveTimer.current) { + clearTimeout(saveTimer.current); + saveTimer.current = null; + await save(cur.profileName, cur.data); + } + + const next: LoadedConfig = { + profileName: cur.profileName, + data: { ...cur.data, [k]: v }, + }; + loadedRef.current = next; + setLoaded(next); + + try { + await SettingsSvc.SetGuardedSettings({ + profileName: cur.profileName, + username, + [k]: v, + }); + } catch (e) { + // The daemon is authoritative either way, so re-read before + // reporting. A declined prompt is not an error and does not come + // through here at all; this is a prompt that could not be raised, + // which carries the command that would have done it. + await reload(cur.profileName); + await errorDialog({ + Title: i18next.t("settings.error.saveTitle"), + Message: errorMessage(e), + Command: errorCommand(e), + }); + return; + } + // Either the change went through or the user declined it. The daemon + // says which. + await reload(cur.profileName); + }, + [username, save, reload], + ); + const saveFields = useCallback( async (partial: Partial, opts?: { preSharedKey?: string }) => { if (!loaded) return; @@ -225,15 +306,27 @@ const useSettingsState = () => { [loaded, save], ); - return { config: loaded?.data ?? null, guiVersion, setField, saveField, saveFields, saveNow }; + return { + config: loaded?.data ?? null, + guiVersion, + setField, + saveField, + saveFields, + saveGuardedField, + saveNow, + }; }; export const SettingsProvider = ({ children }: { children: ReactNode }) => { - const { config, guiVersion, setField, saveField, saveFields, saveNow } = useSettingsState(); + const { config, guiVersion, setField, saveField, saveFields, saveGuardedField, saveNow } = + useSettingsState(); const value = useMemo( - () => (config ? { config, guiVersion, setField, saveField, saveFields, saveNow } : null), - [config, guiVersion, setField, saveField, saveFields, saveNow], + () => + config + ? { config, guiVersion, setField, saveField, saveFields, saveGuardedField, saveNow } + : null, + [config, guiVersion, setField, saveField, saveFields, saveGuardedField, saveNow], ); if (!value) { diff --git a/client/ui/frontend/src/hooks/usePrivilege.ts b/client/ui/frontend/src/hooks/usePrivilege.ts index 05e9a7ce0..d67fcc4b1 100644 --- a/client/ui/frontend/src/hooks/usePrivilege.ts +++ b/client/ui/frontend/src/hooks/usePrivilege.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { Settings as SettingsSvc } from "@bindings/services"; -import { Privilege } from "@bindings/services/models.js"; +import { type Privilege } from "@bindings/services/models.js"; // usePrivilege reports whether this UI process may perform the changes the daemon // restricts to root/administrator. It is answered in-process from our own token diff --git a/client/ui/frontend/src/modules/settings/PrivilegeGuard.tsx b/client/ui/frontend/src/modules/settings/PrivilegeGuard.tsx index 2f99eba28..eec01c451 100644 --- a/client/ui/frontend/src/modules/settings/PrivilegeGuard.tsx +++ b/client/ui/frontend/src/modules/settings/PrivilegeGuard.tsx @@ -1,86 +1,174 @@ +import { type TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import { CopyToClipboard } from "@/components/CopyToClipboard"; +import { type GuardedField, useSettings } from "@/contexts/SettingsContext.tsx"; import { usePrivilege } from "@/hooks/usePrivilege.ts"; -import { Privilege } from "@bindings/services/models.js"; -import { type ReactNode } from "react"; +import type { Privilege } from "@bindings/services/models.js"; +import { type ReactNode, useState } from "react"; +// GuardedControl is what a settings page needs to render one control the daemon +// restricts to root/administrator: how to apply a change to it, whether it can be +// touched at all, and the explanation that belongs under it. export type GuardedControl = { + apply: (value: boolean) => void; disabled: boolean; hint: ReactNode; }; -// useGuardedControl returns a guard for the settings controls the daemon -// restricts to root/administrator: enabling a remote-access server, or removing -// one of its safeguards. +// useGuardedControl returns a guard for the settings the daemon restricts to +// root/administrator: enabling a remote-access server, or removing one of its +// safeguards. // // The daemon restricts only the direction that hands out access from a process -// running as root. So for an unprivileged user a guarded control is either -// unavailable (it is off and only they could turn it on) or a one-way switch (it -// is on, they may turn it off, but not back on) — say which, either way. +// running as root: for every one of these settings that is switching the field on. // -// A null privilege means we could not determine it: leave the control alone -// rather than greying it out with nothing to explain why. The daemon enforces -// this regardless, and a rejected save reports its own guidance. +// An unprivileged user gets that direction routed through the platform's elevation +// prompt where there is one to raise, and otherwise the old arrangement, where the +// control is either unavailable (it is off and only a privileged caller could turn +// it on) or a one-way switch (it is on, they may turn it off but not back on) with +// the command that does it. +// +// A null privilege means we could not determine it: leave the control alone rather +// than greying it out with nothing to explain why. The daemon enforces this +// regardless, and a rejected save reports its own guidance. export const useGuardedControl = () => { + const { t } = useTranslation(); + const { config, setField, saveGuardedField } = useSettings(); const privilege = usePrivilege(); + // The field whose elevation prompt is currently up, if any. The prompt is + // modal to the operating system, not to us, so the guarded controls are held + // still meanwhile rather than allowed to stack a second one behind it. + const [authorizing, setAuthorizing] = useState(null); + + const authorize = async (field: GuardedField, value: boolean) => { + setAuthorizing(field); + try { + await saveGuardedField(field, value); + } finally { + setAuthorizing(null); + } + }; return ( - guardedDirectionActive: boolean, + field: GuardedField, command: (p: Privilege) => string, - // inverted marks a control whose guarded direction is switching it off, - // so the one-way warning has to read the other way round. + // inverted marks a control whose guarded direction is switching it off, so + // the one-way warning has to read the other way round. inverted = false, ): GuardedControl => { + const plain = (value: boolean) => setField(field, value); if (!privilege || privilege.privileged) { - return { disabled: false, hint: undefined }; + return { apply: plain, disabled: false, hint: undefined }; } - const hint = ( - ( + ); - return { disabled: !guardedDirectionActive, hint }; + + if (privilege.canElevate) { + return { + // Switching off is ours to do; only switching on is authorized. + apply: (value: boolean) => { + if (!value) { + plain(value); + return; + } + void authorize(field, value); + }, + disabled: authorizing !== null, + hint: hint(authorizing === field), + }; + } + return { + apply: plain, + disabled: !guardedDirectionActive, + hint: hint(false, command(privilege)), + }; }; }; -// PrivilegeHint explains what an unprivileged user can and cannot do with a -// guarded control, and offers the command that does it with the privileges the -// daemon requires. oneWay covers the control being in the guarded state already: -// switching it back is the part that needs privileges. -export function PrivilegeHint({ +// actorLabel names the principal the daemon requires, in the user's language. The +// Go side reports which one it is rather than wording it, because "administrator +// privileges" is English and a translated sentence cannot borrow it. +function actorLabel(privilege: Privilege, t: TFunction): string { + return privilege.actorKey === "administrator" + ? t("settings.privilege.actorAdministrator") + : t("settings.privilege.actorRoot"); +} + +// GuardedHint is what a control the daemon guards says to an unprivileged user. +// There are three things worth saying, and it says at most one: +// +// - A prompt is open. Worth a line because it can take a few seconds to appear, +// long enough that a control which merely went inert would read as a hang. +// - The setting is in its guarded state already (oneWay), so the user may switch +// it back as they please and it is switching it away again that will ask. No +// command either way: the direction they can take is theirs to take. +// - Only a privileged caller can move it at all, and there is no prompt to +// raise: the command that does it belongs here, and nothing else will do. +// +// Which leaves the case of a control whose guarded direction is still ahead of the +// user and a prompt that can be raised for it: nothing to say, because clicking it +// raises the prompt and the prompt explains itself. +function GuardedHint({ actor, - command, oneWay, inverted, + pending, + command, }: { actor: string; - command: string; oneWay: boolean; inverted: boolean; + pending: boolean; + command?: string; }): ReactNode { const { t } = useTranslation(); + + if (pending) { + return {t("settings.privilege.authorizePending")}; + } + if (oneWay) { + return ( + + + {inverted + ? t("settings.privilege.oneWayInverted", { actor }) + : t("settings.privilege.oneWay", { actor })} + + + ); + } if (!command) return null; + return ( + + {t("settings.privilege.hint", { actor })} + + + {command} + + + + ); +} + +// HintBox is the box a guarded control puts its explanation in, directly under the +// control it belongs to. +function HintBox({ children }: { children: ReactNode }): ReactNode { return (
- - {!oneWay - ? t("settings.privilege.hint", { actor }) - : inverted - ? t("settings.privilege.oneWayInverted", { actor }) - : t("settings.privilege.oneWay", { actor })} - - - - {command} - - + {children}
); } diff --git a/client/ui/frontend/src/modules/settings/SettingsSSH.tsx b/client/ui/frontend/src/modules/settings/SettingsSSH.tsx index 8c85ebb52..9114993c6 100644 --- a/client/ui/frontend/src/modules/settings/SettingsSSH.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsSSH.tsx @@ -14,11 +14,12 @@ export function SettingsSSH() { const { config, setField } = useSettings(); const guarded = useGuardedControl(); const isSSHServerEnabled = config.serverSshAllowed; - const sshServer = guarded(config.serverSshAllowed, (p) => p.allowSshServer); - const sshRoot = guarded(config.enableSshRoot, (p) => p.enableSshRoot); + + const sshServer = guarded("serverSshAllowed", (p) => p.allowSshServer); + const sshRoot = guarded("enableSshRoot", (p) => p.enableSshRoot); // Inverted control: the guarded direction is switching authentication off, so // it is the already-disabled state that is the one-way one. - const sshAuth = guarded(config.disableSshAuth, (p) => p.disableSshAuth, true); + const sshAuth = guarded("disableSshAuth", (p) => p.disableSshAuth, true); const jwtTtlId = useId(); const [jwtTtlInput, setJwtTtlInput] = useState(String(config.sshJwtCacheTtl)); @@ -52,7 +53,7 @@ export function SettingsSSH() { setField("serverSshAllowed", v)} + onChange={sshServer.apply} disabled={sshServer.disabled} label={t("settings.ssh.server.label")} helpText={t("settings.ssh.server.help")} @@ -66,7 +67,7 @@ export function SettingsSSH() { > setField("enableSshRoot", v)} + onChange={sshRoot.apply} disabled={sshRoot.disabled} label={t("settings.ssh.root.label")} helpText={t("settings.ssh.root.help")} @@ -98,7 +99,7 @@ export function SettingsSSH() { > setField("disableSshAuth", !v)} + onChange={(v) => sshAuth.apply(!v)} disabled={sshAuth.disabled} label={t("settings.ssh.jwt.label")} helpText={t("settings.ssh.jwt.help")} diff --git a/client/ui/frontend/src/modules/settings/SettingsVNC.tsx b/client/ui/frontend/src/modules/settings/SettingsVNC.tsx index a5fa8c9fc..8d2f732a5 100644 --- a/client/ui/frontend/src/modules/settings/SettingsVNC.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsVNC.tsx @@ -7,23 +7,23 @@ import { useRestrictions } from "@/contexts/RestrictionsContext.tsx"; export function SettingsVNC() { const { t } = useTranslation(); - const { config, setField } = useSettings(); + const { config } = useSettings(); const { mdm } = useRestrictions(); const guarded = useGuardedControl(); const isVNCServerEnabled = config.serverVncAllowed; const vncServerManaged = mdm.allowServerVNC != null; - const vncServer = guarded(config.serverVncAllowed, (p) => p.allowVncServer); + const vncServer = guarded("serverVncAllowed", (p) => p.allowVncServer); // Inverted control: the guarded direction is switching the approval prompt // off, so the already-disabled state is the one-way one. - const vncApproval = guarded(config.disableVncApproval, (p) => p.disableVncApproval, true); + const vncApproval = guarded("disableVncApproval", (p) => p.disableVncApproval, true); return ( <> setField("serverVncAllowed", v)} + onChange={vncServer.apply} label={t("settings.vnc.server.label")} helpText={t("settings.vnc.server.help")} disabled={vncServerManaged || vncServer.disabled} @@ -38,7 +38,7 @@ export function SettingsVNC() { > setField("disableVncApproval", !v)} + onChange={(v) => vncApproval.apply(!v)} label={t("settings.vnc.approval.label")} helpText={t("settings.vnc.approval.help")} disabled={vncApproval.disabled} diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json index b79e09306..db662e763 100644 --- a/client/ui/i18n/locales/de/common.json +++ b/client/ui/i18n/locales/de/common.json @@ -401,9 +401,6 @@ "networks.bulk.label": { "message": "Alle sichtbaren Ressourcen umschalten" }, - "settings.nav.label": { - "message": "Einstellungsbereiche" - }, "profile.switch.title": { "message": "Zu Profil \"{name}\" wechseln?" }, @@ -497,6 +494,9 @@ "settings.error.debugBundleTitle": { "message": "Debug-Paket fehlgeschlagen" }, + "settings.nav.label": { + "message": "Einstellungsbereiche" + }, "settings.tabs.general": { "message": "Allgemein" }, @@ -512,6 +512,9 @@ "settings.tabs.ssh": { "message": "SSH" }, + "settings.tabs.vnc": { + "message": "VNC" + }, "settings.tabs.advanced": { "message": "Erweitert" }, @@ -721,6 +724,24 @@ "settings.ssh.jwtTtl.suffix": { "message": "Sekunde(n)" }, + "settings.vnc.section.server": { + "message": "Server" + }, + "settings.vnc.section.approval": { + "message": "Genehmigung" + }, + "settings.vnc.server.label": { + "message": "VNC-Server aktivieren" + }, + "settings.vnc.server.help": { + "message": "Den NetBird-VNC-Server auf diesem Host ausführen, damit autorisierte Peers den Bildschirm ansehen oder steuern können." + }, + "settings.vnc.approval.label": { + "message": "Verbindungsgenehmigung erforderlich" + }, + "settings.vnc.approval.help": { + "message": "Auf diesem Host eine Aufforderung anzeigen, die bestätigt werden muss, bevor eine eingehende VNC-Verbindung zugelassen wird." + }, "settings.advanced.section.interface": { "message": "Schnittstelle" }, @@ -1042,6 +1063,45 @@ "window.title.sessionExpiration": { "message": "Sitzung läuft ab" }, + "window.title.approval": { + "message": "Verbindungsanfrage" + }, + "approval.title.vnc": { + "message": "VNC-Verbindung zulassen?" + }, + "approval.title.ssh": { + "message": "SSH-Verbindung zulassen?" + }, + "approval.title.default": { + "message": "Eingehende Verbindung zulassen?" + }, + "approval.field.user": { + "message": "Von Benutzer" + }, + "approval.field.keyFingerprint": { + "message": "Schlüssel-Fingerabdruck" + }, + "approval.field.peer": { + "message": "Über Peer" + }, + "approval.field.sourceIp": { + "message": "Quell-IP" + }, + "approval.field.osUser": { + "message": "Betriebssystem-Benutzer" + }, + "approval.countdown": { + "message": "Automatische Ablehnung in {seconds}s" + }, + "approval.action.allow": { + "message": "Zulassen" + }, + "approval.action.allowViewOnly": { + "message": "Zulassen (nur ansehen)" + }, + "approval.action.deny": { + "message": "Ablehnen" + }, "window.title.updating": { "message": "Aktualisierung" }, @@ -1351,73 +1411,40 @@ "error.unknown": { "message": "Vorgang fehlgeschlagen." }, - "settings.tabs.vnc": { - "message": "VNC" + "error.elevation_unavailable": { + "message": "NetBird konnte auf diesem System nicht die nötigen Rechte anfordern. Führen Sie stattdessen dies aus:" }, - "settings.vnc.section.server": { - "message": "Server" + "error.elevation_failed": { + "message": "Die Änderung konnte mit erhöhten Rechten nicht angewendet werden. Führen Sie stattdessen dies aus:" }, - "settings.vnc.section.approval": { - "message": "Genehmigung" + "settings.privilege.actorRoot": { + "message": "root-Rechte" }, - "settings.vnc.server.label": { - "message": "VNC-Server aktivieren" + "settings.privilege.actorAdministrator": { + "message": "Administratorrechte" }, - "settings.vnc.server.help": { - "message": "Den NetBird-VNC-Server auf diesem Host ausführen, damit autorisierte Peers den Bildschirm ansehen oder steuern können." + "settings.privilege.authorizePending": { + "message": "Warten auf Autorisierung…" }, - "settings.vnc.approval.label": { - "message": "Verbindungsgenehmigung erforderlich" + "connect.activeSession.badge": { + "message": "Bildschirm geteilt", + "description": "Short label on the badge shown on the main screen while somebody is attached to this machine over VNC." }, - "settings.vnc.approval.help": { - "message": "Auf diesem Host eine Aufforderung anzeigen, die bestätigt werden muss, bevor eine eingehende VNC-Verbindung zugelassen wird." + "connect.activeSession.tooltip": { + "message": "Dieser Bildschirm wird über VNC angesehen ({sessionCount} Sitzung(en)). Beim Trennen endet sie, und wenn Sie selbst über VNC verbunden sind, verlieren Sie den Zugriff.", + "description": "Tooltip on the screen-shared badge. {sessionCount} is how many VNC sessions are attached; wording covers any number since the bundle has no plural forms." }, - "window.title.approval": { - "message": "Verbindungsanfrage" - }, - "approval.title.vnc": { - "message": "VNC-Verbindung zulassen?" - }, - "approval.title.ssh": { - "message": "SSH-Verbindung zulassen?" - }, - "approval.title.default": { - "message": "Eingehende Verbindung zulassen?" - }, - "approval.field.user": { - "message": "Von Benutzer" - }, - "approval.field.keyFingerprint": { - "message": "Schlüssel-Fingerabdruck" - }, - "approval.field.peer": { - "message": "Über Peer" - }, - "approval.field.sourceIp": { - "message": "Quell-IP" - }, - "approval.field.osUser": { - "message": "Betriebssystem-Benutzer" - }, - "approval.countdown": { - "message": "Automatische Ablehnung in {seconds}s" - }, - "approval.action.allow": { - "message": "Zulassen" - }, - "approval.action.allowViewOnly": { - "message": "Zulassen (nur ansehen)" - }, - "approval.action.deny": { - "message": "Ablehnen" + "connect.activeSession.tooltipNamed": { + "message": "Dieser Bildschirm wird von {who} über VNC angesehen ({sessionCount} Sitzung(en)). Beim Trennen endet sie, und wenn Sie selbst über VNC verbunden sind, verlieren Sie den Zugriff.", + "description": "As connect.activeSession.tooltip, with {who} naming the dashboard users who started the sessions." }, "settings.privilege.hint": { "message": "Erfordert {actor}. Führen Sie stattdessen dies aus:" }, "settings.privilege.oneWay": { - "message": "Sie können dies deaktivieren, aber zum erneuten Aktivieren sind {actor} erforderlich:" + "message": "Sie können dies deaktivieren, zum erneuten Aktivieren sind {actor} erforderlich." }, "settings.privilege.oneWayInverted": { - "message": "Sie können dies aktivieren, aber zum erneuten Deaktivieren sind {actor} erforderlich:" + "message": "Sie können dies aktivieren, zum erneuten Deaktivieren sind {actor} erforderlich." } } diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json index ff9f2079d..f51b9996d 100644 --- a/client/ui/i18n/locales/en/common.json +++ b/client/ui/i18n/locales/en/common.json @@ -1879,6 +1879,26 @@ "message": "Operation failed.", "description": "Generic fallback error message used when no specific error applies." }, + "error.elevation_unavailable": { + "message": "NetBird could not ask this system for the privileges the change needs. Run this instead:", + "description": "Error: this computer has no way to prompt for elevated privileges. Followed by a copyable command that applies the setting from a terminal." + }, + "error.elevation_failed": { + "message": "The change could not be applied with elevated privileges. Run this instead:", + "description": "Error: the authorization succeeded but applying the setting afterwards failed. Followed by a copyable command that applies the setting from a terminal." + }, + "settings.privilege.actorRoot": { + "message": "root", + "description": "Fills {actor} in the settings.privilege.* messages on Linux, macOS and BSD, where the daemon requires the root account. 'root' is an account name and stays as it is; add the word for privileges or rights around it if the sentence needs one to read naturally." + }, + "settings.privilege.actorAdministrator": { + "message": "administrator privileges", + "description": "Fills {actor} in the settings.privilege.* messages on Windows, where the daemon requires an elevated administrator. The Windows term for the rights an account is asked to elevate to." + }, + "settings.privilege.authorizePending": { + "message": "Waiting for authorization…", + "description": "Replaces the help text under a guarded remote-access setting while the authorization prompt is open, which can take a few seconds to appear. Keep the trailing ellipsis." + }, "connect.activeSession.badge": { "message": "Screen shared", "description": "Short label on the badge shown on the main screen while somebody is attached to this machine over VNC." @@ -1893,14 +1913,14 @@ }, "settings.privilege.hint": { "message": "Requires {actor}. Run this instead:", - "description": "Help text under a remote-access setting the user cannot change: it needs elevated privileges. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows. Followed by a copyable command." + "description": "Help text under a remote-access setting (SSH or VNC) the user cannot change: it needs elevated privileges. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows. Followed by a copyable command." }, "settings.privilege.oneWay": { - "message": "You can switch this off, but switching it back on needs {actor}:", - "description": "Warning under a remote-access setting an unprivileged user may disable but not re-enable. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows. Followed by a copyable command." + "message": "You can switch this off, but switching it back on needs {actor}.", + "description": "Help text under a remote-access setting (SSH or VNC) that is already on: an unprivileged user may switch it off freely, and switching it on again is what needs the privileges. No command follows, since the direction they can take is theirs to take. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows." }, "settings.privilege.oneWayInverted": { - "message": "You can switch this on, but switching it back off needs {actor}:", - "description": "Warning under a safeguard setting (SSH authentication, VNC approval) which an unprivileged user may re-enable but not disable again. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows. Followed by a copyable command." + "message": "You can switch this on, but switching it back off needs {actor}.", + "description": "Same as settings.privilege.oneWay, for a safeguard setting (SSH authentication, VNC approval) once it has been switched off: switching it off again is what needs the privileges." } } diff --git a/client/ui/i18n/locales/es/common.json b/client/ui/i18n/locales/es/common.json index 090739057..9f6f4db4d 100644 --- a/client/ui/i18n/locales/es/common.json +++ b/client/ui/i18n/locales/es/common.json @@ -401,9 +401,6 @@ "networks.bulk.label": { "message": "Conmutar todos los recursos visibles" }, - "settings.nav.label": { - "message": "Secciones de configuración" - }, "profile.switch.title": { "message": "¿Cambiar el perfil a «{name}»?" }, @@ -497,6 +494,9 @@ "settings.error.debugBundleTitle": { "message": "Error en el paquete de diagnóstico" }, + "settings.nav.label": { + "message": "Secciones de configuración" + }, "settings.tabs.general": { "message": "General" }, @@ -512,6 +512,9 @@ "settings.tabs.ssh": { "message": "SSH" }, + "settings.tabs.vnc": { + "message": "VNC" + }, "settings.tabs.advanced": { "message": "Avanzado" }, @@ -721,6 +724,24 @@ "settings.ssh.jwtTtl.suffix": { "message": "s" }, + "settings.vnc.section.server": { + "message": "Servidor" + }, + "settings.vnc.section.approval": { + "message": "Aprobación" + }, + "settings.vnc.server.label": { + "message": "Habilitar el servidor VNC" + }, + "settings.vnc.server.help": { + "message": "Ejecuta el servidor VNC de NetBird en este host para que los peers autorizados puedan ver o controlar su pantalla." + }, + "settings.vnc.approval.label": { + "message": "Requerir aprobación de conexión" + }, + "settings.vnc.approval.help": { + "message": "Mostrar en este host una solicitud que debe aceptarse antes de permitir una conexión VNC entrante." + }, "settings.advanced.section.interface": { "message": "Interfaz" }, @@ -1042,6 +1063,45 @@ "window.title.sessionExpiration": { "message": "Sesión a punto de expirar" }, + "window.title.approval": { + "message": "Solicitud de conexión" + }, + "approval.title.vnc": { + "message": "¿Permitir la conexión VNC?" + }, + "approval.title.ssh": { + "message": "¿Permitir la conexión SSH?" + }, + "approval.title.default": { + "message": "¿Permitir la conexión entrante?" + }, + "approval.field.user": { + "message": "Del usuario" + }, + "approval.field.keyFingerprint": { + "message": "Huella de la clave" + }, + "approval.field.peer": { + "message": "A través del peer" + }, + "approval.field.sourceIp": { + "message": "IP de origen" + }, + "approval.field.osUser": { + "message": "Usuario del SO" + }, + "approval.countdown": { + "message": "Rechazo automático en {seconds}s" + }, + "approval.action.allow": { + "message": "Permitir" + }, + "approval.action.allowViewOnly": { + "message": "Permitir (solo ver)" + }, + "approval.action.deny": { + "message": "Denegar" + }, "window.title.updating": { "message": "Actualizando" }, @@ -1351,73 +1411,40 @@ "error.unknown": { "message": "La operación falló." }, - "settings.tabs.vnc": { - "message": "VNC" + "error.elevation_unavailable": { + "message": "NetBird no pudo solicitar a este sistema los privilegios necesarios. Ejecute esto en su lugar:" }, - "settings.vnc.section.server": { - "message": "Servidor" + "error.elevation_failed": { + "message": "No se pudo aplicar el cambio con privilegios elevados. Ejecute esto en su lugar:" }, - "settings.vnc.section.approval": { - "message": "Aprobación" + "settings.privilege.actorRoot": { + "message": "privilegios de root" }, - "settings.vnc.server.label": { - "message": "Habilitar el servidor VNC" + "settings.privilege.actorAdministrator": { + "message": "privilegios de administrador" }, - "settings.vnc.server.help": { - "message": "Ejecuta el servidor VNC de NetBird en este host para que los peers autorizados puedan ver o controlar su pantalla." + "settings.privilege.authorizePending": { + "message": "Esperando la autorización…" }, - "settings.vnc.approval.label": { - "message": "Requerir aprobación de conexión" + "connect.activeSession.badge": { + "message": "Pantalla compartida", + "description": "Short label on the badge shown on the main screen while somebody is attached to this machine over VNC." }, - "settings.vnc.approval.help": { - "message": "Mostrar en este host una solicitud que debe aceptarse antes de permitir una conexión VNC entrante." + "connect.activeSession.tooltip": { + "message": "Esta pantalla se está viendo por VNC ({sessionCount} sesión/sesiones). Al desconectar se cerrará, y si estás conectado por VNC perderás el acceso.", + "description": "Tooltip on the screen-shared badge. {sessionCount} is how many VNC sessions are attached; wording covers any number since the bundle has no plural forms." }, - "window.title.approval": { - "message": "Solicitud de conexión" - }, - "approval.title.vnc": { - "message": "¿Permitir la conexión VNC?" - }, - "approval.title.ssh": { - "message": "¿Permitir la conexión SSH?" - }, - "approval.title.default": { - "message": "¿Permitir la conexión entrante?" - }, - "approval.field.user": { - "message": "Del usuario" - }, - "approval.field.keyFingerprint": { - "message": "Huella de la clave" - }, - "approval.field.peer": { - "message": "A través del peer" - }, - "approval.field.sourceIp": { - "message": "IP de origen" - }, - "approval.field.osUser": { - "message": "Usuario del SO" - }, - "approval.countdown": { - "message": "Rechazo automático en {seconds}s" - }, - "approval.action.allow": { - "message": "Permitir" - }, - "approval.action.allowViewOnly": { - "message": "Permitir (solo ver)" - }, - "approval.action.deny": { - "message": "Denegar" + "connect.activeSession.tooltipNamed": { + "message": "{who} está viendo esta pantalla por VNC ({sessionCount} sesión/sesiones). Al desconectar se cerrará, y si estás conectado por VNC perderás el acceso.", + "description": "As connect.activeSession.tooltip, with {who} naming the dashboard users who started the sessions." }, "settings.privilege.hint": { "message": "Requiere {actor}. Ejecute esto en su lugar:" }, "settings.privilege.oneWay": { - "message": "Puede desactivarlo, pero volver a activarlo requiere {actor}:" + "message": "Puede desactivarlo, pero volver a activarlo requiere {actor}." }, "settings.privilege.oneWayInverted": { - "message": "Puede activarlo, pero volver a desactivarlo requiere {actor}:" + "message": "Puede activarlo, pero volver a desactivarlo requiere {actor}." } } diff --git a/client/ui/i18n/locales/fr/common.json b/client/ui/i18n/locales/fr/common.json index 2b8e480cd..ffbfa3a96 100644 --- a/client/ui/i18n/locales/fr/common.json +++ b/client/ui/i18n/locales/fr/common.json @@ -401,9 +401,6 @@ "networks.bulk.label": { "message": "Activer/désactiver toutes les ressources visibles" }, - "settings.nav.label": { - "message": "Sections des paramètres" - }, "profile.switch.title": { "message": "Basculer vers le profil « {name} » ?" }, @@ -497,6 +494,9 @@ "settings.error.debugBundleTitle": { "message": "Échec du lot de diagnostic" }, + "settings.nav.label": { + "message": "Sections des paramètres" + }, "settings.tabs.general": { "message": "Général" }, @@ -512,6 +512,9 @@ "settings.tabs.ssh": { "message": "SSH" }, + "settings.tabs.vnc": { + "message": "VNC" + }, "settings.tabs.advanced": { "message": "Avancé" }, @@ -721,6 +724,24 @@ "settings.ssh.jwtTtl.suffix": { "message": "s" }, + "settings.vnc.section.server": { + "message": "Serveur" + }, + "settings.vnc.section.approval": { + "message": "Approbation" + }, + "settings.vnc.server.label": { + "message": "Activer le serveur VNC" + }, + "settings.vnc.server.help": { + "message": "Exécuter le serveur VNC de NetBird sur cet hôte afin que les pairs autorisés puissent voir ou contrôler son écran." + }, + "settings.vnc.approval.label": { + "message": "Exiger l'approbation des connexions" + }, + "settings.vnc.approval.help": { + "message": "Afficher sur cet hôte une invite qui doit être acceptée avant d'autoriser une connexion VNC entrante." + }, "settings.advanced.section.interface": { "message": "Interface" }, @@ -1042,6 +1063,45 @@ "window.title.sessionExpiration": { "message": "Expiration de session" }, + "window.title.approval": { + "message": "Demande de connexion" + }, + "approval.title.vnc": { + "message": "Autoriser la connexion VNC ?" + }, + "approval.title.ssh": { + "message": "Autoriser la connexion SSH ?" + }, + "approval.title.default": { + "message": "Autoriser la connexion entrante ?" + }, + "approval.field.user": { + "message": "De l'utilisateur" + }, + "approval.field.keyFingerprint": { + "message": "Empreinte de clé" + }, + "approval.field.peer": { + "message": "Via le pair" + }, + "approval.field.sourceIp": { + "message": "IP source" + }, + "approval.field.osUser": { + "message": "Utilisateur du système" + }, + "approval.countdown": { + "message": "Refus automatique dans {seconds}s" + }, + "approval.action.allow": { + "message": "Autoriser" + }, + "approval.action.allowViewOnly": { + "message": "Autoriser (lecture seule)" + }, + "approval.action.deny": { + "message": "Refuser" + }, "window.title.updating": { "message": "Mise à jour" }, @@ -1351,73 +1411,40 @@ "error.unknown": { "message": "L’opération a échoué." }, - "settings.tabs.vnc": { - "message": "VNC" + "error.elevation_unavailable": { + "message": "NetBird n’a pas pu demander à ce système les privilèges nécessaires. Exécutez plutôt ceci :" }, - "settings.vnc.section.server": { - "message": "Serveur" + "error.elevation_failed": { + "message": "La modification n’a pas pu être appliquée avec des privilèges élevés. Exécutez plutôt ceci :" }, - "settings.vnc.section.approval": { - "message": "Approbation" + "settings.privilege.actorRoot": { + "message": "les privilèges root" }, - "settings.vnc.server.label": { - "message": "Activer le serveur VNC" + "settings.privilege.actorAdministrator": { + "message": "les privilèges administrateur" }, - "settings.vnc.server.help": { - "message": "Exécuter le serveur VNC de NetBird sur cet hôte afin que les pairs autorisés puissent voir ou contrôler son écran." + "settings.privilege.authorizePending": { + "message": "En attente de l’autorisation…" }, - "settings.vnc.approval.label": { - "message": "Exiger l'approbation des connexions" + "connect.activeSession.badge": { + "message": "Écran partagé", + "description": "Short label on the badge shown on the main screen while somebody is attached to this machine over VNC." }, - "settings.vnc.approval.help": { - "message": "Afficher sur cet hôte une invite qui doit être acceptée avant d'autoriser une connexion VNC entrante." + "connect.activeSession.tooltip": { + "message": "Cet écran est consulté via VNC ({sessionCount} session(s)). La déconnexion y met fin, et si vous êtes connecté via VNC vous perdrez l’accès.", + "description": "Tooltip on the screen-shared badge. {sessionCount} is how many VNC sessions are attached; wording covers any number since the bundle has no plural forms." }, - "window.title.approval": { - "message": "Demande de connexion" - }, - "approval.title.vnc": { - "message": "Autoriser la connexion VNC ?" - }, - "approval.title.ssh": { - "message": "Autoriser la connexion SSH ?" - }, - "approval.title.default": { - "message": "Autoriser la connexion entrante ?" - }, - "approval.field.user": { - "message": "De l'utilisateur" - }, - "approval.field.keyFingerprint": { - "message": "Empreinte de clé" - }, - "approval.field.peer": { - "message": "Via le pair" - }, - "approval.field.sourceIp": { - "message": "IP source" - }, - "approval.field.osUser": { - "message": "Utilisateur du système" - }, - "approval.countdown": { - "message": "Refus automatique dans {seconds}s" - }, - "approval.action.allow": { - "message": "Autoriser" - }, - "approval.action.allowViewOnly": { - "message": "Autoriser (lecture seule)" - }, - "approval.action.deny": { - "message": "Refuser" + "connect.activeSession.tooltipNamed": { + "message": "Cet écran est consulté via VNC par {who} ({sessionCount} session(s)). La déconnexion y met fin, et si vous êtes connecté via VNC vous perdrez l’accès.", + "description": "As connect.activeSession.tooltip, with {who} naming the dashboard users who started the sessions." }, "settings.privilege.hint": { "message": "Nécessite {actor}. Exécutez plutôt ceci :" }, "settings.privilege.oneWay": { - "message": "Vous pouvez le désactiver, mais le réactiver nécessite {actor} :" + "message": "Vous pouvez le désactiver, mais le réactiver nécessite {actor}." }, "settings.privilege.oneWayInverted": { - "message": "Vous pouvez l’activer, mais le désactiver de nouveau nécessite {actor} :" + "message": "Vous pouvez l’activer, mais le désactiver de nouveau nécessite {actor}." } } diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json index f63118150..b357c0d55 100644 --- a/client/ui/i18n/locales/hu/common.json +++ b/client/ui/i18n/locales/hu/common.json @@ -401,9 +401,6 @@ "networks.bulk.label": { "message": "Összes látható erőforrás be/ki" }, - "settings.nav.label": { - "message": "Beállítások szakaszai" - }, "profile.switch.title": { "message": "Váltás a(z) \"{name}\" profilra?" }, @@ -497,6 +494,9 @@ "settings.error.debugBundleTitle": { "message": "Hibakeresési csomag sikertelen" }, + "settings.nav.label": { + "message": "Beállítások szakaszai" + }, "settings.tabs.general": { "message": "Általános" }, @@ -512,6 +512,9 @@ "settings.tabs.ssh": { "message": "SSH" }, + "settings.tabs.vnc": { + "message": "VNC" + }, "settings.tabs.advanced": { "message": "Speciális" }, @@ -721,6 +724,24 @@ "settings.ssh.jwtTtl.suffix": { "message": "másodperc" }, + "settings.vnc.section.server": { + "message": "Szerver" + }, + "settings.vnc.section.approval": { + "message": "Jóváhagyás" + }, + "settings.vnc.server.label": { + "message": "VNC szerver engedélyezése" + }, + "settings.vnc.server.help": { + "message": "A NetBird VNC szerver futtatása ezen a gépen, hogy az arra jogosult partnerek megtekinthessék vagy vezérelhessék a képernyőjét." + }, + "settings.vnc.approval.label": { + "message": "Kapcsolat jóváhagyásának megkövetelése" + }, + "settings.vnc.approval.help": { + "message": "Megerősítést kérő ablak megjelenítése ezen a gépen, amelyet el kell fogadni a bejövő VNC-kapcsolat engedélyezése előtt." + }, "settings.advanced.section.interface": { "message": "Interfész" }, @@ -1042,6 +1063,45 @@ "window.title.sessionExpiration": { "message": "Munkamenet lejár" }, + "window.title.approval": { + "message": "Kapcsolódási kérés" + }, + "approval.title.vnc": { + "message": "Engedélyezi a VNC-kapcsolatot?" + }, + "approval.title.ssh": { + "message": "Engedélyezi az SSH-kapcsolatot?" + }, + "approval.title.default": { + "message": "Engedélyezi a bejövő kapcsolatot?" + }, + "approval.field.user": { + "message": "Felhasználótól" + }, + "approval.field.keyFingerprint": { + "message": "Kulcs ujjlenyomata" + }, + "approval.field.peer": { + "message": "Partneren keresztül" + }, + "approval.field.sourceIp": { + "message": "Forrás IP" + }, + "approval.field.osUser": { + "message": "OS-felhasználó" + }, + "approval.countdown": { + "message": "Automatikus elutasítás {seconds} mp múlva" + }, + "approval.action.allow": { + "message": "Engedélyezés" + }, + "approval.action.allowViewOnly": { + "message": "Engedélyezés (csak megtekintés)" + }, + "approval.action.deny": { + "message": "Elutasítás" + }, "window.title.updating": { "message": "Frissítés" }, @@ -1351,73 +1411,40 @@ "error.unknown": { "message": "A művelet meghiúsult." }, - "settings.tabs.vnc": { - "message": "VNC" + "error.elevation_unavailable": { + "message": "A NetBird nem tudta bekérni a rendszertől a szükséges jogosultságokat. Futtassa inkább ezt:" }, - "settings.vnc.section.server": { - "message": "Szerver" + "error.elevation_failed": { + "message": "A módosítást emelt szintű jogosultságokkal sem sikerült alkalmazni. Futtassa inkább ezt:" }, - "settings.vnc.section.approval": { - "message": "Jóváhagyás" + "settings.privilege.actorRoot": { + "message": "root jogosultság" }, - "settings.vnc.server.label": { - "message": "VNC szerver engedélyezése" + "settings.privilege.actorAdministrator": { + "message": "rendszergazdai jogosultság" }, - "settings.vnc.server.help": { - "message": "A NetBird VNC szerver futtatása ezen a gépen, hogy az arra jogosult partnerek megtekinthessék vagy vezérelhessék a képernyőjét." + "settings.privilege.authorizePending": { + "message": "Várakozás az engedélyezésre…" }, - "settings.vnc.approval.label": { - "message": "Kapcsolat jóváhagyásának megkövetelése" + "connect.activeSession.badge": { + "message": "Képernyő megosztva", + "description": "Short label on the badge shown on the main screen while somebody is attached to this machine over VNC." }, - "settings.vnc.approval.help": { - "message": "Megerősítést kérő ablak megjelenítése ezen a gépen, amelyet el kell fogadni a bejövő VNC-kapcsolat engedélyezése előtt." + "connect.activeSession.tooltip": { + "message": "Ezt a képernyőt VNC-n keresztül nézik ({sessionCount} munkamenet). A leválasztás véget vet neki, és ha Ön VNC-n keresztül kapcsolódik, elveszíti a hozzáférést.", + "description": "Tooltip on the screen-shared badge. {sessionCount} is how many VNC sessions are attached; wording covers any number since the bundle has no plural forms." }, - "window.title.approval": { - "message": "Kapcsolódási kérés" - }, - "approval.title.vnc": { - "message": "Engedélyezi a VNC-kapcsolatot?" - }, - "approval.title.ssh": { - "message": "Engedélyezi az SSH-kapcsolatot?" - }, - "approval.title.default": { - "message": "Engedélyezi a bejövő kapcsolatot?" - }, - "approval.field.user": { - "message": "Felhasználótól" - }, - "approval.field.keyFingerprint": { - "message": "Kulcs ujjlenyomata" - }, - "approval.field.peer": { - "message": "Partneren keresztül" - }, - "approval.field.sourceIp": { - "message": "Forrás IP" - }, - "approval.field.osUser": { - "message": "OS-felhasználó" - }, - "approval.countdown": { - "message": "Automatikus elutasítás {seconds} mp múlva" - }, - "approval.action.allow": { - "message": "Engedélyezés" - }, - "approval.action.allowViewOnly": { - "message": "Engedélyezés (csak megtekintés)" - }, - "approval.action.deny": { - "message": "Elutasítás" + "connect.activeSession.tooltipNamed": { + "message": "Ezt a képernyőt {who} nézi VNC-n keresztül ({sessionCount} munkamenet). A leválasztás véget vet neki, és ha Ön VNC-n keresztül kapcsolódik, elveszíti a hozzáférést.", + "description": "As connect.activeSession.tooltip, with {who} naming the dashboard users who started the sessions." }, "settings.privilege.hint": { "message": "{actor} szükséges hozzá. Futtassa inkább ezt:" }, "settings.privilege.oneWay": { - "message": "Kikapcsolhatja, de a visszakapcsolásához {actor} szükséges:" + "message": "Kikapcsolhatja, de a visszakapcsolásához {actor} szükséges." }, "settings.privilege.oneWayInverted": { - "message": "Bekapcsolhatja, de az ismételt kikapcsolásához {actor} szükséges:" + "message": "Bekapcsolhatja, de az ismételt kikapcsolásához {actor} szükséges." } } diff --git a/client/ui/i18n/locales/it/common.json b/client/ui/i18n/locales/it/common.json index 812525d86..a5f16e838 100644 --- a/client/ui/i18n/locales/it/common.json +++ b/client/ui/i18n/locales/it/common.json @@ -401,9 +401,6 @@ "networks.bulk.label": { "message": "Attiva/disattiva tutte le risorse visibili" }, - "settings.nav.label": { - "message": "Sezioni delle impostazioni" - }, "profile.switch.title": { "message": "Passare al profilo «{name}»?" }, @@ -497,6 +494,9 @@ "settings.error.debugBundleTitle": { "message": "Pacchetto di debug non riuscito" }, + "settings.nav.label": { + "message": "Sezioni delle impostazioni" + }, "settings.tabs.general": { "message": "Generale" }, @@ -512,6 +512,9 @@ "settings.tabs.ssh": { "message": "SSH" }, + "settings.tabs.vnc": { + "message": "VNC" + }, "settings.tabs.advanced": { "message": "Avanzate" }, @@ -721,6 +724,24 @@ "settings.ssh.jwtTtl.suffix": { "message": "sec." }, + "settings.vnc.section.server": { + "message": "Server" + }, + "settings.vnc.section.approval": { + "message": "Approvazione" + }, + "settings.vnc.server.label": { + "message": "Abilita server VNC" + }, + "settings.vnc.server.help": { + "message": "Esegui il server VNC di NetBird su questo host in modo che i peer autorizzati possano visualizzarne o controllarne lo schermo." + }, + "settings.vnc.approval.label": { + "message": "Richiedi l'approvazione della connessione" + }, + "settings.vnc.approval.help": { + "message": "Mostra su questo host una richiesta che deve essere accettata prima di consentire una connessione VNC in entrata." + }, "settings.advanced.section.interface": { "message": "Interfaccia" }, @@ -1042,6 +1063,45 @@ "window.title.sessionExpiration": { "message": "Sessione in scadenza" }, + "window.title.approval": { + "message": "Richiesta di connessione" + }, + "approval.title.vnc": { + "message": "Consentire la connessione VNC?" + }, + "approval.title.ssh": { + "message": "Consentire la connessione SSH?" + }, + "approval.title.default": { + "message": "Consentire la connessione in entrata?" + }, + "approval.field.user": { + "message": "Dall'utente" + }, + "approval.field.keyFingerprint": { + "message": "Impronta della chiave" + }, + "approval.field.peer": { + "message": "Tramite peer" + }, + "approval.field.sourceIp": { + "message": "IP di origine" + }, + "approval.field.osUser": { + "message": "Utente del sistema" + }, + "approval.countdown": { + "message": "Rifiuto automatico tra {seconds}s" + }, + "approval.action.allow": { + "message": "Consenti" + }, + "approval.action.allowViewOnly": { + "message": "Consenti (sola visualizzazione)" + }, + "approval.action.deny": { + "message": "Rifiuta" + }, "window.title.updating": { "message": "Aggiornamento" }, @@ -1351,73 +1411,40 @@ "error.unknown": { "message": "Operazione non riuscita." }, - "settings.tabs.vnc": { - "message": "VNC" + "error.elevation_unavailable": { + "message": "NetBird non ha potuto richiedere a questo sistema i privilegi necessari. Esegua invece questo:" }, - "settings.vnc.section.server": { - "message": "Server" + "error.elevation_failed": { + "message": "Non è stato possibile applicare la modifica con privilegi elevati. Esegua invece questo:" }, - "settings.vnc.section.approval": { - "message": "Approvazione" + "settings.privilege.actorRoot": { + "message": "i privilegi di root" }, - "settings.vnc.server.label": { - "message": "Abilita server VNC" + "settings.privilege.actorAdministrator": { + "message": "i privilegi di amministratore" }, - "settings.vnc.server.help": { - "message": "Esegui il server VNC di NetBird su questo host in modo che i peer autorizzati possano visualizzarne o controllarne lo schermo." + "settings.privilege.authorizePending": { + "message": "In attesa dell'autorizzazione…" }, - "settings.vnc.approval.label": { - "message": "Richiedi l'approvazione della connessione" + "connect.activeSession.badge": { + "message": "Schermo condiviso", + "description": "Short label on the badge shown on the main screen while somebody is attached to this machine over VNC." }, - "settings.vnc.approval.help": { - "message": "Mostra su questo host una richiesta che deve essere accettata prima di consentire una connessione VNC in entrata." + "connect.activeSession.tooltip": { + "message": "Questo schermo è visualizzato tramite VNC ({sessionCount} sessione/sessioni). Disconnettendosi la sessione termina e, se sei collegato tramite VNC, perderai l’accesso.", + "description": "Tooltip on the screen-shared badge. {sessionCount} is how many VNC sessions are attached; wording covers any number since the bundle has no plural forms." }, - "window.title.approval": { - "message": "Richiesta di connessione" - }, - "approval.title.vnc": { - "message": "Consentire la connessione VNC?" - }, - "approval.title.ssh": { - "message": "Consentire la connessione SSH?" - }, - "approval.title.default": { - "message": "Consentire la connessione in entrata?" - }, - "approval.field.user": { - "message": "Dall'utente" - }, - "approval.field.keyFingerprint": { - "message": "Impronta della chiave" - }, - "approval.field.peer": { - "message": "Tramite peer" - }, - "approval.field.sourceIp": { - "message": "IP di origine" - }, - "approval.field.osUser": { - "message": "Utente del sistema" - }, - "approval.countdown": { - "message": "Rifiuto automatico tra {seconds}s" - }, - "approval.action.allow": { - "message": "Consenti" - }, - "approval.action.allowViewOnly": { - "message": "Consenti (sola visualizzazione)" - }, - "approval.action.deny": { - "message": "Rifiuta" + "connect.activeSession.tooltipNamed": { + "message": "Questo schermo è visualizzato tramite VNC da {who} ({sessionCount} sessione/sessioni). Disconnettendosi la sessione termina e, se sei collegato tramite VNC, perderai l’accesso.", + "description": "As connect.activeSession.tooltip, with {who} naming the dashboard users who started the sessions." }, "settings.privilege.hint": { "message": "Richiede {actor}. Esegua invece questo:" }, "settings.privilege.oneWay": { - "message": "Può disabilitarlo, ma riabilitarlo richiede {actor}:" + "message": "Può disabilitarlo, ma riabilitarlo richiede {actor}." }, "settings.privilege.oneWayInverted": { - "message": "Può abilitarlo, ma disabilitarlo di nuovo richiede {actor}:" + "message": "Può abilitarlo, ma disabilitarlo di nuovo richiede {actor}." } } diff --git a/client/ui/i18n/locales/ja/common.json b/client/ui/i18n/locales/ja/common.json index 157bc39af..204c60025 100644 --- a/client/ui/i18n/locales/ja/common.json +++ b/client/ui/i18n/locales/ja/common.json @@ -512,6 +512,10 @@ "settings.tabs.ssh": { "message": "SSH" }, + "settings.tabs.vnc": { + "message": "VNC", + "description": "Settings tab label: VNC. Acronym — keep as-is." + }, "settings.tabs.advanced": { "message": "詳細設定" }, @@ -721,6 +725,30 @@ "settings.ssh.jwtTtl.suffix": { "message": "秒" }, + "settings.vnc.section.server": { + "message": "サーバー", + "description": "Section heading: Server (VNC settings)." + }, + "settings.vnc.section.approval": { + "message": "承認", + "description": "Section heading: Approval (VNC connection approval settings)." + }, + "settings.vnc.server.label": { + "message": "VNC サーバーを有効にする", + "description": "Toggle label: enable the embedded VNC server." + }, + "settings.vnc.server.help": { + "message": "このホストで NetBird VNC サーバーを実行し、許可されたピアが画面を表示または操作できるようにします。", + "description": "Helper text for the VNC server toggle." + }, + "settings.vnc.approval.label": { + "message": "接続の承認を必須にする", + "description": "Toggle label: prompt for approval before each inbound VNC connection." + }, + "settings.vnc.approval.help": { + "message": "受信した VNC 接続を許可する前に、このホスト上で承認を求めるダイアログを表示します。", + "description": "Helper text for the VNC connection-approval toggle." + }, "settings.advanced.section.interface": { "message": "インターフェース" }, @@ -1042,6 +1070,58 @@ "window.title.sessionExpiration": { "message": "セッションの期限切れ" }, + "window.title.approval": { + "message": "接続リクエスト", + "description": "OS window-chrome title for the inbound-connection approval window." + }, + "approval.title.vnc": { + "message": "VNC 接続を許可しますか?", + "description": "Approval dialog heading for an inbound VNC connection." + }, + "approval.title.ssh": { + "message": "SSH 接続を許可しますか?", + "description": "Approval dialog heading for an inbound SSH connection." + }, + "approval.title.default": { + "message": "受信接続を許可しますか?", + "description": "Approval dialog heading for an inbound connection of unknown kind." + }, + "approval.field.user": { + "message": "ユーザー", + "description": "Approval dialog row label: the initiating user's display name." + }, + "approval.field.keyFingerprint": { + "message": "鍵のフィンガープリント", + "description": "Approval dialog row label: the connecting peer's cryptographic key fingerprint." + }, + "approval.field.peer": { + "message": "経由ピア", + "description": "Approval dialog row label: the peer the connection arrives through." + }, + "approval.field.sourceIp": { + "message": "送信元 IP", + "description": "Approval dialog row label: the source IP address of the connection." + }, + "approval.field.osUser": { + "message": "OS ユーザー", + "description": "Approval dialog row label: the target operating-system user." + }, + "approval.countdown": { + "message": "{seconds} 秒後に自動拒否", + "description": "Approval dialog countdown; {seconds} is the remaining whole seconds before the daemon auto-denies." + }, + "approval.action.allow": { + "message": "許可", + "description": "Approval dialog button: allow the connection." + }, + "approval.action.allowViewOnly": { + "message": "許可(表示のみ)", + "description": "Approval dialog button: allow the connection in view-only mode." + }, + "approval.action.deny": { + "message": "拒否", + "description": "Approval dialog button: deny the connection." + }, "window.title.updating": { "message": "更新中" }, @@ -1351,13 +1431,40 @@ "error.unknown": { "message": "操作に失敗しました。" }, + "error.elevation_unavailable": { + "message": "NetBird はこのシステムに必要な権限を要求できませんでした。代わりに次のコマンドを実行してください:" + }, + "error.elevation_failed": { + "message": "昇格した権限でも変更を適用できませんでした。代わりに次のコマンドを実行してください:" + }, + "settings.privilege.actorRoot": { + "message": "root 権限" + }, + "settings.privilege.actorAdministrator": { + "message": "管理者権限" + }, + "settings.privilege.authorizePending": { + "message": "承認を待っています…" + }, + "connect.activeSession.badge": { + "message": "画面を共有中", + "description": "Short label on the badge shown on the main screen while somebody is attached to this machine over VNC." + }, + "connect.activeSession.tooltip": { + "message": "この画面は VNC 経由で表示されています({sessionCount} 個のセッション)。切断するとセッションは終了し、ご自身が VNC で接続している場合はアクセスを失います。", + "description": "Tooltip on the screen-shared badge. {sessionCount} is how many VNC sessions are attached; wording covers any number since the bundle has no plural forms." + }, + "connect.activeSession.tooltipNamed": { + "message": "この画面は {who} が VNC 経由で表示しています({sessionCount} 個のセッション)。切断するとセッションは終了し、ご自身が VNC で接続している場合はアクセスを失います。", + "description": "As connect.activeSession.tooltip, with {who} naming the dashboard users who started the sessions." + }, "settings.privilege.hint": { "message": "{actor}が必要です。代わりに次のコマンドを実行してください:" }, "settings.privilege.oneWay": { - "message": "無効にはできますが、再度有効にするには{actor}が必要です:" + "message": "無効にはできますが、再度有効にするには{actor}が必要です。" }, "settings.privilege.oneWayInverted": { - "message": "有効にはできますが、再度無効にするには{actor}が必要です:" + "message": "有効にはできますが、再度無効にするには{actor}が必要です。" } } diff --git a/client/ui/i18n/locales/pt/common.json b/client/ui/i18n/locales/pt/common.json index d3addffef..2b354aeef 100644 --- a/client/ui/i18n/locales/pt/common.json +++ b/client/ui/i18n/locales/pt/common.json @@ -401,9 +401,6 @@ "networks.bulk.label": { "message": "Alternar todos os recursos visíveis" }, - "settings.nav.label": { - "message": "Seções das configurações" - }, "profile.switch.title": { "message": "Alternar perfil para \"{name}\"?" }, @@ -497,6 +494,9 @@ "settings.error.debugBundleTitle": { "message": "Falha no pacote de depuração" }, + "settings.nav.label": { + "message": "Seções das configurações" + }, "settings.tabs.general": { "message": "Geral" }, @@ -512,6 +512,9 @@ "settings.tabs.ssh": { "message": "SSH" }, + "settings.tabs.vnc": { + "message": "VNC" + }, "settings.tabs.advanced": { "message": "Avançado" }, @@ -721,6 +724,24 @@ "settings.ssh.jwtTtl.suffix": { "message": "s" }, + "settings.vnc.section.server": { + "message": "Servidor" + }, + "settings.vnc.section.approval": { + "message": "Aprovação" + }, + "settings.vnc.server.label": { + "message": "Ativar servidor VNC" + }, + "settings.vnc.server.help": { + "message": "Execute o servidor VNC do NetBird neste host para que os peers autorizados possam ver ou controlar a sua tela." + }, + "settings.vnc.approval.label": { + "message": "Exigir aprovação de conexão" + }, + "settings.vnc.approval.help": { + "message": "Mostrar neste host um aviso que precisa ser aceito antes de permitir uma conexão VNC de entrada." + }, "settings.advanced.section.interface": { "message": "Interface" }, @@ -1042,6 +1063,45 @@ "window.title.sessionExpiration": { "message": "Sessão expirando" }, + "window.title.approval": { + "message": "Solicitação de conexão" + }, + "approval.title.vnc": { + "message": "Permitir a conexão VNC?" + }, + "approval.title.ssh": { + "message": "Permitir a conexão SSH?" + }, + "approval.title.default": { + "message": "Permitir a conexão de entrada?" + }, + "approval.field.user": { + "message": "Do usuário" + }, + "approval.field.keyFingerprint": { + "message": "Impressão digital da chave" + }, + "approval.field.peer": { + "message": "Via peer" + }, + "approval.field.sourceIp": { + "message": "IP de origem" + }, + "approval.field.osUser": { + "message": "Usuário do SO" + }, + "approval.countdown": { + "message": "Negação automática em {seconds}s" + }, + "approval.action.allow": { + "message": "Permitir" + }, + "approval.action.allowViewOnly": { + "message": "Permitir (somente visualização)" + }, + "approval.action.deny": { + "message": "Negar" + }, "window.title.updating": { "message": "Atualizando" }, @@ -1351,73 +1411,40 @@ "error.unknown": { "message": "A operação falhou." }, - "settings.tabs.vnc": { - "message": "VNC" + "error.elevation_unavailable": { + "message": "O NetBird não conseguiu solicitar a este sistema os privilégios necessários. Execute isto em vez disso:" }, - "settings.vnc.section.server": { - "message": "Servidor" + "error.elevation_failed": { + "message": "Não foi possível aplicar a alteração com privilégios elevados. Execute isto em vez disso:" }, - "settings.vnc.section.approval": { - "message": "Aprovação" + "settings.privilege.actorRoot": { + "message": "privilégios de root" }, - "settings.vnc.server.label": { - "message": "Ativar servidor VNC" + "settings.privilege.actorAdministrator": { + "message": "privilégios de administrador" }, - "settings.vnc.server.help": { - "message": "Execute o servidor VNC do NetBird neste host para que os peers autorizados possam ver ou controlar a sua tela." + "settings.privilege.authorizePending": { + "message": "Aguardando a autorização…" }, - "settings.vnc.approval.label": { - "message": "Exigir aprovação de conexão" + "connect.activeSession.badge": { + "message": "Ecrã partilhado", + "description": "Short label on the badge shown on the main screen while somebody is attached to this machine over VNC." }, - "settings.vnc.approval.help": { - "message": "Mostrar neste host um aviso que precisa ser aceito antes de permitir uma conexão VNC de entrada." + "connect.activeSession.tooltip": { + "message": "Este ecrã está a ser visto por VNC ({sessionCount} sessão/sessões). Desligar termina-a e, se estiver ligado por VNC, perderá o acesso.", + "description": "Tooltip on the screen-shared badge. {sessionCount} is how many VNC sessions are attached; wording covers any number since the bundle has no plural forms." }, - "window.title.approval": { - "message": "Solicitação de conexão" - }, - "approval.title.vnc": { - "message": "Permitir a conexão VNC?" - }, - "approval.title.ssh": { - "message": "Permitir a conexão SSH?" - }, - "approval.title.default": { - "message": "Permitir a conexão de entrada?" - }, - "approval.field.user": { - "message": "Do usuário" - }, - "approval.field.keyFingerprint": { - "message": "Impressão digital da chave" - }, - "approval.field.peer": { - "message": "Via peer" - }, - "approval.field.sourceIp": { - "message": "IP de origem" - }, - "approval.field.osUser": { - "message": "Usuário do SO" - }, - "approval.countdown": { - "message": "Negação automática em {seconds}s" - }, - "approval.action.allow": { - "message": "Permitir" - }, - "approval.action.allowViewOnly": { - "message": "Permitir (somente visualização)" - }, - "approval.action.deny": { - "message": "Negar" + "connect.activeSession.tooltipNamed": { + "message": "Este ecrã está a ser visto por VNC por {who} ({sessionCount} sessão/sessões). Desligar termina-a e, se estiver ligado por VNC, perderá o acesso.", + "description": "As connect.activeSession.tooltip, with {who} naming the dashboard users who started the sessions." }, "settings.privilege.hint": { "message": "Requer {actor}. Execute isto em vez disso:" }, "settings.privilege.oneWay": { - "message": "Você pode desativar isto, mas ativar novamente requer {actor}:" + "message": "Você pode desativar isto, mas ativar novamente requer {actor}." }, "settings.privilege.oneWayInverted": { - "message": "Você pode ativar isto, mas desativar novamente requer {actor}:" + "message": "Você pode ativar isto, mas desativar novamente requer {actor}." } } diff --git a/client/ui/i18n/locales/ru/common.json b/client/ui/i18n/locales/ru/common.json index d803664c3..c008e850c 100644 --- a/client/ui/i18n/locales/ru/common.json +++ b/client/ui/i18n/locales/ru/common.json @@ -401,9 +401,6 @@ "networks.bulk.label": { "message": "Переключить все видимые ресурсы" }, - "settings.nav.label": { - "message": "Разделы настроек" - }, "profile.switch.title": { "message": "Переключиться на профиль «{name}»?" }, @@ -497,6 +494,9 @@ "settings.error.debugBundleTitle": { "message": "Не удалось создать отладочный пакет" }, + "settings.nav.label": { + "message": "Разделы настроек" + }, "settings.tabs.general": { "message": "Общие" }, @@ -512,6 +512,9 @@ "settings.tabs.ssh": { "message": "SSH" }, + "settings.tabs.vnc": { + "message": "VNC" + }, "settings.tabs.advanced": { "message": "Дополнительно" }, @@ -721,6 +724,24 @@ "settings.ssh.jwtTtl.suffix": { "message": "сек." }, + "settings.vnc.section.server": { + "message": "Сервер" + }, + "settings.vnc.section.approval": { + "message": "Подтверждение" + }, + "settings.vnc.server.label": { + "message": "Включить VNC-сервер" + }, + "settings.vnc.server.help": { + "message": "Запустить VNC-сервер NetBird на этом хосте, чтобы авторизованные пиры могли просматривать его экран или управлять им." + }, + "settings.vnc.approval.label": { + "message": "Требовать подтверждение подключения" + }, + "settings.vnc.approval.help": { + "message": "Показывать на этом хосте запрос, который нужно принять перед разрешением входящего VNC-подключения." + }, "settings.advanced.section.interface": { "message": "Интерфейс" }, @@ -1042,6 +1063,45 @@ "window.title.sessionExpiration": { "message": "Истечение сеанса" }, + "window.title.approval": { + "message": "Запрос на подключение" + }, + "approval.title.vnc": { + "message": "Разрешить VNC-подключение?" + }, + "approval.title.ssh": { + "message": "Разрешить SSH-подключение?" + }, + "approval.title.default": { + "message": "Разрешить входящее подключение?" + }, + "approval.field.user": { + "message": "От пользователя" + }, + "approval.field.keyFingerprint": { + "message": "Отпечаток ключа" + }, + "approval.field.peer": { + "message": "Через пир" + }, + "approval.field.sourceIp": { + "message": "IP-адрес источника" + }, + "approval.field.osUser": { + "message": "Пользователь ОС" + }, + "approval.countdown": { + "message": "Автоотклонение через {seconds} с" + }, + "approval.action.allow": { + "message": "Разрешить" + }, + "approval.action.allowViewOnly": { + "message": "Разрешить (только просмотр)" + }, + "approval.action.deny": { + "message": "Отклонить" + }, "window.title.updating": { "message": "Обновление" }, @@ -1351,73 +1411,40 @@ "error.unknown": { "message": "Не удалось выполнить операцию." }, - "settings.tabs.vnc": { - "message": "VNC" + "error.elevation_unavailable": { + "message": "NetBird не смог запросить у этой системы нужные права. Выполните вместо этого:" }, - "settings.vnc.section.server": { - "message": "Сервер" + "error.elevation_failed": { + "message": "Не удалось применить изменение с повышенными правами. Выполните вместо этого:" }, - "settings.vnc.section.approval": { - "message": "Подтверждение" + "settings.privilege.actorRoot": { + "message": "права root" }, - "settings.vnc.server.label": { - "message": "Включить VNC-сервер" + "settings.privilege.actorAdministrator": { + "message": "права администратора" }, - "settings.vnc.server.help": { - "message": "Запустить VNC-сервер NetBird на этом хосте, чтобы авторизованные пиры могли просматривать его экран или управлять им." + "settings.privilege.authorizePending": { + "message": "Ожидание авторизации…" }, - "settings.vnc.approval.label": { - "message": "Требовать подтверждение подключения" + "connect.activeSession.badge": { + "message": "Экран доступен", + "description": "Short label on the badge shown on the main screen while somebody is attached to this machine over VNC." }, - "settings.vnc.approval.help": { - "message": "Показывать на этом хосте запрос, который нужно принять перед разрешением входящего VNC-подключения." + "connect.activeSession.tooltip": { + "message": "Этот экран просматривается по VNC ({sessionCount} сеанс(ов)). Отключение завершит его, и если вы подключены через VNC, вы потеряете доступ.", + "description": "Tooltip on the screen-shared badge. {sessionCount} is how many VNC sessions are attached; wording covers any number since the bundle has no plural forms." }, - "window.title.approval": { - "message": "Запрос на подключение" - }, - "approval.title.vnc": { - "message": "Разрешить VNC-подключение?" - }, - "approval.title.ssh": { - "message": "Разрешить SSH-подключение?" - }, - "approval.title.default": { - "message": "Разрешить входящее подключение?" - }, - "approval.field.user": { - "message": "От пользователя" - }, - "approval.field.keyFingerprint": { - "message": "Отпечаток ключа" - }, - "approval.field.peer": { - "message": "Через пир" - }, - "approval.field.sourceIp": { - "message": "IP-адрес источника" - }, - "approval.field.osUser": { - "message": "Пользователь ОС" - }, - "approval.countdown": { - "message": "Автоотклонение через {seconds} с" - }, - "approval.action.allow": { - "message": "Разрешить" - }, - "approval.action.allowViewOnly": { - "message": "Разрешить (только просмотр)" - }, - "approval.action.deny": { - "message": "Отклонить" + "connect.activeSession.tooltipNamed": { + "message": "Этот экран просматривает {who} по VNC ({sessionCount} сеанс(ов)). Отключение завершит его, и если вы подключены через VNC, вы потеряете доступ.", + "description": "As connect.activeSession.tooltip, with {who} naming the dashboard users who started the sessions." }, "settings.privilege.hint": { "message": "Требуются {actor}. Выполните вместо этого:" }, "settings.privilege.oneWay": { - "message": "Отключить можно, но чтобы включить снова, нужны {actor}:" + "message": "Отключить можно, но чтобы включить снова, нужны {actor}." }, "settings.privilege.oneWayInverted": { - "message": "Включить можно, но чтобы отключить снова, нужны {actor}:" + "message": "Включить можно, но чтобы отключить снова, нужны {actor}." } } diff --git a/client/ui/i18n/locales/zh-CN/common.json b/client/ui/i18n/locales/zh-CN/common.json index 40d1b1496..de190058c 100644 --- a/client/ui/i18n/locales/zh-CN/common.json +++ b/client/ui/i18n/locales/zh-CN/common.json @@ -401,9 +401,6 @@ "networks.bulk.label": { "message": "切换所有可见资源" }, - "settings.nav.label": { - "message": "设置部分" - }, "profile.switch.title": { "message": "切换到配置文件“{name}”?" }, @@ -497,6 +494,9 @@ "settings.error.debugBundleTitle": { "message": "创建调试包失败" }, + "settings.nav.label": { + "message": "设置部分" + }, "settings.tabs.general": { "message": "常规" }, @@ -512,6 +512,9 @@ "settings.tabs.ssh": { "message": "SSH" }, + "settings.tabs.vnc": { + "message": "VNC" + }, "settings.tabs.advanced": { "message": "高级" }, @@ -721,6 +724,24 @@ "settings.ssh.jwtTtl.suffix": { "message": "秒" }, + "settings.vnc.section.server": { + "message": "服务器" + }, + "settings.vnc.section.approval": { + "message": "批准" + }, + "settings.vnc.server.label": { + "message": "启用 VNC 服务器" + }, + "settings.vnc.server.help": { + "message": "在此主机上运行 NetBird VNC 服务器,以便授权的对端可以查看或控制其屏幕。" + }, + "settings.vnc.approval.label": { + "message": "要求连接批准" + }, + "settings.vnc.approval.help": { + "message": "在此主机上显示一个提示,必须先接受该提示才能允许传入的 VNC 连接。" + }, "settings.advanced.section.interface": { "message": "接口" }, @@ -1042,6 +1063,45 @@ "window.title.sessionExpiration": { "message": "会话即将过期" }, + "window.title.approval": { + "message": "连接请求" + }, + "approval.title.vnc": { + "message": "允许 VNC 连接?" + }, + "approval.title.ssh": { + "message": "允许 SSH 连接?" + }, + "approval.title.default": { + "message": "允许传入连接?" + }, + "approval.field.user": { + "message": "来自用户" + }, + "approval.field.keyFingerprint": { + "message": "密钥指纹" + }, + "approval.field.peer": { + "message": "经由对端" + }, + "approval.field.sourceIp": { + "message": "源 IP" + }, + "approval.field.osUser": { + "message": "操作系统用户" + }, + "approval.countdown": { + "message": "{seconds} 秒后自动拒绝" + }, + "approval.action.allow": { + "message": "允许" + }, + "approval.action.allowViewOnly": { + "message": "允许(仅查看)" + }, + "approval.action.deny": { + "message": "拒绝" + }, "window.title.updating": { "message": "正在更新" }, @@ -1351,73 +1411,40 @@ "error.unknown": { "message": "操作失败。" }, - "settings.tabs.vnc": { - "message": "VNC" + "error.elevation_unavailable": { + "message": "NetBird 无法向此系统请求所需的权限。请改为运行:" }, - "settings.vnc.section.server": { - "message": "服务器" + "error.elevation_failed": { + "message": "即使使用提升的权限也无法应用此更改。请改为运行:" }, - "settings.vnc.section.approval": { - "message": "批准" + "settings.privilege.actorRoot": { + "message": "root 权限" }, - "settings.vnc.server.label": { - "message": "启用 VNC 服务器" + "settings.privilege.actorAdministrator": { + "message": "管理员权限" }, - "settings.vnc.server.help": { - "message": "在此主机上运行 NetBird VNC 服务器,以便授权的对端可以查看或控制其屏幕。" + "settings.privilege.authorizePending": { + "message": "正在等待授权…" }, - "settings.vnc.approval.label": { - "message": "要求连接批准" + "connect.activeSession.badge": { + "message": "屏幕共享中", + "description": "Short label on the badge shown on the main screen while somebody is attached to this machine over VNC." }, - "settings.vnc.approval.help": { - "message": "在此主机上显示一个提示,必须先接受该提示才能允许传入的 VNC 连接。" + "connect.activeSession.tooltip": { + "message": "此屏幕正通过 VNC 被查看({sessionCount} 个会话)。断开连接会结束会话;如果你自己是通过 VNC 连接的,将失去访问权限。", + "description": "Tooltip on the screen-shared badge. {sessionCount} is how many VNC sessions are attached; wording covers any number since the bundle has no plural forms." }, - "window.title.approval": { - "message": "连接请求" - }, - "approval.title.vnc": { - "message": "允许 VNC 连接?" - }, - "approval.title.ssh": { - "message": "允许 SSH 连接?" - }, - "approval.title.default": { - "message": "允许传入连接?" - }, - "approval.field.user": { - "message": "来自用户" - }, - "approval.field.keyFingerprint": { - "message": "密钥指纹" - }, - "approval.field.peer": { - "message": "经由对端" - }, - "approval.field.sourceIp": { - "message": "源 IP" - }, - "approval.field.osUser": { - "message": "操作系统用户" - }, - "approval.countdown": { - "message": "{seconds} 秒后自动拒绝" - }, - "approval.action.allow": { - "message": "允许" - }, - "approval.action.allowViewOnly": { - "message": "允许(仅查看)" - }, - "approval.action.deny": { - "message": "拒绝" + "connect.activeSession.tooltipNamed": { + "message": "{who} 正通过 VNC 查看此屏幕({sessionCount} 个会话)。断开连接会结束会话;如果你自己是通过 VNC 连接的,将失去访问权限。", + "description": "As connect.activeSession.tooltip, with {who} naming the dashboard users who started the sessions." }, "settings.privilege.hint": { "message": "需要{actor}。请改为运行:" }, "settings.privilege.oneWay": { - "message": "您可以关闭此项,但重新开启需要{actor}:" + "message": "您可以关闭此项,但重新开启需要{actor}。" }, "settings.privilege.oneWayInverted": { - "message": "您可以开启此项,但再次关闭需要{actor}:" + "message": "您可以开启此项,但再次关闭需要{actor}。" } } diff --git a/client/ui/main.go b/client/ui/main.go index aa7701887..83cb52fda 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -8,6 +8,7 @@ import ( "flag" "io/fs" "log" + "os" "runtime" "strings" @@ -79,6 +80,14 @@ func init() { } func main() { + // The one-shot that applies the settings the daemon restricts to + // root/administrator, which this binary runs itself as under the platform's + // elevation prompt. Handled before anything GUI so no window, tray or + // single-instance lock is involved. + if services.IsPrivilegedSettingsRun(os.Args[1:]) { + os.Exit(runPrivilegedSettings(os.Args[1:])) + } + daemonAddr, userSetLogFile := parseFlagsAndInitLog() conn := NewConn(daemonAddr) diff --git a/client/ui/privileged_settings.go b/client/ui/privileged_settings.go new file mode 100644 index 000000000..1e8b4bbf6 --- /dev/null +++ b/client/ui/privileged_settings.go @@ -0,0 +1,27 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/client/ui/services" +) + +// The one-shot mode this binary runs itself in, elevated, to apply the settings the +// daemon restricts to root/administrator. It is handled before anything GUI, so no +// window, tray or single-instance lock is involved. +// +// Only the wiring is here: what the mode accepts and does lives beside the code +// that asks for it, in services.RunPrivilegedSettings, so the settings it will +// apply are declared once. There is nothing privileged about the mode itself; it +// sends the same request the frontend would have sent, and the daemon authorizes it +// from the identity the kernel reports on the control channel exactly as it does +// for `sudo netbird up`. +func runPrivilegedSettings(args []string) int { + return services.RunPrivilegedSettings(args, func(addr string) (proto.DaemonServiceClient, error) { + if addr == "" { + addr = DaemonAddr() + } + return NewConn(addr).Client() + }) +} diff --git a/client/ui/services/guarded.go b/client/ui/services/guarded.go new file mode 100644 index 000000000..77a3adb6d --- /dev/null +++ b/client/ui/services/guarded.go @@ -0,0 +1,235 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/elevate" + "github.com/netbirdio/netbird/client/internal/ipcauth" +) + +// The command line of the one-shot mode this binary runs itself in, elevated, to +// apply a setting the daemon restricts to root/administrator. The setting flags +// spell the same words as `netbird up`, so the command a user is shown and what +// runs behind the prompt read alike. Parsed in oneshot.go. +const ( + FlagApplyPrivilegedSettings = "apply-privileged-settings" + FlagDaemonAddr = "daemon-addr" + FlagProfile = "profile" + FlagUser = "user" + FlagLogLevel = "log-level" + FlagManagementURL = "management-url" + FlagAllowServerSSH = "allow-server-ssh" + FlagEnableSSHRoot = "enable-ssh-root" + FlagDisableSSHAuth = "disable-ssh-auth" + FlagAllowServerVNC = "allow-server-vnc" + FlagDisableVNCApproval = "disable-vnc-approval" +) + +// Error codes for the ways asking for privileges can fail. +const ( + CodeElevationUnavailable = "elevation_unavailable" + CodeElevationFailed = "elevation_failed" +) + +// elevationTimeout bounds the wait for a prompt and the change behind it, so a +// dialog nobody answers does not leave its control disabled for the session. Long +// enough to find a password manager, and no shorter than the platforms' own prompt +// timeouts: Windows gives up on its consent dialog after two minutes by itself. +// +// It always ends our waiting, and not always the prompt: Security.framework offers +// no way to withdraw a request, so on macOS the system's own timeout is what closes +// the dialog. +const elevationTimeout = 5 * time.Minute + +// elevator raises the platform's privilege prompt and runs the change behind it. +// An interface so tests can answer without a prompt. +type elevator interface { + // Run runs this binary again, elevated, with the given arguments. + Run(ctx context.Context, args ...string) error + // Available reports whether there is a prompt to raise on this host at all. + Available() bool +} + +// osElevator is the real thing: see the elevate package. +type osElevator struct{} + +func (osElevator) Run(ctx context.Context, args ...string) error { + return elevate.Run(ctx, args...) +} + +func (osElevator) Available() bool { + return elevate.Available() +} + +// SaveOutcome reports what became of a change that needed authorization. +// +// A declined prompt is a result, not an error: the user was asked and said no, so +// nothing was applied and nothing went wrong. Reporting it as an error would have +// every cancelled prompt logged as one. +type SaveOutcome struct { + // Declined is set when the user dismissed the authorization prompt, or was + // refused by policy. Nothing was changed. + Declined bool `json:"declined"` +} + +// GuardedSettings is the subset of the config the daemon restricts to +// root/administrator. Only the fields that are set are changed: a nil pointer, or +// an empty management URL, leaves that setting alone. +// +// The management URL is in here because pointing a host with a remote-access +// server running at another management identity hands the decision of who may +// open a shell on it, or reach its desktop, to whoever runs that server, which is +// the same power as enabling that server in the first place. +type GuardedSettings struct { + ProfileName string `json:"profileName"` + Username string `json:"username"` + ManagementURL string `json:"managementUrl,omitempty"` + ServerSSHAllowed *bool `json:"serverSshAllowed,omitempty"` + EnableSSHRoot *bool `json:"enableSshRoot,omitempty"` + DisableSSHAuth *bool `json:"disableSshAuth,omitempty"` + ServerVNCAllowed *bool `json:"serverVncAllowed,omitempty"` + DisableVNCApproval *bool `json:"disableVncApproval,omitempty"` +} + +// guardedSetting is one setting to change, in the two spellings this needs: the +// one-shot's own flag, and the `netbird up` flag that does the same thing from a +// terminal, for when there is no prompt to raise. +type guardedSetting struct { + arg string + flag string +} + +// SetGuardedSettings applies settings the daemon refuses from an unprivileged +// caller, by having the operating system run this binary again, elevated, to send +// the same request the frontend would have sent itself. +// +// The user authorizes it at the platform's own prompt: the UAC consent dialog, +// the macOS authentication dialog, or the polkit agent's. Any credentials are the +// operating system's business; NetBird neither sees nor asks for them. Nothing +// about the daemon's rules changes, and the elevated process is authorized like +// any other privileged caller, from the identity the kernel reports for it. +// +// A declined prompt comes back as SaveOutcome.Declined with no error. When there is +// no prompt to raise, or the elevated run failed, the error carries the command +// that does the same thing from a terminal. +func (s *Settings) SetGuardedSettings(ctx context.Context, p GuardedSettings) (SaveOutcome, error) { + settings := guardedSettings(p) + if len(settings) == 0 { + return SaveOutcome{}, &ClientError{ + Code: CodeElevationFailed, + Short: "no setting to apply", + Long: "no setting to apply", + } + } + + // The elevated run has no window and, on Linux, an environment pkexec has + // cleared, so what it writes to stderr is all there is to go on. It follows + // this process's level so that starting the app with --log-level debug says + // something about the run behind the prompt too. + args := append([]string{ + "--" + FlagApplyPrivilegedSettings, + "--" + FlagDaemonAddr, s.daemonAddr, + "--" + FlagProfile, p.ProfileName, + "--" + FlagUser, p.Username, + "--" + FlagLogLevel, log.GetLevel().String(), + }, oneShotArgs(settings)...) + + ctx, cancel := context.WithTimeout(ctx, elevationTimeout) + defer cancel() + + // These changes hand out shells on this host, so both ends are logged: when the + // prompt went up, and what came of it. It is also the only account of a prompt + // that was slow to appear or never answered. + log.Infof("asking for privileges to apply %s", guardedSummary(p)) + + if err := s.elevator.Run(ctx, args...); err != nil { + return s.elevationOutcome(err, p) + } + + log.Infof("applied %s with the privileges the user authorized", guardedSummary(p)) + return SaveOutcome{}, nil +} + +// elevationOutcome sorts what came back into the one normal ending and the two +// that need reporting, with the command that does the same thing by hand. +func (s *Settings) elevationOutcome(err error, p GuardedSettings) (SaveOutcome, error) { + switch { + case errors.Is(err, elevate.ErrDeclined): + // With the reason: an account that may not elevate at all lands here too, + // and the log is the only place that says which it was. + log.Infof("the elevation prompt for %s was declined: %v", guardedSummary(p), err) + return SaveOutcome{Declined: true}, nil + case errors.Is(err, elevate.ErrUnavailable): + log.Warnf("cannot ask for privileges to apply %s: %v", guardedSummary(p), err) + return SaveOutcome{}, &ClientError{ + Code: CodeElevationUnavailable, + Short: s.classifier.translateShort(CodeElevationUnavailable), + Long: err.Error(), + Command: guardedCommand(p), + } + default: + log.Errorf("applying %s with elevated privileges failed: %v", guardedSummary(p), err) + return SaveOutcome{}, &ClientError{ + Code: CodeElevationFailed, + Short: s.classifier.translateShort(CodeElevationFailed), + Long: err.Error(), + Command: guardedCommand(p), + } + } +} + +// guardedSettings renders the settings that are actually being changed, from the +// same table the one-shot parses them with: see oneshot.go. +func guardedSettings(p GuardedSettings) []guardedSetting { + var settings []guardedSetting + for _, field := range guardedFields { + value, ok := field.read(p) + if !ok { + continue + } + settings = append(settings, guardedSetting{ + arg: "--" + field.flag + "=" + value, + flag: field.up(value), + }) + } + return settings +} + +func oneShotArgs(settings []guardedSetting) []string { + args := make([]string, 0, len(settings)) + for _, setting := range settings { + args = append(args, setting.arg) + } + return args +} + +func upFlags(settings []guardedSetting) []string { + flags := make([]string, 0, len(settings)) + for _, setting := range settings { + flags = append(flags, setting.flag) + } + return flags +} + +// guardedCommand is the elevated command line equivalent to the requested +// change, the same shape the daemon names in its own refusals. +func guardedCommand(p GuardedSettings) string { + settings := guardedSettings(p) + if len(settings) == 0 { + return "" + } + return ipcauth.UpCommand(strings.Join(upFlags(settings), " ")) +} + +// guardedSummary names the change for the log. +func guardedSummary(p GuardedSettings) string { + return fmt.Sprintf("%v for profile %q", oneShotArgs(guardedSettings(p)), p.ProfileName) +} diff --git a/client/ui/services/guarded_test.go b/client/ui/services/guarded_test.go new file mode 100644 index 000000000..42c00ce4f --- /dev/null +++ b/client/ui/services/guarded_test.go @@ -0,0 +1,355 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + "errors" + "testing" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal/elevate" + "github.com/netbirdio/netbird/client/internal/ipcauth" + "github.com/netbirdio/netbird/client/proto" +) + +// A Unix socket, so the daemon address is one that carries a caller's identity and +// elevation is worth offering at all: see Settings.canElevate. +const testDaemonAddr = "unix:///var/run/netbird.sock" + +// storedManagementURL is what the stub daemon already holds, so that a request +// naming a different one is a change: see Settings.guardedChanges. +const storedManagementURL = "https://stored.example.com" + +// stubElevator stands in for the platform's prompt: it records what would have run +// and answers with a fixed outcome. +type stubElevator struct { + outcome error + available bool + calls [][]string +} + +func (e *stubElevator) Run(_ context.Context, args ...string) error { + e.calls = append(e.calls, args) + return e.outcome +} + +func (e *stubElevator) Available() bool { return e.available } + +// stubDaemon implements only the RPCs under test. The embedded interface is nil, so +// any other call panics rather than passing quietly. +type stubDaemon struct { + proto.DaemonServiceClient + setConfig func(*proto.SetConfigRequest) error + // stored is what GetConfig reports, which is what a refused request's guarded + // settings are compared against. + stored *proto.GetConfigResponse + requests []*proto.SetConfigRequest +} + +func (d *stubDaemon) SetConfig(_ context.Context, in *proto.SetConfigRequest, _ ...grpc.CallOption) (*proto.SetConfigResponse, error) { + d.requests = append(d.requests, in) + if err := d.setConfig(in); err != nil { + return nil, err + } + return &proto.SetConfigResponse{}, nil +} + +func (d *stubDaemon) GetConfig(_ context.Context, _ *proto.GetConfigRequest, _ ...grpc.CallOption) (*proto.GetConfigResponse, error) { + return d.stored, nil +} + +type stubConn struct{ client proto.DaemonServiceClient } + +func (c stubConn) Client() (proto.DaemonServiceClient, error) { return c.client, nil } + +// privilegeRefusal is the error the daemon raises for a change it restricts to +// root, detail and all: see server.privilegeError. +func privilegeRefusal(t *testing.T) error { + t.Helper() + + st, err := gstatus.New(codes.PermissionDenied, "Changing the management URL requires root."). + WithDetails(&errdetails.ErrorInfo{ + Reason: ipcauth.ErrorReasonPrivilegeRequired, + Domain: ipcauth.ErrorDomain, + Metadata: map[string]string{ + ipcauth.ErrorMetaSummary: "Changing the management URL requires root.", + ipcauth.ErrorMetaCommand: "sudo netbird down; sudo netbird up -m https://mgmt.example.com", + }, + }) + require.NoError(t, err, "build the refusal detail") + return st.Err() +} + +func settingsWithElevation(t *testing.T, outcome error) (*Settings, *stubElevator) { + t.Helper() + + elev := &stubElevator{outcome: outcome, available: true} + return &Settings{daemonAddr: testDaemonAddr, elevator: elev}, elev +} + +// settingsRefusingOnce returns a Settings whose daemon refuses the first SetConfig +// for want of privileges and accepts anything after it. Its stored config holds +// another management server and no SSH grants, so a request naming either is a +// change rather than a restatement. +func settingsRefusingOnce(t *testing.T, elev *stubElevator) (*Settings, *stubDaemon) { + t.Helper() + + refusal := privilegeRefusal(t) + daemon := &stubDaemon{stored: &proto.GetConfigResponse{ManagementUrl: storedManagementURL}} + daemon.setConfig = func(*proto.SetConfigRequest) error { + if len(daemon.requests) == 1 { + return refusal + } + return nil + } + return &Settings{conn: stubConn{client: daemon}, daemonAddr: testDaemonAddr, elevator: elev}, daemon +} + +func TestSetGuardedSettingsPassesOnlyTheChangedSettings(t *testing.T) { + s, elev := settingsWithElevation(t, nil) + + root := true + outcome, err := s.SetGuardedSettings(context.Background(), GuardedSettings{ + ProfileName: "work", + Username: "vma", + EnableSSHRoot: &root, + }) + require.NoError(t, err) + assert.False(t, outcome.Declined, "the prompt was answered") + + want := []string{ + "--" + FlagApplyPrivilegedSettings, + "--" + FlagDaemonAddr, testDaemonAddr, + "--" + FlagProfile, "work", + "--" + FlagUser, "vma", + "--" + FlagLogLevel, log.GetLevel().String(), + "--" + FlagEnableSSHRoot + "=true", + } + require.Len(t, elev.calls, 1, "one prompt for one change") + assert.Equal(t, want, elev.calls[0], "elevated arguments") + + // argv[1] is what the polkit action is pinned to, so the marker has to stay + // first however the rest of the line grows. + assert.Equal(t, "--"+FlagApplyPrivilegedSettings, elev.calls[0][0], "the flag polkit matches on") +} + +// Turning a setting off has to be as explicit as turning it on: a bare flag would +// read as "on" to the one-shot's parser. +func TestSetGuardedSettingsSpellsOutFalse(t *testing.T) { + s, elev := settingsWithElevation(t, nil) + + off := false + _, err := s.SetGuardedSettings(context.Background(), GuardedSettings{ + ProfileName: "default", + ServerSSHAllowed: &off, + DisableSSHAuth: &off, + }) + require.NoError(t, err) + + args := elev.calls[0] + assert.Contains(t, args, "--"+FlagAllowServerSSH+"=false", "the setting being switched off") + assert.Contains(t, args, "--"+FlagDisableSSHAuth+"=false", "the setting being switched off") + assert.NotContains(t, args, "--"+FlagEnableSSHRoot+"=false", "no flag for a setting nobody touched") +} + +func TestSetGuardedSettingsPassesTheManagementURL(t *testing.T) { + s, elev := settingsWithElevation(t, nil) + + _, err := s.SetGuardedSettings(context.Background(), GuardedSettings{ + ProfileName: "default", + ManagementURL: "https://mgmt.example.com:33073", + }) + require.NoError(t, err) + + assert.Contains(t, elev.calls[0], "--"+FlagManagementURL+"=https://mgmt.example.com:33073", + "the management URL to point the profile at") +} + +func TestSetGuardedSettingsWithoutASettingDoesNotElevate(t *testing.T) { + s, elev := settingsWithElevation(t, nil) + + _, err := s.SetGuardedSettings(context.Background(), GuardedSettings{ProfileName: "default"}) + + require.Error(t, err, "nothing to apply is not something to prompt for") + assert.Empty(t, elev.calls, "no prompt at all") +} + +// A declined prompt is the one ending that is not an error: reporting it as one +// would have every cancelled prompt logged as a failure. +func TestSetGuardedSettingsReportsADeclinedPromptAsAnOutcome(t *testing.T) { + s, _ := settingsWithElevation(t, elevate.ErrDeclined) + + root := true + outcome, err := s.SetGuardedSettings(context.Background(), GuardedSettings{ + ProfileName: "default", + EnableSSHRoot: &root, + }) + + require.NoError(t, err, "the user was asked and answered; nothing went wrong") + assert.True(t, outcome.Declined, "nothing was applied") +} + +func TestSetGuardedSettingsMapsFailures(t *testing.T) { + tests := []struct { + name string + outcome error + wantCode string + }{ + { + // Nothing to raise a prompt with: the user needs the command. + name: "no mechanism falls back to the command", + outcome: elevate.ErrUnavailable, + wantCode: CodeElevationUnavailable, + }, + { + name: "a failed run falls back to the command", + outcome: errors.New("elevated netbird exited with 1"), + wantCode: CodeElevationFailed, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s, _ := settingsWithElevation(t, tt.outcome) + + root := true + _, err := s.SetGuardedSettings(context.Background(), GuardedSettings{ + ProfileName: "default", + EnableSSHRoot: &root, + }) + + var clientErr *ClientError + require.ErrorAs(t, err, &clientErr, "the frontend needs a code to act on") + assert.Equal(t, tt.wantCode, clientErr.Code, "error code") + assert.Contains(t, clientErr.Command, "--"+FlagEnableSSHRoot+"=true", + "the setting in the fallback command") + assert.Contains(t, clientErr.Command, "netbird up", "the fallback command") + }) + } +} + +// Changing the management URL is only privileged while the host runs the SSH +// server, which no control can know up front, so the refusal is what triggers the +// prompt. The original request goes again afterwards, so the fields the one-shot +// does not understand are applied too. +func TestSetConfigElevatesAfterARefusalAndRetries(t *testing.T) { + elev := &stubElevator{available: true} + s, daemon := settingsRefusingOnce(t, elev) + + mtu := int64(1280) + outcome, err := s.SetConfig(context.Background(), SetConfigParams{ + ProfileName: "default", + ManagementURL: "https://mgmt.example.com", + MTU: &mtu, + }) + require.NoError(t, err) + assert.False(t, outcome.Declined, "the prompt was answered") + + require.Len(t, elev.calls, 1, "one prompt") + assert.Contains(t, elev.calls[0], "--"+FlagManagementURL+"=https://mgmt.example.com", + "the guarded part of the request") + require.Len(t, daemon.requests, 2, "the refused request and the retry") + assert.Equal(t, mtu, daemon.requests[1].GetMtu(), + "the retry carries the rest of the request, which the one-shot does not understand") +} + +func TestSetConfigDoesNotRetryWhenTheUserDeclines(t *testing.T) { + elev := &stubElevator{outcome: elevate.ErrDeclined, available: true} + s, daemon := settingsRefusingOnce(t, elev) + + outcome, err := s.SetConfig(context.Background(), SetConfigParams{ + ProfileName: "default", + ManagementURL: "https://mgmt.example.com", + }) + + require.NoError(t, err, "a declined prompt is not an error") + assert.True(t, outcome.Declined, "nothing was applied") + assert.Len(t, daemon.requests, 1, "only the refused request") +} + +// With no prompt to raise, the refusal is reported as the daemon wrote it, which is +// the guidance that was there before elevation existed. +func TestSetConfigReportsTheRefusalWhenItCannotElevate(t *testing.T) { + elev := &stubElevator{available: false} + s, _ := settingsRefusingOnce(t, elev) + + _, err := s.SetConfig(context.Background(), SetConfigParams{ + ProfileName: "default", + ManagementURL: "https://mgmt.example.com", + }) + + var clientErr *ClientError + require.ErrorAs(t, err, &clientErr) + assert.Equal(t, "privilege_required", clientErr.Code, "error code") + assert.Contains(t, clientErr.Command, "netbird up -m https://mgmt.example.com", + "the daemon's own command") + assert.Empty(t, elev.calls, "no prompt where there is none to raise") +} + +// One authorization must buy only the change the user made. A settings form +// submits every field it holds, so most of a refused request restates what the +// daemon already has, and elevating those too would apply a guarded setting the +// user never touched — a value gone stale since the form loaded above all. +func TestSetConfigElevatesOnlyTheGuardedSettingsThatChange(t *testing.T) { + elev := &stubElevator{available: true} + s, _ := settingsRefusingOnce(t, elev) + + on, off := true, false + _, err := s.SetConfig(context.Background(), SetConfigParams{ + ProfileName: "default", + ManagementURL: storedManagementURL, + ServerSSHAllowed: &off, + EnableSSHRoot: &off, + DisableSSHAuth: &on, + }) + require.NoError(t, err) + + require.Len(t, elev.calls, 1, "one prompt") + args := elev.calls[0] + assert.Contains(t, args, "--"+FlagDisableSSHAuth+"=true", "the setting that changes") + assert.NotContains(t, args, "--"+FlagManagementURL+"="+storedManagementURL, + "a management URL the daemon already holds") + assert.NotContains(t, args, "--"+FlagAllowServerSSH+"=false", "a setting already off") + assert.NotContains(t, args, "--"+FlagEnableSSHRoot+"=false", "a setting already off") +} + +// A request that changes no guarded setting has nothing an elevated run could +// apply, so the refusal must have come from somewhere a prompt cannot reach. +func TestSetConfigDoesNotElevateWhenNoGuardedSettingChanges(t *testing.T) { + elev := &stubElevator{available: true} + s, _ := settingsRefusingOnce(t, elev) + + off := false + _, err := s.SetConfig(context.Background(), SetConfigParams{ + ProfileName: "default", + ManagementURL: storedManagementURL, + ServerSSHAllowed: &off, + }) + + var clientErr *ClientError + require.ErrorAs(t, err, &clientErr) + assert.Equal(t, "privilege_required", clientErr.Code, "error code") + assert.Empty(t, elev.calls, "no prompt for a change nobody made") +} + +// A refusal with nothing in the request the one-shot could apply: the daemon +// cannot see who is calling, and being root would not help either. +func TestSetConfigReportsARefusalWithNothingToElevate(t *testing.T) { + elev := &stubElevator{available: true} + s, _ := settingsRefusingOnce(t, elev) + + _, err := s.SetConfig(context.Background(), SetConfigParams{ProfileName: "default"}) + + var clientErr *ClientError + require.ErrorAs(t, err, &clientErr) + assert.Equal(t, "privilege_required", clientErr.Code, "error code") + assert.Empty(t, elev.calls, "no prompt") +} diff --git a/client/ui/services/oneshot.go b/client/ui/services/oneshot.go new file mode 100644 index 000000000..06b6e7e47 --- /dev/null +++ b/client/ui/services/oneshot.go @@ -0,0 +1,245 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + "errors" + "flag" + "fmt" + "os" + "strconv" + "time" + + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal/elevate" + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/util" +) + +// The other end of SetGuardedSettings: the mode this binary runs itself in, +// elevated, to apply the settings the daemon restricts to root/administrator. +// +// Both ends are here on purpose. What may be changed this way is an allowlist, and +// an allowlist declared twice is one that will eventually disagree with itself, so +// the arguments are rendered and parsed from a single table: guardedFields. Adding +// a setting is one row; nothing generic passes through, and no field outside the +// table can be reached with an elevated request no matter what lands on the command +// line. + +// oneShotTimeout bounds the whole one-shot: connect, one RPC, exit. Generous +// because the user has just waited for an authentication dialog, and a failure here +// costs them the entire round trip. +const oneShotTimeout = 30 * time.Second + +// Exit codes the parent reads where the platform gives it one. +const ( + exitOK = 0 + exitFailure = 1 + exitUsage = 2 +) + +// guardedField is one setting the one-shot understands, in the two spellings it +// needs and with the two halves of its plumbing. +type guardedField struct { + // flag names it on the one-shot's command line. + flag string + usage string + // read returns the value to send and whether the caller asked for this setting + // at all. + read func(GuardedSettings) (string, bool) + // write parses a value from the command line onto the request. It is the only + // thing that validates the value, so it fails on anything it does not + // recognise rather than guessing. + write func(*proto.SetConfigRequest, string) error + // up renders the equivalent `netbird up` flag, for the fallback command shown + // when there is no prompt to raise. + up func(value string) string +} + +var guardedFields = []guardedField{ + { + flag: FlagManagementURL, + usage: "Management server the profile registers with.", + read: func(p GuardedSettings) (string, bool) { return p.ManagementURL, p.ManagementURL != "" }, + write: func(req *proto.SetConfigRequest, value string) error { + // Parsed with the config layer's own parser, so what the elevated run + // accepts cannot drift from what the daemon would store. + if _, err := profilemanager.ParseServiceURL("Management URL", value); err != nil { + return err + } + req.ManagementUrl = value + return nil + }, + // The daemon names this one as `-m ` in its own refusals. + up: func(value string) string { return "-m " + value }, + }, + boolField(FlagAllowServerSSH, "Run the NetBird SSH server.", + func(p GuardedSettings) *bool { return p.ServerSSHAllowed }, + func(req *proto.SetConfigRequest, v *bool) { req.ServerSSHAllowed = v }), + boolField(FlagEnableSSHRoot, "Allow SSH sessions to privileged accounts.", + func(p GuardedSettings) *bool { return p.EnableSSHRoot }, + func(req *proto.SetConfigRequest, v *bool) { req.EnableSSHRoot = v }), + boolField(FlagDisableSSHAuth, "Accept SSH sessions without authentication.", + func(p GuardedSettings) *bool { return p.DisableSSHAuth }, + func(req *proto.SetConfigRequest, v *bool) { req.DisableSSHAuth = v }), + boolField(FlagAllowServerVNC, "Run the NetBird VNC server.", + func(p GuardedSettings) *bool { return p.ServerVNCAllowed }, + func(req *proto.SetConfigRequest, v *bool) { req.ServerVNCAllowed = v }), + boolField(FlagDisableVNCApproval, "Accept VNC sessions without asking the console user.", + func(p GuardedSettings) *bool { return p.DisableVNCApproval }, + func(req *proto.SetConfigRequest, v *bool) { req.DisableVNCApproval = v }), +} + +// fieldValue is a flag that remembers whether it was given, and requires a value: +// the renderer always writes one, so a bare flag is a caller that got it wrong. +type fieldValue struct { + set bool + value string +} + +func (v *fieldValue) String() string { + if v == nil { + return "" + } + return v.value +} + +func (v *fieldValue) Set(value string) error { + v.set, v.value = true, value + return nil +} + +// boolField describes a setting that is on or off. The value is always spelled out, +// so that turning a setting off is as unambiguous as turning it on and a flag with +// no value is a mistake rather than an "on". +func boolField( + name, usage string, + read func(GuardedSettings) *bool, + write func(*proto.SetConfigRequest, *bool), +) guardedField { + return guardedField{ + flag: name, + usage: usage, + read: func(p GuardedSettings) (string, bool) { + value := read(p) + if value == nil { + return "", false + } + return strconv.FormatBool(*value), true + }, + write: func(req *proto.SetConfigRequest, value string) error { + parsed, err := strconv.ParseBool(value) + if err != nil { + return fmt.Errorf("parse %q as a boolean: %w", value, err) + } + write(req, &parsed) + return nil + }, + up: func(value string) string { return "--" + name + "=" + value }, + } +} + +// IsPrivilegedSettingsRun reports whether this process was started as the one-shot. +// The flag is a marker rather than a value, so only the bare forms count: reading a +// value would mean "--flag=false" started it too. +func IsPrivilegedSettingsRun(args []string) bool { + for _, arg := range args { + if arg == "--"+FlagApplyPrivilegedSettings || arg == "-"+FlagApplyPrivilegedSettings { + return true + } + } + return false +} + +// RunPrivilegedSettings applies the requested settings and returns the process exit +// code. connect dials the daemon, which is the caller's business because only it +// knows how this build talks to it. +// +// Everything it reports goes to stderr, which is what the parent captures where the +// platform lets it. On success it says so on standard output, because macOS gives +// the parent no exit status to read: see elevate.AppliedMarker. +func RunPrivilegedSettings(args []string, connect func(addr string) (proto.DaemonServiceClient, error)) int { + fs := flag.NewFlagSet("netbird-ui --"+FlagApplyPrivilegedSettings, flag.ContinueOnError) + fs.Bool(FlagApplyPrivilegedSettings, false, "Apply the settings the daemon restricts to root/administrator and exit.") + daemonAddr := fs.String(FlagDaemonAddr, "", "Daemon gRPC address: unix:///path, npipe://name or tcp://host:port") + logLevel := fs.String(FlagLogLevel, "info", "Log level: trace|debug|info|warn|error.") + profile := fs.String(FlagProfile, "", "Profile to change.") + username := fs.String(FlagUser, "", "Owner of the profile.") + + values := make([]fieldValue, len(guardedFields)) + for i, field := range guardedFields { + fs.Var(&values[i], field.flag, field.usage) + } + + if err := fs.Parse(args); err != nil { + return exitUsage + } + + if err := util.InitLog(*logLevel, "console"); err != nil { + fmt.Fprintf(os.Stderr, "init log: %v\n", err) + return exitFailure + } + + req, err := privilegedRequest(*profile, *username, values) + if err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + return exitUsage + } + + ctx, cancel := context.WithTimeout(context.Background(), oneShotTimeout) + defer cancel() + + if err := applyPrivilegedSettings(ctx, *daemonAddr, req, connect); err != nil { + fmt.Fprintf(os.Stderr, "apply settings: %v\n", err) + return exitFailure + } + + fmt.Fprintln(os.Stdout, elevate.AppliedMarker) + return exitOK +} + +// privilegedRequest builds the request from the flags that were given, and refuses +// one that asks for nothing. +func privilegedRequest(profile, username string, values []fieldValue) (*proto.SetConfigRequest, error) { + req := &proto.SetConfigRequest{ProfileName: profile, Username: username} + + given := 0 + for i, field := range guardedFields { + if !values[i].set { + continue + } + if err := field.write(req, values[i].value); err != nil { + return nil, fmt.Errorf("--%s: %w", field.flag, err) + } + given++ + } + if given == 0 { + return nil, errors.New("no setting to apply") + } + return req, nil +} + +func applyPrivilegedSettings( + ctx context.Context, + daemonAddr string, + req *proto.SetConfigRequest, + connect func(addr string) (proto.DaemonServiceClient, error), +) error { + client, err := connect(daemonAddr) + if err != nil { + return err + } + if _, err := client.SetConfig(ctx, req); err != nil { + // Unwrapped: the daemon's message is written for a person, and a refusal + // elevation cannot fix has to say so where the parent can read it off + // stderr. + return errors.New(gstatus.Convert(err).Message()) + } + return nil +} + +// interface guard: the one-shot's flags are flag.Value. +var _ flag.Value = (*fieldValue)(nil) diff --git a/client/ui/services/oneshot_test.go b/client/ui/services/oneshot_test.go new file mode 100644 index 000000000..f8eb43066 --- /dev/null +++ b/client/ui/services/oneshot_test.go @@ -0,0 +1,151 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "flag" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/proto" +) + +func TestIsPrivilegedSettingsRun(t *testing.T) { + tests := []struct { + name string + args []string + want bool + }{ + {name: "no arguments"}, + {name: "double dash", args: []string{"--" + FlagApplyPrivilegedSettings}, want: true}, + {name: "single dash", args: []string{"-" + FlagApplyPrivilegedSettings}, want: true}, + { + name: "among other flags", + args: []string{"--daemon-addr", "unix:///tmp/x.sock", "--" + FlagApplyPrivilegedSettings}, + want: true, + }, + // A marker, not a value: the caller never passes one, and reading a value + // would mean "--flag=false" started the one-shot too. + {name: "with a value", args: []string{"--" + FlagApplyPrivilegedSettings + "=true"}}, + {name: "unrelated flags", args: []string{"--log-level", "debug"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, IsPrivilegedSettingsRun(tt.args), "args %v", tt.args) + }) + } +} + +// What SetGuardedSettings renders has to be what the one-shot reads back, for every +// setting in the table. This is the property that keeps the two ends of an allowlist +// from drifting, so it is checked field by field rather than by example. +func TestGuardedFieldsRoundTrip(t *testing.T) { + on, off := true, false + tests := []struct { + name string + settings GuardedSettings + want func(*testing.T, *proto.SetConfigRequest) + }{ + { + name: "management url", + settings: GuardedSettings{ManagementURL: "https://mgmt.example.com:33073"}, + want: func(t *testing.T, req *proto.SetConfigRequest) { + assert.Equal(t, "https://mgmt.example.com:33073", req.GetManagementUrl()) + }, + }, + { + name: "ssh server on", + settings: GuardedSettings{ServerSSHAllowed: &on}, + want: func(t *testing.T, req *proto.SetConfigRequest) { + require.NotNil(t, req.ServerSSHAllowed) + assert.True(t, *req.ServerSSHAllowed) + }, + }, + { + name: "ssh root off", + settings: GuardedSettings{EnableSSHRoot: &off}, + want: func(t *testing.T, req *proto.SetConfigRequest) { + require.NotNil(t, req.EnableSSHRoot, "an explicit false must survive, not read as absent") + assert.False(t, *req.EnableSSHRoot) + }, + }, + { + name: "ssh auth off", + settings: GuardedSettings{DisableSSHAuth: &on}, + want: func(t *testing.T, req *proto.SetConfigRequest) { + require.NotNil(t, req.DisableSSHAuth) + assert.True(t, *req.DisableSSHAuth) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := parseRendered(t, tt.settings) + tt.want(t, req) + }) + } +} + +// A setting nobody asked about must not arrive at the daemon at all: sending its +// zero value would change it. +func TestGuardedFieldsCarryOnlyWhatWasAsked(t *testing.T) { + on := true + req := parseRendered(t, GuardedSettings{ProfileName: "work", EnableSSHRoot: &on}) + + assert.Equal(t, "work", req.GetProfileName(), "profile") + require.NotNil(t, req.EnableSSHRoot) + assert.Nil(t, req.ServerSSHAllowed, "untouched setting") + assert.Nil(t, req.DisableSSHAuth, "untouched setting") + assert.Empty(t, req.GetManagementUrl(), "untouched setting") +} + +func TestPrivilegedRequestRejectsAnEmptyChange(t *testing.T) { + _, err := privilegedRequest("default", "vma", make([]fieldValue, len(guardedFields))) + require.Error(t, err, "nothing to apply is not a request worth sending as root") +} + +// A value the table cannot parse is refused rather than guessed at. +func TestPrivilegedRequestRejectsAnUnparseableValue(t *testing.T) { + values := make([]fieldValue, len(guardedFields)) + for i, field := range guardedFields { + if field.flag != FlagEnableSSHRoot { + continue + } + require.NoError(t, values[i].Set("perhaps")) + } + + _, err := privilegedRequest("default", "vma", values) + require.Error(t, err) + assert.Contains(t, err.Error(), FlagEnableSSHRoot, "which flag was wrong") +} + +// parseRendered puts the settings through both ends: rendered as the arguments the +// elevated process is given, then parsed by a flag set registered from the same +// table, which is what the one-shot itself parses them with. Anything hand-rolled +// here would pin down a parser nothing uses. +func parseRendered(t *testing.T, p GuardedSettings) *proto.SetConfigRequest { + t.Helper() + + rendered := guardedSettings(p) + require.NotEmpty(t, rendered, "nothing rendered for %+v", p) + + args := make([]string, 0, len(rendered)) + for _, setting := range rendered { + args = append(args, setting.arg) + } + + fs := flag.NewFlagSet(t.Name(), flag.ContinueOnError) + values := make([]fieldValue, len(guardedFields)) + for i, field := range guardedFields { + fs.Var(&values[i], field.flag, field.usage) + } + require.NoError(t, fs.Parse(args), "the one-shot's own flag set must accept %v", args) + + req, err := privilegedRequest(p.ProfileName, p.Username, values) + require.NoError(t, err) + return req +} diff --git a/client/ui/services/settings.go b/client/ui/services/settings.go index e087cfec5..f12a35272 100644 --- a/client/ui/services/settings.go +++ b/client/ui/services/settings.go @@ -46,12 +46,19 @@ type Restrictions struct { } // Privilege tells the frontend whether this process may perform the changes the -// daemon restricts to root/administrator, and carries the command for each so a -// disabled control can show the way to do it. +// daemon restricts to root/administrator, whether it can ask the operating +// system for the privileges instead, and the command for each so a control that +// can do neither can still show the way. type Privilege struct { Privileged bool `json:"privileged"` - // Actor names what the operation requires ("root", "administrator privileges"). - Actor string `json:"actor"` + // ActorKey identifies the principal the operation requires without wording it, + // so the frontend can name it in the user's language: see + // ipcauth.PrivilegedActorKey. The words are not sent, because English ones + // cannot be dropped into a translated sentence. + ActorKey string `json:"actorKey"` + // CanElevate reports whether a guarded control can offer to authorize the + // change through the platform's own prompt: see SetGuardedSettings. + CanElevate bool `json:"canElevate"` // Commands equivalent to the settings the daemon guards, ready to copy. AllowSSHServer string `json:"allowSshServer"` EnableSSHRoot string `json:"enableSshRoot"` @@ -136,6 +143,9 @@ type Settings struct { // daemonAddr is where the daemon listens, used to tell whether it runs as // this user and would therefore authorize us: see Privilege. daemonAddr string + // elevator raises the platform's privilege prompt when a change needs more + // rights than this process has. + elevator elevator } func NewSettings(conn DaemonConn, translator ErrorTranslator, prefs LanguagePreference, daemonAddr string) *Settings { @@ -143,6 +153,7 @@ func NewSettings(conn DaemonConn, translator ErrorTranslator, prefs LanguagePref conn: conn, classifier: errorClassifier{translator: translator, prefs: prefs}, daemonAddr: daemonAddr, + elevator: osElevator{}, } } @@ -190,10 +201,10 @@ func (s *Settings) GetConfig(ctx context.Context, p ConfigParams) (Config, error }, nil } -func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) error { +func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) (SaveOutcome, error) { cli, err := s.conn.Client() if err != nil { - return err + return SaveOutcome{}, err } req := &proto.SetConfigRequest{ ProfileName: p.ProfileName, @@ -227,19 +238,94 @@ func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) error { SshJWTCacheTTL: p.SSHJWTCacheTTL, } if _, err := cli.SetConfig(ctx, req); err != nil { + if _, refused := privilegeErrorInfo(err); refused { + return s.setConfigElevated(ctx, p, req, err) + } // Classified so the frontend gets the daemon's guidance instead of the - // gRPC envelope, which is what a refused privileged change looks like. - return s.classifier.classify(err) + // gRPC envelope. + return SaveOutcome{}, s.classifier.classify(err) } - return nil + return SaveOutcome{}, nil +} + +// setConfigElevated answers a request the daemon refused for want of privileges by +// asking the user to authorize it, and sending it again if they do. It is the same +// offer the SSH settings make up front, for the changes a control cannot know are +// guarded until it is told: repointing a profile at another management server is +// only privileged while that host runs the SSH server. +// +// Two steps, because the elevated one-shot deliberately understands only the +// settings the daemon guards: it applies those, and the original request then goes +// through as this user, its privileged parts now asking for nothing that is not +// already stored. Nothing was applied by the refused attempt — the daemon decides +// before it writes — so there is no half-applied state to undo either way. +func (s *Settings) setConfigElevated(ctx context.Context, p SetConfigParams, req *proto.SetConfigRequest, refusal error) (SaveOutcome, error) { + if !s.canElevate() { + return SaveOutcome{}, s.classifier.classify(refusal) + } + + guarded, err := s.guardedChanges(ctx, p) + if err != nil { + log.Warnf("cannot tell which guarded settings this request changes: %v", err) + return SaveOutcome{}, s.classifier.classify(refusal) + } + if len(guardedSettings(guarded)) == 0 { + // Refused over something no prompt can settle, such as a control channel + // that carries no caller identity. Report the daemon's own guidance. + return SaveOutcome{}, s.classifier.classify(refusal) + } + + outcome, err := s.SetGuardedSettings(ctx, guarded) + if err != nil || outcome.Declined { + return outcome, err + } + + cli, err := s.conn.Client() + if err != nil { + return SaveOutcome{}, err + } + if _, err := cli.SetConfig(ctx, req); err != nil { + return SaveOutcome{}, s.classifier.classify(err) + } + return SaveOutcome{}, nil +} + +// guardedChanges is the guarded part of a request, reduced to what it actually +// changes. +// +// A settings form submits every field it holds, so a request restates values the +// daemon already has. Carrying those into the elevated run would spend one +// authorization on more than the user asked for, and a value that has gone stale +// since the form was loaded would spend it on something they never asked about. +func (s *Settings) guardedChanges(ctx context.Context, p SetConfigParams) (GuardedSettings, error) { + stored, err := s.GetConfig(ctx, ConfigParams{ProfileName: p.ProfileName, Username: p.Username}) + if err != nil { + return GuardedSettings{}, fmt.Errorf("read the stored config: %w", err) + } + + guarded := GuardedSettings{ + ProfileName: p.ProfileName, + Username: p.Username, + ServerSSHAllowed: changedFlag(p.ServerSSHAllowed, stored.ServerSSHAllowed), + EnableSSHRoot: changedFlag(p.EnableSSHRoot, stored.EnableSSHRoot), + DisableSSHAuth: changedFlag(p.DisableSSHAuth, stored.DisableSSHAuth), + ServerVNCAllowed: changedFlag(p.ServerVNCAllowed, stored.ServerVNCAllowed), + DisableVNCApproval: changedFlag(p.DisableVNCApproval, stored.DisableVNCApproval), + } + // An empty URL leaves the setting alone, which is the daemon's rule too. + if p.ManagementURL != "" && p.ManagementURL != stored.ManagementURL { + guarded.ManagementURL = p.ManagementURL + } + return guarded, nil } // Privilege reports whether this UI process could carry out the changes the -// daemon restricts to root/administrator, and the command that performs each of -// the ones users hit in the SSH and VNC settings. It applies the daemon's own rule to what it can -// see locally, so the frontend can present those controls as unavailable up front -// instead of letting a save fail. No daemon round-trip, so it also works while the -// daemon is down. +// daemon restricts to root/administrator, whether it can instead ask the +// operating system for the privileges when the user wants one of them, and the +// command that performs each of the ones users hit in the SSH and VNC settings. +// It applies the daemon's own rule to what it can see locally, so the frontend +// can decide up front how to present those controls instead of letting a save +// fail. No daemon round-trip, so it also works while the daemon is down. // // Being root or an elevated administrator is one way. The other is running as the // daemon's own user while the daemon is unprivileged, which the daemon accepts @@ -249,20 +335,21 @@ func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) error { func (s *Settings) Privilege() Privilege { id, err := ipcauth.CurrentProcessIdentity() if err != nil { - // Fail closed: report unprivileged, which only ever disables controls. + // Fail closed: report unprivileged, which only ever asks for more. log.Warnf("cannot read this process's identity, treating it as unprivileged: %v", err) - return newPrivilege(false) + return s.newPrivilege(false) } if id.IsPrivileged() { - return newPrivilege(true) + return s.newPrivilege(true) } - return newPrivilege(daemonaddr.DaemonRunsAsSelf(s.daemonAddr)) + return s.newPrivilege(daemonaddr.DaemonRunsAsSelf(s.daemonAddr)) } -func newPrivilege(privileged bool) Privilege { +func (s *Settings) newPrivilege(privileged bool) Privilege { return Privilege{ Privileged: privileged, - Actor: ipcauth.PrivilegedActor(), + ActorKey: ipcauth.PrivilegedActorKey(), + CanElevate: s.canElevate(), AllowSSHServer: ipcauth.UpCommand("--allow-server-ssh"), EnableSSHRoot: ipcauth.UpCommand("--enable-ssh-root"), DisableSSHAuth: ipcauth.UpCommand("--disable-ssh-auth"), @@ -271,6 +358,19 @@ func newPrivilege(privileged bool) Privilege { } } +// canElevate reports whether offering the platform's elevation prompt would get +// the user anywhere. It needs a mechanism to raise the prompt with and a control +// channel that tells the daemon who is calling: on loopback TCP the daemon +// refuses these changes to everybody, root included, so a prompt there would +// only waste the user's password. +func (s *Settings) canElevate() bool { + if !daemonaddr.CarriesIdentity(s.daemonAddr) { + log.Debugf("not offering elevation: the daemon address %s carries no caller identity", s.daemonAddr) + return false + } + return s.elevator.Available() +} + func (s *Settings) GetRestrictions(ctx context.Context) (Restrictions, error) { cli, err := s.conn.Client() if err != nil { @@ -303,6 +403,15 @@ func (s *Settings) GetRestrictions(ctx context.Context) (Restrictions, error) { return r, nil } +// changedFlag returns requested only when it differs from what is stored, so a +// setting the request merely restates is left out of the elevated run. +func changedFlag(requested *bool, stored bool) *bool { + if requested == nil || *requested == stored { + return nil + } + return requested +} + func applyMDMRestrictions(mdm *MDMFields, cfgResp *proto.GetConfigResponse) { managed := cfgResp.GetMDMManagedFields() if len(managed) == 0 { diff --git a/go.mod b/go.mod index 2c439b01c..20a816616 100644 --- a/go.mod +++ b/go.mod @@ -72,19 +72,20 @@ 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/jezek/xgb v1.3.0 github.com/kirides/go-d3d v1.0.1 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 @@ -102,6 +103,7 @@ 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/prometheus/client_model v0.6.2 github.com/quic-go/quic-go v0.59.1 github.com/redis/go-redis/v9 v9.7.3 github.com/rs/xid v1.3.0 @@ -239,8 +241,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 @@ -252,6 +254,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 @@ -292,7 +295,6 @@ require ( 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 diff --git a/go.sum b/go.sum index 12e382da5..68f112b3c 100644 --- a/go.sum +++ b/go.sum @@ -343,12 +343,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= @@ -420,6 +420,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= @@ -489,8 +491,8 @@ github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8 h1:pBxXEsxcsO3qVU github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8/go.mod h1:mFViabv4PpnoDw9w7W21a7xux6APA4q7KQZRsv4BCl8= github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51 h1:Ov4qdafATOgGMB1wbSuh+0aAHcwz9hdvB6VZjh1mVMI= github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51/go.mod h1:ZSIbPdBn5hePO8CpF1PekH2SfpTxg1PDhEwtbqZS7R8= -github.com/netbirdio/management-integrations/integrations v0.0.0-20260416123949-2355d972be42 h1:F3zS5fT9xzD1OFLfcdAE+3FfyiwjGukF1hvj0jErgs8= -github.com/netbirdio/management-integrations/integrations v0.0.0-20260416123949-2355d972be42/go.mod h1:n47r67ZSPgwSmT/Z1o48JjZQW9YJ6m/6Bd/uAXkL3Pg= +github.com/netbirdio/management-integrations/integrations v0.0.0-20260803100840-78e79ba20f87 h1:iJeUvSMC0BTpkw7u4JyWcY4/3dl7fEL9DR/TpKf2+1w= +github.com/netbirdio/management-integrations/integrations v0.0.0-20260803100840-78e79ba20f87/go.mod h1:pmsCPx1S0nuZRxCextGpc9AV4hLgGSuTsc4NMuwGeCo= github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 h1:3tHlFmhTdX9axERMVN63dqyFqnvuD+EMJHzM7mNGON8= github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM= github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ= diff --git a/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/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go
index 30de974a1..e74b17638 100644
--- a/management/internals/controllers/network_map/controller/controller.go
+++ b/management/internals/controllers/network_map/controller/controller.go
@@ -18,8 +18,10 @@ import (
 	"github.com/netbirdio/netbird/management/internals/controllers/network_map"
 	"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache"
 	"github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral"
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
 	"github.com/netbirdio/netbird/management/internals/server/config"
 	"github.com/netbirdio/netbird/management/internals/shared/grpc"
+	"github.com/netbirdio/netbird/management/internals/shared/requestbuffer"
 	"github.com/netbirdio/netbird/management/server/account"
 	"github.com/netbirdio/netbird/management/server/integrations/integrated_validator"
 	"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
@@ -30,12 +32,16 @@ import (
 	"github.com/netbirdio/netbird/management/server/telemetry"
 	"github.com/netbirdio/netbird/management/server/types"
 	sharedgrpc "github.com/netbirdio/netbird/shared/management/grpc"
+	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	"github.com/netbirdio/netbird/shared/management/proto"
 	"github.com/netbirdio/netbird/shared/management/status"
 	"github.com/netbirdio/netbird/util"
 	"github.com/netbirdio/netbird/version"
 )
 
+const defaultNetworkMapDataBufferInterval = 100 * time.Millisecond
+
 type Controller struct {
 	repo    Repository
 	metrics *metrics
@@ -61,6 +67,9 @@ type Controller struct {
 	serverSupportedSyncMessageVersion sharedgrpc.SyncMessageVersion
 
 	perAccountServerSupportedSyncMessageVersions map[string]sharedgrpc.SyncMessageVersion
+
+	nmdataStore  *networkmapdb.NetworkMapDBStoreImpl
+	nmdataBuffer *requestbuffer.Buffer[*networkmap.NetworkMapData]
 }
 
 type bufferUpdate struct {
@@ -78,13 +87,13 @@ type bufferAffectedUpdate struct {
 
 var _ network_map.Controller = (*Controller)(nil)
 
-func NewController(ctx context.Context, store store.Store, metrics telemetry.AppMetrics, peersUpdateManager network_map.PeersUpdateManager, requestBuffer account.RequestBuffer, integratedPeerValidator integrated_validator.IntegratedValidator, settingsManager settings.Manager, dnsDomain string, proxyController port_forwarding.Controller, ephemeralPeersManager ephemeral.Manager, config *config.Config) *Controller {
+func NewController(ctx context.Context, store store.Store, metrics telemetry.AppMetrics, peersUpdateManager network_map.PeersUpdateManager, requestBuffer account.RequestBuffer, integratedPeerValidator integrated_validator.IntegratedValidator, settingsManager settings.Manager, dnsDomain string, proxyController port_forwarding.Controller, ephemeralPeersManager ephemeral.Manager, config *config.Config, nmdataStore *networkmapdb.NetworkMapDBStoreImpl) *Controller {
 	nMetrics, err := newMetrics(metrics.UpdateChannelMetrics())
 	if err != nil {
 		log.Fatal(fmt.Errorf("error creating metrics: %w", err))
 	}
 
-	return &Controller{
+	c := &Controller{
 		repo:                    newRepository(store),
 		metrics:                 nMetrics,
 		accountManagerMetrics:   metrics.AccountManagerMetrics(),
@@ -99,7 +108,16 @@ func NewController(ctx context.Context, store store.Store, metrics telemetry.App
 		EphemeralPeersManager:                        ephemeralPeersManager,
 		serverSupportedSyncMessageVersion:            sharedgrpc.SyncMessageVersionFromConfig(config.HighestSupportedSyncMessageVersion),
 		perAccountServerSupportedSyncMessageVersions: sharedgrpc.SyncMessageVersionsFromMap(config.PerAccountHighestSupportedSyncMessageVersion),
+		nmdataStore:                                  nmdataStore,
 	}
+
+	if nmdataStore != nil {
+		interval := requestbuffer.Interval(ctx, "NB_NETWORK_MAP_DATA_BUFFER_INTERVAL", defaultNetworkMapDataBufferInterval)
+		log.WithContext(ctx).Infof("set network map data request buffer interval to %s", interval)
+		c.nmdataBuffer = requestbuffer.New(ctx, "network map data request buffer", interval, c.fetchNetworkMapData)
+	}
+
+	return c
 }
 
 func (c *Controller) OnPeerConnected(ctx context.Context, accountID string, peerID string) (chan *network_map.UpdateMessage, error) {
@@ -125,12 +143,12 @@ func (c *Controller) OnPeerDisconnected(ctx context.Context, accountID string, p
 
 // injectAllProxyPolicies prepares an account for the per-peer network-map
 // computation. It prepends the in-memory agent-network services synthesised
-// from the account's current provider/policy state to account.Services so
-// the existing InjectProxyPolicies + injectPrivateServicePolicies walks pick
-// them up alongside persisted reverse-proxy services. Synthesised services
-// are never persisted; the account is loaded fresh per cycle so re-prepending
-// is safe and idempotent. Accounts without agent-network providers get an
-// empty synth slice — no behaviour change.
+// from the account's current provider/policy state to account.Services, so the
+// twin store built from the account carries them alongside the persisted
+// reverse-proxy services and synthesises their ACLs. Synthesised services are
+// never persisted; the account is loaded fresh per cycle so re-prepending is
+// safe and idempotent. Accounts without agent-network providers get an empty
+// synth slice — no behaviour change.
 func (c *Controller) injectAllProxyPolicies(ctx context.Context, account *types.Account) {
 	synth, err := c.repo.SynthesizeAgentNetworkServices(ctx, account.Id)
 	if err != nil {
@@ -138,7 +156,26 @@ func (c *Controller) injectAllProxyPolicies(ctx context.Context, account *types.
 	} else if len(synth) > 0 {
 		account.Services = append(synth, account.Services...)
 	}
-	account.InjectProxyPolicies(ctx)
+}
+
+// proxyServicesFromRepo is the store-path counterpart of
+// injectAllProxyPolicies: the network-map store reads the policies table, which
+// never holds the proxy ACLs, so the twin gets the services they are
+// synthesised from — the synthesised agent-network ones first, exactly as the
+// account path orders them.
+func (c *Controller) proxyServicesFromRepo(ctx context.Context, accountID string) []*nmdata.Service {
+	persisted, err := c.repo.GetAccountServices(ctx, accountID)
+	if err != nil {
+		log.WithContext(ctx).Errorf("failed to get services for account %s: %v", accountID, err)
+		return nil
+	}
+
+	synth, err := c.repo.SynthesizeAgentNetworkServices(ctx, accountID)
+	if err != nil {
+		log.WithContext(ctx).Warnf("synthesise agent-network services for account %s: %v", accountID, err)
+	}
+
+	return types.TwinServices(append(synth, persisted...))
 }
 
 func (c *Controller) CountStreams() int {
@@ -147,6 +184,11 @@ func (c *Controller) CountStreams() int {
 
 func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error {
 	log.WithContext(ctx).Tracef("updating peers for account %s from %s", accountID, util.GetCallerName())
+
+	if nmData := c.getNetworkMapData(ctx, accountID); nmData != nil {
+		return c.sendUpdateAccountPeersFromData(ctx, accountID, reason, nmData)
+	}
+
 	account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
 	if err != nil {
 		return fmt.Errorf("failed to get account: %v", err)
@@ -167,7 +209,7 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
 		return nil
 	}
 
-	approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
+	approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
 	if err != nil {
 		return fmt.Errorf("failed to get validate peers: %v", err)
 	}
@@ -255,7 +297,7 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
 				// proxyNetworkMap rides the envelope as a ProxyPatch sidecar;
 				// the client merges it into Calculate()'s output the same
 				// way the legacy server did via NetworkMap.Merge.
-				update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort)
+				update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort)
 				c.metrics.CountToComponentSyncResponseDuration(time.Since(start))
 
 				c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
@@ -276,7 +318,7 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
 			}
 
 			start = time.Now()
-			update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, nmap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort)
+			update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, nmap, dnsDomain, postureChecks, dnsCache, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort)
 			c.metrics.CountToSyncResponseDuration(time.Since(start))
 
 			c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
@@ -294,6 +336,290 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
 	return nil
 }
 
+// sendUpdateAccountPeersFromData is the account-free variant of
+// sendUpdateAccountPeers: everything is computed from the network-map DB
+// store's twin data; only extra settings and validated peers are resolved at
+// runtime. Proxy network maps and policy injection, private-service zones,
+// group-to-user SSH mappings and forced routing-peer DNS resolution have no
+// DB-backed source yet and are omitted.
+func (c *Controller) sendUpdateAccountPeersFromData(ctx context.Context, accountID string, reason types.UpdateReason, nmData *networkmap.NetworkMapData) error {
+	peersToUpdate := c.connectedPeersFromData(nmData, nil)
+	if len(peersToUpdate) == 0 {
+		return nil
+	}
+	return c.sendUpdatesFromData(ctx, accountID, nmData, peersToUpdate, &reason)
+}
+
+// sendUpdateForAffectedPeersFromData is the account-free variant of
+// sendUpdateForAffectedPeers.
+func (c *Controller) sendUpdateForAffectedPeersFromData(ctx context.Context, accountID string, peerIDs []string, nmData *networkmap.NetworkMapData) error {
+	if len(peerIDs) == 0 {
+		log.WithContext(ctx).Tracef("sendUpdateForAffectedPeersFromData: no affected peers")
+		return nil
+	}
+
+	peersToUpdate := c.connectedPeersFromData(nmData, peerIDs)
+	if len(peersToUpdate) == 0 {
+		log.WithContext(ctx).Tracef("sendUpdateForAffectedPeersFromData: no peers to update (affected peers not found in data or no channels)")
+		return nil
+	}
+
+	log.WithContext(ctx).Tracef("sendUpdateForAffectedPeersFromData: sending network map to %d connected peers", len(peersToUpdate))
+
+	return c.sendUpdatesFromData(ctx, accountID, nmData, peersToUpdate, nil)
+}
+
+// connectedPeersFromData returns the peers with an open update channel. An
+// empty affected list means all peers; a non-empty list restricts the result
+// to those peer IDs.
+func (c *Controller) connectedPeersFromData(nmData *networkmap.NetworkMapData, affected []string) []*nmdata.Peer {
+	if len(affected) == 0 {
+		result := make([]*nmdata.Peer, 0, len(nmData.Peers))
+		for _, peer := range nmData.Peers {
+			if c.peersUpdateManager.HasChannel(peer.ID) {
+				result = append(result, peer)
+			}
+		}
+		return result
+	}
+
+	result := make([]*nmdata.Peer, 0, len(affected))
+	for _, peerID := range affected {
+		peer := nmData.Peers[peerID]
+		if peer == nil {
+			continue
+		}
+		if c.peersUpdateManager.HasChannel(peerID) {
+			result = append(result, peer)
+		}
+	}
+	return result
+}
+
+func (c *Controller) sendUpdatesFromData(ctx context.Context, accountID string, nmData *networkmap.NetworkMapData, peersToUpdate []*nmdata.Peer, reason *types.UpdateReason) error {
+	globalStart := time.Now()
+
+	extraSettings, err := c.settingsManager.GetExtraSettings(ctx, accountID)
+	if err != nil {
+		return fmt.Errorf("failed to get flow enabled status: %v", err)
+	}
+
+	dnsCache := &cache.DNSConfigCache{}
+	dnsDomain := c.getDNSDomainFromData(nmData.AccountSettings)
+	peersCustomZone := networkmap.PeersCustomZone(ctx, accountID, dnsDomain, nmData.Peers, IPv6AllowedPeersFromData(nmData))
+
+	dnsFwdPort := ComputeForwarderPortFromData(nmData.Peers, network_map.DnsForwarderPortMinVersion)
+
+	var wg sync.WaitGroup
+	semaphore := make(chan struct{}, 10)
+
+	for _, peer := range peersToUpdate {
+		if reason != nil && c.accountManagerMetrics != nil {
+			c.accountManagerMetrics.CountNmapTriggered(string(reason.Resource), string(reason.Operation))
+		}
+
+		wg.Add(1)
+		semaphore <- struct{}{}
+		go func(p *nmdata.Peer) {
+			defer wg.Done()
+			defer func() { <-semaphore }()
+
+			start := time.Now()
+
+			postureChecks := peerPostureChecksFromData(nmData, p.ID)
+
+			c.metrics.CountCalcPostureChecksDuration(time.Since(start))
+			start = time.Now()
+
+			peerGroups := maps.Keys(nmData.GetPeerGroups(p.ID))
+			var update *proto.SyncResponse
+
+			commonSyncMessageVersion := sharedgrpc.HighestCommonSyncMessageVersion(
+				c.perAccountOrGlobalSupportedSyncMessageVersions(accountID),
+				sharedgrpc.SyncMessageVersionFromConfig(&p.Meta.SyncMessageVersion))
+
+			log.WithContext(ctx).
+				WithFields(log.Fields{
+					"sync_message_version":        commonSyncMessageVersion,
+					"server_sync_message_version": c.perAccountOrGlobalSupportedSyncMessageVersions(accountID),
+					"peer_sync_message_version":   sharedgrpc.SyncMessageVersionFromConfig(&p.Meta.SyncMessageVersion),
+				}).Debug("common highest sync message version")
+
+			if commonSyncMessageVersion == sharedgrpc.ComponentNetworkMap {
+				components := nmData.GetPeerNetworkMapComponents(p.ID, peersCustomZone)
+
+				c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start))
+
+				start = time.Now()
+				update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, components, nil, dnsDomain, postureChecks, nmData.AccountSettings, extraSettings, peerGroups, dnsFwdPort)
+				c.metrics.CountToComponentSyncResponseDuration(time.Since(start))
+
+				c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
+					Update:      update,
+					MessageType: network_map.MessageTypeNetworkMap,
+				})
+
+				return
+			}
+
+			nmap := NetworkMapFromData(ctx, nmData, p.ID, peersCustomZone, c.accountManagerMetrics)
+
+			c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start))
+
+			start = time.Now()
+			update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, nmap, dnsDomain, postureChecks, dnsCache, nmData.AccountSettings, extraSettings, peerGroups, dnsFwdPort)
+			c.metrics.CountToSyncResponseDuration(time.Since(start))
+
+			c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
+				Update:      update,
+				MessageType: network_map.MessageTypeNetworkMap,
+			})
+		}(peer)
+	}
+
+	wg.Wait()
+	if c.accountManagerMetrics != nil {
+		c.accountManagerMetrics.CountUpdateAccountPeersDuration(time.Since(globalStart))
+	}
+
+	return nil
+}
+
+func (c *Controller) getNetworkMapData(ctx context.Context, accountID string) *networkmap.NetworkMapData {
+	if c.nmdataBuffer == nil {
+		return nil
+	}
+
+	nmData, err := c.nmdataBuffer.Get(ctx, accountID)
+	if err != nil {
+		log.WithContext(ctx).Errorf("failed to get network map data for account %s, falling back to account-based computation: %v", accountID, err)
+		return nil
+	}
+
+	return nmData
+}
+
+// fetchNetworkMapData reads the twin once per buffer window. Its result is
+// shared by every waiter of that window, so the mutating steps run here, before
+// it is handed out: the twin the callers see is read-only. Injected proxy
+// policies carry no posture checks, so precomputing after the injection yields
+// the same validation as precomputing before it.
+func (c *Controller) fetchNetworkMapData(ctx context.Context, accountID string) (*networkmap.NetworkMapData, error) {
+	nmData, err := c.nmdataStore.GetNetworkMapData(ctx, accountID)
+	if err != nil {
+		return nil, err
+	}
+
+	nmData.Services = c.proxyServicesFromRepo(ctx, accountID)
+	nmData.InjectProxyPolicies()
+	nmData.PrecomputePostureValidation()
+
+	return nmData, nil
+}
+
+func (c *Controller) getDNSDomainFromData(settings *nmdata.AccountSettingsInfo) string {
+	if settings == nil || settings.DNSDomain == "" {
+		return c.dnsDomain
+	}
+	return settings.DNSDomain
+}
+
+func IPv6AllowedPeersFromData(nmData *networkmap.NetworkMapData) map[string]struct{} {
+	result := make(map[string]struct{})
+	// An account with no IPv6-enabled group runs no overlay at all, so the
+	// embedded-proxy carve-out below has nothing to reach and stays shut.
+	if nmData.AccountSettings == nil || len(nmData.AccountSettings.IPv6EnabledGroups) == 0 {
+		return result
+	}
+	for _, groupID := range nmData.AccountSettings.IPv6EnabledGroups {
+		group := nmData.Groups[groupID]
+		if group == nil {
+			continue
+		}
+		for _, peerID := range group.Peers {
+			result[peerID] = struct{}{}
+		}
+	}
+	for id, p := range nmData.Peers {
+		if p != nil && p.ProxyMeta.Embedded {
+			result[id] = struct{}{}
+		}
+	}
+	return result
+}
+
+func NetworkMapFromData(ctx context.Context, nmData *networkmap.NetworkMapData, peerID string, peersCustomZone nmdata.CustomZone, metrics *telemetry.AccountManagerMetrics) *types.NetworkMap {
+	start := time.Now()
+
+	components := nmData.GetPeerNetworkMapComponents(peerID, peersCustomZone)
+	if components.IsEmpty() {
+		return &types.NetworkMap{Network: components.Network}
+	}
+	nm := types.CalculateNetworkMapFromComponents(ctx, components)
+
+	if metrics != nil {
+		objectCount := int64(len(nm.Peers) + len(nm.OfflinePeers) + len(nm.Routes) + len(nm.FirewallRules) + len(nm.RoutesFirewallRules))
+		metrics.CountNetworkMapObjects(objectCount)
+		metrics.CountGetPeerNetworkMapDuration(time.Since(start))
+	}
+
+	return nm
+}
+
+// peerPostureChecksFromData mirrors getPeerPostureChecks on the twin store. The
+// sync response only encodes process-check file paths, so only ProcessCheck is
+// converted back to the server posture type.
+func peerPostureChecksFromData(nmData *networkmap.NetworkMapData, peerID string) []*posture.Checks {
+	if len(nmData.PostureChecks) == 0 {
+		return nil
+	}
+
+	peerPostureChecks := make(map[string]*posture.Checks)
+	for _, policy := range nmData.Policies {
+		if policy == nil || !policy.Enabled || len(policy.SourcePostureChecks) == 0 {
+			continue
+		}
+		if !isPeerInPolicySourceGroupsFromData(nmData, peerID, policy) {
+			continue
+		}
+		for _, checkID := range policy.SourcePostureChecks {
+			twin := nmData.PostureChecks[checkID]
+			if twin == nil {
+				continue
+			}
+			peerPostureChecks[checkID] = postureChecksFromTwin(twin)
+		}
+	}
+
+	return maps.Values(peerPostureChecks)
+}
+
+func isPeerInPolicySourceGroupsFromData(nmData *networkmap.NetworkMapData, peerID string, policy *nmdata.Policy) bool {
+	for _, rule := range policy.Rules {
+		if rule == nil || !rule.Enabled {
+			continue
+		}
+		for _, groupID := range rule.Sources {
+			if group := nmData.Groups[groupID]; group != nil && slices.Contains(group.Peers, peerID) {
+				return true
+			}
+		}
+	}
+	return false
+}
+
+func postureChecksFromTwin(twin *nmdata.PostureChecks) *posture.Checks {
+	checks := &posture.Checks{ID: twin.ID}
+	if twin.Checks.ProcessCheck != nil {
+		processes := make([]posture.Process, 0, len(twin.Checks.ProcessCheck.Processes))
+		for _, p := range twin.Checks.ProcessCheck.Processes {
+			processes = append(processes, posture.Process{LinuxPath: p.LinuxPath, MacPath: p.MacPath, WindowsPath: p.WindowsPath})
+		}
+		checks.Checks.ProcessCheck = &posture.ProcessCheck{Processes: processes}
+	}
+	return checks
+}
+
 func (c *Controller) perAccountOrGlobalSupportedSyncMessageVersions(accountId string) sharedgrpc.SyncMessageVersion {
 	if perAccount, ok := c.perAccountServerSupportedSyncMessageVersions[accountId]; ok {
 		return perAccount
@@ -326,6 +652,10 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s
 		return nil
 	}
 
+	if nmData := c.getNetworkMapData(ctx, accountID); nmData != nil {
+		return c.sendUpdateForAffectedPeersFromData(ctx, accountID, peerIDs, nmData)
+	}
+
 	account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
 	if err != nil {
 		return fmt.Errorf("failed to get account: %v", err)
@@ -341,7 +671,7 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s
 
 	log.WithContext(ctx).Tracef("sendUpdateForAffectedPeers: sending network map to %d connected peers", len(peersToUpdate))
 
-	approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
+	approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
 	if err != nil {
 		return fmt.Errorf("failed to get validate peers: %v", err)
 	}
@@ -428,7 +758,7 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s
 				// proxyNetworkMap rides the envelope as a ProxyPatch sidecar;
 				// the client merges it into Calculate()'s output the same
 				// way the legacy server did via NetworkMap.Merge.
-				update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort)
+				update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort)
 				c.metrics.CountToComponentSyncResponseDuration(time.Since(start))
 
 				c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
@@ -449,7 +779,7 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s
 			}
 
 			start = time.Now()
-			update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, nmap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort)
+			update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, nmap, dnsDomain, postureChecks, dnsCache, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort)
 			c.metrics.CountToSyncResponseDuration(time.Since(start))
 
 			c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
@@ -506,7 +836,7 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe
 		return fmt.Errorf("peer %s doesn't exists in account %s", peerId, accountId)
 	}
 
-	approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
+	approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
 	if err != nil {
 		return fmt.Errorf("failed to get validated peers: %v", err)
 	}
@@ -566,7 +896,7 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe
 		// proxyNetworkMap rides the envelope as a ProxyPatch sidecar;
 		// the client merges it into Calculate()'s output the same
 		// way the legacy server did via NetworkMap.Merge.
-		update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, peer, nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, account.Settings, extraSettings, maps.Keys(peerGroups), dnsFwdPort)
+		update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(peer), nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSettings, maps.Keys(peerGroups), dnsFwdPort)
 
 		c.peersUpdateManager.SendUpdate(ctx, peer.ID, &network_map.UpdateMessage{
 			Update:      update,
@@ -583,7 +913,7 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe
 		nmap.Merge(proxyNetworkMap)
 	}
 
-	update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, peer, nil, nil, nmap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSettings, maps.Keys(peerGroups), dnsFwdPort)
+	update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(peer), nil, nil, nmap, dnsDomain, postureChecks, dnsCache, types.TwinAccountSettings(account.Settings), extraSettings, maps.Keys(peerGroups), dnsFwdPort)
 
 	c.peersUpdateManager.SendUpdate(ctx, peer.ID, &network_map.UpdateMessage{
 		Update:      update,
@@ -643,7 +973,11 @@ func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequi
 		if err != nil {
 			return nil, nil, nil, nil, 0, err
 		}
-		return peer, &types.NetworkMapComponents{Network: network.Copy()}, nil, nil, 0, nil
+		return peer, &types.NetworkMapComponents{Network: types.TwinNetwork(network)}, nil, nil, 0, nil
+	}
+
+	if nmData := c.getNetworkMapData(ctx, accountID); nmData != nil {
+		return c.getValidatedPeerWithComponentsFromData(ctx, accountID, peer, nmData)
 	}
 
 	account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
@@ -658,7 +992,7 @@ func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequi
 
 	c.injectAllProxyPolicies(ctx, account)
 
-	approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
+	approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
 	if err != nil {
 		return nil, nil, nil, nil, 0, err
 	}
@@ -695,6 +1029,21 @@ func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequi
 	return peer, components, proxyNetworkMaps[peer.ID], postureChecks, dnsFwdPort, nil
 }
 
+// getValidatedPeerWithComponentsFromData is the account-free variant of
+// GetValidatedPeerWithComponents. The proxy network map fragment is omitted
+// like on the other nmdata paths.
+func (c *Controller) getValidatedPeerWithComponentsFromData(ctx context.Context, accountID string, peer *nbpeer.Peer, nmData *networkmap.NetworkMapData) (*nbpeer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*posture.Checks, int64, error) {
+	postureChecks := peerPostureChecksFromData(nmData, peer.ID)
+
+	dnsDomain := c.getDNSDomainFromData(nmData.AccountSettings)
+	peersCustomZone := networkmap.PeersCustomZone(ctx, accountID, dnsDomain, nmData.Peers, IPv6AllowedPeersFromData(nmData))
+
+	components := nmData.GetPeerNetworkMapComponents(peer.ID, peersCustomZone)
+	dnsFwdPort := ComputeForwarderPortFromData(nmData.Peers, network_map.DnsForwarderPortMinVersion)
+
+	return peer, components, nil, postureChecks, dnsFwdPort, nil
+}
+
 // BufferUpdateAffectedPeers accumulates peer IDs and flushes them after the buffer interval.
 func (c *Controller) BufferUpdateAffectedPeers(ctx context.Context, accountID string, peerIDs []string, reason types.UpdateReason) error {
 	if len(peerIDs) == 0 {
@@ -801,11 +1150,15 @@ func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresAppr
 		}
 
 		emptyMap := &types.NetworkMap{
-			Network: network.Copy(),
+			Network: types.TwinNetwork(network),
 		}
 		return emptyMap, nil, 0, nil
 	}
 
+	if nmData := c.getNetworkMapData(ctx, accountID); nmData != nil {
+		return c.getValidatedPeerWithMapFromData(ctx, accountID, peerID, nmData)
+	}
+
 	account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
 	if err != nil {
 		return nil, nil, 0, err
@@ -813,7 +1166,7 @@ func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresAppr
 
 	c.injectAllProxyPolicies(ctx, account)
 
-	approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
+	approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
 	if err != nil {
 		return nil, nil, 0, err
 	}
@@ -853,6 +1206,21 @@ func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresAppr
 	return networkMap, postureChecks, dnsFwdPort, nil
 }
 
+// getValidatedPeerWithMapFromData is the account-free variant of
+// GetValidatedPeerWithMap. The proxy network map fragment is omitted like on
+// the other nmdata paths.
+func (c *Controller) getValidatedPeerWithMapFromData(ctx context.Context, accountID string, peerID string, nmData *networkmap.NetworkMapData) (*types.NetworkMap, []*posture.Checks, int64, error) {
+	postureChecks := peerPostureChecksFromData(nmData, peerID)
+
+	dnsDomain := c.getDNSDomainFromData(nmData.AccountSettings)
+	peersCustomZone := networkmap.PeersCustomZone(ctx, accountID, dnsDomain, nmData.Peers, IPv6AllowedPeersFromData(nmData))
+
+	networkMap := NetworkMapFromData(ctx, nmData, peerID, peersCustomZone, c.accountManagerMetrics)
+	dnsFwdPort := ComputeForwarderPortFromData(nmData.Peers, network_map.DnsForwarderPortMinVersion)
+
+	return networkMap, postureChecks, dnsFwdPort, nil
+}
+
 // GetDNSDomain returns the configured dnsDomain
 func (c *Controller) GetDNSDomain(settings *types.Settings) string {
 	if settings == nil {
@@ -915,20 +1283,36 @@ func (c *Controller) StartWarmup(ctx context.Context) {
 // computeForwarderPort checks if all peers in the account have updated to a specific version or newer.
 // If all peers have the required version, it returns the new well-known port (22054), otherwise returns 0.
 func computeForwarderPort(peers []*nbpeer.Peer, requiredVersion string) int64 {
-	if len(peers) == 0 {
+	versions := make([]string, 0, len(peers))
+	for _, peer := range peers {
+		versions = append(versions, peer.Meta.WtVersion)
+	}
+	return computeForwarderPortFromVersions(versions, requiredVersion)
+}
+
+func ComputeForwarderPortFromData(peers map[string]*nmdata.Peer, requiredVersion string) int64 {
+	versions := make([]string, 0, len(peers))
+	for _, peer := range peers {
+		versions = append(versions, peer.Meta.WtVersion)
+	}
+	return computeForwarderPortFromVersions(versions, requiredVersion)
+}
+
+func computeForwarderPortFromVersions(wtVersions []string, requiredVersion string) int64 {
+	if len(wtVersions) == 0 {
 		return int64(network_map.OldForwarderPort)
 	}
 
 	reqVer := semver.Canonical(requiredVersion)
 
 	// Check if all peers have the required version or newer
-	for _, peer := range peers {
+	for _, wtVersion := range wtVersions {
 
 		// Development version is always supported
-		if version.IsDevelopmentVersion(peer.Meta.WtVersion) {
+		if version.IsDevelopmentVersion(wtVersion) {
 			continue
 		}
-		peerVersion := semver.Canonical("v" + peer.Meta.WtVersion)
+		peerVersion := semver.Canonical("v" + wtVersion)
 		if peerVersion == "" {
 			// If any peer doesn't have version info, return 0
 			return int64(network_map.OldForwarderPort)
@@ -1062,7 +1446,12 @@ func (c *Controller) GetNetworkMap(ctx context.Context, peerID string) (*types.N
 		groups[groupID] = group.Peers
 	}
 
-	validatedPeers, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
+	extraSettings, err := c.settingsManager.GetExtraSettings(ctx, account.Id)
+	if err != nil {
+		return nil, err
+	}
+
+	validatedPeers, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), extraSettings)
 	if err != nil {
 		return nil, err
 	}
diff --git a/management/internals/controllers/network_map/controller/ipv6_allowed_test.go b/management/internals/controllers/network_map/controller/ipv6_allowed_test.go
new file mode 100644
index 000000000..c80f3b734
--- /dev/null
+++ b/management/internals/controllers/network_map/controller/ipv6_allowed_test.go
@@ -0,0 +1,47 @@
+package controller
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+// The account-side builder (types.Account.peerIPv6AllowedSet) is the reference:
+// an account with no IPv6-enabled group runs no IPv6 overlay at all, embedded
+// proxy peers included — see TestPeerIPv6AllowedEmbeddedProxy. Both builders
+// gate the same AAAA records, so the store-backed one has to agree.
+func TestIPv6AllowedPeersFromData(t *testing.T) {
+	data := func(enabledGroups []string) *networkmap.NetworkMapData {
+		return &networkmap.NetworkMapData{
+			AccountSettings: &nmdata.AccountSettingsInfo{IPv6EnabledGroups: enabledGroups},
+			Peers: map[string]*nmdata.Peer{
+				"peer1":  {ID: "peer1"},
+				"lonely": {ID: "lonely"},
+				"proxy":  {ID: "proxy", ProxyMeta: nmdata.ProxyMeta{Embedded: true, Cluster: "netbird.test"}},
+			},
+			Groups: map[string]*nmdata.Group{
+				"group-devs": {ID: "group-devs", Peers: []string{"peer1"}},
+			},
+		}
+	}
+
+	t.Run("embedded proxy allowed when any v6 group exists, without group membership", func(t *testing.T) {
+		allowed := IPv6AllowedPeersFromData(data([]string{"group-devs"}))
+		assert.Contains(t, allowed, "proxy", "embedded proxy participates in v6 overlay")
+		assert.Contains(t, allowed, "peer1", "regular peer in enabled group still allowed")
+	})
+
+	t.Run("embedded proxy denied when no v6 group enabled", func(t *testing.T) {
+		allowed := IPv6AllowedPeersFromData(data(nil))
+		assert.NotContains(t, allowed, "proxy", "v6 disabled account-wide denies embedded proxies too")
+		assert.Empty(t, allowed, "no peer participates in the v6 overlay")
+	})
+
+	t.Run("non-embedded peer outside any enabled group is not pulled in", func(t *testing.T) {
+		allowed := IPv6AllowedPeersFromData(data([]string{"group-devs"}))
+		assert.NotContains(t, allowed, "lonely", "embedded-proxy bypass must not leak to regular peers")
+	})
+}
diff --git a/management/internals/controllers/network_map/controller/repository.go b/management/internals/controllers/network_map/controller/repository.go
index bd8ed4e80..5c3195f16 100644
--- a/management/internals/controllers/network_map/controller/repository.go
+++ b/management/internals/controllers/network_map/controller/repository.go
@@ -24,6 +24,7 @@ type Repository interface {
 	// services synthesised from the account's agent-network provider/policy
 	// state. Empty for accounts without agent-network providers.
 	SynthesizeAgentNetworkServices(ctx context.Context, accountID string) ([]*service.Service, error)
+	GetAccountServices(ctx context.Context, accountID string) ([]*service.Service, error)
 }
 
 type repository struct {
@@ -62,6 +63,10 @@ func (r *repository) SynthesizeAgentNetworkServices(ctx context.Context, account
 	return agentnetwork.SynthesizeServices(ctx, r.store, accountID)
 }
 
+func (r *repository) GetAccountServices(ctx context.Context, accountID string) ([]*service.Service, error) {
+	return r.store.GetAccountServices(ctx, store.LockingStrengthNone, accountID)
+}
+
 func (r *repository) GetAccountZones(ctx context.Context, accountID string) ([]*zones.Zone, error) {
 	return r.store.GetAccountZones(ctx, store.LockingStrengthNone, accountID)
 }
diff --git a/management/internals/controllers/network_map/nmaptest/canonicalize.go b/management/internals/controllers/network_map/nmaptest/canonicalize.go
new file mode 100644
index 000000000..ec6614d81
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/canonicalize.go
@@ -0,0 +1,380 @@
+package nmaptest
+
+import (
+	"bytes"
+	"cmp"
+	"fmt"
+	"slices"
+	"sort"
+	"strconv"
+	"strings"
+
+	"github.com/netbirdio/netbird/shared/management/proto"
+)
+
+// normalizeIDSpace replaces policy and route identifiers with positional
+// placeholders so a comparison can reach everything else.
+//
+// This exists only because the envelope round-trip currently substitutes each
+// internal xid with the object's public id, which is a tracked defect and not a
+// licence to differ: those identifiers reach the server again inside flow
+// events, which resolve them by internal id, so the substitution silently
+// breaks flow attribution for component-format peers. TestIDSpaceMatches
+// asserts the equality that must eventually hold; this erasure keeps the other
+// 40-odd cases reporting on semantics meanwhile. When the id space is unified,
+// delete this and the calls to it — every case should still pass.
+//
+// Cardinality and cross-references survive the erasure: two rules under one
+// policy still share a token and a route firewall rule still points at its
+// route, so a path that drops a policy, merges two policies, or misattributes a
+// rule to the wrong route still fails.
+func normalizeIDSpace(nm *proto.NetworkMap) {
+	if nm == nil {
+		return
+	}
+	policies := newTokenizer("policy")
+	routes := newTokenizer("route")
+
+	for _, i := range orderBy(nm.Routes, routeKeyWithoutID) {
+		nm.Routes[i].ID = routes.get(nm.Routes[i].ID)
+	}
+	for _, i := range orderBy(nm.FirewallRules, firewallKeyWithoutPolicy) {
+		r := nm.FirewallRules[i]
+		if len(r.PolicyID) > 0 {
+			r.PolicyID = []byte(policies.get(string(r.PolicyID)))
+		}
+	}
+	for _, i := range orderBy(nm.RoutesFirewallRules, routeFirewallKeyWithoutIDs) {
+		r := nm.RoutesFirewallRules[i]
+		if len(r.PolicyID) > 0 {
+			r.PolicyID = []byte(policies.get(string(r.PolicyID)))
+		}
+		r.RouteID = routes.get(r.RouteID)
+	}
+}
+
+// tokenizer maps identifiers to positional placeholders in order of first use.
+type tokenizer struct {
+	prefix string
+	seen   map[string]string
+}
+
+func newTokenizer(prefix string) *tokenizer {
+	return &tokenizer{prefix: prefix, seen: make(map[string]string)}
+}
+
+func (t *tokenizer) get(id string) string {
+	if id == "" {
+		return ""
+	}
+	if tok, ok := t.seen[id]; ok {
+		return tok
+	}
+	tok := fmt.Sprintf("%s#%d", t.prefix, len(t.seen))
+	t.seen[id] = tok
+	return tok
+}
+
+// orderBy returns indices sorted by key, so placeholder numbering does not
+// depend on the identifiers being erased.
+func orderBy[T any](items []T, key func(T) string) []int {
+	idx := make([]int, len(items))
+	for i := range idx {
+		idx[i] = i
+	}
+	sort.SliceStable(idx, func(a, b int) bool { return key(items[idx[a]]) < key(items[idx[b]]) })
+	return idx
+}
+
+func routeKeyWithoutID(r *proto.Route) string {
+	if r == nil {
+		return ""
+	}
+	return fmt.Sprintf("%s|%s|%s|%d|%d|%t|%t|%v",
+		r.Network, r.NetID, r.Peer, r.Metric, r.NetworkType, r.Masquerade, r.KeepRoute, r.Domains)
+}
+
+func firewallKeyWithoutPolicy(r *proto.FirewallRule) string {
+	if r == nil {
+		return ""
+	}
+	return fmt.Sprintf("%s|%d|%d|%d|%s|%s|%v",
+		r.PeerIP, r.Direction, r.Action, r.Protocol, r.Port, portInfoKey(r.PortInfo), r.SourcePrefixes) //nolint:staticcheck
+}
+
+func routeFirewallKeyWithoutIDs(r *proto.RouteFirewallRule) string {
+	if r == nil {
+		return ""
+	}
+	return fmt.Sprintf("%s|%d|%d|%s|%v|%v|%t|%d",
+		r.Destination, r.Protocol, r.Action, portInfoKey(r.PortInfo), r.Domains, r.SourceRanges, r.IsDynamic, r.CustomProtocol)
+}
+
+// canonicalize sorts every repeated field of the NetworkMap by a stable key.
+// The producing paths iterate Go maps while building these slices, so order
+// can differ between runs even when the content is identical; comparing
+// without this reports noise.
+func canonicalize(nm *proto.NetworkMap) {
+	if nm == nil {
+		return
+	}
+	slices.SortFunc(nm.RemotePeers, cmpRemotePeer)
+	slices.SortFunc(nm.OfflinePeers, cmpRemotePeer)
+	slices.SortFunc(nm.Routes, cmpRoute)
+	slices.SortFunc(nm.FirewallRules, cmpFirewallRule)
+	slices.SortFunc(nm.RoutesFirewallRules, cmpRouteFirewallRule)
+	slices.SortFunc(nm.ForwardingRules, cmpForwardingRule)
+
+	for _, r := range nm.FirewallRules {
+		slices.SortFunc(r.SourcePrefixes, bytes.Compare)
+	}
+	for _, r := range nm.RoutesFirewallRules {
+		slices.Sort(r.SourceRanges)
+	}
+	canonicalizeDNSConfig(nm.DNSConfig)
+	canonicalizeSSHAuth(nm.SshAuth)
+}
+
+func canonicalizeDNSConfig(d *proto.DNSConfig) {
+	if d == nil {
+		return
+	}
+	for _, g := range d.NameServerGroups {
+		if g == nil {
+			continue
+		}
+		slices.Sort(g.Domains)
+		slices.SortFunc(g.NameServers, func(a, b *proto.NameServer) int {
+			if a == nil || b == nil {
+				return boolCmp(a == nil, b == nil)
+			}
+			if c := cmp.Compare(a.IP, b.IP); c != 0 {
+				return c
+			}
+			if c := cmp.Compare(a.Port, b.Port); c != 0 {
+				return c
+			}
+			return cmp.Compare(a.NSType, b.NSType)
+		})
+	}
+	slices.SortFunc(d.NameServerGroups, func(a, b *proto.NameServerGroup) int {
+		return cmp.Compare(nsgKey(a), nsgKey(b))
+	})
+	for _, z := range d.CustomZones {
+		if z == nil {
+			continue
+		}
+		slices.SortFunc(z.Records, cmpSimpleRecord)
+	}
+	slices.SortFunc(d.CustomZones, func(a, b *proto.CustomZone) int {
+		if a == nil || b == nil {
+			return boolCmp(a == nil, b == nil)
+		}
+		return cmp.Compare(a.Domain, b.Domain)
+	})
+}
+
+// canonicalizeSSHAuth sorts AuthorizedUsers and re-keys MachineUsers.Indexes
+// against the new ordering, preserving which machine user maps to which hashes.
+func canonicalizeSSHAuth(s *proto.SSHAuth) {
+	if s == nil || len(s.AuthorizedUsers) == 0 {
+		return
+	}
+	type hashed struct {
+		bytes []byte
+		old   uint32
+	}
+	entries := make([]hashed, len(s.AuthorizedUsers))
+	for i, b := range s.AuthorizedUsers {
+		entries[i] = hashed{bytes: b, old: uint32(i)}
+	}
+	slices.SortFunc(entries, func(a, b hashed) int { return bytes.Compare(a.bytes, b.bytes) })
+
+	remap := make(map[uint32]uint32, len(entries))
+	sorted := make([][]byte, len(entries))
+	for newIdx, e := range entries {
+		remap[e.old] = uint32(newIdx)
+		sorted[newIdx] = e.bytes
+	}
+	s.AuthorizedUsers = sorted
+
+	for _, mu := range s.MachineUsers {
+		if mu == nil {
+			continue
+		}
+		for i, oldIdx := range mu.Indexes {
+			if newIdx, ok := remap[oldIdx]; ok {
+				mu.Indexes[i] = newIdx
+			}
+		}
+		slices.Sort(mu.Indexes)
+	}
+}
+
+func boolCmp(a, b bool) int {
+	if a == b {
+		return 0
+	}
+	if a {
+		return 1
+	}
+	return -1
+}
+
+func nsgKey(g *proto.NameServerGroup) string {
+	if g == nil {
+		return ""
+	}
+	var parts []string
+	for _, ns := range g.NameServers {
+		if ns == nil {
+			continue
+		}
+		parts = append(parts, ns.IP+":"+strconv.FormatInt(ns.Port, 10)+":"+strconv.FormatInt(ns.NSType, 10))
+	}
+	slices.Sort(parts)
+	key := strings.Join(parts, ",")
+	domains := append([]string(nil), g.Domains...)
+	slices.Sort(domains)
+	key += "|" + strings.Join(domains, "|")
+	if g.Primary {
+		key += "|P"
+	}
+	if g.SearchDomainsEnabled {
+		key += "|S"
+	}
+	return key
+}
+
+func cmpSimpleRecord(a, b *proto.SimpleRecord) int {
+	if a == nil || b == nil {
+		return boolCmp(a == nil, b == nil)
+	}
+	if c := cmp.Compare(a.Name, b.Name); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.Type, b.Type); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.Class, b.Class); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.RData, b.RData); c != 0 {
+		return c
+	}
+	return cmp.Compare(a.TTL, b.TTL)
+}
+
+func cmpRemotePeer(a, b *proto.RemotePeerConfig) int {
+	if a == nil || b == nil {
+		return boolCmp(a == nil, b == nil)
+	}
+	return cmp.Compare(a.WgPubKey, b.WgPubKey)
+}
+
+func cmpRoute(a, b *proto.Route) int {
+	if a == nil || b == nil {
+		return boolCmp(a == nil, b == nil)
+	}
+	if c := cmp.Compare(a.ID, b.ID); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.NetID, b.NetID); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.Network, b.Network); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.Peer, b.Peer); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.Metric, b.Metric); c != 0 {
+		return c
+	}
+	return slices.Compare(a.Domains, b.Domains)
+}
+
+func cmpFirewallRule(a, b *proto.FirewallRule) int {
+	if a == nil || b == nil {
+		return boolCmp(a == nil, b == nil)
+	}
+	if c := bytes.Compare(a.PolicyID, b.PolicyID); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.PeerIP, b.PeerIP); c != 0 { //nolint:staticcheck
+		return c
+	}
+	if c := cmp.Compare(int32(a.Direction), int32(b.Direction)); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(int32(a.Action), int32(b.Action)); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(int32(a.Protocol), int32(b.Protocol)); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.Port, b.Port); c != 0 {
+		return c
+	}
+	return cmp.Compare(portInfoKey(a.PortInfo), portInfoKey(b.PortInfo))
+}
+
+func cmpRouteFirewallRule(a, b *proto.RouteFirewallRule) int {
+	if a == nil || b == nil {
+		return boolCmp(a == nil, b == nil)
+	}
+	if c := bytes.Compare(a.PolicyID, b.PolicyID); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.RouteID, b.RouteID); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.Destination, b.Destination); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(int32(a.Protocol), int32(b.Protocol)); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(portInfoKey(a.PortInfo), portInfoKey(b.PortInfo)); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(int32(a.Action), int32(b.Action)); c != 0 {
+		return c
+	}
+	if c := slices.Compare(a.Domains, b.Domains); c != 0 {
+		return c
+	}
+	if c := slices.Compare(a.SourceRanges, b.SourceRanges); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.CustomProtocol, b.CustomProtocol); c != 0 {
+		return c
+	}
+	return boolCmp(a.IsDynamic, b.IsDynamic)
+}
+
+func cmpForwardingRule(a, b *proto.ForwardingRule) int {
+	if a == nil || b == nil {
+		return boolCmp(a == nil, b == nil)
+	}
+	if c := cmp.Compare(int32(a.Protocol), int32(b.Protocol)); c != 0 {
+		return c
+	}
+	return bytes.Compare(a.TranslatedAddress, b.TranslatedAddress)
+}
+
+func portInfoKey(pi *proto.PortInfo) string {
+	if pi == nil {
+		return ""
+	}
+	switch sel := pi.PortSelection.(type) {
+	case *proto.PortInfo_Port:
+		return "P" + strconv.FormatUint(uint64(sel.Port), 10)
+	case *proto.PortInfo_Range_:
+		if sel.Range == nil {
+			return "R"
+		}
+		return "R" + strconv.FormatUint(uint64(sel.Range.Start), 10) + "-" + strconv.FormatUint(uint64(sel.Range.End), 10)
+	}
+	return ""
+}
diff --git a/management/internals/controllers/network_map/nmaptest/fixture.go b/management/internals/controllers/network_map/nmaptest/fixture.go
new file mode 100644
index 000000000..d56285f95
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/fixture.go
@@ -0,0 +1,218 @@
+package nmaptest
+
+import (
+	"crypto/sha256"
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"net"
+	"os"
+
+	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+// LoadNetworkMapData reads a fixture holding the NetworkMapData the store
+// would return for one account. Unknown fields are rejected so fixture typos
+// fail loudly instead of silently testing a default.
+func LoadNetworkMapData(path string) (*networkmap.NetworkMapData, error) {
+	f, err := os.Open(path)
+	if err != nil {
+		return nil, fmt.Errorf("open fixture: %w", err)
+	}
+	defer f.Close()
+
+	dec := json.NewDecoder(f)
+	dec.DisallowUnknownFields()
+	var nmData networkmap.NetworkMapData
+	if err := dec.Decode(&nmData); err != nil {
+		return nil, fmt.Errorf("decode fixture %s: %w", path, err)
+	}
+	return &nmData, nil
+}
+
+var defaultNetworkNet = func() net.IPNet {
+	_, ipnet, err := net.ParseCIDR("100.64.0.0/10")
+	if err != nil {
+		panic(err)
+	}
+	return *ipnet
+}()
+
+// applyFixtureDefaults fills the boilerplate a fixture may omit. Map-keyed
+// objects inherit their key as ID, peers get a deterministic WG-shaped key
+// and their ID as DNS label, PublicIDs default to the internal ID (the
+// envelope encoder puts public IDs on the wire and silently degrades on
+// empty ones), and a nil ValidatedPeers validates every peer — production
+// fills it through the integrated validator, not the store.
+func applyFixtureDefaults(nmData *networkmap.NetworkMapData) {
+	if nmData.Network == nil {
+		nmData.Network = &nmdata.Network{}
+	}
+	if nmData.Network.Identifier == "" {
+		nmData.Network.Identifier = "network"
+	}
+	if nmData.Network.Net.IP == nil {
+		nmData.Network.Net = defaultNetworkNet
+	}
+	if nmData.AccountSettings == nil {
+		nmData.AccountSettings = &nmdata.AccountSettingsInfo{}
+	}
+	if nmData.DNSSettings == nil {
+		nmData.DNSSettings = &nmdata.DNSSettings{}
+	}
+
+	for id, p := range nmData.Peers {
+		if p == nil {
+			continue
+		}
+		if p.ID == "" {
+			p.ID = id
+		}
+		if p.Key == "" {
+			p.Key = derivedWgKey(p.ID)
+		}
+		if p.DNSLabel == "" {
+			p.DNSLabel = p.ID
+		}
+	}
+
+	for id, g := range nmData.Groups {
+		if g == nil {
+			continue
+		}
+		if g.ID == "" {
+			g.ID = id
+		}
+		if g.Name == "" {
+			g.Name = g.ID
+		}
+		if g.PublicID == "" {
+			g.PublicID = g.ID
+		}
+	}
+
+	for _, policy := range nmData.Policies {
+		defaultPolicyIDs(policy)
+	}
+	resolveResourcePolicyRefs(nmData)
+
+	for _, r := range nmData.Routes {
+		if r != nil && r.PublicID == "" {
+			r.PublicID = r.ID
+		}
+	}
+	for _, nsg := range nmData.NameServerGroups {
+		if nsg != nil && nsg.PublicID == "" {
+			nsg.PublicID = nsg.ID
+		}
+	}
+	for _, res := range nmData.NetworkResources {
+		if res == nil {
+			continue
+		}
+		if res.PublicID == "" {
+			res.PublicID = res.ID
+		}
+		defaultXIDMapping(&nmData.NetworkXIDToPublicID, res.NetworkID)
+	}
+	for networkID, routers := range nmData.Routers {
+		defaultXIDMapping(&nmData.NetworkXIDToPublicID, networkID)
+		for _, router := range routers {
+			if router != nil && router.PublicID == "" {
+				router.PublicID = networkID
+			}
+		}
+	}
+
+	for id, pc := range nmData.PostureChecks {
+		if pc == nil {
+			continue
+		}
+		if pc.ID == "" {
+			pc.ID = id
+		}
+		defaultXIDMapping(&nmData.PostureCheckXIDToPublicID, pc.ID)
+	}
+
+	if nmData.ValidatedPeers == nil {
+		nmData.ValidatedPeers = make(map[string]struct{}, len(nmData.Peers))
+		for id := range nmData.Peers {
+			nmData.ValidatedPeers[id] = struct{}{}
+		}
+	}
+}
+
+// resolveResourcePolicyRefs lets a fixture name an account policy by ID in
+// ResourcePolicies — {"ID": "pol-x"} with no rules — instead of repeating it.
+// The real store puts the same policy pointer in both places, which is what
+// resolving the reference reproduces.
+func resolveResourcePolicyRefs(nmData *networkmap.NetworkMapData) {
+	byID := make(map[string]*nmdata.Policy, len(nmData.Policies))
+	for _, policy := range nmData.Policies {
+		if policy != nil && policy.ID != "" {
+			byID[policy.ID] = policy
+		}
+	}
+
+	for _, policies := range nmData.ResourcePolicies {
+		for i, policy := range policies {
+			if policy == nil {
+				continue
+			}
+			if len(policy.Rules) == 0 {
+				if full, ok := byID[policy.ID]; ok {
+					policies[i] = full
+					continue
+				}
+			}
+			defaultPolicyIDs(policy)
+		}
+	}
+}
+
+func defaultPolicyIDs(policy *nmdata.Policy) {
+	if policy == nil {
+		return
+	}
+	if policy.PublicID == "" {
+		policy.PublicID = policy.ID
+	}
+	for i, rule := range policy.Rules {
+		if rule == nil {
+			continue
+		}
+		if rule.PolicyID == "" {
+			rule.PolicyID = policy.ID
+		}
+		if rule.ID == "" {
+			// Production gives a rule its policy's id (management/server/policy.go:205,
+			// "when policy can contain multiple rules, need refactor"), so a
+			// single-rule policy — the only shape the product can create today —
+			// must be modelled that way or the wire ids come out unrealistic.
+			rule.ID = policy.ID
+			if len(policy.Rules) > 1 {
+				rule.ID = fmt.Sprintf("%s-rule-%d", policy.ID, i)
+			}
+		}
+	}
+}
+
+func defaultXIDMapping(m *map[string]string, id string) {
+	if id == "" {
+		return
+	}
+	if *m == nil {
+		*m = make(map[string]string)
+	}
+	if _, ok := (*m)[id]; !ok {
+		(*m)[id] = id
+	}
+}
+
+// derivedWgKey returns a deterministic base64 key of 32 bytes, valid for the
+// envelope decoder's WG-key identity.
+func derivedWgKey(peerID string) string {
+	sum := sha256.Sum256([]byte(peerID))
+	return base64.StdEncoding.EncodeToString(sum[:])
+}
diff --git a/management/internals/controllers/network_map/nmaptest/golden_test.go b/management/internals/controllers/network_map/nmaptest/golden_test.go
new file mode 100644
index 000000000..75c0d57d2
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/golden_test.go
@@ -0,0 +1,12 @@
+package nmaptest_test
+
+import (
+	"path/filepath"
+	"testing"
+
+	"github.com/netbirdio/netbird/management/internals/controllers/network_map/nmaptest"
+)
+
+func TestNetworkMapGolden(t *testing.T) {
+	nmaptest.RunGoldenDir(t, filepath.Join("testdata", "cases"))
+}
diff --git a/management/internals/controllers/network_map/nmaptest/legacyaccount.go b/management/internals/controllers/network_map/nmaptest/legacyaccount.go
new file mode 100644
index 000000000..d6a653f7a
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/legacyaccount.go
@@ -0,0 +1,543 @@
+package nmaptest
+
+import (
+	"context"
+	"strings"
+	"testing"
+
+	"github.com/miekg/dns"
+	"github.com/stretchr/testify/require"
+
+	nbdns "github.com/netbirdio/netbird/dns"
+	"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache"
+	"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
+	"github.com/netbirdio/netbird/management/internals/modules/zones"
+	"github.com/netbirdio/netbird/management/internals/modules/zones/records"
+	resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
+	routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
+	networkTypes "github.com/netbirdio/netbird/management/server/networks/types"
+	nbpeer "github.com/netbirdio/netbird/management/server/peer"
+	"github.com/netbirdio/netbird/management/server/posture"
+	"github.com/netbirdio/netbird/management/server/types"
+	"github.com/netbirdio/netbird/management/server/types/legacynmap"
+	nbroute "github.com/netbirdio/netbird/route"
+	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+	"github.com/netbirdio/netbird/shared/management/proto"
+	sharedtypes "github.com/netbirdio/netbird/shared/management/types"
+)
+
+// legacyInput is the account and the four derived arguments main's computation
+// took alongside it. The controller resolved them from the account before
+// calling; the twin carries them as fields, so the fixture is the source for
+// both halves.
+type legacyInput struct {
+	account          *types.Account
+	accountZones     []*zones.Zone
+	validatedPeers   map[string]struct{}
+	resourcePolicies map[string][]*types.Policy
+	routers          map[string]map[string]*routerTypes.NetworkRouter
+	groupIDToUserIDs map[string][]string
+}
+
+// legacyInputFromData rebuilds the Account the fixture stands for. A fixture is
+// the value the store returns, and the store's twins carry exactly the state
+// the computation reads, so inverting them reproduces the account main would
+// have loaded — which is what lets one expectation measure all three paths.
+//
+// The inverse is only defined for what a twin carries: fields the builders drop
+// (peer names, policy descriptions, user records behind AllowedUserIDs) come
+// back as the zero value or a minimal stand-in, because no path reads them.
+func legacyInputFromData(accountID string, nmData *networkmap.NetworkMapData) legacyInput {
+	account := &types.Account{
+		Id:               accountID,
+		Network:          accountNetwork(nmData.Network),
+		Settings:         accountSettings(nmData.AccountSettings),
+		DNSSettings:      types.DNSSettings{DisabledManagementGroups: nmData.DNSSettings.DisabledManagementGroups},
+		Peers:            make(map[string]*nbpeer.Peer, len(nmData.Peers)),
+		Groups:           make(map[string]*types.Group, len(nmData.Groups)),
+		Policies:         make([]*types.Policy, 0, len(nmData.Policies)),
+		Routes:           make(map[nbroute.ID]*nbroute.Route, len(nmData.Routes)),
+		NameServerGroups: make(map[string]*nbdns.NameServerGroup, len(nmData.NameServerGroups)),
+		NetworkResources: make([]*resourceTypes.NetworkResource, 0, len(nmData.NetworkResources)),
+		PostureChecks:    make([]*posture.Checks, 0, len(nmData.PostureChecks)),
+		Users:            make(map[string]*types.User, len(nmData.AllowedUserIDs)),
+		Services:         accountServices(nmData.Services),
+	}
+
+	for id, p := range nmData.Peers {
+		account.Peers[id] = accountPeer(id, p)
+	}
+	for id, g := range nmData.Groups {
+		account.Groups[id] = accountGroup(id, g)
+	}
+
+	policiesByID := make(map[string]*types.Policy, len(nmData.Policies))
+	for _, p := range nmData.Policies {
+		policy := accountPolicy(p)
+		if policy == nil {
+			continue
+		}
+		account.Policies = append(account.Policies, policy)
+		policiesByID[policy.ID] = policy
+	}
+
+	for _, r := range nmData.Routes {
+		route := accountRoute(r)
+		if route != nil {
+			account.Routes[route.ID] = route
+		}
+	}
+	for _, nsg := range nmData.NameServerGroups {
+		group := accountNSG(nsg)
+		if group != nil {
+			account.NameServerGroups[group.ID] = group
+		}
+	}
+	for _, res := range nmData.NetworkResources {
+		if resource := accountNetworkResource(res); resource != nil {
+			account.NetworkResources = append(account.NetworkResources, resource)
+		}
+	}
+	for id, pc := range nmData.PostureChecks {
+		if check := accountPostureChecks(id, pc, nmData.PostureCheckXIDToPublicID[id]); check != nil {
+			account.PostureChecks = append(account.PostureChecks, check)
+		}
+	}
+	for xid, publicID := range nmData.NetworkXIDToPublicID {
+		account.Networks = append(account.Networks, &networkTypes.Network{ID: xid, PublicID: publicID})
+	}
+	// The twin keeps only the ids of the users a peer may be shared with; the
+	// legacy side derives the same set from the account's user records, so a
+	// bare non-blocked regular user per id is enough.
+	for userID := range nmData.AllowedUserIDs {
+		account.Users[userID] = &types.User{Id: userID}
+	}
+
+	// Main's network-map controller synthesised the reverse-proxy ACLs onto the
+	// account and only then derived the resource-policy map, so the frozen copy
+	// has to be fed in that order to stand for what main produced.
+	account.Policies = append(account.Policies, legacynmap.SynthesizeProxyPolicies(account)...)
+
+	return legacyInput{
+		account:          account,
+		accountZones:     accountZones(nmData.AppliedZoneCandidates),
+		validatedPeers:   nmData.ValidatedPeers,
+		resourcePolicies: account.GetResourcePoliciesMap(),
+		routers:          accountRouters(nmData.Routers),
+		groupIDToUserIDs: nmData.GroupIDToUserIDs,
+	}
+}
+
+// computeLegacy runs the fixture through main's frozen path and its own proto
+// encoder, the one comparison surface the three modes share.
+func computeLegacy(t *testing.T, ctx context.Context, legacy legacyInput, peerID string, zone nmdata.CustomZone, dnsDomain string, dnsFwdPort int64) *proto.NetworkMap {
+	t.Helper()
+
+	require.NotNil(t, legacy.account, "legacy mode needs an account rebuilt from the fixture")
+	peer := legacy.account.Peers[peerID]
+	require.NotNil(t, peer, "target peer %q not in rebuilt account", peerID)
+
+	nm := legacynmap.GetPeerNetworkMapFromComponents(
+		legacy.account, ctx, peerID, legacyCustomZone(zone), legacy.accountZones, legacy.validatedPeers,
+		legacy.resourcePolicies, legacy.routers, nil, legacy.groupIDToUserIDs,
+	)
+	require.NotNil(t, nm, "legacy path returned no network map for peer %q", peerID)
+
+	return legacynmap.ToProtoNetworkMap(
+		ctx, peer, nm, dnsDomain, legacy.account.Settings, nil, &cache.DNSConfigCache{}, dnsFwdPort,
+	)
+}
+
+// legacyCustomZone converts the peers custom zone the runner computes once for
+// every mode into the shape main's path took.
+func legacyCustomZone(z nmdata.CustomZone) nbdns.CustomZone {
+	zoneRecords := make([]nbdns.SimpleRecord, 0, len(z.Records))
+	for _, r := range z.Records {
+		zoneRecords = append(zoneRecords, nbdns.SimpleRecord{
+			Name:  r.Name,
+			Type:  r.Type,
+			Class: r.Class,
+			TTL:   r.TTL,
+			RData: r.RData,
+		})
+	}
+	return nbdns.CustomZone{
+		Domain:               z.Domain,
+		Records:              zoneRecords,
+		SearchDomainDisabled: z.SearchDomainDisabled,
+		NonAuthoritative:     z.NonAuthoritative,
+	}
+}
+
+func accountNetwork(n *nmdata.Network) *types.Network {
+	if n == nil {
+		return nil
+	}
+	return &types.Network{
+		Identifier: n.Identifier,
+		Net:        n.Net,
+		NetV6:      n.NetV6,
+		Dns:        n.Dns,
+		Serial:     uint64(n.Serial),
+	}
+}
+
+func accountSettings(s *nmdata.AccountSettingsInfo) *types.Settings {
+	if s == nil {
+		return nil
+	}
+	return &types.Settings{
+		PeerLoginExpirationEnabled:      s.PeerLoginExpirationEnabled,
+		PeerLoginExpiration:             s.PeerLoginExpiration,
+		PeerInactivityExpirationEnabled: s.PeerInactivityExpirationEnabled,
+		PeerInactivityExpiration:        s.PeerInactivityExpiration,
+		DNSDomain:                       s.DNSDomain,
+		IPv6EnabledGroups:               s.IPv6EnabledGroups,
+		RoutingPeerDNSResolutionEnabled: s.RoutingPeerDNSResolutionEnabled,
+		LazyConnectionEnabled:           s.LazyConnectionEnabled,
+		AutoUpdateVersion:               s.AutoUpdateVersion,
+		AutoUpdateAlways:                s.AutoUpdateAlways,
+		MetricsPushEnabled:              s.MetricsPushEnabled,
+	}
+}
+
+func accountPeer(id string, p *nmdata.Peer) *nbpeer.Peer {
+	if p == nil {
+		return nil
+	}
+	networkAddresses := make([]nbpeer.NetworkAddress, 0, len(p.Meta.NetworkAddresses))
+	for _, na := range p.Meta.NetworkAddresses {
+		networkAddresses = append(networkAddresses, nbpeer.NetworkAddress{NetIP: na.NetIP})
+	}
+	files := make([]nbpeer.File, 0, len(p.Meta.Files))
+	for _, f := range p.Meta.Files {
+		files = append(files, nbpeer.File{Path: f.Path, ProcessIsRunning: f.ProcessIsRunning})
+	}
+	return &nbpeer.Peer{
+		ID:                     id,
+		Key:                    p.Key,
+		SSHKey:                 p.SSHKey,
+		DNSLabel:               p.DNSLabel,
+		UserID:                 p.UserID,
+		SSHEnabled:             p.SSHEnabled,
+		LoginExpirationEnabled: p.LoginExpirationEnabled,
+		LastLogin:              p.LastLogin,
+		IP:                     p.IP,
+		IPv6:                   p.IPv6,
+		ExtraDNSLabels:         p.ExtraDNSLabels,
+		ProxyMeta:              nbpeer.ProxyMeta{Embedded: p.ProxyMeta.Embedded, Cluster: p.ProxyMeta.Cluster},
+		// Connected is what SynthesizePrivateServiceZones gates its records on,
+		// and a fixture peer stands for a peer the store returned, so it is one
+		// the account would have reported connected.
+		Status: &nbpeer.PeerStatus{RequiresApproval: p.RequiresApproval, Connected: true},
+		Meta: nbpeer.PeerSystemMeta{
+			WtVersion:          p.Meta.WtVersion,
+			GoOS:               p.Meta.GoOS,
+			OSVersion:          p.Meta.OSVersion,
+			KernelVersion:      p.Meta.KernelVersion,
+			NetworkAddresses:   networkAddresses,
+			Files:              files,
+			Capabilities:       p.Meta.Capabilities,
+			SyncMessageVersion: p.Meta.SyncMessageVersion,
+			Flags: nbpeer.Flags{
+				ServerSSHAllowed: p.Meta.Flags.ServerSSHAllowed,
+				DisableIPv6:      p.Meta.Flags.DisableIPv6,
+			},
+		},
+		Location: nbpeer.Location{
+			CountryCode:  p.Location.CountryCode,
+			CityName:     p.Location.CityName,
+			ConnectionIP: p.Location.ConnectionIP,
+		},
+	}
+}
+
+func accountGroup(id string, g *nmdata.Group) *types.Group {
+	if g == nil {
+		return nil
+	}
+	return &types.Group{
+		ID:       id,
+		Name:     g.Name,
+		PublicID: g.PublicID,
+		Peers:    g.Peers,
+	}
+}
+
+func accountPolicy(p *nmdata.Policy) *types.Policy {
+	if p == nil {
+		return nil
+	}
+	rules := make([]*types.PolicyRule, 0, len(p.Rules))
+	for _, r := range p.Rules {
+		if r == nil {
+			continue
+		}
+		var portRanges []sharedtypes.RulePortRange
+		if r.PortRanges != nil {
+			portRanges = make([]sharedtypes.RulePortRange, len(r.PortRanges))
+			for i, pr := range r.PortRanges {
+				portRanges[i] = sharedtypes.RulePortRange{Start: pr.Start, End: pr.End}
+			}
+		}
+		rules = append(rules, &types.PolicyRule{
+			ID:                  r.ID,
+			PolicyID:            r.PolicyID,
+			Enabled:             r.Enabled,
+			Action:              sharedtypes.PolicyTrafficActionType(r.Action),
+			Protocol:            sharedtypes.PolicyRuleProtocolType(r.Protocol),
+			Bidirectional:       r.Bidirectional,
+			Sources:             r.Sources,
+			Destinations:        r.Destinations,
+			SourceResource:      types.Resource{ID: r.SourceResource.ID, Type: sharedtypes.ResourceType(r.SourceResource.Type)},
+			DestinationResource: types.Resource{ID: r.DestinationResource.ID, Type: sharedtypes.ResourceType(r.DestinationResource.Type)},
+			Ports:               r.Ports,
+			PortRanges:          portRanges,
+			AuthorizedGroups:    r.AuthorizedGroups,
+			AuthorizedUser:      r.AuthorizedUser,
+		})
+	}
+	return &types.Policy{
+		ID:                  p.ID,
+		PublicID:            p.PublicID,
+		Enabled:             p.Enabled,
+		SourcePostureChecks: p.SourcePostureChecks,
+		Rules:               rules,
+	}
+}
+
+func accountRoute(r *nmdata.Route) *nbroute.Route {
+	if r == nil {
+		return nil
+	}
+	return &nbroute.Route{
+		ID:                  nbroute.ID(r.ID),
+		AccountID:           r.AccountID,
+		PublicID:            r.PublicID,
+		Network:             r.Network,
+		Domains:             r.Domains,
+		KeepRoute:           r.KeepRoute,
+		NetID:               nbroute.NetID(r.NetID),
+		Description:         r.Description,
+		Peer:                r.Peer,
+		PeerID:              r.PeerID,
+		PeerGroups:          r.PeerGroups,
+		NetworkType:         nbroute.NetworkType(r.NetworkType),
+		Masquerade:          r.Masquerade,
+		Metric:              r.Metric,
+		Enabled:             r.Enabled,
+		Groups:              r.Groups,
+		AccessControlGroups: r.AccessControlGroups,
+		SkipAutoApply:       r.SkipAutoApply,
+	}
+}
+
+func accountNSG(n *nmdata.NameServerGroup) *nbdns.NameServerGroup {
+	if n == nil {
+		return nil
+	}
+	nameServers := make([]nbdns.NameServer, 0, len(n.NameServers))
+	for _, ns := range n.NameServers {
+		nameServers = append(nameServers, nbdns.NameServer{
+			IP:     ns.IP,
+			NSType: nbdns.NameServerType(ns.NSType),
+			Port:   ns.Port,
+		})
+	}
+	return &nbdns.NameServerGroup{
+		ID:                   n.ID,
+		PublicID:             n.PublicID,
+		Name:                 n.Name,
+		Description:          n.Description,
+		NameServers:          nameServers,
+		Groups:               n.Groups,
+		Primary:              n.Primary,
+		Domains:              n.Domains,
+		Enabled:              n.Enabled,
+		SearchDomainsEnabled: n.SearchDomainsEnabled,
+	}
+}
+
+func accountNetworkResource(r *nmdata.NetworkResource) *resourceTypes.NetworkResource {
+	if r == nil {
+		return nil
+	}
+	return &resourceTypes.NetworkResource{
+		ID:          r.ID,
+		NetworkID:   r.NetworkID,
+		AccountID:   r.AccountID,
+		PublicID:    r.PublicID,
+		Name:        r.Name,
+		Description: r.Description,
+		Type:        resourceTypes.NetworkResourceType(r.Type),
+		Address:     r.Address,
+		Domain:      r.Domain,
+		Prefix:      r.Prefix,
+		Enabled:     r.Enabled,
+	}
+}
+
+func accountPostureChecks(id string, pc *nmdata.PostureChecks, publicID string) *posture.Checks {
+	if pc == nil {
+		return nil
+	}
+	out := &posture.Checks{ID: id, PublicID: publicID}
+	def := pc.Checks
+	if def.NBVersionCheck != nil {
+		out.Checks.NBVersionCheck = &posture.NBVersionCheck{MinVersion: def.NBVersionCheck.MinVersion}
+	}
+	if def.OSVersionCheck != nil {
+		oc := &posture.OSVersionCheck{}
+		if def.OSVersionCheck.Android != nil {
+			oc.Android = &posture.MinVersionCheck{MinVersion: def.OSVersionCheck.Android.MinVersion}
+		}
+		if def.OSVersionCheck.Darwin != nil {
+			oc.Darwin = &posture.MinVersionCheck{MinVersion: def.OSVersionCheck.Darwin.MinVersion}
+		}
+		if def.OSVersionCheck.Ios != nil {
+			oc.Ios = &posture.MinVersionCheck{MinVersion: def.OSVersionCheck.Ios.MinVersion}
+		}
+		if def.OSVersionCheck.Linux != nil {
+			oc.Linux = &posture.MinKernelVersionCheck{MinKernelVersion: def.OSVersionCheck.Linux.MinKernelVersion}
+		}
+		if def.OSVersionCheck.Windows != nil {
+			oc.Windows = &posture.MinKernelVersionCheck{MinKernelVersion: def.OSVersionCheck.Windows.MinKernelVersion}
+		}
+		out.Checks.OSVersionCheck = oc
+	}
+	if def.GeoLocationCheck != nil {
+		gc := &posture.GeoLocationCheck{Action: def.GeoLocationCheck.Action}
+		for _, loc := range def.GeoLocationCheck.Locations {
+			gc.Locations = append(gc.Locations, posture.Location{CountryCode: loc.CountryCode, CityName: loc.CityName})
+		}
+		out.Checks.GeoLocationCheck = gc
+	}
+	if def.PeerNetworkRangeCheck != nil {
+		out.Checks.PeerNetworkRangeCheck = &posture.PeerNetworkRangeCheck{
+			Action: def.PeerNetworkRangeCheck.Action,
+			Ranges: def.PeerNetworkRangeCheck.Ranges,
+		}
+	}
+	if def.ProcessCheck != nil {
+		procs := make([]posture.Process, 0, len(def.ProcessCheck.Processes))
+		for _, p := range def.ProcessCheck.Processes {
+			procs = append(procs, posture.Process{LinuxPath: p.LinuxPath, MacPath: p.MacPath, WindowsPath: p.WindowsPath})
+		}
+		out.Checks.ProcessCheck = &posture.ProcessCheck{Processes: procs}
+	}
+	return out
+}
+
+func accountServices(services []*nmdata.Service) []*service.Service {
+	if len(services) == 0 {
+		return nil
+	}
+	out := make([]*service.Service, 0, len(services))
+	for _, svc := range services {
+		if svc == nil {
+			continue
+		}
+		targets := make([]*service.Target, 0, len(svc.Targets))
+		for _, t := range svc.Targets {
+			if t == nil {
+				continue
+			}
+			target := &service.Target{
+				Enabled:    t.Enabled,
+				Port:       t.Port,
+				Protocol:   t.Protocol,
+				TargetId:   t.TargetID,
+				TargetType: service.TargetType(t.TargetType),
+			}
+			if t.Path != "" {
+				path := t.Path
+				target.Path = &path
+			}
+			targets = append(targets, target)
+		}
+		out = append(out, &service.Service{
+			ID:           svc.ID,
+			Enabled:      svc.Enabled,
+			Private:      svc.Private,
+			Mode:         svc.Mode,
+			ProxyCluster: svc.ProxyCluster,
+			AccessGroups: svc.AccessGroups,
+			Targets:      targets,
+		})
+	}
+	return out
+}
+
+// accountZones inverts buildAppliedZoneCandidates. Records come back with the
+// record type the builder mapped them from; a candidate only ever carries the
+// three types it converts.
+func accountZones(candidates []networkmap.AppliedZoneCandidate) []*zones.Zone {
+	if len(candidates) == 0 {
+		return nil
+	}
+	out := make([]*zones.Zone, 0, len(candidates))
+	for _, candidate := range candidates {
+		zoneRecords := make([]*records.Record, 0, len(candidate.Zone.Records))
+		for _, r := range candidate.Zone.Records {
+			recordType, ok := zoneRecordType(r.Type)
+			if !ok {
+				continue
+			}
+			zoneRecords = append(zoneRecords, &records.Record{
+				Name:    strings.TrimSuffix(r.Name, "."),
+				Type:    recordType,
+				Content: r.RData,
+				TTL:     r.TTL,
+			})
+		}
+		out = append(out, &zones.Zone{
+			ID:                 candidate.Zone.Domain,
+			Domain:             strings.TrimSuffix(candidate.Zone.Domain, "."),
+			Enabled:            true,
+			EnableSearchDomain: !candidate.Zone.SearchDomainDisabled,
+			DistributionGroups: candidate.DistributionGroups,
+			Records:            zoneRecords,
+		})
+	}
+	return out
+}
+
+func zoneRecordType(recordType int) (records.RecordType, bool) {
+	switch uint16(recordType) {
+	case dns.TypeA:
+		return records.RecordTypeA, true
+	case dns.TypeAAAA:
+		return records.RecordTypeAAAA, true
+	case dns.TypeCNAME:
+		return records.RecordTypeCNAME, true
+	default:
+		return "", false
+	}
+}
+
+func accountRouters(routers map[string]map[string]*nmdata.NetworkRouter) map[string]map[string]*routerTypes.NetworkRouter {
+	if len(routers) == 0 {
+		return nil
+	}
+	out := make(map[string]map[string]*routerTypes.NetworkRouter, len(routers))
+	for networkID, inner := range routers {
+		converted := make(map[string]*routerTypes.NetworkRouter, len(inner))
+		for peerID, router := range inner {
+			if router == nil {
+				continue
+			}
+			converted[peerID] = &routerTypes.NetworkRouter{
+				NetworkID:  networkID,
+				PublicID:   router.PublicID,
+				Peer:       peerID,
+				PeerGroups: router.PeerGroups,
+				Masquerade: router.Masquerade,
+				Metric:     router.Metric,
+				Enabled:    router.Enabled,
+			}
+		}
+		out[networkID] = converted
+	}
+	return out
+}
diff --git a/management/internals/controllers/network_map/nmaptest/runner.go b/management/internals/controllers/network_map/nmaptest/runner.go
new file mode 100644
index 000000000..c70bd7298
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/runner.go
@@ -0,0 +1,332 @@
+// Package nmaptest measures network map generation on the dedicated store
+// path against committed expectations. A case stands in for the store load
+// with a NetworkMapData fixture — the value NetworkMapDBStoreImpl returns for
+// one account — then runs the production per-peer pipeline the controller
+// uses, PeersCustomZone → GetPeerNetworkMapComponents → proto conversion, in
+// both wire shapes: the full map (grpc.ToSyncResponse) and the component
+// envelope expanded client-side (grpc.ToComponentSyncResponse →
+// networkmap.EnvelopeToNetworkMap). A third mode inverts the fixture back into
+// the Account it stands for and runs main's frozen path over it (legacynmap),
+// so every case is pinned to what main shipped as well.
+//
+// The expectation files are the point of the framework. They state what the
+// output should be, so a failing case means the code disagrees with the
+// expectation and the answer is normally to fix the code; an expectation
+// changes only through a deliberate reviewed edit. Nothing in this package
+// writes to testdata — there is no flag that records current behaviour into an
+// expectation, because that is how a defect becomes the baseline. Cases whose
+// expectation encodes correct behaviour the code does not yet deliver stay red
+// on purpose.
+//
+// A case lives in testdata/cases// as case.json (manifest: description,
+// peers, optional accountID, dnsDomain, modes), nmdata.json (the fixture the
+// mocked store returns, using Go field names; zero values may be omitted and
+// applyFixtureDefaults fills the boilerplate) and golden/.json.
+//
+// There is ONE expectation per peer, shared by every mode, because all three
+// must arrive at the same client-facing map. Full and envelope are not even
+// different computations — CalculateNetworkMapFromComponents is
+// components.Calculate and both assemble the proto with the same encode
+// helpers — so the only variable between them is what the envelope round-trip
+// did in transit, and a difference there is a round-trip fidelity defect.
+// Legacy is a different computation, main's, reached from a rebuilt account;
+// a difference there is this tree having drifted from what main shipped.
+// Results are canonicalized before comparison, since repeated proto fields
+// come from map iteration.
+package nmaptest
+
+import (
+	"bytes"
+	"context"
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"slices"
+	"strings"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/stretchr/testify/require"
+	"golang.org/x/exp/maps"
+	"google.golang.org/protobuf/encoding/protojson"
+	"google.golang.org/protobuf/testing/protocmp"
+
+	"github.com/netbirdio/netbird/management/internals/controllers/network_map"
+	"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
+	"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache"
+	mgmtgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
+	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+	"github.com/netbirdio/netbird/shared/management/proto"
+)
+
+// Mode selects the wire shape a case is verified through. Both end in a
+// *proto.NetworkMap, the one comparison surface shared by every path.
+type Mode string
+
+const (
+	// ModeFull is the legacy wire shape: the server runs Calculate and sends
+	// the expanded map (grpc.ToSyncResponse).
+	ModeFull Mode = "full"
+	// ModeEnvelope is the component wire shape: the server encodes components
+	// into a NetworkMapEnvelope (grpc.ToComponentSyncResponse) and the map is
+	// expanded the way the client engine does (networkmap.EnvelopeToNetworkMap).
+	ModeEnvelope Mode = "envelope"
+	// ModeLegacy is main's frozen path: the fixture is inverted back into the
+	// Account it stands for and run through legacynmap, the copy of what main
+	// shipped. It is the outside measurement — the other two modes share this
+	// tree's computation, so only this one can catch the whole tree drifting.
+	ModeLegacy Mode = "legacy"
+
+	defaultAccountID = "account"
+	defaultDNSDomain = "netbird.test"
+)
+
+var defaultModes = []Mode{ModeFull, ModeEnvelope, ModeLegacy}
+
+// Case is one nmap-generation scenario: store data for a single account, the
+// peers whose network maps are computed, and the directory holding one expected
+// *proto.NetworkMap per peer — shared by every mode.
+type Case struct {
+	Name      string
+	AccountID string
+	DNSDomain string
+	Peers     []string
+	Modes     []Mode
+	Data      *networkmap.NetworkMapData
+	GoldenDir string
+}
+
+type manifest struct {
+	Description string
+	AccountID   string
+	DNSDomain   string
+	Peers       []string
+	Modes       []Mode
+}
+
+// RunGoldenDir discovers and runs every fixture case under dir. A case is a
+// directory containing case.json (manifest), nmdata.json (store fixture) and
+// golden/.json (expected proto.NetworkMap, protojson).
+func RunGoldenDir(t *testing.T, dir string) {
+	t.Helper()
+
+	entries, err := os.ReadDir(dir)
+	require.NoError(t, err, "read cases dir")
+
+	ran := 0
+	for _, entry := range entries {
+		if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
+			continue
+		}
+		caseDir := filepath.Join(dir, entry.Name())
+		c, err := loadCase(caseDir)
+		require.NoError(t, err, "load case %s", entry.Name())
+		ran++
+		t.Run(entry.Name(), func(t *testing.T) {
+			RunCase(t, c)
+		})
+	}
+	require.NotZero(t, ran, "no cases found under %s", dir)
+}
+
+func loadCase(caseDir string) (Case, error) {
+	raw, err := os.ReadFile(filepath.Join(caseDir, "case.json"))
+	if err != nil {
+		return Case{}, fmt.Errorf("read manifest: %w", err)
+	}
+	dec := json.NewDecoder(bytes.NewReader(raw))
+	dec.DisallowUnknownFields()
+	var m manifest
+	if err := dec.Decode(&m); err != nil {
+		return Case{}, fmt.Errorf("decode manifest: %w", err)
+	}
+
+	data, err := LoadNetworkMapData(filepath.Join(caseDir, "nmdata.json"))
+	if err != nil {
+		return Case{}, err
+	}
+
+	return Case{
+		Name:      filepath.Base(caseDir),
+		AccountID: m.AccountID,
+		DNSDomain: m.DNSDomain,
+		Peers:     m.Peers,
+		Modes:     m.Modes,
+		Data:      data,
+		GoldenDir: filepath.Join(caseDir, "golden"),
+	}, nil
+}
+
+// RunCase computes each target peer's network map through every enabled mode
+// and compares the canonicalized result against the peer's expectation file.
+// It mirrors the controller's store path: fill fixture defaults, precompute
+// posture validation once, then run the per-peer pipeline.
+func RunCase(t *testing.T, c Case) {
+	t.Helper()
+
+	require.NotNil(t, c.Data, "case %s: Data is required", c.Name)
+	require.NotEmpty(t, c.Peers, "case %s: Peers is required", c.Name)
+	require.NotEmpty(t, c.GoldenDir, "case %s: GoldenDir is required", c.Name)
+	if c.AccountID == "" {
+		c.AccountID = defaultAccountID
+	}
+	if c.DNSDomain == "" {
+		c.DNSDomain = defaultDNSDomain
+	}
+	if len(c.Modes) == 0 {
+		c.Modes = defaultModes
+	}
+
+	ctx := context.Background()
+	nmData := c.Data
+	applyFixtureDefaults(nmData)
+	nmData.PrecomputePostureValidation()
+
+	dnsDomain := c.DNSDomain
+	if nmData.AccountSettings.DNSDomain != "" {
+		dnsDomain = nmData.AccountSettings.DNSDomain
+	}
+
+	zone := networkmap.PeersCustomZone(ctx, c.AccountID, dnsDomain, nmData.Peers, controller.IPv6AllowedPeersFromData(nmData))
+	dnsFwdPort := controller.ComputeForwarderPortFromData(nmData.Peers, network_map.DnsForwarderPortMinVersion)
+
+	for _, mode := range c.Modes {
+		if mode == ModeEnvelope {
+			requireEnvelopeSafeKeys(t, nmData, c.Name)
+			break
+		}
+	}
+
+	// Built before any mode runs: the first per-peer computation injects the
+	// synthesised proxy ACLs into the twin's policies, and the legacy side
+	// synthesises its own, so inverting a twin that already carries them would
+	// hand the legacy path each ACL twice.
+	var legacy legacyInput
+	if slices.Contains(c.Modes, ModeLegacy) {
+		legacy = legacyInputFromData(c.AccountID, nmData)
+	}
+
+	for _, peerID := range c.Peers {
+		peer := nmData.Peers[peerID]
+		require.NotNil(t, peer, "case %s: target peer %q not in fixture", c.Name, peerID)
+
+		for _, mode := range c.Modes {
+			t.Run(peerID+"/"+string(mode), func(t *testing.T) {
+				got := computeMode(t, ctx, mode, nmData, peerID, zone, dnsDomain, dnsFwdPort, legacy)
+				canonicalize(got)
+				compareGolden(t, filepath.Join(c.GoldenDir, peerID+".json"), got, mode)
+			})
+		}
+	}
+}
+
+// computeMode produces the peer's proto.NetworkMap the way the controller does
+// for that wire shape.
+func computeMode(t *testing.T, ctx context.Context, mode Mode, nmData *networkmap.NetworkMapData,
+	peerID string, zone nmdata.CustomZone, dnsDomain string, dnsFwdPort int64, legacy legacyInput) *proto.NetworkMap {
+	t.Helper()
+
+	peer := nmData.Peers[peerID]
+	require.NotNil(t, peer, "target peer %q not in fixture", peerID)
+
+	switch mode {
+	case ModeLegacy:
+		return computeLegacy(t, ctx, legacy, peerID, zone, dnsDomain, dnsFwdPort)
+	case ModeFull:
+		nmap := controller.NetworkMapFromData(ctx, nmData, peerID, zone, nil)
+		return mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, peer, nil, nil, nmap, dnsDomain, nil,
+			&cache.DNSConfigCache{}, nmData.AccountSettings, nil, nil, dnsFwdPort).NetworkMap
+	case ModeEnvelope:
+		components := nmData.GetPeerNetworkMapComponents(peerID, zone)
+		peerGroups := maps.Keys(nmData.GetPeerGroups(peerID))
+		resp := mgmtgrpc.ToComponentSyncResponse(ctx, nil, nil, nil, peer, nil, nil, components, nil,
+			dnsDomain, nil, nmData.AccountSettings, nil, peerGroups, dnsFwdPort)
+		res, err := networkmap.EnvelopeToNetworkMap(ctx, resp.NetworkMapEnvelope, peer.Key, dnsDomain)
+		require.NoError(t, err, "expand envelope")
+		return res.NetworkMap
+	default:
+		t.Fatalf("unknown mode %q", mode)
+		return nil
+	}
+}
+
+// requireEnvelopeSafeKeys fails fast on peer keys the envelope decoder would
+// silently drop: it re-keys peers by base64 of the raw 32-byte WG public key.
+func requireEnvelopeSafeKeys(t *testing.T, nmData *networkmap.NetworkMapData, caseName string) {
+	t.Helper()
+	for id, p := range nmData.Peers {
+		if p == nil {
+			continue
+		}
+		raw, err := base64.StdEncoding.DecodeString(p.Key)
+		if err != nil || len(raw) != 32 {
+			t.Fatalf("case %s: peer %q Key must be base64 of 32 bytes for mode %q (the envelope decoder drops it otherwise); use a real WireGuard public key or restrict the case to mode %q",
+				caseName, id, ModeEnvelope, ModeFull)
+		}
+	}
+}
+
+// compareGolden measures got against the committed expectation file. One
+// expectation serves every mode, because the modes run the same computation and
+// must therefore agree. The expectation is the authority: a mismatch means the
+// code does not produce what this case says it should, so it is reported as a
+// failure and not quietly absorbed.
+//
+// The full and legacy modes are compared verbatim, identifiers included, so the
+// expectation pins real ids and stays readable. The envelope mode has
+// identifiers erased on both sides first, because it currently rewrites them —
+// a tracked defect that TestIDSpaceMatches asserts against on its own, so it
+// does not have to drown out every other case here.
+// Nothing here writes to testdata. Expectation files are authored by hand and
+// only ever change through a reviewed edit, so there is no mode in which a run
+// can create or replace one. When a file is missing the computed map is printed
+// for the author to read and, if it is genuinely correct, save deliberately.
+func compareGolden(t *testing.T, path string, got *proto.NetworkMap, mode Mode) {
+	t.Helper()
+
+	if mode == ModeEnvelope {
+		normalizeIDSpace(got)
+		canonicalize(got)
+	}
+
+	raw, err := os.ReadFile(path)
+	if err != nil {
+		rendered, mErr := renderNetworkMap(got)
+		require.NoError(t, mErr)
+		t.Fatalf("no expectation file %s: %v\nThis case has nothing to measure against — write the "+
+			"proto.NetworkMap this peer should receive. Mode %s currently produces:\n%s\nRead it before "+
+			"saving any of it: if the code is wrong, so is this.", path, err, mode, rendered)
+	}
+	want := &proto.NetworkMap{}
+	require.NoError(t, protojson.Unmarshal(raw, want), "parse expectation %s", path)
+	canonicalize(want)
+	if mode == ModeEnvelope {
+		normalizeIDSpace(want)
+		canonicalize(want)
+	}
+
+	if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" {
+		t.Errorf("mode %s does not produce what %s expects (-want +got):\n%s\n"+
+			"Every mode has to deliver the same client-facing map for the same account state. "+
+			"The expectation file is the committed statement of correct output — fix the code, or change the "+
+			"expectation deliberately if the intended behaviour really moved.", mode, path, diff)
+	}
+}
+
+// renderNetworkMap renders stable protojson: protojson output whitespace is
+// deliberately unstable, so it is reformatted through json.Indent.
+func renderNetworkMap(nm *proto.NetworkMap) ([]byte, error) {
+	raw, err := protojson.Marshal(nm)
+	if err != nil {
+		return nil, err
+	}
+	var buf bytes.Buffer
+	if err := json.Indent(&buf, raw, "", "  "); err != nil {
+		return nil, err
+	}
+	buf.WriteByte('\n')
+	return buf.Bytes(), nil
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/case.json
new file mode 100644
index 000000000..4747e9640
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/case.json
@@ -0,0 +1,7 @@
+{
+  "description": "Two groups joined by one allow-all policy; peer-c has SSH enabled so the legacy-SSH path fills SshAuth from AllowedUserIDs.",
+  "peers": [
+    "peer-a",
+    "peer-c"
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/golden/peer-a.json
new file mode 100644
index 000000000..e2b69276c
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/golden/peer-a.json
@@ -0,0 +1,65 @@
+{
+  "Serial": "5",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "4deEImv8zGvsyBmmfC2G0eQkbyMzyGuz/YK7pcYETwM=",
+      "allowedIps": [
+        "100.64.0.3/32"
+      ],
+      "sshConfig": {
+        "sshPubKey": "c3NoLXBlZXItYw=="
+      },
+      "fqdn": "peer-c.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-c.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.3"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.3",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLWFsbA=="
+    },
+    {
+      "PeerIP": "100.64.0.3",
+      "Direction": "OUT",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLWFsbA=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/golden/peer-c.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/golden/peer-c.json
new file mode 100644
index 000000000..4c358b163
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/golden/peer-c.json
@@ -0,0 +1,102 @@
+{
+  "Serial": "5",
+  "peerConfig": {
+    "address": "100.64.0.3/10",
+    "sshConfig": {
+      "sshEnabled": true
+    },
+    "fqdn": "peer-c.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "AvldyrZ12Pf90jzf3AXmhPwg3UcI+jtJHfbpBlupvko=",
+      "allowedIps": [
+        "100.64.0.2/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-b.netbird.test",
+      "agentVersion": "0.60.0"
+    },
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-b.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          },
+          {
+            "Name": "peer-c.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.3"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLWFsbA=="
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLWFsbA=="
+    },
+    {
+      "PeerIP": "100.64.0.2",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLWFsbA=="
+    },
+    {
+      "PeerIP": "100.64.0.2",
+      "Direction": "OUT",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLWFsbA=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub",
+    "AuthorizedUsers": [
+      "u9dHvAXZJKiXITuwP9jD/A=="
+    ],
+    "machineUsers": {
+      "*": {
+        "indexes": [
+          0
+        ]
+      }
+    }
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/nmdata.json
new file mode 100644
index 000000000..7d78e5c61
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/nmdata.json
@@ -0,0 +1,31 @@
+{
+  "Network": {"Serial": 5},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-c": {"IP": "100.64.0.3", "SSHEnabled": true, "SSHKey": "ssh-peer-c", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a", "peer-b"]},
+    "grp-ops": {"Peers": ["peer-c"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-all",
+      "PublicID": "pol-all-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "all",
+          "Bidirectional": true,
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-ops"]
+        }
+      ]
+    }
+  ],
+  "AllowedUserIDs": {"user-ops": {}}
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/case.json
new file mode 100644
index 000000000..e4c46c63d
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/case.json
@@ -0,0 +1,7 @@
+{
+  "description": "Nameserver group and applied custom zones distributed to grp-dev; peer-a (with an extra DNS label) receives them, peer-c is outside that group and receives only the zone distributed to grp-ops. Zone flags travel per zone: both grp-dev zones are match-only (NonAuthoritative), only search-off.internal. disables the search domain, and the built-in peer zone stays authoritative.",
+  "peers": [
+    "peer-a",
+    "peer-c"
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/golden/peer-a.json
new file mode 100644
index 000000000..f06a19d9d
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/golden/peer-a.json
@@ -0,0 +1,115 @@
+{
+  "Serial": "8",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "AvldyrZ12Pf90jzf3AXmhPwg3UcI+jtJHfbpBlupvko=",
+      "allowedIps": [
+        "100.64.0.2/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-b.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "NameServerGroups": [
+      {
+        "NameServers": [
+          {
+            "IP": "8.8.8.8",
+            "Port": "53"
+          }
+        ],
+        "Primary": true
+      }
+    ],
+    "CustomZones": [
+      {
+        "Domain": "corp.internal.",
+        "NonAuthoritative": true,
+        "Records": [
+          {
+            "Name": "db.corp.internal.",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "10.10.0.5"
+          }
+        ]
+      },
+      {
+        "Domain": "search-off.internal.",
+        "SearchDomainDisabled": true,
+        "NonAuthoritative": true,
+        "Records": [
+          {
+            "Name": "alias.search-off.internal.",
+            "Type": "5",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "app.search-off.internal."
+          },
+          {
+            "Name": "app.search-off.internal.",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "10.10.0.6"
+          }
+        ]
+      },
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-b.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          },
+          {
+            "Name": "www.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.2",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLW1lc2g="
+    },
+    {
+      "PeerIP": "100.64.0.2",
+      "Direction": "OUT",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLW1lc2g="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/golden/peer-c.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/golden/peer-c.json
new file mode 100644
index 000000000..7e04dca40
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/golden/peer-c.json
@@ -0,0 +1,47 @@
+{
+  "Serial": "8",
+  "peerConfig": {
+    "address": "100.64.0.3/10",
+    "sshConfig": {},
+    "fqdn": "peer-c.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-c.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.3"
+          }
+        ]
+      },
+      {
+        "Domain": "ops-only.internal.",
+        "NonAuthoritative": true,
+        "Records": [
+          {
+            "Name": "tool.ops-only.internal.",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "10.10.0.7"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/nmdata.json
new file mode 100644
index 000000000..b9741ef16
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/nmdata.json
@@ -0,0 +1,74 @@
+{
+  "Network": {"Serial": 8},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "ExtraDNSLabels": ["www"], "Meta": {"WtVersion": "0.60.0"}},
+    "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-c": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a", "peer-b"]},
+    "grp-ops": {"Peers": ["peer-c"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-mesh",
+      "PublicID": "pol-mesh-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "all",
+          "Bidirectional": true,
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-dev"]
+        }
+      ]
+    }
+  ],
+  "NameServerGroups": [
+    {
+      "ID": "nsg-1",
+      "Name": "dns-primary",
+      "NameServers": [{"IP": "8.8.8.8", "Port": 53}],
+      "Groups": ["grp-dev"],
+      "Primary": true,
+      "Enabled": true
+    }
+  ],
+  "AppliedZoneCandidates": [
+    {
+      "DistributionGroups": ["grp-dev"],
+      "Zone": {
+        "Domain": "corp.internal.",
+        "NonAuthoritative": true,
+        "Records": [
+          {"Name": "db.corp.internal.", "Type": 1, "Class": "IN", "TTL": 300, "RData": "10.10.0.5"}
+        ]
+      }
+    },
+    {
+      "DistributionGroups": ["grp-dev"],
+      "Zone": {
+        "Domain": "search-off.internal.",
+        "NonAuthoritative": true,
+        "SearchDomainDisabled": true,
+        "Records": [
+          {"Name": "app.search-off.internal.", "Type": 1, "Class": "IN", "TTL": 300, "RData": "10.10.0.6"},
+          {"Name": "alias.search-off.internal.", "Type": 5, "Class": "IN", "TTL": 300, "RData": "app.search-off.internal."}
+        ]
+      }
+    },
+    {
+      "DistributionGroups": ["grp-ops"],
+      "Zone": {
+        "Domain": "ops-only.internal.",
+        "NonAuthoritative": true,
+        "Records": [
+          {"Name": "tool.ops-only.internal.", "Type": 1, "Class": "IN", "TTL": 300, "RData": "10.10.0.7"}
+        ]
+      }
+    }
+  ]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/case.json
new file mode 100644
index 000000000..a5776d0e7
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "Domain network resource: the route carries the domain list and the 192.0.2.0/32 placeholder network with NetworkType 3 (dynamic), and peer-r's route firewall rules must be marked dynamic and repeat the domain. Two ports on the policy must produce one rule per port. A domain resource contributes no DNS custom zone of its own — resolution happens through the routing peer's forwarder.",
+  "peers": ["peer-a", "peer-r"]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/golden/peer-a.json
new file mode 100644
index 000000000..f83e7a2f2
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/golden/peer-a.json
@@ -0,0 +1,59 @@
+{
+  "Serial": "22",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "allowedIps": [
+        "100.64.0.9/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-r.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-domain:peer-r",
+      "Network": "192.0.2.0/32",
+      "NetworkType": "3",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "app-domain",
+      "Domains": [
+        "app.internal"
+      ],
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/golden/peer-r.json
new file mode 100644
index 000000000..41ae3dd33
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/golden/peer-r.json
@@ -0,0 +1,92 @@
+{
+  "Serial": "22",
+  "peerConfig": {
+    "address": "100.64.0.9/10",
+    "sshConfig": {},
+    "fqdn": "peer-r.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-domain:peer-r",
+      "Network": "192.0.2.0/32",
+      "NetworkType": "3",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "app-domain",
+      "Domains": [
+        "app.internal"
+      ],
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-r.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.9"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRules": [
+    {
+      "sourceRanges": [
+        "100.64.0.1/32"
+      ],
+      "destination": "192.0.2.0/32",
+      "protocol": "TCP",
+      "portInfo": {
+        "port": 443
+      },
+      "isDynamic": true,
+      "domains": [
+        "app.internal"
+      ],
+      "PolicyID": "cG9sLWFwcA==",
+      "RouteID": "res-domain:peer-r"
+    },
+    {
+      "sourceRanges": [
+        "100.64.0.1/32"
+      ],
+      "destination": "192.0.2.0/32",
+      "protocol": "TCP",
+      "portInfo": {
+        "port": 80
+      },
+      "isDynamic": true,
+      "domains": [
+        "app.internal"
+      ],
+      "PolicyID": "cG9sLWFwcA==",
+      "RouteID": "res-domain:peer-r"
+    }
+  ],
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/nmdata.json
new file mode 100644
index 000000000..db6dc8eda
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/nmdata.json
@@ -0,0 +1,43 @@
+{
+  "Network": {"Serial": 22},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-app",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["80", "443"],
+          "Sources": ["grp-dev"],
+          "DestinationResource": {"ID": "res-domain", "Type": "domain"}
+        }
+      ]
+    }
+  ],
+  "ResourcePolicies": {"res-domain": [{"ID": "pol-app"}]},
+  "NetworkResources": [
+    {
+      "ID": "res-domain",
+      "NetworkID": "net-1",
+      "Name": "app-domain",
+      "Type": "domain",
+      "Domain": "app.internal",
+      "Enabled": true
+    }
+  ],
+  "Routers": {
+    "net-1": {
+      "peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true}
+    }
+  }
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/case.json
new file mode 100644
index 000000000..fa1e5c24b
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "Host network resource (single /32) behind one directly-assigned router. peer-a is in the resource policy's source group and must receive one route to 10.10.0.7/32 via peer-r with KeepRoute set and NetID taken from the resource name; peer-r as the router must receive the same route plus a route firewall rule whose SourceRanges are the policy's source peers. A client never gets route firewall rules.",
+  "peers": ["peer-a", "peer-r"]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/golden/peer-a.json
new file mode 100644
index 000000000..8bf83f20e
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/golden/peer-a.json
@@ -0,0 +1,56 @@
+{
+  "Serial": "20",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "allowedIps": [
+        "100.64.0.9/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-r.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-host:peer-r",
+      "Network": "10.10.0.7/32",
+      "NetworkType": "1",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "web-host",
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/golden/peer-r.json
new file mode 100644
index 000000000..ef3b5a6c8
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/golden/peer-r.json
@@ -0,0 +1,69 @@
+{
+  "Serial": "20",
+  "peerConfig": {
+    "address": "100.64.0.9/10",
+    "sshConfig": {},
+    "fqdn": "peer-r.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-host:peer-r",
+      "Network": "10.10.0.7/32",
+      "NetworkType": "1",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "web-host",
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-r.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.9"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRules": [
+    {
+      "sourceRanges": [
+        "100.64.0.1/32"
+      ],
+      "destination": "10.10.0.7/32",
+      "protocol": "TCP",
+      "portInfo": {
+        "port": 443
+      },
+      "PolicyID": "cG9sLXdlYg==",
+      "RouteID": "res-host:peer-r"
+    }
+  ],
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/nmdata.json
new file mode 100644
index 000000000..fdd35a439
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/nmdata.json
@@ -0,0 +1,43 @@
+{
+  "Network": {"Serial": 20},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-web",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["443"],
+          "Sources": ["grp-dev"],
+          "DestinationResource": {"ID": "res-host", "Type": "host"}
+        }
+      ]
+    }
+  ],
+  "ResourcePolicies": {"res-host": [{"ID": "pol-web"}]},
+  "NetworkResources": [
+    {
+      "ID": "res-host",
+      "NetworkID": "net-1",
+      "Name": "web-host",
+      "Type": "host",
+      "Prefix": "10.10.0.7/32",
+      "Enabled": true
+    }
+  ],
+  "Routers": {
+    "net-1": {
+      "peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true}
+    }
+  }
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/case.json
new file mode 100644
index 000000000..ca54a4b81
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "A disabled resource with a valid policy and router must leave no trace: no routes and no route firewall rules for either the client or the router. Disabling a resource is the switch that revokes access without deleting the policy.",
+  "peers": ["peer-a", "peer-r"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/golden/peer-a.json
new file mode 100644
index 000000000..a4f5a92bb
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/golden/peer-a.json
@@ -0,0 +1,34 @@
+{
+  "Serial": "25",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/golden/peer-r.json
new file mode 100644
index 000000000..b83cfdff6
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/golden/peer-r.json
@@ -0,0 +1,34 @@
+{
+  "Serial": "25",
+  "peerConfig": {
+    "address": "100.64.0.9/10",
+    "sshConfig": {},
+    "fqdn": "peer-r.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-r.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.9"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/nmdata.json
new file mode 100644
index 000000000..43e00a2db
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/nmdata.json
@@ -0,0 +1,42 @@
+{
+  "Network": {"Serial": 25},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-off-resource",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["443"],
+          "Sources": ["grp-dev"],
+          "DestinationResource": {"ID": "res-disabled", "Type": "subnet"}
+        }
+      ]
+    }
+  ],
+  "ResourcePolicies": {"res-disabled": [{"ID": "pol-off-resource"}]},
+  "NetworkResources": [
+    {
+      "ID": "res-disabled",
+      "NetworkID": "net-1",
+      "Name": "disabled-subnet",
+      "Type": "subnet",
+      "Prefix": "10.50.0.0/24"
+    }
+  ],
+  "Routers": {
+    "net-1": {
+      "peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true}
+    }
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/case.json
new file mode 100644
index 000000000..494ac0fce
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "An enabled resource with a healthy router but no policy granting access to it must produce nothing anywhere: no route for the client and none for the router either, since access to a resource is only ever created by a policy. The router also gets no route firewall rules despite being a routing peer for the network.",
+  "peers": ["peer-a", "peer-r"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/golden/peer-a.json
new file mode 100644
index 000000000..32f9cf35e
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/golden/peer-a.json
@@ -0,0 +1,34 @@
+{
+  "Serial": "24",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/golden/peer-r.json
new file mode 100644
index 000000000..a97eac9a3
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/golden/peer-r.json
@@ -0,0 +1,34 @@
+{
+  "Serial": "24",
+  "peerConfig": {
+    "address": "100.64.0.9/10",
+    "sshConfig": {},
+    "fqdn": "peer-r.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-r.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.9"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/nmdata.json
new file mode 100644
index 000000000..a3bbf299a
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/nmdata.json
@@ -0,0 +1,26 @@
+{
+  "Network": {"Serial": 24},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a"]}
+  },
+  "NetworkResources": [
+    {
+      "ID": "res-orphan",
+      "NetworkID": "net-1",
+      "Name": "orphan-subnet",
+      "Type": "subnet",
+      "Prefix": "10.40.0.0/24",
+      "Enabled": true
+    }
+  ],
+  "Routers": {
+    "net-1": {
+      "peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true}
+    }
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/case.json
new file mode 100644
index 000000000..2922a5deb
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "A DISABLED policy granting access to a network resource must grant nothing: no route to 10.90.0.0/24 for peer-a and none for the router either, exactly as if the policy were absent. THE FULL EXPECTATION CURRENTLY FAILS, and should: resource-policy selection never checks policy.Enabled (networkmapcompute.go and networkmap_components.go both test only nil/len(Rules)/Rules[0]), so the legacy path still hands out the route — access survives disabling the policy. The envelope path happens to be correct because the encoder drops disabled policies from the wire. Fix the compute path, do not weaken this expectation.",
+  "peers": ["peer-a", "peer-r"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/golden/peer-a.json
new file mode 100644
index 000000000..92ba75b1e
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/golden/peer-a.json
@@ -0,0 +1,34 @@
+{
+  "Serial": "39",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/golden/peer-r.json
new file mode 100644
index 000000000..8c27320ee
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/golden/peer-r.json
@@ -0,0 +1,34 @@
+{
+  "Serial": "39",
+  "peerConfig": {
+    "address": "100.64.0.9/10",
+    "sshConfig": {},
+    "fqdn": "peer-r.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-r.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.9"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/nmdata.json
new file mode 100644
index 000000000..be7251712
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/nmdata.json
@@ -0,0 +1,42 @@
+{
+  "Network": {"Serial": 39},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-revoked",
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["5432"],
+          "Sources": ["grp-dev"],
+          "DestinationResource": {"ID": "res-db", "Type": "subnet"}
+        }
+      ]
+    }
+  ],
+  "ResourcePolicies": {"res-db": [{"ID": "pol-revoked"}]},
+  "NetworkResources": [
+    {
+      "ID": "res-db",
+      "NetworkID": "net-1",
+      "Name": "db-subnet",
+      "Type": "subnet",
+      "Prefix": "10.90.0.0/24",
+      "Enabled": true
+    }
+  ],
+  "Routers": {
+    "net-1": {
+      "peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true}
+    }
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/case.json
new file mode 100644
index 000000000..de1027d48
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "The routing peer for the resource is not in ValidatedPeers — an unapproved peer, which the integrated validator withholds. peer-a must therefore receive no route through it and must not see it as a peer at all: traffic may not be routed through a peer the account has not approved. THE ENVELOPE EXPECTATION CURRENTLY FAILS, and should: component selection puts every routing peer into RouterPeers without checking validation, the encoder indexes them into the envelope's peer table, and the client decoder puts every peer it finds back into its peer map, so the unapproved router reappears client-side with a working route. The full path drops it correctly. Fix the component/encoder path, do not weaken this expectation.",
+  "peers": ["peer-a"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/golden/peer-a.json
new file mode 100644
index 000000000..2554fc08c
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/golden/peer-a.json
@@ -0,0 +1,34 @@
+{
+  "Serial": "40",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/nmdata.json
new file mode 100644
index 000000000..98ba94471
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/nmdata.json
@@ -0,0 +1,44 @@
+{
+  "Network": {"Serial": 40},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "ValidatedPeers": {"peer-a": {}},
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-db",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["5432"],
+          "Sources": ["grp-dev"],
+          "DestinationResource": {"ID": "res-db", "Type": "subnet"}
+        }
+      ]
+    }
+  ],
+  "ResourcePolicies": {"res-db": [{"ID": "pol-db"}]},
+  "NetworkResources": [
+    {
+      "ID": "res-db",
+      "NetworkID": "net-1",
+      "Name": "db-subnet",
+      "Type": "subnet",
+      "Prefix": "10.100.0.0/24",
+      "Enabled": true
+    }
+  ],
+  "Routers": {
+    "net-1": {
+      "peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true}
+    }
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/case.json
new file mode 100644
index 000000000..8ec63c816
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "Routing peer group: one router record assigned to a peer group, which the store expands into one entry per member peer sharing the router's settings. peer-a must receive one route per routing peer — same NetID and destination, different route ID and peer — which is what gives the client an HA pair to choose between. Each router must receive only its own route, never its sibling's, plus its own route firewall rule.",
+  "peers": ["peer-a", "peer-r1", "peer-r2"]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-a.json
new file mode 100644
index 000000000..020c34835
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-a.json
@@ -0,0 +1,75 @@
+{
+  "Serial": "23",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "Iu6Lj1HqDXfgDh5RppHCRI6RO3lMlZBCndRNrdS7QUI=",
+      "allowedIps": [
+        "100.64.0.11/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-r1.netbird.test",
+      "agentVersion": "0.60.0"
+    },
+    {
+      "wgPubKey": "YC6sbWtvpB2d6W/XqsNIV9crsrXBoVDdwmhCoszjCps=",
+      "allowedIps": [
+        "100.64.0.12/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-r2.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-ha:peer-r1",
+      "Network": "10.30.0.0/24",
+      "NetworkType": "1",
+      "Peer": "Iu6Lj1HqDXfgDh5RppHCRI6RO3lMlZBCndRNrdS7QUI=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "ha-subnet",
+      "keepRoute": true
+    },
+    {
+      "ID": "res-ha:peer-r2",
+      "Network": "10.30.0.0/24",
+      "NetworkType": "1",
+      "Peer": "YC6sbWtvpB2d6W/XqsNIV9crsrXBoVDdwmhCoszjCps=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "ha-subnet",
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-r1.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-r1.json
new file mode 100644
index 000000000..e42214ca8
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-r1.json
@@ -0,0 +1,69 @@
+{
+  "Serial": "23",
+  "peerConfig": {
+    "address": "100.64.0.11/10",
+    "sshConfig": {},
+    "fqdn": "peer-r1.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-ha:peer-r1",
+      "Network": "10.30.0.0/24",
+      "NetworkType": "1",
+      "Peer": "Iu6Lj1HqDXfgDh5RppHCRI6RO3lMlZBCndRNrdS7QUI=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "ha-subnet",
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-r1.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.11"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRules": [
+    {
+      "sourceRanges": [
+        "100.64.0.1/32"
+      ],
+      "destination": "10.30.0.0/24",
+      "protocol": "TCP",
+      "portInfo": {
+        "port": 5432
+      },
+      "PolicyID": "cG9sLWhh",
+      "RouteID": "res-ha:peer-r1"
+    }
+  ],
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-r2.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-r2.json
new file mode 100644
index 000000000..2560742fb
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-r2.json
@@ -0,0 +1,69 @@
+{
+  "Serial": "23",
+  "peerConfig": {
+    "address": "100.64.0.12/10",
+    "sshConfig": {},
+    "fqdn": "peer-r2.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-ha:peer-r2",
+      "Network": "10.30.0.0/24",
+      "NetworkType": "1",
+      "Peer": "YC6sbWtvpB2d6W/XqsNIV9crsrXBoVDdwmhCoszjCps=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "ha-subnet",
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-r2.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.12"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRules": [
+    {
+      "sourceRanges": [
+        "100.64.0.1/32"
+      ],
+      "destination": "10.30.0.0/24",
+      "protocol": "TCP",
+      "portInfo": {
+        "port": 5432
+      },
+      "PolicyID": "cG9sLWhh",
+      "RouteID": "res-ha:peer-r2"
+    }
+  ],
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/nmdata.json
new file mode 100644
index 000000000..03937cb14
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/nmdata.json
@@ -0,0 +1,46 @@
+{
+  "Network": {"Serial": 23},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-r1": {"IP": "100.64.0.11", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-r2": {"IP": "100.64.0.12", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a"]},
+    "grp-routers": {"Peers": ["peer-r1", "peer-r2"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-ha",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["5432"],
+          "Sources": ["grp-dev"],
+          "DestinationResource": {"ID": "res-ha", "Type": "subnet"}
+        }
+      ]
+    }
+  ],
+  "ResourcePolicies": {"res-ha": [{"ID": "pol-ha"}]},
+  "NetworkResources": [
+    {
+      "ID": "res-ha",
+      "NetworkID": "net-ha",
+      "Name": "ha-subnet",
+      "Type": "subnet",
+      "Prefix": "10.30.0.0/24",
+      "Enabled": true
+    }
+  ],
+  "Routers": {
+    "net-ha": {
+      "peer-r1": {"PublicID": "router-ha", "PeerGroups": ["grp-routers"], "Masquerade": true, "Metric": 9999, "Enabled": true},
+      "peer-r2": {"PublicID": "router-ha", "PeerGroups": ["grp-routers"], "Masquerade": true, "Metric": 9999, "Enabled": true}
+    }
+  }
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/case.json
new file mode 100644
index 000000000..16cfedf34
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "Subnet network resource behind one directly-assigned router, with masquerade off and a non-default metric so both reach the wire verbatim, and an all-protocol policy from a two-peer source group. peer-r's route firewall rule must list both source peers; peer-b confirms a second client in the same group gets its own identical route.",
+  "peers": ["peer-a", "peer-b", "peer-r"]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-a.json
new file mode 100644
index 000000000..a5bc8f880
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-a.json
@@ -0,0 +1,55 @@
+{
+  "Serial": "21",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "allowedIps": [
+        "100.64.0.9/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-r.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-subnet:peer-r",
+      "Network": "10.20.0.0/24",
+      "NetworkType": "1",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "500",
+      "NetID": "office-subnet",
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-b.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-b.json
new file mode 100644
index 000000000..01c31edf6
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-b.json
@@ -0,0 +1,55 @@
+{
+  "Serial": "21",
+  "peerConfig": {
+    "address": "100.64.0.2/10",
+    "sshConfig": {},
+    "fqdn": "peer-b.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "allowedIps": [
+        "100.64.0.9/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-r.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-subnet:peer-r",
+      "Network": "10.20.0.0/24",
+      "NetworkType": "1",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "500",
+      "NetID": "office-subnet",
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-b.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-r.json
new file mode 100644
index 000000000..39a29125d
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-r.json
@@ -0,0 +1,76 @@
+{
+  "Serial": "21",
+  "peerConfig": {
+    "address": "100.64.0.9/10",
+    "sshConfig": {},
+    "fqdn": "peer-r.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "AvldyrZ12Pf90jzf3AXmhPwg3UcI+jtJHfbpBlupvko=",
+      "allowedIps": [
+        "100.64.0.2/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-b.netbird.test",
+      "agentVersion": "0.60.0"
+    },
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-subnet:peer-r",
+      "Network": "10.20.0.0/24",
+      "NetworkType": "1",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "500",
+      "NetID": "office-subnet",
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-r.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.9"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRules": [
+    {
+      "sourceRanges": [
+        "100.64.0.1/32",
+        "100.64.0.2/32"
+      ],
+      "destination": "10.20.0.0/24",
+      "protocol": "ALL",
+      "portInfo": {},
+      "PolicyID": "cG9sLXN1Ym5ldA==",
+      "RouteID": "res-subnet:peer-r"
+    }
+  ],
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/nmdata.json
new file mode 100644
index 000000000..ba27f494c
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/nmdata.json
@@ -0,0 +1,43 @@
+{
+  "Network": {"Serial": 21},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a", "peer-b"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-subnet",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "all",
+          "Sources": ["grp-dev"],
+          "DestinationResource": {"ID": "res-subnet", "Type": "subnet"}
+        }
+      ]
+    }
+  ],
+  "ResourcePolicies": {"res-subnet": [{"ID": "pol-subnet"}]},
+  "NetworkResources": [
+    {
+      "ID": "res-subnet",
+      "NetworkID": "net-1",
+      "Name": "office-subnet",
+      "Type": "subnet",
+      "Prefix": "10.20.0.0/24",
+      "Enabled": true
+    }
+  ],
+  "Routers": {
+    "net-1": {
+      "peer-r": {"PublicID": "router-direct", "Metric": 500, "Enabled": true}
+    }
+  }
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/case.json
new file mode 100644
index 000000000..39c477b9f
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "Direct peer-to-peer policy via Source/DestinationResource of type peer, no groups involved; peer-a and peer-b see each other, bystander peer-c sees nobody.",
+  "peers": ["peer-a", "peer-b", "peer-c"]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-a.json
new file mode 100644
index 000000000..4d59c33bb
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-a.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "15",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "AvldyrZ12Pf90jzf3AXmhPwg3UcI+jtJHfbpBlupvko=",
+      "allowedIps": [
+        "100.64.0.2/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-b.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-b.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.2",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRpcmVjdA=="
+    },
+    {
+      "PeerIP": "100.64.0.2",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRpcmVjdA=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-b.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-b.json
new file mode 100644
index 000000000..59b4bd24c
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-b.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "15",
+  "peerConfig": {
+    "address": "100.64.0.2/10",
+    "sshConfig": {},
+    "fqdn": "peer-b.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-b.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRpcmVjdA=="
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRpcmVjdA=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-c.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-c.json
new file mode 100644
index 000000000..9ff24ce1a
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-c.json
@@ -0,0 +1,33 @@
+{
+  "Serial": "15",
+  "peerConfig": {
+    "address": "100.64.0.3/10",
+    "sshConfig": {},
+    "fqdn": "peer-c.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-c.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.3"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/nmdata.json
new file mode 100644
index 000000000..f3ee4d163
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/nmdata.json
@@ -0,0 +1,26 @@
+{
+  "Network": {"Serial": 15},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-c": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Policies": [
+    {
+      "ID": "pol-direct",
+      "PublicID": "pol-direct-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["443"],
+          "Bidirectional": true,
+          "SourceResource": {"ID": "peer-a", "Type": "peer"},
+          "DestinationResource": {"ID": "peer-b", "Type": "peer"}
+        }
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/case.json
new file mode 100644
index 000000000..a0eccba20
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "One-way udp/514 plus bidirectional tcp port-range 1000-2000 between the same groups; a disabled policy and a policy whose only rule is disabled must leave no trace.",
+  "peers": ["peer-a", "peer-srv"]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/golden/peer-a.json
new file mode 100644
index 000000000..5a2276d96
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/golden/peer-a.json
@@ -0,0 +1,81 @@
+{
+  "Serial": "14",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "jEboa3bOv65XRbq8+8JuagiwWz+mM7Fc5MUfBQbOf6Y=",
+      "allowedIps": [
+        "100.64.0.10/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-srv.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.10",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 1000,
+          "end": 2000
+        }
+      },
+      "PolicyID": "cG9sLXJhbmdl"
+    },
+    {
+      "PeerIP": "100.64.0.10",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 1000,
+          "end": 2000
+        }
+      },
+      "PolicyID": "cG9sLXJhbmdl"
+    },
+    {
+      "PeerIP": "100.64.0.10",
+      "Direction": "OUT",
+      "Protocol": "UDP",
+      "Port": "514",
+      "PolicyID": "cG9sLXN5c2xvZw=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/golden/peer-srv.json
new file mode 100644
index 000000000..6a89148f5
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/golden/peer-srv.json
@@ -0,0 +1,80 @@
+{
+  "Serial": "14",
+  "peerConfig": {
+    "address": "100.64.0.10/10",
+    "sshConfig": {},
+    "fqdn": "peer-srv.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 1000,
+          "end": 2000
+        }
+      },
+      "PolicyID": "cG9sLXJhbmdl"
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 1000,
+          "end": 2000
+        }
+      },
+      "PolicyID": "cG9sLXJhbmdl"
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "UDP",
+      "Port": "514",
+      "PolicyID": "cG9sLXN5c2xvZw=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/nmdata.json
new file mode 100644
index 000000000..f1262a0d7
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/nmdata.json
@@ -0,0 +1,74 @@
+{
+  "Network": {"Serial": 14},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a"]},
+    "grp-svc": {"Peers": ["peer-srv"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-syslog",
+      "PublicID": "pol-syslog-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "udp",
+          "Ports": ["514"],
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-svc"]
+        }
+      ]
+    },
+    {
+      "ID": "pol-range",
+      "PublicID": "pol-range-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "PortRanges": [{"Start": 1000, "End": 2000}],
+          "Bidirectional": true,
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-svc"]
+        }
+      ]
+    },
+    {
+      "ID": "pol-off",
+      "PublicID": "pol-off-pub",
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["9999"],
+          "Bidirectional": true,
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-svc"]
+        }
+      ]
+    },
+    {
+      "ID": "pol-rule-off",
+      "PublicID": "pol-rule-off-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Action": "accept",
+          "Protocol": "udp",
+          "Ports": ["1111"],
+          "Bidirectional": true,
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-svc"]
+        }
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/case.json
new file mode 100644
index 000000000..3307e0afe
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "Posture checks gate a policy's sources only, never its destinations. peer-srv-old would fail the version check, but it sits in the destination group, so peer-client must still receive it alongside peer-srv-new, and peer-srv-old must still receive peer-client. This asymmetry is deliberate in the compute path — destination peers are resolved with no posture checks passed in — and it is worth pinning because it is easy to assume a posture check protects both ends.",
+  "peers": ["peer-client", "peer-srv-old"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/golden/peer-client.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/golden/peer-client.json
new file mode 100644
index 000000000..73182932b
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/golden/peer-client.json
@@ -0,0 +1,93 @@
+{
+  "Serial": "37",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-client.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "MdeD+cDSnurizeZ/Zd7rEdIhs9VZViEnutUwkodqb1s=",
+      "allowedIps": [
+        "100.64.0.12/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-srv-new.netbird.test",
+      "agentVersion": "1.0.0"
+    },
+    {
+      "wgPubKey": "ph1eqUTlSeLQ6V9zLEUpck25m5K5sOQq+AHY879HZME=",
+      "allowedIps": [
+        "100.64.0.11/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-srv-old.netbird.test",
+      "agentVersion": "0.30.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-client.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv-new.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.12"
+          },
+          {
+            "Name": "peer-srv-old.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.11"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.11",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRlc3Q="
+    },
+    {
+      "PeerIP": "100.64.0.11",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRlc3Q="
+    },
+    {
+      "PeerIP": "100.64.0.12",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRlc3Q="
+    },
+    {
+      "PeerIP": "100.64.0.12",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRlc3Q="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/golden/peer-srv-old.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/golden/peer-srv-old.json
new file mode 100644
index 000000000..8d1ad4feb
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/golden/peer-srv-old.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "37",
+  "peerConfig": {
+    "address": "100.64.0.11/10",
+    "sshConfig": {},
+    "fqdn": "peer-srv-old.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "tKxuKEYQFPR8lCpcfVWBKVX0vGFKYXtTtFjXhoiu5zc=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-client.netbird.test",
+      "agentVersion": "1.0.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-client.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv-old.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.11"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRlc3Q="
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRlc3Q="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/nmdata.json
new file mode 100644
index 000000000..ca5ece21a
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/nmdata.json
@@ -0,0 +1,33 @@
+{
+  "Network": {"Serial": 37},
+  "Peers": {
+    "peer-client": {"IP": "100.64.0.1", "Meta": {"WtVersion": "1.0.0"}},
+    "peer-srv-old": {"IP": "100.64.0.11", "Meta": {"WtVersion": "0.30.0"}},
+    "peer-srv-new": {"IP": "100.64.0.12", "Meta": {"WtVersion": "1.0.0"}}
+  },
+  "Groups": {
+    "grp-clients": {"Peers": ["peer-client"]},
+    "grp-srv": {"Peers": ["peer-srv-old", "peer-srv-new"]}
+  },
+  "PostureChecks": {
+    "chk-version": {"Checks": {"NBVersionCheck": {"MinVersion": "0.45.0"}}}
+  },
+  "Policies": [
+    {
+      "ID": "pol-dest",
+      "Enabled": true,
+      "SourcePostureChecks": ["chk-version"],
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["443"],
+          "Bidirectional": true,
+          "Sources": ["grp-clients"],
+          "Destinations": ["grp-srv"]
+        }
+      ]
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/case.json
new file mode 100644
index 000000000..a661fa53e
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/case.json
@@ -0,0 +1,7 @@
+{
+  "description": "Source-side NB-version posture check: peer-b (0.40.0) fails the 0.45.0 minimum, so peer-c must not see it and peer-b itself gets no policy connectivity.",
+  "peers": [
+    "peer-b",
+    "peer-c"
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/golden/peer-b.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/golden/peer-b.json
new file mode 100644
index 000000000..201be294f
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/golden/peer-b.json
@@ -0,0 +1,34 @@
+{
+  "Serial": "6",
+  "peerConfig": {
+    "address": "100.64.0.2/10",
+    "sshConfig": {},
+    "fqdn": "peer-b.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-b.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/golden/peer-c.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/golden/peer-c.json
new file mode 100644
index 000000000..009d00490
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/golden/peer-c.json
@@ -0,0 +1,65 @@
+{
+  "Serial": "6",
+  "peerConfig": {
+    "address": "100.64.0.3/10",
+    "sshConfig": {},
+    "fqdn": "peer-c.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-c.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.3"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWdhdGVk"
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWdhdGVk"
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/nmdata.json
new file mode 100644
index 000000000..4962e8b6a
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/nmdata.json
@@ -0,0 +1,36 @@
+{
+  "Network": {"Serial": 6},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.40.0"}},
+    "peer-c": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a", "peer-b"]},
+    "grp-ops": {"Peers": ["peer-c"]}
+  },
+  "PostureChecks": {
+    "chk-ver": {"Checks": {"NBVersionCheck": {"MinVersion": "0.45.0"}}}
+  },
+  "PostureCheckXIDToPublicID": {"chk-ver": "chk-ver-pub"},
+  "Policies": [
+    {
+      "ID": "pol-gated",
+      "PublicID": "pol-gated-pub",
+      "Enabled": true,
+      "SourcePostureChecks": ["chk-ver"],
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Bidirectional": true,
+          "Ports": ["443"],
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-ops"]
+        }
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/case.json
new file mode 100644
index 000000000..9f7f86860
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "Geo location posture check in allow mode. An entry naming only a country matches the whole country, so peer-de passes; an entry naming a city must match that city exactly, so peer-us-ny passes while peer-us-bos does not. peer-fr matches nothing and fails. peer-nowhere has no location at all, which the check reports as an error, and an errored check denies — so it fails too.",
+  "peers": ["peer-srv", "peer-de", "peer-us-bos", "peer-nowhere"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-de.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-de.json
new file mode 100644
index 000000000..9a07139fe
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-de.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "31",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-de.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "jEboa3bOv65XRbq8+8JuagiwWz+mM7Fc5MUfBQbOf6Y=",
+      "allowedIps": [
+        "100.64.0.10/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-srv.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-de.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.10",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWdlbw=="
+    },
+    {
+      "PeerIP": "100.64.0.10",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWdlbw=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-nowhere.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-nowhere.json
new file mode 100644
index 000000000..aed97a69f
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-nowhere.json
@@ -0,0 +1,33 @@
+{
+  "Serial": "31",
+  "peerConfig": {
+    "address": "100.64.0.5/10",
+    "sshConfig": {},
+    "fqdn": "peer-nowhere.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-nowhere.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.5"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-srv.json
new file mode 100644
index 000000000..9f3fcb1ba
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-srv.json
@@ -0,0 +1,93 @@
+{
+  "Serial": "31",
+  "peerConfig": {
+    "address": "100.64.0.10/10",
+    "sshConfig": {},
+    "fqdn": "peer-srv.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "9nwvdE0wik6Fcs8Tw6WBnmOqGZmzdiTR4VZAdRBOJF4=",
+      "allowedIps": [
+        "100.64.0.2/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-us-ny.netbird.test",
+      "agentVersion": "0.60.0"
+    },
+    {
+      "wgPubKey": "s/f5frZqT3DT1o9QCuhA14Pj5GUa4JUsF4M3twrprmk=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-de.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-de.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          },
+          {
+            "Name": "peer-us-ny.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWdlbw=="
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWdlbw=="
+    },
+    {
+      "PeerIP": "100.64.0.2",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWdlbw=="
+    },
+    {
+      "PeerIP": "100.64.0.2",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWdlbw=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-us-bos.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-us-bos.json
new file mode 100644
index 000000000..624666c36
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-us-bos.json
@@ -0,0 +1,33 @@
+{
+  "Serial": "31",
+  "peerConfig": {
+    "address": "100.64.0.3/10",
+    "sshConfig": {},
+    "fqdn": "peer-us-bos.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-us-bos.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.3"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/nmdata.json
new file mode 100644
index 000000000..b92840266
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/nmdata.json
@@ -0,0 +1,46 @@
+{
+  "Network": {"Serial": 31},
+  "Peers": {
+    "peer-de": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}, "Location": {"CountryCode": "DE", "CityName": "Berlin"}},
+    "peer-us-ny": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}, "Location": {"CountryCode": "US", "CityName": "New York"}},
+    "peer-us-bos": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.60.0"}, "Location": {"CountryCode": "US", "CityName": "Boston"}},
+    "peer-fr": {"IP": "100.64.0.4", "Meta": {"WtVersion": "0.60.0"}, "Location": {"CountryCode": "FR", "CityName": "Paris"}},
+    "peer-nowhere": {"IP": "100.64.0.5", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-clients": {"Peers": ["peer-de", "peer-us-ny", "peer-us-bos", "peer-fr", "peer-nowhere"]},
+    "grp-srv": {"Peers": ["peer-srv"]}
+  },
+  "PostureChecks": {
+    "chk-geo": {
+      "Checks": {
+        "GeoLocationCheck": {
+          "Action": "allow",
+          "Locations": [
+            {"CountryCode": "DE"},
+            {"CountryCode": "US", "CityName": "New York"}
+          ]
+        }
+      }
+    }
+  },
+  "Policies": [
+    {
+      "ID": "pol-geo",
+      "Enabled": true,
+      "SourcePostureChecks": ["chk-geo"],
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["443"],
+          "Bidirectional": true,
+          "Sources": ["grp-clients"],
+          "Destinations": ["grp-srv"]
+        }
+      ]
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/case.json
new file mode 100644
index 000000000..f234b5280
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "Geo location posture check in deny mode: matching the list rejects, not matching passes, so peer-ru is excluded and peer-de is admitted. peer-nowhere has no location and fails here as well — a missing location is an error and errors deny in both modes, so deny mode is not a way to admit peers whose location is unknown.",
+  "peers": ["peer-srv", "peer-ru", "peer-nowhere"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-nowhere.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-nowhere.json
new file mode 100644
index 000000000..e73b77d9e
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-nowhere.json
@@ -0,0 +1,33 @@
+{
+  "Serial": "32",
+  "peerConfig": {
+    "address": "100.64.0.3/10",
+    "sshConfig": {},
+    "fqdn": "peer-nowhere.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-nowhere.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.3"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-ru.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-ru.json
new file mode 100644
index 000000000..721267f37
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-ru.json
@@ -0,0 +1,33 @@
+{
+  "Serial": "32",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-ru.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-ru.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-srv.json
new file mode 100644
index 000000000..93883369e
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-srv.json
@@ -0,0 +1,62 @@
+{
+  "Serial": "32",
+  "peerConfig": {
+    "address": "100.64.0.10/10",
+    "sshConfig": {},
+    "fqdn": "peer-srv.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "s/f5frZqT3DT1o9QCuhA14Pj5GUa4JUsF4M3twrprmk=",
+      "allowedIps": [
+        "100.64.0.2/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-de.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-de.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.2",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLWdlby1kZW55"
+    },
+    {
+      "PeerIP": "100.64.0.2",
+      "Direction": "OUT",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLWdlby1kZW55"
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/nmdata.json
new file mode 100644
index 000000000..5edc50a38
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/nmdata.json
@@ -0,0 +1,40 @@
+{
+  "Network": {"Serial": 32},
+  "Peers": {
+    "peer-ru": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}, "Location": {"CountryCode": "RU", "CityName": "Moscow"}},
+    "peer-de": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}, "Location": {"CountryCode": "DE", "CityName": "Berlin"}},
+    "peer-nowhere": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-clients": {"Peers": ["peer-ru", "peer-de", "peer-nowhere"]},
+    "grp-srv": {"Peers": ["peer-srv"]}
+  },
+  "PostureChecks": {
+    "chk-geo-deny": {
+      "Checks": {
+        "GeoLocationCheck": {
+          "Action": "deny",
+          "Locations": [{"CountryCode": "RU"}]
+        }
+      }
+    }
+  },
+  "Policies": [
+    {
+      "ID": "pol-geo-deny",
+      "Enabled": true,
+      "SourcePostureChecks": ["chk-geo-deny"],
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "all",
+          "Bidirectional": true,
+          "Sources": ["grp-clients"],
+          "Destinations": ["grp-srv"]
+        }
+      ]
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/case.json
new file mode 100644
index 000000000..c0eab5408
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "One posture check bundle holding two different checks. All checks in a bundle must pass, so only peer-both is admitted: peer-badgeo satisfies the version rule and peer-badversion satisfies the location rule, and each is still rejected on the other. This pins the AND semantics of a bundle rather than any-of.",
+  "peers": ["peer-srv", "peer-both", "peer-badgeo"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-badgeo.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-badgeo.json
new file mode 100644
index 000000000..6a6272909
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-badgeo.json
@@ -0,0 +1,33 @@
+{
+  "Serial": "35",
+  "peerConfig": {
+    "address": "100.64.0.2/10",
+    "sshConfig": {},
+    "fqdn": "peer-badgeo.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-badgeo.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-both.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-both.json
new file mode 100644
index 000000000..7e18e7cb8
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-both.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "35",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-both.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "jEboa3bOv65XRbq8+8JuagiwWz+mM7Fc5MUfBQbOf6Y=",
+      "allowedIps": [
+        "100.64.0.10/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-srv.netbird.test",
+      "agentVersion": "1.0.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-both.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.10",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWNvbWJv"
+    },
+    {
+      "PeerIP": "100.64.0.10",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWNvbWJv"
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-srv.json
new file mode 100644
index 000000000..e91e8f777
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-srv.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "35",
+  "peerConfig": {
+    "address": "100.64.0.10/10",
+    "sshConfig": {},
+    "fqdn": "peer-srv.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "ilmSCJoVLTTY/Am7or8ES8R0hL/OdfE2FDK197pxc5o=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-both.netbird.test",
+      "agentVersion": "1.0.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-both.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWNvbWJv"
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWNvbWJv"
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/nmdata.json
new file mode 100644
index 000000000..fbf7a5f1e
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/nmdata.json
@@ -0,0 +1,42 @@
+{
+  "Network": {"Serial": 35},
+  "Peers": {
+    "peer-both": {"IP": "100.64.0.1", "Meta": {"WtVersion": "1.0.0"}, "Location": {"CountryCode": "DE", "CityName": "Berlin"}},
+    "peer-badgeo": {"IP": "100.64.0.2", "Meta": {"WtVersion": "1.0.0"}, "Location": {"CountryCode": "FR", "CityName": "Paris"}},
+    "peer-badversion": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.30.0"}, "Location": {"CountryCode": "DE", "CityName": "Berlin"}},
+    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "1.0.0"}}
+  },
+  "Groups": {
+    "grp-clients": {"Peers": ["peer-both", "peer-badgeo", "peer-badversion"]},
+    "grp-srv": {"Peers": ["peer-srv"]}
+  },
+  "PostureChecks": {
+    "chk-combo": {
+      "Checks": {
+        "NBVersionCheck": {"MinVersion": "0.45.0"},
+        "GeoLocationCheck": {
+          "Action": "allow",
+          "Locations": [{"CountryCode": "DE"}]
+        }
+      }
+    }
+  },
+  "Policies": [
+    {
+      "ID": "pol-combo",
+      "Enabled": true,
+      "SourcePostureChecks": ["chk-combo"],
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["443"],
+          "Bidirectional": true,
+          "Sources": ["grp-clients"],
+          "Destinations": ["grp-srv"]
+        }
+      ]
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/case.json
new file mode 100644
index 000000000..d10bfc9ae
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "Peer network range posture check in allow mode over 192.168.0.0/16. peer-office passes on its reported interface network, and peer-by-connip passes on the address it connected from, which the check folds in as a single-host prefix — so either source of address information can satisfy it. peer-remote is outside the range and peer-noaddr reports no address at all, which errors and therefore denies. Note this policy is tcp/22 without the peer's SSH flag, so no authorized users appear.",
+  "peers": ["peer-srv", "peer-office", "peer-by-connip", "peer-remote"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-by-connip.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-by-connip.json
new file mode 100644
index 000000000..c9cb3ed0a
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-by-connip.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "33",
+  "peerConfig": {
+    "address": "100.64.0.3/10",
+    "sshConfig": {},
+    "fqdn": "peer-by-connip.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "jEboa3bOv65XRbq8+8JuagiwWz+mM7Fc5MUfBQbOf6Y=",
+      "allowedIps": [
+        "100.64.0.10/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-srv.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-by-connip.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.3"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.10",
+      "Protocol": "TCP",
+      "Port": "22",
+      "PolicyID": "cG9sLXJhbmdl"
+    },
+    {
+      "PeerIP": "100.64.0.10",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "22",
+      "PolicyID": "cG9sLXJhbmdl"
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-office.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-office.json
new file mode 100644
index 000000000..1b9834d12
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-office.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "33",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-office.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "jEboa3bOv65XRbq8+8JuagiwWz+mM7Fc5MUfBQbOf6Y=",
+      "allowedIps": [
+        "100.64.0.10/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-srv.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-office.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.10",
+      "Protocol": "TCP",
+      "Port": "22",
+      "PolicyID": "cG9sLXJhbmdl"
+    },
+    {
+      "PeerIP": "100.64.0.10",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "22",
+      "PolicyID": "cG9sLXJhbmdl"
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-remote.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-remote.json
new file mode 100644
index 000000000..134a0aff2
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-remote.json
@@ -0,0 +1,33 @@
+{
+  "Serial": "33",
+  "peerConfig": {
+    "address": "100.64.0.2/10",
+    "sshConfig": {},
+    "fqdn": "peer-remote.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-remote.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-srv.json
new file mode 100644
index 000000000..a8499e031
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-srv.json
@@ -0,0 +1,93 @@
+{
+  "Serial": "33",
+  "peerConfig": {
+    "address": "100.64.0.10/10",
+    "sshConfig": {},
+    "fqdn": "peer-srv.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "2Za6YHlJPJPv3hG/vDvdT0emXqIQADX9+wE8F1ZsgHU=",
+      "allowedIps": [
+        "100.64.0.3/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-by-connip.netbird.test",
+      "agentVersion": "0.60.0"
+    },
+    {
+      "wgPubKey": "iVuRGJEIX4iqqF3zV01cxAEgyjXW3X4rrMoal6iA4fc=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-office.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-by-connip.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.3"
+          },
+          {
+            "Name": "peer-office.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "Port": "22",
+      "PolicyID": "cG9sLXJhbmdl"
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "22",
+      "PolicyID": "cG9sLXJhbmdl"
+    },
+    {
+      "PeerIP": "100.64.0.3",
+      "Protocol": "TCP",
+      "Port": "22",
+      "PolicyID": "cG9sLXJhbmdl"
+    },
+    {
+      "PeerIP": "100.64.0.3",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "22",
+      "PolicyID": "cG9sLXJhbmdl"
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/nmdata.json
new file mode 100644
index 000000000..a258e855d
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/nmdata.json
@@ -0,0 +1,42 @@
+{
+  "Network": {"Serial": 33},
+  "Peers": {
+    "peer-office": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0", "NetworkAddresses": [{"NetIP": "192.168.1.10/24"}]}},
+    "peer-remote": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0", "NetworkAddresses": [{"NetIP": "10.0.0.5/8"}]}},
+    "peer-by-connip": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.60.0"}, "Location": {"ConnectionIP": "192.168.5.5"}},
+    "peer-noaddr": {"IP": "100.64.0.4", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-clients": {"Peers": ["peer-office", "peer-remote", "peer-by-connip", "peer-noaddr"]},
+    "grp-srv": {"Peers": ["peer-srv"]}
+  },
+  "PostureChecks": {
+    "chk-range": {
+      "Checks": {
+        "PeerNetworkRangeCheck": {
+          "Action": "allow",
+          "Ranges": ["192.168.0.0/16"]
+        }
+      }
+    }
+  },
+  "Policies": [
+    {
+      "ID": "pol-range",
+      "Enabled": true,
+      "SourcePostureChecks": ["chk-range"],
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["22"],
+          "Bidirectional": true,
+          "Sources": ["grp-clients"],
+          "Destinations": ["grp-srv"]
+        }
+      ]
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/case.json
new file mode 100644
index 000000000..b23a187d4
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "OS version posture check with per-OS minimums. peer-srv must see only the peers that satisfy their own platform's rule: the Linux peer on kernel 6.1 (the check compares the part before the first dash) and the macOS peer on 14.2. The old Linux and macOS peers fail. peer-win fails too even though its version looks modern, because the check defines no Windows minimum and a platform with no rule configured is treated as failing, not as unrestricted — a surprising rule worth freezing. Each rejected peer also loses its own view of peer-srv, since the policy is its only connectivity.",
+  "peers": ["peer-srv", "peer-lin-ok", "peer-lin-old", "peer-win"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-lin-ok.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-lin-ok.json
new file mode 100644
index 000000000..f299b9fcf
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-lin-ok.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "30",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-lin-ok.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "jEboa3bOv65XRbq8+8JuagiwWz+mM7Fc5MUfBQbOf6Y=",
+      "allowedIps": [
+        "100.64.0.10/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-srv.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-lin-ok.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.10",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLW9z"
+    },
+    {
+      "PeerIP": "100.64.0.10",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLW9z"
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-lin-old.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-lin-old.json
new file mode 100644
index 000000000..c0a5412fa
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-lin-old.json
@@ -0,0 +1,33 @@
+{
+  "Serial": "30",
+  "peerConfig": {
+    "address": "100.64.0.2/10",
+    "sshConfig": {},
+    "fqdn": "peer-lin-old.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-lin-old.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-srv.json
new file mode 100644
index 000000000..91883d20b
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-srv.json
@@ -0,0 +1,93 @@
+{
+  "Serial": "30",
+  "peerConfig": {
+    "address": "100.64.0.10/10",
+    "sshConfig": {},
+    "fqdn": "peer-srv.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "XpdB4aptfFjgsQOHfEO65dNozY8R7EIw3/alAnXjl+k=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-lin-ok.netbird.test",
+      "agentVersion": "0.60.0"
+    },
+    {
+      "wgPubKey": "gRO02HiaHUKq2xTbYJhPsmGj06bK0HGU2tgL0pKB2yQ=",
+      "allowedIps": [
+        "100.64.0.3/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-mac-ok.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-lin-ok.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-mac-ok.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.3"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLW9z"
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLW9z"
+    },
+    {
+      "PeerIP": "100.64.0.3",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLW9z"
+    },
+    {
+      "PeerIP": "100.64.0.3",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLW9z"
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-win.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-win.json
new file mode 100644
index 000000000..0b0cafb26
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-win.json
@@ -0,0 +1,33 @@
+{
+  "Serial": "30",
+  "peerConfig": {
+    "address": "100.64.0.5/10",
+    "sshConfig": {},
+    "fqdn": "peer-win.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-win.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.5"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/nmdata.json
new file mode 100644
index 000000000..018c0cc21
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/nmdata.json
@@ -0,0 +1,43 @@
+{
+  "Network": {"Serial": 30},
+  "Peers": {
+    "peer-lin-ok": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0", "GoOS": "linux", "KernelVersion": "6.1.0-arch1"}},
+    "peer-lin-old": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0", "GoOS": "linux", "KernelVersion": "5.4.0-generic"}},
+    "peer-mac-ok": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.60.0", "GoOS": "darwin", "OSVersion": "14.2"}},
+    "peer-mac-old": {"IP": "100.64.0.4", "Meta": {"WtVersion": "0.60.0", "GoOS": "darwin", "OSVersion": "12.0"}},
+    "peer-win": {"IP": "100.64.0.5", "Meta": {"WtVersion": "0.60.0", "GoOS": "windows", "KernelVersion": "10.0.19045"}},
+    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0", "GoOS": "linux", "KernelVersion": "6.1.0-arch1"}}
+  },
+  "Groups": {
+    "grp-clients": {"Peers": ["peer-lin-ok", "peer-lin-old", "peer-mac-ok", "peer-mac-old", "peer-win"]},
+    "grp-srv": {"Peers": ["peer-srv"]}
+  },
+  "PostureChecks": {
+    "chk-os": {
+      "Checks": {
+        "OSVersionCheck": {
+          "Linux": {"MinKernelVersion": "6.0.0"},
+          "Darwin": {"MinVersion": "13.0"}
+        }
+      }
+    }
+  },
+  "Policies": [
+    {
+      "ID": "pol-os",
+      "Enabled": true,
+      "SourcePostureChecks": ["chk-os"],
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["443"],
+          "Bidirectional": true,
+          "Sources": ["grp-clients"],
+          "Destinations": ["grp-srv"]
+        }
+      ]
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/case.json
new file mode 100644
index 000000000..b730389a6
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "Process posture check, which picks the path for the peer's own platform. peer-lin-running and peer-mac-running each have their platform's process running and pass. peer-lin-stopped reports the same file but not running, so it fails — presence of the binary is not enough. peer-bsd runs an unsupported operating system, which the check reports as an error, and errors deny.",
+  "peers": ["peer-srv", "peer-lin-running", "peer-lin-stopped", "peer-bsd"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-bsd.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-bsd.json
new file mode 100644
index 000000000..f75459d39
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-bsd.json
@@ -0,0 +1,33 @@
+{
+  "Serial": "34",
+  "peerConfig": {
+    "address": "100.64.0.4/10",
+    "sshConfig": {},
+    "fqdn": "peer-bsd.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-bsd.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.4"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-lin-running.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-lin-running.json
new file mode 100644
index 000000000..ddfa39472
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-lin-running.json
@@ -0,0 +1,62 @@
+{
+  "Serial": "34",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-lin-running.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "jEboa3bOv65XRbq8+8JuagiwWz+mM7Fc5MUfBQbOf6Y=",
+      "allowedIps": [
+        "100.64.0.10/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-srv.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-lin-running.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.10",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLXByb2M="
+    },
+    {
+      "PeerIP": "100.64.0.10",
+      "Direction": "OUT",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLXByb2M="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-lin-stopped.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-lin-stopped.json
new file mode 100644
index 000000000..828a933f7
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-lin-stopped.json
@@ -0,0 +1,33 @@
+{
+  "Serial": "34",
+  "peerConfig": {
+    "address": "100.64.0.2/10",
+    "sshConfig": {},
+    "fqdn": "peer-lin-stopped.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-lin-stopped.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-srv.json
new file mode 100644
index 000000000..6eb409623
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-srv.json
@@ -0,0 +1,89 @@
+{
+  "Serial": "34",
+  "peerConfig": {
+    "address": "100.64.0.10/10",
+    "sshConfig": {},
+    "fqdn": "peer-srv.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "d9LCTf7vwqctprOKyF95j17uPWpRjEeirHfB75RIGlk=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-lin-running.netbird.test",
+      "agentVersion": "0.60.0"
+    },
+    {
+      "wgPubKey": "rgP4xt50GcHp7fFgBoSt8yz5bp5AnOAVHMmOd+rTbR4=",
+      "allowedIps": [
+        "100.64.0.3/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-mac-running.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-lin-running.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-mac-running.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.3"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLXByb2M="
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLXByb2M="
+    },
+    {
+      "PeerIP": "100.64.0.3",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLXByb2M="
+    },
+    {
+      "PeerIP": "100.64.0.3",
+      "Direction": "OUT",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLXByb2M="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/nmdata.json
new file mode 100644
index 000000000..b112e2235
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/nmdata.json
@@ -0,0 +1,70 @@
+{
+  "Network": {"Serial": 34},
+  "Peers": {
+    "peer-lin-running": {
+      "IP": "100.64.0.1",
+      "Meta": {
+        "WtVersion": "0.60.0",
+        "GoOS": "linux",
+        "Files": [{"Path": "/usr/bin/agent", "ProcessIsRunning": true}]
+      }
+    },
+    "peer-lin-stopped": {
+      "IP": "100.64.0.2",
+      "Meta": {
+        "WtVersion": "0.60.0",
+        "GoOS": "linux",
+        "Files": [{"Path": "/usr/bin/agent"}]
+      }
+    },
+    "peer-mac-running": {
+      "IP": "100.64.0.3",
+      "Meta": {
+        "WtVersion": "0.60.0",
+        "GoOS": "darwin",
+        "Files": [{"Path": "/Applications/Agent.app", "ProcessIsRunning": true}]
+      }
+    },
+    "peer-bsd": {
+      "IP": "100.64.0.4",
+      "Meta": {
+        "WtVersion": "0.60.0",
+        "GoOS": "freebsd",
+        "Files": [{"Path": "/usr/bin/agent", "ProcessIsRunning": true}]
+      }
+    },
+    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0", "GoOS": "linux"}}
+  },
+  "Groups": {
+    "grp-clients": {"Peers": ["peer-lin-running", "peer-lin-stopped", "peer-mac-running", "peer-bsd"]},
+    "grp-srv": {"Peers": ["peer-srv"]}
+  },
+  "PostureChecks": {
+    "chk-proc": {
+      "Checks": {
+        "ProcessCheck": {
+          "Processes": [
+            {"LinuxPath": "/usr/bin/agent", "MacPath": "/Applications/Agent.app"}
+          ]
+        }
+      }
+    }
+  },
+  "Policies": [
+    {
+      "ID": "pol-proc",
+      "Enabled": true,
+      "SourcePostureChecks": ["chk-proc"],
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "all",
+          "Bidirectional": true,
+          "Sources": ["grp-clients"],
+          "Destinations": ["grp-srv"]
+        }
+      ]
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/case.json
new file mode 100644
index 000000000..b30b06243
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "Posture check on a policy granting access to a network resource. peer-ok must receive the route to the resource through peer-r, while peer-bad fails the version check and must receive no route at all. The router's route firewall rule must narrow its SourceRanges to peer-ok's address only — a peer rejected by posture must not be permitted through the routing peer either, which is the enforcement that actually matters.",
+  "peers": ["peer-ok", "peer-bad", "peer-r"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-bad.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-bad.json
new file mode 100644
index 000000000..b1aa1a494
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-bad.json
@@ -0,0 +1,34 @@
+{
+  "Serial": "38",
+  "peerConfig": {
+    "address": "100.64.0.2/10",
+    "sshConfig": {},
+    "fqdn": "peer-bad.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-bad.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-ok.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-ok.json
new file mode 100644
index 000000000..6aa688eef
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-ok.json
@@ -0,0 +1,56 @@
+{
+  "Serial": "38",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-ok.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "allowedIps": [
+        "100.64.0.9/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-r.netbird.test",
+      "agentVersion": "1.0.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-db:peer-r",
+      "Network": "10.80.0.0/24",
+      "NetworkType": "1",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "db-subnet",
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-ok.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-r.json
new file mode 100644
index 000000000..192304ca6
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-r.json
@@ -0,0 +1,69 @@
+{
+  "Serial": "38",
+  "peerConfig": {
+    "address": "100.64.0.9/10",
+    "sshConfig": {},
+    "fqdn": "peer-r.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "XPXITqbev8jVtIkumbPt5vpohe2OHWvhNGO3z2mgcxM=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-ok.netbird.test",
+      "agentVersion": "1.0.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-db:peer-r",
+      "Network": "10.80.0.0/24",
+      "NetworkType": "1",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "db-subnet",
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-r.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.9"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRules": [
+    {
+      "sourceRanges": [
+        "100.64.0.1/32"
+      ],
+      "destination": "10.80.0.0/24",
+      "protocol": "TCP",
+      "portInfo": {
+        "port": 5432
+      },
+      "PolicyID": "cG9sLXJlcw==",
+      "RouteID": "res-db:peer-r"
+    }
+  ],
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/nmdata.json
new file mode 100644
index 000000000..840c5a7f8
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/nmdata.json
@@ -0,0 +1,48 @@
+{
+  "Network": {"Serial": 38},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-ok": {"IP": "100.64.0.1", "Meta": {"WtVersion": "1.0.0"}},
+    "peer-bad": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.30.0"}},
+    "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "1.0.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-ok", "peer-bad"]}
+  },
+  "PostureChecks": {
+    "chk-version": {"Checks": {"NBVersionCheck": {"MinVersion": "0.45.0"}}}
+  },
+  "Policies": [
+    {
+      "ID": "pol-res",
+      "Enabled": true,
+      "SourcePostureChecks": ["chk-version"],
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["5432"],
+          "Sources": ["grp-dev"],
+          "DestinationResource": {"ID": "res-db", "Type": "subnet"}
+        }
+      ]
+    }
+  ],
+  "ResourcePolicies": {"res-db": [{"ID": "pol-res"}]},
+  "NetworkResources": [
+    {
+      "ID": "res-db",
+      "NetworkID": "net-1",
+      "Name": "db-subnet",
+      "Type": "subnet",
+      "Prefix": "10.80.0.0/24",
+      "Enabled": true
+    }
+  ],
+  "Routers": {
+    "net-1": {
+      "peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true}
+    }
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/case.json
new file mode 100644
index 000000000..203b89533
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "The same peer under two policies carrying different posture checks. peer-x is on an old agent version but in an allowed country, so the version-gated policy rejects it while the location-gated one admits it: it must reach peer-srv-b on 8443 and not peer-srv-a at all. Failing one policy's check must not leak into another policy's decision. peer-srv-a correspondingly sees nobody, peer-srv-b sees peer-x.",
+  "peers": ["peer-x", "peer-srv-a", "peer-srv-b"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-srv-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-srv-a.json
new file mode 100644
index 000000000..8008db39c
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-srv-a.json
@@ -0,0 +1,33 @@
+{
+  "Serial": "36",
+  "peerConfig": {
+    "address": "100.64.0.11/10",
+    "sshConfig": {},
+    "fqdn": "peer-srv-a.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-srv-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.11"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-srv-b.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-srv-b.json
new file mode 100644
index 000000000..c7bc45742
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-srv-b.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "36",
+  "peerConfig": {
+    "address": "100.64.0.12/10",
+    "sshConfig": {},
+    "fqdn": "peer-srv-b.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "N1oKwtIwXdTDF0HdKDss0gPhxkqz+/Z/91734QZVCng=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-x.netbird.test",
+      "agentVersion": "0.40.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-srv-b.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.12"
+          },
+          {
+            "Name": "peer-x.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "Port": "8443",
+      "PolicyID": "cG9sLWxlbmllbnQ="
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "8443",
+      "PolicyID": "cG9sLWxlbmllbnQ="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-x.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-x.json
new file mode 100644
index 000000000..461454233
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-x.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "36",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-x.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "fT/Mb0QBqXGx2q06gXqizvlXp5uz+ErGCaKmCEXVbMk=",
+      "allowedIps": [
+        "100.64.0.12/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-srv-b.netbird.test",
+      "agentVersion": "1.0.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-srv-b.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.12"
+          },
+          {
+            "Name": "peer-x.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.12",
+      "Protocol": "TCP",
+      "Port": "8443",
+      "PolicyID": "cG9sLWxlbmllbnQ="
+    },
+    {
+      "PeerIP": "100.64.0.12",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "8443",
+      "PolicyID": "cG9sLWxlbmllbnQ="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/nmdata.json
new file mode 100644
index 000000000..2e0512194
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/nmdata.json
@@ -0,0 +1,55 @@
+{
+  "Network": {"Serial": 36},
+  "Peers": {
+    "peer-x": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.40.0"}, "Location": {"CountryCode": "DE", "CityName": "Berlin"}},
+    "peer-srv-a": {"IP": "100.64.0.11", "Meta": {"WtVersion": "1.0.0"}},
+    "peer-srv-b": {"IP": "100.64.0.12", "Meta": {"WtVersion": "1.0.0"}}
+  },
+  "Groups": {
+    "grp-clients": {"Peers": ["peer-x"]},
+    "grp-srv-a": {"Peers": ["peer-srv-a"]},
+    "grp-srv-b": {"Peers": ["peer-srv-b"]}
+  },
+  "PostureChecks": {
+    "chk-version": {"Checks": {"NBVersionCheck": {"MinVersion": "0.45.0"}}},
+    "chk-geo": {
+      "Checks": {
+        "GeoLocationCheck": {"Action": "allow", "Locations": [{"CountryCode": "DE"}]}
+      }
+    }
+  },
+  "Policies": [
+    {
+      "ID": "pol-strict",
+      "Enabled": true,
+      "SourcePostureChecks": ["chk-version"],
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["443"],
+          "Bidirectional": true,
+          "Sources": ["grp-clients"],
+          "Destinations": ["grp-srv-a"]
+        }
+      ]
+    },
+    {
+      "ID": "pol-lenient",
+      "Enabled": true,
+      "SourcePostureChecks": ["chk-geo"],
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["8443"],
+          "Bidirectional": true,
+          "Sources": ["grp-clients"],
+          "Destinations": ["grp-srv-b"]
+        }
+      ]
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/case.json
new file mode 100644
index 000000000..5f9e98ea7
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/case.json
@@ -0,0 +1,7 @@
+{
+  "description": "A reverse-proxy service targeting a domain network resource. The synthesised proxy-access ACL is a resource policy too: on the account path the resource-policy map was built after injection, so the routing peer must carry a route firewall rule sourced from the proxy peer for the resource's domain. The store reads the policies table and ResourcePolicies never holds it, so only the synthesis puts it there.",
+  "peers": [
+    "router-peer",
+    "proxy-peer"
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/golden/proxy-peer.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/golden/proxy-peer.json
new file mode 100644
index 000000000..a9e74061f
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/golden/proxy-peer.json
@@ -0,0 +1,60 @@
+{
+  "Serial": "32",
+  "peerConfig": {
+    "address": "100.64.0.99/10",
+    "sshConfig": {},
+    "fqdn": "proxy-peer.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "4IuEBozN3DjkJXkJtBp/9Ekkr0zkfucwk9gqxWhV1hE=",
+      "allowedIps": [
+        "100.64.0.9/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "router-peer.netbird.test",
+      "lazyState": "LazyStateLazy",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-domain:router-peer",
+      "Network": "192.0.2.0/32",
+      "NetworkType": "3",
+      "Peer": "4IuEBozN3DjkJXkJtBp/9Ekkr0zkfucwk9gqxWhV1hE=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "app-domain",
+      "Domains": [
+        "app.internal"
+      ],
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "proxy-peer.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.99"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/golden/router-peer.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/golden/router-peer.json
new file mode 100644
index 000000000..d0bb48a3b
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/golden/router-peer.json
@@ -0,0 +1,80 @@
+{
+  "Serial": "32",
+  "peerConfig": {
+    "address": "100.64.0.9/10",
+    "sshConfig": {},
+    "fqdn": "router-peer.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "MgrwmZOHFZ+i0SXrfbBOcATxBAQsWKllrGL/32GvlxY=",
+      "allowedIps": [
+        "100.64.0.99/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "proxy-peer.netbird.test",
+      "lazyState": "LazyStateLazy",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-domain:router-peer",
+      "Network": "192.0.2.0/32",
+      "NetworkType": "3",
+      "Peer": "4IuEBozN3DjkJXkJtBp/9Ekkr0zkfucwk9gqxWhV1hE=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "app-domain",
+      "Domains": [
+        "app.internal"
+      ],
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "router-peer.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.9"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRules": [
+    {
+      "sourceRanges": [
+        "100.64.0.99/32"
+      ],
+      "destination": "192.0.2.0/32",
+      "protocol": "TCP",
+      "portInfo": {
+        "range": {
+          "start": 443,
+          "end": 443
+        }
+      },
+      "isDynamic": true,
+      "domains": [
+        "app.internal"
+      ],
+      "PolicyID": "cHJveHktYWNjZXNzLXN2Yy0xLXByb3h5LXBlZXIt",
+      "RouteID": "res-domain:router-peer"
+    }
+  ],
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/nmdata.json
new file mode 100644
index 000000000..cbf729d9f
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/nmdata.json
@@ -0,0 +1,44 @@
+{
+  "Network": {"Serial": 32},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "router-peer": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}},
+    "proxy-peer": {
+      "IP": "100.64.0.99",
+      "Meta": {"WtVersion": "0.60.0"},
+      "ProxyMeta": {"Embedded": true, "Cluster": "eu.proxy.netbird.io"}
+    }
+  },
+  "NetworkResources": [
+    {
+      "ID": "res-domain",
+      "NetworkID": "net-1",
+      "Name": "app-domain",
+      "Type": "domain",
+      "Domain": "app.internal",
+      "Enabled": true
+    }
+  ],
+  "Routers": {
+    "net-1": {
+      "router-peer": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true}
+    }
+  },
+  "ProxyTargetedDomainResourceIDs": {"res-domain": {}},
+  "Services": [
+    {
+      "ID": "svc-1",
+      "Enabled": true,
+      "Mode": "http",
+      "ProxyCluster": "eu.proxy.netbird.io",
+      "Targets": [
+        {
+          "Enabled": true,
+          "Protocol": "https",
+          "TargetID": "res-domain",
+          "TargetType": "domain"
+        }
+      ]
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/case.json
new file mode 100644
index 000000000..d8450505f
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/case.json
@@ -0,0 +1,7 @@
+{
+  "description": "A reverse-proxy service targeting a peer. The proxy-access ACL is synthesised from Services, never loaded from the policies table, and is what lets the cluster's embedded proxy peer reach the target on the target's port: proxy-peer gets an OUT rule to app-peer on TCP 8080 and app-peer the matching IN rule. Without the synthesis both maps are empty of each other.",
+  "peers": [
+    "proxy-peer",
+    "app-peer"
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/golden/app-peer.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/golden/app-peer.json
new file mode 100644
index 000000000..0e71a62ba
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/golden/app-peer.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "30",
+  "peerConfig": {
+    "address": "100.64.0.10/10",
+    "sshConfig": {},
+    "fqdn": "app-peer.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "MgrwmZOHFZ+i0SXrfbBOcATxBAQsWKllrGL/32GvlxY=",
+      "allowedIps": [
+        "100.64.0.99/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "proxy-peer.netbird.test",
+      "lazyState": "LazyStateLazy",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "app-peer.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          },
+          {
+            "Name": "proxy-peer.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.99"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.99",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 8080,
+          "end": 8080
+        }
+      },
+      "PolicyID": "cHJveHktYWNjZXNzLXN2Yy0xLXByb3h5LXBlZXIt"
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/golden/proxy-peer.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/golden/proxy-peer.json
new file mode 100644
index 000000000..4c6053319
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/golden/proxy-peer.json
@@ -0,0 +1,65 @@
+{
+  "Serial": "30",
+  "peerConfig": {
+    "address": "100.64.0.99/10",
+    "sshConfig": {},
+    "fqdn": "proxy-peer.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "/wFxrqMtMwWNZak/f0UDddUkCZMTmxNuiuk4/RGGNcY=",
+      "allowedIps": [
+        "100.64.0.10/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "app-peer.netbird.test",
+      "lazyState": "LazyStateLazy",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "app-peer.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          },
+          {
+            "Name": "proxy-peer.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.99"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.10",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 8080,
+          "end": 8080
+        }
+      },
+      "PolicyID": "cHJveHktYWNjZXNzLXN2Yy0xLXByb3h5LXBlZXIt"
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/nmdata.json
new file mode 100644
index 000000000..b4645fb90
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/nmdata.json
@@ -0,0 +1,29 @@
+{
+  "Network": {"Serial": 30},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "proxy-peer": {
+      "IP": "100.64.0.99",
+      "Meta": {"WtVersion": "0.60.0"},
+      "ProxyMeta": {"Embedded": true, "Cluster": "eu.proxy.netbird.io"}
+    },
+    "app-peer": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Services": [
+    {
+      "ID": "svc-1",
+      "Enabled": true,
+      "Mode": "http",
+      "ProxyCluster": "eu.proxy.netbird.io",
+      "Targets": [
+        {
+          "Enabled": true,
+          "Port": 8080,
+          "Protocol": "http",
+          "TargetID": "app-peer",
+          "TargetType": "peer"
+        }
+      ]
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/case.json
new file mode 100644
index 000000000..8ef26f4cc
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/case.json
@@ -0,0 +1,7 @@
+{
+  "description": "A private reverse-proxy service. The private-access ACL is synthesised from Services, never loaded from the policies table, and is what lets the service's AccessGroups reach the cluster's embedded proxy peer on TCP 80 and 443: user-peer gets OUT rules on both ports and proxy-peer the matching IN rules. Without the synthesis both maps are empty of each other.",
+  "peers": [
+    "user-peer",
+    "proxy-peer"
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/golden/proxy-peer.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/golden/proxy-peer.json
new file mode 100644
index 000000000..7022f0c70
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/golden/proxy-peer.json
@@ -0,0 +1,75 @@
+{
+  "Serial": "31",
+  "peerConfig": {
+    "address": "100.64.0.99/10",
+    "sshConfig": {},
+    "fqdn": "proxy-peer.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "v19TN/CymWAs/WppcLjz3atM+t4ySNdImGtoPu4wnT8=",
+      "allowedIps": [
+        "100.64.0.10/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "user-peer.netbird.test",
+      "lazyState": "LazyStateLazy",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "proxy-peer.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.99"
+          },
+          {
+            "Name": "user-peer.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.10",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 443,
+          "end": 443
+        }
+      },
+      "PolicyID": "cHJpdmF0ZS1hY2Nlc3Mtc3ZjLTEtcHJveHktcGVlcg=="
+    },
+    {
+      "PeerIP": "100.64.0.10",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 80,
+          "end": 80
+        }
+      },
+      "PolicyID": "cHJpdmF0ZS1hY2Nlc3Mtc3ZjLTEtcHJveHktcGVlcg=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/golden/user-peer.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/golden/user-peer.json
new file mode 100644
index 000000000..7197abf22
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/golden/user-peer.json
@@ -0,0 +1,77 @@
+{
+  "Serial": "31",
+  "peerConfig": {
+    "address": "100.64.0.10/10",
+    "sshConfig": {},
+    "fqdn": "user-peer.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "MgrwmZOHFZ+i0SXrfbBOcATxBAQsWKllrGL/32GvlxY=",
+      "allowedIps": [
+        "100.64.0.99/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "proxy-peer.netbird.test",
+      "lazyState": "LazyStateLazy",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "proxy-peer.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.99"
+          },
+          {
+            "Name": "user-peer.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.99",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 443,
+          "end": 443
+        }
+      },
+      "PolicyID": "cHJpdmF0ZS1hY2Nlc3Mtc3ZjLTEtcHJveHktcGVlcg=="
+    },
+    {
+      "PeerIP": "100.64.0.99",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 80,
+          "end": 80
+        }
+      },
+      "PolicyID": "cHJpdmF0ZS1hY2Nlc3Mtc3ZjLTEtcHJveHktcGVlcg=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/nmdata.json
new file mode 100644
index 000000000..066849bd4
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/nmdata.json
@@ -0,0 +1,26 @@
+{
+  "Network": {"Serial": 31},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "user-peer": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}},
+    "other-peer": {"IP": "100.64.0.11", "Meta": {"WtVersion": "0.60.0"}},
+    "proxy-peer": {
+      "IP": "100.64.0.99",
+      "Meta": {"WtVersion": "0.60.0"},
+      "ProxyMeta": {"Embedded": true, "Cluster": "eu.proxy.netbird.io"}
+    }
+  },
+  "Groups": {
+    "grp-admins": {"Peers": ["user-peer"]}
+  },
+  "Services": [
+    {
+      "ID": "svc-1",
+      "Enabled": true,
+      "Private": true,
+      "Mode": "http",
+      "ProxyCluster": "eu.proxy.netbird.io",
+      "AccessGroups": ["grp-admins", "grp-deleted"]
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/case.json
new file mode 100644
index 000000000..8d99a239c
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "Classic route with access control groups: instead of the wide-open default permit, peer-r's route firewall rule must be narrowed to the policy that targets the ACL group — protocol and port from that rule, SourceRanges limited to the two source peers. peer-a still receives the route itself.",
+  "peers": ["peer-a", "peer-r"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/golden/peer-a.json
new file mode 100644
index 000000000..c52aecce9
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/golden/peer-a.json
@@ -0,0 +1,76 @@
+{
+  "Serial": "27",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "allowedIps": [
+        "100.64.0.9/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-r.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "rt-acl",
+      "Network": "10.70.0.0/24",
+      "NetworkType": "1",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "db-net"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-r.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.9"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.9",
+      "Protocol": "TCP",
+      "Port": "3306",
+      "PolicyID": "cG9sLWFjbA=="
+    },
+    {
+      "PeerIP": "100.64.0.9",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "3306",
+      "PolicyID": "cG9sLWFjbA=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/golden/peer-r.json
new file mode 100644
index 000000000..cc03ef08c
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/golden/peer-r.json
@@ -0,0 +1,119 @@
+{
+  "Serial": "27",
+  "peerConfig": {
+    "address": "100.64.0.9/10",
+    "sshConfig": {},
+    "fqdn": "peer-r.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "AvldyrZ12Pf90jzf3AXmhPwg3UcI+jtJHfbpBlupvko=",
+      "allowedIps": [
+        "100.64.0.2/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-b.netbird.test",
+      "agentVersion": "0.60.0"
+    },
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "rt-acl",
+      "Network": "10.70.0.0/24",
+      "NetworkType": "1",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "db-net"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-b.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          },
+          {
+            "Name": "peer-r.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.9"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "Port": "3306",
+      "PolicyID": "cG9sLWFjbA=="
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "3306",
+      "PolicyID": "cG9sLWFjbA=="
+    },
+    {
+      "PeerIP": "100.64.0.2",
+      "Protocol": "TCP",
+      "Port": "3306",
+      "PolicyID": "cG9sLWFjbA=="
+    },
+    {
+      "PeerIP": "100.64.0.2",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "3306",
+      "PolicyID": "cG9sLWFjbA=="
+    }
+  ],
+  "routesFirewallRules": [
+    {
+      "sourceRanges": [
+        "100.64.0.1/32",
+        "100.64.0.2/32"
+      ],
+      "destination": "10.70.0.0/24",
+      "protocol": "TCP",
+      "portInfo": {
+        "port": 3306
+      },
+      "PolicyID": "cG9sLWFjbA==",
+      "RouteID": "rt-acl"
+    }
+  ],
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/nmdata.json
new file mode 100644
index 000000000..d7bfd0d08
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/nmdata.json
@@ -0,0 +1,45 @@
+{
+  "Network": {"Serial": 27},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a", "peer-b"]},
+    "grp-acl": {"Peers": ["peer-r"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-acl",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["3306"],
+          "Bidirectional": true,
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-acl"]
+        }
+      ]
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "rt-acl",
+      "NetID": "db-net",
+      "Network": "10.70.0.0/24",
+      "NetworkType": 1,
+      "Peer": "peer-r",
+      "PeerID": "peer-r",
+      "Groups": ["grp-dev"],
+      "AccessControlGroups": ["grp-acl"],
+      "Metric": 9999,
+      "Masquerade": true,
+      "Enabled": true
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/case.json
new file mode 100644
index 000000000..52d45346a
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "Classic route served by a peer group instead of one peer: each routing peer's copy takes the route id with its own peer id appended and drops the PeerGroups field, and the distribution group's peer-a must receive both copies as an HA pair. Each router receives only its own copy plus a default-permit route firewall rule, because the route carries no access control groups. A policy connecting the two groups is required — route distribution follows peers the target may already talk to.",
+  "peers": ["peer-a", "peer-r1"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/golden/peer-a.json
new file mode 100644
index 000000000..fd9e32274
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/golden/peer-a.json
@@ -0,0 +1,114 @@
+{
+  "Serial": "26",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "Iu6Lj1HqDXfgDh5RppHCRI6RO3lMlZBCndRNrdS7QUI=",
+      "allowedIps": [
+        "100.64.0.11/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-r1.netbird.test",
+      "agentVersion": "0.60.0"
+    },
+    {
+      "wgPubKey": "YC6sbWtvpB2d6W/XqsNIV9crsrXBoVDdwmhCoszjCps=",
+      "allowedIps": [
+        "100.64.0.12/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-r2.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "rt-ha:peer-r1",
+      "Network": "10.60.0.0/24",
+      "NetworkType": "1",
+      "Peer": "Iu6Lj1HqDXfgDh5RppHCRI6RO3lMlZBCndRNrdS7QUI=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "ha-net"
+    },
+    {
+      "ID": "rt-ha:peer-r2",
+      "Network": "10.60.0.0/24",
+      "NetworkType": "1",
+      "Peer": "YC6sbWtvpB2d6W/XqsNIV9crsrXBoVDdwmhCoszjCps=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "ha-net"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-r1.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.11"
+          },
+          {
+            "Name": "peer-r2.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.12"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.11",
+      "Protocol": "TCP",
+      "Port": "8080",
+      "PolicyID": "cG9sLWNvbm4="
+    },
+    {
+      "PeerIP": "100.64.0.11",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "8080",
+      "PolicyID": "cG9sLWNvbm4="
+    },
+    {
+      "PeerIP": "100.64.0.12",
+      "Protocol": "TCP",
+      "Port": "8080",
+      "PolicyID": "cG9sLWNvbm4="
+    },
+    {
+      "PeerIP": "100.64.0.12",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "8080",
+      "PolicyID": "cG9sLWNvbm4="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/golden/peer-r1.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/golden/peer-r1.json
new file mode 100644
index 000000000..bd8bdecd0
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/golden/peer-r1.json
@@ -0,0 +1,93 @@
+{
+  "Serial": "26",
+  "peerConfig": {
+    "address": "100.64.0.11/10",
+    "sshConfig": {},
+    "fqdn": "peer-r1.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "rt-ha:peer-r1",
+      "Network": "10.60.0.0/24",
+      "NetworkType": "1",
+      "Peer": "Iu6Lj1HqDXfgDh5RppHCRI6RO3lMlZBCndRNrdS7QUI=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "ha-net"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-r1.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.11"
+          },
+          {
+            "Name": "peer-r2.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.12"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "Port": "8080",
+      "PolicyID": "cG9sLWNvbm4="
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "8080",
+      "PolicyID": "cG9sLWNvbm4="
+    }
+  ],
+  "routesFirewallRules": [
+    {
+      "sourceRanges": [
+        "0.0.0.0/0"
+      ],
+      "destination": "10.60.0.0/24",
+      "protocol": "ALL",
+      "portInfo": {},
+      "RouteID": "rt-ha:peer-r1"
+    }
+  ],
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/nmdata.json
new file mode 100644
index 000000000..8b81a585f
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/nmdata.json
@@ -0,0 +1,43 @@
+{
+  "Network": {"Serial": 26},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-r1": {"IP": "100.64.0.11", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-r2": {"IP": "100.64.0.12", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a"]},
+    "grp-routers": {"Peers": ["peer-r1", "peer-r2"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-conn",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["8080"],
+          "Bidirectional": true,
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-routers"]
+        }
+      ]
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "rt-ha",
+      "NetID": "ha-net",
+      "Network": "10.60.0.0/24",
+      "NetworkType": 1,
+      "PeerGroups": ["grp-routers"],
+      "Groups": ["grp-dev"],
+      "Metric": 9999,
+      "Masquerade": true,
+      "Enabled": true
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/case.json
new file mode 100644
index 000000000..a94d3653f
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/case.json
@@ -0,0 +1,7 @@
+{
+  "description": "Classic route distributed to grp-dev plus a network resource behind router peer-r with a resource policy; peer-a gets routes and route firewall rules, peer-r gets the routing-peer view.",
+  "peers": [
+    "peer-a",
+    "peer-r"
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/golden/peer-a.json
new file mode 100644
index 000000000..9426bd846
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/golden/peer-a.json
@@ -0,0 +1,86 @@
+{
+  "Serial": "7",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "allowedIps": [
+        "100.64.0.9/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-r.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-db:peer-r",
+      "Network": "10.10.0.0/24",
+      "NetworkType": "1",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "db",
+      "keepRoute": true
+    },
+    {
+      "ID": "rt-1",
+      "Network": "10.20.0.0/24",
+      "NetworkType": "1",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "office-net"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-r.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.9"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.9",
+      "Protocol": "TCP",
+      "Port": "8080",
+      "PolicyID": "cG9sLWNvbm4="
+    },
+    {
+      "PeerIP": "100.64.0.9",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "8080",
+      "PolicyID": "cG9sLWNvbm4="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/golden/peer-r.json
new file mode 100644
index 000000000..e5971796d
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/golden/peer-r.json
@@ -0,0 +1,138 @@
+{
+  "Serial": "7",
+  "peerConfig": {
+    "address": "100.64.0.9/10",
+    "sshConfig": {},
+    "fqdn": "peer-r.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "AvldyrZ12Pf90jzf3AXmhPwg3UcI+jtJHfbpBlupvko=",
+      "allowedIps": [
+        "100.64.0.2/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-b.netbird.test",
+      "agentVersion": "0.60.0"
+    },
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-db:peer-r",
+      "Network": "10.10.0.0/24",
+      "NetworkType": "1",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "db",
+      "keepRoute": true
+    },
+    {
+      "ID": "rt-1",
+      "Network": "10.20.0.0/24",
+      "NetworkType": "1",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "office-net"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-b.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          },
+          {
+            "Name": "peer-r.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.9"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "Port": "8080",
+      "PolicyID": "cG9sLWNvbm4="
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "8080",
+      "PolicyID": "cG9sLWNvbm4="
+    },
+    {
+      "PeerIP": "100.64.0.2",
+      "Protocol": "TCP",
+      "Port": "8080",
+      "PolicyID": "cG9sLWNvbm4="
+    },
+    {
+      "PeerIP": "100.64.0.2",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "8080",
+      "PolicyID": "cG9sLWNvbm4="
+    }
+  ],
+  "routesFirewallRules": [
+    {
+      "sourceRanges": [
+        "0.0.0.0/0"
+      ],
+      "destination": "10.20.0.0/24",
+      "protocol": "ALL",
+      "portInfo": {},
+      "RouteID": "rt-1"
+    },
+    {
+      "sourceRanges": [
+        "100.64.0.1/32",
+        "100.64.0.2/32"
+      ],
+      "destination": "10.10.0.0/24",
+      "protocol": "TCP",
+      "portInfo": {
+        "port": 5432
+      },
+      "PolicyID": "cG9sLWRi",
+      "RouteID": "res-db:peer-r"
+    }
+  ],
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/nmdata.json
new file mode 100644
index 000000000..795047f50
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/nmdata.json
@@ -0,0 +1,97 @@
+{
+  "Network": {"Serial": 7},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a", "peer-b"]},
+    "grp-routers": {"Peers": ["peer-r"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-conn",
+      "PublicID": "pol-conn-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Bidirectional": true,
+          "Ports": ["8080"],
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-routers"]
+        }
+      ]
+    },
+    {
+      "ID": "pol-db",
+      "PublicID": "pol-db-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["5432"],
+          "Sources": ["grp-dev"],
+          "DestinationResource": {"ID": "res-db", "Type": "subnet"}
+        }
+      ]
+    }
+  ],
+  "ResourcePolicies": {
+    "res-db": [
+      {
+        "ID": "pol-db",
+        "PublicID": "pol-db-pub",
+        "Enabled": true,
+        "Rules": [
+          {
+            "Enabled": true,
+            "Action": "accept",
+            "Protocol": "tcp",
+            "Ports": ["5432"],
+            "Sources": ["grp-dev"],
+            "DestinationResource": {"ID": "res-db", "Type": "subnet"}
+          }
+        ]
+      }
+    ]
+  },
+  "Routes": [
+    {
+      "ID": "rt-1",
+      "PublicID": "rt-1-pub",
+      "NetID": "office-net",
+      "Network": "10.20.0.0/24",
+      "NetworkType": 1,
+      "Peer": "peer-r",
+      "PeerID": "peer-r",
+      "Metric": 9999,
+      "Masquerade": true,
+      "Enabled": true,
+      "Groups": ["grp-dev"]
+    }
+  ],
+  "NetworkResources": [
+    {
+      "ID": "res-db",
+      "PublicID": "res-db-pub",
+      "NetworkID": "net-1",
+      "Name": "db",
+      "Type": "subnet",
+      "Prefix": "10.10.0.0/24",
+      "Enabled": true
+    }
+  ],
+  "Routers": {
+    "net-1": {
+      "peer-r": {"Masquerade": true, "Metric": 9999, "Enabled": true}
+    }
+  },
+  "NetworkXIDToPublicID": {"net-1": "net-1-pub"}
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/case.json
new file mode 100644
index 000000000..78ac576d9
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "netbird-ssh with AuthorizedGroups: grp-admins members may log in as root, grp-oncall (empty local-user list) as any machine user; peer-srv must receive both mappings in SshAuth, clients get plain TCP firewall rules. THE ENVELOPE EXPECTATION CURRENTLY FAILS, and should: encodeAuthorizedGroups/encodeGroupIDToUserIDs translate group keys via components.Groups, which never holds user-only groups, so the wire loses every authorized user while PeerConfig still reports sshEnabled — the peer runs sshd and denies every login. Pre-existing on main since PR #6711, not a regression. Fix the encoder, do not weaken this expectation.",
+  "peers": ["peer-srv", "peer-a"]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/golden/peer-a.json
new file mode 100644
index 000000000..6560bc74d
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/golden/peer-a.json
@@ -0,0 +1,63 @@
+{
+  "Serial": "10",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "jEboa3bOv65XRbq8+8JuagiwWz+mM7Fc5MUfBQbOf6Y=",
+      "allowedIps": [
+        "100.64.0.10/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-srv.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.10",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 22022,
+          "end": 22022
+        }
+      },
+      "PolicyID": "cG9sLXNzaA=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/golden/peer-srv.json
new file mode 100644
index 000000000..0a66bcbf0
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/golden/peer-srv.json
@@ -0,0 +1,109 @@
+{
+  "Serial": "10",
+  "peerConfig": {
+    "address": "100.64.0.10/10",
+    "sshConfig": {
+      "sshEnabled": true
+    },
+    "fqdn": "peer-srv.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "AvldyrZ12Pf90jzf3AXmhPwg3UcI+jtJHfbpBlupvko=",
+      "allowedIps": [
+        "100.64.0.2/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-b.netbird.test",
+      "agentVersion": "0.60.0"
+    },
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-b.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 22022,
+          "end": 22022
+        }
+      },
+      "PolicyID": "cG9sLXNzaA=="
+    },
+    {
+      "PeerIP": "100.64.0.2",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 22022,
+          "end": 22022
+        }
+      },
+      "PolicyID": "cG9sLXNzaA=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub",
+    "AuthorizedUsers": [
+      "CF6q+CJTtcJE8MVIcpPyOw==",
+      "zSsmm7BAxWD/EuunyETFXA==",
+      "0M0MizUGgS6HAaJa0LjGKQ=="
+    ],
+    "machineUsers": {
+      "*": {
+        "indexes": [
+          2
+        ]
+      },
+      "root": {
+        "indexes": [
+          0,
+          1
+        ]
+      }
+    }
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/nmdata.json
new file mode 100644
index 000000000..de0788ac9
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/nmdata.json
@@ -0,0 +1,37 @@
+{
+  "Network": {"Serial": 10},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a", "peer-b"]},
+    "grp-srv": {"Peers": ["peer-srv"]}
+  },
+  "GroupIDToUserIDs": {
+    "grp-admins": ["user-x", "user-y"],
+    "grp-oncall": ["user-z"]
+  },
+  "Policies": [
+    {
+      "ID": "pol-ssh",
+      "PublicID": "pol-ssh-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "netbird-ssh",
+          "PortRanges": [{"Start": 22022, "End": 22022}],
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-srv"],
+          "AuthorizedGroups": {
+            "grp-admins": ["root"],
+            "grp-oncall": []
+          }
+        }
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/case.json
new file mode 100644
index 000000000..e4799638e
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "netbird-ssh with a single AuthorizedUser: peer-srv's SshAuth maps the wildcard machine user to exactly user-solo.",
+  "peers": ["peer-srv"]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/golden/peer-srv.json
new file mode 100644
index 000000000..dde0d4014
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/golden/peer-srv.json
@@ -0,0 +1,74 @@
+{
+  "Serial": "11",
+  "peerConfig": {
+    "address": "100.64.0.10/10",
+    "sshConfig": {
+      "sshEnabled": true
+    },
+    "fqdn": "peer-srv.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 22022,
+          "end": 22022
+        }
+      },
+      "PolicyID": "cG9sLXNzaC11c2Vy"
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub",
+    "AuthorizedUsers": [
+      "/6cwl49UgLozU42NCr0RUA=="
+    ],
+    "machineUsers": {
+      "*": {
+        "indexes": [
+          0
+        ]
+      }
+    }
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/nmdata.json
new file mode 100644
index 000000000..1c92329a8
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/nmdata.json
@@ -0,0 +1,29 @@
+{
+  "Network": {"Serial": 11},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a"]},
+    "grp-srv": {"Peers": ["peer-srv"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-ssh-user",
+      "PublicID": "pol-ssh-user-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "netbird-ssh",
+          "PortRanges": [{"Start": 22022, "End": 22022}],
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-srv"],
+          "AuthorizedUser": "user-solo"
+        }
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/case.json
new file mode 100644
index 000000000..8d4fb6c1a
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "netbird-ssh with neither AuthorizedGroups nor AuthorizedUser falls back to the account AllowedUserIDs under the wildcard machine user — and works with the peer's own SSHEnabled left off, unlike legacy SSH.",
+  "peers": ["peer-srv"]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/golden/peer-srv.json
new file mode 100644
index 000000000..e63768a95
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/golden/peer-srv.json
@@ -0,0 +1,76 @@
+{
+  "Serial": "12",
+  "peerConfig": {
+    "address": "100.64.0.10/10",
+    "sshConfig": {
+      "sshEnabled": true
+    },
+    "fqdn": "peer-srv.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 22022,
+          "end": 22022
+        }
+      },
+      "PolicyID": "cG9sLXNzaC1hbnk="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub",
+    "AuthorizedUsers": [
+      "O8fBfcakRSAM4gX+YBNe+w==",
+      "1vwcS03btOdBRX0dhz0NRg=="
+    ],
+    "machineUsers": {
+      "*": {
+        "indexes": [
+          0,
+          1
+        ]
+      }
+    }
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/nmdata.json
new file mode 100644
index 000000000..97bfe3342
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/nmdata.json
@@ -0,0 +1,29 @@
+{
+  "Network": {"Serial": 12},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a"]},
+    "grp-srv": {"Peers": ["peer-srv"]}
+  },
+  "AllowedUserIDs": {"user-1": {}, "user-2": {}},
+  "Policies": [
+    {
+      "ID": "pol-ssh-any",
+      "PublicID": "pol-ssh-any-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "netbird-ssh",
+          "PortRanges": [{"Start": 22022, "End": 22022}],
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-srv"]
+        }
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/case.json
new file mode 100644
index 000000000..1427fbd19
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "A tcp/22 policy implies legacy SSH only when the destination peer has SSHEnabled; here it does not, so peer-srv gets the firewall rules but no authorized users.",
+  "peers": ["peer-srv"]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/golden/peer-srv.json
new file mode 100644
index 000000000..1ab6d4697
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/golden/peer-srv.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "13",
+  "peerConfig": {
+    "address": "100.64.0.10/10",
+    "sshConfig": {},
+    "fqdn": "peer-srv.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "Port": "22",
+      "PolicyID": "cG9sLXRjcDIy"
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "22",
+      "PolicyID": "cG9sLXRjcDIy"
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/nmdata.json
new file mode 100644
index 000000000..f00d0f06e
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/nmdata.json
@@ -0,0 +1,30 @@
+{
+  "Network": {"Serial": 13},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a"]},
+    "grp-srv": {"Peers": ["peer-srv"]}
+  },
+  "AllowedUserIDs": {"user-1": {}},
+  "Policies": [
+    {
+      "ID": "pol-tcp22",
+      "PublicID": "pol-tcp22-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["22"],
+          "Bidirectional": true,
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-srv"]
+        }
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/management/internals/network_map_db/factory/db_store.go b/management/internals/network_map_db/factory/db_store.go
new file mode 100644
index 000000000..3eea0ae69
--- /dev/null
+++ b/management/internals/network_map_db/factory/db_store.go
@@ -0,0 +1,76 @@
+package networkmapdbfactory
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"os"
+
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql"
+	networkmap_sqlite "github.com/netbirdio/netbird/management/internals/network_map_db/sqlite"
+	"github.com/netbirdio/netbird/management/server/integrations/integrated_validator"
+	"github.com/netbirdio/netbird/management/server/settings"
+	"github.com/netbirdio/netbird/management/server/store"
+	"github.com/netbirdio/netbird/management/server/types"
+	log "github.com/sirupsen/logrus"
+)
+
+const storeSqliteFileName = "store.db"
+
+var ErrNotSupportedStoreEngine = errors.New("unsupported store engine")
+
+func NewNetworkMapDBStore(
+	ctx context.Context,
+	kind types.Engine,
+	dataDir string,
+	integratedPeerValidator integrated_validator.IntegratedValidator,
+	extraSettingsManager settings.Manager) (*networkmapdb.NetworkMapDBStoreImpl, error) {
+	switch kind {
+	case types.SqliteStoreEngine:
+		log.WithContext(ctx).Info("networkmap store is using SQLite")
+		storeFile := storeSqliteFileName
+		if envFile, ok := os.LookupEnv("NB_STORE_ENGINE_SQLITE_FILE"); ok && envFile != "" {
+			storeFile = envFile
+		}
+		store, err := networkmap_sqlite.NewSqliteStore(storeFile, dataDir)
+		if err != nil {
+			return nil, err
+		}
+		return &networkmapdb.NetworkMapDBStoreImpl{
+			Store:                   store,
+			IntegratedPeerValidator: integratedPeerValidator,
+			ExtraSettingsManager:    extraSettingsManager,
+		}, nil
+	case types.PostgresStoreEngine:
+		log.WithContext(ctx).Info("using Postgres store engine")
+		dsn, err := mustLookupDsnEnv()
+		if err != nil {
+			return nil, err
+		}
+
+		store, err := networkmap_pgsql.NewPostgresqlStore(ctx, dsn)
+		if err != nil {
+			return nil, err
+		}
+
+		return &networkmapdb.NetworkMapDBStoreImpl{
+			Store:                   store,
+			IntegratedPeerValidator: integratedPeerValidator,
+			ExtraSettingsManager:    extraSettingsManager,
+		}, nil
+	}
+
+	return nil, fmt.Errorf("networkmap store doesn't support engine %s, %w", kind, ErrNotSupportedStoreEngine)
+}
+
+func mustLookupDsnEnv() (string, error) {
+	if v, ok := os.LookupEnv(store.PostgresDsnEnv); ok {
+		return v, nil
+	}
+	if v, ok := os.LookupEnv(store.PostgresDsnEnvLegacy); ok {
+		return v, nil
+	}
+
+	return "", fmt.Errorf("%s env var must be set when using postgres networkmap store", store.PostgresDsnEnv)
+}
diff --git a/management/internals/network_map_db/network_map_data.go b/management/internals/network_map_db/network_map_data.go
new file mode 100644
index 000000000..f18cc8650
--- /dev/null
+++ b/management/internals/network_map_db/network_map_data.go
@@ -0,0 +1,277 @@
+package networkmapdb
+
+import (
+	"context"
+	"fmt"
+	"net/netip"
+	"strings"
+
+	"github.com/miekg/dns"
+	log "github.com/sirupsen/logrus"
+	"golang.org/x/exp/maps"
+
+	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+func (s *NetworkMapDBStoreImpl) GetNetworkMapData(ctx context.Context, accountId string) (*networkmap.NetworkMapData, error) {
+	tx, err := s.Store.BeginTx(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	acctSettings, err := tx.GetAccountSettings(ctx, accountId)
+	if err != nil {
+		return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get account settings: %w", err))
+	}
+	dnsZones, err := tx.GetAppliedZoneCandidates(ctx, accountId)
+	if err != nil {
+		return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get applied zone candidates: %w", err))
+	}
+	groups, resourceToGroupIdx, err := tx.GetGroups(ctx, accountId)
+	if err != nil {
+		return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get groups: %w", err))
+	}
+	nsGroups, err := tx.GetNameServerGroups(ctx, accountId)
+	if err != nil {
+		return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get nameserver groups: %w", err))
+	}
+	networkResources, err := tx.GetNetworkResources(ctx, accountId)
+	if err != nil {
+		return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get network resources: %w", err))
+	}
+	routers, err := tx.GetNetworkRouters(ctx, accountId)
+	if err != nil {
+		return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get network routers: %w", err))
+	}
+	network, err := tx.GetNetwork(ctx, accountId)
+	if err != nil {
+		return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get network: %w", err))
+	}
+	peers, proxyPeers, err := tx.GetPeers(ctx, accountId)
+	if err != nil {
+		return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get peers: %w", err))
+	}
+	policies, policyToDestinationResourceIdx, policyToDestinationGroupIdx, err := tx.GetPolicies(ctx, accountId)
+	if err != nil {
+		return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get policies: %w", err))
+	}
+	postureChecks, postureCheckXIDToPublicID, err := tx.GetPostureChecks(ctx, accountId)
+	if err != nil {
+		return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get posture checks: %w", err))
+	}
+	routes, err := tx.GetRoutes(ctx, accountId)
+	if err != nil {
+		return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get routes: %w", err))
+	}
+	networkXIDToPublicID, err := tx.GetNetworkXIDToPublicIdMap(ctx, accountId)
+	if err != nil {
+		return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get network xid to public id map: %w", err))
+	}
+	allowedUserIds, groupsToUserIds, err := tx.GetAllowedUsers(ctx, accountId)
+	if err != nil {
+		return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get allowed users: %w", err))
+	}
+	dnsSettings, err := tx.GetDnsSettings(ctx, accountId)
+	if err != nil {
+		return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get dns settings: %w", err))
+	}
+	domains, err := tx.GetDomains(ctx, accountId)
+	if err != nil {
+		return rollbackAndReturnError(ctx, tx, err)
+	}
+	services, err := tx.GetPrivateServices(ctx, accountId)
+	if err != nil {
+		return rollbackAndReturnError(ctx, tx, err)
+	}
+	proxyTargetedDomainResourceIDs, err := tx.GetProxyTargetedDomainResourceIDs(ctx, accountId)
+	if err != nil {
+		return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get proxy targeted domain resources: %w", err))
+	}
+
+	if err = tx.CommitTx(ctx); err != nil {
+		log.WithContext(ctx).Warnf("failed to commit network map read transaction: %v", err)
+	}
+
+	resourcePolicies := buildResourcePolicies(
+		networkResources, policies, resourceToGroupIdx, policyToDestinationResourceIdx, policyToDestinationGroupIdx)
+
+	toret := networkmap.NetworkMapData{
+		AccountSettings:                &acctSettings,
+		DNSSettings:                    &dnsSettings,
+		Network:                        &network,
+		Peers:                          toMap(peers, func(p nmdata.Peer) string { return p.ID }),
+		Groups:                         toMap(groups, func(g nmdata.Group) string { return g.ID }),
+		Policies:                       toSliceOfPtrs(policies),
+		ResourcePolicies:               resourcePolicies,
+		Routes:                         toSliceOfPtrs(routes),
+		Routers:                        routers,
+		NameServerGroups:               toSliceOfPtrs(nsGroups),
+		NetworkResources:               toSliceOfPtrs(networkResources),
+		PostureChecks:                  toMap(postureChecks, func(pc nmdata.PostureChecks) string { return pc.ID }),
+		AllowedUserIDs:                 allowedUserIds,
+		GroupIDToUserIDs:               groupsToUserIds,
+		NetworkXIDToPublicID:           networkXIDToPublicID, // TODO (dmitri) maybe we can switch to public ids everywhere?
+		AppliedZoneCandidates:          dnsZones,
+		PrivateServiceCandidates:       buildPrivateServiceCandidates(services, domains, proxyPeers),
+		PostureCheckXIDToPublicID:      postureCheckXIDToPublicID,
+		ProxyTargetedDomainResourceIDs: proxyTargetedDomainResourceIDs,
+	}
+
+	extraSettings, err := s.ExtraSettingsManager.GetExtraSettings(ctx, accountId)
+	if err != nil {
+		return nil, err
+	}
+
+	toret.ValidatedPeers, err = s.IntegratedPeerValidator.GetValidatedPeers(ctx, accountId, maps.Values(toret.Groups), maps.Values(toret.Peers), extraSettings)
+	if err != nil {
+		return nil, err
+	}
+
+	return &toret, nil
+}
+
+func rollbackAndReturnError(ctx context.Context, tx NetworkMapDBStoreConn, err error) (*networkmap.NetworkMapData, error) {
+	if errr := tx.RollbackTx(ctx); errr != nil {
+		log.WithContext(ctx).Warnf("failed to rollback network map read transaction: %v", errr)
+	}
+	return nil, err
+}
+
+func toMap[T any](all []T, id func(t T) string) map[string]*T {
+	toret := make(map[string]*T, len(all))
+	for _, t := range all {
+		toret[id(t)] = &t
+	}
+	return toret
+}
+
+func toSliceOfPtrs[T any](all []T) []*T {
+	toret := make([]*T, 0, len(all))
+	for _, t := range all {
+		toret = append(toret, &t)
+	}
+	return toret
+}
+
+func serviceDomainZone(svc Service, ds []Domain) string {
+	if domainFromSuffix(svc.Domain.String, svc.ProxyCluster.String) {
+		return svc.ProxyCluster.String
+	}
+
+	var zoneName string
+	for _, domain := range ds {
+		if domain.TargetCluster.String != svc.ProxyCluster.String {
+			continue
+		}
+		if domainFromSuffix(svc.Domain.String, domain.Domain.String) && len(domain.Domain.String) > len(zoneName) {
+			zoneName = domain.Domain.String
+		}
+	}
+
+	return zoneName
+}
+
+func domainFromSuffix(domain, suffix string) bool {
+	if suffix == "" {
+		return false
+	}
+	return domain == suffix || strings.HasSuffix(domain, "."+suffix)
+}
+
+func buildPrivateServiceCandidates(svcs []Service, domains []Domain, proxyPeersByCluster map[string][]*nmdata.Peer) []networkmap.PrivateServiceCandidate {
+	var out []networkmap.PrivateServiceCandidate
+
+	if len(proxyPeersByCluster) == 0 {
+		return out
+	}
+
+	for _, svc := range svcs {
+		if !svc.Enabled.Bool || !svc.Private.Bool {
+			continue
+		}
+		if len(svc.AccessGroups) == 0 {
+			continue
+		}
+
+		domainZone := serviceDomainZone(svc, domains)
+		if domainZone == "" {
+			continue
+		}
+
+		// this is implied when domainZone != "", but for maintainability's sake the check is explicit
+		// TODO (dmitri) make this an invariant
+		if svc.Domain.String == "" {
+			continue
+		}
+		var records []nmdata.SimpleRecord
+		for _, proxyPeer := range proxyPeersByCluster[svc.ProxyCluster.String] {
+			if record, ok := recordForProxyPeer(svc.Domain.String, proxyPeer.IP); ok {
+				records = append(records, record)
+			}
+		}
+		if len(records) == 0 {
+			continue
+		}
+
+		out = append(out, networkmap.PrivateServiceCandidate{
+			AccessGroups: svc.AccessGroups,
+			Zone: nmdata.CustomZone{
+				Domain:               dns.Fqdn(domainZone),
+				Records:              records,
+				NonAuthoritative:     true,
+				SearchDomainDisabled: true,
+			},
+		})
+	}
+
+	return out
+}
+
+func recordForProxyPeer(fqdn string, ip netip.Addr) (nmdata.SimpleRecord, bool) {
+	if !ip.IsValid() {
+		return nmdata.SimpleRecord{}, false
+	}
+
+	return nmdata.SimpleRecord{
+		Name:  dns.Fqdn(fqdn),
+		Type:  int(dns.TypeA),
+		Class: "IN",
+		TTL:   5,
+		RData: ip.String(),
+	}, true
+}
+
+func buildResourcePolicies(networkResources []nmdata.NetworkResource,
+	policies []nmdata.Policy,
+	resourceToGroupIdx map[string]map[string]any,
+	policyToDestinationResourceIdx map[string]map[string]any,
+	policyToDestinationGroupIdx map[string]map[string]any) map[string][]*nmdata.Policy {
+
+	resourcePolicies := make(map[string][]*nmdata.Policy)
+	for _, resource := range networkResources {
+		if !resource.Enabled {
+			continue
+		}
+		networkResourceGroups := resourceToGroupIdx[resource.ID]
+		for _, policy := range policies {
+			if !policy.Enabled {
+				continue
+			}
+			if _, ok := policyToDestinationResourceIdx[policy.ID][resource.ID]; ok {
+				resourcePolicies[resource.ID] = append(resourcePolicies[resource.ID], &policy) // TODO (dmitri) maybe use public id?
+				continue
+			}
+			if groupIds, ok := policyToDestinationGroupIdx[policy.ID]; ok {
+				for networkResourceGroup := range networkResourceGroups {
+					if _, ok := groupIds[networkResourceGroup]; ok {
+						resourcePolicies[resource.ID] = append(resourcePolicies[resource.ID], &policy)
+						break
+					}
+				}
+			}
+		}
+	}
+
+	return resourcePolicies
+}
diff --git a/management/internals/network_map_db/network_map_data_test.go b/management/internals/network_map_db/network_map_data_test.go
new file mode 100644
index 000000000..925a23d1c
--- /dev/null
+++ b/management/internals/network_map_db/network_map_data_test.go
@@ -0,0 +1,399 @@
+package networkmapdb
+
+import (
+	"database/sql"
+	"net/netip"
+	"testing"
+
+	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestDomainFromSuffix(t *testing.T) {
+	assert.False(t, domainFromSuffix("test", ""))
+	assert.False(t, domainFromSuffix("test", "suffix"))               // domain != suffix
+	assert.True(t, domainFromSuffix("test", "test"))                  // domain == suffix
+	assert.False(t, domainFromSuffix("test.anothersuffix", "suffix")) // domain doesn't contain suffix
+	assert.True(t, domainFromSuffix("test.suffix", "suffix"))         // domain contains suffix
+}
+
+func TestServiceDomainZone(t *testing.T) {
+	// shortcut -- service's domain is a subomain of proxy cluster
+	assert.Equal(t, "cluster",
+		serviceDomainZone(
+			Service{
+				Domain:       sql.NullString{Valid: true, String: "test.cluster"},
+				ProxyCluster: sql.NullString{Valid: true, String: "cluster"}},
+			[]Domain{}))
+	assert.Equal(t, "a.b", serviceDomainZone(
+		Service{
+			Domain:       sql.NullString{Valid: true, String: "test.a.b"},
+			ProxyCluster: sql.NullString{Valid: true, String: "cluster"}},
+		[]Domain{
+			{TargetCluster: sql.NullString{Valid: true, String: "a-cluster"}},
+			{TargetCluster: sql.NullString{Valid: true, String: "cluster"},
+				Domain: sql.NullString{Valid: true, String: "b"}},
+			{TargetCluster: sql.NullString{Valid: true, String: "cluster"},
+				Domain: sql.NullString{Valid: true, String: "a.b"}}, // should return this domain, as it's the longest match
+			{TargetCluster: sql.NullString{Valid: true, String: "b-cluster"}},
+		}))
+	// service and domain clusters don't match
+	assert.Empty(t, serviceDomainZone(
+		Service{
+			Domain:       sql.NullString{Valid: true, String: "test.a.b"},
+			ProxyCluster: sql.NullString{Valid: true, String: "c-cluster"}},
+		[]Domain{
+			{TargetCluster: sql.NullString{Valid: true, String: "cluster"},
+				Domain: sql.NullString{Valid: true, String: "a.b"}},
+		}))
+	// service domain is empty
+	assert.Empty(t, serviceDomainZone(
+		Service{
+			Domain:       sql.NullString{Valid: false, String: ""},
+			ProxyCluster: sql.NullString{Valid: true, String: "cluster"}},
+		[]Domain{
+			{TargetCluster: sql.NullString{Valid: true, String: "cluster"},
+				Domain: sql.NullString{Valid: true, String: "a.b"}},
+		}))
+}
+
+func TestRecordForProxyPeer(t *testing.T) {
+	record, ok := recordForProxyPeer("test.cluster", netip.MustParseAddr("127.0.0.1"))
+	assert.True(t, ok)
+	assert.Equal(t, nmdata.SimpleRecord{
+		Name:  "test.cluster.",
+		Type:  1,
+		Class: "IN",
+		TTL:   5,
+		RData: "127.0.0.1",
+	}, record)
+
+	// invalid address
+	var addr netip.Addr
+	_, ok = recordForProxyPeer("test.cluster", addr)
+	assert.False(t, ok)
+}
+
+var empty []networkmap.PrivateServiceCandidate
+
+// empty proxyPeersByCluster results in empty []PrivateServiceCandidates
+func TestBuildPrivateServiceCandidates_EmptyProxyPeers(t *testing.T) {
+	assert.Equal(t, empty, buildPrivateServiceCandidates([]Service{}, []Domain{}, nil))
+}
+
+// disabled service returns an empty result
+func TestBuildPrivateServiceCandidates_DisabledService(t *testing.T) {
+	assert.Equal(t, empty,
+		buildPrivateServiceCandidates([]Service{
+			{Enabled: sql.NullBool{Valid: true, Bool: false},
+				Private:      sql.NullBool{Valid: true, Bool: true},
+				AccessGroups: []string{"group-1", "group-2"},
+				Domain:       sql.NullString{Valid: true, String: "test.a.b"},
+				ProxyCluster: sql.NullString{Valid: true, String: "cluster"}},
+		}, []Domain{
+			{TargetCluster: sql.NullString{Valid: true, String: "cluster"},
+				Domain: sql.NullString{Valid: true, String: "a.b"}},
+		},
+			map[string][]*nmdata.Peer{
+				"cluster":   {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.1")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.2")}},
+				"a-cluster": {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.3")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.4")}},
+			}))
+}
+
+// non-private service results in empty []PrivateServiceCandidates
+func TestBuildPrivateServiceCandidates_PublicService(t *testing.T) {
+	assert.Equal(t, empty,
+		buildPrivateServiceCandidates([]Service{
+			{Enabled: sql.NullBool{Valid: true, Bool: true},
+				Private:      sql.NullBool{Valid: true, Bool: false},
+				AccessGroups: []string{"group-1", "group-2"},
+				Domain:       sql.NullString{Valid: true, String: "test.a.b"},
+				ProxyCluster: sql.NullString{Valid: true, String: "cluster"}},
+		}, []Domain{
+			{TargetCluster: sql.NullString{Valid: true, String: "cluster"},
+				Domain: sql.NullString{Valid: true, String: "a.b"}},
+		},
+			map[string][]*nmdata.Peer{
+				"cluster":   {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.1")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.2")}},
+				"a-cluster": {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.3")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.4")}},
+			}))
+}
+
+// empty AccessList results in empty []PrivateServiceCandidates
+func TestBuildPrivateServiceCandidates_EmptyAccessList(t *testing.T) {
+	assert.Equal(t, empty,
+		buildPrivateServiceCandidates([]Service{
+			{Enabled: sql.NullBool{Valid: true, Bool: true},
+				Private:      sql.NullBool{Valid: true, Bool: true},
+				Domain:       sql.NullString{Valid: true, String: "test.a.b"},
+				ProxyCluster: sql.NullString{Valid: true, String: "cluster"}},
+		}, []Domain{
+			{TargetCluster: sql.NullString{Valid: true, String: "cluster"},
+				Domain: sql.NullString{Valid: true, String: "a.b"}},
+		},
+			map[string][]*nmdata.Peer{
+				"cluster":   {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.1")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.2")}},
+				"a-cluster": {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.3")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.4")}},
+			}))
+}
+
+// empty TragetCluster results in empty []PrivateServiceCandidates
+func TestBuildPrivateServiceCandidates_EmptyTargetCluster(t *testing.T) {
+	assert.Equal(t, empty,
+		buildPrivateServiceCandidates([]Service{
+			{Enabled: sql.NullBool{Valid: true, Bool: true},
+				Private:      sql.NullBool{Valid: true, Bool: true},
+				AccessGroups: []string{"group-1", "group-2"},
+				Domain:       sql.NullString{Valid: true, String: "test.a.b"},
+				ProxyCluster: sql.NullString{Valid: true, String: "cluster"}},
+		}, []Domain{
+			{TargetCluster: sql.NullString{Valid: true, String: ""},
+				Domain: sql.NullString{Valid: true, String: "a.b"}},
+		},
+			map[string][]*nmdata.Peer{
+				"cluster":   {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.1")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.2")}},
+				"a-cluster": {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.3")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.4")}},
+			}))
+}
+
+func TestBuildPrivateServiceCandidates_EmptyServiceDomain(t *testing.T) {
+	assert.Equal(t, empty,
+		buildPrivateServiceCandidates([]Service{
+			{Enabled: sql.NullBool{Valid: true, Bool: true},
+				Private:      sql.NullBool{Valid: true, Bool: true},
+				Domain:       sql.NullString{Valid: true, String: ""},
+				ProxyCluster: sql.NullString{Valid: true, String: "cluster"}},
+		}, []Domain{
+			{TargetCluster: sql.NullString{Valid: true, String: "cluster"},
+				Domain: sql.NullString{Valid: true, String: "a.b"}},
+		},
+			map[string][]*nmdata.Peer{
+				"cluster":   {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.1")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.2")}},
+				"a-cluster": {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.3")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.4")}},
+			}))
+}
+
+func TestBuildPrivateServiceCandidates_HappyPath(t *testing.T) {
+	assert.Equal(t, []networkmap.PrivateServiceCandidate{
+		{
+			AccessGroups: []string{"group-1", "group-2"},
+			Zone: nmdata.CustomZone{
+				Domain:               "a.b.",
+				SearchDomainDisabled: true,
+				NonAuthoritative:     true,
+				Records: []nmdata.SimpleRecord{
+					{
+						Name:  "test.a.b.",
+						Type:  1,
+						Class: "IN",
+						TTL:   5,
+						RData: "127.0.0.1",
+					},
+					{
+						Name:  "test.a.b.",
+						Type:  1,
+						Class: "IN",
+						TTL:   5,
+						RData: "127.0.0.2",
+					},
+				},
+			},
+		},
+		{
+			AccessGroups: []string{"group-1", "group-2"},
+			Zone: nmdata.CustomZone{
+				Domain:               "c.d.",
+				SearchDomainDisabled: true,
+				NonAuthoritative:     true,
+				Records: []nmdata.SimpleRecord{
+					{
+						Name:  "test.c.d.",
+						Type:  1,
+						Class: "IN",
+						TTL:   5,
+						RData: "127.0.0.3",
+					},
+					{
+						Name:  "test.c.d.",
+						Type:  1,
+						Class: "IN",
+						TTL:   5,
+						RData: "127.0.0.4",
+					},
+				},
+			},
+		},
+	},
+		buildPrivateServiceCandidates([]Service{
+			{Enabled: sql.NullBool{Valid: true, Bool: true},
+				Private:      sql.NullBool{Valid: true, Bool: true},
+				AccessGroups: []string{"group-1", "group-2"},
+				Domain:       sql.NullString{Valid: true, String: "test.a.b"},
+				ProxyCluster: sql.NullString{Valid: true, String: "cluster"}},
+			{Enabled: sql.NullBool{Valid: true, Bool: true},
+				Private:      sql.NullBool{Valid: true, Bool: true},
+				AccessGroups: []string{"group-1", "group-2"},
+				Domain:       sql.NullString{Valid: true, String: "test.c.d"},
+				ProxyCluster: sql.NullString{Valid: true, String: "a-cluster"}},
+		}, []Domain{
+			{TargetCluster: sql.NullString{Valid: true, String: "cluster"},
+				Domain: sql.NullString{Valid: true, String: "a.b"}},
+			{TargetCluster: sql.NullString{Valid: true, String: "a-cluster"},
+				Domain: sql.NullString{Valid: true, String: "c.d"}},
+		},
+			map[string][]*nmdata.Peer{
+				"cluster":   {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.1")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.2")}},
+				"a-cluster": {&nmdata.Peer{IP: netip.MustParseAddr("127.0.0.3")}, &nmdata.Peer{IP: netip.MustParseAddr("127.0.0.4")}},
+			}))
+}
+
+// disabled network resource shouldn't be in the resulting map
+func TestBuildResourcePolicies_DisabledNetworkResource(t *testing.T) {
+	networkResources := []nmdata.NetworkResource{
+		{ID: "net-res-1", Enabled: false},
+	}
+	policies := []nmdata.Policy{
+		{ID: "policy-1", Enabled: true},
+	}
+	resourceToGroupIdx := map[string]map[string]any{}
+	policyToDestinationResourceIdx := map[string]map[string]any{
+		"policy-1": {
+			"net-res-1": struct{}{},
+			"net-res-3": struct{}{},
+		},
+	}
+	policyToDestinationGroupIdx := map[string]map[string]any{}
+
+	assert.Empty(t, buildResourcePolicies(
+		networkResources, policies, resourceToGroupIdx, policyToDestinationResourceIdx, policyToDestinationGroupIdx))
+}
+
+// disabled policy shouldn't be in the resulting map
+func TestBuildResourcePolicies_DisabledPolicy(t *testing.T) {
+	networkResources := []nmdata.NetworkResource{
+		{ID: "net-res-1", Enabled: true},
+	}
+	policies := []nmdata.Policy{
+		{ID: "policy-1", Enabled: false},
+	}
+	resourceToGroupIdx := map[string]map[string]any{}
+	policyToDestinationResourceIdx := map[string]map[string]any{
+		"policy-1": {
+			"net-res-1": struct{}{},
+			"net-res-3": struct{}{},
+		},
+	}
+	policyToDestinationGroupIdx := map[string]map[string]any{}
+
+	assert.Empty(t, buildResourcePolicies(
+		networkResources, policies, resourceToGroupIdx, policyToDestinationResourceIdx, policyToDestinationGroupIdx))
+}
+
+// build ResourcePolicies via PolicyToDestinationResourceIdx only
+func TestBuildResourcePolicies_ViaPolicyToDestinationResourceIdx(t *testing.T) {
+	networkResources := []nmdata.NetworkResource{
+		{ID: "net-res-1", Enabled: true},
+		{ID: "net-res-2", Enabled: true},
+		{ID: "net-res-3", Enabled: true},
+	}
+	policies := []nmdata.Policy{
+		{ID: "policy-1", Enabled: true},
+		{ID: "policy-2", Enabled: true},
+		{ID: "policy-3", Enabled: true},
+	}
+	resourceToGroupIdx := map[string]map[string]any{}
+	policyToDestinationResourceIdx := map[string]map[string]any{
+		"policy-1": {
+			"net-res-1": struct{}{},
+			"net-res-3": struct{}{},
+		},
+		"policy-2": {
+			"net-res-2": struct{}{},
+		},
+		"policy-3": {
+			"net-res-1": struct{}{},
+			"net-res-2": struct{}{},
+		},
+	}
+	policyToDestinationGroupIdx := map[string]map[string]any{}
+
+	resourceToPolicies := buildResourcePolicies(
+		networkResources, policies, resourceToGroupIdx, policyToDestinationResourceIdx, policyToDestinationGroupIdx)
+
+	assert.Equal(t, map[string][]*nmdata.Policy{
+		"net-res-1": {
+			{ID: "policy-1", Enabled: true},
+			{ID: "policy-3", Enabled: true},
+		},
+		"net-res-2": {
+			{ID: "policy-2", Enabled: true},
+			{ID: "policy-3", Enabled: true},
+		},
+		"net-res-3": {
+			{ID: "policy-1", Enabled: true},
+		},
+	}, resourceToPolicies)
+}
+
+// build ResourcePolicies via PolicyToDestinationGroupIdx only
+func TestBuildResourcePolicies_ViaPolicyToDestinationGroupIdx(t *testing.T) {
+	networkResources := []nmdata.NetworkResource{
+		{ID: "net-res-1", Enabled: true},
+		{ID: "net-res-2", Enabled: true},
+		{ID: "net-res-3", Enabled: true},
+	}
+	policies := []nmdata.Policy{
+		{ID: "policy-1", Enabled: true},
+		{ID: "policy-2", Enabled: true},
+		{ID: "policy-3", Enabled: true},
+	}
+	resourceToGroupIdx := map[string]map[string]any{
+		"net-res-1": {
+			"group-1": struct{}{},
+			"group-2": struct{}{},
+		},
+		"net-res-2": {
+			"group-2": struct{}{},
+			"group-3": struct{}{},
+		},
+		"net-res-3": {
+			"group-3": struct{}{},
+			"group-4": struct{}{},
+		},
+	}
+	policyToDestinationResourceIdx := map[string]map[string]any{}
+	policyToDestinationGroupIdx := map[string]map[string]any{
+		"policy-1": {
+			"group-1": struct{}{},
+			"group-2": struct{}{},
+		},
+		"policy-2": {
+			"group-1": struct{}{},
+			"group-4": struct{}{},
+		},
+		"policy-3": {
+			"group-1": struct{}{},
+			"group-3": struct{}{},
+		},
+	}
+
+	resourceToPolicies := buildResourcePolicies(
+		networkResources, policies, resourceToGroupIdx, policyToDestinationResourceIdx, policyToDestinationGroupIdx)
+
+	assert.Equal(t, map[string][]*nmdata.Policy{
+		"net-res-1": {
+			{ID: "policy-1", Enabled: true},
+			{ID: "policy-2", Enabled: true},
+			{ID: "policy-3", Enabled: true},
+		},
+		"net-res-2": {
+			{ID: "policy-1", Enabled: true},
+			{ID: "policy-3", Enabled: true},
+		},
+		"net-res-3": {
+			{ID: "policy-2", Enabled: true},
+			{ID: "policy-3", Enabled: true},
+		},
+	}, resourceToPolicies)
+}
diff --git a/management/internals/network_map_db/pgsql/account_settings.go b/management/internals/network_map_db/pgsql/account_settings.go
new file mode 100644
index 000000000..cd5a36e35
--- /dev/null
+++ b/management/internals/network_map_db/pgsql/account_settings.go
@@ -0,0 +1,61 @@
+package networkmap_pgsql
+
+import (
+	"context"
+	"encoding/json"
+	"time"
+
+	"github.com/jackc/pgx/v5"
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetAccountSettingsQuery = `
+	select settings_peer_login_expiration_enabled as peer_login_expiration_enabled,
+	settings_peer_login_expiration as peer_login_expiration,
+	settings_peer_inactivity_expiration_enabled as peer_inactivity_expiration_enabled,
+	settings_peer_inactivity_expiration as peer_inactivity_expiration,
+	settings_dns_domain as dns_domain,
+	settings_ipv6_enabled_groups as ipv6_enabled_groups,
+	settings_routing_peer_dns_resolution_enabled as routing_peer_dns_resolution_enabled,
+	settings_lazy_connection_enabled as lazy_connection_enabled,
+	settings_auto_update_version as auto_update_version,
+	settings_auto_update_always as auto_update_always,
+	settings_metrics_push_enabled as metrics_push_enabled
+	from accounts
+	where id=$1
+	`
+)
+
+func (pgc *PgStoreConn) GetAccountSettings(ctx context.Context, accountId string) (nmdata.AccountSettingsInfo, error) {
+	rows, err := pgc.Conn.Query(ctx, GetAccountSettingsQuery, accountId)
+	if err != nil {
+		return nmdata.AccountSettingsInfo{}, err
+	}
+
+	settings, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[networkmapdb.Account])
+	if err != nil {
+		return nmdata.AccountSettingsInfo{}, err
+	}
+
+	settingsInfo := nmdata.AccountSettingsInfo{
+		PeerLoginExpirationEnabled:      settings.PeerLoginExpirationEnabled.Bool,
+		PeerLoginExpiration:             time.Duration(settings.PeerLoginExpiration.Int64),
+		PeerInactivityExpirationEnabled: settings.PeerInactivityExpirationEnabled.Bool,
+		PeerInactivityExpiration:        time.Duration(settings.PeerInactivityExpiration.Int64),
+		DNSDomain:                       settings.DNSDomain.String,
+		RoutingPeerDNSResolutionEnabled: settings.RoutingPeerDNSResolutionEnabled.Bool,
+		LazyConnectionEnabled:           settings.LazyConnectionEnabled.Bool,
+		AutoUpdateVersion:               settings.AutoUpdateVersion.String,
+		AutoUpdateAlways:                settings.AutoUpdateAlways.Bool,
+		MetricsPushEnabled:              settings.MetricsPushEnabled.Bool,
+	}
+	if settings.IPv6EnabledGroups != nil {
+		if err := json.Unmarshal(settings.IPv6EnabledGroups, &settingsInfo.IPv6EnabledGroups); err != nil {
+			return nmdata.AccountSettingsInfo{}, err
+		}
+	}
+
+	return settingsInfo, nil
+}
diff --git a/management/internals/network_map_db/pgsql/dns.go b/management/internals/network_map_db/pgsql/dns.go
new file mode 100644
index 000000000..b22b43903
--- /dev/null
+++ b/management/internals/network_map_db/pgsql/dns.go
@@ -0,0 +1,33 @@
+package networkmap_pgsql
+
+import (
+	"context"
+
+	"github.com/jackc/pgx/v5"
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap"
+)
+
+const (
+	GetAccountZonesQuery = `
+	select zones.id as id, domain, not enable_search_domain as search_domain_disabled, distribution_groups,
+	r.name as record_name, r.type as record_type, 'IN' record_class, r.ttl as record_ttl, r.content as record_rdata
+	from zones
+	left join records as r on r.zone_id = zones.id
+	where zones.account_id=$1 and zones.enabled
+	`
+)
+
+func (pgc *PgStoreConn) GetAppliedZoneCandidates(ctx context.Context, accountId string) ([]networkmap.AppliedZoneCandidate, error) {
+	rows, err := pgc.Conn.Query(ctx, GetAccountZonesQuery, accountId)
+	if err != nil {
+		return nil, err
+	}
+
+	zones, err := pgx.CollectRows(rows, pgx.RowToStructByName[networkmapdb.Zone])
+	if err != nil {
+		return nil, err
+	}
+
+	return networkmapdb.ZonesToAppliedZoneCandidates(zones)
+}
diff --git a/management/internals/network_map_db/pgsql/dns_settings.go b/management/internals/network_map_db/pgsql/dns_settings.go
new file mode 100644
index 000000000..aec44e0f2
--- /dev/null
+++ b/management/internals/network_map_db/pgsql/dns_settings.go
@@ -0,0 +1,45 @@
+package networkmap_pgsql
+
+import (
+	"context"
+	"encoding/json"
+
+	"github.com/jackc/pgx/v5"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetDnsSettingsQuery = `
+	select dns_settings_disabled_management_groups
+	from accounts
+	where id=$1
+	`
+)
+
+func (pgc *PgStoreConn) GetDnsSettings(ctx context.Context, accountId string) (nmdata.DNSSettings, error) {
+	rows, err := pgc.Conn.Query(ctx, GetDnsSettingsQuery, accountId)
+	if err != nil {
+		return nmdata.DNSSettings{}, err
+	}
+
+	return pgx.CollectOneRow(rows, rowToDnsSettings)
+}
+
+func rowToDnsSettings(row pgx.CollectableRow) (nmdata.DNSSettings, error) {
+	var value nmdata.DNSSettings
+	var settings json.RawMessage
+
+	if err := row.Scan(&settings); err != nil {
+		return value, err
+	}
+
+	if settings == nil {
+		return nmdata.DNSSettings{}, nil
+	}
+
+	if err := json.Unmarshal(settings, &value.DisabledManagementGroups); err != nil {
+		return value, err
+	}
+
+	return value, nil
+}
diff --git a/management/internals/network_map_db/pgsql/domain.go b/management/internals/network_map_db/pgsql/domain.go
new file mode 100644
index 000000000..8730007c5
--- /dev/null
+++ b/management/internals/network_map_db/pgsql/domain.go
@@ -0,0 +1,25 @@
+package networkmap_pgsql
+
+import (
+	"context"
+
+	"github.com/jackc/pgx/v5"
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+)
+
+const (
+	GetDomainsQuery = `
+	select domain, target_cluster
+	from domains
+	where account_id=$1 and domain<>'' and target_cluster<>''
+	`
+)
+
+func (pgc *PgStoreConn) GetDomains(ctx context.Context, accountId string) ([]networkmapdb.Domain, error) {
+	rows, err := pgc.Conn.Query(ctx, GetDomainsQuery, accountId)
+	if err != nil {
+		return nil, err
+	}
+
+	return pgx.CollectRows(rows, pgx.RowToStructByName[networkmapdb.Domain])
+}
diff --git a/management/internals/network_map_db/pgsql/group.go b/management/internals/network_map_db/pgsql/group.go
new file mode 100644
index 000000000..874e743a5
--- /dev/null
+++ b/management/internals/network_map_db/pgsql/group.go
@@ -0,0 +1,64 @@
+package networkmap_pgsql
+
+import (
+	"context"
+	"database/sql"
+	"encoding/json"
+	"reflect"
+
+	"github.com/jackc/pgx/v5"
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetGroupsQuery = `
+	select id, name, public_id, resources,
+	(
+	  select array_agg(group_peers.peer_id)
+      from group_peers
+	  where group_peers.group_id = groups.id and group_peers.account_id=$1
+	) as peers
+	from groups where account_id=$1
+	`
+)
+
+// we also return a resource-to-group index.
+// an alternative is to add json indexes, query this directly. Not sure how expensive
+// json indexes are. TODO (dmitri) verify and maybe change the implementation here.
+func (pgc *PgStoreConn) GetGroups(ctx context.Context, accountId string) ([]nmdata.Group, map[string]map[string]any, error) {
+	rows, err := pgc.Conn.Query(ctx, GetGroupsQuery, accountId)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	groups, err := pgx.CollectRows(rows, pgx.RowToStructByName[group])
+	toret := make([]nmdata.Group, 0, len(groups))
+	resourceToGroupIdx := make(map[string]map[string]any)
+
+	for _, g := range groups {
+		dg := nmdata.Group{}
+		err := networkmapdb.FromSqlTypesToSharedTypes(
+			reflect.ValueOf(&g), reflect.ValueOf(&dg))
+		if err != nil {
+			return nil, nil, err
+		}
+		toret = append(toret, dg)
+		for _, resource := range dg.Resources {
+			if _, ok := resourceToGroupIdx[resource.ID]; !ok {
+				resourceToGroupIdx[resource.ID] = make(map[string]any)
+			}
+			resourceToGroupIdx[resource.ID][g.ID] = struct{}{}
+		}
+	}
+
+	return toret, resourceToGroupIdx, err
+}
+
+type group struct {
+	ID        string
+	Name      sql.NullString
+	PublicID  sql.NullString
+	Resources json.RawMessage
+	Peers     []string
+}
diff --git a/management/internals/network_map_db/pgsql/nameserver.go b/management/internals/network_map_db/pgsql/nameserver.go
new file mode 100644
index 000000000..12f215edb
--- /dev/null
+++ b/management/internals/network_map_db/pgsql/nameserver.go
@@ -0,0 +1,31 @@
+package networkmap_pgsql
+
+import (
+	"context"
+
+	"github.com/jackc/pgx/v5"
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetNameserversQuery = `
+	select id, public_id, name, description, name_servers, groups, "primary", domains, enabled, search_domains_enabled
+	from name_server_groups
+	where account_id=$1
+	`
+)
+
+func (pgc *PgStoreConn) GetNameServerGroups(ctx context.Context, accountId string) ([]nmdata.NameServerGroup, error) {
+	rows, err := pgc.Conn.Query(ctx, GetNameserversQuery, accountId)
+	if err != nil {
+		return nil, err
+	}
+
+	nsgroups, err := pgx.CollectRows(rows, pgx.RowToStructByName[networkmapdb.NameserverGroup])
+	if err != nil {
+		return nil, err
+	}
+
+	return networkmapdb.ConvertAllToSharedTypes[networkmapdb.NameserverGroup, nmdata.NameServerGroup](nsgroups)
+}
diff --git a/management/internals/network_map_db/pgsql/network.go b/management/internals/network_map_db/pgsql/network.go
new file mode 100644
index 000000000..5d7d33bcc
--- /dev/null
+++ b/management/internals/network_map_db/pgsql/network.go
@@ -0,0 +1,39 @@
+package networkmap_pgsql
+
+import (
+	"context"
+	"reflect"
+
+	"github.com/jackc/pgx/v5"
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetNetworkQuery = `
+	select network_identifier as identifier, network_net as net, network_net_v6 as net_v6, network_dns as dns, network_serial as serial
+	from accounts
+	where id=$1
+	`
+)
+
+func (pgc *PgStoreConn) GetNetwork(ctx context.Context, accountId string) (nmdata.Network, error) {
+	rows, err := pgc.Conn.Query(ctx, GetNetworkQuery, accountId)
+	if err != nil {
+		return nmdata.Network{}, err
+	}
+
+	n, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[networkmapdb.AccountNetwork])
+	if err != nil {
+		return nmdata.Network{}, err
+	}
+
+	toret := nmdata.Network{}
+	err = networkmapdb.FromSqlTypesToSharedTypes(
+		reflect.ValueOf(&n), reflect.ValueOf(&toret))
+	if err != nil {
+		return nmdata.Network{}, err
+	}
+
+	return toret, nil
+}
diff --git a/management/internals/network_map_db/pgsql/network_resource.go b/management/internals/network_map_db/pgsql/network_resource.go
new file mode 100644
index 000000000..48c9b0611
--- /dev/null
+++ b/management/internals/network_map_db/pgsql/network_resource.go
@@ -0,0 +1,31 @@
+package networkmap_pgsql
+
+import (
+	"context"
+
+	"github.com/jackc/pgx/v5"
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetNetworkResourcesQuery = `
+	select id, network_id, account_id, public_id, name, description, type, domain, prefix, enabled
+	from network_resources
+	where account_id=$1
+	`
+)
+
+func (pgc *PgStoreConn) GetNetworkResources(ctx context.Context, accountId string) ([]nmdata.NetworkResource, error) {
+	rows, err := pgc.Conn.Query(ctx, GetNetworkResourcesQuery, accountId)
+	if err != nil {
+		return nil, err
+	}
+
+	netresorces, err := pgx.CollectRows(rows, pgx.RowToStructByName[networkmapdb.Networkresource])
+	if err != nil {
+		return nil, err
+	}
+
+	return networkmapdb.ConvertAllToSharedTypes[networkmapdb.Networkresource, nmdata.NetworkResource](netresorces)
+}
diff --git a/management/internals/network_map_db/pgsql/network_router.go b/management/internals/network_map_db/pgsql/network_router.go
new file mode 100644
index 000000000..42d5e3b28
--- /dev/null
+++ b/management/internals/network_map_db/pgsql/network_router.go
@@ -0,0 +1,80 @@
+package networkmap_pgsql
+
+import (
+	"context"
+	"database/sql"
+	"encoding/json"
+	"fmt"
+	"reflect"
+
+	"github.com/jackc/pgx/v5"
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetNetworkRouterQuery = `
+	select public_id, peer, network_id, masquerade, metric, enabled, peer_groups,
+	(
+	  select array_agg(group_peers.peer_id)
+	  from group_peers
+	  where group_peers.account_id=$1 and group_peers.group_id in (select json_array_elements_text(peer_groups::json))
+	) as peers_via_groups
+	from network_routers
+	where account_id=$1
+	`
+)
+
+func (pgc *PgStoreConn) GetNetworkRouters(ctx context.Context, accountId string) (map[string]map[string]*nmdata.NetworkRouter, error) {
+	rows, err := pgc.Conn.Query(ctx, GetNetworkRouterQuery, accountId)
+	if err != nil {
+		return nil, err
+	}
+
+	routers, err := pgx.CollectRows(rows, pgx.RowToStructByName[networkrouter])
+	if err != nil {
+		return nil, err
+	}
+
+	toret := make(map[string]map[string]*nmdata.NetworkRouter)
+	for _, router := range routers {
+		if !router.Enabled.Bool {
+			continue
+		}
+
+		networkId := router.NetworkID.String
+		if networkId == "" {
+			return nil, fmt.Errorf("router with public_id %s doesn't have network_id set", router.PublicID.String)
+		}
+
+		nmdatarouter := nmdata.NetworkRouter{}
+		err := networkmapdb.FromSqlTypesToSharedTypes(reflect.ValueOf(&router), reflect.ValueOf(&nmdatarouter))
+		if err != nil {
+			return nil, err
+		}
+
+		if toret[networkId] == nil {
+			toret[networkId] = make(map[string]*nmdata.NetworkRouter)
+		}
+		if router.Peer.String != "" {
+			toret[networkId][router.Peer.String] = &nmdatarouter
+			continue
+		}
+		for _, peerId := range router.PeersViaGroups {
+			toret[networkId][peerId] = &nmdatarouter
+		}
+	}
+
+	return toret, nil
+}
+
+type networkrouter struct {
+	PublicID       sql.NullString
+	NetworkID      sql.NullString `nmap:"skip"`
+	Peer           sql.NullString `nmap:"skip"`
+	PeerGroups     json.RawMessage
+	PeersViaGroups []string `nmap:"skip"`
+	Masquerade     sql.NullBool
+	Metric         sql.NullInt64
+	Enabled        sql.NullBool
+}
diff --git a/management/internals/network_map_db/pgsql/networks.go b/management/internals/network_map_db/pgsql/networks.go
new file mode 100644
index 000000000..306356972
--- /dev/null
+++ b/management/internals/network_map_db/pgsql/networks.go
@@ -0,0 +1,36 @@
+package networkmap_pgsql
+
+import (
+	"context"
+
+	"github.com/jackc/pgx/v5"
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+)
+
+const (
+	GetNetworksQuery = `
+	select id, public_id
+	from networks where account_id=$1
+	`
+)
+
+func (pgc *PgStoreConn) GetNetworkXIDToPublicIdMap(ctx context.Context, accountId string) (map[string]string, error) {
+	rows, err := pgc.Conn.Query(ctx, GetNetworksQuery, accountId)
+	if err != nil {
+		return nil, err
+	}
+
+	networks, err := pgx.CollectRows(rows, pgx.RowToStructByName[networkmapdb.Network])
+	if err != nil {
+		return nil, err
+	}
+
+	toret := make(map[string]string)
+	for _, n := range networks {
+		if n.PublicID.Valid {
+			toret[n.ID] = n.PublicID.String
+		}
+	}
+
+	return toret, nil
+}
diff --git a/management/internals/network_map_db/pgsql/peer.go b/management/internals/network_map_db/pgsql/peer.go
new file mode 100644
index 000000000..962669f7a
--- /dev/null
+++ b/management/internals/network_map_db/pgsql/peer.go
@@ -0,0 +1,34 @@
+package networkmap_pgsql
+
+import (
+	"context"
+
+	"github.com/jackc/pgx/v5"
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetPeersQuery = `
+	select id, key, ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6,
+	peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster,
+	meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, meta_capabilities, meta_flags, meta_sync_message_version,
+	location_country_code, location_city_name, location_connection_ip
+	from peers
+	where account_id = $1
+	`
+)
+
+func (pgc *PgStoreConn) GetPeers(ctx context.Context, accountId string) ([]nmdata.Peer, map[string][]*nmdata.Peer, error) {
+	rows, err := pgc.Conn.Query(ctx, GetPeersQuery, accountId)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	peers, err := pgx.CollectRows(rows, pgx.RowToStructByName[networkmapdb.Peer])
+	if err != nil {
+		return nil, nil, err
+	}
+
+	return networkmapdb.ConvertToNmdataPeers(peers)
+}
diff --git a/management/internals/network_map_db/pgsql/pg_store.go b/management/internals/network_map_db/pgsql/pg_store.go
new file mode 100644
index 000000000..0cae610f8
--- /dev/null
+++ b/management/internals/network_map_db/pgsql/pg_store.go
@@ -0,0 +1,128 @@
+package networkmap_pgsql
+
+import (
+	"context"
+	"fmt"
+	"reflect"
+	"time"
+
+	"github.com/jackc/pgx/v5"
+	"github.com/jackc/pgx/v5/pgconn"
+	"github.com/jackc/pgx/v5/pgtype"
+	"github.com/jackc/pgx/v5/pgxpool"
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+)
+
+const (
+	pgMaxConnections    = 30
+	pgMinConnections    = 1
+	pgMaxConnLifetime   = 60 * time.Minute
+	pgHealthCheckPeriod = 1 * time.Minute
+)
+
+var _ networkmapdb.NetworkMapDBStore = &PgStore{}
+
+type PgStore struct {
+	Pool     *pgxpool.Pool
+	Location *time.Location
+}
+
+type PgStoreConn struct {
+	Conn pgInterface
+}
+
+type pgInterface interface {
+	Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
+	Exec(ctx context.Context, sql string, arguments ...any) (pgconn.CommandTag, error)
+}
+
+var _ networkmapdb.NetworkMapDBStoreConn = &PgStoreConn{}
+
+func NewPostgresqlStore(ctx context.Context, dsn string) (*PgStore, error) {
+	pool, err := connectToPgDb(ctx, dsn)
+	if err != nil {
+		return nil, err
+	}
+
+	return &PgStore{Pool: pool}, nil
+}
+
+// This is used to control the timezone timestamps returned in.
+// By default pgx returns timestamps in the local timezone,
+// which may not be desirable.
+// use .UsingTimeZone(time.UTC) to return timestamps in UTC TZ
+func (p *PgStore) UsingTimeZone(location *time.Location) {
+	p.Location = location
+}
+
+func (p *PgStore) UsingConnection(c *pgx.Conn) networkmapdb.NetworkMapDBStoreConn {
+	if p.Location != nil {
+		c.TypeMap().RegisterType(&pgtype.Type{
+			Name:  "timestamptz",
+			OID:   pgtype.TimestamptzOID,
+			Codec: &pgtype.TimestamptzCodec{ScanLocation: time.UTC},
+		})
+	}
+
+	return &PgStoreConn{Conn: c}
+}
+
+func (p *PgStore) Exec(ctx context.Context, query string, args ...any) error {
+	_, err := p.Pool.Exec(ctx, query, args...)
+	return err
+}
+
+func (p *PgStore) BeginTx(ctx context.Context) (networkmapdb.NetworkMapDBStoreConn, error) {
+	tx, err := p.Pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly})
+	if err != nil {
+		return nil, err
+	}
+	if p.Location != nil {
+		tx.Conn().TypeMap().RegisterType(&pgtype.Type{
+			Name:  "timestamptz",
+			OID:   pgtype.TimestamptzOID,
+			Codec: &pgtype.TimestamptzCodec{ScanLocation: time.UTC},
+		})
+	}
+	return &PgStoreConn{Conn: tx}, nil
+}
+
+func (c *PgStoreConn) RollbackTx(ctx context.Context) error {
+	tx, ok := c.Conn.(pgx.Tx)
+	if !ok {
+		return fmt.Errorf("expected an pgx.Tx got %s", reflect.TypeOf(c.Conn).Kind())
+	}
+	return tx.Rollback(ctx)
+}
+
+func (c *PgStoreConn) CommitTx(ctx context.Context) error {
+	tx, ok := c.Conn.(pgx.Tx)
+	if !ok {
+		return fmt.Errorf("expected an sql.Tx got %s", reflect.TypeOf(c.Conn).Kind())
+	}
+	return tx.Commit(ctx)
+}
+
+func connectToPgDb(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
+	config, err := pgxpool.ParseConfig(dsn)
+	if err != nil {
+		return nil, fmt.Errorf("unable to parse database config: %w", err)
+	}
+
+	config.MaxConns = pgMaxConnections
+	config.MinConns = pgMinConnections
+	config.MaxConnLifetime = pgMaxConnLifetime
+	config.HealthCheckPeriod = pgHealthCheckPeriod
+
+	pool, err := pgxpool.NewWithConfig(ctx, config)
+	if err != nil {
+		return nil, fmt.Errorf("unable to create connection pool: %w", err)
+	}
+
+	if err := pool.Ping(ctx); err != nil {
+		pool.Close()
+		return nil, fmt.Errorf("unable to ping database: %w", err)
+	}
+
+	return pool, nil
+}
diff --git a/management/internals/network_map_db/pgsql/policy.go b/management/internals/network_map_db/pgsql/policy.go
new file mode 100644
index 000000000..45927d7a2
--- /dev/null
+++ b/management/internals/network_map_db/pgsql/policy.go
@@ -0,0 +1,34 @@
+package networkmap_pgsql
+
+import (
+	"context"
+
+	"github.com/jackc/pgx/v5"
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetPoliciesQuery = `
+	select p.id, p.public_id, p.enabled, p.source_posture_checks, pr.enabled as rule_enabled, pr.action, pr.protocol, pr.bidirectional, 
+	pr.sources, pr.destinations, pr.source_resource, pr.destination_resource, pr.ports, pr.port_ranges,
+	pr.authorized_groups, pr.authorized_user
+	from policies as p
+	left join policy_rules as pr on p.id = pr.policy_id 
+	where account_id=$1
+	`
+)
+
+func (pgc *PgStoreConn) GetPolicies(ctx context.Context, accountId string) ([]nmdata.Policy, map[string]map[string]any, map[string]map[string]any, error) {
+	rows, err := pgc.Conn.Query(ctx, GetPoliciesQuery, accountId)
+	if err != nil {
+		return nil, nil, nil, err
+	}
+
+	policies, err := pgx.CollectRows(rows, pgx.RowToStructByName[networkmapdb.Policy])
+	if err != nil {
+		return nil, nil, nil, err
+	}
+
+	return networkmapdb.ConvertToNmdataPolicy(policies)
+}
diff --git a/management/internals/network_map_db/pgsql/posture.go b/management/internals/network_map_db/pgsql/posture.go
new file mode 100644
index 000000000..aedfec2a5
--- /dev/null
+++ b/management/internals/network_map_db/pgsql/posture.go
@@ -0,0 +1,44 @@
+package networkmap_pgsql
+
+import (
+	"context"
+	"reflect"
+
+	"github.com/jackc/pgx/v5"
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetPostureChecksQuery = `
+	select id, public_id, checks
+	from posture_checks
+	where account_id=$1
+	`
+)
+
+func (pgc *PgStoreConn) GetPostureChecks(ctx context.Context, accountId string) ([]nmdata.PostureChecks, map[string]string, error) {
+	rows, err := pgc.Conn.Query(ctx, GetPostureChecksQuery, accountId)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	checks, err := pgx.CollectRows(rows, pgx.RowToStructByName[networkmapdb.PostureChecks])
+	if err != nil {
+		return nil, nil, err
+	}
+
+	toret := make([]nmdata.PostureChecks, 0, len(checks))
+	idToPublicIDIdx := make(map[string]string)
+	for _, c := range checks {
+		checks := nmdata.PostureChecks{}
+		err := networkmapdb.FromSqlTypesToSharedTypes(reflect.ValueOf(&c), reflect.ValueOf(&checks))
+		if err != nil {
+			return nil, nil, err
+		}
+		toret = append(toret, checks)
+		idToPublicIDIdx[checks.ID] = c.PublicID.String
+	}
+
+	return toret, idToPublicIDIdx, nil
+}
diff --git a/management/internals/network_map_db/pgsql/route.go b/management/internals/network_map_db/pgsql/route.go
new file mode 100644
index 000000000..4f9a16c0e
--- /dev/null
+++ b/management/internals/network_map_db/pgsql/route.go
@@ -0,0 +1,33 @@
+package networkmap_pgsql
+
+import (
+	"context"
+
+	"github.com/jackc/pgx/v5"
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetRoutesQuery = `
+	select id, account_id, public_id, network, domains, keep_route, net_id, description,
+	peer, peer as peer_id, peer_groups, network_type, masquerade, metric, enabled, 
+	groups, access_control_groups, skip_auto_apply
+	from routes
+	where account_id=$1
+	`
+)
+
+func (pgc *PgStoreConn) GetRoutes(ctx context.Context, accountId string) ([]nmdata.Route, error) {
+	rows, err := pgc.Conn.Query(ctx, GetRoutesQuery, accountId)
+	if err != nil {
+		return nil, err
+	}
+
+	routes, err := pgx.CollectRows(rows, pgx.RowToStructByName[networkmapdb.Route])
+	if err != nil {
+		return nil, err
+	}
+
+	return networkmapdb.ConvertAllToSharedTypes[networkmapdb.Route, nmdata.Route](routes)
+}
diff --git a/management/internals/network_map_db/pgsql/service.go b/management/internals/network_map_db/pgsql/service.go
new file mode 100644
index 000000000..5d82046be
--- /dev/null
+++ b/management/internals/network_map_db/pgsql/service.go
@@ -0,0 +1,51 @@
+package networkmap_pgsql
+
+import (
+	"context"
+
+	"github.com/jackc/pgx/v5"
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+)
+
+const (
+	GetServicesQuery = `
+	select enabled, private, array (select json_array_elements_text(access_groups::json)) as access_groups, proxy_cluster, domain
+	from services
+	where account_id=$1
+	`
+
+	GetProxyTargetedDomainResourcesQuery = `
+	select t.target_id
+	from targets as t
+	join services as s on s.id = t.service_id
+	where s.account_id=$1 and s.enabled and not coalesce(s.terminated, false)
+	and t.enabled and t.target_type='domain' and t.target_id is not null
+	`
+)
+
+func (pgc *PgStoreConn) GetPrivateServices(ctx context.Context, accountId string) ([]networkmapdb.Service, error) {
+	rows, err := pgc.Conn.Query(ctx, GetServicesQuery, accountId)
+	if err != nil {
+		return nil, err
+	}
+
+	return pgx.CollectRows(rows, pgx.RowToStructByName[networkmapdb.Service])
+}
+
+func (pgc *PgStoreConn) GetProxyTargetedDomainResourceIDs(ctx context.Context, accountId string) (map[string]struct{}, error) {
+	rows, err := pgc.Conn.Query(ctx, GetProxyTargetedDomainResourcesQuery, accountId)
+	if err != nil {
+		return nil, err
+	}
+
+	ids, err := pgx.CollectRows(rows, pgx.RowTo[string])
+	if err != nil {
+		return nil, err
+	}
+
+	toret := make(map[string]struct{}, len(ids))
+	for _, id := range ids {
+		toret[id] = struct{}{}
+	}
+	return toret, nil
+}
diff --git a/management/internals/network_map_db/pgsql/user.go b/management/internals/network_map_db/pgsql/user.go
new file mode 100644
index 000000000..9e22c3575
--- /dev/null
+++ b/management/internals/network_map_db/pgsql/user.go
@@ -0,0 +1,60 @@
+package networkmap_pgsql
+
+import (
+	"context"
+
+	"github.com/jackc/pgx/v5"
+)
+
+const (
+	GetAllowedUserIdsQuery = `
+	select id, array (select json_array_elements_text(auto_groups::json)) as auto_groups
+	from users
+	where account_id=$1 and not blocked and not is_service_user
+	`
+
+	GetAllGroupIdQuery = `
+	select array_agg(id) from groups
+	where account_id=$1 and name='All'
+	`
+)
+
+func (pgc *PgStoreConn) GetAllowedUsers(ctx context.Context, accountId string) (map[string]struct{}, map[string][]string, error) {
+	rows, err := pgc.Conn.Query(ctx, GetAllowedUserIdsQuery, accountId)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	users, err := pgx.CollectRows(rows, pgx.RowToStructByName[user])
+	if err != nil {
+		return nil, nil, err
+	}
+
+	rows, err = pgc.Conn.Query(ctx, GetAllGroupIdQuery, accountId)
+	if err != nil {
+		return nil, nil, err
+	}
+	allGroupIds, err := pgx.CollectOneRow(rows, pgx.RowTo[[]string])
+	if err != nil {
+		return nil, nil, err
+	}
+
+	userIdIdx := make(map[string]struct{})
+	groupIdToUserIds := make(map[string][]string)
+	for _, user := range users {
+		userIdIdx[user.ID] = struct{}{}
+		for _, groupId := range user.AutoGroups {
+			groupIdToUserIds[groupId] = append(groupIdToUserIds[groupId], user.ID)
+		}
+		for _, allgid := range allGroupIds {
+			groupIdToUserIds[allgid] = append(groupIdToUserIds[allgid], user.ID)
+		}
+	}
+
+	return userIdIdx, groupIdToUserIds, nil
+}
+
+type user struct {
+	ID         string
+	AutoGroups []string
+}
diff --git a/management/internals/network_map_db/shared_types.go b/management/internals/network_map_db/shared_types.go
new file mode 100644
index 000000000..bdd387877
--- /dev/null
+++ b/management/internals/network_map_db/shared_types.go
@@ -0,0 +1,472 @@
+package networkmapdb
+
+import (
+	"context"
+	"database/sql"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"reflect"
+
+	"github.com/miekg/dns"
+	"github.com/netbirdio/netbird/management/server/integrations/integrated_validator"
+	"github.com/netbirdio/netbird/management/server/settings"
+	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+var ErrDnsUnsupportedRecordType = errors.New("unsupported record type")
+
+type NetworkMapDBStore interface { //nolint:revive // established name across the codebase
+	BeginTx(ctx context.Context) (NetworkMapDBStoreConn, error)
+	Exec(ctx context.Context, query string, args ...any) error
+}
+
+type NetworkMapDBStoreConn interface { //nolint:revive // established name across the codebase
+	GetGroups(ctx context.Context, accountId string) ([]nmdata.Group, map[string]map[string]any, error)
+	GetDomains(ctx context.Context, accountId string) ([]Domain, error)
+	GetPeers(ctx context.Context, accountId string) ([]nmdata.Peer, map[string][]*nmdata.Peer, error)
+	GetPolicies(ctx context.Context, accountId string) ([]nmdata.Policy, map[string]map[string]any, map[string]map[string]any, error)
+	GetRoutes(ctx context.Context, accountId string) ([]nmdata.Route, error)
+	GetNameServerGroups(ctx context.Context, accountId string) ([]nmdata.NameServerGroup, error)
+	GetNetworkResources(ctx context.Context, accountId string) ([]nmdata.NetworkResource, error)
+	GetNetworkRouters(ctx context.Context, accountId string) (map[string]map[string]*nmdata.NetworkRouter, error)
+	GetNetwork(ctx context.Context, accountId string) (nmdata.Network, error)
+	GetAppliedZoneCandidates(ctx context.Context, accountId string) ([]networkmap.AppliedZoneCandidate, error)
+	GetAccountSettings(ctx context.Context, accountId string) (nmdata.AccountSettingsInfo, error)
+	GetPostureChecks(ctx context.Context, accountId string) ([]nmdata.PostureChecks, map[string]string, error)
+	GetAllowedUsers(ctx context.Context, accountId string) (map[string]struct{}, map[string][]string, error)
+	GetDnsSettings(ctx context.Context, accountId string) (nmdata.DNSSettings, error)
+	GetNetworkXIDToPublicIdMap(ctx context.Context, accountId string) (map[string]string, error)
+	GetPrivateServices(ctx context.Context, accountId string) ([]Service, error)
+	GetProxyTargetedDomainResourceIDs(ctx context.Context, accountId string) (map[string]struct{}, error)
+
+	CommitTx(ctx context.Context) error
+	RollbackTx(ctx context.Context) error
+}
+
+type NetworkMapDBStoreImpl struct { //nolint:revive // established name across the codebase
+	Store                   NetworkMapDBStore
+	IntegratedPeerValidator integrated_validator.IntegratedValidator
+	ExtraSettingsManager    settings.Manager
+}
+
+// The order of fields in these structs is important.
+// Mapping of results of sqlite queries relies on the order
+// of the fields in these structs, when a query or a struct changes,
+// corresponding changes must be made to its counterpart.
+
+type Account struct {
+	PeerLoginExpirationEnabled      sql.NullBool
+	PeerLoginExpiration             sql.NullInt64
+	PeerInactivityExpirationEnabled sql.NullBool
+	PeerInactivityExpiration        sql.NullInt64
+	DNSDomain                       sql.NullString
+	IPv6EnabledGroups               []byte `nmap:"json"`
+	RoutingPeerDNSResolutionEnabled sql.NullBool
+	LazyConnectionEnabled           sql.NullBool
+	AutoUpdateVersion               sql.NullString
+	AutoUpdateAlways                sql.NullBool
+	MetricsPushEnabled              sql.NullBool
+}
+
+type Domain struct {
+	Domain        sql.NullString
+	TargetCluster sql.NullString
+}
+
+type Service struct {
+	Enabled      sql.NullBool
+	Private      sql.NullBool
+	AccessGroups []string
+	ProxyCluster sql.NullString
+	Domain       sql.NullString
+}
+
+type Zone struct {
+	Id                   string `nmap:"skip"`
+	Domain               sql.NullString
+	SearchDomainDisabled sql.NullBool
+	DistributionGroups   []byte         `nmap:"skip,json"`
+	RecordName           sql.NullString `nmap:"skip"`
+	RecordType           sql.NullString `nmap:"skip"`
+	RecordClass          sql.NullString `nmap:"skip"`
+	RecordTTL            sql.NullInt64  `nmap:"skip"`
+	RecordRData          sql.NullString `nmap:"skip"`
+}
+
+type NameserverGroup struct {
+	ID                   string
+	PublicID             sql.NullString
+	Name                 sql.NullString
+	Description          sql.NullString
+	NameServers          []byte `nmap:"json"`
+	Groups               []byte `nmap:"json"`
+	Primary              sql.NullBool
+	Domains              []byte `nmap:"json"`
+	Enabled              sql.NullBool
+	SearchDomainsEnabled sql.NullBool
+}
+
+type Networkresource struct {
+	ID          string
+	NetworkID   sql.NullString
+	AccountID   sql.NullString
+	PublicID    sql.NullString
+	Name        sql.NullString
+	Description sql.NullString
+	Type        sql.NullString
+	Domain      sql.NullString
+	Prefix      []byte `nmap:"json"`
+	Enabled     sql.NullBool
+}
+
+type AccountNetwork struct {
+	Identifier sql.NullString
+	Net        []byte `nmap:"json"`
+	NetV6      []byte `nmap:"json"`
+	Dns        sql.NullString
+	Serial     sql.NullInt64
+}
+
+type Network struct {
+	ID       string
+	PublicID sql.NullString
+}
+
+type Policy struct {
+	ID                  string
+	PublicID            sql.NullString
+	Enabled             sql.NullBool
+	SourcePostureChecks []byte         `nmap:"json"`
+	RuleEnabled         sql.NullBool   `nmap:"skip"`
+	Action              sql.NullString `nmap:"skip"`
+	Protocol            sql.NullString `nmap:"skip"`
+	Bidirectional       sql.NullBool   `nmap:"skip"`
+	Sources             []byte         `nmap:"skip,json"`
+	Destinations        []byte         `nmap:"skip,json"`
+	SourceResource      []byte         `nmap:"skip,json"`
+	DestinationResource []byte         `nmap:"skip,json"`
+	Ports               []byte         `nmap:"skip,json"`
+	PortRanges          []byte         `nmap:"skip,json"`
+	AuthorizedGroups    []byte         `nmap:"skip,json"`
+	AuthorizedUser      sql.NullString `nmap:"skip"`
+}
+
+// Depending on db interface LastLogin contains time in different formats:
+// for sqlite/sql.NullTime the time in UTC
+// for pgx the time is in the local timezone
+// TODO add support for creating struct fields from denormalized fields
+type Peer struct {
+	ID                         string
+	Key                        sql.NullString
+	SSHKey                     sql.NullString
+	DNSLabel                   sql.NullString
+	ExtraDNSLabels             []byte `nmap:"json"`
+	UserID                     sql.NullString
+	SSHEnabled                 sql.NullBool
+	LoginExpirationEnabled     sql.NullBool
+	LastLogin                  sql.NullTime
+	IP                         []byte         `nmap:"json"`
+	IPv6                       []byte         `nmap:"json"`
+	PeerStatusRequiresApproval sql.NullBool   `nmap:"map_to:RequiresApproval"`
+	PeerStatusConnected        sql.NullBool   `nmap:"skip"`
+	ProxyMetaEmbedded          sql.NullBool   `nmap:"skip"`
+	ProxyMetaCluster           sql.NullString `nmap:"skip"`
+	MetaWtVersion              sql.NullString `nmap:"skip"`
+	MetaGoOS                   sql.NullString `nmap:"skip"`
+	MetaOSVersion              sql.NullString `nmap:"skip"`
+	MetaKernelVersion          sql.NullString `nmap:"skip"`
+	MetaNetworkAddresses       []byte         `nmap:"skip,json"`
+	MetaFiles                  []byte         `nmap:"skip,json"`
+	MetaCapabilities           []byte         `nmap:"skip,json"`
+	MetaFlags                  []byte         `nmap:"skip,json"`
+	MetaSyncMessageVersion     sql.NullInt64  `nmap:"skip"`
+	LocationCountryCode        sql.NullString `nmap:"skip"`
+	LocationCityName           sql.NullString `nmap:"skip"`
+	LocationConnectionIp       []byte         `nmap:"skip,json"`
+}
+
+type PostureChecks struct {
+	ID       string
+	PublicID sql.NullString `nmap:"skip"`
+	Checks   []byte         `nmap:"json"`
+}
+
+type Route struct {
+	ID                  string
+	AccountID           sql.NullString
+	PublicID            sql.NullString
+	Network             []byte `nmap:"json"`
+	Domains             []byte `nmap:"json"`
+	KeepRoute           sql.NullBool
+	NetID               sql.NullString
+	Description         sql.NullString
+	Peer                sql.NullString
+	PeerID              sql.NullString
+	PeerGroups          []byte `nmap:"json"`
+	NetworkType         sql.NullInt64
+	Masquerade          sql.NullBool
+	Metric              sql.NullInt64
+	Enabled             sql.NullBool
+	Groups              []byte `nmap:"json"`
+	AccessControlGroups []byte `nmap:"json"`
+	SkipAutoApply       sql.NullBool
+}
+
+func RecordTypeAndRdata(t, rdata string) (int, string, error) {
+	switch t {
+	case "A":
+		return int(dns.TypeA), rdata, nil
+	case "AAAA":
+		return int(dns.TypeAAAA), rdata, nil
+	case "CNAME":
+		return int(dns.TypeCNAME), dns.Fqdn(rdata), nil
+	default:
+		return 0, "", fmt.Errorf("record type: %s %w", t, ErrDnsUnsupportedRecordType)
+	}
+}
+
+func ZonesToAppliedZoneCandidates(zones []Zone) ([]networkmap.AppliedZoneCandidate, error) {
+	toret := make([]networkmap.AppliedZoneCandidate, 0, len(zones))
+	currentZoneId := ""
+	for _, z := range zones {
+		if !z.RecordType.Valid {
+			continue
+		}
+
+		zone := nmdata.CustomZone{}
+		err := FromSqlTypesToSharedTypes(
+			reflect.ValueOf(&z), reflect.ValueOf(&zone))
+		if err != nil {
+			return nil, err
+		}
+
+		var distributionGroups []string
+		if err := json.Unmarshal(z.DistributionGroups, &distributionGroups); err != nil {
+			return nil, err
+		}
+
+		if z.Id != currentZoneId {
+			// The account-side builder (types.buildAppliedZoneCandidates) states
+			// the shape of an applied zone: names fully qualified, served
+			// non-authoritatively. Both builders feed the same client-facing map,
+			// so this one has to produce the same value.
+			zone.Domain = dns.Fqdn(zone.Domain)
+			zone.NonAuthoritative = true
+			zone.Records = []nmdata.SimpleRecord{}
+			toret = append(toret, AppliedZoneCandidateFromZone(zone, distributionGroups))
+			currentZoneId = z.Id
+		}
+
+		rtype, rdata, err := RecordTypeAndRdata(z.RecordType.String, z.RecordRData.String)
+		if err != nil {
+			if errors.Is(err, ErrDnsUnsupportedRecordType) {
+				continue
+			}
+			return nil, err
+		}
+
+		lastZone := &toret[len(toret)-1]
+		lastZone.Zone.Records = append(lastZone.Zone.Records, nmdata.SimpleRecord{
+			Name:  dns.Fqdn(z.RecordName.String),
+			Class: z.RecordClass.String,
+			TTL:   int(z.RecordTTL.Int64),
+			RData: rdata,
+			Type:  rtype,
+		})
+	}
+	return toret, nil
+}
+
+func AppliedZoneCandidateFromZone(z nmdata.CustomZone, distributionGroups []string) networkmap.AppliedZoneCandidate {
+	return networkmap.AppliedZoneCandidate{
+		DistributionGroups: distributionGroups,
+		Zone:               z,
+	}
+}
+
+func ConvertToNmdataPeers(peers []Peer) ([]nmdata.Peer, map[string][]*nmdata.Peer, error) {
+	toret := make([]nmdata.Peer, 0, len(peers))
+	clusterToPeerIdx := make(map[string][]*nmdata.Peer)
+	for _, p := range peers {
+		dp := nmdata.Peer{}
+		err := FromSqlTypesToSharedTypes(
+			reflect.ValueOf(&p), reflect.ValueOf(&dp))
+		if err != nil {
+			return nil, nil, err
+		}
+
+		if p.ProxyMetaEmbedded.Valid {
+			dp.ProxyMeta.Embedded = p.ProxyMetaEmbedded.Bool
+		}
+		dp.ProxyMeta.Cluster = p.ProxyMetaCluster.String
+		// This is only used to build private service candidates, not connected peers are skipped
+		if dp.ProxyMeta.Embedded && p.PeerStatusConnected.Bool {
+			clusterToPeerIdx[p.ProxyMetaCluster.String] = append(clusterToPeerIdx[p.ProxyMetaCluster.String], &dp)
+		}
+		if p.MetaWtVersion.Valid {
+			dp.Meta.WtVersion = p.MetaWtVersion.String
+		}
+		if p.MetaSyncMessageVersion.Valid {
+			dp.Meta.SyncMessageVersion = int(p.MetaSyncMessageVersion.Int64)
+		}
+		if p.MetaGoOS.Valid {
+			dp.Meta.GoOS = p.MetaGoOS.String
+		}
+		if p.MetaOSVersion.Valid {
+			dp.Meta.OSVersion = p.MetaOSVersion.String
+		}
+		if p.MetaKernelVersion.Valid {
+			dp.Meta.KernelVersion = p.MetaKernelVersion.String
+		}
+		if p.LocationCountryCode.Valid {
+			dp.Location.CountryCode = p.LocationCountryCode.String
+		}
+		if p.LocationCityName.Valid {
+			dp.Location.CityName = p.LocationCityName.String
+		}
+		if p.LocationConnectionIp != nil {
+			err := json.Unmarshal(p.LocationConnectionIp, &dp.Location.ConnectionIP)
+			if err != nil {
+				return toret, nil, err
+			}
+		}
+		if p.MetaFiles != nil {
+			err := json.Unmarshal(p.MetaFiles, &dp.Meta.Files)
+			if err != nil {
+				return toret, nil, err
+			}
+		}
+		if p.MetaCapabilities != nil {
+			err := json.Unmarshal(p.MetaCapabilities, &dp.Meta.Capabilities)
+			if err != nil {
+				return toret, nil, err
+			}
+		}
+		if p.MetaFlags != nil {
+			err := json.Unmarshal(p.MetaFlags, &dp.Meta.Flags)
+			if err != nil {
+				return toret, nil, err
+			}
+		}
+		if p.MetaNetworkAddresses != nil {
+			err := json.Unmarshal(p.MetaNetworkAddresses, &dp.Meta.NetworkAddresses)
+			if err != nil {
+				return toret, nil, err
+			}
+		}
+
+		toret = append(toret, dp)
+	}
+
+	return toret, clusterToPeerIdx, nil
+}
+
+func ConvertToNmdataPolicy(policies []Policy) ([]nmdata.Policy, map[string]map[string]any, map[string]map[string]any, error) {
+	toret := make([]nmdata.Policy, 0, len(policies))
+	policyToDestinationResourceIdx := make(map[string]map[string]any) // policy id to destination resource id
+	policyToDestinationGroupIdx := make(map[string]map[string]any)    // policy id to destination group id
+	for _, p := range policies {
+		policy := nmdata.Policy{}
+		err := FromSqlTypesToSharedTypes(
+			reflect.ValueOf(&p), reflect.ValueOf(&policy))
+		if err != nil {
+			return nil, nil, nil, err
+		}
+
+		var policyRule *nmdata.PolicyRule
+		pr := func() *nmdata.PolicyRule {
+			if policyRule != nil {
+				return policyRule
+			}
+
+			policyRule = &nmdata.PolicyRule{}
+			return policyRule
+		}
+
+		if p.RuleEnabled.Valid {
+			pr().Enabled = p.RuleEnabled.Bool
+		}
+		if p.Action.Valid {
+			pr().Action = p.Action.String
+		}
+		if p.Protocol.Valid {
+			pr().Protocol = p.Protocol.String
+		}
+		if p.Bidirectional.Valid {
+			pr().Bidirectional = p.Bidirectional.Bool
+		}
+		if len(p.Sources) > 0 {
+			err := json.Unmarshal([]byte(p.Sources), &pr().Sources)
+			if err != nil {
+				return toret, nil, nil, err
+			}
+		}
+		if len(p.Destinations) > 0 {
+			err := json.Unmarshal([]byte(p.Destinations), &pr().Destinations)
+			if err != nil {
+				return toret, nil, nil, err
+			}
+
+			if p.RuleEnabled.Valid && p.RuleEnabled.Bool {
+				for _, dst := range pr().Destinations {
+					if _, ok := policyToDestinationGroupIdx[p.ID]; !ok {
+						policyToDestinationGroupIdx[p.ID] = make(map[string]any)
+					}
+					policyToDestinationGroupIdx[p.ID][dst] = struct{}{}
+				}
+			}
+		}
+		if len(p.SourceResource) > 0 {
+			err := json.Unmarshal([]byte(p.SourceResource), &pr().SourceResource)
+			if err != nil {
+				return toret, nil, nil, err
+			}
+		}
+		if len(p.DestinationResource) > 0 {
+			err := json.Unmarshal([]byte(p.DestinationResource), &pr().DestinationResource)
+			if err != nil {
+				return toret, nil, nil, err
+			}
+
+			if p.RuleEnabled.Valid && p.RuleEnabled.Bool {
+				if _, ok := policyToDestinationResourceIdx[p.ID]; !ok {
+					policyToDestinationResourceIdx[p.ID] = make(map[string]any)
+				}
+				policyToDestinationResourceIdx[p.ID][pr().DestinationResource.ID] = struct{}{}
+			}
+		}
+		if len(p.Ports) > 0 {
+			err := json.Unmarshal([]byte(p.Ports), &pr().Ports)
+			if err != nil {
+				return toret, nil, nil, err
+			}
+		}
+		if len(p.PortRanges) > 0 {
+			err := json.Unmarshal([]byte(p.PortRanges), &pr().PortRanges)
+			if err != nil {
+				return toret, nil, nil, err
+			}
+		}
+		if len(p.AuthorizedGroups) > 0 {
+			err := json.Unmarshal([]byte(p.AuthorizedGroups), &pr().AuthorizedGroups)
+			if err != nil {
+				return toret, nil, nil, err
+			}
+		}
+		if p.AuthorizedUser.Valid {
+			pr().AuthorizedUser = p.AuthorizedUser.String
+		}
+
+		if policyRule != nil {
+			policyRule.ID = p.ID
+			policyRule.PolicyID = p.ID
+			policy.Rules = []*nmdata.PolicyRule{policyRule}
+		}
+
+		toret = append(toret, policy)
+	}
+
+	return toret, policyToDestinationResourceIdx, policyToDestinationGroupIdx, nil
+}
diff --git a/management/internals/network_map_db/shared_types_test.go b/management/internals/network_map_db/shared_types_test.go
new file mode 100644
index 000000000..8a1239e26
--- /dev/null
+++ b/management/internals/network_map_db/shared_types_test.go
@@ -0,0 +1,38 @@
+package networkmapdb
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestRecordTypeAndRdata(t *testing.T) {
+	var tests = []struct {
+		recordType         string
+		expectedRecordType int
+		rdata              string
+		expectedRdata      string
+		expectedErr        error
+	}{
+		{recordType: "A", expectedRecordType: 1, rdata: "test.com", expectedRdata: "test.com", expectedErr: nil},
+		{recordType: "AAAA", expectedRecordType: 28, rdata: "test.com", expectedRdata: "test.com", expectedErr: nil},
+		{recordType: "CNAME", expectedRecordType: 5, rdata: "test.com", expectedRdata: "test.com.", expectedErr: nil},
+		{recordType: "CNAME", expectedRecordType: 5, rdata: "test.com.", expectedRdata: "test.com.", expectedErr: nil},
+		{recordType: "TypeMX", expectedErr: ErrDnsUnsupportedRecordType},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.recordType, func(t *testing.T) {
+			recordType, rdata, err := RecordTypeAndRdata(tt.recordType, tt.rdata)
+
+			if tt.expectedErr != nil {
+				assert.ErrorIs(t, err, ErrDnsUnsupportedRecordType)
+				return
+			}
+
+			assert.NoError(t, err)
+			assert.Equal(t, recordType, tt.expectedRecordType)
+			assert.Equal(t, rdata, tt.expectedRdata)
+		})
+	}
+}
diff --git a/management/internals/network_map_db/sql_type_conversion_test.go b/management/internals/network_map_db/sql_type_conversion_test.go
new file mode 100644
index 000000000..77dab93a8
--- /dev/null
+++ b/management/internals/network_map_db/sql_type_conversion_test.go
@@ -0,0 +1,253 @@
+package networkmapdb
+
+import (
+	"database/sql"
+	"encoding/json"
+	"reflect"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestNullStringSupport(t *testing.T) {
+	src := withNullString{Name: sql.NullString{String: "string", Valid: true}}
+	dst := withString{}
+	assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
+	assert.Equal(t, withString{Name: "string"}, dst)
+
+	src = withNullString{Name: sql.NullString{Valid: false}}
+	dst = withString{}
+	assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
+	assert.Equal(t, withString{Name: ""}, dst)
+}
+
+func TestNullBoolSupport(t *testing.T) {
+	src := withNullBool{TrueOrFalse: sql.NullBool{Bool: true, Valid: true}}
+	dst := withBool{}
+	assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
+	assert.Equal(t, withBool{TrueOrFalse: true}, dst)
+
+}
+
+func TestRawJsonSupport(t *testing.T) {
+	jb, _ := json.Marshal(embeddedS{Name: "blob-name", SomeField: 1})
+	src := withRawJson{Blob: json.RawMessage(jb)}
+	dst := fromJson{}
+	assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
+	assert.Equal(t, fromJson{Blob: embeddedS{Name: "blob-name", SomeField: 1}}, dst)
+
+	src1 := withRawJson{}
+	dst1 := fromJson{}
+	assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src1), reflect.ValueOf(&dst1)))
+	assert.Equal(t, fromJson{}, dst1)
+}
+
+func TestShouldSkipTag(t *testing.T) {
+	src5 := withSkipTag{Field: "shouldskip"}
+	dst5 := emptySkipTagTarget{}
+	assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src5), reflect.ValueOf(&dst5)))
+	assert.Equal(t, emptySkipTagTarget{}, dst5)
+
+}
+
+func TestMapToTag(t *testing.T) {
+	src6 := withMapToTag{Field: "fieldvalue"}
+	dst6 := mapToTagTarget{}
+	assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src6), reflect.ValueOf(&dst6)))
+	assert.Equal(t, mapToTagTarget{AnotherField: "fieldvalue"}, dst6)
+}
+
+func TestNullableInt64Support(t *testing.T) {
+	src := withInt64{Field: sql.NullInt64{Int64: int64(1), Valid: true}}
+	dst := int64Target{}
+	assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
+	assert.Equal(t, int64Target{Field: 1}, dst)
+}
+
+func TestNullableTimeSupport(t *testing.T) {
+	now := time.Now()
+	src := withNullableTime{Field: sql.NullTime{Time: now, Valid: true}}
+	dst := nullableTimeTarget{}
+	assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
+	assert.Equal(t, nullableTimeTarget{Field: now}, dst)
+}
+
+func TestNullableTimePointerSupport(t *testing.T) {
+	now := time.Now()
+	src := withNullableTime{Field: sql.NullTime{Time: now, Valid: true}}
+	dst := nullableTimePointerTarget{}
+	assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
+	assert.Equal(t, nullableTimePointerTarget{Field: &now}, dst)
+}
+
+func TestStringSLiceSupport(t *testing.T) {
+	src := withStringSlice{Field: []string{"one"}}
+	dst := withStringSlice{}
+	assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
+	assert.Equal(t, withStringSlice{Field: []string{"one"}}, dst)
+}
+
+func TestNullStringSLiceSupport(t *testing.T) {
+	src := withStringSlice{}
+	dst := withStringSlice{}
+	assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
+	assert.Equal(t, withStringSlice{}, dst)
+}
+
+func TestWithMultipleFields(t *testing.T) {
+	now := time.Now()
+	src := withMultipleFields{
+		Field1: sql.NullString{String: "aaa", Valid: true},
+		Field2: sql.NullBool{Bool: true, Valid: true},
+		Field3: sql.NullTime{Time: now, Valid: true},
+		Field4: sql.NullInt64{Int64: 1, Valid: true},
+		Field5: "another",
+	}
+	dst := multipleFieldsTarget{}
+	assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
+	assert.Equal(t, multipleFieldsTarget{
+		Field1: "aaa",
+		Field2: true,
+		Field3: now,
+		Field4: 1,
+		Field5: "another",
+	}, dst)
+}
+
+func TestEmptyPublicIdsFilled(t *testing.T) {
+	src := withEmptyPublicIds{}
+	dst := emptyPublicIdTarget{}
+	assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
+	assert.NotEmpty(t, dst.PublicID)
+	assert.NotEmpty(t, dst.PublicId)
+}
+
+// only []byte and []uint8 slices with "json" tag are being parsed
+func TestByteSliceSupport(t *testing.T) {
+	src := withByteSlice{
+		Field: []byte("[\"one\",\"two\",\"three\"]"),
+	}
+	dst := byteSliceTarget{}
+	assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
+	assert.Equal(t, []string{"one", "two", "three"}, dst.Field)
+}
+
+func TestUint8SliceSupport(t *testing.T) {
+	src := withUint8Slice{
+		Field: []uint8("[\"one\",\"two\",\"three\"]"),
+	}
+	dst := uint8SliceTarget{}
+	assert.NoError(t, FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
+	assert.Equal(t, []string{"one", "two", "three"}, dst.Field)
+}
+
+type withNullString struct {
+	Name sql.NullString
+}
+
+type withString struct {
+	Name string
+}
+
+type withMultipleFields struct {
+	Field1 sql.NullString
+	Field2 sql.NullBool
+	Field3 sql.NullTime
+	Field4 sql.NullInt64
+	Field5 string
+}
+
+type multipleFieldsTarget struct {
+	Field1 string
+	Field2 bool
+	Field3 time.Time
+	Field4 int64
+	Field5 string
+}
+
+type withNullBool struct {
+	TrueOrFalse sql.NullBool
+}
+
+type withBool struct {
+	TrueOrFalse bool
+}
+
+type withRawJson struct {
+	Blob json.RawMessage
+}
+
+type embeddedS struct {
+	Name      string
+	SomeField int
+}
+type fromJson struct {
+	Blob embeddedS
+}
+
+type withSkipTag struct {
+	Field string `nmap:"skip"`
+}
+
+type emptySkipTagTarget struct {
+	Field string
+}
+
+type withMapToTag struct {
+	Field string `nmap:"map_to:AnotherField"`
+}
+
+type mapToTagTarget struct {
+	AnotherField string
+}
+
+type withInt64 struct {
+	Field sql.NullInt64
+}
+
+type int64Target struct {
+	Field int
+}
+
+type withNullableTime struct {
+	Field sql.NullTime
+}
+
+type nullableTimeTarget struct {
+	Field time.Time
+}
+
+type nullableTimePointerTarget struct {
+	Field *time.Time
+}
+
+type withStringSlice struct {
+	Field []string
+}
+
+type withEmptyPublicIds struct {
+	PublicID sql.NullString
+	PublicId sql.NullString
+}
+
+type emptyPublicIdTarget struct {
+	PublicID string
+	PublicId string
+}
+
+type withByteSlice struct {
+	Field []byte `nmap:"json"`
+}
+
+type byteSliceTarget struct {
+	Field []string
+}
+
+type withUint8Slice struct {
+	Field []byte `nmap:"json"`
+}
+
+type uint8SliceTarget struct {
+	Field []string
+}
diff --git a/management/internals/network_map_db/sqlite/account_setting.go b/management/internals/network_map_db/sqlite/account_setting.go
new file mode 100644
index 000000000..9a1a152fe
--- /dev/null
+++ b/management/internals/network_map_db/sqlite/account_setting.go
@@ -0,0 +1,47 @@
+package networkmap_sqlite
+
+import (
+	"context"
+	"reflect"
+
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetAccountSettingsQuery = `
+	select settings_peer_login_expiration_enabled as peer_login_expiration_enabled,
+	settings_peer_login_expiration as peer_login_expiration,
+	settings_peer_inactivity_expiration_enabled as peer_inactivity_expiration_enabled,
+	settings_peer_inactivity_expiration as peer_inactivity_expiration,
+	settings_dns_domain as dns_domain,
+	settings_ipv6_enabled_groups as ipv6_enabled_groups,
+	settings_routing_peer_dns_resolution_enabled as routing_peer_dns_resolution_enabled,
+	settings_lazy_connection_enabled as lazy_connection_enabled,
+	settings_auto_update_version as auto_update_version,
+	settings_auto_update_always as auto_update_always,
+	settings_metrics_push_enabled as metrics_push_enabled
+	from accounts
+	where id=?
+	`
+)
+
+func (sc *SqliteStoreConn) GetAccountSettings(ctx context.Context, accountId string) (nmdata.AccountSettingsInfo, error) {
+	rows, err := sc.Conn.QueryContext(ctx, GetAccountSettingsQuery, accountId)
+	if err != nil {
+		return nmdata.AccountSettingsInfo{}, err
+	}
+
+	a, err := CollectOneRowForSqlite[networkmapdb.Account](rows)
+	if err != nil {
+		return nmdata.AccountSettingsInfo{}, err
+	}
+
+	settingsInfo := nmdata.AccountSettingsInfo{}
+	err = networkmapdb.FromSqlTypesToSharedTypes(reflect.ValueOf(&a), reflect.ValueOf(&settingsInfo))
+	if err != nil {
+		return nmdata.AccountSettingsInfo{}, err
+	}
+
+	return settingsInfo, nil
+}
diff --git a/management/internals/network_map_db/sqlite/dns.go b/management/internals/network_map_db/sqlite/dns.go
new file mode 100644
index 000000000..dd2cb3758
--- /dev/null
+++ b/management/internals/network_map_db/sqlite/dns.go
@@ -0,0 +1,32 @@
+package networkmap_sqlite
+
+import (
+	"context"
+
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap"
+)
+
+const (
+	GetAccountZonesQuery = `
+	select zones.id as id, domain, not enable_search_domain as search_domain_disabled, distribution_groups,
+	r.name as record_name, r.type as record_type, 'IN' record_class, r.ttl as record_ttl, r.content as record_rdata
+	from zones
+	left join records as r on r.zone_id = zones.id
+	where zones.account_id=? and zones.enabled
+	`
+)
+
+func (sc *SqliteStoreConn) GetAppliedZoneCandidates(ctx context.Context, accountId string) ([]networkmap.AppliedZoneCandidate, error) {
+	rows, err := sc.Conn.QueryContext(ctx, GetAccountZonesQuery, accountId)
+	if err != nil {
+		return nil, err
+	}
+
+	zones, err := CollectRowsForSqlite[networkmapdb.Zone](rows)
+	if err != nil {
+		return nil, err
+	}
+
+	return networkmapdb.ZonesToAppliedZoneCandidates(zones)
+}
diff --git a/management/internals/network_map_db/sqlite/dns_setting.go b/management/internals/network_map_db/sqlite/dns_setting.go
new file mode 100644
index 000000000..7c6e9e7eb
--- /dev/null
+++ b/management/internals/network_map_db/sqlite/dns_setting.go
@@ -0,0 +1,42 @@
+package networkmap_sqlite
+
+import (
+	"context"
+	"encoding/json"
+
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetDnsSettingsQuery = `
+	select dns_settings_disabled_management_groups
+	from accounts
+	where id=?
+	`
+)
+
+func (sc *SqliteStoreConn) GetDnsSettings(ctx context.Context, accountId string) (nmdata.DNSSettings, error) {
+	rows, err := sc.Conn.QueryContext(ctx, GetDnsSettingsQuery, accountId)
+	if err != nil {
+		return nmdata.DNSSettings{}, err
+	}
+	defer rows.Close()
+
+	var value nmdata.DNSSettings
+	var settings []byte
+
+	rows.Next()
+	if err := rows.Scan(&settings); err != nil {
+		return value, err
+	}
+
+	if settings == nil {
+		return nmdata.DNSSettings{}, nil
+	}
+
+	if err := json.Unmarshal(settings, &value.DisabledManagementGroups); err != nil {
+		return value, err
+	}
+
+	return value, nil
+}
diff --git a/management/internals/network_map_db/sqlite/domain.go b/management/internals/network_map_db/sqlite/domain.go
new file mode 100644
index 000000000..572977c3b
--- /dev/null
+++ b/management/internals/network_map_db/sqlite/domain.go
@@ -0,0 +1,24 @@
+package networkmap_sqlite
+
+import (
+	"context"
+
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+)
+
+const (
+	GetDomainsQuery = `
+	select domain, target_cluster
+	from domains
+	where account_id=? and domain<>'' and target_cluster<>''
+	`
+)
+
+func (sc *SqliteStoreConn) GetDomains(ctx context.Context, accountId string) ([]networkmapdb.Domain, error) {
+	rows, err := sc.Conn.QueryContext(ctx, GetDomainsQuery, accountId)
+	if err != nil {
+		return nil, err
+	}
+
+	return CollectRowsForSqlite[networkmapdb.Domain](rows)
+}
diff --git a/management/internals/network_map_db/sqlite/group.go b/management/internals/network_map_db/sqlite/group.go
new file mode 100644
index 000000000..c324d0d29
--- /dev/null
+++ b/management/internals/network_map_db/sqlite/group.go
@@ -0,0 +1,70 @@
+package networkmap_sqlite
+
+import (
+	"context"
+	"database/sql"
+	"reflect"
+
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetGroupsQuery = `
+	select groups.id, groups.name, groups.public_id, groups.resources, gp.peer_id
+	from groups 
+	left join group_peers gp on gp.group_id=groups.id and gp.account_id=?
+	where groups.account_id=?
+	`
+)
+
+// we also return a resource-to-group index.
+// an alternative is to add json indexes, query this directly. Not sure how expensive
+// json indexes are. TODO (dmitri) verify and maybe change the implementation here.
+func (sc *SqliteStoreConn) GetGroups(ctx context.Context, accountId string) ([]nmdata.Group, map[string]map[string]any, error) {
+	rows, err := sc.Conn.QueryContext(ctx, GetGroupsQuery, accountId, accountId)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	groups, err := CollectRowsForSqlite[group](rows)
+
+	toret := make([]nmdata.Group, 0, len(groups))
+	resourceToGroupIdx := make(map[string]map[string]any)
+
+	for _, g := range groups {
+		if len(toret) > 0 && toret[len(toret)-1].ID == g.ID && g.PeerID.Valid {
+			toret[len(toret)-1].Peers = append(toret[len(toret)-1].Peers, g.PeerID.String)
+			continue
+		}
+
+		dg := nmdata.Group{}
+		err := networkmapdb.FromSqlTypesToSharedTypes(
+			reflect.ValueOf(&g), reflect.ValueOf(&dg))
+		if err != nil {
+			return nil, nil, err
+		}
+
+		if g.PeerID.Valid {
+			dg.Peers = append(dg.Peers, g.PeerID.String)
+		}
+		toret = append(toret, dg)
+
+		for _, resource := range dg.Resources {
+			if _, ok := resourceToGroupIdx[resource.ID]; !ok {
+				resourceToGroupIdx[resource.ID] = make(map[string]any)
+			}
+			resourceToGroupIdx[resource.ID][g.ID] = struct{}{}
+		}
+	}
+
+	return toret, resourceToGroupIdx, err
+}
+
+type group struct {
+	ID        string
+	Name      sql.NullString
+	PublicID  sql.NullString
+	Resources []byte         `nmap:"json"`
+	PeerID    sql.NullString `nmap:"skip"`
+}
diff --git a/management/internals/network_map_db/sqlite/nameserver.go b/management/internals/network_map_db/sqlite/nameserver.go
new file mode 100644
index 000000000..618e1a1f3
--- /dev/null
+++ b/management/internals/network_map_db/sqlite/nameserver.go
@@ -0,0 +1,30 @@
+package networkmap_sqlite
+
+import (
+	"context"
+
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetNameserversQuery = `
+	select id, public_id, name, description, name_servers, groups, "primary", domains, enabled, search_domains_enabled
+	from name_server_groups
+	where account_id=?
+	`
+)
+
+func (sc *SqliteStoreConn) GetNameServerGroups(ctx context.Context, accountId string) ([]nmdata.NameServerGroup, error) {
+	rows, err := sc.Conn.QueryContext(ctx, GetNameserversQuery, accountId)
+	if err != nil {
+		return nil, err
+	}
+
+	nsgroups, err := CollectRowsForSqlite[networkmapdb.NameserverGroup](rows)
+	if err != nil {
+		return nil, err
+	}
+
+	return networkmapdb.ConvertAllToSharedTypes[networkmapdb.NameserverGroup, nmdata.NameServerGroup](nsgroups)
+}
diff --git a/management/internals/network_map_db/sqlite/network.go b/management/internals/network_map_db/sqlite/network.go
new file mode 100644
index 000000000..3fa85ecdf
--- /dev/null
+++ b/management/internals/network_map_db/sqlite/network.go
@@ -0,0 +1,38 @@
+package networkmap_sqlite
+
+import (
+	"context"
+	"reflect"
+
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetNetworkQuery = `
+	select network_identifier as identifier, network_net as net, network_net_v6 as net_v6, network_dns as dns, network_serial as serial
+	from accounts
+	where id=?
+	`
+)
+
+func (sc *SqliteStoreConn) GetNetwork(ctx context.Context, accountId string) (nmdata.Network, error) {
+	rows, err := sc.Conn.QueryContext(ctx, GetNetworkQuery, accountId)
+	if err != nil {
+		return nmdata.Network{}, err
+	}
+
+	n, err := CollectOneRowForSqlite[networkmapdb.AccountNetwork](rows)
+	if err != nil {
+		return nmdata.Network{}, err
+	}
+
+	toret := nmdata.Network{}
+	err = networkmapdb.FromSqlTypesToSharedTypes(
+		reflect.ValueOf(&n), reflect.ValueOf(&toret))
+	if err != nil {
+		return nmdata.Network{}, err
+	}
+
+	return toret, nil
+}
diff --git a/management/internals/network_map_db/sqlite/network_resource.go b/management/internals/network_map_db/sqlite/network_resource.go
new file mode 100644
index 000000000..1d98a12e9
--- /dev/null
+++ b/management/internals/network_map_db/sqlite/network_resource.go
@@ -0,0 +1,30 @@
+package networkmap_sqlite
+
+import (
+	"context"
+
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetNetworkResourcesQuery = `
+	select id, network_id, account_id, public_id, name, description, type, domain, prefix, enabled
+	from network_resources
+	where account_id=?
+	`
+)
+
+func (sc *SqliteStoreConn) GetNetworkResources(ctx context.Context, accountId string) ([]nmdata.NetworkResource, error) {
+	rows, err := sc.Conn.QueryContext(ctx, GetNetworkResourcesQuery, accountId)
+	if err != nil {
+		return nil, err
+	}
+
+	netresorces, err := CollectRowsForSqlite[networkmapdb.Networkresource](rows)
+	if err != nil {
+		return nil, err
+	}
+
+	return networkmapdb.ConvertAllToSharedTypes[networkmapdb.Networkresource, nmdata.NetworkResource](netresorces)
+}
diff --git a/management/internals/network_map_db/sqlite/network_router.go b/management/internals/network_map_db/sqlite/network_router.go
new file mode 100644
index 000000000..8c4c31cd6
--- /dev/null
+++ b/management/internals/network_map_db/sqlite/network_router.go
@@ -0,0 +1,74 @@
+package networkmap_sqlite
+
+import (
+	"context"
+	"database/sql"
+	"fmt"
+	"reflect"
+
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetNetworkRouterQuery = `
+	select public_id, peer, network_id, masquerade, metric, enabled, peer_groups, group_peers.peer_id
+	from network_routers, json_each(peer_groups)
+	left join group_peers on group_peers.account_id=? and group_peers.group_id=json_each.value
+	where network_routers.account_id=?
+	`
+)
+
+func (sc *SqliteStoreConn) GetNetworkRouters(ctx context.Context, accountId string) (map[string]map[string]*nmdata.NetworkRouter, error) {
+	rows, err := sc.Conn.QueryContext(ctx, GetNetworkRouterQuery, accountId, accountId)
+	if err != nil {
+		return nil, err
+	}
+
+	routers, err := CollectRowsForSqlite[networkrouter](rows)
+	if err != nil {
+		return nil, err
+	}
+
+	toret := make(map[string]map[string]*nmdata.NetworkRouter)
+	for _, router := range routers {
+		if !router.Enabled.Bool {
+			continue
+		}
+
+		networkId := router.NetworkID.String
+		if networkId == "" {
+			return nil, fmt.Errorf("router with public_id %s doesn't have network_id set", router.PublicID.String)
+		}
+
+		nmdatarouter := nmdata.NetworkRouter{}
+		err := networkmapdb.FromSqlTypesToSharedTypes(reflect.ValueOf(&router), reflect.ValueOf(&nmdatarouter))
+		if err != nil {
+			return nil, err
+		}
+
+		if toret[networkId] == nil {
+			toret[networkId] = make(map[string]*nmdata.NetworkRouter)
+		}
+		if router.Peer.String != "" {
+			toret[networkId][router.Peer.String] = &nmdatarouter
+			continue
+		}
+		if router.PeerViaGroups.String != "" {
+			toret[networkId][router.PeerViaGroups.String] = &nmdatarouter
+		}
+	}
+
+	return toret, nil
+}
+
+type networkrouter struct {
+	PublicID      sql.NullString
+	Peer          sql.NullString `nmap:"skip"`
+	NetworkID     sql.NullString `nmap:"skip"`
+	Masquerade    sql.NullBool
+	Metric        sql.NullInt64
+	Enabled       sql.NullBool
+	PeerGroups    []byte         `nmap:"json"`
+	PeerViaGroups sql.NullString `nmap:"skip"`
+}
diff --git a/management/internals/network_map_db/sqlite/networks.go b/management/internals/network_map_db/sqlite/networks.go
new file mode 100644
index 000000000..e19336846
--- /dev/null
+++ b/management/internals/network_map_db/sqlite/networks.go
@@ -0,0 +1,35 @@
+package networkmap_sqlite
+
+import (
+	"context"
+
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+)
+
+const (
+	GetNetworksQuery = `
+	select id, public_id
+	from networks where account_id=?
+	`
+)
+
+func (sc *SqliteStoreConn) GetNetworkXIDToPublicIdMap(ctx context.Context, accountId string) (map[string]string, error) {
+	rows, err := sc.Conn.QueryContext(ctx, GetNetworksQuery, accountId)
+	if err != nil {
+		return nil, err
+	}
+
+	networks, err := CollectRowsForSqlite[networkmapdb.Network](rows)
+	if err != nil {
+		return nil, err
+	}
+
+	toret := make(map[string]string)
+	for _, n := range networks {
+		if n.PublicID.Valid {
+			toret[n.ID] = n.PublicID.String
+		}
+	}
+
+	return toret, nil
+}
diff --git a/management/internals/network_map_db/sqlite/peer.go b/management/internals/network_map_db/sqlite/peer.go
new file mode 100644
index 000000000..12d9e9ab7
--- /dev/null
+++ b/management/internals/network_map_db/sqlite/peer.go
@@ -0,0 +1,33 @@
+package networkmap_sqlite
+
+import (
+	"context"
+
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetPeersQuery = `
+	select id, key, ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6,
+	peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster,
+	meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, meta_capabilities, meta_flags, meta_sync_message_version,
+	location_country_code, location_city_name, location_connection_ip
+	from peers
+	where account_id = ?
+	`
+)
+
+func (sc *SqliteStoreConn) GetPeers(ctx context.Context, accountId string) ([]nmdata.Peer, map[string][]*nmdata.Peer, error) {
+	rows, err := sc.Conn.QueryContext(ctx, GetPeersQuery, accountId)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	peers, err := CollectRowsForSqlite[networkmapdb.Peer](rows)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	return networkmapdb.ConvertToNmdataPeers(peers)
+}
diff --git a/management/internals/network_map_db/sqlite/policy.go b/management/internals/network_map_db/sqlite/policy.go
new file mode 100644
index 000000000..1a11f6e20
--- /dev/null
+++ b/management/internals/network_map_db/sqlite/policy.go
@@ -0,0 +1,33 @@
+package networkmap_sqlite
+
+import (
+	"context"
+
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetPoliciesQuery = `
+	select p.id, p.public_id, p.enabled, p.source_posture_checks, pr.enabled as rule_enabled, pr.action, pr.protocol, pr.bidirectional, 
+	pr.sources, pr.destinations, pr.source_resource, pr.destination_resource, pr.ports, pr.port_ranges,
+	pr.authorized_groups, pr.authorized_user
+	from policies as p
+	left join policy_rules as pr on p.id = pr.policy_id 
+	where account_id=?
+	`
+)
+
+func (sc *SqliteStoreConn) GetPolicies(ctx context.Context, accountId string) ([]nmdata.Policy, map[string]map[string]any, map[string]map[string]any, error) {
+	rows, err := sc.Conn.QueryContext(ctx, GetPoliciesQuery, accountId)
+	if err != nil {
+		return nil, nil, nil, err
+	}
+
+	policies, err := CollectRowsForSqlite[networkmapdb.Policy](rows)
+	if err != nil {
+		return nil, nil, nil, err
+	}
+
+	return networkmapdb.ConvertToNmdataPolicy(policies)
+}
diff --git a/management/internals/network_map_db/sqlite/posture.go b/management/internals/network_map_db/sqlite/posture.go
new file mode 100644
index 000000000..6caee6e79
--- /dev/null
+++ b/management/internals/network_map_db/sqlite/posture.go
@@ -0,0 +1,43 @@
+package networkmap_sqlite
+
+import (
+	"context"
+	"reflect"
+
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetPostureChecksQuery = `
+	select id, public_id, checks
+	from posture_checks
+	where account_id=?
+	`
+)
+
+func (sc *SqliteStoreConn) GetPostureChecks(ctx context.Context, accountId string) ([]nmdata.PostureChecks, map[string]string, error) {
+	rows, err := sc.Conn.QueryContext(ctx, GetPostureChecksQuery, accountId)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	checks, err := CollectRowsForSqlite[networkmapdb.PostureChecks](rows)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	toret := make([]nmdata.PostureChecks, 0, len(checks))
+	idToPublicIDIdx := make(map[string]string)
+	for _, c := range checks {
+		checks := nmdata.PostureChecks{}
+		err := networkmapdb.FromSqlTypesToSharedTypes(reflect.ValueOf(&c), reflect.ValueOf(&checks))
+		if err != nil {
+			return nil, nil, err
+		}
+		toret = append(toret, checks)
+		idToPublicIDIdx[checks.ID] = c.PublicID.String
+	}
+
+	return toret, idToPublicIDIdx, nil
+}
diff --git a/management/internals/network_map_db/sqlite/route.go b/management/internals/network_map_db/sqlite/route.go
new file mode 100644
index 000000000..58b3eca55
--- /dev/null
+++ b/management/internals/network_map_db/sqlite/route.go
@@ -0,0 +1,32 @@
+package networkmap_sqlite
+
+import (
+	"context"
+
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const (
+	GetRoutesQuery = `
+	select id, account_id, public_id, network, domains, keep_route, net_id, description,
+	peer, peer as peer_id, peer_groups, network_type, masquerade, metric, enabled, 
+	groups, access_control_groups, skip_auto_apply
+	from routes
+	where account_id=?
+	`
+)
+
+func (sc *SqliteStoreConn) GetRoutes(ctx context.Context, accountId string) ([]nmdata.Route, error) {
+	rows, err := sc.Conn.QueryContext(ctx, GetRoutesQuery, accountId)
+	if err != nil {
+		return nil, err
+	}
+
+	routes, err := CollectRowsForSqlite[networkmapdb.Route](rows)
+	if err != nil {
+		return nil, err
+	}
+
+	return networkmapdb.ConvertAllToSharedTypes[networkmapdb.Route, nmdata.Route](routes)
+}
diff --git a/management/internals/network_map_db/sqlite/service.go b/management/internals/network_map_db/sqlite/service.go
new file mode 100644
index 000000000..5d25f69e5
--- /dev/null
+++ b/management/internals/network_map_db/sqlite/service.go
@@ -0,0 +1,89 @@
+package networkmap_sqlite
+
+import (
+	"context"
+	"database/sql"
+	"encoding/json"
+
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+)
+
+const (
+	GetServicesQuery = `
+	select enabled, private, access_groups, proxy_cluster, domain
+	from services
+	where account_id=?
+	`
+
+	GetProxyTargetedDomainResourcesQuery = `
+	select t.target_id
+	from targets as t
+	join services as s on s.id = t.service_id
+	where s.account_id=? and s.enabled and not coalesce(s.terminated, false)
+	and t.enabled and t.target_type='domain' and t.target_id is not null
+	`
+)
+
+func (sc *SqliteStoreConn) GetPrivateServices(ctx context.Context, accountId string) ([]networkmapdb.Service, error) {
+	rows, err := sc.Conn.QueryContext(ctx, GetServicesQuery, accountId)
+	if err != nil {
+		return nil, err
+	}
+
+	services, err := CollectRowsForSqlite[service](rows)
+	if err != nil {
+		return nil, err
+	}
+
+	toret := make([]networkmapdb.Service, 0, len(services))
+	for _, service := range services {
+		acg := []string{}
+		if service.AccessGroups != nil {
+			if err := json.Unmarshal(service.AccessGroups, &acg); err != nil {
+				return nil, err
+			}
+		}
+		s := networkmapdb.Service{
+			Enabled:      service.Enabled,
+			Private:      service.Private,
+			AccessGroups: acg,
+			ProxyCluster: service.ProxyCluster,
+			Domain:       service.Domain,
+		}
+
+		toret = append(toret, s)
+	}
+	return toret, nil
+}
+
+func (sc *SqliteStoreConn) GetProxyTargetedDomainResourceIDs(ctx context.Context, accountId string) (map[string]struct{}, error) {
+	rows, err := sc.Conn.QueryContext(ctx, GetProxyTargetedDomainResourcesQuery, accountId)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	toret := make(map[string]struct{})
+	for rows.Next() {
+		var id string
+		err := rows.Scan(&id)
+		if err != nil {
+			return nil, err
+		}
+		toret[id] = struct{}{}
+	}
+
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+
+	return toret, nil
+}
+
+type service struct {
+	Enabled      sql.NullBool
+	Private      sql.NullBool
+	AccessGroups []byte
+	ProxyCluster sql.NullString
+	Domain       sql.NullString
+}
diff --git a/management/internals/network_map_db/sqlite/sqlite_store.go b/management/internals/network_map_db/sqlite/sqlite_store.go
new file mode 100644
index 000000000..14abf80bc
--- /dev/null
+++ b/management/internals/network_map_db/sqlite/sqlite_store.go
@@ -0,0 +1,148 @@
+package networkmap_sqlite
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/url"
+	"os"
+	"path/filepath"
+	"reflect"
+	"runtime"
+	"strings"
+
+	"database/sql"
+
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+)
+
+var ErrNoRows = errors.New("no rows in result set")
+
+type SqliteStore struct {
+	Db *sql.DB
+}
+
+type sqliteInterface interface {
+	QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
+	ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
+}
+
+type SqliteStoreConn struct {
+	Conn sqliteInterface
+}
+
+func NewSqliteStore(storeFile, dataDir string) (*SqliteStore, error) {
+	dbfile := storeFile
+	if envFile, ok := os.LookupEnv("NB_STORE_ENGINE_SQLITE_FILE"); ok && envFile != "" {
+		dbfile = envFile
+	}
+
+	// Separate file path from any SQLite URI query parameters (e.g., "store.db?mode=rwc")
+	filePath, query, hasQuery := strings.Cut(dbfile, "?")
+
+	connStr := filePath
+	if filePath != ":memory:" && !filepath.IsAbs(filePath) {
+		connStr = filepath.Join(dataDir, filePath)
+	}
+
+	// Compose query parameters. User-provided ?_busy_timeout (or its mattn alias
+	// ?_timeout) overrides our default; otherwise inject 30s so SQLite waits at
+	// most that long on a lock instead of blocking the only Go-side connection.
+	// mattn/go-sqlite3 applies PRAGMA from the DSN on every fresh connection, so
+	// the value survives ConnMaxIdleTime/ConnMaxLifetime recycling. cache=shared
+	// stays the default on non-Windows for the same reason as before.
+	parsed, _ := url.ParseQuery(query)
+	var defaults []string
+	if parsed.Get("_busy_timeout") == "" && parsed.Get("_timeout") == "" {
+		defaults = append(defaults, "_busy_timeout=30000")
+	}
+	if !hasQuery && runtime.GOOS != "windows" {
+		// To avoid `The process cannot access the file because it is being used by another process` on Windows
+		defaults = append(defaults, "cache=shared")
+	}
+	parts := defaults
+	if hasQuery {
+		parts = append(parts, query)
+	}
+	if len(parts) > 0 {
+		connStr += "?" + strings.Join(parts, "&")
+	}
+
+	db, err := sql.Open("sqlite3", connStr)
+	if err != nil {
+		return nil, err
+	}
+
+	return &SqliteStore{Db: db}, nil
+}
+
+func (s *SqliteStore) BeginTx(ctx context.Context) (networkmapdb.NetworkMapDBStoreConn, error) {
+	tx, err := s.Db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true, Isolation: sql.LevelRepeatableRead})
+	if err != nil {
+		return nil, err
+	}
+	return &SqliteStoreConn{Conn: tx}, nil
+}
+
+func (s *SqliteStore) Exec(_ context.Context, query string, args ...any) error {
+	_, err := s.Db.Exec(query, args...)
+	return err
+}
+
+func (sc *SqliteStoreConn) RollbackTx(ctx context.Context) error {
+	tx, ok := sc.Conn.(*sql.Tx)
+	if !ok {
+		return fmt.Errorf("expected an sql.Tx got %s", reflect.TypeOf(sc.Conn).Kind())
+	}
+	return tx.Rollback()
+}
+
+func (sc *SqliteStoreConn) CommitTx(ctx context.Context) error {
+	tx, ok := sc.Conn.(*sql.Tx)
+	if !ok {
+		return fmt.Errorf("expected an sql.Tx got %s", reflect.TypeOf(sc.Conn).Kind())
+	}
+	return tx.Commit()
+}
+
+func (s *SqliteStore) UsingConn() *SqliteStoreConn {
+	return &SqliteStoreConn{Conn: s.Db}
+}
+
+func CollectOneRowForSqlite[T any](rows *sql.Rows) (T, error) {
+	defer rows.Close()
+	var r T
+
+	if !rows.Next() {
+		if err := rows.Err(); err != nil {
+			return r, err
+		}
+		return r, ErrNoRows
+	}
+	err := rows.Scan(networkmapdb.StructFields(&r)...)
+	if err != nil {
+		return r, err
+	}
+
+	return r, nil
+}
+
+func CollectRowsForSqlite[T any](rows *sql.Rows) ([]T, error) {
+	defer rows.Close()
+	toret := make([]T, 0)
+
+	for rows.Next() {
+		var r T
+		err := rows.Scan(networkmapdb.StructFields(&r)...)
+		if err != nil {
+			return nil, err
+		}
+		toret = append(toret, r)
+	}
+
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+
+	return toret, nil
+}
diff --git a/management/internals/network_map_db/sqlite/user.go b/management/internals/network_map_db/sqlite/user.go
new file mode 100644
index 000000000..0bdda372e
--- /dev/null
+++ b/management/internals/network_map_db/sqlite/user.go
@@ -0,0 +1,84 @@
+package networkmap_sqlite
+
+import (
+	"context"
+	"database/sql"
+	"encoding/json"
+)
+
+const (
+	GetAllowedUserIdsQuery = `
+	select id, auto_groups
+	from users
+	where account_id=? and not blocked and not is_service_user
+	`
+
+	GetAllGroupIdQuery = `
+	select id from groups
+	where account_id=? and name='All'
+	`
+)
+
+func (sc *SqliteStoreConn) GetAllowedUsers(ctx context.Context, accountId string) (map[string]struct{}, map[string][]string, error) {
+	rows, err := sc.Conn.QueryContext(ctx, GetAllowedUserIdsQuery, accountId)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	users, err := CollectRowsForSqlite[user](rows)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	rows, err = sc.Conn.QueryContext(ctx, GetAllGroupIdQuery, accountId)
+	if err != nil {
+		return nil, nil, err
+	}
+	allGroupIds, err := collectAllGroupIds(rows)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	userIdIdx := make(map[string]struct{})
+	groupIdToUserIds := make(map[string][]string)
+	for _, user := range users {
+		autogroups := make([]string, 0)
+		if err := json.Unmarshal(user.AutoGroups, &autogroups); err != nil {
+			return nil, nil, err
+		}
+		userIdIdx[user.ID] = struct{}{}
+		for _, groupId := range autogroups {
+			groupIdToUserIds[groupId] = append(groupIdToUserIds[groupId], user.ID)
+		}
+		for _, allgid := range allGroupIds {
+			groupIdToUserIds[allgid] = append(groupIdToUserIds[allgid], user.ID)
+		}
+	}
+
+	return userIdIdx, groupIdToUserIds, nil
+}
+
+func collectAllGroupIds(rows *sql.Rows) ([]string, error) {
+	defer rows.Close()
+	var toret []string
+
+	for rows.Next() {
+		var id string
+		err := rows.Scan(&id)
+		if err != nil {
+			return nil, err
+		}
+		toret = append(toret, id)
+	}
+
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+
+	return toret, nil
+}
+
+type user struct {
+	ID         string
+	AutoGroups []byte
+}
diff --git a/management/internals/network_map_db/struct_helpers.go b/management/internals/network_map_db/struct_helpers.go
new file mode 100644
index 000000000..1719662fd
--- /dev/null
+++ b/management/internals/network_map_db/struct_helpers.go
@@ -0,0 +1,157 @@
+package networkmapdb
+
+import (
+	"database/sql"
+	"encoding/json"
+	"errors"
+	"reflect"
+	"strings"
+
+	"github.com/rs/xid"
+)
+
+var ErrNoRows = errors.New("no rows in result set")
+
+const (
+	NMAP_STRUCT_TAG = "nmap"
+	NMAP_SKIP       = "skip"
+	NMAP_MAP_TO     = "map_to"
+	NMAP_JSON       = "json"
+)
+
+type fieldTag struct {
+	Key   string
+	Value string
+}
+
+func tagFromString(t string) fieldTag {
+	kv := strings.Split(t, ":")
+	if len(kv) == 1 {
+		return fieldTag{Key: strings.TrimSpace(kv[0])}
+	}
+	return fieldTag{Key: strings.TrimSpace(kv[0]), Value: strings.TrimSpace(kv[1])}
+}
+
+func FromSqlTypesToSharedTypes(src reflect.Value, dst reflect.Value) error {
+	typ := src.Elem().Type()
+
+	for i := 0; i < typ.NumField(); i++ {
+		f := typ.Field(i)
+
+		fieldTags := make(map[string]string)
+		if v := f.Tag.Get(NMAP_STRUCT_TAG); v != "" {
+			for _, t := range strings.Split(v, ",") {
+				kv := tagFromString(t)
+				fieldTags[kv.Key] = kv.Value
+			}
+		}
+		if _, ok := fieldTags[NMAP_SKIP]; ok {
+			continue
+		}
+		if f.PkgPath != "" { // skip unexported fields
+			continue
+		}
+		dstFieldName := f.Name
+		if override, ok := fieldTags[NMAP_MAP_TO]; ok {
+			dstFieldName = override
+		}
+
+		dstField := dst.Elem().FieldByName(dstFieldName)
+		if !dstField.IsValid() {
+			return errors.New("unsupported type in destination field: " + dstFieldName)
+		}
+
+		srcField := src.Elem().Field(i)
+		srcFieldType := srcField.Type().String()
+		switch srcFieldType {
+		case "string":
+			s := srcField.Interface().(string)
+			dstField.SetString(s)
+		case "sql.NullString":
+			s := srcField.Interface().(sql.NullString)
+			if s.Valid {
+				dstField.SetString(s.String)
+			}
+			if (dstFieldName == "PublicId" || dstFieldName == "PublicID") && s.String == "" {
+				dstField.SetString(xid.New().String()) // TODO (dmitri) this needs to be removed to support delta updates
+			}
+		case "sql.NullTime":
+			s := srcField.Interface().(sql.NullTime)
+			if s.Valid {
+				if dstField.Kind() == reflect.Ptr {
+					t := reflect.ValueOf(&s.Time).Elem()
+					dstField.Set(t.Addr())
+				} else {
+					dstField.Set(reflect.ValueOf(s.Time))
+				}
+			}
+		case "sql.NullBool":
+			s := srcField.Interface().(sql.NullBool)
+			if s.Valid {
+				dstField.SetBool(s.Bool)
+			}
+		case "sql.NullInt64":
+			s := srcField.Interface().(sql.NullInt64)
+			if s.Valid {
+				dstField.SetInt(s.Int64)
+			}
+		case "json.RawMessage":
+			s := srcField.Interface().(json.RawMessage)
+			if len(s) == 0 {
+				continue
+			}
+			if err := json.Unmarshal(s, dstField.Addr().Interface()); err != nil {
+				return err
+			}
+		case "[]byte", "[]uint8":
+			s := srcField.Interface().([]byte)
+			if _, ok := fieldTags[NMAP_JSON]; !ok || len(s) == 0 {
+				continue
+			}
+			if err := json.Unmarshal(s, dstField.Addr().Interface()); err != nil {
+				return err
+			}
+		case "[]string":
+			if srcField.IsNil() {
+				continue
+			}
+			dstv := reflect.MakeSlice(dstField.Type(), srcField.Len(), srcField.Cap())
+			reflect.Copy(dstv, srcField)
+			dstField.Set(dstv)
+		}
+	}
+
+	return nil
+}
+
+func StructFields(s any) []any {
+	src := reflect.ValueOf(s)
+	toret := make([]any, 0)
+	typ := src.Elem().Type()
+
+	for i := 0; i < typ.NumField(); i++ {
+		f := typ.Field(i)
+		if f.PkgPath != "" { // skip unexported fields
+			continue
+		}
+
+		srcField := src.Elem().Field(i)
+		toret = append(toret, srcField.Addr().Interface())
+	}
+
+	return toret
+}
+
+func ConvertAllToSharedTypes[T any, T1 any](allsrc []T) ([]T1, error) {
+	toret := make([]T1, 0, len(allsrc))
+	for _, src := range allsrc {
+		var dst T1
+		err := FromSqlTypesToSharedTypes(
+			reflect.ValueOf(&src), reflect.ValueOf(&dst))
+		if err != nil {
+			return nil, err
+		}
+		toret = append(toret, dst)
+	}
+	return toret, nil
+}
diff --git a/management/internals/server/boot.go b/management/internals/server/boot.go
index e8f4964c6..0a4df3924 100644
--- a/management/internals/server/boot.go
+++ b/management/internals/server/boot.go
@@ -5,6 +5,7 @@ package server
 import (
 	"context"
 	"crypto/tls"
+	"errors"
 	"net/http"
 	"net/netip"
 	"slices"
@@ -30,6 +31,8 @@ import (
 	proxyactivity "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/activity"
 	proxyactivitymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/activity/manager"
 	rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	networkmapdbfactory "github.com/netbirdio/netbird/management/internals/network_map_db/factory"
 	nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
 	"github.com/netbirdio/netbird/management/server/activity"
 	activitystore "github.com/netbirdio/netbird/management/server/activity/store"
@@ -101,6 +104,26 @@ func (s *BaseServer) Store() store.Store {
 	})
 }
 
+// TODO dmitri: move all validation checks (e.g. config+env vars) from runtime to base server creation
+// this way we don't need to spread defensive checks throughout the codebase
+func (s *BaseServer) NetworkMapStore() *networkmapdb.NetworkMapDBStoreImpl {
+	return Create(s, func() *networkmapdb.NetworkMapDBStoreImpl {
+		store, err := networkmapdbfactory.NewNetworkMapDBStore(
+			context.Background(),
+			s.Config.StoreConfig.Engine,
+			s.Config.Datadir,
+			s.IntegratedValidator(),
+			s.SettingsManager())
+		// networkmap db store supports postgres and sqlite backends only
+		// for other backends a fallback is used, so NotSupportedStoreEngineError
+		// is not a fatal error
+		if err != nil && !errors.Is(err, networkmapdbfactory.ErrNotSupportedStoreEngine) {
+			log.Fatalf("failed to create network map store: %v", err)
+		}
+		return store
+	})
+}
+
 func (s *BaseServer) EventStore() activity.Store {
 	return Create(s, func() activity.Store {
 		var err error
diff --git a/management/internals/server/controllers.go b/management/internals/server/controllers.go
index 1b2556809..a9293d266 100644
--- a/management/internals/server/controllers.go
+++ b/management/internals/server/controllers.go
@@ -123,7 +123,7 @@ func (s *BaseServer) EphemeralManager() ephemeral.Manager {
 
 func (s *BaseServer) NetworkMapController() network_map.Controller {
 	return Create(s, func() network_map.Controller {
-		return nmapcontroller.NewController(context.Background(), s.Store(), s.Metrics(), s.PeersUpdateManager(), s.AccountRequestBuffer(), s.IntegratedValidator(), s.SettingsManager(), s.DNSDomain(), s.ProxyController(), s.EphemeralManager(), s.Config)
+		return nmapcontroller.NewController(context.Background(), s.Store(), s.Metrics(), s.PeersUpdateManager(), s.AccountRequestBuffer(), s.IntegratedValidator(), s.SettingsManager(), s.DNSDomain(), s.ProxyController(), s.EphemeralManager(), s.Config, s.NetworkMapStore())
 	})
 }
 
diff --git a/management/internals/shared/grpc/components_encoder.go b/management/internals/shared/grpc/components_encoder.go
index e0ac2c5a9..ac574b67f 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)),
@@ -282,14 +300,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
@@ -316,16 +334,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
 		}
@@ -334,6 +351,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 {
@@ -347,17 +382,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
 }
 
@@ -391,7 +438,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
 	}
@@ -406,7 +453,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
 	}
@@ -444,7 +491,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
 	}
@@ -467,7 +514,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
 	}
@@ -482,7 +529,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
 	}
@@ -499,7 +546,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
 	}
@@ -515,7 +562,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
 	}
@@ -544,7 +591,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
 	}
@@ -580,7 +627,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
 	}
@@ -601,6 +648,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 {
@@ -617,7 +667,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
 		}
@@ -667,7 +717,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{}
 	}
@@ -677,7 +727,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
 	}
@@ -693,20 +743,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,
+		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 {
@@ -755,7 +806,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 f7df82f2f..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,35 +697,40 @@ 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) {
-	assert.Nil(t, toProxyPatch(nil, "netbird.cloud", false, false))
-	assert.Nil(t, toProxyPatch(&types.NetworkMap{}, "netbird.cloud", false, false),
+	assert.Nil(t, toProxyPatch(nil, "netbird.cloud", false, false, false))
+	assert.Nil(t, toProxyPatch(&types.NetworkMap{}, "netbird.cloud", false, false, false),
 		"empty NetworkMap (no peers, rules, routes etc) → nil patch so proto3 omits the field")
 }
 
 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",
 		}},
 	}
 
-	patch := toProxyPatch(nm, "netbird.cloud", false, false)
+	patch := toProxyPatch(nm, "netbird.cloud", false, false, false)
 
 	require.NotNil(t, patch)
 	assert.Len(t, patch.Peers, 1)
@@ -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 8364fe301..049049db2 100644
--- a/management/internals/shared/grpc/components_envelope_response.go
+++ b/management/internals/shared/grpc/components_envelope_response.go
@@ -6,11 +6,11 @@ import (
 	integrationsConfig "github.com/netbirdio/management-integrations/integrations/config"
 
 	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"
 	auth "github.com/netbirdio/netbird/shared/sessionauth"
 )
@@ -31,14 +31,14 @@ func ToComponentSyncResponse(
 	config *nbconfig.Config,
 	httpConfig *nbconfig.HttpServerConfig,
 	deviceFlowConfig *nbconfig.DeviceAuthorizationFlow,
-	peer *nbpeer.Peer,
+	peer *nmdata.Peer,
 	turnCredentials *Token,
 	relayCredentials *Token,
 	components *types.NetworkMapComponents,
 	proxyPatch *types.NetworkMap,
 	dnsName string,
 	checks []*posture.Checks,
-	settings *types.Settings,
+	settings *nmdata.AccountSettingsInfo,
 	extraSettings *types.ExtraSettings,
 	peerGroups []string,
 	dnsFwdPort int64,
@@ -66,7 +66,7 @@ func ToComponentSyncResponse(
 		DNSDomain:        dnsName,
 		DNSForwarderPort: dnsFwdPort,
 		UserIDClaim:      userIDClaim,
-		ProxyPatch:       toProxyPatch(proxyPatch, dnsName, includeIPv6, useSourcePrefixes),
+		ProxyPatch:       toProxyPatch(proxyPatch, dnsName, includeIPv6, useSourcePrefixes, peer.ProxyMeta.Embedded),
 	})
 
 	resp := &proto.SyncResponse{
@@ -104,7 +104,7 @@ func ToComponentSyncResponse(
 // derive them from. Components purity isn't violated: proxy data isn't
 // policy-graph-derived, it's externally injected post-Calculate, so the
 // client merges it on top of its locally-computed NetworkMap.
-func toProxyPatch(nm *types.NetworkMap, dnsName string, includeIPv6, useSourcePrefixes bool) *proto.ProxyPatch {
+func toProxyPatch(nm *types.NetworkMap, dnsName string, includeIPv6, useSourcePrefixes, localIsProxy bool) *proto.ProxyPatch {
 	if nm == nil {
 		return nil
 	}
@@ -114,8 +114,8 @@ func toProxyPatch(nm *types.NetworkMap, dnsName string, includeIPv6, useSourcePr
 	}
 
 	patch := &proto.ProxyPatch{
-		Peers:              networkmap.AppendRemotePeerConfig(nil, nm.Peers, dnsName, includeIPv6),
-		OfflinePeers:       networkmap.AppendRemotePeerConfig(nil, nm.OfflinePeers, dnsName, includeIPv6),
+		Peers:              networkmap.AppendRemotePeerConfig(nil, nm.Peers, dnsName, includeIPv6, localIsProxy),
+		OfflinePeers:       networkmap.AppendRemotePeerConfig(nil, nm.OfflinePeers, dnsName, includeIPv6, localIsProxy),
 		FirewallRules:      networkmap.ToProtocolFirewallRules(nm.FirewallRules, includeIPv6, useSourcePrefixes),
 		Routes:             networkmap.ToProtocolRoutes(nm.Routes),
 		RouteFirewallRules: networkmap.ToProtocolRoutesFirewallRules(nm.RoutesFirewallRules),
@@ -145,7 +145,7 @@ func toProxyPatch(nm *types.NetworkMap, dnsName string, includeIPv6, useSourcePr
 //
 // The full SSH AuthorizedUsers map is still produced by the client when it
 // runs Calculate() over the envelope.
-func computeSSHEnabledForPeer(c *types.NetworkMapComponents, peer *nbpeer.Peer) bool {
+func computeSSHEnabledForPeer(c *types.NetworkMapComponents, peer *nmdata.Peer) bool {
 	if c == nil || peer == nil {
 		return false
 	}
@@ -170,25 +170,25 @@ func computeSSHEnabledForPeer(c *types.NetworkMapComponents, peer *nbpeer.Peer)
 // ruleEnablesSSHForPeer returns true when rule is active, targets peer, and
 // either explicitly authorises SSH or covers the legacy TCP/22 path while the
 // peer itself has SSH enabled locally.
-func ruleEnablesSSHForPeer(c *types.NetworkMapComponents, rule *types.PolicyRule, peer *nbpeer.Peer) bool {
+func ruleEnablesSSHForPeer(c *types.NetworkMapComponents, rule *nmdata.PolicyRule, peer *nmdata.Peer) bool {
 	if rule == nil || !rule.Enabled {
 		return false
 	}
 	if !peerInDestinations(c, rule, peer.ID) {
 		return false
 	}
-	if rule.Protocol == types.PolicyRuleProtocolNetbirdSSH {
+	if rule.Protocol == string(types.PolicyRuleProtocolNetbirdSSH) {
 		return true
 	}
-	return peer.SSHEnabled && types.PolicyRuleImpliesLegacySSH(rule)
+	return peer.SSHEnabled && nmdata.PolicyRuleImpliesLegacySSH(rule)
 }
 
 // peerInDestinations reports whether peerID is in any of rule.Destinations'
 // groups (or matches DestinationResource if it's a peer-typed resource —
 // for non-peer types Calculate falls through to group lookup, so we mirror
 // that exactly to avoid silent divergence).
-func peerInDestinations(c *types.NetworkMapComponents, rule *types.PolicyRule, peerID string) bool {
-	if rule.DestinationResource.Type == types.ResourceTypePeer && rule.DestinationResource.ID != "" {
+func peerInDestinations(c *types.NetworkMapComponents, rule *nmdata.PolicyRule, peerID string) bool {
+	if rule.DestinationResource.Type == string(types.ResourceTypePeer) && rule.DestinationResource.ID != "" {
 		return rule.DestinationResource.ID == peerID
 	}
 	for _, groupID := range rule.Destinations {
diff --git a/management/internals/shared/grpc/components_envelope_response_test.go b/management/internals/shared/grpc/components_envelope_response_test.go
index 20f4e6824..039cb73f4 100644
--- a/management/internals/shared/grpc/components_envelope_response_test.go
+++ b/management/internals/shared/grpc/components_envelope_response_test.go
@@ -5,8 +5,8 @@ import (
 
 	"github.com/stretchr/testify/assert"
 
-	nbpeer "github.com/netbirdio/netbird/management/server/peer"
 	"github.com/netbirdio/netbird/management/server/types"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 )
 
 // TestComputeSSHEnabledForPeer covers both Calculate-mirroring branches:
@@ -17,16 +17,15 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
 	const targetPeerID = "target"
 	const targetGroupID = "g_dst"
 
-	mkComponents := func(rule *types.PolicyRule, sshEnabled bool) (*types.NetworkMapComponents, *nbpeer.Peer) {
-		peer := &nbpeer.Peer{ID: targetPeerID, SSHEnabled: sshEnabled}
-		group := &types.ComponentGroup{ID: targetGroupID, Name: "dst", Peers: []string{targetPeerID}}
+	mkComponents := func(rule *nmdata.PolicyRule, sshEnabled bool) (*types.NetworkMapComponents, *nmdata.Peer) {
+		peer := &nmdata.Peer{ID: targetPeerID, SSHEnabled: sshEnabled}
 		return &types.NetworkMapComponents{
-			Peers:  map[string]*types.ComponentPeer{targetPeerID: peer.ToComponent()},
-			Groups: map[string]*types.ComponentGroup{targetGroupID: group},
-			Policies: []*types.Policy{{
+			Peers:  map[string]*nmdata.Peer{targetPeerID: peer},
+			Groups: map[string]*nmdata.Group{targetGroupID: {Name: "dst", Peers: []string{targetPeerID}}},
+			Policies: []*nmdata.Policy{{
 				ID:      "p",
 				Enabled: true,
-				Rules:   []*types.PolicyRule{rule},
+				Rules:   []*nmdata.PolicyRule{rule},
 			}},
 		}, peer
 	}
@@ -34,14 +33,14 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
 	cases := []struct {
 		name        string
 		peerSSH     bool
-		rule        types.PolicyRule
+		rule        nmdata.PolicyRule
 		wantEnabled bool
 	}{
 		{
 			name:    "explicit-netbird-ssh-activates-regardless-of-peer-ssh",
 			peerSSH: false,
-			rule: types.PolicyRule{
-				Enabled: true, Protocol: types.PolicyRuleProtocolNetbirdSSH,
+			rule: nmdata.PolicyRule{
+				Enabled: true, Protocol: string(types.PolicyRuleProtocolNetbirdSSH),
 				Destinations: []string{targetGroupID},
 			},
 			wantEnabled: true,
@@ -49,8 +48,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
 		{
 			name:    "implicit-tcp-22-with-peer-ssh",
 			peerSSH: true,
-			rule: types.PolicyRule{
-				Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"22"},
+			rule: nmdata.PolicyRule{
+				Enabled: true, Protocol: string(types.PolicyRuleProtocolTCP), Ports: []string{"22"},
 				Destinations: []string{targetGroupID},
 			},
 			wantEnabled: true,
@@ -58,8 +57,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
 		{
 			name:    "implicit-tcp-22-without-peer-ssh-disabled",
 			peerSSH: false,
-			rule: types.PolicyRule{
-				Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"22"},
+			rule: nmdata.PolicyRule{
+				Enabled: true, Protocol: string(types.PolicyRuleProtocolTCP), Ports: []string{"22"},
 				Destinations: []string{targetGroupID},
 			},
 			wantEnabled: false,
@@ -67,8 +66,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
 		{
 			name:    "implicit-tcp-22022-with-peer-ssh",
 			peerSSH: true,
-			rule: types.PolicyRule{
-				Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"22022"},
+			rule: nmdata.PolicyRule{
+				Enabled: true, Protocol: string(types.PolicyRuleProtocolTCP), Ports: []string{"22022"},
 				Destinations: []string{targetGroupID},
 			},
 			wantEnabled: true,
@@ -76,8 +75,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
 		{
 			name:    "implicit-all-protocol-with-peer-ssh",
 			peerSSH: true,
-			rule: types.PolicyRule{
-				Enabled: true, Protocol: types.PolicyRuleProtocolALL,
+			rule: nmdata.PolicyRule{
+				Enabled: true, Protocol: string(types.PolicyRuleProtocolALL),
 				Destinations: []string{targetGroupID},
 			},
 			wantEnabled: true,
@@ -85,10 +84,10 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
 		{
 			name:    "implicit-port-range-covers-22",
 			peerSSH: true,
-			rule: types.PolicyRule{
+			rule: nmdata.PolicyRule{
 				Enabled:      true,
-				Protocol:     types.PolicyRuleProtocolTCP,
-				PortRanges:   []types.RulePortRange{{Start: 20, End: 30}},
+				Protocol:     string(types.PolicyRuleProtocolTCP),
+				PortRanges:   []nmdata.RulePortRange{{Start: 20, End: 30}},
 				Destinations: []string{targetGroupID},
 			},
 			wantEnabled: true,
@@ -96,8 +95,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
 		{
 			name:    "tcp-80-no-ssh",
 			peerSSH: true,
-			rule: types.PolicyRule{
-				Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"80"},
+			rule: nmdata.PolicyRule{
+				Enabled: true, Protocol: string(types.PolicyRuleProtocolTCP), Ports: []string{"80"},
 				Destinations: []string{targetGroupID},
 			},
 			wantEnabled: false,
@@ -105,8 +104,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
 		{
 			name:    "disabled-rule-skipped",
 			peerSSH: true,
-			rule: types.PolicyRule{
-				Enabled: false, Protocol: types.PolicyRuleProtocolNetbirdSSH,
+			rule: nmdata.PolicyRule{
+				Enabled: false, Protocol: string(types.PolicyRuleProtocolNetbirdSSH),
 				Destinations: []string{targetGroupID},
 			},
 			wantEnabled: false,
@@ -114,8 +113,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
 		{
 			name:    "peer-not-in-destinations",
 			peerSSH: true,
-			rule: types.PolicyRule{
-				Enabled: true, Protocol: types.PolicyRuleProtocolNetbirdSSH,
+			rule: nmdata.PolicyRule{
+				Enabled: true, Protocol: string(types.PolicyRuleProtocolNetbirdSSH),
 				Destinations: []string{"g_other"}, // target not in this group
 			},
 			wantEnabled: false,
@@ -123,21 +122,21 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
 		{
 			name:    "peer-typed-destination-resource-matches",
 			peerSSH: false,
-			rule: types.PolicyRule{
+			rule: nmdata.PolicyRule{
 				Enabled:             true,
-				Protocol:            types.PolicyRuleProtocolNetbirdSSH,
-				DestinationResource: types.Resource{ID: targetPeerID, Type: types.ResourceTypePeer},
+				Protocol:            string(types.PolicyRuleProtocolNetbirdSSH),
+				DestinationResource: nmdata.Resource{ID: targetPeerID, Type: string(types.ResourceTypePeer)},
 			},
 			wantEnabled: true,
 		},
 		{
 			name:    "non-peer-destination-resource-falls-through-to-groups",
 			peerSSH: false,
-			rule: types.PolicyRule{
+			rule: nmdata.PolicyRule{
 				Enabled:             true,
-				Protocol:            types.PolicyRuleProtocolNetbirdSSH,
-				DestinationResource: types.Resource{ID: targetPeerID, Type: "host"}, // wrong type
-				Destinations:        []string{targetGroupID},                        // saved by group fallback
+				Protocol:            string(types.PolicyRuleProtocolNetbirdSSH),
+				DestinationResource: nmdata.Resource{ID: targetPeerID, Type: "host"}, // wrong type
+				Destinations:        []string{targetGroupID},                         // saved by group fallback
 			},
 			wantEnabled: true,
 		},
@@ -156,16 +155,16 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
 // belt-and-suspenders presence guard mirroring Calculate's
 // getAllPeersFromGroups invariant.
 func TestComputeSSHEnabledForPeer_TargetMissingFromComponents(t *testing.T) {
-	peer := &nbpeer.Peer{ID: "missing", SSHEnabled: true}
+	peer := &nmdata.Peer{ID: "missing", SSHEnabled: true}
 	c := &types.NetworkMapComponents{
-		Peers: map[string]*types.ComponentPeer{}, // target peer NOT present
-		Groups: map[string]*types.ComponentGroup{
-			"g": {ID: "g", Peers: []string{"missing"}},
+		Peers: map[string]*nmdata.Peer{}, // target peer NOT present
+		Groups: map[string]*nmdata.Group{
+			"g": {Peers: []string{"missing"}},
 		},
-		Policies: []*types.Policy{{
+		Policies: []*nmdata.Policy{{
 			ID: "p", Enabled: true,
-			Rules: []*types.PolicyRule{{
-				Enabled: true, Protocol: types.PolicyRuleProtocolNetbirdSSH,
+			Rules: []*nmdata.PolicyRule{{
+				Enabled: true, Protocol: string(types.PolicyRuleProtocolNetbirdSSH),
 				Destinations: []string{"g"},
 			}},
 		}},
@@ -179,6 +178,6 @@ func TestComputeSSHEnabledForPeer_TargetMissingFromComponents(t *testing.T) {
 // exported indirectly via ToComponentSyncResponse and may receive nil
 // components on graceful-degrade paths.
 func TestComputeSSHEnabledForPeer_NilInputs(t *testing.T) {
-	assert.False(t, computeSSHEnabledForPeer(nil, &nbpeer.Peer{ID: "x"}))
+	assert.False(t, computeSSHEnabledForPeer(nil, &nmdata.Peer{ID: "x"}))
 	assert.False(t, computeSSHEnabledForPeer(&types.NetworkMapComponents{}, nil))
 }
diff --git a/management/internals/shared/grpc/conversion.go b/management/internals/shared/grpc/conversion.go
index 32fcd2978..b5ef61c01 100644
--- a/management/internals/shared/grpc/conversion.go
+++ b/management/internals/shared/grpc/conversion.go
@@ -18,10 +18,10 @@ import (
 
 	"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache"
 	nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
-	nbpeer "github.com/netbirdio/netbird/management/server/peer"
 	"github.com/netbirdio/netbird/management/server/posture"
 	"github.com/netbirdio/netbird/management/server/types"
 	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	"github.com/netbirdio/netbird/shared/management/proto"
 	"github.com/netbirdio/netbird/shared/netiputil"
 )
@@ -47,7 +47,7 @@ func init() {
 // nil when no server config is set (the fan-out network-map path) because clients treat any
 // non-nil config as authoritative: a config without a relay section is interpreted as relay
 // disabled and wipes the clients' relay URLs.
-func toNetbirdConfig(config *nbconfig.Config, turnCredentials *Token, relayToken *Token, extraSettings *types.ExtraSettings, settings *types.Settings) *proto.NetbirdConfig {
+func toNetbirdConfig(config *nbconfig.Config, turnCredentials *Token, relayToken *Token, extraSettings *types.ExtraSettings, settings *nmdata.AccountSettingsInfo) *proto.NetbirdConfig {
 	if config == nil {
 		return nil
 	}
@@ -119,7 +119,7 @@ func toNetbirdConfig(config *nbconfig.Config, turnCredentials *Token, relayToken
 	return nbConfig
 }
 
-func toPeerConfig(peer *nbpeer.Peer, network *types.Network, dnsName string, settings *types.Settings, httpConfig *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, enableSSH bool, forceRoutingPeerDNS bool) *proto.PeerConfig {
+func toPeerConfig(peer *nmdata.Peer, network *nmdata.Network, dnsName string, settings *nmdata.AccountSettingsInfo, httpConfig *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, enableSSH bool, forceRoutingPeerDNS bool) *proto.PeerConfig {
 	netmask, _ := network.Net.Mask.Size()
 	fqdn := peer.FQDN(dnsName)
 
@@ -151,12 +151,13 @@ func toPeerConfig(peer *nbpeer.Peer, network *types.Network, dnsName string, set
 	return peerConfig
 }
 
-func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, peer *nbpeer.Peer, turnCredentials *Token, relayCredentials *Token, networkMap *types.NetworkMap, dnsName string, checks []*posture.Checks, dnsCache *cache.DNSConfigCache, settings *types.Settings, extraSettings *types.ExtraSettings, peerGroups []string, dnsFwdPort int64) *proto.SyncResponse {
+func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, peer *nmdata.Peer, turnCredentials *Token, relayCredentials *Token, networkMap *types.NetworkMap, dnsName string, checks []*posture.Checks, dnsCache *cache.DNSConfigCache, settings *nmdata.AccountSettingsInfo, extraSettings *types.ExtraSettings, peerGroups []string, dnsFwdPort int64) *proto.SyncResponse {
 	// IPv6 data in AllowedIPs and SourcePrefixes wildcard expansion depends on
 	// whether the target peer supports IPv6. Routes and firewall rules are already
 	// filtered at the source (network map builder).
 	includeIPv6 := peer.SupportsIPv6() && peer.IPv6.IsValid()
 	useSourcePrefixes := peer.SupportsSourcePrefixes()
+	localIsProxy := peer.ProxyMeta.Embedded
 
 	peerConfig := toPeerConfig(peer, networkMap.Network, dnsName, settings, httpConfig, deviceFlowConfig, networkMap.EnableSSH, networkMap.ForceRoutingPeerDNSResolution)
 	response := &proto.SyncResponse{
@@ -175,7 +176,7 @@ func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nb
 	response.NetbirdConfig = extendedConfig
 
 	remotePeers := make([]*proto.RemotePeerConfig, 0, len(networkMap.Peers)+len(networkMap.OfflinePeers))
-	remotePeers = networkmap.AppendRemotePeerConfig(remotePeers, networkMap.Peers, dnsName, includeIPv6)
+	remotePeers = networkmap.AppendRemotePeerConfig(remotePeers, networkMap.Peers, dnsName, includeIPv6, localIsProxy)
 
 	if !shouldSkipSendingDeprecatedRemotePeers(peer.Meta.WtVersion) {
 		response.RemotePeers = remotePeers
@@ -185,7 +186,7 @@ func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nb
 	response.RemotePeersIsEmpty = len(remotePeers) == 0
 	response.NetworkMap.RemotePeersIsEmpty = response.RemotePeersIsEmpty
 
-	response.NetworkMap.OfflinePeers = networkmap.AppendRemotePeerConfig(nil, networkMap.OfflinePeers, dnsName, includeIPv6)
+	response.NetworkMap.OfflinePeers = networkmap.AppendRemotePeerConfig(nil, networkMap.OfflinePeers, dnsName, includeIPv6, localIsProxy)
 
 	firewallRules := networkmap.ToProtocolFirewallRules(networkMap.FirewallRules, includeIPv6, useSourcePrefixes)
 	response.NetworkMap.FirewallRules = firewallRules
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 5204431b2..bbd309322 100644
--- a/management/internals/shared/grpc/server.go
+++ b/management/internals/shared/grpc/server.go
@@ -921,8 +921,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),
 	}
 
@@ -1053,9 +1053,9 @@ func (s *Server) sendInitialSync(ctx context.Context, peerKey wgtypes.Key, peer
 			log.WithContext(ctx).Errorf("failed to build components for peer %s on initial sync: %v", peer.ID, err)
 			return status.Errorf(codes.Internal, "failed to build initial sync envelope")
 		}
-		plainResp = ToComponentSyncResponse(ctx, s.config, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, freshPeer, turnToken, relayToken, components, proxyPatch, dnsName, freshPostureChecks, settings, settings.Extra, peerGroups, freshDnsFwdPort)
+		plainResp = ToComponentSyncResponse(ctx, s.config, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, types.TwinPeer(freshPeer), turnToken, relayToken, components, proxyPatch, dnsName, freshPostureChecks, types.TwinAccountSettings(settings), settings.Extra, peerGroups, freshDnsFwdPort)
 	} else {
-		plainResp = ToSyncResponse(ctx, s.config, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, peer, turnToken, relayToken, networkMap, dnsName, postureChecks, nil, settings, settings.Extra, peerGroups, dnsFwdPort)
+		plainResp = ToSyncResponse(ctx, s.config, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, types.TwinPeer(peer), turnToken, relayToken, networkMap, dnsName, postureChecks, nil, types.TwinAccountSettings(settings), settings.Extra, peerGroups, dnsFwdPort)
 	}
 
 	key, err := s.secretsManager.GetWGKey()
diff --git a/management/internals/shared/requestbuffer/buffer.go b/management/internals/shared/requestbuffer/buffer.go
new file mode 100644
index 000000000..c3823776c
--- /dev/null
+++ b/management/internals/shared/requestbuffer/buffer.go
@@ -0,0 +1,102 @@
+// Package requestbuffer coalesces concurrent reads of the same expensive
+// resource into a single fetch.
+package requestbuffer
+
+import (
+	"context"
+	"os"
+	"sync"
+	"time"
+
+	log "github.com/sirupsen/logrus"
+)
+
+// FetchFunc reads the resource identified by key.
+type FetchFunc[T any] func(ctx context.Context, key string) (T, error)
+
+// Buffer batches requests per key: the first request opens a window, every
+// request arriving within it joins the batch, and a single fetch serves them
+// all. The fetch starts only after the window closed, so a caller never
+// observes data read before its own request.
+type Buffer[T any] struct {
+	ctx      context.Context
+	name     string
+	fetch    FetchFunc[T]
+	interval time.Duration
+
+	mu      sync.Mutex
+	waiting map[string][]chan result[T]
+}
+
+type result[T any] struct {
+	value T
+	err   error
+}
+
+// New returns a Buffer serving batched requests through fetch. ctx bounds the
+// fetches, not the callers, and must outlive them.
+func New[T any](ctx context.Context, name string, interval time.Duration, fetch FetchFunc[T]) *Buffer[T] {
+	return &Buffer[T]{
+		ctx:      ctx,
+		name:     name,
+		fetch:    fetch,
+		interval: interval,
+		waiting:  make(map[string][]chan result[T]),
+	}
+}
+
+// Get returns the value for key, sharing one fetch with the other callers of
+// the current batch. The value is shared as is, so callers must treat it as
+// read-only unless the fetch hands out copies.
+func (b *Buffer[T]) Get(ctx context.Context, key string) (T, error) {
+	ch := make(chan result[T], 1)
+
+	b.mu.Lock()
+	b.waiting[key] = append(b.waiting[key], ch)
+	first := len(b.waiting[key]) == 1
+	b.mu.Unlock()
+
+	if first {
+		time.AfterFunc(b.interval, func() { b.flush(key) })
+	}
+
+	select {
+	case res := <-ch:
+		return res.value, res.err
+	case <-ctx.Done():
+		var zero T
+		return zero, ctx.Err()
+	}
+}
+
+func (b *Buffer[T]) flush(key string) {
+	b.mu.Lock()
+	waiting := b.waiting[key]
+	delete(b.waiting, key)
+	b.mu.Unlock()
+
+	if len(waiting) == 0 {
+		return
+	}
+
+	start := time.Now()
+	value, err := b.fetch(b.ctx, key)
+	log.WithContext(b.ctx).Tracef("%s: fetched %s for %d waiters in %s", b.name, key, len(waiting), time.Since(start))
+
+	for _, ch := range waiting {
+		ch <- result[T]{value: value, err: err}
+	}
+}
+
+// Interval reads a buffer interval from envVar, falling back to def.
+func Interval(ctx context.Context, envVar string, def time.Duration) time.Duration {
+	value := os.Getenv(envVar)
+	interval, err := time.ParseDuration(value)
+	if err != nil {
+		if value != "" {
+			log.WithContext(ctx).Warnf("failed to parse %s: %s", envVar, err)
+		}
+		return def
+	}
+	return interval
+}
diff --git a/management/internals/shared/requestbuffer/buffer_test.go b/management/internals/shared/requestbuffer/buffer_test.go
new file mode 100644
index 000000000..9e356145e
--- /dev/null
+++ b/management/internals/shared/requestbuffer/buffer_test.go
@@ -0,0 +1,106 @@
+package requestbuffer
+
+import (
+	"context"
+	"errors"
+	"sync"
+	"sync/atomic"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestBufferCoalescesConcurrentRequests(t *testing.T) {
+	var fetches atomic.Int32
+	buffer := New(context.Background(), "test", 50*time.Millisecond,
+		func(ctx context.Context, key string) (string, error) {
+			fetches.Add(1)
+			return key, nil
+		})
+
+	var wg sync.WaitGroup
+	for range 10 {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			value, err := buffer.Get(context.Background(), "account")
+			assert.NoError(t, err)
+			assert.Equal(t, "account", value)
+		}()
+	}
+	wg.Wait()
+
+	assert.Equal(t, int32(1), fetches.Load())
+}
+
+func TestBufferSeparatesKeys(t *testing.T) {
+	keys := make(chan string, 2)
+	buffer := New(context.Background(), "test", 10*time.Millisecond,
+		func(ctx context.Context, key string) (string, error) {
+			keys <- key
+			return key, nil
+		})
+
+	var wg sync.WaitGroup
+	for _, key := range []string{"a", "b"} {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			_, err := buffer.Get(context.Background(), key)
+			assert.NoError(t, err)
+		}()
+	}
+	wg.Wait()
+	close(keys)
+
+	var fetched []string
+	for key := range keys {
+		fetched = append(fetched, key)
+	}
+	assert.ElementsMatch(t, []string{"a", "b"}, fetched)
+}
+
+func TestBufferFetchesAfterRequest(t *testing.T) {
+	var version atomic.Int32
+	buffer := New(context.Background(), "test", 10*time.Millisecond,
+		func(ctx context.Context, key string) (int32, error) {
+			return version.Load(), nil
+		})
+
+	first, err := buffer.Get(context.Background(), "account")
+	require.NoError(t, err)
+	assert.Equal(t, int32(0), first)
+
+	version.Store(1)
+
+	second, err := buffer.Get(context.Background(), "account")
+	require.NoError(t, err)
+	assert.Equal(t, int32(1), second)
+}
+
+func TestBufferPropagatesError(t *testing.T) {
+	fetchErr := errors.New("fetch failed")
+	buffer := New(context.Background(), "test", 10*time.Millisecond,
+		func(ctx context.Context, key string) (*int, error) {
+			return nil, fetchErr
+		})
+
+	value, err := buffer.Get(context.Background(), "account")
+	assert.ErrorIs(t, err, fetchErr)
+	assert.Nil(t, value)
+}
+
+func TestBufferHonorsCallerContext(t *testing.T) {
+	buffer := New(context.Background(), "test", time.Minute,
+		func(ctx context.Context, key string) (string, error) {
+			return key, nil
+		})
+
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
+	defer cancel()
+
+	_, err := buffer.Get(ctx, "account")
+	assert.ErrorIs(t, err, context.DeadlineExceeded)
+}
diff --git a/management/server/account_request_buffer.go b/management/server/account_request_buffer.go
index e1672c2d0..792099431 100644
--- a/management/server/account_request_buffer.go
+++ b/management/server/account_request_buffer.go
@@ -2,117 +2,38 @@ package server
 
 import (
 	"context"
-	"os"
-	"sync"
 	"time"
 
 	log "github.com/sirupsen/logrus"
 
+	"github.com/netbirdio/netbird/management/internals/shared/requestbuffer"
 	"github.com/netbirdio/netbird/management/server/store"
 	"github.com/netbirdio/netbird/management/server/types"
 )
 
-// AccountRequest holds the result channel to return the requested account.
-type AccountRequest struct {
-	AccountID  string
-	ResultChan chan *AccountResult
-}
-
-// AccountResult holds the account data or an error.
-type AccountResult struct {
-	Account *types.Account
-	Err     error
-}
+const defaultAccountBufferInterval = 100 * time.Millisecond
 
 type AccountRequestBuffer struct {
-	store               store.Store
-	getAccountRequests  map[string][]*AccountRequest
-	mu                  sync.Mutex
-	getAccountRequestCh chan *AccountRequest
-	bufferInterval      time.Duration
+	buffer *requestbuffer.Buffer[*types.Account]
 }
 
 func NewAccountRequestBuffer(ctx context.Context, store store.Store) *AccountRequestBuffer {
-	bufferIntervalStr := os.Getenv("NB_GET_ACCOUNT_BUFFER_INTERVAL")
-	bufferInterval, err := time.ParseDuration(bufferIntervalStr)
-	if err != nil {
-		if bufferIntervalStr != "" {
-			log.WithContext(ctx).Warnf("failed to parse account request buffer interval: %s", err)
-		}
-		bufferInterval = 100 * time.Millisecond
+	interval := requestbuffer.Interval(ctx, "NB_GET_ACCOUNT_BUFFER_INTERVAL", defaultAccountBufferInterval)
+	log.WithContext(ctx).Infof("set account request buffer interval to %s", interval)
+
+	return &AccountRequestBuffer{
+		buffer: requestbuffer.New(ctx, "account request buffer", interval, store.GetAccount),
 	}
-
-	log.WithContext(ctx).Infof("set account request buffer interval to %s", bufferInterval)
-
-	ac := AccountRequestBuffer{
-		store:               store,
-		getAccountRequests:  make(map[string][]*AccountRequest),
-		getAccountRequestCh: make(chan *AccountRequest),
-		bufferInterval:      bufferInterval,
-	}
-
-	go ac.processGetAccountRequests(ctx)
-
-	return &ac
 }
+
 func (ac *AccountRequestBuffer) GetAccountWithBackpressure(ctx context.Context, accountID string) (*types.Account, error) {
-	req := &AccountRequest{
-		AccountID:  accountID,
-		ResultChan: make(chan *AccountResult, 1),
+	account, err := ac.buffer.Get(ctx, accountID)
+	if err != nil || account == nil {
+		return account, err
 	}
 
-	log.WithContext(ctx).Tracef("requesting account %s with backpressure", accountID)
-	startTime := time.Now()
-	ac.getAccountRequestCh <- req
-
-	result := <-req.ResultChan
-	log.WithContext(ctx).Tracef("got account with backpressure after %s", time.Since(startTime))
-	return result.Account, result.Err
-}
-
-func (ac *AccountRequestBuffer) processGetAccountBatch(ctx context.Context, accountID string) {
-	ac.mu.Lock()
-	requests := ac.getAccountRequests[accountID]
-	delete(ac.getAccountRequests, accountID)
-	ac.mu.Unlock()
-
-	if len(requests) == 0 {
-		return
-	}
-
-	startTime := time.Now()
-	account, err := ac.store.GetAccount(ctx, accountID)
-	log.WithContext(ctx).Tracef("getting account %s in batch took %s", accountID, time.Since(startTime))
-	result := &AccountResult{Account: account, Err: err}
-
-	for _, req := range requests {
-		if account != nil {
-			// Shallow copy the account so each goroutine gets its own struct value.
-			// This prevents data races when callers mutate fields like Policies.
-			accountCopy := *account
-			req.ResultChan <- &AccountResult{Account: &accountCopy, Err: err}
-		} else {
-			req.ResultChan <- result
-		}
-		close(req.ResultChan)
-	}
-}
-
-func (ac *AccountRequestBuffer) processGetAccountRequests(ctx context.Context) {
-	for {
-		select {
-		case req := <-ac.getAccountRequestCh:
-			ac.mu.Lock()
-			ac.getAccountRequests[req.AccountID] = append(ac.getAccountRequests[req.AccountID], req)
-			if len(ac.getAccountRequests[req.AccountID]) == 1 {
-				go func(ctx context.Context, accountID string) {
-					time.Sleep(ac.bufferInterval)
-					ac.processGetAccountBatch(ctx, accountID)
-				}(ctx, req.AccountID)
-			}
-			ac.mu.Unlock()
-		case <-ctx.Done():
-			return
-		}
-	}
+	// Shallow copy the account so each caller gets its own struct value.
+	// This prevents data races when callers mutate fields like Policies.
+	accountCopy := *account
+	return &accountCopy, nil
 }
diff --git a/management/server/account_test.go b/management/server/account_test.go
index 5a826e103..a5a484c1a 100644
--- a/management/server/account_test.go
+++ b/management/server/account_test.go
@@ -3331,7 +3331,7 @@ func createManager(t testing.TB) (*DefaultAccountManager, *update_channel.PeersU
 
 	updateManager := update_channel.NewPeersUpdateManager(metrics)
 	requestBuffer := NewAccountRequestBuffer(ctx, store)
-	networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{})
+	networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil)
 	manager, err := BuildManager(ctx, &config.Config{}, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
 	if err != nil {
 		return nil, nil, err
diff --git a/management/server/affected_peers_property_test.go b/management/server/affected_peers_property_test.go
index f393465bc..b64aeb813 100644
--- a/management/server/affected_peers_property_test.go
+++ b/management/server/affected_peers_property_test.go
@@ -27,8 +27,6 @@ func allPeerMaps(t *testing.T, manager *DefaultAccountManager, accountID string)
 	account, err := manager.Store.GetAccount(ctx, accountID)
 	require.NoError(t, err)
 
-	account.InjectProxyPolicies(ctx)
-
 	validated := make(map[string]struct{}, len(account.Peers))
 	for id := range account.Peers {
 		validated[id] = struct{}{}
diff --git a/management/server/dns_test.go b/management/server/dns_test.go
index d7667a304..25bef664c 100644
--- a/management/server/dns_test.go
+++ b/management/server/dns_test.go
@@ -234,7 +234,7 @@ func createDNSManager(t *testing.T) (*DefaultAccountManager, error) {
 
 	updateManager := update_channel.NewPeersUpdateManager(metrics)
 	requestBuffer := NewAccountRequestBuffer(ctx, store)
-	networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.test", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{})
+	networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.test", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil)
 
 	return BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
 }
diff --git a/management/server/groups/manager.go b/management/server/groups/manager.go
index 6d19b1c35..893be1e5a 100644
--- a/management/server/groups/manager.go
+++ b/management/server/groups/manager.go
@@ -6,7 +6,6 @@ import (
 
 	"github.com/netbirdio/netbird/management/server/account"
 	"github.com/netbirdio/netbird/management/server/activity"
-	resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
 	"github.com/netbirdio/netbird/management/server/permissions"
 	"github.com/netbirdio/netbird/management/server/permissions/modules"
 	"github.com/netbirdio/netbird/management/server/permissions/operations"
@@ -31,10 +30,6 @@ type managerImpl struct {
 	accountManager     account.Manager
 }
 
-func eventMetaResource(group *types.Group, resource *resourceTypes.NetworkResource) map[string]any {
-	return map[string]any{"name": group.Name, "id": group.ID, "resource_name": resource.Name, "resource_id": resource.ID, "resource_type": resource.Type}
-}
-
 type mockManager struct {
 }
 
@@ -114,7 +109,7 @@ func (m *managerImpl) AddResourceToGroupInTransaction(ctx context.Context, trans
 	}
 
 	event := func() {
-		m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceAddedToGroup, eventMetaResource(group, networkResource))
+		m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceAddedToGroup, group.EventMetaResource(types.TwinNetworkResource(networkResource)))
 	}
 
 	return event, nil
@@ -138,7 +133,7 @@ func (m *managerImpl) RemoveResourceFromGroupInTransaction(ctx context.Context,
 	}
 
 	event := func() {
-		m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceRemovedFromGroup, eventMetaResource(group, networkResource))
+		m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceRemovedFromGroup, group.EventMetaResource(types.TwinNetworkResource(networkResource)))
 	}
 
 	return event, nil
diff --git a/management/server/http/handlers/peers/peers_handler.go b/management/server/http/handlers/peers/peers_handler.go
index 9ad3f94b2..6454e6194 100644
--- a/management/server/http/handlers/peers/peers_handler.go
+++ b/management/server/http/handlers/peers/peers_handler.go
@@ -448,7 +448,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) {
@@ -628,20 +628,22 @@ func validateVNCSessionPubKey(raw *string) (string, error) {
 	return *raw, nil
 }
 
-// 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 ff25296d7..210b95fd3 100644
--- a/management/server/management_proto_test.go
+++ b/management/server/management_proto_test.go
@@ -376,7 +376,7 @@ func startManagementForTest(t *testing.T, testFile string, config *config.Config
 		return nil, nil, "", cleanup, err
 	}
 
-	networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeralMgr, config)
+	networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeralMgr, config, nil)
 	accountManager, err := BuildManager(ctx, nil, store, networkMapController, jobManager, nil, "",
 		eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
 
diff --git a/management/server/management_test.go b/management/server/management_test.go
index 80c76f0de..3a8d6ecc2 100644
--- a/management/server/management_test.go
+++ b/management/server/management_test.go
@@ -216,7 +216,7 @@ func startServer(
 
 	updateManager := update_channel.NewPeersUpdateManager(metrics)
 	requestBuffer := server.NewAccountRequestBuffer(ctx, str)
-	networkMapController := controller.NewController(ctx, str, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(str, peers.NewManager(str, permissionsManager)), config)
+	networkMapController := controller.NewController(ctx, str, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(str, peers.NewManager(str, permissionsManager)), config, nil)
 
 	accountManager, err := server.BuildManager(
 		context.Background(),
diff --git a/management/server/nameserver_test.go b/management/server/nameserver_test.go
index ce5d5d57b..deed9c34f 100644
--- a/management/server/nameserver_test.go
+++ b/management/server/nameserver_test.go
@@ -803,7 +803,7 @@ func createNSManager(t *testing.T) (*DefaultAccountManager, error) {
 
 	updateManager := update_channel.NewPeersUpdateManager(metrics)
 	requestBuffer := NewAccountRequestBuffer(ctx, store)
-	networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{})
+	networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil)
 
 	return BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
 }
diff --git a/management/server/networks/resources/types/resource.go b/management/server/networks/resources/types/resource.go
index 643f9cdd6..4cf7f7ea3 100644
--- a/management/server/networks/resources/types/resource.go
+++ b/management/server/networks/resources/types/resource.go
@@ -14,7 +14,6 @@ import (
 	nbDomain "github.com/netbirdio/netbird/shared/management/domain"
 
 	"github.com/netbirdio/netbird/shared/management/http/api"
-	sharedTypes "github.com/netbirdio/netbird/shared/management/types"
 )
 
 type NetworkResourceType string
@@ -65,27 +64,6 @@ func NewNetworkResource(accountID, networkID, name, description, address string,
 	}, nil
 }
 
-// ToComponent converts the resource to its self-contained components
-// representation. Returns nil for a nil resource.
-func (n *NetworkResource) ToComponent() *sharedTypes.ComponentResource {
-	if n == nil {
-		return nil
-	}
-	return &sharedTypes.ComponentResource{
-		ID:          n.ID,
-		PublicID:    n.PublicID,
-		NetworkID:   n.NetworkID,
-		AccountID:   n.AccountID,
-		Name:        n.Name,
-		Description: n.Description,
-		Type:        sharedTypes.ComponentResourceType(n.Type),
-		Address:     n.Address,
-		Domain:      n.Domain,
-		Prefix:      n.Prefix,
-		Enabled:     n.Enabled,
-	}
-}
-
 func (n *NetworkResource) ToAPIResponse(groups []api.GroupMinimum) *api.NetworkResource {
 	addr := n.Prefix.String()
 	if n.Type == Domain {
diff --git a/management/server/networks/routers/types/router.go b/management/server/networks/routers/types/router.go
index b8097cdbb..189d7f792 100644
--- a/management/server/networks/routers/types/router.go
+++ b/management/server/networks/routers/types/router.go
@@ -7,7 +7,6 @@ import (
 
 	"github.com/netbirdio/netbird/management/server/networks/types"
 	"github.com/netbirdio/netbird/shared/management/http/api"
-	sharedTypes "github.com/netbirdio/netbird/shared/management/types"
 )
 
 type NetworkRouter struct {
@@ -22,36 +21,6 @@ type NetworkRouter struct {
 	Enabled    bool
 }
 
-// ToComponent converts the router to its self-contained components
-// representation. Returns nil for a nil router.
-func (n *NetworkRouter) ToComponent() *sharedTypes.ComponentRouter {
-	if n == nil {
-		return nil
-	}
-	return &sharedTypes.ComponentRouter{
-		NetworkID:  n.NetworkID,
-		PublicID:   n.PublicID,
-		Peer:       n.Peer,
-		PeerGroups: n.PeerGroups,
-		Masquerade: n.Masquerade,
-		Metric:     n.Metric,
-		Enabled:    n.Enabled,
-	}
-}
-
-// ToComponentMap converts a peer-keyed router map to its components
-// representation.
-func ToComponentMap(routers map[string]*NetworkRouter) map[string]*sharedTypes.ComponentRouter {
-	if routers == nil {
-		return nil
-	}
-	out := make(map[string]*sharedTypes.ComponentRouter, len(routers))
-	for id, r := range routers {
-		out[id] = r.ToComponent()
-	}
-	return out
-}
-
 func NewNetworkRouter(accountID string, networkID string, peer string, peerGroups []string, masquerade bool, metric int, enabled bool) (*NetworkRouter, error) {
 	r := &NetworkRouter{
 		ID:         xid.New().String(),
diff --git a/management/server/peer.go b/management/server/peer.go
index 589cf9abf..579ff2708 100644
--- a/management/server/peer.go
+++ b/management/server/peer.go
@@ -21,6 +21,7 @@ import (
 	"github.com/netbirdio/netbird/management/server/permissions/modules"
 	"github.com/netbirdio/netbird/management/server/permissions/operations"
 	"github.com/netbirdio/netbird/shared/management/domain"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 
 	"github.com/netbirdio/netbird/management/server/posture"
 	"github.com/netbirdio/netbird/management/server/store"
@@ -1588,7 +1589,7 @@ func affectedPeerIDsFromNetworkMap(nmap *types.NetworkMap, selfPeerID string) []
 	}
 	seen := make(map[string]struct{}, len(nmap.Peers)+len(nmap.OfflinePeers))
 	ids := make([]string, 0, len(nmap.Peers)+len(nmap.OfflinePeers))
-	add := func(peers []*types.ComponentPeer) {
+	add := func(peers []*nmdata.Peer) {
 		for _, p := range peers {
 			if p == nil || p.ID == "" || p.ID == selfPeerID {
 				continue
diff --git a/management/server/peer/peer.go b/management/server/peer/peer.go
index bdf1f2b98..0a9cce9f6 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.
@@ -207,35 +207,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(),
-	}
-	if p.LastLogin != nil {
-		cp.LastLogin = *p.LastLogin
-	}
-	return cp
-}
-
 // HasCapability reports whether the peer has the given capability.
 func (p *Peer) HasCapability(capability int32) bool {
 	return slices.Contains(p.Meta.Capabilities, capability)
diff --git a/management/server/peer_test.go b/management/server/peer_test.go
index 80d270e98..9a662bdbf 100644
--- a/management/server/peer_test.go
+++ b/management/server/peer_test.go
@@ -57,6 +57,7 @@ import (
 	"github.com/netbirdio/netbird/management/server/types"
 	nbroute "github.com/netbirdio/netbird/route"
 	"github.com/netbirdio/netbird/shared/management/domain"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	"github.com/netbirdio/netbird/shared/management/proto"
 )
 
@@ -1091,22 +1092,22 @@ func TestToSyncResponse(t *testing.T) {
 		Signature: "turn-pass",
 	}
 	networkMap := &types.NetworkMap{
-		Network: &types.Network{Net: *ipnet, Serial: 1000},
-		Peers: []*types.ComponentPeer{{
+		Network: &nmdata.Network{Net: *ipnet, Serial: 1000},
+		Peers: []*nmdata.Peer{{
 			IP:         netip.MustParseAddr("192.168.1.2"),
 			IPv6:       netip.MustParseAddr("fd00::2"),
 			Key:        "peer2-key",
 			DNSLabel:   "peer2",
 			SSHEnabled: true,
 			SSHKey:     "peer2-ssh-key"}},
-		OfflinePeers: []*types.ComponentPeer{{
+		OfflinePeers: []*nmdata.Peer{{
 			IP:         netip.MustParseAddr("192.168.1.3"),
 			IPv6:       netip.MustParseAddr("fd00::3"),
 			Key:        "peer3-key",
 			DNSLabel:   "peer3",
 			SSHEnabled: true,
 			SSHKey:     "peer3-ssh-key"}},
-		Routes: []*nbroute.Route{
+		Routes: []*nmdata.Route{
 			{
 				ID:          "route1",
 				Network:     netip.MustParsePrefix("10.0.0.0/24"),
@@ -1180,7 +1181,7 @@ func TestToSyncResponse(t *testing.T) {
 	}
 	dnsCache := &cache.DNSConfigCache{}
 	accountSettings := &types.Settings{RoutingPeerDNSResolutionEnabled: true}
-	response := grpc.ToSyncResponse(context.Background(), config, config.HttpConfig, config.DeviceAuthorizationFlow, peer, turnRelayToken, turnRelayToken, networkMap, dnsName, checks, dnsCache, accountSettings, nil, []string{}, int64(dnsForwarderPort))
+	response := grpc.ToSyncResponse(context.Background(), config, config.HttpConfig, config.DeviceAuthorizationFlow, types.TwinPeer(peer), turnRelayToken, turnRelayToken, networkMap, dnsName, checks, dnsCache, types.TwinAccountSettings(accountSettings), nil, []string{}, int64(dnsForwarderPort))
 
 	assert.NotNil(t, response)
 	// assert peer config
@@ -1300,7 +1301,7 @@ func Test_RegisterPeerByUser(t *testing.T) {
 
 	updateManager := update_channel.NewPeersUpdateManager(metrics)
 	requestBuffer := NewAccountRequestBuffer(ctx, s)
-	networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{})
+	networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil)
 
 	am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
 	assert.NoError(t, err)
@@ -1391,7 +1392,7 @@ func Test_RegisterPeerBySetupKey(t *testing.T) {
 
 	updateManager := update_channel.NewPeersUpdateManager(metrics)
 	requestBuffer := NewAccountRequestBuffer(ctx, s)
-	networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{})
+	networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil)
 
 	am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
 	assert.NoError(t, err)
@@ -1550,7 +1551,7 @@ func Test_RegisterPeerRollbackOnFailure(t *testing.T) {
 
 	updateManager := update_channel.NewPeersUpdateManager(metrics)
 	requestBuffer := NewAccountRequestBuffer(ctx, s)
-	networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{})
+	networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil)
 
 	am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
 	assert.NoError(t, err)
@@ -1635,7 +1636,7 @@ func Test_LoginPeer(t *testing.T) {
 
 	updateManager := update_channel.NewPeersUpdateManager(metrics)
 	requestBuffer := NewAccountRequestBuffer(ctx, s)
-	networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{})
+	networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil)
 
 	am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
 	assert.NoError(t, err)
diff --git a/management/server/route_test.go b/management/server/route_test.go
index 53dbb29d9..4ca9ee48f 100644
--- a/management/server/route_test.go
+++ b/management/server/route_test.go
@@ -1201,7 +1201,7 @@ func TestGetNetworkMap_RouteSync(t *testing.T) {
 	peer1Routes, err := am.GetNetworkMap(context.Background(), peer1ID)
 	require.NoError(t, err)
 	require.Len(t, peer1Routes.Routes, 1, "we should receive one route for peer1")
-	require.True(t, expectedRoute.Equal(peer1Routes.Routes[0]), "received route should be equal")
+	require.True(t, types.TwinRoute(expectedRoute).Equal(peer1Routes.Routes[0]), "received route should be equal")
 
 	peer2Routes, err := am.GetNetworkMap(context.Background(), peer2ID)
 	require.NoError(t, err)
@@ -1299,7 +1299,7 @@ func createRouterManager(t *testing.T) (*DefaultAccountManager, *update_channel.
 
 	updateManager := update_channel.NewPeersUpdateManager(metrics)
 	requestBuffer := NewAccountRequestBuffer(ctx, store)
-	networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{})
+	networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil)
 
 	am, err := BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
 	if err != nil {
diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go
index f51470338..c88f8019b 100644
--- a/management/server/store/sql_store.go
+++ b/management/server/store/sql_store.go
@@ -3172,9 +3172,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 52462c686..b54b3d01b 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"
@@ -17,8 +16,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"
@@ -27,12 +24,13 @@ 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"
 	auth "github.com/netbirdio/netbird/shared/sessionauth"
 )
 
 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
@@ -1009,10 +924,10 @@ func (a *Account) applyPolicyRule(
 	destinationPeers, peerInDestinations := a.resolveRuleEndpoint(ctx, rule.DestinationResource, rule.Destinations, peer.ID, nil, validatedPeersMap)
 
 	cb := RuleAuthCallbacks{
-		CollectSSHUsers: func(r *PolicyRule, t map[string]map[string]struct{}) {
+		CollectSSHUsers: func(r *nmdata.PolicyRule, t map[string]map[string]struct{}) {
 			a.collectAuthorizedUsers(ctx, r, groupIDToUserIDs, t)
 		},
-		CollectVNCUsers: func(r *PolicyRule, t map[string]map[string]struct{}) {
+		CollectVNCUsers: func(r *nmdata.PolicyRule, t map[string]map[string]struct{}) {
 			a.collectAuthorizedUsers(ctx, r, groupIDToUserIDs, t)
 		},
 		GetAllowedUserIDs: a.getAllowedUserIDs,
@@ -1048,7 +963,7 @@ func (a *Account) resolveRuleEndpoint(
 }
 
 // collectAuthorizedUsers populates the target map with authorized user mappings from the rule.
-func (a *Account) collectAuthorizedUsers(ctx context.Context, rule *PolicyRule, groupIDToUserIDs map[string][]string, target map[string]map[string]struct{}) {
+func (a *Account) collectAuthorizedUsers(ctx context.Context, rule *nmdata.PolicyRule, groupIDToUserIDs map[string][]string, target map[string]map[string]struct{}) {
 	switch {
 	case len(rule.AuthorizedGroups) > 0:
 		MergeAuthorizedGroupUsers(ctx, rule.AuthorizedGroups, groupIDToUserIDs, target)
@@ -1059,6 +974,26 @@ func (a *Account) collectAuthorizedUsers(ctx context.Context, rule *PolicyRule,
 	}
 }
 
+// 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 {
@@ -1079,7 +1014,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 {
@@ -1112,10 +1046,10 @@ func (a *Account) connResourcesGenerator(ctx context.Context, targetPeer *nbpeer
 				if len(effectiveRule.Ports) == 0 && len(effectiveRule.PortRanges) == 0 {
 					rules = append(rules, &fr)
 				} else {
-					rules = append(rules, ExpandPortsAndRanges(fr, effectiveRule, targetComponent)...)
+					rules = append(rules, ExpandPortsAndRanges(fr, effectiveRule, targetPeer)...)
 				}
 
-				rules = AppendIPv6FirewallRule(rules, rulesExists, peer.ToComponent(), targetComponent, effectiveRule, FirewallRuleContext{
+				rules = AppendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, effectiveRule, FirewallRuleContext{
 					Direction:   direction,
 					DirStr:      strconv.Itoa(direction),
 					ProtocolStr: string(protocol),
@@ -1262,7 +1196,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]
@@ -1289,13 +1223,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
 }
@@ -1498,54 +1432,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{})
@@ -1646,176 +1532,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 {
@@ -1848,66 +1564,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..e0305be7c
--- /dev/null
+++ b/management/server/types/account_networkmapdata.go
@@ -0,0 +1,615 @@
+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,
+		SessionPubKey:       r.SessionPubKey,
+		SessionDisplayName:  r.SessionDisplayName,
+	}
+}
+
+func twinRoute(r *nbroute.Route) *nmdata.Route {
+	return &nmdata.Route{
+		ID:                  string(r.ID),
+		AccountID:           r.AccountID,
+		PublicID:            r.PublicID,
+		Network:             r.Network,
+		Domains:             r.Domains,
+		KeepRoute:           r.KeepRoute,
+		NetID:               string(r.NetID),
+		Description:         r.Description,
+		Peer:                r.Peer,
+		PeerID:              r.PeerID,
+		PeerGroups:          r.PeerGroups,
+		NetworkType:         int(r.NetworkType),
+		Masquerade:          r.Masquerade,
+		Metric:              r.Metric,
+		Enabled:             r.Enabled,
+		Groups:              r.Groups,
+		AccessControlGroups: r.AccessControlGroups,
+		SkipAutoApply:       r.SkipAutoApply,
+	}
+}
+
+// TwinRoute converts a real *route.Route to its slim nmdata twin. Exported for
+// tests that assert against twin routes returned in a NetworkMap.
+func TwinRoute(r *nbroute.Route) *nmdata.Route {
+	return twinRoute(r)
+}
+
+func TwinNetworkResource(r *resourceTypes.NetworkResource) *nmdata.NetworkResource {
+	if r == nil {
+		return nil
+	}
+	return &nmdata.NetworkResource{
+		ID:          r.ID,
+		NetworkID:   r.NetworkID,
+		AccountID:   r.AccountID,
+		PublicID:    r.PublicID,
+		Name:        r.Name,
+		Description: r.Description,
+		Type:        string(r.Type),
+		Address:     r.Address,
+		Domain:      r.Domain,
+		Prefix:      r.Prefix,
+		Enabled:     r.Enabled,
+	}
+}
+
+func twinRouter(r *routerTypes.NetworkRouter) *nmdata.NetworkRouter {
+	if r == nil {
+		return nil
+	}
+	return &nmdata.NetworkRouter{
+		PublicID:   r.PublicID,
+		PeerGroups: r.PeerGroups,
+		Masquerade: r.Masquerade,
+		Metric:     r.Metric,
+		Enabled:    r.Enabled,
+	}
+}
+
+func twinNSG(n *nbdns.NameServerGroup) *nmdata.NameServerGroup {
+	if n == nil {
+		return nil
+	}
+	nameServers := make([]nmdata.NameServer, 0, len(n.NameServers))
+	for _, ns := range n.NameServers {
+		nameServers = append(nameServers, nmdata.NameServer{
+			IP:     ns.IP,
+			NSType: int(ns.NSType),
+			Port:   ns.Port,
+		})
+	}
+	return &nmdata.NameServerGroup{
+		ID:                   n.ID,
+		PublicID:             n.PublicID,
+		Name:                 n.Name,
+		Description:          n.Description,
+		NameServers:          nameServers,
+		Groups:               n.Groups,
+		Primary:              n.Primary,
+		Domains:              n.Domains,
+		Enabled:              n.Enabled,
+		SearchDomainsEnabled: n.SearchDomainsEnabled,
+	}
+}
+
+// TwinNetwork converts a real *Network to its slim twin. Exported for the
+// graceful-degrade path that builds a minimal NetworkMapComponents directly.
+func TwinNetwork(n *Network) *nmdata.Network {
+	nc := n.Copy()
+	return &nmdata.Network{
+		Identifier: nc.Identifier,
+		Net:        nc.Net,
+		NetV6:      nc.NetV6,
+		Dns:        nc.Dns,
+		Serial:     int64(nc.Serial),
+	}
+}
+
+func twinPostureChecks(pc *posture.Checks) *nmdata.PostureChecks {
+	if pc == nil {
+		return nil
+	}
+	out := &nmdata.PostureChecks{ID: pc.ID}
+	def := pc.Checks
+	if def.NBVersionCheck != nil {
+		out.Checks.NBVersionCheck = &nmdata.NBVersionCheck{MinVersion: def.NBVersionCheck.MinVersion}
+	}
+	if def.OSVersionCheck != nil {
+		oc := &nmdata.OSVersionCheck{}
+		if def.OSVersionCheck.Android != nil {
+			oc.Android = &nmdata.MinVersionCheck{MinVersion: def.OSVersionCheck.Android.MinVersion}
+		}
+		if def.OSVersionCheck.Darwin != nil {
+			oc.Darwin = &nmdata.MinVersionCheck{MinVersion: def.OSVersionCheck.Darwin.MinVersion}
+		}
+		if def.OSVersionCheck.Ios != nil {
+			oc.Ios = &nmdata.MinVersionCheck{MinVersion: def.OSVersionCheck.Ios.MinVersion}
+		}
+		if def.OSVersionCheck.Linux != nil {
+			oc.Linux = &nmdata.MinKernelVersionCheck{MinKernelVersion: def.OSVersionCheck.Linux.MinKernelVersion}
+		}
+		if def.OSVersionCheck.Windows != nil {
+			oc.Windows = &nmdata.MinKernelVersionCheck{MinKernelVersion: def.OSVersionCheck.Windows.MinKernelVersion}
+		}
+		out.Checks.OSVersionCheck = oc
+	}
+	if def.GeoLocationCheck != nil {
+		gc := &nmdata.GeoLocationCheck{Action: def.GeoLocationCheck.Action}
+		for _, loc := range def.GeoLocationCheck.Locations {
+			gc.Locations = append(gc.Locations, nmdata.GeoLocation{CountryCode: loc.CountryCode, CityName: loc.CityName})
+		}
+		out.Checks.GeoLocationCheck = gc
+	}
+	if def.PeerNetworkRangeCheck != nil {
+		out.Checks.PeerNetworkRangeCheck = &nmdata.PeerNetworkRangeCheck{
+			Action: def.PeerNetworkRangeCheck.Action,
+			Ranges: def.PeerNetworkRangeCheck.Ranges,
+		}
+	}
+	if def.ProcessCheck != nil {
+		procs := make([]nmdata.Process, 0, len(def.ProcessCheck.Processes))
+		for _, p := range def.ProcessCheck.Processes {
+			procs = append(procs, nmdata.Process{LinuxPath: p.LinuxPath, MacPath: p.MacPath, WindowsPath: p.WindowsPath})
+		}
+		out.Checks.ProcessCheck = &nmdata.ProcessCheck{Processes: procs}
+	}
+	return out
+}
+
+// buildAppliedZoneCandidates precomputes the account-level custom DNS zones
+// (record conversion) once; the per-peer distribution-group gate runs in the
+// components calc. Mirrors the account-level half of filterPeerAppliedZones.
+func buildAppliedZoneCandidates(accountZones []*zones.Zone) []networkmap.AppliedZoneCandidate {
+	var out []networkmap.AppliedZoneCandidate
+	for _, zone := range accountZones {
+		if !zone.Enabled || len(zone.Records) == 0 {
+			continue
+		}
+		simpleRecords := make([]nmdata.SimpleRecord, 0, len(zone.Records))
+		for _, record := range zone.Records {
+			var recordType int
+			rData := record.Content
+			switch record.Type {
+			case records.RecordTypeA:
+				recordType = int(dns.TypeA)
+			case records.RecordTypeAAAA:
+				recordType = int(dns.TypeAAAA)
+			case records.RecordTypeCNAME:
+				recordType = int(dns.TypeCNAME)
+				rData = dns.Fqdn(record.Content)
+			default:
+				continue
+			}
+			simpleRecords = append(simpleRecords, nmdata.SimpleRecord{
+				Name:  dns.Fqdn(record.Name),
+				Type:  recordType,
+				Class: nbdns.DefaultClass,
+				TTL:   record.TTL,
+				RData: rData,
+			})
+		}
+		out = append(out, networkmap.AppliedZoneCandidate{
+			DistributionGroups: zone.DistributionGroups,
+			Zone: nmdata.CustomZone{
+				Domain:               dns.Fqdn(zone.Domain),
+				Records:              simpleRecords,
+				SearchDomainDisabled: !zone.EnableSearchDomain,
+				NonAuthoritative:     true,
+			},
+		})
+	}
+	return out
+}
+
+// buildPrivateServiceCandidates precomputes the connected-proxy A records per
+// private service (account-level); the per-peer access-group gate + apex merge
+// run in the components calc. Mirrors the account-level half of
+// SynthesizePrivateServiceZones.
+func (a *Account) buildPrivateServiceCandidates() []networkmap.PrivateServiceCandidate {
+	if len(a.Services) == 0 {
+		return nil
+	}
+	proxyPeersByCluster := a.GetProxyPeers()
+	if len(proxyPeersByCluster) == 0 {
+		return nil
+	}
+
+	var out []networkmap.PrivateServiceCandidate
+	for _, svc := range a.Services {
+		if svc == nil || !svc.Enabled || !svc.Private {
+			continue
+		}
+		if len(svc.AccessGroups) == 0 {
+			continue
+		}
+		proxyPeers := proxyPeersByCluster[svc.ProxyCluster]
+		if len(proxyPeers) == 0 {
+			continue
+		}
+		apex := a.privateServiceDomainZone(svc)
+		if apex == "" {
+			continue
+		}
+
+		var recs []nmdata.SimpleRecord
+		for _, p := range proxyPeers {
+			if p == nil || !p.IP.IsValid() {
+				continue
+			}
+			if p.Status == nil || !p.Status.Connected {
+				continue
+			}
+			recs = append(recs, nmdata.SimpleRecord{
+				Name:  dns.Fqdn(svc.Domain),
+				Type:  int(dns.TypeA),
+				Class: nbdns.DefaultClass,
+				TTL:   privateServiceDNSRecordTTL,
+				RData: p.IP.String(),
+			})
+		}
+		if len(recs) == 0 {
+			continue
+		}
+
+		out = append(out, networkmap.PrivateServiceCandidate{
+			AccessGroups: svc.AccessGroups,
+			Zone: nmdata.CustomZone{
+				Domain:               dns.Fqdn(apex),
+				Records:              recs,
+				NonAuthoritative:     true,
+				SearchDomainDisabled: true,
+			},
+		})
+	}
+	return out
+}
+
+// TwinAccountSettings converts real account settings to the slim nmdata twin.
+// Exported for callers of the twin-based sync response builders.
+func TwinAccountSettings(s *Settings) *nmdata.AccountSettingsInfo {
+	if s == nil {
+		return nil
+	}
+	return &nmdata.AccountSettingsInfo{
+		PeerLoginExpirationEnabled:      s.PeerLoginExpirationEnabled,
+		PeerLoginExpiration:             s.PeerLoginExpiration,
+		PeerInactivityExpirationEnabled: s.PeerInactivityExpirationEnabled,
+		PeerInactivityExpiration:        s.PeerInactivityExpiration,
+		DNSDomain:                       s.DNSDomain,
+		IPv6EnabledGroups:               s.IPv6EnabledGroups,
+		RoutingPeerDNSResolutionEnabled: s.RoutingPeerDNSResolutionEnabled,
+		LazyConnectionEnabled:           s.LazyConnectionEnabled,
+		AutoUpdateVersion:               s.AutoUpdateVersion,
+		AutoUpdateAlways:                s.AutoUpdateAlways,
+		MetricsPushEnabled:              s.MetricsPushEnabled,
+	}
+}
+
+func fromTwinCustomZone(z nmdata.CustomZone) nbdns.CustomZone {
+	records := make([]nbdns.SimpleRecord, 0, len(z.Records))
+	for _, r := range z.Records {
+		records = append(records, nbdns.SimpleRecord{
+			Name:  r.Name,
+			Type:  r.Type,
+			Class: r.Class,
+			TTL:   r.TTL,
+			RData: r.RData,
+		})
+	}
+	return nbdns.CustomZone{
+		Domain:               z.Domain,
+		Records:              records,
+		SearchDomainDisabled: z.SearchDomainDisabled,
+		NonAuthoritative:     z.NonAuthoritative,
+	}
+}
+
+// TwinCustomZone converts a real DNS custom zone to its slim nmdata twin.
+// Exported for the network-map controller's DB-store path, which feeds real
+// zones into the twin-based components calculation.
+func TwinCustomZone(z nbdns.CustomZone) nmdata.CustomZone {
+	records := make([]nmdata.SimpleRecord, 0, len(z.Records))
+	for _, r := range z.Records {
+		records = append(records, nmdata.SimpleRecord{
+			Name:  r.Name,
+			Type:  r.Type,
+			Class: r.Class,
+			TTL:   r.TTL,
+			RData: r.RData,
+		})
+	}
+	return nmdata.CustomZone{
+		Domain:               z.Domain,
+		Records:              records,
+		SearchDomainDisabled: z.SearchDomainDisabled,
+		NonAuthoritative:     z.NonAuthoritative,
+	}
+}
diff --git a/management/server/types/account_private_netmap_test.go b/management/server/types/account_private_netmap_test.go
index 11b3d985a..5dccfbf30 100644
--- a/management/server/types/account_private_netmap_test.go
+++ b/management/server/types/account_private_netmap_test.go
@@ -9,6 +9,7 @@ import (
 	"github.com/stretchr/testify/require"
 
 	nbdns "github.com/netbirdio/netbird/dns"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 )
 
 func TestPrivateService_NetworkMap_UserPeer_AndProxyPeer(t *testing.T) {
@@ -17,7 +18,6 @@ func TestPrivateService_NetworkMap_UserPeer_AndProxyPeer(t *testing.T) {
 	account.Peers["proxy-peer"].Meta.WtVersion = "0.50.0"
 
 	ctx := context.Background()
-	account.InjectProxyPolicies(ctx)
 
 	validated := map[string]struct{}{
 		"user-peer":  {},
@@ -48,7 +48,7 @@ func TestPrivateService_NetworkMap_UserPeer_AndProxyPeer(t *testing.T) {
 	})
 }
 
-func netmapPeerIDs(peers []*ComponentPeer) []string {
+func netmapPeerIDs(peers []*nmdata.Peer) []string {
 	ids := make([]string, 0, len(peers))
 	for _, p := range peers {
 		if p == nil {
diff --git a/management/server/types/account_test.go b/management/server/types/account_test.go
index 324087baa..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 9f35f7e02..c82a0363d 100644
--- a/management/server/types/aliases.go
+++ b/management/server/types/aliases.go
@@ -2,55 +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
@@ -65,22 +41,30 @@ type PeerConnResolveState = sharedtypes.PeerConnResolveState
 type RuleAuthCallbacks = sharedtypes.RuleAuthCallbacks
 type VNCSessionPubKey = sharedtypes.VNCSessionPubKey
 
-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 NewPeerConnResolveState() *PeerConnResolveState {
 	return sharedtypes.NewPeerConnResolveState()
 }
 
+// ApplyResolvedRuleToState forwards to the twin-typed helper while keeping the
+// real rule on the way back out to generateResources, which the legacy Account
+// calc still drives with real types.
 func ApplyResolvedRuleToState(rule *PolicyRule, sourcePeers, destPeers []*nbpeer.Peer, peerInSources, peerInDestinations, targetPeerSSHEnabled bool, generateResources func(*PolicyRule, []*nbpeer.Peer, int), cb RuleAuthCallbacks, state *PeerConnResolveState) {
-	sharedtypes.ApplyResolvedRuleToState(rule, sourcePeers, destPeers, peerInSources, peerInDestinations, targetPeerSSHEnabled, generateResources, cb, state)
+	emit := func(_ *nmdata.PolicyRule, peers []*nbpeer.Peer, direction int) {
+		generateResources(rule, peers, direction)
+	}
+	sharedtypes.ApplyResolvedRuleToState(twinRule(rule), sourcePeers, destPeers, peerInSources, peerInDestinations, targetPeerSSHEnabled, emit, cb, state)
 }
 
 func MergeAuthorizedGroupUsers(ctx context.Context, authorizedGroups map[string][]string, groupIDToUserIDs map[string][]string, target map[string]map[string]struct{}) {
@@ -95,48 +79,37 @@ func MergeWildcardUsers(dst map[string]map[string]struct{}, users map[string]str
 	sharedtypes.MergeWildcardUsers(dst, users)
 }
 
+// NormalizePolicyRuleProtocol is the real-typed sibling of the twin helper in
+// shared types: it maps the NetBird virtual protocols to the wire protocol and
+// scopes a portless netbird-vnc rule to the embedded VNC port.
 func NormalizePolicyRuleProtocol(rule *PolicyRule) (*PolicyRule, PolicyRuleProtocolType) {
-	return sharedtypes.NormalizePolicyRuleProtocol(rule)
+	protocol := sharedtypes.WirePolicyRuleProtocol(rule.Protocol)
+	if rule.Protocol != PolicyRuleProtocolNetbirdVNC || len(rule.Ports) > 0 || len(rule.PortRanges) > 0 {
+		return rule, protocol
+	}
+	scoped := *rule
+	scoped.Ports = sharedtypes.VNCScopedPorts()
+	return &scoped, protocol
 }
 
-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 (
@@ -144,6 +117,11 @@ const (
 	FirewallRuleDirectionOUT = sharedtypes.FirewallRuleDirectionOUT
 )
 
+const (
+	AllowedIPsFormat   = sharedtypes.AllowedIPsFormat
+	AllowedIPsV6Format = sharedtypes.AllowedIPsV6Format
+)
+
 const (
 	ResourceTypePeer   = sharedtypes.ResourceTypePeer
 	ResourceTypeDomain = sharedtypes.ResourceTypeDomain
@@ -164,15 +142,3 @@ const (
 	PolicyRuleProtocolNetbirdSSH = sharedtypes.PolicyRuleProtocolNetbirdSSH
 	PolicyRuleProtocolNetbirdVNC = sharedtypes.PolicyRuleProtocolNetbirdVNC
 )
-
-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 94%
rename from shared/management/types/component_types.go
rename to management/server/types/legacynmap/component_types.go
index a511097b1..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"
@@ -25,6 +25,9 @@ type ComponentPeer struct {
 	LoginExpirationEnabled bool
 	AddedWithSSOLogin      bool
 	LastLogin              time.Time
+	// ProxyEmbedded marks an ephemeral embedded proxy peer. Connections
+	// involving such a peer on either endpoint default to lazy.
+	ProxyEmbedded bool
 }
 
 // FQDN returns the peer's FQDN combined of the peer's DNS label and the system's DNS domain.
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..1d4a44ae9
--- /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"
+
+	auth "github.com/netbirdio/netbird/shared/sessionauth"
+	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..79841df64
--- /dev/null
+++ b/management/server/types/legacynmap/proto_legacy.go
@@ -0,0 +1,220 @@
+package legacynmap
+
+import (
+	"context"
+	"fmt"
+	"net/netip"
+	"net/url"
+	"strings"
+
+	auth "github.com/netbirdio/netbird/shared/sessionauth"
+	nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
+	nbpeer "github.com/netbirdio/netbird/management/server/peer"
+	"github.com/netbirdio/netbird/management/server/types"
+	nbroute "github.com/netbirdio/netbird/route"
+	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/proto"
+	"github.com/netbirdio/netbird/shared/netiputil"
+)
+
+func ToProtocolRoutes(routes []*nbroute.Route) []*proto.Route {
+	protoRoutes := make([]*proto.Route, 0, len(routes))
+	for _, r := range routes {
+		protoRoutes = append(protoRoutes, ToProtocolRoute(r))
+	}
+	return protoRoutes
+}
+
+func ToProtocolRoute(route *nbroute.Route) *proto.Route {
+	return &proto.Route{
+		ID:            string(route.ID),
+		NetID:         string(route.NetID),
+		Network:       route.Network.String(),
+		Domains:       route.Domains.ToPunycodeList(),
+		NetworkType:   int64(route.NetworkType),
+		Peer:          route.Peer,
+		Metric:        int64(route.Metric),
+		Masquerade:    route.Masquerade,
+		KeepRoute:     route.KeepRoute,
+		SkipAutoApply: route.SkipAutoApply,
+	}
+}
+
+func AppendRemotePeerConfig(dst []*proto.RemotePeerConfig, peers []*ComponentPeer, dnsName string, includeIPv6 bool, localIsProxy bool) []*proto.RemotePeerConfig {
+	for _, rPeer := range peers {
+		allowedIPs := []string{rPeer.IP.String() + "/32"}
+		if includeIPv6 && rPeer.IPv6.IsValid() {
+			allowedIPs = append(allowedIPs, rPeer.IPv6.String()+"/128")
+		}
+		dst = append(dst, &proto.RemotePeerConfig{
+			WgPubKey:     rPeer.Key,
+			AllowedIps:   allowedIPs,
+			SshConfig:    &proto.SSHConfig{SshPubKey: []byte(rPeer.SSHKey)},
+			Fqdn:         rPeer.FQDN(dnsName),
+			AgentVersion: rPeer.AgentVersion,
+			LazyState:    lazyStateFor(localIsProxy, rPeer),
+		})
+	}
+	return dst
+}
+
+// lazyStateFor returns the per-peer lazy override for a remote peer. Connections
+// involving an ephemeral proxy peer on either endpoint default to lazy so shared
+// proxy infrastructure is not kept permanently connected to every peer. All
+// other peers follow the account-wide flag. A future admin-facing per-peer
+// setting can return LazyStateEager here to force a peer always-active.
+func lazyStateFor(localIsProxy bool, rPeer *ComponentPeer) proto.LazyState {
+	if localIsProxy || rPeer.ProxyEmbedded {
+		return proto.LazyState_LazyStateLazy
+	}
+	return proto.LazyState_LazyStateDefault
+}
+
+func buildJWTConfig(config *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow) *proto.JWTConfig {
+	if config == nil || config.AuthAudience == "" {
+		return nil
+	}
+
+	issuer := strings.TrimSpace(config.AuthIssuer)
+	if issuer == "" && deviceFlowConfig != nil {
+		if d := deriveIssuerFromTokenEndpoint(deviceFlowConfig.ProviderConfig.TokenEndpoint); d != "" {
+			issuer = d
+		}
+	}
+	if issuer == "" {
+		return nil
+	}
+
+	keysLocation := strings.TrimSpace(config.AuthKeysLocation)
+	if keysLocation == "" {
+		keysLocation = strings.TrimSuffix(issuer, "/") + "/.well-known/jwks.json"
+	}
+
+	audience := config.AuthAudience
+	if config.CLIAuthAudience != "" {
+		audience = config.CLIAuthAudience
+	}
+
+	audiences := []string{config.AuthAudience}
+	if config.CLIAuthAudience != "" && config.CLIAuthAudience != config.AuthAudience {
+		audiences = append(audiences, config.CLIAuthAudience)
+	}
+
+	return &proto.JWTConfig{
+		Issuer:       issuer,
+		Audience:     audience, //nolint:staticcheck
+		Audiences:    audiences,
+		KeysLocation: keysLocation,
+	}
+}
+
+func toPeerConfig(peer *nbpeer.Peer, network *Network, dnsName string, settings *types.Settings, httpConfig *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, enableSSH bool, forceRoutingPeerDNS bool) *proto.PeerConfig {
+	netmask, _ := network.Net.Mask.Size()
+	fqdn := peer.FQDN(dnsName)
+
+	sshConfig := &proto.SSHConfig{
+		SshEnabled: peer.SSHEnabled || enableSSH,
+	}
+
+	if sshConfig.SshEnabled {
+		sshConfig.JwtConfig = buildJWTConfig(httpConfig, deviceFlowConfig)
+	}
+
+	peerConfig := &proto.PeerConfig{
+		Address:                         fmt.Sprintf("%s/%d", peer.IP.String(), netmask),
+		SshConfig:                       sshConfig,
+		Fqdn:                            fqdn,
+		RoutingPeerDnsResolutionEnabled: settings.RoutingPeerDNSResolutionEnabled || peer.ProxyMeta.Embedded || forceRoutingPeerDNS,
+		LazyConnectionEnabled:           settings.LazyConnectionEnabled,
+		AutoUpdate: &proto.AutoUpdateSettings{
+			Version:      settings.AutoUpdateVersion,
+			AlwaysUpdate: settings.AutoUpdateAlways,
+		},
+	}
+
+	if peer.SupportsIPv6() && peer.IPv6.IsValid() && network.NetV6.IP != nil {
+		ones, _ := network.NetV6.Mask.Size()
+		v6Prefix := netip.PrefixFrom(peer.IPv6.Unmap(), ones)
+		if b, err := netiputil.EncodePrefix(v6Prefix); err == nil {
+			peerConfig.AddressV6 = b
+		}
+	}
+
+	return peerConfig
+}
+
+// ToProtoNetworkMap mirrors main's ToSyncResponse, restricted to the
+// proto.NetworkMap it produces. SyncResponse-level fields (NetbirdConfig,
+// Checks, the deprecated top-level RemotePeers) are omitted — they are not part
+// of the equivalence surface. PeerConfig is included because proto.NetworkMap
+// carries it, and it is where main's ForceRoutingPeerDNSResolution surfaces.
+func ToProtoNetworkMap(
+	ctx context.Context,
+	peer *nbpeer.Peer,
+	nm *NetworkMap,
+	dnsName string,
+	settings *types.Settings,
+	httpConfig *nbconfig.HttpServerConfig,
+	dnsCache networkmap.DNSConfigCache,
+	dnsFwdPort int64,
+) *proto.NetworkMap {
+	includeIPv6 := peer.SupportsIPv6() && peer.IPv6.IsValid()
+	useSourcePrefixes := peer.SupportsSourcePrefixes()
+	localIsProxy := peer.ProxyMeta.Embedded
+
+	peerConfig := toPeerConfig(peer, nm.Network, dnsName, settings, httpConfig, nil, nm.EnableSSH, nm.ForceRoutingPeerDNSResolution)
+
+	pm := &proto.NetworkMap{
+		Serial:     nm.Network.CurrentSerial(),
+		Routes:     ToProtocolRoutes(nm.Routes),
+		DNSConfig:  networkmap.ToProtocolDNSConfig(nm.DNSConfig, dnsCache, dnsFwdPort),
+		PeerConfig: peerConfig,
+	}
+
+	remotePeers := make([]*proto.RemotePeerConfig, 0, len(nm.Peers)+len(nm.OfflinePeers))
+	remotePeers = AppendRemotePeerConfig(remotePeers, nm.Peers, dnsName, includeIPv6, localIsProxy)
+	pm.RemotePeers = remotePeers
+	pm.RemotePeersIsEmpty = len(remotePeers) == 0
+
+	pm.OfflinePeers = AppendRemotePeerConfig(nil, nm.OfflinePeers, dnsName, includeIPv6, localIsProxy)
+
+	firewallRules := networkmap.ToProtocolFirewallRules(nm.FirewallRules, includeIPv6, useSourcePrefixes)
+	pm.FirewallRules = firewallRules
+	pm.FirewallRulesIsEmpty = len(firewallRules) == 0
+
+	routesFirewallRules := networkmap.ToProtocolRoutesFirewallRules(nm.RoutesFirewallRules)
+	pm.RoutesFirewallRules = routesFirewallRules
+	pm.RoutesFirewallRulesIsEmpty = len(routesFirewallRules) == 0
+
+	if nm.ForwardingRules != nil {
+		forwardingRules := make([]*proto.ForwardingRule, 0, len(nm.ForwardingRules))
+		for _, rule := range nm.ForwardingRules {
+			forwardingRules = append(forwardingRules, rule.ToProto())
+		}
+		pm.ForwardingRules = forwardingRules
+	}
+
+	if nm.AuthorizedUsers != nil {
+		hashedUsers, machineUsers := networkmap.BuildAuthorizedUsersProto(ctx, nm.AuthorizedUsers)
+		userIDClaim := auth.DefaultUserIDClaim
+		if httpConfig != nil && httpConfig.AuthUserIDClaim != "" {
+			userIDClaim = httpConfig.AuthUserIDClaim
+		}
+		pm.SshAuth = &proto.SSHAuth{AuthorizedUsers: hashedUsers, MachineUsers: machineUsers, UserIDClaim: userIDClaim}
+	}
+
+	return pm
+}
+
+func deriveIssuerFromTokenEndpoint(tokenEndpoint string) string {
+	if tokenEndpoint == "" {
+		return ""
+	}
+
+	u, err := url.Parse(tokenEndpoint)
+	if err != nil {
+		return ""
+	}
+
+	return fmt.Sprintf("%s://%s/", u.Scheme, u.Host)
+}
diff --git a/management/server/types/legacynmap/proxy_policies.go b/management/server/types/legacynmap/proxy_policies.go
new file mode 100644
index 000000000..e8f8c3969
--- /dev/null
+++ b/management/server/types/legacynmap/proxy_policies.go
@@ -0,0 +1,150 @@
+package legacynmap
+
+import (
+	"fmt"
+
+	"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
+	nbpeer "github.com/netbirdio/netbird/management/server/peer"
+	sharedtypes "github.com/netbirdio/netbird/shared/management/types"
+)
+
+// SynthesizeProxyPolicies is main's Account.InjectProxyPolicies, frozen. On
+// main the network-map controller called it on the account before computing,
+// so a comparison that starts from the account has to apply it too. It returns
+// the policies instead of appending them, so the caller can measure the legacy
+// path without mutating the account the other paths share.
+func SynthesizeProxyPolicies(a *Account) []*Policy {
+	if len(a.Services) == 0 {
+		return nil
+	}
+
+	proxyPeersByCluster := a.GetProxyPeers()
+	if len(proxyPeersByCluster) == 0 {
+		return nil
+	}
+
+	var out []*Policy
+	for _, svc := range a.Services {
+		if svc == nil || !svc.Enabled {
+			continue
+		}
+
+		proxyPeers := proxyPeersByCluster[svc.ProxyCluster]
+		for _, target := range svc.Targets {
+			if target == nil || !target.Enabled {
+				continue
+			}
+			port, ok := legacyTargetPort(target)
+			if !ok {
+				continue
+			}
+			path := ""
+			if target.Path != nil {
+				path = *target.Path
+			}
+			for _, proxyPeer := range proxyPeers {
+				out = append(out, legacyProxyPolicy(svc, target, proxyPeer, port, path))
+			}
+		}
+
+		out = append(out, legacyPrivateServicePolicies(a, svc, proxyPeers)...)
+	}
+	return out
+}
+
+func legacyPrivateServicePolicies(a *Account, svc *service.Service, proxyPeers []*nbpeer.Peer) []*Policy {
+	if !svc.Private || len(svc.AccessGroups) == 0 || len(proxyPeers) == 0 {
+		return nil
+	}
+
+	sources := make([]string, 0, len(svc.AccessGroups))
+	for _, groupID := range svc.AccessGroups {
+		if _, ok := a.Groups[groupID]; ok {
+			sources = append(sources, groupID)
+		}
+	}
+	if len(sources) == 0 {
+		return nil
+	}
+
+	out := make([]*Policy, 0, len(proxyPeers))
+	for _, proxyPeer := range proxyPeers {
+		policyID := fmt.Sprintf("private-access-%s-%s", svc.ID, proxyPeer.ID)
+		out = append(out, &Policy{
+			ID:      policyID,
+			Name:    fmt.Sprintf("Private Access to %s", svc.Name),
+			Enabled: true,
+			Rules: []*PolicyRule{
+				{
+					ID:       policyID,
+					PolicyID: policyID,
+					Name:     fmt.Sprintf("Allow access groups to reach %s", svc.Name),
+					Enabled:  true,
+					Sources:  append([]string(nil), sources...),
+					DestinationResource: Resource{
+						ID:   proxyPeer.ID,
+						Type: ResourceTypePeer,
+					},
+					Bidirectional: false,
+					Protocol:      PolicyRuleProtocolTCP,
+					Action:        PolicyTrafficActionAccept,
+					PortRanges: []RulePortRange{
+						{Start: 80, End: 80},
+						{Start: 443, End: 443},
+					},
+				},
+			},
+		})
+	}
+	return out
+}
+
+func legacyProxyPolicy(svc *service.Service, target *service.Target, proxyPeer *nbpeer.Peer, port uint16, path string) *Policy {
+	policyID := fmt.Sprintf("proxy-access-%s-%s-%s", svc.ID, proxyPeer.ID, path)
+
+	protocol := PolicyRuleProtocolTCP
+	if svc.Mode == service.ModeUDP {
+		protocol = sharedtypes.PolicyRuleProtocolUDP
+	}
+
+	return &Policy{
+		ID:      policyID,
+		Name:    fmt.Sprintf("Proxy Access to %s", svc.Name),
+		Enabled: true,
+		Rules: []*PolicyRule{
+			{
+				ID:       policyID,
+				PolicyID: policyID,
+				Name:     fmt.Sprintf("Allow access to %s", svc.Name),
+				Enabled:  true,
+				SourceResource: Resource{
+					ID:   proxyPeer.ID,
+					Type: ResourceTypePeer,
+				},
+				DestinationResource: Resource{
+					ID:   target.TargetId,
+					Type: sharedtypes.ResourceType(target.TargetType),
+				},
+				Bidirectional: false,
+				Protocol:      protocol,
+				Action:        PolicyTrafficActionAccept,
+				PortRanges:    []RulePortRange{{Start: port, End: port}},
+			},
+		},
+	}
+}
+
+func legacyTargetPort(target *service.Target) (uint16, bool) {
+	if target.Port != 0 {
+		return target.Port, true
+	}
+
+	switch target.Protocol {
+	case "https", "tls":
+		return 443, true
+	case "http":
+		return 80, true
+	default:
+		return 0, false
+	}
+}
diff --git a/management/server/types/network.go b/management/server/types/network.go
new file mode 100644
index 000000000..72ca1af85
--- /dev/null
+++ b/management/server/types/network.go
@@ -0,0 +1,271 @@
+package types
+
+import (
+	"encoding/binary"
+	"fmt"
+	"math/rand"
+	"net"
+	"net/netip"
+	"slices"
+	"sync"
+	"time"
+
+	"github.com/c-robinson/iplib"
+	"github.com/rs/xid"
+
+	"github.com/netbirdio/netbird/shared/management/status"
+)
+
+const (
+	// SubnetSize is a size of the subnet of the global network, e.g.  100.77.0.0/16
+	SubnetSize = 16
+	// NetSize is a global network size 100.64.0.0/10
+	NetSize = 10
+
+	// IPv6SubnetSize is the prefix length of per-account IPv6 subnets.
+	// Each account gets a /64 from its unique /48 ULA prefix.
+	IPv6SubnetSize = 64
+)
+
+type Network struct {
+	Identifier string    `json:"id"`
+	Net        net.IPNet `gorm:"serializer:json"`
+	// NetV6 is the IPv6 ULA subnet for this account's overlay. Empty if not yet allocated.
+	NetV6 net.IPNet `gorm:"serializer:json"`
+	Dns   string
+	// Serial is an ID that increments by 1 when any change to the network happened (e.g. new peer has been added).
+	// Used to synchronize state to the client apps.
+	Serial uint64
+
+	Mu sync.Mutex `json:"-" gorm:"-"`
+}
+
+// NewNetwork creates a new Network initializing it with a Serial=0
+// It takes a random /16 subnet from 100.64.0.0/10 (64 different subnets)
+// and a random /64 subnet from fd00:4e42::/32 for IPv6.
+func NewNetwork() *Network {
+	n := iplib.NewNet4(net.ParseIP("100.64.0.0"), NetSize)
+	sub, _ := n.Subnet(SubnetSize)
+
+	s := rand.NewSource(time.Now().UnixNano())
+	r := rand.New(s)
+	intn := r.Intn(len(sub))
+
+	return &Network{
+		Identifier: xid.New().String(),
+		Net:        sub[intn].IPNet,
+		NetV6:      AllocateIPv6Subnet(r),
+		Dns:        "",
+		Serial:     0,
+	}
+}
+
+// AllocateIPv6Subnet generates a random RFC 4193 ULA /64 prefix.
+// The format follows RFC 4193 section 3.1: fd + 40-bit Global ID + 16-bit Subnet ID.
+// The Global ID and Subnet ID are randomized (simplified from the SHA-1 algorithm
+// in section 3.2.2), giving 2^56 possible /64 subnets across all accounts.
+func AllocateIPv6Subnet(r *rand.Rand) net.IPNet {
+	ip := make(net.IP, 16)
+	ip[0] = 0xfd
+	// Bytes 1-5: 40-bit random Global ID
+	ip[1] = byte(r.Intn(256))
+	ip[2] = byte(r.Intn(256))
+	ip[3] = byte(r.Intn(256))
+	ip[4] = byte(r.Intn(256))
+	ip[5] = byte(r.Intn(256))
+	// Bytes 6-7: 16-bit random Subnet ID
+	ip[6] = byte(r.Intn(256))
+	ip[7] = byte(r.Intn(256))
+
+	return net.IPNet{
+		IP:   ip,
+		Mask: net.CIDRMask(IPv6SubnetSize, 128),
+	}
+}
+
+// IncSerial increments Serial by 1 reflecting that the network state has been changed
+func (n *Network) IncSerial() {
+	n.Mu.Lock()
+	defer n.Mu.Unlock()
+	n.Serial++
+}
+
+// CurrentSerial returns the Network.Serial of the network (latest state id)
+func (n *Network) CurrentSerial() uint64 {
+	n.Mu.Lock()
+	defer n.Mu.Unlock()
+	return n.Serial
+}
+
+func (n *Network) Copy() *Network {
+	n.Mu.Lock()
+	defer n.Mu.Unlock()
+	return &Network{
+		Identifier: n.Identifier,
+		Net:        n.Net,
+		NetV6:      n.NetV6,
+		Dns:        n.Dns,
+		Serial:     n.Serial,
+	}
+}
+
+// AllocatePeerIP picks an available IP from a netip.Prefix.
+// This method considers already taken IPs and reuses IPs if there are gaps in takenIps.
+// E.g. if prefix=100.30.0.0/16 and takenIps=[100.30.0.1, 100.30.0.4] then the result would be 100.30.0.2 or 100.30.0.3.
+func AllocatePeerIP(prefix netip.Prefix, takenIps []netip.Addr) (netip.Addr, error) {
+	b := prefix.Masked().Addr().As4()
+	baseIP := binary.BigEndian.Uint32(b[:])
+	hostBits := 32 - prefix.Bits()
+	totalIPs := uint32(1 << hostBits)
+
+	taken := make(map[uint32]struct{}, len(takenIps)+1)
+	taken[baseIP] = struct{}{}            // reserve network IP
+	taken[baseIP+totalIPs-1] = struct{}{} // reserve broadcast IP
+
+	for _, ip := range takenIps {
+		ab := ip.As4()
+		taken[binary.BigEndian.Uint32(ab[:])] = struct{}{}
+	}
+
+	rng := rand.New(rand.NewSource(time.Now().UnixNano()))
+	maxAttempts := (int(totalIPs) - len(taken)) / 100
+
+	for i := 0; i < maxAttempts; i++ {
+		offset := uint32(rng.Intn(int(totalIPs-2))) + 1
+		candidate := baseIP + offset
+		if _, exists := taken[candidate]; !exists {
+			return uint32ToIP(candidate), nil
+		}
+	}
+
+	for offset := uint32(1); offset < totalIPs-1; offset++ {
+		candidate := baseIP + offset
+		if _, exists := taken[candidate]; !exists {
+			return uint32ToIP(candidate), nil
+		}
+	}
+
+	return netip.Addr{}, status.Errorf(status.PreconditionFailed, "network %s is out of IPs", prefix.String())
+}
+
+// AllocateRandomPeerIP picks a random available IP from a netip.Prefix.
+func AllocateRandomPeerIP(prefix netip.Prefix) (netip.Addr, error) {
+	b := prefix.Masked().Addr().As4()
+	baseIP := binary.BigEndian.Uint32(b[:])
+	hostBits := 32 - prefix.Bits()
+	totalIPs := uint32(1 << hostBits)
+
+	rng := rand.New(rand.NewSource(time.Now().UnixNano()))
+	offset := uint32(rng.Intn(int(totalIPs-2))) + 1
+
+	candidate := baseIP + offset
+	return uint32ToIP(candidate), nil
+}
+
+// AllocateRandomPeerIPv6 picks a random host address within the given IPv6 prefix.
+// Only the host bits (after the prefix length) are randomized.
+func AllocateRandomPeerIPv6(prefix netip.Prefix) (netip.Addr, error) {
+	ones := prefix.Bits()
+	if ones == 0 || ones > 126 || !prefix.Addr().Is6() {
+		return netip.Addr{}, fmt.Errorf("invalid IPv6 subnet: %s", prefix.String())
+	}
+
+	ip := prefix.Addr().As16()
+
+	rng := rand.New(rand.NewSource(time.Now().UnixNano()))
+
+	// Determine which byte the host bits start in
+	firstHostByte := ones / 8
+	// If the prefix doesn't end on a byte boundary, handle the partial byte
+	partialBits := ones % 8
+
+	if partialBits > 0 {
+		// Keep the network bits in the partial byte, randomize the rest
+		hostMask := byte(0xff >> partialBits)
+		ip[firstHostByte] = (ip[firstHostByte] & ^hostMask) | (byte(rng.Intn(256)) & hostMask)
+		firstHostByte++
+	}
+
+	// Randomize remaining full host bytes
+	for i := firstHostByte; i < 16; i++ {
+		ip[i] = byte(rng.Intn(256))
+	}
+
+	// Avoid all-zeros and all-ones host parts by checking only host bits.
+	if isHostAllZeroOrOnes(ip[:], ones) {
+		ip = prefix.Masked().Addr().As16()
+		ip[15] |= 0x01
+	}
+
+	return netip.AddrFrom16(ip).Unmap(), nil
+}
+
+// isHostAllZeroOrOnes checks whether all host bits (after prefixLen) are zero or all ones.
+func isHostAllZeroOrOnes(ip []byte, prefixLen int) bool {
+	hostStart := prefixLen / 8
+	partialBits := prefixLen % 8
+
+	hostSlice := slices.Clone(ip[hostStart:])
+	if partialBits > 0 {
+		hostSlice[0] &= 0xff >> partialBits
+	}
+
+	allZero := !slices.ContainsFunc(hostSlice, func(v byte) bool { return v != 0 })
+	if allZero {
+		return true
+	}
+
+	// Build the all-ones mask for host bits
+	onesMask := make([]byte, len(hostSlice))
+	for i := range onesMask {
+		onesMask[i] = 0xff
+	}
+	if partialBits > 0 {
+		onesMask[0] = 0xff >> partialBits
+	}
+
+	return slices.Equal(hostSlice, onesMask)
+}
+
+func uint32ToIP(n uint32) netip.Addr {
+	var b [4]byte
+	binary.BigEndian.PutUint32(b[:], n)
+	return netip.AddrFrom4(b)
+}
+
+// generateIPs generates a list of all possible IPs of the given network excluding IPs specified in the exclusion list
+func generateIPs(ipNet *net.IPNet, exclusions map[string]struct{}) ([]net.IP, int) {
+
+	var ips []net.IP
+	for ip := ipNet.IP.Mask(ipNet.Mask); ipNet.Contains(ip); incIP(ip) {
+		if _, ok := exclusions[ip.String()]; !ok && ip[3] != 0 {
+			ips = append(ips, copyIP(ip))
+		}
+	}
+
+	// remove network address, broadcast and Fake DNS resolver address
+	lenIPs := len(ips)
+	switch {
+	case lenIPs < 2:
+		return ips, lenIPs
+	case lenIPs < 3:
+		return ips[1 : len(ips)-1], lenIPs - 2
+	default:
+		return ips[1 : len(ips)-2], lenIPs - 3
+	}
+}
+
+func copyIP(ip net.IP) net.IP {
+	dup := make(net.IP, len(ip))
+	copy(dup, ip)
+	return dup
+}
+
+func incIP(ip net.IP) {
+	for j := len(ip) - 1; j >= 0; j-- {
+		ip[j]++
+		if ip[j] > 0 {
+			break
+		}
+	}
+}
diff --git a/management/server/types/network_test.go b/management/server/types/network_test.go
new file mode 100644
index 000000000..d8a06dbbc
--- /dev/null
+++ b/management/server/types/network_test.go
@@ -0,0 +1,264 @@
+package types
+
+import (
+	"encoding/binary"
+	"net"
+	"net/netip"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestNewNetwork(t *testing.T) {
+	network := NewNetwork()
+
+	// generated net should be a subnet of a larger 100.64.0.0/10 net
+	ipNet := net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.IPMask{255, 192, 0, 0}}
+	assert.Equal(t, ipNet.Contains(network.Net.IP), true)
+}
+
+func TestAllocatePeerIP(t *testing.T) {
+	prefix := netip.MustParsePrefix("100.64.0.0/24")
+	var ips []netip.Addr
+	for i := 0; i < 252; i++ {
+		ip, err := AllocatePeerIP(prefix, ips)
+		if err != nil {
+			t.Fatal(err)
+		}
+		ips = append(ips, ip)
+	}
+
+	assert.Len(t, ips, 252)
+
+	uniq := make(map[string]struct{})
+	for _, ip := range ips {
+		if _, ok := uniq[ip.String()]; !ok {
+			uniq[ip.String()] = struct{}{}
+		} else {
+			t.Errorf("found duplicate IP %s", ip.String())
+		}
+	}
+}
+
+func TestAllocatePeerIPSmallSubnet(t *testing.T) {
+	// Test /27 network (10.0.0.0/27) - should only have 30 usable IPs (10.0.0.1 to 10.0.0.30)
+	prefix := netip.MustParsePrefix("10.0.0.0/27")
+	var ips []netip.Addr
+
+	// Allocate all available IPs in the /27 network
+	for i := 0; i < 30; i++ {
+		ip, err := AllocatePeerIP(prefix, ips)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		// Verify IP is within the correct range
+		if !prefix.Contains(ip) {
+			t.Errorf("allocated IP %s is not within network %s", ip.String(), prefix.String())
+		}
+
+		ips = append(ips, ip)
+	}
+
+	assert.Len(t, ips, 30)
+
+	// Verify all IPs are unique
+	uniq := make(map[string]struct{})
+	for _, ip := range ips {
+		if _, ok := uniq[ip.String()]; !ok {
+			uniq[ip.String()] = struct{}{}
+		} else {
+			t.Errorf("found duplicate IP %s", ip.String())
+		}
+	}
+
+	// Try to allocate one more IP - should fail as network is full
+	_, err := AllocatePeerIP(prefix, ips)
+	if err == nil {
+		t.Error("expected error when network is full, but got none")
+	}
+}
+
+func TestAllocatePeerIPVariousCIDRs(t *testing.T) {
+	testCases := []struct {
+		name           string
+		cidr           string
+		expectedUsable int
+	}{
+		{"/30 network", "192.168.1.0/30", 2},   // 4 total - 2 reserved = 2 usable
+		{"/29 network", "192.168.1.0/29", 6},   // 8 total - 2 reserved = 6 usable
+		{"/28 network", "192.168.1.0/28", 14},  // 16 total - 2 reserved = 14 usable
+		{"/27 network", "192.168.1.0/27", 30},  // 32 total - 2 reserved = 30 usable
+		{"/26 network", "192.168.1.0/26", 62},  // 64 total - 2 reserved = 62 usable
+		{"/25 network", "192.168.1.0/25", 126}, // 128 total - 2 reserved = 126 usable
+		{"/16 network", "10.0.0.0/16", 65534},  // 65536 total - 2 reserved = 65534 usable
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			prefix, err := netip.ParsePrefix(tc.cidr)
+			require.NoError(t, err)
+			prefix = prefix.Masked()
+
+			var ips []netip.Addr
+
+			// For larger networks, test only a subset to avoid long test runs
+			testCount := tc.expectedUsable
+			if testCount > 1000 {
+				testCount = 1000
+			}
+
+			// Allocate IPs and verify they're within the correct range
+			for i := 0; i < testCount; i++ {
+				ip, err := AllocatePeerIP(prefix, ips)
+				require.NoError(t, err, "failed to allocate IP %d", i)
+
+				// Verify IP is within the correct range
+				assert.True(t, prefix.Contains(ip), "allocated IP %s is not within network %s", ip.String(), prefix.String())
+
+				// Verify IP is not network or broadcast address
+				networkAddr := prefix.Masked().Addr()
+				hostBits := 32 - prefix.Bits()
+				b := networkAddr.As4()
+				baseIP := binary.BigEndian.Uint32(b[:])
+				broadcastIP := uint32ToIP(baseIP + (1 << hostBits) - 1)
+
+				assert.NotEqual(t, networkAddr, ip, "allocated network address %s", ip.String())
+				assert.NotEqual(t, broadcastIP, ip, "allocated broadcast address %s", ip.String())
+
+				ips = append(ips, ip)
+			}
+
+			assert.Len(t, ips, testCount)
+
+			// Verify all IPs are unique
+			uniq := make(map[string]struct{})
+			for _, ip := range ips {
+				ipStr := ip.String()
+				assert.NotContains(t, uniq, ipStr, "found duplicate IP %s", ipStr)
+				uniq[ipStr] = struct{}{}
+			}
+		})
+	}
+}
+
+func TestGenerateIPs(t *testing.T) {
+	ipNet := net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.IPMask{255, 255, 255, 0}}
+	ips, ipsLen := generateIPs(&ipNet, map[string]struct{}{"100.64.0.0": {}})
+	if ipsLen != 252 {
+		t.Errorf("expected 252 ips, got %d", len(ips))
+		return
+	}
+	if ips[len(ips)-1].String() != "100.64.0.253" {
+		t.Errorf("expected last ip to be: 100.64.0.253, got %s", ips[len(ips)-1].String())
+	}
+}
+
+func TestNewNetworkHasIPv6(t *testing.T) {
+	network := NewNetwork()
+
+	assert.NotNil(t, network.NetV6.IP, "v6 subnet should be allocated")
+	assert.True(t, network.NetV6.IP.To4() == nil, "v6 subnet should be IPv6")
+	assert.Equal(t, byte(0xfd), network.NetV6.IP[0], "v6 subnet should be ULA (fd prefix)")
+
+	ones, bits := network.NetV6.Mask.Size()
+	assert.Equal(t, 64, ones, "v6 subnet should be /64")
+	assert.Equal(t, 128, bits)
+}
+
+func TestAllocateIPv6SubnetUniqueness(t *testing.T) {
+	seen := make(map[string]struct{})
+	for i := 0; i < 100; i++ {
+		network := NewNetwork()
+		key := network.NetV6.IP.String()
+		_, duplicate := seen[key]
+		assert.False(t, duplicate, "duplicate v6 subnet: %s", key)
+		seen[key] = struct{}{}
+	}
+}
+
+func TestAllocateRandomPeerIPv6(t *testing.T) {
+	prefix := netip.MustParsePrefix("fd12:3456:7890:abcd::/64")
+
+	ip, err := AllocateRandomPeerIPv6(prefix)
+	require.NoError(t, err)
+
+	assert.True(t, ip.Is6(), "should be IPv6")
+	assert.True(t, prefix.Contains(ip), "should be within subnet")
+	// First 8 bytes (network prefix) should match
+	b := ip.As16()
+	prefixBytes := prefix.Addr().As16()
+	assert.Equal(t, prefixBytes[:8], b[:8], "prefix should match")
+	// Interface ID should not be all zeros
+	allZero := true
+	for _, v := range b[8:] {
+		if v != 0 {
+			allZero = false
+			break
+		}
+	}
+	assert.False(t, allZero, "interface ID should not be all zeros")
+}
+
+func TestAllocateRandomPeerIPv6_VariousPrefixes(t *testing.T) {
+	tests := []struct {
+		name   string
+		cidr   string
+		prefix int
+	}{
+		{"standard /64", "fd00:1234:5678:abcd::/64", 64},
+		{"small /112", "fd00:1234:5678:abcd::/112", 112},
+		{"large /48", "fd00:1234::/48", 48},
+		{"non-boundary /60", "fd00:1234:5670::/60", 60},
+		{"non-boundary /52", "fd00:1230::/52", 52},
+		{"minimum /120", "fd00:1234:5678:abcd::100/120", 120},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			prefix, err := netip.ParsePrefix(tt.cidr)
+			require.NoError(t, err)
+			prefix = prefix.Masked()
+
+			assert.Equal(t, tt.prefix, prefix.Bits())
+
+			for i := 0; i < 50; i++ {
+				ip, err := AllocateRandomPeerIPv6(prefix)
+				require.NoError(t, err)
+				assert.True(t, prefix.Contains(ip), "IP %s should be within %s", ip, prefix)
+			}
+		})
+	}
+}
+
+func TestAllocateRandomPeerIPv6_PreservesNetworkBits(t *testing.T) {
+	// For a /112, bytes 0-13 should be preserved, only bytes 14-15 should vary
+	prefix := netip.MustParsePrefix("fd00:1234:5678:abcd:ef01:2345:6789:0/112")
+
+	prefixBytes := prefix.Addr().As16()
+	for i := 0; i < 20; i++ {
+		ip, err := AllocateRandomPeerIPv6(prefix)
+		require.NoError(t, err)
+		// First 14 bytes (112 bits = 14 bytes) must match the network
+		b := ip.As16()
+		assert.Equal(t, prefixBytes[:14], b[:14], "network bytes should be preserved for /112")
+	}
+}
+
+func TestAllocateRandomPeerIPv6_NonByteBoundary(t *testing.T) {
+	// For a /60, the first 7.5 bytes are network, so byte 7 is partial
+	prefix := netip.MustParsePrefix("fd00:1234:5678:abc0::/60")
+
+	prefixBytes := prefix.Addr().As16()
+	for i := 0; i < 50; i++ {
+		ip, err := AllocateRandomPeerIPv6(prefix)
+		require.NoError(t, err)
+		b := ip.As16()
+		assert.True(t, prefix.Contains(ip), "IP %s should be within %s", ip, prefix)
+		// First 7 bytes must match exactly
+		assert.Equal(t, prefixBytes[:7], b[:7], "full network bytes should match for /60")
+		// Byte 7: top 4 bits (0xc = 1100) must be preserved
+		assert.Equal(t, prefixBytes[7]&0xf0, b[7]&0xf0, "partial byte network bits should be preserved for /60")
+	}
+}
diff --git a/management/server/types/networkmap_components_correctness_test.go b/management/server/types/networkmap_components_correctness_test.go
index 825d51d4e..eb3e4fe3b 100644
--- a/management/server/types/networkmap_components_correctness_test.go
+++ b/management/server/types/networkmap_components_correctness_test.go
@@ -388,7 +388,7 @@ func TestComponents_NetworkSerial(t *testing.T) {
 	account.Network.Serial = 42
 	nm := componentsNetworkMap(account, "peer-0", validatedPeers)
 	require.NotNil(t, nm)
-	assert.Equal(t, uint64(42), nm.Network.Serial, "network serial should match")
+	assert.Equal(t, uint64(42), nm.Network.CurrentSerial(), "network serial should match")
 }
 
 // ──────────────────────────────────────────────────────────────────────────────
@@ -812,7 +812,7 @@ func TestComponents_AllPeersGetValidMaps(t *testing.T) {
 		}
 		nm := componentsNetworkMap(account, peerID, validatedPeers)
 		require.NotNil(t, nm, "network map should not be nil for %s", peerID)
-		assert.Equal(t, account.Network.Serial, nm.Network.Serial, "serial mismatch for %s", peerID)
+		assert.Equal(t, account.Network.Serial, nm.Network.CurrentSerial(), "serial mismatch for %s", peerID)
 		assert.NotEmpty(t, nm.Peers, "validated peer %s should see other peers", peerID)
 	}
 }
@@ -833,7 +833,7 @@ func TestComponents_LargeScaleMapGeneration(t *testing.T) {
 				require.NotNil(t, nm, "network map should not be nil for %s", peerID)
 				assert.NotEmpty(t, nm.Peers, "peer %s should see other peers at scale", peerID)
 				assert.NotEmpty(t, nm.Routes, "peer %s should have routes at scale", peerID)
-				assert.Equal(t, account.Network.Serial, nm.Network.Serial, "serial mismatch for %s", peerID)
+				assert.Equal(t, account.Network.Serial, nm.Network.CurrentSerial(), "serial mismatch for %s", peerID)
 			}
 		})
 	}
diff --git a/management/server/types/networkmap_components_test.go b/management/server/types/networkmap_components_test.go
index 3f2288f88..f6d542609 100644
--- a/management/server/types/networkmap_components_test.go
+++ b/management/server/types/networkmap_components_test.go
@@ -18,6 +18,7 @@ import (
 	"github.com/netbirdio/netbird/management/server/posture"
 	"github.com/netbirdio/netbird/management/server/types"
 	"github.com/netbirdio/netbird/route"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 )
 
 func networkMapFromComponents(t *testing.T, account *types.Account, peerID string, validatedPeers map[string]struct{}) *types.NetworkMap {
@@ -49,7 +50,7 @@ func allPeersValidated(account *types.Account, excludePeerIDs ...string) map[str
 	return validated
 }
 
-func peerIDs(peers []*types.ComponentPeer) []string {
+func peerIDs(peers []*nmdata.Peer) []string {
 	ids := make([]string, len(peers))
 	for i, p := range peers {
 		ids[i] = p.ID
@@ -625,7 +626,7 @@ func TestNetworkMapComponents_DomainNetworkResource(t *testing.T) {
 
 	var hasDomainRoute bool
 	for _, r := range nm.Routes {
-		if r.NetworkType == route.DomainNetwork && len(r.Domains) > 0 && r.Domains[0].SafeString() == "api.example.com" {
+		if r.NetworkType == int(route.DomainNetwork) && len(r.Domains) > 0 && r.Domains[0].SafeString() == "api.example.com" {
 			hasDomainRoute = true
 		}
 	}
diff --git a/management/server/types/networkmap_wire_benchmark_test.go b/management/server/types/networkmap_wire_benchmark_test.go
index ee9839a3f..ccec054cd 100644
--- a/management/server/types/networkmap_wire_benchmark_test.go
+++ b/management/server/types/networkmap_wire_benchmark_test.go
@@ -66,7 +66,7 @@ func BenchmarkNetworkMapWireEncode(b *testing.B) {
 
 		// Pre-encode once so the size metric is identical for every run inside
 		// the same scale; the b.Loop call only re-runs encode + Marshal.
-		legacyResp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, peer, nil, nil, networkMap, "netbird.cloud", nil, dnsCache, settings, nil, nil, 0)
+		legacyResp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, types.TwinPeer(peer), nil, nil, networkMap, "netbird.cloud", nil, dnsCache, types.TwinAccountSettings(settings), nil, nil, 0)
 		legacyBytes, err := goproto.Marshal(legacyResp.NetworkMap)
 		if err != nil {
 			b.Fatalf("marshal legacy networkmap: %v", err)
@@ -88,7 +88,7 @@ func BenchmarkNetworkMapWireEncode(b *testing.B) {
 			b.ReportMetric(float64(len(legacyBytes)), "bytes/msg")
 			b.ResetTimer()
 			for range b.N {
-				resp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, peer, nil, nil, networkMap, "netbird.cloud", nil, dnsCache, settings, nil, nil, 0)
+				resp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, types.TwinPeer(peer), nil, nil, networkMap, "netbird.cloud", nil, dnsCache, types.TwinAccountSettings(settings), nil, nil, 0)
 				if _, err := goproto.Marshal(resp.NetworkMap); err != nil {
 					b.Fatal(err)
 				}
@@ -135,7 +135,7 @@ func BenchmarkNetworkMapWireSize(b *testing.B) {
 		dnsCache := &cache.DNSConfigCache{}
 		settings := &types.Settings{}
 
-		legacyResp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, peer, nil, nil, networkMap, "netbird.cloud", nil, dnsCache, settings, nil, nil, 0)
+		legacyResp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, types.TwinPeer(peer), nil, nil, networkMap, "netbird.cloud", nil, dnsCache, types.TwinAccountSettings(settings), nil, nil, 0)
 		legacyBytes, err := goproto.Marshal(legacyResp.NetworkMap)
 		if err != nil {
 			b.Fatalf("marshal legacy networkmap: %v", err)
diff --git a/management/server/types/networkmap_wire_breakdown_test.go b/management/server/types/networkmap_wire_breakdown_test.go
index ac2855fa3..adf66b386 100644
--- a/management/server/types/networkmap_wire_breakdown_test.go
+++ b/management/server/types/networkmap_wire_breakdown_test.go
@@ -45,7 +45,7 @@ func TestNetworkMapWireBreakdown(t *testing.T) {
 	dnsCache := &cache.DNSConfigCache{}
 	settings := &types.Settings{}
 
-	legacyResp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, peer, nil, nil, networkMap, "netbird.cloud", nil, dnsCache, settings, nil, nil, 0)
+	legacyResp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, types.TwinPeer(peer), nil, nil, networkMap, "netbird.cloud", nil, dnsCache, types.TwinAccountSettings(settings), nil, nil, 0)
 	legacyTotal := mustMarshalSize(t, legacyResp.NetworkMap)
 
 	envelope := mgmtgrpc.EncodeNetworkMapEnvelope(mgmtgrpc.ComponentsEnvelopeInput{
diff --git a/shared/management/types/policy.go b/management/server/types/policy.go
similarity index 56%
rename from shared/management/types/policy.go
rename to management/server/types/policy.go
index 3292e61cb..0f7298d18 100644
--- a/shared/management/types/policy.go
+++ b/management/server/types/policy.go
@@ -1,34 +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")
-	// PolicyRuleProtocolNetbirdVNC type of traffic
-	PolicyRuleProtocolNetbirdVNC = PolicyRuleProtocolType("netbird-vnc")
-)
-
 const (
 	// PolicyRuleFlowDirect allows traffic from source to destination
 	PolicyRuleFlowDirect = PolicyRuleDirection("direct")
@@ -186,87 +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
-	case "netbird-vnc":
-		return PolicyRuleProtocolNetbirdVNC, RulePortRange{Start: vncInternalPort, End: vncInternalPort}, 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..86f0df3b4
--- /dev/null
+++ b/management/server/types/policyrule.go
@@ -0,0 +1,213 @@
+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
+
+	// SessionPubKey is the base64 X25519 public key used with Noise_IK to
+	// bind a VNC session to the AuthorizedUser. Set together with
+	// AuthorizedUser when the rule was created via temporary-access for a
+	// VNC scope; empty otherwise.
+	SessionPubKey string
+
+	// SessionDisplayName is a human-readable label for the user the
+	// SessionPubKey was issued to (typically display name, falling back
+	// to email or user id). The daemon surfaces it on the host's
+	// per-connection approval prompt so the user being asked can
+	// recognise who is requesting access.
+	SessionDisplayName 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,
+		SessionPubKey:       pm.SessionPubKey,
+		SessionDisplayName:  pm.SessionDisplayName,
+	}
+	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 ||
+		pm.SessionPubKey != other.SessionPubKey ||
+		pm.SessionDisplayName != other.SessionDisplayName {
+		return false
+	}
+
+	if !stringSlicesEqualUnordered(pm.Sources, other.Sources) {
+		return false
+	}
+	if !stringSlicesEqualUnordered(pm.Destinations, other.Destinations) {
+		return false
+	}
+	if !stringSlicesEqualUnordered(pm.Ports, other.Ports) {
+		return false
+	}
+	if !portRangeSlicesEqualUnordered(pm.PortRanges, other.PortRanges) {
+		return false
+	}
+	if !authorizedGroupsEqual(pm.AuthorizedGroups, other.AuthorizedGroups) {
+		return false
+	}
+
+	return true
+}
+
+func stringSlicesEqualUnordered(a, b []string) bool {
+	if len(a) != len(b) {
+		return false
+	}
+	if len(a) == 0 {
+		return true
+	}
+	sorted1 := make([]string, len(a))
+	sorted2 := make([]string, len(b))
+	copy(sorted1, a)
+	copy(sorted2, b)
+	slices.Sort(sorted1)
+	slices.Sort(sorted2)
+	return slices.Equal(sorted1, sorted2)
+}
+
+func portRangeSlicesEqualUnordered(a, b []RulePortRange) bool {
+	if len(a) != len(b) {
+		return false
+	}
+	if len(a) == 0 {
+		return true
+	}
+	cmp := func(x, y RulePortRange) int {
+		if x.Start != y.Start {
+			if x.Start < y.Start {
+				return -1
+			}
+			return 1
+		}
+		if x.End != y.End {
+			if x.End < y.End {
+				return -1
+			}
+			return 1
+		}
+		return 0
+	}
+	sorted1 := make([]RulePortRange, len(a))
+	sorted2 := make([]RulePortRange, len(b))
+	copy(sorted1, a)
+	copy(sorted2, b)
+	slices.SortFunc(sorted1, cmp)
+	slices.SortFunc(sorted2, cmp)
+	return slices.EqualFunc(sorted1, sorted2, func(x, y RulePortRange) bool {
+		return x.Start == y.Start && x.End == y.End
+	})
+}
+
+func authorizedGroupsEqual(a, b map[string][]string) bool {
+	if len(a) != len(b) {
+		return false
+	}
+	for k, va := range a {
+		vb, ok := b[k]
+		if !ok {
+			return false
+		}
+		if !stringSlicesEqualUnordered(va, vb) {
+			return false
+		}
+	}
+	return true
+}
diff --git a/management/server/types/resource.go b/management/server/types/resource.go
new file mode 100644
index 000000000..0f065c850
--- /dev/null
+++ b/management/server/types/resource.go
@@ -0,0 +1,30 @@
+package types
+
+import (
+	"github.com/netbirdio/netbird/shared/management/http/api"
+)
+
+type Resource struct {
+	ID   string
+	Type ResourceType
+}
+
+func (r *Resource) ToAPIResponse() *api.Resource {
+	if r.ID == "" && r.Type == "" {
+		return nil
+	}
+
+	return &api.Resource{
+		Id:   r.ID,
+		Type: api.ResourceType(r.Type),
+	}
+}
+
+func (r *Resource) FromAPIRequest(req *api.Resource) {
+	if req == nil {
+		return
+	}
+
+	r.ID = req.Id
+	r.Type = ResourceType(req.Type)
+}
diff --git a/management/server/types/user.go b/management/server/types/user.go
index dc601e15b..2e975809c 100644
--- a/management/server/types/user.go
+++ b/management/server/types/user.go
@@ -6,7 +6,7 @@ import (
 	"time"
 
 	"github.com/netbirdio/netbird/management/server/idp"
-	"github.com/netbirdio/netbird/management/server/integration_reference"
+	"github.com/netbirdio/netbird/shared/management/integration_reference"
 	"github.com/netbirdio/netbird/util/crypt"
 )
 
diff --git a/management/server/user_test.go b/management/server/user_test.go
index a2e71616a..3a2414540 100644
--- a/management/server/user_test.go
+++ b/management/server/user_test.go
@@ -33,7 +33,7 @@ import (
 	"github.com/netbirdio/netbird/idp/dex"
 	"github.com/netbirdio/netbird/management/server/activity"
 	"github.com/netbirdio/netbird/management/server/idp"
-	"github.com/netbirdio/netbird/management/server/integration_reference"
+	"github.com/netbirdio/netbird/shared/management/integration_reference"
 )
 
 const (
diff --git a/proxy/internal/roundtrip/netbird.go b/proxy/internal/roundtrip/netbird.go
index cb2e7f930..ae3308a3e 100644
--- a/proxy/internal/roundtrip/netbird.go
+++ b/proxy/internal/roundtrip/netbird.go
@@ -30,6 +30,12 @@ import (
 
 const deviceNamePrefix = "ingress-proxy-"
 
+// envProxyRosenpass toggles Rosenpass (permissive) on the embedded proxy client. Defaults to on.
+const envProxyRosenpass = "NB_PROXY_ROSENPASS" //nolint:gosec // env var name, not a credential
+
+// envProxyClientLogLevel sets the embedded NetBird client's log level.
+const envProxyClientLogLevel = "NB_PROXY_CLIENT_LOG_LEVEL"
+
 const clientStopTimeout = 30 * time.Second
 
 const createProxyPeerTimeout = 30 * time.Second
@@ -353,11 +359,11 @@ func (n *NetBird) createClientEntry(ctx context.Context, accountID types.Account
 	// NB_PROXY_CLIENT_LOG_LEVEL (e.g. "trace") to surface the embedded NetBird
 	// client's relay / signal / handshake detail for local debugging.
 	clientLogLevel := log.WarnLevel.String()
-	if v := strings.TrimSpace(os.Getenv("NB_PROXY_CLIENT_LOG_LEVEL")); v != "" {
+	if v := strings.TrimSpace(os.Getenv(envProxyClientLogLevel)); v != "" {
 		if lvl, err := log.ParseLevel(v); err == nil {
 			clientLogLevel = lvl.String()
 		} else {
-			n.logger.Warnf("invalid NB_PROXY_CLIENT_LOG_LEVEL %q, using %q: %v", v, clientLogLevel, err)
+			n.logger.Warnf("invalid %s %q, using %q: %v", envProxyClientLogLevel, v, clientLogLevel, err)
 		}
 	}
 
@@ -367,15 +373,26 @@ func (n *NetBird) createClientEntry(ctx context.Context, accountID types.Account
 		}
 	})
 
+	// Rosenpass runs in permissive mode by default so the embedded proxy can
+	// establish connections with Rosenpass-enabled peers (which otherwise fail
+	// on a PSK mismatch) while still falling back to plain WireGuard for peers
+	// that do not run Rosenpass. Set NB_PROXY_ROSENPASS=false to disable it.
+	rosenpassEnabled := true
+	if v, ok := envBool(envProxyRosenpass, n.logger); ok {
+		rosenpassEnabled = v
+	}
+
 	// Create embedded NetBird client with the generated private key.
 	// The peer has already been created via CreateProxyPeer RPC with the public key.
 	wgPort := int(n.clientCfg.WGPort)
 	embedOpts := embed.Options{
-		DeviceName:    deviceNamePrefix + n.proxyID,
-		ManagementURL: n.clientCfg.MgmtAddr,
-		PrivateKey:    privateKey.String(),
-		LogLevel:      clientLogLevel,
-		BlockInbound:  n.clientCfg.BlockInbound,
+		DeviceName:          deviceNamePrefix + n.proxyID,
+		ManagementURL:       n.clientCfg.MgmtAddr,
+		PrivateKey:          privateKey.String(),
+		LogLevel:            clientLogLevel,
+		BlockInbound:        n.clientCfg.BlockInbound,
+		EnableRosenpass:     rosenpassEnabled,
+		RosenpassPermissive: rosenpassEnabled,
 		// The embedded proxy peer must never be a stepping stone into
 		// the proxy host's LAN: it only exists to reach NetBird mesh
 		// targets or, when direct_upstream is set, the host network
@@ -899,6 +916,8 @@ func logEmbedOptions(logger *log.Logger, accountID types.AccountID, serviceID ty
 		"mtu":                   mtu,
 		"block_inbound":         opts.BlockInbound,
 		"block_lan_access":      opts.BlockLANAccess,
+		"rosenpass_enabled":     opts.EnableRosenpass,
+		"rosenpass_permissive":  opts.RosenpassPermissive,
 		"disable_ipv6":          opts.DisableIPv6,
 		"disable_client_routes": opts.DisableClientRoutes,
 		"no_userspace":          opts.NoUserspace,
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/shared/management/client/grpc.go b/shared/management/client/grpc.go
index 388648707..408e2c3fc 100644
--- a/shared/management/client/grpc.go
+++ b/shared/management/client/grpc.go
@@ -21,8 +21,7 @@ import (
 	"google.golang.org/grpc/connectivity"
 
 	nbgrpc "github.com/netbirdio/netbird/client/grpc"
-	"github.com/netbirdio/netbird/client/netstate"
-	"github.com/netbirdio/netbird/client/netsweep"
+	"github.com/netbirdio/netbird/client/netevents"
 	"github.com/netbirdio/netbird/client/system"
 	"github.com/netbirdio/netbird/encryption"
 	"github.com/netbirdio/netbird/shared/management/domain"
@@ -64,12 +63,9 @@ type GrpcClient struct {
 	connStateCallbackLock sync.RWMutex
 	serverURL             string
 
-	// netState gates the stream retry loop on OS-reported network
-	// availability; nil (the default) disables gating.
-	netState *netstate.State
-
-	// sweeper cuts the transport connections on network change; nil disables it.
-	sweeper *netsweep.Sweeper
+	// netMgr gates the stream retry loop on OS-reported network
+	// availability and sweeps the transport on network change.
+	netMgr *netevents.Manager
 
 	// syncStreamErr holds the last Sync stream error, or nil while the stream
 	// is established and healthy. GetServerKey succeeds even when the peer
@@ -123,15 +119,9 @@ func MaxRecvMsgSize() int {
 // Option configures optional GrpcClient behavior.
 type Option func(*GrpcClient)
 
-// WithNetworkState injects the OS network availability state that gates the
-// stream retry loop; without it gating is disabled.
-func WithNetworkState(netState *netstate.State) Option {
-	return func(c *GrpcClient) { c.netState = netState }
-}
-
-// WithSweeper injects the network change sweeper.
-func WithSweeper(sweeper *netsweep.Sweeper) Option {
-	return func(c *GrpcClient) { c.sweeper = sweeper }
+// WithNetEvents injects the OS network event handling.
+func WithNetEvents(events *netevents.Manager) Option {
+	return func(c *GrpcClient) { c.netMgr = events }
 }
 
 // NewClient creates a new client to Management service
@@ -152,8 +142,8 @@ func NewClient(ctx context.Context, addr string, ourPrivateKey wgtypes.Key, tlsE
 		extraOpts = append(extraOpts, grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(maxSize)))
 		log.Infof("management gRPC max receive message size set to %d bytes", maxSize)
 	}
-	if c.sweeper != nil {
-		extraOpts = append(extraOpts, nbgrpc.WithSweeper(c.sweeper))
+	if c.netMgr != nil {
+		extraOpts = append(extraOpts, nbgrpc.WithSweeper(c.netMgr))
 	}
 
 	var conn *grpc.ClientConn
@@ -235,16 +225,19 @@ func (c *GrpcClient) withMgmtStream(
 	ctx context.Context,
 	handler func(ctx context.Context, serverPubKey wgtypes.Key, backOff backoff.BackOff) error,
 ) error {
-	backOff := c.sweeper.QuickRetryBackoff(ctx, defaultBackoff(ctx), c.netState)
+	backOff := c.netMgr.QuickRetryBackoff(ctx, defaultBackoff(ctx))
 	operation := func() error {
 		// suspend reconnect attempts while the OS reports no usable network.
 		// Wait only errors on a cancelled context, which means shutdown, so
 		// stop the loop without reporting a failure.
-		if waited, err := c.netState.Wait(ctx); err != nil {
+		if waited, err := c.netMgr.Wait(ctx); err != nil {
 			log.Debugf("management connection context has been canceled while offline, this usually indicates shutdown")
 			return nil //nolint:nilerr // a cancelled context means shutdown, not a retryable failure
 		} else if waited {
 			backOff.Reset()
+			// dials attempted while offline grew the channel's internal backoff;
+			// reset it too, or the reconnect waits out that timer first
+			c.conn.ResetConnectBackoff()
 		}
 
 		connState := c.conn.GetState()
@@ -273,7 +266,7 @@ func (c *GrpcClient) withMgmtStream(
 		return handler(ctx, *serverPubKey, backOff)
 	}
 
-	err := nbgrpc.Retry(ctx, operation, backOff, c.netState)
+	err := nbgrpc.Retry(ctx, operation, backOff, c.netMgr)
 	if err != nil {
 		log.Warnf("exiting the Management service connection retry loop due to the unrecoverable error: %s", 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 f7b4ca027..d4b6649f0 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,32 +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,
+		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:
@@ -295,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),
@@ -314,11 +394,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,
 	}
 }
@@ -326,15 +406,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
@@ -355,15 +439,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,
@@ -383,8 +467,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,
@@ -392,13 +476,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),
 			})
 		}
@@ -406,14 +490,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,
@@ -426,10 +510,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,
@@ -440,10 +524,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,
@@ -464,16 +548,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 fbf93b2be..b11bc43c7 100644
--- a/shared/management/networkmap/encode.go
+++ b/shared/management/networkmap/encode.go
@@ -18,10 +18,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"
@@ -29,7 +30,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))
@@ -38,7 +39,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),
@@ -275,8 +276,9 @@ func ToProtocolDNSConfig(update nbdns.Config, cache DNSConfigCache, forwardPort
 }
 
 // AppendRemotePeerConfig appends typed peers as proto.RemotePeerConfig
-// entries to dst and returns the result.
-func AppendRemotePeerConfig(dst []*proto.RemotePeerConfig, peers []*types.ComponentPeer, dnsName string, includeIPv6 bool) []*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 []*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() {
@@ -287,7 +289,8 @@ 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),
 		})
 	}
 	return dst
@@ -328,6 +331,18 @@ func BuildSessionPubKeysProto(ctx context.Context, in []types.VNCSessionPubKey)
 	return out
 }
 
+// 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 *nmdata.Peer) proto.LazyState {
+	if localIsProxy || rPeer.ProxyMeta.Embedded {
+		return proto.LazyState_LazyStateLazy
+	}
+	return proto.LazyState_LazyStateDefault
+}
+
 // BuildAuthorizedUsersProto deduplicates user-IDs into a hashed list and
 // builds per-machine-user index maps. Returns (hashedUsers, machineUsers).
 // Errors from individual hash failures are logged via the provided context;
diff --git a/shared/management/networkmap/envelope.go b/shared/management/networkmap/envelope.go
index d0b1e01d6..9d2293fb3 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)
+	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)
+	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 ae0df9759..4b624a7f8 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"},
@@ -94,13 +95,13 @@ func TestCalculate_FirewallRuleProtocol_NeverNetbirdSSH(t *testing.T) {
 func TestEnvelopeToNetworkMap_VNCPolicyProducesVncAuth(t *testing.T) {
 	c, localPeerKey := buildSmokeComponents(t)
 	c.GroupIDToUserIDs = map[string][]string{"1": {"user-1"}}
-	c.Policies = []*types.Policy{{
+	c.Policies = []*nmdata.Policy{{
 		ID: "pol-vnc", PublicID: "2", Enabled: true,
-		Rules: []*types.PolicyRule{{
+		Rules: []*nmdata.PolicyRule{{
 			ID:                 "rule-vnc",
 			Enabled:            true,
-			Action:             types.PolicyTrafficActionAccept,
-			Protocol:           types.PolicyRuleProtocolNetbirdVNC,
+			Action:             string(types.PolicyTrafficActionAccept),
+			Protocol:           string(types.PolicyRuleProtocolNetbirdVNC),
 			Bidirectional:      true,
 			Sources:            []string{"group-all"},
 			Destinations:       []string{"group-all"},
@@ -214,39 +215,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"},
 			}},
@@ -302,12 +303,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})},
 		},
 	})
@@ -362,33 +363,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"},
@@ -397,21 +398,21 @@ func buildSmokeComponents(t *testing.T) (*types.NetworkMapComponents, string) {
 
 	c := &types.NetworkMapComponents{
 		PeerID: "peer-A",
-		Network: &types.Network{
+		Network: &nmdata.Network{
 			Identifier: "net-smoke",
 			Net:        net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)},
 			Serial:     1,
 		},
-		AccountSettings: &types.AccountSettingsInfo{},
-		DNSSettings:     &types.DNSSettings{},
-		Peers: map[string]*types.ComponentPeer{
+		AccountSettings: &nmdata.AccountSettingsInfo{},
+		DNSSettings:     &nmdata.DNSSettings{},
+		Peers: map[string]*nmdata.Peer{
 			"peer-A": peerA,
 			"peer-B": peerB,
 		},
-		Groups: map[string]*types.ComponentGroup{
+		Groups: map[string]*nmdata.Group{
 			"group-all": group,
 		},
-		Policies: []*types.Policy{policy},
+		Policies: []*nmdata.Policy{policy},
 	}
 	return c, peerAKey
 }
diff --git a/shared/management/networkmap/networkmapcompute.go b/shared/management/networkmap/networkmapcompute.go
new file mode 100644
index 000000000..1cf7aeef4
--- /dev/null
+++ b/shared/management/networkmap/networkmapcompute.go
@@ -0,0 +1,812 @@
+package networkmap
+
+import (
+	"slices"
+
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+	"github.com/netbirdio/netbird/shared/management/types"
+)
+
+type sshRequirements struct {
+	neededGroupIDs     map[string]struct{}
+	needAllowedUserIDs bool
+}
+
+// GetPeerNetworkMapComponents computes the peer's NetworkMapComponents from the
+// slim twin store. It mirrors the former Account.GetPeerNetworkMapComponents
+// exactly, operating on nmdata twins throughout — no Account reference and no
+// twin↔real conversion, since the produced components hold twins.
+func (nmd *NetworkMapData) GetPeerNetworkMapComponents(peerID string, peersCustomZone nmdata.CustomZone) *types.NetworkMapComponents {
+	nmd.InjectProxyPolicies()
+
+	forceRoutingPeerDNS := nmd.forcesRoutingPeerDNSResolution(peerID)
+
+	peer := nmd.Peers[peerID]
+	if peer == nil {
+		return types.EmptyNetworkMapComponents(&types.NetworkMapComponents{
+			PeerID:                        peerID,
+			Network:                       nmd.Network,
+			Peers:                         map[string]*nmdata.Peer{peerID: peer},
+			ForceRoutingPeerDNSResolution: forceRoutingPeerDNS,
+		})
+	}
+
+	if _, ok := nmd.ValidatedPeers[peerID]; !ok {
+		return types.EmptyNetworkMapComponents(&types.NetworkMapComponents{
+			PeerID:                        peerID,
+			Network:                       nmd.Network,
+			Peers:                         map[string]*nmdata.Peer{peerID: peer},
+			ForceRoutingPeerDNSResolution: forceRoutingPeerDNS,
+		})
+	}
+
+	components := &types.NetworkMapComponents{
+		PeerID:                        peerID,
+		Network:                       nmd.Network,
+		AccountSettings:               nmd.AccountSettings,
+		DNSSettings:                   nmd.DNSSettings,
+		CustomZoneDomain:              peersCustomZone.Domain,
+		NameServerGroups:              make([]*nmdata.NameServerGroup, 0),
+		ResourcePoliciesMap:           make(map[string][]*nmdata.Policy),
+		RoutersMap:                    make(map[string]map[string]*nmdata.NetworkRouter),
+		NetworkResources:              make([]*nmdata.NetworkResource, 0),
+		PostureFailedPeers:            make(map[string]map[string]struct{}, len(nmd.PostureChecks)),
+		RouterPeers:                   make(map[string]*nmdata.Peer),
+		NetworkXIDToPublicID:          nmd.NetworkXIDToPublicID,
+		PostureCheckXIDToPublicID:     nmd.PostureCheckXIDToPublicID,
+		ForceRoutingPeerDNSResolution: forceRoutingPeerDNS,
+	}
+
+	relevantPeers, relevantGroups, relevantPolicies, relevantRoutes, sshReqs := nmd.getPeersGroupsPoliciesRoutes(peerID, peer.SSHEnabled, &components.PostureFailedPeers)
+
+	if len(sshReqs.neededGroupIDs) > 0 {
+		components.GroupIDToUserIDs = filterGroupIDToUserIDs(nmd.GroupIDToUserIDs, sshReqs.neededGroupIDs)
+	}
+	if sshReqs.needAllowedUserIDs {
+		components.AllowedUserIDs = nmd.getAllowedUserIDs()
+	}
+
+	components.Peers = relevantPeers
+	components.Groups = relevantGroups
+	components.Policies = relevantPolicies
+	components.Routes = relevantRoutes
+	components.AllDNSRecords = filterDNSRecordsByPeers(peersCustomZone.Records, relevantPeers, peer.SupportsIPv6() && peer.IPv6.IsValid())
+
+	peerGroups := nmd.GetPeerGroups(peerID)
+	components.AccountZones = nmd.appliedZones(peerGroups)
+	components.AccountZones = append(components.AccountZones, nmd.privateServiceZones(peerGroups)...)
+
+	for _, nsGroup := range nmd.NameServerGroups {
+		if nsGroup != nil && nsGroup.Enabled {
+			for _, gID := range nsGroup.Groups {
+				if _, found := relevantGroups[gID]; found {
+					components.NameServerGroups = append(components.NameServerGroups, nsGroup)
+					break
+				}
+			}
+		}
+	}
+
+	for _, resource := range nmd.NetworkResources {
+		if resource == nil || !resource.Enabled {
+			continue
+		}
+
+		policies, exists := nmd.ResourcePolicies[resource.ID]
+		if !exists {
+			continue
+		}
+
+		addSourcePeers := false
+
+		networkRoutingPeers, routerExists := nmd.Routers[resource.NetworkID]
+		if routerExists {
+			if _, ok := networkRoutingPeers[peerID]; ok {
+				addSourcePeers = true
+			}
+		}
+
+		for _, policy := range policies {
+			if policy == nil || !policy.Enabled || len(policy.Rules) == 0 || policy.Rules[0] == nil {
+				continue
+			}
+			if addSourcePeers {
+				var peers []string
+				if policy.Rules[0].SourceResource.Type == string(types.ResourceTypePeer) && policy.Rules[0].SourceResource.ID != "" {
+					peers = []string{policy.Rules[0].SourceResource.ID}
+				} else {
+					peers = nmd.getUniquePeerIDsFromGroupsIDs(policy.SourceGroups())
+				}
+				for _, pID := range nmd.getPostureValidPeersSaveFailed(peers, policy.SourcePostureChecks, &components.PostureFailedPeers) {
+					if _, exists := components.Peers[pID]; !exists {
+						components.Peers[pID] = nmd.Peers[pID]
+					}
+				}
+			} else {
+				peerInSources := false
+				if policy.Rules[0].SourceResource.Type == string(types.ResourceTypePeer) && policy.Rules[0].SourceResource.ID != "" {
+					peerInSources = policy.Rules[0].SourceResource.ID == peerID
+				} else {
+					for _, groupID := range policy.SourceGroups() {
+						if group := nmd.Groups[groupID]; group != nil && slices.Contains(group.Peers, peerID) {
+							peerInSources = true
+							break
+						}
+					}
+				}
+				if !peerInSources {
+					continue
+				}
+				isValid, pname := nmd.validatePostureChecksOnPeerGetFailed(policy.SourcePostureChecks, peerID)
+				if !isValid && len(pname) > 0 {
+					if _, ok := components.PostureFailedPeers[pname]; !ok {
+						components.PostureFailedPeers[pname] = make(map[string]struct{})
+					}
+					components.PostureFailedPeers[pname][peer.ID] = struct{}{}
+					continue
+				}
+				addSourcePeers = true
+			}
+
+			for _, rule := range policy.Rules {
+				if rule == nil || !rule.Enabled {
+					continue
+				}
+				for _, srcGroupID := range rule.Sources {
+					if g := nmd.Groups[srcGroupID]; g != nil {
+						if _, exists := components.Groups[srcGroupID]; !exists {
+							components.Groups[srcGroupID] = g
+						}
+					}
+				}
+				for _, dstGroupID := range rule.Destinations {
+					if g := nmd.Groups[dstGroupID]; g != nil {
+						if _, exists := components.Groups[dstGroupID]; !exists {
+							components.Groups[dstGroupID] = g
+						}
+					}
+				}
+			}
+			components.ResourcePoliciesMap[resource.ID] = policies
+		}
+
+		if addSourcePeers {
+			components.RoutersMap[resource.NetworkID] = networkRoutingPeers
+			for peerIDKey := range networkRoutingPeers {
+				p := nmd.Peers[peerIDKey]
+				if p == nil {
+					continue
+				}
+				// An unapproved peer must not carry traffic, so it is kept out of
+				// RouterPeers as well: the envelope encoder indexes that map into
+				// the wire peer table, from which the client restores every entry.
+				if _, validated := nmd.ValidatedPeers[peerIDKey]; !validated {
+					continue
+				}
+				if _, exists := components.RouterPeers[peerIDKey]; !exists {
+					components.RouterPeers[peerIDKey] = p
+				}
+				if _, exists := components.Peers[peerIDKey]; !exists {
+					components.Peers[peerIDKey] = p
+				}
+			}
+			components.NetworkResources = append(components.NetworkResources, resource)
+		}
+	}
+
+	filterGroupPeers(&components.Groups, components.Peers)
+	filterPostureFailedPeers(&components.PostureFailedPeers, components.Policies, components.ResourcePoliciesMap, components.Peers)
+
+	return components
+}
+
+func (nmd *NetworkMapData) getPeersGroupsPoliciesRoutes(
+	peerID string,
+	peerSSHEnabled bool,
+	postureFailedPeers *map[string]map[string]struct{},
+) (map[string]*nmdata.Peer, map[string]*nmdata.Group, []*nmdata.Policy, []*nmdata.Route, sshRequirements) {
+	relevantPeerIDs := make(map[string]*nmdata.Peer, len(nmd.Peers)/4)
+	relevantGroupIDs := make(map[string]*nmdata.Group, len(nmd.Groups)/4)
+	relevantPolicies := make([]*nmdata.Policy, 0, len(nmd.Policies))
+	relevantRoutes := make([]*nmdata.Route, 0, len(nmd.Routes))
+	sshReqs := sshRequirements{neededGroupIDs: make(map[string]struct{})}
+
+	relevantPeerIDs[peerID] = nmd.Peers[peerID]
+
+	peerGroupSet := nmd.GetPeerGroups(peerID)
+	for groupID := range peerGroupSet {
+		relevantGroupIDs[groupID] = nmd.Groups[groupID]
+	}
+
+	routeAccessControlGroups := make(map[string]struct{})
+	for _, r := range nmd.Routes {
+		if r == nil {
+			continue
+		}
+		relevant := r.Peer == peerID
+		if !relevant {
+			for _, groupID := range r.PeerGroups {
+				if _, ok := peerGroupSet[groupID]; ok {
+					relevant = true
+					break
+				}
+			}
+		}
+		if !relevant && r.Enabled {
+			for _, groupID := range r.Groups {
+				if _, ok := peerGroupSet[groupID]; ok {
+					relevant = true
+					break
+				}
+			}
+		}
+		if !relevant {
+			continue
+		}
+
+		for _, groupID := range r.PeerGroups {
+			if g := nmd.Groups[groupID]; g != nil {
+				relevantGroupIDs[groupID] = g
+			}
+		}
+		for _, groupID := range r.Groups {
+			if g := nmd.Groups[groupID]; g != nil {
+				relevantGroupIDs[groupID] = g
+			}
+		}
+		if r.Enabled {
+			for _, groupID := range r.AccessControlGroups {
+				if g := nmd.Groups[groupID]; g != nil {
+					relevantGroupIDs[groupID] = g
+				}
+				routeAccessControlGroups[groupID] = struct{}{}
+			}
+		}
+
+		if r.Peer != "" {
+			if _, ok := nmd.ValidatedPeers[r.Peer]; ok {
+				if p := nmd.Peers[r.Peer]; p != nil {
+					relevantPeerIDs[r.Peer] = p
+				}
+			}
+		}
+		for _, groupID := range r.PeerGroups {
+			g := nmd.Groups[groupID]
+			if g == nil {
+				continue
+			}
+			for _, pid := range g.Peers {
+				if _, exists := relevantPeerIDs[pid]; exists {
+					continue
+				}
+				if _, ok := nmd.ValidatedPeers[pid]; !ok {
+					continue
+				}
+				if p := nmd.Peers[pid]; p != nil {
+					relevantPeerIDs[pid] = p
+				}
+			}
+		}
+		relevantRoutes = append(relevantRoutes, r)
+	}
+
+	for _, policy := range nmd.Policies {
+		if policy == nil || !policy.Enabled {
+			continue
+		}
+
+		policyRelevant := false
+		for _, rule := range policy.Rules {
+			if rule == nil || !rule.Enabled {
+				continue
+			}
+
+			if len(routeAccessControlGroups) > 0 {
+				for _, destGroupID := range rule.Destinations {
+					if _, needed := routeAccessControlGroups[destGroupID]; needed {
+						policyRelevant = true
+						for _, srcGroupID := range rule.Sources {
+							if g := nmd.Groups[srcGroupID]; g != nil {
+								relevantGroupIDs[srcGroupID] = g
+							}
+						}
+						for _, dstGroupID := range rule.Destinations {
+							if g := nmd.Groups[dstGroupID]; g != nil {
+								relevantGroupIDs[dstGroupID] = g
+							}
+						}
+						break
+					}
+				}
+			}
+
+			var sourcePeers, destinationPeers []string
+			var peerInSources, peerInDestinations bool
+
+			if rule.SourceResource.Type == string(types.ResourceTypePeer) && rule.SourceResource.ID != "" {
+				sourcePeers = []string{rule.SourceResource.ID}
+				if rule.SourceResource.ID == peerID {
+					peerInSources = true
+				}
+			} else {
+				sourcePeers, peerInSources = nmd.getPeersFromGroups(rule.Sources, peerID, policy.SourcePostureChecks, postureFailedPeers)
+			}
+
+			if rule.DestinationResource.Type == string(types.ResourceTypePeer) && rule.DestinationResource.ID != "" {
+				destinationPeers = []string{rule.DestinationResource.ID}
+				if rule.DestinationResource.ID == peerID {
+					peerInDestinations = true
+				}
+			} else {
+				destinationPeers, peerInDestinations = nmd.getPeersFromGroups(rule.Destinations, peerID, nil, postureFailedPeers)
+			}
+
+			if peerInSources {
+				policyRelevant = true
+				for _, pid := range destinationPeers {
+					relevantPeerIDs[pid] = nmd.Peers[pid]
+				}
+				for _, dstGroupID := range rule.Destinations {
+					if g := nmd.Groups[dstGroupID]; g != nil {
+						relevantGroupIDs[dstGroupID] = g
+					}
+				}
+			}
+
+			if peerInDestinations {
+				policyRelevant = true
+				for _, pid := range sourcePeers {
+					relevantPeerIDs[pid] = nmd.Peers[pid]
+				}
+				for _, srcGroupID := range rule.Sources {
+					if g := nmd.Groups[srcGroupID]; g != nil {
+						relevantGroupIDs[srcGroupID] = g
+					}
+				}
+
+				if rule.Protocol == string(types.PolicyRuleProtocolNetbirdSSH) {
+					switch {
+					case len(rule.AuthorizedGroups) > 0:
+						for groupID := range rule.AuthorizedGroups {
+							sshReqs.neededGroupIDs[groupID] = struct{}{}
+						}
+					case rule.AuthorizedUser != "":
+					default:
+						sshReqs.needAllowedUserIDs = true
+					}
+				} else if nmdata.PolicyRuleImpliesLegacySSH(rule) && peerSSHEnabled {
+					sshReqs.needAllowedUserIDs = true
+				}
+			}
+		}
+		if policyRelevant {
+			relevantPolicies = append(relevantPolicies, policy)
+		}
+	}
+
+	return relevantPeerIDs, relevantGroupIDs, relevantPolicies, relevantRoutes, sshReqs
+}
+
+func (nmd *NetworkMapData) getPeersFromGroups(groups []string, peerID string, sourcePostureChecksIDs []string,
+	postureFailedPeers *map[string]map[string]struct{}) ([]string, bool) {
+	peerInGroups := false
+	filteredPeerIDs := make([]string, 0, len(groups))
+	seenPeerIds := make(map[string]struct{}, len(groups))
+
+	for _, gid := range groups {
+		group := nmd.Groups[gid]
+		if group == nil {
+			continue
+		}
+
+		if group.IsGroupAll() || len(groups) == 1 {
+			filteredPeerIDs = make([]string, 0, len(group.Peers))
+			peerInGroups = false
+			for _, pid := range group.Peers {
+				peer, ok := nmd.Peers[pid]
+				if !ok || peer == nil {
+					continue
+				}
+
+				if _, ok := nmd.ValidatedPeers[peer.ID]; !ok {
+					continue
+				}
+
+				isValid, pname := nmd.validatePostureChecksOnPeerGetFailed(sourcePostureChecksIDs, peer.ID)
+				if !isValid && len(pname) > 0 {
+					if _, ok := (*postureFailedPeers)[pname]; !ok {
+						(*postureFailedPeers)[pname] = make(map[string]struct{})
+					}
+					(*postureFailedPeers)[pname][peer.ID] = struct{}{}
+					continue
+				}
+
+				if peer.ID == peerID {
+					peerInGroups = true
+					continue
+				}
+
+				filteredPeerIDs = append(filteredPeerIDs, peer.ID)
+			}
+			return filteredPeerIDs, peerInGroups
+		}
+
+		for _, pid := range group.Peers {
+			if _, seen := seenPeerIds[pid]; seen {
+				continue
+			}
+			seenPeerIds[pid] = struct{}{}
+			peer, ok := nmd.Peers[pid]
+			if !ok || peer == nil {
+				continue
+			}
+
+			if _, ok := nmd.ValidatedPeers[peer.ID]; !ok {
+				continue
+			}
+
+			isValid, pname := nmd.validatePostureChecksOnPeerGetFailed(sourcePostureChecksIDs, peer.ID)
+			if !isValid && len(pname) > 0 {
+				if _, ok := (*postureFailedPeers)[pname]; !ok {
+					(*postureFailedPeers)[pname] = make(map[string]struct{})
+				}
+				(*postureFailedPeers)[pname][peer.ID] = struct{}{}
+				continue
+			}
+
+			if peer.ID == peerID {
+				peerInGroups = true
+				continue
+			}
+
+			filteredPeerIDs = append(filteredPeerIDs, peer.ID)
+		}
+	}
+
+	return filteredPeerIDs, peerInGroups
+}
+
+func (nmd *NetworkMapData) validatePostureChecksOnPeerGetFailed(sourcePostureChecksID []string, peerID string) (bool, string) {
+	peer, ok := nmd.Peers[peerID]
+	if !ok || peer == nil {
+		return false, ""
+	}
+
+	for _, postureChecksID := range sourcePostureChecksID {
+		if valid, cached := nmd.cachedPostureCheckResult(postureChecksID, peerID); cached {
+			if !valid {
+				return false, postureChecksID
+			}
+			continue
+		}
+
+		postureChecks := nmd.PostureChecks[postureChecksID]
+		if postureChecks == nil {
+			continue
+		}
+		if !postureChecks.Passes(peer) {
+			return false, postureChecksID
+		}
+	}
+	return true, ""
+}
+
+func (nmd *NetworkMapData) PrecomputePostureValidation() {
+	if len(nmd.PostureChecks) == 0 {
+		nmd.PostureValidation = nil
+		return
+	}
+
+	checkPeerIDs := make(map[string]map[string]struct{})
+	for _, policy := range nmd.Policies {
+		if policy == nil || !policy.Enabled || len(policy.SourcePostureChecks) == 0 {
+			continue
+		}
+
+		groupPeerIDs := nmd.getUniquePeerIDsFromGroupsIDs(policy.SourceGroups())
+		for _, postureChecksID := range policy.SourcePostureChecks {
+			set := checkPeerIDs[postureChecksID]
+			if set == nil {
+				set = make(map[string]struct{}, len(groupPeerIDs))
+				checkPeerIDs[postureChecksID] = set
+			}
+			for _, pid := range groupPeerIDs {
+				set[pid] = struct{}{}
+			}
+			for _, rule := range policy.Rules {
+				if rule == nil {
+					continue
+				}
+				if rule.SourceResource.Type == string(types.ResourceTypePeer) && rule.SourceResource.ID != "" {
+					set[rule.SourceResource.ID] = struct{}{}
+				}
+			}
+		}
+	}
+
+	results := make(map[string]map[string]bool, len(checkPeerIDs))
+	for postureChecksID, peerIDs := range checkPeerIDs {
+		results[postureChecksID] = nmd.evaluatePostureChecksForPeers(postureChecksID, peerIDs)
+	}
+	nmd.PostureValidation = results
+}
+
+func (nmd *NetworkMapData) evaluatePostureChecksForPeers(postureChecksID string, peerIDs map[string]struct{}) map[string]bool {
+	postureChecks := nmd.PostureChecks[postureChecksID]
+	if postureChecks == nil {
+		return nil
+	}
+
+	checks := postureChecks.GetChecks()
+	results := make(map[string]bool, len(peerIDs))
+	for peerID := range peerIDs {
+		peer := nmd.Peers[peerID]
+		if peer == nil {
+			continue
+		}
+		results[peerID] = nmdata.PassesChecks(checks, peer)
+	}
+	return results
+}
+
+func (nmd *NetworkMapData) cachedPostureCheckResult(postureChecksID, peerID string) (bool, bool) {
+	results, ok := nmd.PostureValidation[postureChecksID]
+	if !ok {
+		return false, false
+	}
+	if results == nil {
+		return true, true
+	}
+	valid, found := results[peerID]
+	return valid, found
+}
+
+func (nmd *NetworkMapData) getPostureValidPeersSaveFailed(inputPeers []string, postureChecksIDs []string, postureFailedPeers *map[string]map[string]struct{}) []string {
+	var dest []string
+	for _, peerID := range inputPeers {
+		if _, validated := nmd.ValidatedPeers[peerID]; !validated {
+			continue
+		}
+		valid, pname := nmd.validatePostureChecksOnPeerGetFailed(postureChecksIDs, peerID)
+		if valid {
+			dest = append(dest, peerID)
+			continue
+		}
+		if pname == "" {
+			continue
+		}
+		if _, ok := (*postureFailedPeers)[pname]; !ok {
+			(*postureFailedPeers)[pname] = make(map[string]struct{})
+		}
+		(*postureFailedPeers)[pname][peerID] = struct{}{}
+	}
+	return dest
+}
+
+// forcesRoutingPeerDNSResolution reports whether the given peer must run
+// routing-peer DNS resolution regardless of the account-global
+// RoutingPeerDNSResolutionEnabled setting: true when the peer routes a domain
+// network resource targeted by an enabled reverse-proxy service, so the peer's
+// DNS forwarder starts and can resolve the target for the embedded proxy peers.
+func (nmd *NetworkMapData) forcesRoutingPeerDNSResolution(peerID string) bool {
+	if len(nmd.ProxyTargetedDomainResourceIDs) == 0 {
+		return false
+	}
+
+	for _, resource := range nmd.NetworkResources {
+		if resource == nil || !resource.Enabled || resource.Type != string(types.ResourceTypeDomain) {
+			continue
+		}
+		if _, ok := nmd.ProxyTargetedDomainResourceIDs[resource.ID]; !ok {
+			continue
+		}
+		if _, isRouter := nmd.Routers[resource.NetworkID][peerID]; isRouter {
+			return true
+		}
+	}
+
+	return false
+}
+
+// GetPeerGroups returns the set of group IDs the peer belongs to. The
+// underlying peer→groups index is built once per NetworkMapData and the
+// returned set is shared — callers must not mutate it.
+func (nmd *NetworkMapData) GetPeerGroups(peerID string) map[string]struct{} {
+	nmd.peerGroupsOnce.Do(func() {
+		idx := make(map[string]map[string]struct{}, len(nmd.Peers))
+		for groupID, group := range nmd.Groups {
+			if group == nil {
+				continue
+			}
+			for _, pid := range group.Peers {
+				set, ok := idx[pid]
+				if !ok {
+					set = make(map[string]struct{})
+					idx[pid] = set
+				}
+				set[groupID] = struct{}{}
+			}
+		}
+		nmd.peerGroupsIdx = idx
+	})
+
+	if set, ok := nmd.peerGroupsIdx[peerID]; ok {
+		return set
+	}
+	return map[string]struct{}{}
+}
+
+func (nmd *NetworkMapData) getUniquePeerIDsFromGroupsIDs(groups []string) []string {
+	peerIDs := make(map[string]struct{}, len(groups))
+	for _, groupID := range groups {
+		group := nmd.Groups[groupID]
+		if group == nil {
+			continue
+		}
+
+		if group.IsGroupAll() || len(groups) == 1 {
+			return group.Peers
+		}
+
+		for _, peerID := range group.Peers {
+			peerIDs[peerID] = struct{}{}
+		}
+	}
+
+	ids := make([]string, 0, len(peerIDs))
+	for peerID := range peerIDs {
+		ids = append(ids, peerID)
+	}
+
+	return ids
+}
+
+func (nmd *NetworkMapData) getAllowedUserIDs() map[string]struct{} {
+	return nmd.AllowedUserIDs
+}
+
+func (nmd *NetworkMapData) appliedZones(peerGroups map[string]struct{}) []nmdata.CustomZone {
+	if len(peerGroups) == 0 {
+		return nil
+	}
+	var out []nmdata.CustomZone
+	for _, cand := range nmd.AppliedZoneCandidates {
+		if peerInDistributionGroups(peerGroups, cand.DistributionGroups) {
+			out = append(out, cand.Zone)
+		}
+	}
+	return out
+}
+
+func (nmd *NetworkMapData) privateServiceZones(peerGroups map[string]struct{}) []nmdata.CustomZone {
+	byApex := make(map[string]*nmdata.CustomZone)
+	var order []string
+	for _, cand := range nmd.PrivateServiceCandidates {
+		if !peerInDistributionGroups(peerGroups, cand.AccessGroups) {
+			continue
+		}
+		zone, exists := byApex[cand.Zone.Domain]
+		if !exists {
+			nz := nmdata.CustomZone{
+				Domain:               cand.Zone.Domain,
+				SearchDomainDisabled: cand.Zone.SearchDomainDisabled,
+				NonAuthoritative:     cand.Zone.NonAuthoritative,
+			}
+			byApex[cand.Zone.Domain] = &nz
+			zone = &nz
+			order = append(order, cand.Zone.Domain)
+		}
+		zone.Records = append(zone.Records, cand.Zone.Records...)
+	}
+
+	var out []nmdata.CustomZone
+	for _, apex := range order {
+		zone := byApex[apex]
+		if len(zone.Records) == 0 {
+			continue
+		}
+		out = append(out, *zone)
+	}
+	return out
+}
+
+func peerInDistributionGroups(peerGroups map[string]struct{}, groups []string) bool {
+	for _, g := range groups {
+		if _, ok := peerGroups[g]; ok {
+			return true
+		}
+	}
+	return false
+}
+
+func filterGroupPeers(groups *map[string]*nmdata.Group, peers map[string]*nmdata.Peer) {
+	for groupID, groupInfo := range *groups {
+		filteredPeers := make([]string, 0, len(groupInfo.Peers))
+		for _, pid := range groupInfo.Peers {
+			if _, exists := peers[pid]; exists {
+				filteredPeers = append(filteredPeers, pid)
+			}
+		}
+
+		if len(filteredPeers) != len(groupInfo.Peers) {
+			ng := groupInfo.Copy()
+			ng.Peers = filteredPeers
+			(*groups)[groupID] = ng
+		}
+	}
+}
+
+func filterPostureFailedPeers(postureFailedPeers *map[string]map[string]struct{}, policies []*nmdata.Policy, resourcePoliciesMap map[string][]*nmdata.Policy, peers map[string]*nmdata.Peer) {
+	if len(*postureFailedPeers) == 0 {
+		return
+	}
+
+	referencedPostureChecks := make(map[string]struct{})
+	for _, policy := range policies {
+		for _, checkID := range policy.SourcePostureChecks {
+			referencedPostureChecks[checkID] = struct{}{}
+		}
+	}
+	for _, resPolicies := range resourcePoliciesMap {
+		for _, policy := range resPolicies {
+			for _, checkID := range policy.SourcePostureChecks {
+				referencedPostureChecks[checkID] = struct{}{}
+			}
+		}
+	}
+
+	for checkID, failedPeers := range *postureFailedPeers {
+		if _, referenced := referencedPostureChecks[checkID]; !referenced {
+			delete(*postureFailedPeers, checkID)
+			continue
+		}
+		for peerID := range failedPeers {
+			if _, exists := peers[peerID]; !exists {
+				delete(failedPeers, peerID)
+			}
+		}
+		if len(failedPeers) == 0 {
+			delete(*postureFailedPeers, checkID)
+		}
+	}
+}
+
+func filterDNSRecordsByPeers(records []nmdata.SimpleRecord, peers map[string]*nmdata.Peer, includeIPv6 bool) []nmdata.SimpleRecord {
+	if len(records) == 0 || len(peers) == 0 {
+		return nil
+	}
+
+	peerIPs := make(map[string]struct{}, len(peers)*2)
+	for _, peer := range peers {
+		if peer == nil {
+			continue
+		}
+		peerIPs[peer.IP.String()] = struct{}{}
+		if includeIPv6 && peer.IPv6.IsValid() {
+			peerIPs[peer.IPv6.String()] = struct{}{}
+		}
+	}
+
+	filteredRecords := make([]nmdata.SimpleRecord, 0, len(records))
+	for _, record := range records {
+		if _, exists := peerIPs[record.RData]; exists {
+			filteredRecords = append(filteredRecords, record)
+		}
+	}
+
+	return filteredRecords
+}
+
+func filterGroupIDToUserIDs(fullMap map[string][]string, neededGroupIDs map[string]struct{}) map[string][]string {
+	if len(neededGroupIDs) == 0 {
+		return nil
+	}
+
+	filtered := make(map[string][]string, len(neededGroupIDs))
+	for groupID := range neededGroupIDs {
+		if users, ok := fullMap[groupID]; ok {
+			filtered[groupID] = users
+		}
+	}
+	return filtered
+}
diff --git a/shared/management/networkmap/networkmapcompute_test.go b/shared/management/networkmap/networkmapcompute_test.go
new file mode 100644
index 000000000..8c9add8c1
--- /dev/null
+++ b/shared/management/networkmap/networkmapcompute_test.go
@@ -0,0 +1,1610 @@
+package networkmap_test
+
+import (
+	"context"
+	"fmt"
+	"net/netip"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+	nbtypes "github.com/netbirdio/netbird/shared/management/types"
+)
+
+const (
+	targetID          = "peer-target"
+	postureMinVersion = "0.30.0"
+	passingVersion    = "1.0.0"
+	failingVersion    = "0.1.0"
+)
+
+func newPeer(id string, hostNum byte) *nmdata.Peer {
+	return &nmdata.Peer{
+		ID:       id,
+		Key:      "key-" + id,
+		IP:       netip.AddrFrom4([4]byte{100, 64, 0, hostNum}),
+		DNSLabel: id,
+		Meta:     nmdata.PeerSystemMeta{WtVersion: passingVersion},
+	}
+}
+
+func newNMD(peers ...*nmdata.Peer) *networkmap.NetworkMapData {
+	nmd := &networkmap.NetworkMapData{
+		Peers:           make(map[string]*nmdata.Peer),
+		Groups:          make(map[string]*nmdata.Group),
+		ValidatedPeers:  make(map[string]struct{}),
+		Network:         &nmdata.Network{Identifier: "network-1", Serial: 7},
+		AccountSettings: &nmdata.AccountSettingsInfo{},
+		DNSSettings:     &nmdata.DNSSettings{},
+	}
+	for _, p := range peers {
+		nmd.Peers[p.ID] = p
+		nmd.ValidatedPeers[p.ID] = struct{}{}
+	}
+	return nmd
+}
+
+func addGroup(nmd *networkmap.NetworkMapData, id string, peerIDs ...string) *nmdata.Group {
+	g := &nmdata.Group{ID: id, Name: id, Peers: peerIDs}
+	nmd.Groups[id] = g
+	return g
+}
+
+func newRule(sources, destinations []string) *nmdata.PolicyRule {
+	return &nmdata.PolicyRule{
+		Enabled:       true,
+		Action:        string(nbtypes.PolicyTrafficActionAccept),
+		Protocol:      string(nbtypes.PolicyRuleProtocolTCP),
+		Bidirectional: true,
+		Sources:       sources,
+		Destinations:  destinations,
+	}
+}
+
+func newPolicy(id string, rules ...*nmdata.PolicyRule) *nmdata.Policy {
+	for i, r := range rules {
+		if r.ID == "" {
+			r.ID = fmt.Sprintf("%s-rule-%d", id, i)
+		}
+		r.PolicyID = id
+	}
+	return &nmdata.Policy{ID: id, Enabled: true, Rules: rules}
+}
+
+func addVersionCheck(nmd *networkmap.NetworkMapData, id, minVersion string) {
+	if nmd.PostureChecks == nil {
+		nmd.PostureChecks = make(map[string]*nmdata.PostureChecks)
+	}
+	nmd.PostureChecks[id] = &nmdata.PostureChecks{
+		ID:     id,
+		Checks: nmdata.ChecksDefinition{NBVersionCheck: &nmdata.NBVersionCheck{MinVersion: minVersion}},
+	}
+}
+
+func compute(nmd *networkmap.NetworkMapData, peerID string) *nbtypes.NetworkMapComponents {
+	return nmd.GetPeerNetworkMapComponents(peerID, nmdata.CustomZone{})
+}
+
+func peerIDSet(peers map[string]*nmdata.Peer) []string {
+	ids := make([]string, 0, len(peers))
+	for id := range peers {
+		ids = append(ids, id)
+	}
+	return ids
+}
+
+func policyIDs(policies []*nmdata.Policy) []string {
+	ids := make([]string, 0, len(policies))
+	for _, p := range policies {
+		ids = append(ids, p.ID)
+	}
+	return ids
+}
+
+func groupIDSet(groups map[string]*nmdata.Group) []string {
+	ids := make([]string, 0, len(groups))
+	for id := range groups {
+		ids = append(ids, id)
+	}
+	return ids
+}
+
+func TestGetPeerNetworkMapComponents_UnknownPeer(t *testing.T) {
+	nmd := newNMD(newPeer("peer-a", 2))
+
+	c := compute(nmd, "missing")
+
+	require.True(t, c.IsEmpty())
+	assert.Equal(t, "missing", c.PeerID)
+	assert.Same(t, nmd.Network, c.Network)
+	require.Contains(t, c.Peers, "missing")
+	assert.Nil(t, c.Peers["missing"])
+	assert.Len(t, c.Peers, 1)
+	assert.Nil(t, c.AccountSettings)
+	assert.Nil(t, c.Policies)
+	assert.False(t, c.ForceRoutingPeerDNSResolution)
+}
+
+func TestGetPeerNetworkMapComponents_UnvalidatedPeer(t *testing.T) {
+	target := newPeer(targetID, 1)
+	nmd := newNMD(target)
+	delete(nmd.ValidatedPeers, targetID)
+
+	c := compute(nmd, targetID)
+
+	require.True(t, c.IsEmpty())
+	assert.Equal(t, targetID, c.PeerID)
+	assert.Same(t, target, c.Peers[targetID])
+	assert.Len(t, c.Peers, 1)
+	assert.Nil(t, c.AccountSettings)
+	assert.Nil(t, c.Groups)
+}
+
+// The forced-DNS flag must be computed even on the empty-components early
+// exits, so an unknown or unvalidated proxy routing peer still starts its DNS
+// forwarder.
+func TestGetPeerNetworkMapComponents_EmptyComponentsKeepForcedDNSResolution(t *testing.T) {
+	build := func() *networkmap.NetworkMapData {
+		nmd := newNMD(newPeer("unval-router", 1))
+		delete(nmd.ValidatedPeers, "unval-router")
+		nmd.NetworkResources = []*nmdata.NetworkResource{
+			{ID: "res-1", NetworkID: "net-1", Type: string(nbtypes.ResourceTypeDomain), Enabled: true},
+		}
+		nmd.ProxyTargetedDomainResourceIDs = map[string]struct{}{"res-1": {}}
+		nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {"ghost-router": {}, "unval-router": {}}}
+		return nmd
+	}
+
+	t.Run("unknown peer", func(t *testing.T) {
+		c := compute(build(), "ghost-router")
+		require.True(t, c.IsEmpty())
+		assert.True(t, c.ForceRoutingPeerDNSResolution)
+	})
+
+	t.Run("unvalidated peer", func(t *testing.T) {
+		c := compute(build(), "unval-router")
+		require.True(t, c.IsEmpty())
+		assert.True(t, c.ForceRoutingPeerDNSResolution)
+	})
+}
+
+func TestGetPeerNetworkMapComponents_ForceRoutingPeerDNSResolution(t *testing.T) {
+	forced := func(mutate func(*networkmap.NetworkMapData)) bool {
+		nmd := newNMD(newPeer(targetID, 1))
+		nmd.NetworkResources = []*nmdata.NetworkResource{
+			{ID: "res-1", NetworkID: "net-1", Type: string(nbtypes.ResourceTypeDomain), Enabled: true},
+		}
+		nmd.ProxyTargetedDomainResourceIDs = map[string]struct{}{"res-1": {}}
+		nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {targetID: {}}}
+		if mutate != nil {
+			mutate(nmd)
+		}
+		return compute(nmd, targetID).ForceRoutingPeerDNSResolution
+	}
+
+	t.Run("router of targeted domain resource is forced", func(t *testing.T) {
+		assert.True(t, forced(nil))
+	})
+	t.Run("no proxy-targeted resources", func(t *testing.T) {
+		assert.False(t, forced(func(nmd *networkmap.NetworkMapData) {
+			nmd.ProxyTargetedDomainResourceIDs = nil
+		}))
+	})
+	t.Run("resource disabled", func(t *testing.T) {
+		assert.False(t, forced(func(nmd *networkmap.NetworkMapData) {
+			nmd.NetworkResources[0].Enabled = false
+		}))
+	})
+	t.Run("resource not a domain", func(t *testing.T) {
+		assert.False(t, forced(func(nmd *networkmap.NetworkMapData) {
+			nmd.NetworkResources[0].Type = string(nbtypes.ResourceTypeHost)
+		}))
+	})
+	t.Run("resource not targeted", func(t *testing.T) {
+		assert.False(t, forced(func(nmd *networkmap.NetworkMapData) {
+			nmd.ProxyTargetedDomainResourceIDs = map[string]struct{}{"res-other": {}}
+		}))
+	})
+	t.Run("peer not a router of the resource network", func(t *testing.T) {
+		assert.False(t, forced(func(nmd *networkmap.NetworkMapData) {
+			nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {"someone-else": {}}}
+		}))
+	})
+	t.Run("nil resource entry tolerated", func(t *testing.T) {
+		assert.True(t, forced(func(nmd *networkmap.NetworkMapData) {
+			nmd.NetworkResources = append([]*nmdata.NetworkResource{nil}, nmd.NetworkResources...)
+		}))
+	})
+}
+
+func TestGetPeerNetworkMapComponents_CoreFieldsPassThrough(t *testing.T) {
+	target := newPeer(targetID, 1)
+	nmd := newNMD(target)
+	nmd.NetworkXIDToPublicID = map[string]string{"net-xid": "net-pub"}
+	nmd.PostureCheckXIDToPublicID = map[string]string{"pc-xid": "pc-pub"}
+
+	c := nmd.GetPeerNetworkMapComponents(targetID, nmdata.CustomZone{Domain: "acme.netbird.cloud."})
+
+	require.False(t, c.IsEmpty())
+	assert.Equal(t, targetID, c.PeerID)
+	assert.Same(t, nmd.Network, c.Network)
+	assert.Same(t, nmd.AccountSettings, c.AccountSettings)
+	assert.Same(t, nmd.DNSSettings, c.DNSSettings)
+	assert.Equal(t, "acme.netbird.cloud.", c.CustomZoneDomain)
+	assert.Equal(t, nmd.NetworkXIDToPublicID, c.NetworkXIDToPublicID)
+	assert.Equal(t, nmd.PostureCheckXIDToPublicID, c.PostureCheckXIDToPublicID)
+
+	assert.Equal(t, map[string]*nmdata.Peer{targetID: target}, c.Peers)
+	assert.Empty(t, c.Groups)
+	assert.Empty(t, c.Policies)
+	assert.Empty(t, c.Routes)
+	assert.Empty(t, c.NameServerGroups)
+	assert.Empty(t, c.NetworkResources)
+	assert.Empty(t, c.ResourcePoliciesMap)
+	assert.Empty(t, c.RoutersMap)
+	assert.Empty(t, c.RouterPeers)
+	assert.Empty(t, c.PostureFailedPeers)
+	assert.Nil(t, c.AllDNSRecords)
+	assert.Empty(t, c.AccountZones)
+	assert.Nil(t, c.GroupIDToUserIDs)
+	assert.Nil(t, c.AllowedUserIDs)
+	assert.False(t, c.ForceRoutingPeerDNSResolution)
+}
+
+func TestGetPeerNetworkMapComponents_OwnGroupsTrimmedWithoutMutatingStore(t *testing.T) {
+	target := newPeer(targetID, 1)
+	bystander := newPeer("peer-bystander", 2)
+	nmd := newNMD(target, bystander)
+	stored := addGroup(nmd, "g-mixed", targetID, bystander.ID)
+
+	c := compute(nmd, targetID)
+
+	require.Contains(t, c.Groups, "g-mixed")
+	assert.Equal(t, []string{targetID}, c.Groups["g-mixed"].Peers)
+	assert.NotSame(t, stored, c.Groups["g-mixed"])
+	assert.Equal(t, []string{targetID, bystander.ID}, stored.Peers)
+}
+
+func TestGetPeerNetworkMapComponents_PolicyRelevance(t *testing.T) {
+	t.Run("peer in sources pulls destination peers and groups", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		srcSibling := newPeer("peer-src-sibling", 2)
+		dst := newPeer("peer-dst", 3)
+		nmd := newNMD(target, srcSibling, dst)
+		addGroup(nmd, "g-src", targetID, srcSibling.ID)
+		addGroup(nmd, "g-dst", dst.ID)
+		p := newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"}))
+		nmd.Policies = []*nmdata.Policy{p}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies))
+		assert.ElementsMatch(t, []string{targetID, dst.ID}, peerIDSet(c.Peers),
+			"source-side siblings must not be connected")
+		assert.ElementsMatch(t, []string{"g-src", "g-dst"}, groupIDSet(c.Groups))
+		assert.Equal(t, []string{targetID}, c.Groups["g-src"].Peers)
+		assert.Equal(t, []string{dst.ID}, c.Groups["g-dst"].Peers)
+	})
+
+	t.Run("peer in destinations pulls source peers and groups", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		src := newPeer("peer-src", 2)
+		nmd := newNMD(target, src)
+		addGroup(nmd, "g-src", src.ID)
+		addGroup(nmd, "g-dst", targetID)
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"}))}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies))
+		assert.ElementsMatch(t, []string{targetID, src.ID}, peerIDSet(c.Peers))
+		assert.ElementsMatch(t, []string{"g-src", "g-dst"}, groupIDSet(c.Groups))
+	})
+
+	t.Run("unrelated policy contributes nothing", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		a := newPeer("peer-a", 2)
+		b := newPeer("peer-b", 3)
+		nmd := newNMD(target, a, b)
+		addGroup(nmd, "g-own", targetID)
+		addGroup(nmd, "g-a", a.ID)
+		addGroup(nmd, "g-b", b.ID)
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-1", newRule([]string{"g-a"}, []string{"g-b"}))}
+
+		c := compute(nmd, targetID)
+
+		assert.Empty(t, c.Policies)
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+		assert.ElementsMatch(t, []string{"g-own"}, groupIDSet(c.Groups))
+	})
+
+	t.Run("disabled policy ignored", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		src := newPeer("peer-src", 2)
+		nmd := newNMD(target, src)
+		addGroup(nmd, "g-src", src.ID)
+		addGroup(nmd, "g-dst", targetID)
+		p := newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"}))
+		p.Enabled = false
+		nmd.Policies = []*nmdata.Policy{p}
+
+		c := compute(nmd, targetID)
+
+		assert.Empty(t, c.Policies)
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+	})
+
+	t.Run("disabled rule ignored", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		src := newPeer("peer-src", 2)
+		nmd := newNMD(target, src)
+		addGroup(nmd, "g-src", src.ID)
+		addGroup(nmd, "g-dst", targetID)
+		rule := newRule([]string{"g-src"}, []string{"g-dst"})
+		rule.Enabled = false
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-1", rule)}
+
+		c := compute(nmd, targetID)
+
+		assert.Empty(t, c.Policies)
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+	})
+
+	t.Run("peer on both sides pulls peers from both directions", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		x := newPeer("peer-x", 2)
+		y := newPeer("peer-y", 3)
+		nmd := newNMD(target, x, y)
+		addGroup(nmd, "g-src", targetID, x.ID)
+		addGroup(nmd, "g-dst", targetID, y.ID)
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"}))}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies))
+		assert.ElementsMatch(t, []string{targetID, x.ID, y.ID}, peerIDSet(c.Peers),
+			"both the source-side and destination-side counterparts must connect")
+	})
+
+	t.Run("rule referencing missing group tolerated", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		nmd := newNMD(target)
+		addGroup(nmd, "g-dst", targetID)
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-1", newRule([]string{"g-ghost"}, []string{"g-dst"}))}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies))
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+	})
+
+	t.Run("nil policy and rule entries tolerated", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		dst := newPeer("peer-dst", 2)
+		nmd := newNMD(target, dst)
+		addGroup(nmd, "g-src", targetID)
+		addGroup(nmd, "g-dst", dst.ID)
+		p := newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"}))
+		p.Rules = append([]*nmdata.PolicyRule{nil}, p.Rules...)
+		nmd.Policies = []*nmdata.Policy{nil, p}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies))
+		assert.ElementsMatch(t, []string{targetID, dst.ID}, peerIDSet(c.Peers))
+	})
+}
+
+func TestGetPeerNetworkMapComponents_PeerResourceRules(t *testing.T) {
+	peerResource := func(id string) nmdata.Resource {
+		return nmdata.Resource{ID: id, Type: string(nbtypes.ResourceTypePeer)}
+	}
+
+	t.Run("target as source resource", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		dst := newPeer("peer-dst", 2)
+		nmd := newNMD(target, dst)
+		addGroup(nmd, "g-dst", dst.ID)
+		rule := newRule(nil, []string{"g-dst"})
+		rule.SourceResource = peerResource(targetID)
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-1", rule)}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies))
+		assert.ElementsMatch(t, []string{targetID, dst.ID}, peerIDSet(c.Peers))
+	})
+
+	t.Run("target as destination resource", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		src := newPeer("peer-src", 2)
+		nmd := newNMD(target, src)
+		addGroup(nmd, "g-src", src.ID)
+		rule := newRule([]string{"g-src"}, nil)
+		rule.DestinationResource = peerResource(targetID)
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-1", rule)}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies))
+		assert.ElementsMatch(t, []string{targetID, src.ID}, peerIDSet(c.Peers))
+	})
+
+	t.Run("remote peer as destination resource", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		remote := newPeer("peer-remote", 2)
+		nmd := newNMD(target, remote)
+		addGroup(nmd, "g-src", targetID)
+		rule := newRule([]string{"g-src"}, nil)
+		rule.DestinationResource = peerResource(remote.ID)
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-1", rule)}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies))
+		assert.ElementsMatch(t, []string{targetID, remote.ID}, peerIDSet(c.Peers))
+	})
+
+	// Legacy parity: directly referenced peers bypass the ValidatedPeers gate
+	// and posture checks that group-derived peers go through; the client-side
+	// Calculate shares this behavior via getPeerFromResource.
+	t.Run("unvalidated source resource peer still connects", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		unval := newPeer("peer-unval", 2)
+		nmd := newNMD(target, unval)
+		delete(nmd.ValidatedPeers, unval.ID)
+		addGroup(nmd, "g-dst", targetID)
+		rule := newRule(nil, []string{"g-dst"})
+		rule.SourceResource = peerResource(unval.ID)
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-1", rule)}
+
+		c := compute(nmd, targetID)
+
+		assert.ElementsMatch(t, []string{targetID, unval.ID}, peerIDSet(c.Peers))
+	})
+
+	t.Run("source resource peer bypasses posture checks", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		failing := newPeer("peer-failing", 2)
+		failing.Meta.WtVersion = failingVersion
+		nmd := newNMD(target, failing)
+		addVersionCheck(nmd, "pc-1", postureMinVersion)
+		addGroup(nmd, "g-dst", targetID)
+		rule := newRule(nil, []string{"g-dst"})
+		rule.SourceResource = peerResource(failing.ID)
+		p := newPolicy("p-1", rule)
+		p.SourcePostureChecks = []string{"pc-1"}
+		nmd.Policies = []*nmdata.Policy{p}
+
+		c := compute(nmd, targetID)
+
+		assert.ElementsMatch(t, []string{targetID, failing.ID}, peerIDSet(c.Peers))
+		assert.Empty(t, c.PostureFailedPeers)
+	})
+
+	t.Run("unrelated peer resource rule ignored", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		a := newPeer("peer-a", 2)
+		b := newPeer("peer-b", 3)
+		nmd := newNMD(target, a, b)
+		rule := newRule(nil, nil)
+		rule.SourceResource = peerResource(a.ID)
+		rule.DestinationResource = peerResource(b.ID)
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-1", rule)}
+
+		c := compute(nmd, targetID)
+
+		assert.Empty(t, c.Policies)
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+	})
+}
+
+// A destination list containing a group named "All" short-circuits peer
+// expansion to that group alone, dropping peers accumulated from earlier
+// groups. Groups themselves are still all shipped. Mirrors legacy behavior
+// that the wire encoding depends on (see
+// TestEnvelopeRoundTrip_AllGroupShortCircuitParity).
+func TestGetPeerNetworkMapComponents_AllGroupShortCircuit(t *testing.T) {
+	target := newPeer(targetID, 1)
+	first := newPeer("peer-first", 2)
+	allMember := newPeer("peer-all-member", 3)
+	nmd := newNMD(target, first, allMember)
+	addGroup(nmd, "g-src", targetID)
+	addGroup(nmd, "g-first", first.ID)
+	nmd.Groups["g-all"] = &nmdata.Group{ID: "g-all", Name: nmdata.GroupAllName, Peers: []string{targetID, allMember.ID}}
+	nmd.Policies = []*nmdata.Policy{newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-first", "g-all"}))}
+
+	c := compute(nmd, targetID)
+
+	assert.ElementsMatch(t, []string{targetID, allMember.ID}, peerIDSet(c.Peers),
+		"peers from groups before the All group must be dropped by the short-circuit")
+	assert.ElementsMatch(t, []string{"g-src", "g-first", "g-all"}, groupIDSet(c.Groups))
+	assert.Empty(t, c.Groups["g-first"].Peers)
+}
+
+func TestGetPeerNetworkMapComponents_UnvalidatedPolicyPeersExcluded(t *testing.T) {
+	target := newPeer(targetID, 1)
+	srcOK := newPeer("peer-src-ok", 2)
+	srcUnval := newPeer("peer-src-unval", 3)
+	nmd := newNMD(target, srcOK, srcUnval)
+	delete(nmd.ValidatedPeers, srcUnval.ID)
+	addGroup(nmd, "g-src", srcOK.ID, srcUnval.ID, "peer-deleted")
+	addGroup(nmd, "g-dst", targetID)
+	nmd.Policies = []*nmdata.Policy{newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"}))}
+
+	c := compute(nmd, targetID)
+
+	assert.ElementsMatch(t, []string{targetID, srcOK.ID}, peerIDSet(c.Peers),
+		"unvalidated and dangling group members must not connect")
+	assert.Equal(t, []string{srcOK.ID}, c.Groups["g-src"].Peers)
+}
+
+// Multi-group rules take the union path of getPeersFromGroups (no All-group
+// short-circuit); validation and source posture checks apply per member.
+func TestGetPeerNetworkMapComponents_MultiGroupSources(t *testing.T) {
+	target := newPeer(targetID, 1)
+	dup := newPeer("peer-dup", 2)
+	unval := newPeer("peer-unval", 3)
+	failing := newPeer("peer-failing", 4)
+	failing.Meta.WtVersion = failingVersion
+	solo := newPeer("peer-solo", 5)
+	nmd := newNMD(target, dup, unval, failing, solo)
+	delete(nmd.ValidatedPeers, unval.ID)
+	addVersionCheck(nmd, "pc-1", postureMinVersion)
+	addGroup(nmd, "g-1", targetID, dup.ID, unval.ID, "peer-deleted")
+	addGroup(nmd, "g-2", dup.ID, failing.ID, solo.ID)
+	addGroup(nmd, "g-tgt", targetID)
+	p := newPolicy("p-1", newRule([]string{"g-1", "g-2"}, []string{"g-tgt"}))
+	p.SourcePostureChecks = []string{"pc-1"}
+	nmd.Policies = []*nmdata.Policy{p}
+
+	c := compute(nmd, targetID)
+
+	assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies))
+	assert.ElementsMatch(t, []string{targetID, dup.ID, solo.ID}, peerIDSet(c.Peers))
+	assert.Empty(t, c.PostureFailedPeers,
+		"failing is not otherwise connected, so its failure record is pruned")
+}
+
+func TestGetPeerNetworkMapComponents_PostureChecks(t *testing.T) {
+	t.Run("failing source peer excluded without orphan failure record", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		failing := newPeer("peer-failing", 2)
+		failing.Meta.WtVersion = failingVersion
+		nmd := newNMD(target, failing)
+		addVersionCheck(nmd, "pc-1", postureMinVersion)
+		addGroup(nmd, "g-src", failing.ID)
+		addGroup(nmd, "g-dst", targetID)
+		p := newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"}))
+		p.SourcePostureChecks = []string{"pc-1"}
+		nmd.Policies = []*nmdata.Policy{p}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies))
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+		assert.Empty(t, c.PostureFailedPeers,
+			"failure records for peers absent from the map must be pruned")
+	})
+
+	t.Run("failure recorded when peer is connected via another policy", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		failing := newPeer("peer-failing", 2)
+		failing.Meta.WtVersion = failingVersion
+		nmd := newNMD(target, failing)
+		addVersionCheck(nmd, "pc-1", postureMinVersion)
+		addGroup(nmd, "g-src", failing.ID)
+		addGroup(nmd, "g-dst", targetID)
+		checked := newPolicy("p-checked", newRule([]string{"g-src"}, []string{"g-dst"}))
+		checked.SourcePostureChecks = []string{"pc-1"}
+		open := newPolicy("p-open", newRule([]string{"g-src"}, []string{"g-dst"}))
+		nmd.Policies = []*nmdata.Policy{checked, open}
+
+		c := compute(nmd, targetID)
+
+		assert.ElementsMatch(t, []string{targetID, failing.ID}, peerIDSet(c.Peers))
+		assert.Equal(t, map[string]map[string]struct{}{"pc-1": {failing.ID: {}}}, c.PostureFailedPeers)
+	})
+
+	t.Run("destination peers bypass source posture checks", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		failing := newPeer("peer-failing", 2)
+		failing.Meta.WtVersion = failingVersion
+		nmd := newNMD(target, failing)
+		addVersionCheck(nmd, "pc-1", postureMinVersion)
+		addGroup(nmd, "g-src", targetID)
+		addGroup(nmd, "g-dst", failing.ID)
+		p := newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"}))
+		p.SourcePostureChecks = []string{"pc-1"}
+		nmd.Policies = []*nmdata.Policy{p}
+
+		c := compute(nmd, targetID)
+
+		assert.ElementsMatch(t, []string{targetID, failing.ID}, peerIDSet(c.Peers))
+		assert.Empty(t, c.PostureFailedPeers)
+	})
+
+	t.Run("target failing its own source check drops the policy", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		target.Meta.WtVersion = failingVersion
+		dst := newPeer("peer-dst", 2)
+		nmd := newNMD(target, dst)
+		addVersionCheck(nmd, "pc-1", postureMinVersion)
+		addGroup(nmd, "g-src", targetID)
+		addGroup(nmd, "g-dst", dst.ID)
+		p := newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"}))
+		p.SourcePostureChecks = []string{"pc-1"}
+		nmd.Policies = []*nmdata.Policy{p}
+
+		c := compute(nmd, targetID)
+
+		assert.Empty(t, c.Policies)
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+		assert.Empty(t, c.PostureFailedPeers)
+	})
+
+	t.Run("failure keyed by the first failing check", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		failing := newPeer("peer-failing", 2)
+		nmd := newNMD(target, failing)
+		addVersionCheck(nmd, "pc-pass", postureMinVersion)
+		addVersionCheck(nmd, "pc-fail", "2.0.0")
+		addGroup(nmd, "g-src", failing.ID)
+		addGroup(nmd, "g-dst", targetID)
+		checked := newPolicy("p-checked", newRule([]string{"g-src"}, []string{"g-dst"}))
+		checked.SourcePostureChecks = []string{"pc-pass", "pc-fail"}
+		open := newPolicy("p-open", newRule([]string{"g-src"}, []string{"g-dst"}))
+		nmd.Policies = []*nmdata.Policy{checked, open}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, map[string]map[string]struct{}{"pc-fail": {failing.ID: {}}}, c.PostureFailedPeers,
+			"the record must be keyed by the failing check, not the first listed")
+	})
+
+	t.Run("unknown posture check id passes everyone", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		src := newPeer("peer-src", 2)
+		nmd := newNMD(target, src)
+		addGroup(nmd, "g-src", src.ID)
+		addGroup(nmd, "g-dst", targetID)
+		p := newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"}))
+		p.SourcePostureChecks = []string{"pc-ghost"}
+		nmd.Policies = []*nmdata.Policy{p}
+
+		c := compute(nmd, targetID)
+
+		assert.ElementsMatch(t, []string{targetID, src.ID}, peerIDSet(c.Peers))
+		assert.Empty(t, c.PostureFailedPeers)
+	})
+}
+
+func TestGetPeerNetworkMapComponents_Routes(t *testing.T) {
+	t.Run("owned route relevant even when disabled", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		dist := newPeer("peer-dist", 2)
+		nmd := newNMD(target, dist)
+		addGroup(nmd, "g-dist", dist.ID)
+		addGroup(nmd, "g-acl")
+		r := &nmdata.Route{ID: "r-1", Peer: targetID, Enabled: false, Groups: []string{"g-dist"}, AccessControlGroups: []string{"g-acl"}}
+		nmd.Routes = []*nmdata.Route{r}
+
+		c := compute(nmd, targetID)
+
+		require.Len(t, c.Routes, 1)
+		assert.Same(t, r, c.Routes[0])
+		assert.Contains(t, c.Groups, "g-dist")
+		assert.NotContains(t, c.Groups, "g-acl",
+			"access control groups of a disabled route must not be collected")
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers),
+			"distribution group members are not connected by the route itself")
+	})
+
+	t.Run("peer-group route disabled still ships and connects HA members", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		ha := newPeer("peer-ha", 2)
+		haUnval := newPeer("peer-ha-unval", 3)
+		nmd := newNMD(target, ha, haUnval)
+		delete(nmd.ValidatedPeers, haUnval.ID)
+		addGroup(nmd, "g-ha", targetID, ha.ID, haUnval.ID)
+		r := &nmdata.Route{ID: "r-1", PeerGroups: []string{"g-ha", "g-ghost"}, Enabled: false}
+		nmd.Routes = []*nmdata.Route{r}
+
+		c := compute(nmd, targetID)
+
+		require.Len(t, c.Routes, 1)
+		assert.ElementsMatch(t, []string{targetID, ha.ID}, peerIDSet(c.Peers))
+		assert.Equal(t, []string{targetID, ha.ID}, c.Groups["g-ha"].Peers)
+	})
+
+	t.Run("route consumer connects HA routing peers from peer groups", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		router1 := newPeer("peer-router-1", 2)
+		router2 := newPeer("peer-router-2", 3)
+		routerUnval := newPeer("peer-router-unval", 4)
+		nmd := newNMD(target, router1, router2, routerUnval)
+		delete(nmd.ValidatedPeers, routerUnval.ID)
+		addGroup(nmd, "g-ha", router1.ID, router2.ID, routerUnval.ID)
+		addGroup(nmd, "g-dist", targetID)
+		nmd.Routes = []*nmdata.Route{{ID: "r-1", PeerGroups: []string{"g-ha"}, Groups: []string{"g-dist"}, Enabled: true}}
+
+		c := compute(nmd, targetID)
+
+		require.Len(t, c.Routes, 1)
+		assert.ElementsMatch(t, []string{targetID, router1.ID, router2.ID}, peerIDSet(c.Peers),
+			"the consumer must connect to every validated HA router")
+		assert.Equal(t, []string{router1.ID, router2.ID}, c.Groups["g-ha"].Peers)
+	})
+
+	t.Run("distribution route connects routing peer", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		router := newPeer("peer-router", 2)
+		nmd := newNMD(target, router)
+		addGroup(nmd, "g-dist", targetID)
+		r := &nmdata.Route{ID: "r-1", Peer: router.ID, Enabled: true, Groups: []string{"g-dist"}}
+		nmd.Routes = []*nmdata.Route{r}
+
+		c := compute(nmd, targetID)
+
+		require.Len(t, c.Routes, 1)
+		assert.ElementsMatch(t, []string{targetID, router.ID}, peerIDSet(c.Peers))
+	})
+
+	t.Run("disabled distribution route not relevant", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		router := newPeer("peer-router", 2)
+		nmd := newNMD(target, router)
+		addGroup(nmd, "g-dist", targetID)
+		nmd.Routes = []*nmdata.Route{{ID: "r-1", Peer: router.ID, Enabled: false, Groups: []string{"g-dist"}}}
+
+		c := compute(nmd, targetID)
+
+		assert.Empty(t, c.Routes)
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+	})
+
+	t.Run("unvalidated routing peer excluded but route ships", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		router := newPeer("peer-router", 2)
+		nmd := newNMD(target, router)
+		delete(nmd.ValidatedPeers, router.ID)
+		addGroup(nmd, "g-dist", targetID)
+		nmd.Routes = []*nmdata.Route{{ID: "r-1", Peer: router.ID, Enabled: true, Groups: []string{"g-dist"}}}
+
+		c := compute(nmd, targetID)
+
+		require.Len(t, c.Routes, 1)
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+	})
+
+	t.Run("nil and unrelated routes skipped", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		other := newPeer("peer-other", 2)
+		nmd := newNMD(target, other)
+		addGroup(nmd, "g-dist", targetID)
+		addGroup(nmd, "g-foreign", other.ID)
+		owned := &nmdata.Route{ID: "r-owned", Peer: targetID, Enabled: true}
+		nmd.Routes = []*nmdata.Route{nil, {ID: "r-foreign", Peer: other.ID, Enabled: true, Groups: []string{"g-foreign"}}, owned}
+
+		c := compute(nmd, targetID)
+
+		require.Len(t, c.Routes, 1)
+		assert.Same(t, owned, c.Routes[0])
+	})
+}
+
+// A policy whose destinations hit an enabled route's access control groups is
+// shipped so the routing peer can build route firewall rules, but its peers
+// are not connected through this bridge.
+func TestGetPeerNetworkMapComponents_RouteAccessControlBridging(t *testing.T) {
+	t.Run("policy targeting route ACG becomes relevant", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		router := newPeer("peer-router", 2)
+		remote := newPeer("peer-remote", 3)
+		nmd := newNMD(target, router, remote)
+		addGroup(nmd, "g-dist", targetID)
+		addGroup(nmd, "g-acl")
+		addGroup(nmd, "g-remote", remote.ID)
+		nmd.Routes = []*nmdata.Route{{ID: "r-1", Peer: router.ID, Enabled: true, Groups: []string{"g-dist"}, AccessControlGroups: []string{"g-acl"}}}
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-acl", newRule([]string{"g-remote"}, []string{"g-acl"}))}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []string{"p-acl"}, policyIDs(c.Policies))
+		assert.ElementsMatch(t, []string{"g-dist", "g-acl", "g-remote"}, groupIDSet(c.Groups))
+		assert.ElementsMatch(t, []string{targetID, router.ID}, peerIDSet(c.Peers),
+			"the bridged policy's source peers must not be connected")
+	})
+
+	t.Run("disabled route does not bridge its ACG policies", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		remote := newPeer("peer-remote", 2)
+		nmd := newNMD(target, remote)
+		addGroup(nmd, "g-acl")
+		addGroup(nmd, "g-remote", remote.ID)
+		nmd.Routes = []*nmdata.Route{{ID: "r-1", Peer: targetID, Enabled: false, AccessControlGroups: []string{"g-acl"}}}
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-acl", newRule([]string{"g-remote"}, []string{"g-acl"}))}
+
+		c := compute(nmd, targetID)
+
+		assert.Empty(t, c.Policies)
+	})
+}
+
+func TestGetPeerNetworkMapComponents_SSHRequirements(t *testing.T) {
+	allowedUsers := map[string]struct{}{"user-1": {}, "user-2": {}}
+	groupUsers := map[string][]string{"g-auth": {"user-a"}, "g-other": {"user-b"}}
+
+	cases := []struct {
+		name          string
+		mutateRule    func(*nmdata.PolicyRule)
+		sshEnabled    bool
+		targetInSrc   bool
+		wantAllowed   bool
+		wantGroupsMap map[string][]string
+	}{
+		{
+			name: "netbird-ssh with authorized groups",
+			mutateRule: func(r *nmdata.PolicyRule) {
+				r.Protocol = string(nbtypes.PolicyRuleProtocolNetbirdSSH)
+				r.AuthorizedGroups = map[string][]string{"g-auth": nil}
+			},
+			wantGroupsMap: map[string][]string{"g-auth": {"user-a"}},
+		},
+		{
+			name: "netbird-ssh with authorized user",
+			mutateRule: func(r *nmdata.PolicyRule) {
+				r.Protocol = string(nbtypes.PolicyRuleProtocolNetbirdSSH)
+				r.AuthorizedUser = "root"
+			},
+		},
+		{
+			name: "netbird-ssh default needs allowed users",
+			mutateRule: func(r *nmdata.PolicyRule) {
+				r.Protocol = string(nbtypes.PolicyRuleProtocolNetbirdSSH)
+			},
+			wantAllowed: true,
+		},
+		{
+			name:        "legacy all-protocol with SSH enabled",
+			mutateRule:  func(r *nmdata.PolicyRule) { r.Protocol = string(nbtypes.PolicyRuleProtocolALL) },
+			sshEnabled:  true,
+			wantAllowed: true,
+		},
+		{
+			name:       "legacy all-protocol with SSH disabled",
+			mutateRule: func(r *nmdata.PolicyRule) { r.Protocol = string(nbtypes.PolicyRuleProtocolALL) },
+		},
+		{
+			name:        "tcp port 22 with SSH enabled",
+			mutateRule:  func(r *nmdata.PolicyRule) { r.Ports = []string{"22"} },
+			sshEnabled:  true,
+			wantAllowed: true,
+		},
+		{
+			name:        "tcp port range covering 22",
+			mutateRule:  func(r *nmdata.PolicyRule) { r.PortRanges = []nmdata.RulePortRange{{Start: 20, End: 30}} },
+			sshEnabled:  true,
+			wantAllowed: true,
+		},
+		{
+			name:        "tcp native ssh port 22022",
+			mutateRule:  func(r *nmdata.PolicyRule) { r.Ports = []string{"22022"} },
+			sshEnabled:  true,
+			wantAllowed: true,
+		},
+		{
+			name:        "tcp port range covering only native ssh port",
+			mutateRule:  func(r *nmdata.PolicyRule) { r.PortRanges = []nmdata.RulePortRange{{Start: 22000, End: 23000}} },
+			sshEnabled:  true,
+			wantAllowed: true,
+		},
+		{
+			name:       "tcp unrelated port",
+			mutateRule: func(r *nmdata.PolicyRule) { r.Ports = []string{"443"} },
+			sshEnabled: true,
+		},
+		{
+			name: "netbird-ssh only counts on the destination side",
+			mutateRule: func(r *nmdata.PolicyRule) {
+				r.Protocol = string(nbtypes.PolicyRuleProtocolNetbirdSSH)
+			},
+			targetInSrc: true,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			target := newPeer(targetID, 1)
+			target.SSHEnabled = tc.sshEnabled
+			admin := newPeer("peer-admin", 2)
+			nmd := newNMD(target, admin)
+			nmd.AllowedUserIDs = allowedUsers
+			nmd.GroupIDToUserIDs = groupUsers
+			addGroup(nmd, "g-adm", admin.ID)
+			addGroup(nmd, "g-tgt", targetID)
+			rule := newRule([]string{"g-adm"}, []string{"g-tgt"})
+			if tc.targetInSrc {
+				rule = newRule([]string{"g-tgt"}, []string{"g-adm"})
+			}
+			tc.mutateRule(rule)
+			nmd.Policies = []*nmdata.Policy{newPolicy("p-1", rule)}
+
+			c := compute(nmd, targetID)
+
+			if tc.wantAllowed {
+				assert.Equal(t, allowedUsers, c.AllowedUserIDs)
+			} else {
+				assert.Nil(t, c.AllowedUserIDs)
+			}
+			assert.Equal(t, tc.wantGroupsMap, c.GroupIDToUserIDs)
+		})
+	}
+}
+
+func TestGetPeerNetworkMapComponents_DNSRecordFiltering(t *testing.T) {
+	record := func(name, rdata string) nmdata.SimpleRecord {
+		return nmdata.SimpleRecord{Name: name, Type: 1, Class: "IN", TTL: 300, RData: rdata}
+	}
+
+	build := func(ipv6Target bool) (*networkmap.NetworkMapData, nmdata.CustomZone) {
+		target := newPeer(targetID, 1)
+		if ipv6Target {
+			target.IPv6 = netip.MustParseAddr("fd00::1")
+			target.Meta.Capabilities = []int32{nmdata.PeerCapabilityIPv6Overlay}
+		}
+		buddy := newPeer("peer-buddy", 2)
+		buddy.IPv6 = netip.MustParseAddr("fd00::2")
+		stranger := newPeer("peer-stranger", 3)
+		nmd := newNMD(target, buddy, stranger)
+		addGroup(nmd, "g-src", targetID)
+		addGroup(nmd, "g-dst", buddy.ID)
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"}))}
+		zone := nmdata.CustomZone{
+			Domain: "acme.netbird.cloud.",
+			Records: []nmdata.SimpleRecord{
+				record(targetID, "100.64.0.1"),
+				record("peer-buddy", "100.64.0.2"),
+				record("peer-stranger", "100.64.0.3"),
+				record("outsider", "9.9.9.9"),
+				record("peer-buddy-v6", "fd00::2"),
+			},
+		}
+		return nmd, zone
+	}
+
+	t.Run("records limited to relevant peers, IPv6 dropped without capability", func(t *testing.T) {
+		nmd, zone := build(false)
+
+		c := nmd.GetPeerNetworkMapComponents(targetID, zone)
+
+		assert.Equal(t, "acme.netbird.cloud.", c.CustomZoneDomain)
+		assert.Equal(t, []nmdata.SimpleRecord{
+			record(targetID, "100.64.0.1"),
+			record("peer-buddy", "100.64.0.2"),
+		}, c.AllDNSRecords)
+	})
+
+	t.Run("IPv6 records of relevant peers kept for capable target", func(t *testing.T) {
+		nmd, zone := build(true)
+
+		c := nmd.GetPeerNetworkMapComponents(targetID, zone)
+
+		assert.Equal(t, []nmdata.SimpleRecord{
+			record(targetID, "100.64.0.1"),
+			record("peer-buddy", "100.64.0.2"),
+			record("peer-buddy-v6", "fd00::2"),
+		}, c.AllDNSRecords)
+	})
+
+	t.Run("no records yields nil", func(t *testing.T) {
+		nmd, _ := build(false)
+
+		c := nmd.GetPeerNetworkMapComponents(targetID, nmdata.CustomZone{Domain: "acme.netbird.cloud."})
+
+		assert.Nil(t, c.AllDNSRecords)
+	})
+}
+
+func TestGetPeerNetworkMapComponents_AccountZones(t *testing.T) {
+	rec := func(name string) nmdata.SimpleRecord {
+		return nmdata.SimpleRecord{Name: name, Type: 1, Class: "IN", RData: "100.64.0.9"}
+	}
+
+	t.Run("applied and private service zones for peer groups", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		nmd := newNMD(target)
+		addGroup(nmd, "g-a", targetID)
+		appliedZone := nmdata.CustomZone{Domain: "zone-one.example.com.", Records: []nmdata.SimpleRecord{rec("z1")}}
+		nmd.AppliedZoneCandidates = []networkmap.AppliedZoneCandidate{
+			{DistributionGroups: []string{"g-a"}, Zone: appliedZone},
+			{DistributionGroups: []string{"g-x"}, Zone: nmdata.CustomZone{Domain: "zone-two.example.com."}},
+		}
+		nmd.PrivateServiceCandidates = []networkmap.PrivateServiceCandidate{
+			{AccessGroups: []string{"g-a"}, Zone: nmdata.CustomZone{Domain: "svc.example.com", SearchDomainDisabled: true, NonAuthoritative: true, Records: []nmdata.SimpleRecord{rec("svc-1")}}},
+			{AccessGroups: []string{"g-a"}, Zone: nmdata.CustomZone{Domain: "svc.example.com", Records: []nmdata.SimpleRecord{rec("svc-2")}}},
+			{AccessGroups: []string{"g-x"}, Zone: nmdata.CustomZone{Domain: "other.example.com", Records: []nmdata.SimpleRecord{rec("other")}}},
+			{AccessGroups: []string{"g-a"}, Zone: nmdata.CustomZone{Domain: "empty.example.com"}},
+		}
+
+		c := compute(nmd, targetID)
+
+		require.Len(t, c.AccountZones, 2)
+		assert.Equal(t, appliedZone, c.AccountZones[0])
+		assert.Equal(t, nmdata.CustomZone{
+			Domain:               "svc.example.com",
+			SearchDomainDisabled: true,
+			NonAuthoritative:     true,
+			Records:              []nmdata.SimpleRecord{rec("svc-1"), rec("svc-2")},
+		}, c.AccountZones[1], "same-apex private service candidates must merge, flags from the first")
+	})
+
+	t.Run("groupless peer receives no zones", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		nmd := newNMD(target)
+		nmd.AppliedZoneCandidates = []networkmap.AppliedZoneCandidate{
+			{DistributionGroups: []string{"g-a"}, Zone: nmdata.CustomZone{Domain: "zone-one.example.com."}},
+		}
+		nmd.PrivateServiceCandidates = []networkmap.PrivateServiceCandidate{
+			{AccessGroups: []string{"g-a"}, Zone: nmdata.CustomZone{Domain: "svc.example.com", Records: []nmdata.SimpleRecord{rec("svc")}}},
+		}
+
+		c := compute(nmd, targetID)
+
+		assert.Empty(t, c.AccountZones)
+	})
+}
+
+func TestGetPeerNetworkMapComponents_NameServerGroups(t *testing.T) {
+	target := newPeer(targetID, 1)
+	other := newPeer("peer-other", 2)
+	nmd := newNMD(target, other)
+	addGroup(nmd, "g-own", targetID)
+	addGroup(nmd, "g-dst", other.ID)
+	addGroup(nmd, "g-foreign")
+	nmd.Policies = []*nmdata.Policy{newPolicy("p-1", newRule([]string{"g-own"}, []string{"g-dst"}))}
+	nsOwn := &nmdata.NameServerGroup{ID: "ns-own", Enabled: true, Groups: []string{"g-own"}}
+	nsDst := &nmdata.NameServerGroup{ID: "ns-dst", Enabled: true, Groups: []string{"g-dst"}}
+	nsDisabled := &nmdata.NameServerGroup{ID: "ns-disabled", Enabled: false, Groups: []string{"g-own"}}
+	nsForeign := &nmdata.NameServerGroup{ID: "ns-foreign", Enabled: true, Groups: []string{"g-foreign"}}
+	nsBoth := &nmdata.NameServerGroup{ID: "ns-both", Enabled: true, Groups: []string{"g-own", "g-dst"}}
+	nmd.NameServerGroups = []*nmdata.NameServerGroup{nsOwn, nil, nsDst, nsDisabled, nsForeign, nsBoth}
+
+	c := compute(nmd, targetID)
+
+	assert.Equal(t, []*nmdata.NameServerGroup{nsOwn, nsDst, nsBoth}, c.NameServerGroups,
+		"nameserver groups attach to any relevant group and ship once even when several groups match")
+}
+
+func TestGetPeerNetworkMapComponents_NetworkResources_SourceSide(t *testing.T) {
+	target := newPeer(targetID, 1)
+	routerOK := newPeer("peer-router-ok", 2)
+	routerUnval := newPeer("peer-router-unval", 3)
+	nmd := newNMD(target, routerOK, routerUnval)
+	delete(nmd.ValidatedPeers, routerUnval.ID)
+	addGroup(nmd, "g-clients", targetID)
+	addGroup(nmd, "g-resource")
+	res := &nmdata.NetworkResource{ID: "res-1", NetworkID: "net-1", Enabled: true}
+	nmd.NetworkResources = []*nmdata.NetworkResource{res}
+	rp := newPolicy("rp-1", newRule([]string{"g-clients"}, []string{"g-resource"}))
+	nmd.Policies = []*nmdata.Policy{rp}
+	nmd.ResourcePolicies = map[string][]*nmdata.Policy{"res-1": {rp}}
+	routers := map[string]*nmdata.NetworkRouter{
+		routerOK.ID:    {Metric: 100},
+		routerUnval.ID: {Metric: 200},
+	}
+	nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": routers}
+
+	c := compute(nmd, targetID)
+
+	assert.Equal(t, []*nmdata.NetworkResource{res}, c.NetworkResources)
+	assert.Equal(t, map[string][]*nmdata.Policy{"res-1": {rp}}, c.ResourcePoliciesMap)
+	assert.Equal(t, map[string]map[string]*nmdata.NetworkRouter{"net-1": routers}, c.RoutersMap)
+	assert.ElementsMatch(t, []string{routerOK.ID}, peerIDSet(c.RouterPeers),
+		"an unvalidated routing peer is withheld from RouterPeers too, since the envelope encoder "+
+			"indexes that map into the wire peer table and the client restores every entry from it")
+	assert.ElementsMatch(t, []string{targetID, routerOK.ID}, peerIDSet(c.Peers),
+		"only validated routing peers are connected")
+	assert.ElementsMatch(t, []string{"g-clients", "g-resource"}, groupIDSet(c.Groups))
+}
+
+func TestGetPeerNetworkMapComponents_NetworkResources_RouterSide(t *testing.T) {
+	t.Run("posture-valid validated source peers connected, failures recorded", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		clientOK := newPeer("peer-client-ok", 2)
+		clientUnval := newPeer("peer-client-unval", 3)
+		clientFail := newPeer("peer-client-fail", 4)
+		clientFail.Meta.WtVersion = failingVersion
+		nmd := newNMD(target, clientOK, clientUnval, clientFail)
+		delete(nmd.ValidatedPeers, clientUnval.ID)
+		addVersionCheck(nmd, "pc-1", postureMinVersion)
+		addGroup(nmd, "g-clients", clientOK.ID, clientUnval.ID, clientFail.ID)
+		addGroup(nmd, "g-resource")
+		addGroup(nmd, "g-tgt", targetID)
+		res := &nmdata.NetworkResource{ID: "res-1", NetworkID: "net-1", Enabled: true}
+		nmd.NetworkResources = []*nmdata.NetworkResource{res}
+		rp := newPolicy("rp-1", newRule([]string{"g-clients"}, []string{"g-resource"}))
+		rp.SourcePostureChecks = []string{"pc-1"}
+		acl := newPolicy("p-acl", newRule([]string{"g-clients"}, []string{"g-tgt"}))
+		nmd.Policies = []*nmdata.Policy{acl}
+		nmd.ResourcePolicies = map[string][]*nmdata.Policy{"res-1": {rp}}
+		nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {targetID: {Metric: 100}}}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []*nmdata.NetworkResource{res}, c.NetworkResources)
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.RouterPeers))
+		assert.ElementsMatch(t, []string{targetID, clientOK.ID, clientFail.ID}, peerIDSet(c.Peers),
+			"clientFail connects via the open ACL policy, clientUnval never connects")
+		assert.Equal(t, map[string]map[string]struct{}{"pc-1": {clientFail.ID: {}}}, c.PostureFailedPeers)
+	})
+
+	t.Run("peer source resource collects exactly that peer", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		client := newPeer("peer-client", 2)
+		other := newPeer("peer-other", 3)
+		nmd := newNMD(target, client, other)
+		res := &nmdata.NetworkResource{ID: "res-1", NetworkID: "net-1", Enabled: true}
+		nmd.NetworkResources = []*nmdata.NetworkResource{res}
+		rule := newRule(nil, nil)
+		rule.SourceResource = nmdata.Resource{ID: client.ID, Type: string(nbtypes.ResourceTypePeer)}
+		nmd.ResourcePolicies = map[string][]*nmdata.Policy{"res-1": {newPolicy("rp-1", rule)}}
+		nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {targetID: {}}}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []*nmdata.NetworkResource{res}, c.NetworkResources)
+		assert.ElementsMatch(t, []string{targetID, client.ID}, peerIDSet(c.Peers))
+	})
+
+	t.Run("multiple source groups unioned, missing group tolerated", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		c1 := newPeer("peer-c1", 2)
+		c2 := newPeer("peer-c2", 3)
+		nmd := newNMD(target, c1, c2)
+		addGroup(nmd, "g-c1", c1.ID)
+		addGroup(nmd, "g-c2", c2.ID)
+		res := &nmdata.NetworkResource{ID: "res-1", NetworkID: "net-1", Enabled: true}
+		nmd.NetworkResources = []*nmdata.NetworkResource{res}
+		nmd.ResourcePolicies = map[string][]*nmdata.Policy{
+			"res-1": {newPolicy("rp-1", newRule([]string{"g-c1", "g-c2", "g-ghost"}, nil))},
+		}
+		nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {targetID: {}}}
+
+		c := compute(nmd, targetID)
+
+		assert.ElementsMatch(t, []string{targetID, c1.ID, c2.ID}, peerIDSet(c.Peers))
+	})
+}
+
+func TestGetPeerNetworkMapComponents_NetworkResources_PeerResourceSource(t *testing.T) {
+	build := func(sourcePeerID string) *networkmap.NetworkMapData {
+		target := newPeer(targetID, 1)
+		router := newPeer("peer-router", 2)
+		other := newPeer("peer-other", 3)
+		nmd := newNMD(target, router, other)
+		res := &nmdata.NetworkResource{ID: "res-1", NetworkID: "net-1", Enabled: true}
+		nmd.NetworkResources = []*nmdata.NetworkResource{res}
+		rule := newRule(nil, nil)
+		rule.SourceResource = nmdata.Resource{ID: sourcePeerID, Type: string(nbtypes.ResourceTypePeer)}
+		nmd.ResourcePolicies = map[string][]*nmdata.Policy{"res-1": {newPolicy("rp-1", rule)}}
+		nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {"peer-router": {}}}
+		return nmd
+	}
+
+	t.Run("target named as source resource gains access", func(t *testing.T) {
+		c := compute(build(targetID), targetID)
+
+		assert.Len(t, c.NetworkResources, 1)
+		assert.ElementsMatch(t, []string{targetID, "peer-router"}, peerIDSet(c.Peers))
+	})
+
+	t.Run("other peer named as source resource denies target", func(t *testing.T) {
+		c := compute(build("peer-other"), targetID)
+
+		assert.Empty(t, c.NetworkResources)
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+	})
+}
+
+func TestGetPeerNetworkMapComponents_NetworkResources_Gating(t *testing.T) {
+	build := func() (*networkmap.NetworkMapData, *nmdata.NetworkResource, *nmdata.Policy) {
+		target := newPeer(targetID, 1)
+		router := newPeer("peer-router", 2)
+		nmd := newNMD(target, router)
+		addGroup(nmd, "g-clients", targetID)
+		addGroup(nmd, "g-resource")
+		res := &nmdata.NetworkResource{ID: "res-1", NetworkID: "net-1", Enabled: true}
+		nmd.NetworkResources = []*nmdata.NetworkResource{res}
+		rp := newPolicy("rp-1", newRule([]string{"g-clients"}, []string{"g-resource"}))
+		nmd.Policies = []*nmdata.Policy{rp}
+		nmd.ResourcePolicies = map[string][]*nmdata.Policy{"res-1": {rp}}
+		nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {router.ID: {}}}
+		return nmd, res, rp
+	}
+
+	assertResourceSkipped := func(t *testing.T, c *nbtypes.NetworkMapComponents) {
+		t.Helper()
+		assert.Empty(t, c.NetworkResources)
+		assert.Empty(t, c.RoutersMap)
+		assert.Empty(t, c.RouterPeers)
+		assert.Empty(t, c.ResourcePoliciesMap)
+	}
+
+	t.Run("baseline grants access", func(t *testing.T) {
+		nmd, res, _ := build()
+		c := compute(nmd, targetID)
+		assert.Equal(t, []*nmdata.NetworkResource{res}, c.NetworkResources)
+	})
+
+	t.Run("disabled resource skipped", func(t *testing.T) {
+		nmd, res, _ := build()
+		res.Enabled = false
+		assertResourceSkipped(t, compute(nmd, targetID))
+	})
+
+	t.Run("resource without policies skipped", func(t *testing.T) {
+		nmd, _, _ := build()
+		nmd.ResourcePolicies = nil
+		assertResourceSkipped(t, compute(nmd, targetID))
+	})
+
+	t.Run("peer neither router nor in sources skipped", func(t *testing.T) {
+		nmd, _, _ := build()
+		nmd.Groups["g-clients"].Peers = []string{"peer-router"}
+		c := compute(nmd, targetID)
+		assertResourceSkipped(t, c)
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+	})
+
+	t.Run("nil and rule-less resource policy entries tolerated", func(t *testing.T) {
+		nmd, res, rp := build()
+		nmd.NetworkResources = append([]*nmdata.NetworkResource{nil}, nmd.NetworkResources...)
+		rp.Rules = append(rp.Rules, nil)
+		nmd.ResourcePolicies["res-1"] = append([]*nmdata.Policy{nil, {ID: "rp-empty", Enabled: true}}, nmd.ResourcePolicies["res-1"]...)
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []*nmdata.NetworkResource{res}, c.NetworkResources,
+			"poisoned sibling entries must not prevent the valid policy from granting access")
+		assert.NotPanics(t, func() { c.Calculate(context.Background()) },
+			"the downstream network map calculation must survive the poisoned components")
+	})
+
+	t.Run("granting policy without routers still ships the resource", func(t *testing.T) {
+		nmd, res, rp := build()
+		nmd.Routers = nil
+		c := compute(nmd, targetID)
+		assert.Equal(t, []*nmdata.NetworkResource{res}, c.NetworkResources)
+		assert.Equal(t, map[string][]*nmdata.Policy{"res-1": {rp}}, c.ResourcePoliciesMap)
+		assert.Contains(t, c.RoutersMap, "net-1")
+		assert.Empty(t, c.RoutersMap["net-1"])
+		assert.Empty(t, c.RouterPeers)
+	})
+
+	t.Run("target failing resource policy posture check skipped", func(t *testing.T) {
+		nmd, _, rp := build()
+		addVersionCheck(nmd, "pc-1", postureMinVersion)
+		rp.SourcePostureChecks = []string{"pc-1"}
+		nmd.Policies = nil
+		nmd.Peers[targetID].Meta.WtVersion = failingVersion
+		c := compute(nmd, targetID)
+		assertResourceSkipped(t, c)
+		assert.Empty(t, c.PostureFailedPeers)
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+	})
+}
+
+// Legacy parity: resource-policy access consults only Rules[0] for peer-type
+// sources, while group sources union across all rules via SourceGroups.
+func TestGetPeerNetworkMapComponents_MultiRulePolicies(t *testing.T) {
+	t.Run("policy matching via multiple rules ships once", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		a := newPeer("peer-a", 2)
+		b := newPeer("peer-b", 3)
+		nmd := newNMD(target, a, b)
+		addGroup(nmd, "g-tgt", targetID)
+		addGroup(nmd, "g-a", a.ID)
+		addGroup(nmd, "g-b", b.ID)
+		p := newPolicy("p-1",
+			newRule([]string{"g-tgt"}, []string{"g-a"}),
+			newRule([]string{"g-tgt"}, []string{"g-b"}))
+		nmd.Policies = []*nmdata.Policy{p}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies))
+		assert.ElementsMatch(t, []string{targetID, a.ID, b.ID}, peerIDSet(c.Peers))
+	})
+
+	t.Run("resource access consults only the first rule's peer source", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		other := newPeer("peer-other", 2)
+		router := newPeer("peer-router", 3)
+		nmd := newNMD(target, other, router)
+		addGroup(nmd, "g-other", other.ID)
+		res := &nmdata.NetworkResource{ID: "res-1", NetworkID: "net-1", Enabled: true}
+		nmd.NetworkResources = []*nmdata.NetworkResource{res}
+		second := newRule(nil, nil)
+		second.SourceResource = nmdata.Resource{ID: targetID, Type: string(nbtypes.ResourceTypePeer)}
+		rp := newPolicy("rp-1", newRule([]string{"g-other"}, nil), second)
+		nmd.ResourcePolicies = map[string][]*nmdata.Policy{"res-1": {rp}}
+		nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {router.ID: {}}}
+
+		c := compute(nmd, targetID)
+
+		assert.Empty(t, c.NetworkResources,
+			"a second rule naming the target as peer source must not grant resource access")
+	})
+
+	t.Run("router-side source collection consults only the first rule's peer source", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		x := newPeer("peer-x", 2)
+		y := newPeer("peer-y", 3)
+		nmd := newNMD(target, x, y)
+		addGroup(nmd, "g-y", y.ID)
+		res := &nmdata.NetworkResource{ID: "res-1", NetworkID: "net-1", Enabled: true}
+		nmd.NetworkResources = []*nmdata.NetworkResource{res}
+		first := newRule(nil, nil)
+		first.SourceResource = nmdata.Resource{ID: x.ID, Type: string(nbtypes.ResourceTypePeer)}
+		rp := newPolicy("rp-1", first, newRule([]string{"g-y"}, nil))
+		nmd.ResourcePolicies = map[string][]*nmdata.Policy{"res-1": {rp}}
+		nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {targetID: {}}}
+
+		c := compute(nmd, targetID)
+
+		assert.ElementsMatch(t, []string{targetID, x.ID}, peerIDSet(c.Peers),
+			"second-rule group sources are not collected when the first rule names a peer")
+	})
+}
+
+// Characterization of legacy parity: once one resource policy grants the peer
+// access, the source peers of the resource's subsequent policies are collected
+// as if the peer were a router.
+func TestGetPeerNetworkMapComponents_NetworkResources_LaterPoliciesContributeSourcePeers(t *testing.T) {
+	target := newPeer(targetID, 1)
+	otherSrc := newPeer("peer-other-src", 2)
+	router := newPeer("peer-router", 3)
+	nmd := newNMD(target, otherSrc, router)
+	addGroup(nmd, "g-a", targetID)
+	addGroup(nmd, "g-b", otherSrc.ID)
+	addGroup(nmd, "g-resource")
+	res := &nmdata.NetworkResource{ID: "res-1", NetworkID: "net-1", Enabled: true}
+	nmd.NetworkResources = []*nmdata.NetworkResource{res}
+	rpA := newPolicy("rp-a", newRule([]string{"g-a"}, []string{"g-resource"}))
+	rpB := newPolicy("rp-b", newRule([]string{"g-b"}, []string{"g-resource"}))
+	nmd.ResourcePolicies = map[string][]*nmdata.Policy{"res-1": {rpA, rpB}}
+	nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {router.ID: {}}}
+
+	c := compute(nmd, targetID)
+
+	assert.Contains(t, c.Peers, otherSrc.ID)
+}
+
+func TestGetPeerNetworkMapComponents_StoreImmutableAndDeterministic(t *testing.T) {
+	zone := nmdata.CustomZone{
+		Domain:  "acme.netbird.cloud.",
+		Records: []nmdata.SimpleRecord{{Name: "peer-src", Type: 1, Class: "IN", TTL: 300, RData: "100.64.0.2"}},
+	}
+	build := func() *networkmap.NetworkMapData {
+		target := newPeer(targetID, 1)
+		src := newPeer("peer-src", 2)
+		failing := newPeer("peer-failing", 3)
+		failing.Meta.WtVersion = failingVersion
+		router := newPeer("peer-router", 4)
+		resRouter := newPeer("peer-res-router", 5)
+		nmd := newNMD(target, src, failing, router, resRouter)
+		addVersionCheck(nmd, "pc-1", postureMinVersion)
+		addGroup(nmd, "g-src", src.ID, failing.ID)
+		addGroup(nmd, "g-dst", targetID, src.ID)
+		addGroup(nmd, "g-dist", targetID, src.ID)
+		addGroup(nmd, "g-auth", src.ID)
+		addGroup(nmd, "g-clients", targetID)
+		addGroup(nmd, "g-resource")
+		nmd.AllowedUserIDs = map[string]struct{}{"user-1": {}}
+		nmd.GroupIDToUserIDs = map[string][]string{"g-auth": {"user-a"}}
+		checked := newPolicy("p-checked", newRule([]string{"g-src"}, []string{"g-dst"}))
+		checked.SourcePostureChecks = []string{"pc-1"}
+		open := newPolicy("p-open", newRule([]string{"g-src"}, []string{"g-dst"}))
+		sshAuth := newRule([]string{"g-src"}, []string{"g-dst"})
+		sshAuth.Protocol = string(nbtypes.PolicyRuleProtocolNetbirdSSH)
+		sshAuth.AuthorizedGroups = map[string][]string{"g-auth": nil}
+		sshPlain := newRule([]string{"g-src"}, []string{"g-dst"})
+		sshPlain.Protocol = string(nbtypes.PolicyRuleProtocolNetbirdSSH)
+		rp := newPolicy("rp-1", newRule([]string{"g-clients"}, []string{"g-resource"}))
+		nmd.Policies = []*nmdata.Policy{checked, open, newPolicy("p-ssh", sshAuth, sshPlain), rp}
+		nmd.Routes = []*nmdata.Route{{ID: "r-1", Peer: router.ID, Enabled: true, Groups: []string{"g-dist"}}}
+		nmd.NetworkResources = []*nmdata.NetworkResource{{ID: "res-1", NetworkID: "net-1", Enabled: true}}
+		nmd.ResourcePolicies = map[string][]*nmdata.Policy{"res-1": {rp}}
+		nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {resRouter.ID: {Metric: 100}}}
+		nmd.NameServerGroups = []*nmdata.NameServerGroup{{ID: "ns-1", Enabled: true, Groups: []string{"g-dst"}}}
+		nmd.AppliedZoneCandidates = []networkmap.AppliedZoneCandidate{
+			{DistributionGroups: []string{"g-dst"}, Zone: nmdata.CustomZone{Domain: "zone.example.com.", Records: []nmdata.SimpleRecord{{Name: "z", Type: 1, RData: "100.64.0.9"}}}},
+		}
+		nmd.PrivateServiceCandidates = []networkmap.PrivateServiceCandidate{
+			{AccessGroups: []string{"g-dst"}, Zone: nmdata.CustomZone{Domain: "svc.example.com", Records: []nmdata.SimpleRecord{{Name: "s", Type: 1, RData: "100.64.0.8"}}}},
+		}
+		return nmd
+	}
+
+	nmd := build()
+	groupSnapshots := make(map[string][]string, len(nmd.Groups))
+	for id, g := range nmd.Groups {
+		groupSnapshots[id] = append([]string(nil), g.Peers...)
+	}
+
+	first := nmd.GetPeerNetworkMapComponents(targetID, zone)
+	_ = nmd.GetPeerNetworkMapComponents("peer-src", zone)
+	second := nmd.GetPeerNetworkMapComponents(targetID, zone)
+
+	for id, g := range nmd.Groups {
+		assert.Equal(t, groupSnapshots[id], g.Peers, "group %s mutated in the store", id)
+	}
+
+	for name, field := range map[string]any{
+		"Peers":               first.Peers,
+		"PostureFailedPeers":  first.PostureFailedPeers,
+		"RoutersMap":          first.RoutersMap,
+		"RouterPeers":         first.RouterPeers,
+		"NetworkResources":    first.NetworkResources,
+		"NameServerGroups":    first.NameServerGroups,
+		"AccountZones":        first.AccountZones,
+		"AllDNSRecords":       first.AllDNSRecords,
+		"AllowedUserIDs":      first.AllowedUserIDs,
+		"GroupIDToUserIDs":    first.GroupIDToUserIDs,
+		"ResourcePoliciesMap": first.ResourcePoliciesMap,
+	} {
+		require.NotEmpty(t, field, "fixture must populate %s or the determinism check is vacuous", name)
+	}
+
+	assert.Equal(t, first.Peers, second.Peers)
+	assert.Equal(t, first.Groups, second.Groups)
+	assert.Equal(t, first.Policies, second.Policies)
+	assert.Equal(t, first.Routes, second.Routes)
+	assert.Equal(t, first.PostureFailedPeers, second.PostureFailedPeers)
+	assert.Equal(t, first.ResourcePoliciesMap, second.ResourcePoliciesMap)
+	assert.Equal(t, first.RoutersMap, second.RoutersMap)
+	assert.Equal(t, first.RouterPeers, second.RouterPeers)
+	assert.Equal(t, first.NetworkResources, second.NetworkResources)
+	assert.Equal(t, first.NameServerGroups, second.NameServerGroups)
+	assert.Equal(t, first.AccountZones, second.AccountZones)
+	assert.Equal(t, first.AllDNSRecords, second.AllDNSRecords)
+	assert.Equal(t, first.AllowedUserIDs, second.AllowedUserIDs)
+	assert.Equal(t, first.GroupIDToUserIDs, second.GroupIDToUserIDs)
+}
+
+func TestPrecomputePostureValidation(t *testing.T) {
+	newFixture := func() *networkmap.NetworkMapData {
+		target := newPeer(targetID, 1)
+		srcPass := newPeer("peer-src-pass", 2)
+		srcFail := newPeer("peer-src-fail", 3)
+		srcFail.Meta.WtVersion = failingVersion
+		other := newPeer("peer-other", 4)
+		other.Meta.WtVersion = failingVersion
+
+		nmd := newNMD(target, srcPass, srcFail, other)
+		addVersionCheck(nmd, "pc-1", postureMinVersion)
+		addGroup(nmd, "g-src", srcPass.ID, srcFail.ID)
+		addGroup(nmd, "g-dst", targetID)
+		addGroup(nmd, "g-open", srcPass.ID, srcFail.ID, other.ID)
+
+		checked := newPolicy("p-checked", newRule([]string{"g-src"}, []string{"g-dst"}))
+		checked.SourcePostureChecks = []string{"pc-1"}
+		open := newPolicy("p-open", newRule([]string{"g-open"}, []string{"g-dst"}))
+		disabled := newPolicy("p-disabled", newRule([]string{"g-open"}, []string{"g-dst"}))
+		disabled.Enabled = false
+		disabled.SourcePostureChecks = []string{"pc-1"}
+		nmd.Policies = []*nmdata.Policy{checked, open, disabled}
+
+		return nmd
+	}
+
+	type snapshot struct {
+		peers              []string
+		postureFailedPeers map[string]map[string]struct{}
+	}
+	snapshotAll := func(nmd *networkmap.NetworkMapData) map[string]snapshot {
+		out := make(map[string]snapshot, len(nmd.Peers))
+		for peerID := range nmd.Peers {
+			c := compute(nmd, peerID)
+			out[peerID] = snapshot{peers: peerIDSet(c.Peers), postureFailedPeers: c.PostureFailedPeers}
+		}
+		return out
+	}
+
+	t.Run("memoized results match direct evaluation", func(t *testing.T) {
+		nmd := newFixture()
+		direct := snapshotAll(nmd)
+
+		nmd.PrecomputePostureValidation()
+		memoized := snapshotAll(nmd)
+
+		require.Len(t, memoized, len(direct))
+		for peerID, want := range direct {
+			assert.ElementsMatch(t, want.peers, memoized[peerID].peers, "visible peers changed for %s", peerID)
+			assert.Equal(t, want.postureFailedPeers, memoized[peerID].postureFailedPeers, "posture failures changed for %s", peerID)
+		}
+	})
+
+	t.Run("only source peers of enabled checked policies are evaluated", func(t *testing.T) {
+		nmd := newFixture()
+		nmd.PrecomputePostureValidation()
+
+		assert.Equal(t, map[string]map[string]bool{
+			"pc-1": {"peer-src-pass": true, "peer-src-fail": false},
+		}, nmd.PostureValidation)
+	})
+
+	t.Run("peer source resources are evaluated", func(t *testing.T) {
+		nmd := newFixture()
+		resourcePolicy := newPolicy("p-resource", newRule(nil, []string{"g-dst"}))
+		resourcePolicy.Rules[0].SourceResource = nmdata.Resource{ID: "peer-other", Type: string(nbtypes.ResourceTypePeer)}
+		resourcePolicy.SourcePostureChecks = []string{"pc-1"}
+		nmd.Policies = append(nmd.Policies, resourcePolicy)
+
+		nmd.PrecomputePostureValidation()
+
+		assert.Equal(t, map[string]bool{"peer-src-pass": true, "peer-src-fail": false, "peer-other": false},
+			nmd.PostureValidation["pc-1"])
+	})
+
+	t.Run("memoized result wins over direct evaluation", func(t *testing.T) {
+		nmd := newFixture()
+		nmd.PostureValidation = map[string]map[string]bool{
+			"pc-1": {"peer-src-pass": false, "peer-src-fail": true},
+		}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, map[string]map[string]struct{}{"pc-1": {"peer-src-pass": {}}}, c.PostureFailedPeers)
+	})
+
+	t.Run("no posture checks clears the memo", func(t *testing.T) {
+		nmd := newFixture()
+		nmd.PrecomputePostureValidation()
+		require.NotEmpty(t, nmd.PostureValidation)
+
+		nmd.PostureChecks = nil
+		nmd.PrecomputePostureValidation()
+
+		assert.Nil(t, nmd.PostureValidation)
+	})
+
+	t.Run("unresolvable check id memoized as passing", func(t *testing.T) {
+		nmd := newFixture()
+		nmd.Policies[0].SourcePostureChecks = []string{"pc-ghost"}
+		nmd.PrecomputePostureValidation()
+
+		require.Contains(t, nmd.PostureValidation, "pc-ghost")
+		assert.Nil(t, nmd.PostureValidation["pc-ghost"])
+
+		c := compute(nmd, targetID)
+		assert.ElementsMatch(t, []string{targetID, "peer-src-pass", "peer-src-fail", "peer-other"}, peerIDSet(c.Peers))
+		assert.Empty(t, c.PostureFailedPeers)
+	})
+
+	t.Run("peers missing from the memo fall back to direct evaluation", func(t *testing.T) {
+		nmd := newFixture()
+		nmd.PostureValidation = map[string]map[string]bool{"pc-1": {"peer-src-pass": true}}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, map[string]map[string]struct{}{"pc-1": {"peer-src-fail": {}}}, c.PostureFailedPeers)
+	})
+}
+
+func TestNetworkMapData_GetPeerGroups(t *testing.T) {
+	target := newPeer(targetID, 1)
+	other := newPeer("peer-other", 2)
+	nmd := newNMD(target, other)
+	addGroup(nmd, "g-1", targetID, other.ID)
+	addGroup(nmd, "g-2", targetID)
+	addGroup(nmd, "g-3", other.ID)
+	nmd.Groups["g-nil"] = nil
+
+	assert.Equal(t, map[string]struct{}{"g-1": {}, "g-2": {}}, nmd.GetPeerGroups(targetID))
+	assert.Empty(t, nmd.GetPeerGroups("missing"))
+}
diff --git a/shared/management/networkmap/networkmapdata.go b/shared/management/networkmap/networkmapdata.go
new file mode 100644
index 000000000..e27605d64
--- /dev/null
+++ b/shared/management/networkmap/networkmapdata.go
@@ -0,0 +1,79 @@
+package networkmap
+
+import (
+	"sync"
+
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+// NetworkMapData is a dependency-light, slim twin of the server Account. It
+// carries only the state GetPeerNetworkMapComponents needs, expressed in the
+// fresh nmdata twin types. A builder converts an Account into a NetworkMapData
+// once per account; the per-peer components calculation then runs on this twin
+// with no reference back to the Account.
+type NetworkMapData struct { //nolint:revive // established name across the codebase
+	Peers            map[string]*nmdata.Peer
+	Groups           map[string]*nmdata.Group
+	Policies         []*nmdata.Policy
+	Routes           []*nmdata.Route
+	NameServerGroups []*nmdata.NameServerGroup
+	NetworkResources []*nmdata.NetworkResource
+
+	Network         *nmdata.Network
+	DNSSettings     *nmdata.DNSSettings
+	AccountSettings *nmdata.AccountSettingsInfo
+
+	PostureChecks map[string]*nmdata.PostureChecks
+
+	// PostureValidation holds the precomputed posture-check results, keyed by
+	// posture check ID then peer ID. Filled by PrecomputePostureValidation; a
+	// present but nil inner map marks a check ID that resolves to no posture
+	// check, which the calc treats as passing.
+	PostureValidation map[string]map[string]bool
+
+	AllowedUserIDs            map[string]struct{}
+	NetworkXIDToPublicID      map[string]string
+	PostureCheckXIDToPublicID map[string]string
+	ValidatedPeers            map[string]struct{}
+	ResourcePolicies          map[string][]*nmdata.Policy
+	Routers                   map[string]map[string]*nmdata.NetworkRouter
+	GroupIDToUserIDs          map[string][]string
+	DNSDomain                 string
+
+	// ProxyTargetedDomainResourceIDs is the account-level half of
+	// forcesRoutingPeerDNSResolution: domain network resources targeted by an
+	// enabled reverse-proxy service.
+	ProxyTargetedDomainResourceIDs map[string]struct{}
+
+	AppliedZoneCandidates    []AppliedZoneCandidate
+	PrivateServiceCandidates []PrivateServiceCandidate
+
+	// Services are the account's reverse-proxy services, persisted ones and
+	// the in-memory ones synthesised from agent-network state. They are the
+	// source of the proxy ACLs injectProxyPolicies synthesises, which no
+	// builder can load because they are never written to the database.
+	Services []*nmdata.Service
+
+	peerGroupsOnce sync.Once
+	peerGroupsIdx  map[string]map[string]struct{}
+
+	proxyPoliciesOnce sync.Once
+}
+
+// AppliedZoneCandidate is an account-level custom DNS zone reduced to the
+// per-peer decision the components calc still makes: include the zone only when
+// the peer belongs to one of its distribution groups. Record conversion is done
+// once at build time.
+type AppliedZoneCandidate struct {
+	DistributionGroups []string
+	Zone               nmdata.CustomZone
+}
+
+// PrivateServiceCandidate is a single private service's synthesized records,
+// carried per apex zone. The builder resolves proxy-cluster connectivity and
+// domain-suffix matching once; the calc merges the candidates whose AccessGroups
+// the peer belongs to, grouped by Zone.Domain.
+type PrivateServiceCandidate struct {
+	AccessGroups []string
+	Zone         nmdata.CustomZone
+}
diff --git a/shared/management/networkmap/nmdata/account_settings.go b/shared/management/networkmap/nmdata/account_settings.go
new file mode 100644
index 000000000..57e29e838
--- /dev/null
+++ b/shared/management/networkmap/nmdata/account_settings.go
@@ -0,0 +1,18 @@
+package nmdata
+
+import "time"
+
+// AccountSettingsInfo is the slim twin of types.AccountSettingsInfo.
+type AccountSettingsInfo struct {
+	PeerLoginExpirationEnabled      bool
+	PeerLoginExpiration             time.Duration
+	PeerInactivityExpirationEnabled bool
+	PeerInactivityExpiration        time.Duration
+	DNSDomain                       string
+	IPv6EnabledGroups               []string
+	RoutingPeerDNSResolutionEnabled bool
+	LazyConnectionEnabled           bool
+	AutoUpdateVersion               string
+	AutoUpdateAlways                bool
+	MetricsPushEnabled              bool
+}
diff --git a/shared/management/networkmap/nmdata/dns.go b/shared/management/networkmap/nmdata/dns.go
new file mode 100644
index 000000000..fe681af1a
--- /dev/null
+++ b/shared/management/networkmap/nmdata/dns.go
@@ -0,0 +1,18 @@
+package nmdata
+
+// SimpleRecord is the slim twin of dns.SimpleRecord.
+type SimpleRecord struct {
+	Name  string
+	Type  int
+	Class string
+	TTL   int
+	RData string
+}
+
+// CustomZone is the slim twin of dns.CustomZone.
+type CustomZone struct {
+	Domain               string
+	Records              []SimpleRecord
+	SearchDomainDisabled bool
+	NonAuthoritative     bool
+}
diff --git a/shared/management/networkmap/nmdata/dns_settings.go b/shared/management/networkmap/nmdata/dns_settings.go
new file mode 100644
index 000000000..69fd5f517
--- /dev/null
+++ b/shared/management/networkmap/nmdata/dns_settings.go
@@ -0,0 +1,6 @@
+package nmdata
+
+// DNSSettings is the slim twin of types.DNSSettings.
+type DNSSettings struct {
+	DisabledManagementGroups []string
+}
diff --git a/shared/management/networkmap/nmdata/group.go b/shared/management/networkmap/nmdata/group.go
new file mode 100644
index 000000000..1cd2cd15e
--- /dev/null
+++ b/shared/management/networkmap/nmdata/group.go
@@ -0,0 +1,30 @@
+package nmdata
+
+import "slices"
+
+// GroupAllName is the reserved name of the default group that contains every
+// peer in an account.
+const GroupAllName = "All"
+
+// Group is the slim twin of types.Group.
+type Group struct {
+	ID        string
+	Name      string
+	PublicID  string
+	Peers     []string
+	Resources []Resource
+}
+
+func (g *Group) IsGroupAll() bool {
+	return g.Name == GroupAllName
+}
+
+func (g *Group) Copy() *Group {
+	return &Group{
+		ID:        g.ID,
+		Name:      g.Name,
+		PublicID:  g.PublicID,
+		Peers:     slices.Clone(g.Peers),
+		Resources: slices.Clone(g.Resources),
+	}
+}
diff --git a/shared/management/networkmap/nmdata/group_test.go b/shared/management/networkmap/nmdata/group_test.go
new file mode 100644
index 000000000..20aaa240f
--- /dev/null
+++ b/shared/management/networkmap/nmdata/group_test.go
@@ -0,0 +1,84 @@
+package nmdata
+
+import (
+	"reflect"
+	"testing"
+)
+
+// TestGroupCopy_AllFieldsCopied fills every Group field with a unique non-zero
+// value derived from its field path, so a field added to Group but forgotten
+// in Copy fails here by name without the test needing an update. The unique
+// per-path values also catch fields swapped inside Copy.
+func TestGroupCopy_AllFieldsCopied(t *testing.T) {
+	src := &Group{}
+	seed := 0
+	fillValue(t, reflect.ValueOf(src).Elem(), "Group", &seed)
+
+	copied := src.Copy()
+
+	srcV := reflect.ValueOf(src).Elem()
+	copiedV := reflect.ValueOf(copied).Elem()
+	for i := 0; i < srcV.NumField(); i++ {
+		name := srcV.Type().Field(i).Name
+		if !reflect.DeepEqual(srcV.Field(i).Interface(), copiedV.Field(i).Interface()) {
+			t.Errorf("field %s not copied: src=%#v copy=%#v",
+				name, srcV.Field(i).Interface(), copiedV.Field(i).Interface())
+		}
+	}
+
+	for i := 0; i < srcV.NumField(); i++ {
+		f := srcV.Field(i)
+		if f.Kind() != reflect.Slice || f.Len() == 0 {
+			continue
+		}
+		name := srcV.Type().Field(i).Name
+		fillValue(t, f.Index(0), name+"-mutated", &seed)
+		if reflect.DeepEqual(f.Interface(), copiedV.Field(i).Interface()) {
+			t.Errorf("field %s shares memory with the copy", name)
+		}
+	}
+}
+
+// fillValue sets v to a deterministic non-zero value derived from its field
+// path. Kinds it does not handle fail the test loudly, so the filler is
+// extended together with the struct instead of silently under-testing new
+// fields.
+func fillValue(t *testing.T, v reflect.Value, path string, seed *int) {
+	t.Helper()
+
+	switch v.Kind() {
+	case reflect.String:
+		v.SetString(path)
+	case reflect.Bool:
+		v.SetBool(true)
+	case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+		*seed++
+		v.SetInt(int64(*seed))
+	case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
+		*seed++
+		v.SetUint(uint64(*seed))
+	case reflect.Float32, reflect.Float64:
+		*seed++
+		v.SetFloat(float64(*seed))
+	case reflect.Slice:
+		s := reflect.MakeSlice(v.Type(), 2, 2)
+		fillValue(t, s.Index(0), path+"[0]", seed)
+		fillValue(t, s.Index(1), path+"[1]", seed)
+		v.Set(s)
+	case reflect.Struct:
+		settable := 0
+		for i := 0; i < v.NumField(); i++ {
+			f := v.Field(i)
+			if !f.CanSet() {
+				continue
+			}
+			settable++
+			fillValue(t, f, path+"."+v.Type().Field(i).Name, seed)
+		}
+		if settable == 0 {
+			t.Fatalf("struct %s at %s has no settable fields — extend fillValue to construct it", v.Type(), path)
+		}
+	default:
+		t.Fatalf("unsupported kind %s at %s — extend fillValue", v.Kind(), path)
+	}
+}
diff --git a/shared/management/networkmap/nmdata/nameserver.go b/shared/management/networkmap/nmdata/nameserver.go
new file mode 100644
index 000000000..2698dd8d0
--- /dev/null
+++ b/shared/management/networkmap/nmdata/nameserver.go
@@ -0,0 +1,24 @@
+package nmdata
+
+import "net/netip"
+
+// NameServerGroup is the slim twin of dns.NameServerGroup.
+type NameServerGroup struct {
+	ID                   string
+	PublicID             string
+	Name                 string
+	Description          string
+	NameServers          []NameServer
+	Groups               []string
+	Primary              bool
+	Domains              []string
+	Enabled              bool
+	SearchDomainsEnabled bool
+}
+
+// NameServer is the slim twin of dns.NameServer.
+type NameServer struct {
+	IP     netip.Addr
+	NSType int
+	Port   int
+}
diff --git a/shared/management/networkmap/nmdata/network.go b/shared/management/networkmap/nmdata/network.go
new file mode 100644
index 000000000..72b6502ef
--- /dev/null
+++ b/shared/management/networkmap/nmdata/network.go
@@ -0,0 +1,16 @@
+package nmdata
+
+import "net"
+
+// Network is the slim twin of types.Network.
+type Network struct {
+	Identifier string
+	Net        net.IPNet
+	NetV6      net.IPNet
+	Dns        string
+	Serial     int64
+}
+
+func (n *Network) CurrentSerial() uint64 {
+	return uint64(n.Serial)
+}
diff --git a/shared/management/networkmap/nmdata/network_resource.go b/shared/management/networkmap/nmdata/network_resource.go
new file mode 100644
index 000000000..44f3c477b
--- /dev/null
+++ b/shared/management/networkmap/nmdata/network_resource.go
@@ -0,0 +1,18 @@
+package nmdata
+
+import "net/netip"
+
+// NetworkResource is the slim twin of resources/types.NetworkResource.
+type NetworkResource struct {
+	ID          string
+	NetworkID   string
+	AccountID   string
+	PublicID    string
+	Name        string
+	Description string
+	Type        string
+	Address     string // TODO: isn't persisted in the DB
+	Domain      string
+	Prefix      netip.Prefix
+	Enabled     bool
+}
diff --git a/shared/management/networkmap/nmdata/network_router.go b/shared/management/networkmap/nmdata/network_router.go
new file mode 100644
index 000000000..fd5df37c4
--- /dev/null
+++ b/shared/management/networkmap/nmdata/network_router.go
@@ -0,0 +1,10 @@
+package nmdata
+
+// NetworkRouter is the slim twin of routers/types.NetworkRouter.
+type NetworkRouter struct {
+	PublicID   string
+	PeerGroups []string
+	Masquerade bool
+	Metric     int
+	Enabled    bool
+}
diff --git a/shared/management/networkmap/nmdata/peer.go b/shared/management/networkmap/nmdata/peer.go
new file mode 100644
index 000000000..3ceb1dbc1
--- /dev/null
+++ b/shared/management/networkmap/nmdata/peer.go
@@ -0,0 +1,129 @@
+package nmdata
+
+import (
+	"net"
+	"net/netip"
+	"slices"
+	"time"
+)
+
+// Peer capability constants mirror the proto enum values.
+const (
+	PeerCapabilitySourcePrefixes      int32 = 1
+	PeerCapabilityIPv6Overlay         int32 = 2
+	PeerCapabilityComponentNetworkMap int32 = 3
+)
+
+// Peer is the slim twin of peer.Peer.
+type Peer struct {
+	ID                     string
+	Key                    string
+	SSHKey                 string
+	DNSLabel               string
+	UserID                 string
+	SSHEnabled             bool
+	LoginExpirationEnabled bool
+	LastLogin              *time.Time
+	IP                     netip.Addr
+	IPv6                   netip.Addr
+	RequiresApproval       bool
+	ExtraDNSLabels         []string
+	Meta                   PeerSystemMeta
+	ProxyMeta              ProxyMeta
+	Location               PeerLocation
+}
+
+// ProxyMeta is the slim twin of peer.ProxyMeta.
+type ProxyMeta struct {
+	Embedded bool
+	Cluster  string
+}
+
+// PeerSystemMeta is the slim twin of peer.PeerSystemMeta.
+type PeerSystemMeta struct {
+	WtVersion          string
+	GoOS               string
+	OSVersion          string
+	KernelVersion      string
+	NetworkAddresses   []NetworkAddress
+	Files              []File
+	Capabilities       []int32
+	Flags              Flags
+	SyncMessageVersion int
+}
+
+// Flags is the slim twin of peer.Flags.
+type Flags struct {
+	ServerSSHAllowed bool
+	DisableIPv6      bool
+}
+
+// NetworkAddress is the slim twin of peer.NetworkAddress.
+type NetworkAddress struct {
+	NetIP netip.Prefix
+}
+
+// File is the slim twin of peer.File.
+type File struct {
+	Path             string
+	ProcessIsRunning bool
+}
+
+// PeerLocation is the slim twin of peer.Location.
+type PeerLocation struct {
+	CountryCode  string
+	CityName     string
+	ConnectionIP net.IP
+}
+
+func (p *Peer) HasCapability(capability int32) bool {
+	return slices.Contains(p.Meta.Capabilities, capability)
+}
+
+func (p *Peer) SupportsIPv6() bool {
+	return !p.Meta.Flags.DisableIPv6 && p.HasCapability(PeerCapabilityIPv6Overlay)
+}
+
+func (p *Peer) SupportsSourcePrefixes() bool {
+	return p.HasCapability(PeerCapabilitySourcePrefixes)
+}
+
+func (p *Peer) AddedWithSSOLogin() bool {
+	return p.UserID != ""
+}
+
+func (p *Peer) FQDN(dnsDomain string) string {
+	if dnsDomain == "" {
+		return ""
+	}
+	return p.DNSLabel + "." + dnsDomain
+}
+
+func (p *Peer) GetLastLogin() time.Time {
+	if p.LastLogin != nil {
+		return *p.LastLogin
+	}
+	return time.Time{}
+}
+
+// SessionExpiresAt mirrors peer.Peer.SessionExpiresAt.
+func (p *Peer) SessionExpiresAt(accountExpirationEnabled bool, expiresIn time.Duration) time.Time {
+	if !accountExpirationEnabled || !p.AddedWithSSOLogin() || !p.LoginExpirationEnabled {
+		return time.Time{}
+	}
+	last := p.GetLastLogin()
+	if last.IsZero() {
+		return time.Time{}
+	}
+	return last.Add(expiresIn).UTC()
+}
+
+func (p *Peer) LoginExpired(expiresIn time.Duration) (bool, time.Duration) {
+	if !p.AddedWithSSOLogin() || !p.LoginExpirationEnabled {
+		return false, 0
+	}
+	expiresAt := p.GetLastLogin().Add(expiresIn)
+	now := time.Now()
+	timeLeft := expiresAt.Sub(now)
+	return timeLeft <= 0, timeLeft
+}
diff --git a/shared/management/networkmap/nmdata/policy.go b/shared/management/networkmap/nmdata/policy.go
new file mode 100644
index 000000000..e6389a8c4
--- /dev/null
+++ b/shared/management/networkmap/nmdata/policy.go
@@ -0,0 +1,98 @@
+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
+	SessionPubKey       string
+	SessionDisplayName  string
+}
+
+// RulePortRange is the slim twin of types.RulePortRange.
+type RulePortRange struct {
+	Start uint16
+	End   uint16
+}
+
+// Resource is the slim twin of types.Resource.
+type Resource struct {
+	ID   string
+	Type string
+}
+
+func (p *Policy) SourceGroups() []string {
+	if len(p.Rules) == 1 && p.Rules[0] != nil {
+		return p.Rules[0].Sources
+	}
+	groups := make(map[string]struct{}, len(p.Rules))
+	for _, rule := range p.Rules {
+		if rule == nil {
+			continue
+		}
+		for _, source := range rule.Sources {
+			groups[source] = struct{}{}
+		}
+	}
+
+	groupIDs := make([]string, 0, len(groups))
+	for groupID := range groups {
+		groupIDs = append(groupIDs, groupID)
+	}
+
+	return groupIDs
+}
+
+// PolicyRuleImpliesLegacySSH is the twin-typed sibling of types.PolicyRuleImpliesLegacySSH.
+func PolicyRuleImpliesLegacySSH(rule *PolicyRule) bool {
+	return rule.Protocol == policyRuleProtocolALL ||
+		(rule.Protocol == policyRuleProtocolTCP && (portsIncludesSSH(rule.Ports) || portRangeIncludesSSH(rule.PortRanges)))
+}
+
+func portRangeIncludesSSH(portRanges []RulePortRange) bool {
+	for _, pr := range portRanges {
+		if (pr.Start <= defaultSSHPortNumber && pr.End >= defaultSSHPortNumber) || (pr.Start <= nativeSSHPortNumber && pr.End >= nativeSSHPortNumber) {
+			return true
+		}
+	}
+	return false
+}
+
+func portsIncludesSSH(ports []string) bool {
+	for _, port := range ports {
+		if port == defaultSSHPortString || port == nativeSSHPortString {
+			return true
+		}
+	}
+	return false
+}
diff --git a/shared/management/networkmap/nmdata/posture.go b/shared/management/networkmap/nmdata/posture.go
new file mode 100644
index 000000000..dc1753791
--- /dev/null
+++ b/shared/management/networkmap/nmdata/posture.go
@@ -0,0 +1,67 @@
+package nmdata
+
+const (
+	checkActionAllow = "allow"
+	checkActionDeny  = "deny"
+)
+
+// PostureChecks is the slim twin of posture.Checks.
+type PostureChecks struct {
+	ID     string
+	Checks ChecksDefinition
+}
+
+// ChecksDefinition is the slim twin of posture.ChecksDefinition.
+type ChecksDefinition struct {
+	NBVersionCheck        *NBVersionCheck
+	OSVersionCheck        *OSVersionCheck
+	GeoLocationCheck      *GeoLocationCheck
+	PeerNetworkRangeCheck *PeerNetworkRangeCheck
+	ProcessCheck          *ProcessCheck
+}
+
+// Check is the slim twin of posture.Check. It is sealed: only the check types
+// in this package implement it.
+type Check interface {
+	check(peer *Peer) (bool, error)
+}
+
+// Passes reports whether the peer satisfies every check in this bundle. It
+// mirrors the server posture path: a check returning (false, _) — including on
+// an evaluation error — fails the bundle.
+func (pc *PostureChecks) Passes(peer *Peer) bool {
+	return PassesChecks(pc.GetChecks(), peer)
+}
+
+// PassesChecks is Passes over an already built check set, for callers that
+// evaluate many peers against the same bundle.
+func PassesChecks(checks []Check, peer *Peer) bool {
+	for _, c := range checks {
+		valid, _ := c.check(peer)
+		if !valid {
+			return false
+		}
+	}
+	return true
+}
+
+// GetChecks returns the initialized checks in the same order as posture.Checks.GetChecks.
+func (pc *PostureChecks) GetChecks() []Check {
+	var checks []Check
+	if pc.Checks.NBVersionCheck != nil {
+		checks = append(checks, pc.Checks.NBVersionCheck)
+	}
+	if pc.Checks.OSVersionCheck != nil {
+		checks = append(checks, pc.Checks.OSVersionCheck)
+	}
+	if pc.Checks.GeoLocationCheck != nil {
+		checks = append(checks, pc.Checks.GeoLocationCheck)
+	}
+	if pc.Checks.PeerNetworkRangeCheck != nil {
+		checks = append(checks, pc.Checks.PeerNetworkRangeCheck)
+	}
+	if pc.Checks.ProcessCheck != nil {
+		checks = append(checks, pc.Checks.ProcessCheck)
+	}
+	return checks
+}
diff --git a/shared/management/networkmap/nmdata/posture_geo_location.go b/shared/management/networkmap/nmdata/posture_geo_location.go
new file mode 100644
index 000000000..18b0919b2
--- /dev/null
+++ b/shared/management/networkmap/nmdata/posture_geo_location.go
@@ -0,0 +1,45 @@
+package nmdata
+
+import "fmt"
+
+// GeoLocation is the slim twin of posture.Location.
+type GeoLocation struct {
+	CountryCode string
+	CityName    string
+}
+
+// GeoLocationCheck is the slim twin of posture.GeoLocationCheck.
+type GeoLocationCheck struct {
+	Locations []GeoLocation
+	Action    string
+}
+
+func (g *GeoLocationCheck) check(peer *Peer) (bool, error) {
+	if peer.Location.CountryCode == "" && peer.Location.CityName == "" {
+		return false, fmt.Errorf("peer's location is not set")
+	}
+
+	for _, loc := range g.Locations {
+		if loc.CountryCode == peer.Location.CountryCode {
+			if loc.CityName == "" || loc.CityName == peer.Location.CityName {
+				switch g.Action {
+				case checkActionDeny:
+					return false, nil
+				case checkActionAllow:
+					return true, nil
+				default:
+					return false, fmt.Errorf("invalid geo location action: %s", g.Action)
+				}
+			}
+		}
+	}
+
+	if g.Action == checkActionDeny {
+		return true, nil
+	}
+	if g.Action == checkActionAllow {
+		return false, nil
+	}
+
+	return false, fmt.Errorf("invalid geo location action: %s", g.Action)
+}
diff --git a/shared/management/networkmap/nmdata/posture_nb_version.go b/shared/management/networkmap/nmdata/posture_nb_version.go
new file mode 100644
index 000000000..3d82a4c80
--- /dev/null
+++ b/shared/management/networkmap/nmdata/posture_nb_version.go
@@ -0,0 +1,38 @@
+package nmdata
+
+import (
+	"strings"
+
+	"github.com/hashicorp/go-version"
+)
+
+// NBVersionCheck is the slim twin of posture.NBVersionCheck.
+type NBVersionCheck struct {
+	MinVersion string
+}
+
+func (n *NBVersionCheck) check(peer *Peer) (bool, error) {
+	return meetsMinVersion(n.MinVersion, peer.Meta.WtVersion)
+}
+
+func meetsMinVersion(minVer, peerVer string) (bool, error) {
+	peerVer = sanitizeVersion(peerVer)
+	minVer = sanitizeVersion(minVer)
+
+	peerNBVer, err := version.NewVersion(peerVer)
+	if err != nil {
+		return false, err
+	}
+
+	constraints, err := version.NewConstraint(">= " + minVer)
+	if err != nil {
+		return false, err
+	}
+
+	return constraints.Check(peerNBVer), nil
+}
+
+func sanitizeVersion(v string) string {
+	parts := strings.Split(v, "-")
+	return parts[0]
+}
diff --git a/shared/management/networkmap/nmdata/posture_network.go b/shared/management/networkmap/nmdata/posture_network.go
new file mode 100644
index 000000000..d8dd2cf00
--- /dev/null
+++ b/shared/management/networkmap/nmdata/posture_network.go
@@ -0,0 +1,62 @@
+package nmdata
+
+import (
+	"fmt"
+	"net/netip"
+)
+
+// PeerNetworkRangeCheck is the slim twin of posture.PeerNetworkRangeCheck.
+type PeerNetworkRangeCheck struct {
+	Action string
+	Ranges []netip.Prefix
+}
+
+func (p *PeerNetworkRangeCheck) check(peer *Peer) (bool, error) {
+	peerPrefixes := make([]netip.Prefix, 0, len(peer.Meta.NetworkAddresses)+1)
+	for _, peerNetAddr := range peer.Meta.NetworkAddresses {
+		peerPrefixes = append(peerPrefixes, peerNetAddr.NetIP)
+	}
+	if connIP := peer.Location.ConnectionIP; len(connIP) > 0 {
+		if addr, ok := netip.AddrFromSlice(connIP); ok {
+			addr = addr.Unmap()
+			peerPrefixes = append(peerPrefixes, netip.PrefixFrom(addr, addr.BitLen()))
+		}
+	}
+
+	if len(peerPrefixes) == 0 {
+		return false, fmt.Errorf("peer's does not contain peer network range addresses")
+	}
+
+	for _, peerPrefix := range peerPrefixes {
+		for _, rangePrefix := range p.Ranges {
+			if !prefixContains(rangePrefix, peerPrefix) {
+				continue
+			}
+			switch p.Action {
+			case checkActionDeny:
+				return false, nil
+			case checkActionAllow:
+				return true, nil
+			default:
+				return false, fmt.Errorf("invalid peer network range check action: %s", p.Action)
+			}
+		}
+	}
+
+	if p.Action == checkActionDeny {
+		return true, nil
+	}
+	if p.Action == checkActionAllow {
+		return false, nil
+	}
+
+	return false, fmt.Errorf("invalid peer network range check action: %s", p.Action)
+}
+
+func prefixContains(outer, inner netip.Prefix) bool {
+	outer = outer.Masked()
+	inner = inner.Masked()
+	return outer.Bits() <= inner.Bits() &&
+		outer.Addr().BitLen() == inner.Addr().BitLen() &&
+		outer.Contains(inner.Addr())
+}
diff --git a/shared/management/networkmap/nmdata/posture_os_version.go b/shared/management/networkmap/nmdata/posture_os_version.go
new file mode 100644
index 000000000..779bd2ac3
--- /dev/null
+++ b/shared/management/networkmap/nmdata/posture_os_version.go
@@ -0,0 +1,79 @@
+package nmdata
+
+import (
+	"strings"
+
+	"github.com/hashicorp/go-version"
+)
+
+// MinVersionCheck is the slim twin of posture.MinVersionCheck.
+type MinVersionCheck struct {
+	MinVersion string
+}
+
+// MinKernelVersionCheck is the slim twin of posture.MinKernelVersionCheck.
+type MinKernelVersionCheck struct {
+	MinKernelVersion string
+}
+
+// OSVersionCheck is the slim twin of posture.OSVersionCheck.
+type OSVersionCheck struct {
+	Android *MinVersionCheck
+	Darwin  *MinVersionCheck
+	Ios     *MinVersionCheck
+	Linux   *MinKernelVersionCheck
+	Windows *MinKernelVersionCheck
+}
+
+func (c *OSVersionCheck) check(peer *Peer) (bool, error) {
+	switch peer.Meta.GoOS {
+	case "android":
+		return checkMinVersion(peer.Meta.OSVersion, c.Android)
+	case "darwin":
+		return checkMinVersion(peer.Meta.OSVersion, c.Darwin)
+	case "ios":
+		return checkMinVersion(peer.Meta.OSVersion, c.Ios)
+	case "linux":
+		kernelVersion := strings.Split(peer.Meta.KernelVersion, "-")[0]
+		return checkMinKernelVersion(kernelVersion, c.Linux)
+	case "windows":
+		return checkMinKernelVersion(peer.Meta.KernelVersion, c.Windows)
+	}
+	return true, nil
+}
+
+func checkMinVersion(peerVersion string, check *MinVersionCheck) (bool, error) {
+	if check == nil {
+		return false, nil
+	}
+
+	peerNBVersion, err := version.NewVersion(peerVersion)
+	if err != nil {
+		return false, err
+	}
+
+	constraints, err := version.NewConstraint(">= " + check.MinVersion)
+	if err != nil {
+		return false, err
+	}
+
+	return constraints.Check(peerNBVersion), nil
+}
+
+func checkMinKernelVersion(peerVersion string, check *MinKernelVersionCheck) (bool, error) {
+	if check == nil {
+		return false, nil
+	}
+
+	peerNBVersion, err := version.NewVersion(peerVersion)
+	if err != nil {
+		return false, err
+	}
+
+	constraints, err := version.NewConstraint(">= " + check.MinKernelVersion)
+	if err != nil {
+		return false, err
+	}
+
+	return constraints.Check(peerNBVersion), nil
+}
diff --git a/shared/management/networkmap/nmdata/posture_process.go b/shared/management/networkmap/nmdata/posture_process.go
new file mode 100644
index 000000000..3d35613b5
--- /dev/null
+++ b/shared/management/networkmap/nmdata/posture_process.go
@@ -0,0 +1,56 @@
+package nmdata
+
+import (
+	"fmt"
+	"slices"
+)
+
+// Process is the slim twin of posture.Process.
+type Process struct {
+	LinuxPath   string
+	MacPath     string
+	WindowsPath string
+}
+
+// ProcessCheck is the slim twin of posture.ProcessCheck.
+type ProcessCheck struct {
+	Processes []Process
+}
+
+func (p *ProcessCheck) check(peer *Peer) (bool, error) {
+	peerActiveProcesses := extractPeerActiveProcesses(peer.Meta.Files)
+
+	var pathSelector func(Process) string
+	switch peer.Meta.GoOS {
+	case "linux":
+		pathSelector = func(process Process) string { return process.LinuxPath }
+	case "darwin":
+		pathSelector = func(process Process) string { return process.MacPath }
+	case "windows":
+		pathSelector = func(process Process) string { return process.WindowsPath }
+	default:
+		return false, fmt.Errorf("unsupported peer's operating system: %s", peer.Meta.GoOS)
+	}
+
+	return p.areAllProcessesRunning(peerActiveProcesses, pathSelector), nil
+}
+
+func (p *ProcessCheck) areAllProcessesRunning(activeProcesses []string, pathSelector func(Process) string) bool {
+	for _, process := range p.Processes {
+		path := pathSelector(process)
+		if path == "" || !slices.Contains(activeProcesses, path) {
+			return false
+		}
+	}
+	return true
+}
+
+func extractPeerActiveProcesses(files []File) []string {
+	activeProcesses := make([]string, 0, len(files))
+	for _, file := range files {
+		if file.ProcessIsRunning {
+			activeProcesses = append(activeProcesses, file.Path)
+		}
+	}
+	return activeProcesses
+}
diff --git a/shared/management/networkmap/nmdata/route.go b/shared/management/networkmap/nmdata/route.go
new file mode 100644
index 000000000..e2301f094
--- /dev/null
+++ b/shared/management/networkmap/nmdata/route.go
@@ -0,0 +1,108 @@
+package nmdata
+
+import (
+	"net/netip"
+	"slices"
+	"strings"
+
+	"github.com/netbirdio/netbird/shared/management/domain"
+)
+
+// NetworkType mirrors route.NetworkType iota values.
+const (
+	NetworkTypeInvalid = 0
+	NetworkTypeIPv4    = 1
+	NetworkTypeIPv6    = 2
+	NetworkTypeDomain  = 3
+
+	haSeparator = "|"
+)
+
+// Route is the slim twin of route.Route.
+type Route struct {
+	ID                  string
+	AccountID           string
+	PublicID            string
+	Network             netip.Prefix
+	Domains             domain.List
+	KeepRoute           bool
+	NetID               string
+	Description         string
+	Peer                string
+	PeerID              string
+	PeerGroups          []string
+	NetworkType         int
+	Masquerade          bool
+	Metric              int
+	Enabled             bool
+	Groups              []string
+	AccessControlGroups []string
+	SkipAutoApply       bool
+}
+
+func (r *Route) Equal(other *Route) bool {
+	if r == nil && other == nil {
+		return true
+	} else if r == nil || other == nil {
+		return false
+	}
+
+	return other.ID == r.ID &&
+		other.Description == r.Description &&
+		other.NetID == r.NetID &&
+		other.Network == r.Network &&
+		slices.Equal(r.Domains, other.Domains) &&
+		other.KeepRoute == r.KeepRoute &&
+		other.NetworkType == r.NetworkType &&
+		other.Peer == r.Peer &&
+		other.PeerID == r.PeerID &&
+		other.Metric == r.Metric &&
+		other.Masquerade == r.Masquerade &&
+		other.Enabled == r.Enabled &&
+		slices.Equal(r.Groups, other.Groups) &&
+		slices.Equal(r.PeerGroups, other.PeerGroups) &&
+		slices.Equal(r.AccessControlGroups, other.AccessControlGroups) &&
+		other.SkipAutoApply == r.SkipAutoApply
+}
+
+func (r *Route) IsDynamic() bool {
+	return r.NetworkType == NetworkTypeDomain
+}
+
+func (r *Route) NetString() string {
+	if r.IsDynamic() && r.Domains != nil {
+		return r.Domains.SafeString()
+	}
+	return r.Network.String()
+}
+
+func (r *Route) GetHAUniqueID() string {
+	return r.NetID + haSeparator + r.NetString()
+}
+
+func (r *Route) GetResourceID() string {
+	return strings.Split(r.ID, ":")[0]
+}
+
+func (r *Route) Copy() *Route {
+	return &Route{
+		ID:                  r.ID,
+		AccountID:           r.AccountID,
+		PublicID:            r.PublicID,
+		Network:             r.Network,
+		Domains:             slices.Clone(r.Domains),
+		KeepRoute:           r.KeepRoute,
+		NetID:               r.NetID,
+		Description:         r.Description,
+		Peer:                r.Peer,
+		PeerID:              r.PeerID,
+		PeerGroups:          slices.Clone(r.PeerGroups),
+		NetworkType:         r.NetworkType,
+		Masquerade:          r.Masquerade,
+		Metric:              r.Metric,
+		Enabled:             r.Enabled,
+		Groups:              slices.Clone(r.Groups),
+		AccessControlGroups: slices.Clone(r.AccessControlGroups),
+		SkipAutoApply:       r.SkipAutoApply,
+	}
+}
diff --git a/shared/management/networkmap/nmdata/service.go b/shared/management/networkmap/nmdata/service.go
new file mode 100644
index 000000000..63557c51e
--- /dev/null
+++ b/shared/management/networkmap/nmdata/service.go
@@ -0,0 +1,25 @@
+package nmdata
+
+// Service is the slim twin of the reverse-proxy service.Service. It carries
+// only the state proxy-policy injection reads: the persisted reverse-proxy
+// services and the in-memory ones synthesised from agent-network state, which
+// are never written to the database.
+type Service struct {
+	ID           string
+	Enabled      bool
+	Private      bool
+	Mode         string
+	ProxyCluster string
+	AccessGroups []string
+	Targets      []*ServiceTarget
+}
+
+// ServiceTarget is the slim twin of service.Target.
+type ServiceTarget struct {
+	Enabled    bool
+	Path       string
+	Port       uint16
+	Protocol   string
+	TargetID   string
+	TargetType string
+}
diff --git a/shared/management/networkmap/peers_custom_zone.go b/shared/management/networkmap/peers_custom_zone.go
new file mode 100644
index 000000000..063844358
--- /dev/null
+++ b/shared/management/networkmap/peers_custom_zone.go
@@ -0,0 +1,111 @@
+package networkmap
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	"github.com/hashicorp/go-multierror"
+	"github.com/miekg/dns"
+	log "github.com/sirupsen/logrus"
+
+	nbdns "github.com/netbirdio/netbird/dns"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const peersZoneRecordTTL = 300
+
+// PeersCustomZone builds the peers DNS zone from twin peer rows. It is the
+// single source of the zone-record logic; Account.GetPeersCustomZone delegates
+// here via twins.
+func PeersCustomZone(ctx context.Context, accountID string, dnsDomain string, peers map[string]*nmdata.Peer, ipv6AllowedPeers map[string]struct{}) nmdata.CustomZone {
+	var merr *multierror.Error
+
+	if dnsDomain == "" {
+		log.WithContext(ctx).Error("no dns domain is set, returning empty zone")
+		return nmdata.CustomZone{}
+	}
+
+	customZone := nmdata.CustomZone{
+		Domain:  dns.Fqdn(dnsDomain),
+		Records: make([]nmdata.SimpleRecord, 0, len(peers)),
+	}
+
+	domainSuffix := "." + dnsDomain
+
+	var sb strings.Builder
+	for _, peer := range peers {
+		if peer == nil {
+			continue
+		}
+		if peer.DNSLabel == "" {
+			merr = multierror.Append(merr, fmt.Errorf("peer %s has an empty DNS label", peer.ID))
+			continue
+		}
+
+		sb.Grow(len(peer.DNSLabel) + len(domainSuffix))
+		sb.WriteString(peer.DNSLabel)
+		sb.WriteString(domainSuffix)
+
+		fqdn := sb.String()
+		customZone.Records = append(customZone.Records, nmdata.SimpleRecord{
+			Name:  fqdn,
+			Type:  int(dns.TypeA),
+			Class: nbdns.DefaultClass,
+			TTL:   peersZoneRecordTTL,
+			RData: peer.IP.String(),
+		})
+		// Only advertise AAAA for peers that have a valid IPv6, whose client supports it,
+		// and that belong to an IPv6-enabled group. Old clients don't configure v6 on their
+		// WireGuard interface, so resolving their AAAA causes connections to hang.
+		// Capability changes (client upgrade/downgrade, --disable-ipv6 toggle) propagate
+		// to other peers via SyncPeer/LoginPeer regardless of version change, so AAAA
+		// records refresh when a peer first reports the IPv6 overlay capability.
+		_, peerAllowed := ipv6AllowedPeers[peer.ID]
+		hasIPv6 := peer.IPv6.IsValid() && peer.SupportsIPv6() && peerAllowed
+		if hasIPv6 {
+			customZone.Records = append(customZone.Records, nmdata.SimpleRecord{
+				Name:  fqdn,
+				Type:  int(dns.TypeAAAA),
+				Class: nbdns.DefaultClass,
+				TTL:   peersZoneRecordTTL,
+				RData: peer.IPv6.String(),
+			})
+		}
+		sb.Reset()
+
+		for _, extraLabel := range peer.ExtraDNSLabels {
+			sb.Grow(len(extraLabel) + len(domainSuffix))
+			sb.WriteString(extraLabel)
+			sb.WriteString(domainSuffix)
+
+			extraFqdn := sb.String()
+			customZone.Records = append(customZone.Records, nmdata.SimpleRecord{
+				Name:  extraFqdn,
+				Type:  int(dns.TypeA),
+				Class: nbdns.DefaultClass,
+				TTL:   peersZoneRecordTTL,
+				RData: peer.IP.String(),
+			})
+			if hasIPv6 {
+				customZone.Records = append(customZone.Records, nmdata.SimpleRecord{
+					Name:  extraFqdn,
+					Type:  int(dns.TypeAAAA),
+					Class: nbdns.DefaultClass,
+					TTL:   peersZoneRecordTTL,
+					RData: peer.IPv6.String(),
+				})
+			}
+			sb.Reset()
+		}
+
+	}
+
+	go func() {
+		if merr != nil {
+			log.WithContext(ctx).Errorf("error generating custom zone for account %s: %v", accountID, merr)
+		}
+	}()
+
+	return customZone
+}
diff --git a/shared/management/networkmap/proxypolicies.go b/shared/management/networkmap/proxypolicies.go
new file mode 100644
index 000000000..7a7c805a6
--- /dev/null
+++ b/shared/management/networkmap/proxypolicies.go
@@ -0,0 +1,209 @@
+package networkmap
+
+import (
+	"fmt"
+	"slices"
+	"strings"
+
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+	"github.com/netbirdio/netbird/shared/management/types"
+)
+
+const (
+	serviceModeUDP = "udp"
+
+	privateServicePortHTTP  = 80
+	privateServicePortHTTPS = 443
+)
+
+// InjectProxyPolicies synthesises the in-memory ACLs that carry reverse-proxy
+// traffic and appends them to the twin's policies. They are never persisted,
+// so no builder can load them: a proxy-access policy lets a cluster's proxy
+// peers reach each enabled target of a service, and a private-access policy
+// lets a private service's AccessGroups reach those proxy peers on HTTP(S).
+//
+// GetPeerNetworkMapComponents calls it, so every caller of the twin gets the
+// same policy set no matter which builder produced it. It runs at most once
+// per twin, and is safe to call again to force the synthesis early.
+func (nmd *NetworkMapData) InjectProxyPolicies() {
+	nmd.proxyPoliciesOnce.Do(nmd.injectProxyPolicies)
+}
+
+func (nmd *NetworkMapData) injectProxyPolicies() {
+	if len(nmd.Services) == 0 {
+		return
+	}
+
+	proxyPeersByCluster := nmd.proxyPeersByCluster()
+	if len(proxyPeersByCluster) == 0 {
+		return
+	}
+
+	for _, svc := range nmd.Services {
+		if svc == nil || !svc.Enabled {
+			continue
+		}
+
+		proxyPeers := proxyPeersByCluster[svc.ProxyCluster]
+		for _, target := range svc.Targets {
+			if target == nil || !target.Enabled {
+				continue
+			}
+			port, ok := resolveTargetPort(target)
+			if !ok {
+				continue
+			}
+			for _, proxyPeer := range proxyPeers {
+				nmd.addInjectedPolicy(proxyAccessPolicy(svc, target, proxyPeer, port))
+			}
+		}
+
+		nmd.injectPrivateServicePolicies(svc, proxyPeers)
+	}
+}
+
+// injectPrivateServicePolicies synthesises AccessGroups → cluster proxy peers on TCP 80/443.
+func (nmd *NetworkMapData) injectPrivateServicePolicies(svc *nmdata.Service, proxyPeers []*nmdata.Peer) {
+	if !svc.Private || len(svc.AccessGroups) == 0 || len(proxyPeers) == 0 {
+		return
+	}
+
+	// A service's AccessGroups can name groups that no longer exist — persisted
+	// services and the agent-network synthesiser both carry the ids verbatim from
+	// their own state. An unresolvable source authorises nothing, so drop it here
+	// rather than let the network-map assembly resolve it to a nil group.
+	sources := nmd.existingGroupIDs(svc.AccessGroups)
+	if len(sources) == 0 {
+		return
+	}
+
+	for _, proxyPeer := range proxyPeers {
+		nmd.addInjectedPolicy(privateAccessPolicy(svc, proxyPeer, sources))
+	}
+}
+
+// addInjectedPolicy appends the policy to the twin's policy set, and to the
+// policies of the network resource it targets — mirroring the account path,
+// where the resource-policy map was built after injection.
+func (nmd *NetworkMapData) addInjectedPolicy(policy *nmdata.Policy) {
+	nmd.Policies = append(nmd.Policies, policy)
+
+	resourceID := policy.Rules[0].DestinationResource.ID
+	if resourceID == "" {
+		return
+	}
+	for _, resource := range nmd.NetworkResources {
+		if resource == nil || !resource.Enabled || resource.ID != resourceID {
+			continue
+		}
+		if nmd.ResourcePolicies == nil {
+			nmd.ResourcePolicies = make(map[string][]*nmdata.Policy)
+		}
+		nmd.ResourcePolicies[resourceID] = append(nmd.ResourcePolicies[resourceID], policy)
+		return
+	}
+}
+
+func proxyAccessPolicy(svc *nmdata.Service, target *nmdata.ServiceTarget, proxyPeer *nmdata.Peer, port uint16) *nmdata.Policy {
+	policyID := fmt.Sprintf("proxy-access-%s-%s-%s", svc.ID, proxyPeer.ID, target.Path)
+
+	protocol := types.PolicyRuleProtocolTCP
+	if svc.Mode == serviceModeUDP {
+		protocol = types.PolicyRuleProtocolUDP
+	}
+
+	return &nmdata.Policy{
+		ID: policyID,
+		// The envelope encoder puts public ids on the wire and degrades to an
+		// empty one when a policy has none. A synthesised policy has no
+		// persisted row to take a public id from, and its own id is already
+		// stable and unique, so it serves as both.
+		PublicID: policyID,
+		Enabled:  true,
+		Rules: []*nmdata.PolicyRule{
+			{
+				ID:                  policyID,
+				PolicyID:            policyID,
+				Enabled:             true,
+				SourceResource:      nmdata.Resource{ID: proxyPeer.ID, Type: string(types.ResourceTypePeer)},
+				DestinationResource: nmdata.Resource{ID: target.TargetID, Type: target.TargetType},
+				Bidirectional:       false,
+				Protocol:            string(protocol),
+				Action:              string(types.PolicyTrafficActionAccept),
+				PortRanges:          []nmdata.RulePortRange{{Start: port, End: port}},
+			},
+		},
+	}
+}
+
+func privateAccessPolicy(svc *nmdata.Service, proxyPeer *nmdata.Peer, accessGroups []string) *nmdata.Policy {
+	policyID := fmt.Sprintf("private-access-%s-%s", svc.ID, proxyPeer.ID)
+
+	return &nmdata.Policy{
+		ID:       policyID,
+		PublicID: policyID,
+		Enabled:  true,
+		Rules: []*nmdata.PolicyRule{
+			{
+				ID:                  policyID,
+				PolicyID:            policyID,
+				Enabled:             true,
+				Sources:             slices.Clone(accessGroups),
+				DestinationResource: nmdata.Resource{ID: proxyPeer.ID, Type: string(types.ResourceTypePeer)},
+				Bidirectional:       false,
+				Protocol:            string(types.PolicyRuleProtocolTCP),
+				Action:              string(types.PolicyTrafficActionAccept),
+				PortRanges: []nmdata.RulePortRange{
+					{Start: privateServicePortHTTP, End: privateServicePortHTTP},
+					{Start: privateServicePortHTTPS, End: privateServicePortHTTPS},
+				},
+			},
+		},
+	}
+}
+
+func resolveTargetPort(target *nmdata.ServiceTarget) (uint16, bool) {
+	if target.Port != 0 {
+		return target.Port, true
+	}
+
+	switch target.Protocol {
+	case "https", "tls":
+		return privateServicePortHTTPS, true
+	case "http":
+		return privateServicePortHTTP, true
+	default:
+		return 0, false
+	}
+}
+
+// proxyPeersByCluster groups the account's embedded proxy peers by the cluster
+// they serve. Sorted by peer ID so the synthesised policy order is stable.
+func (nmd *NetworkMapData) proxyPeersByCluster() map[string][]*nmdata.Peer {
+	var proxyPeers map[string][]*nmdata.Peer
+	for _, peer := range nmd.Peers {
+		if peer == nil || !peer.ProxyMeta.Embedded {
+			continue
+		}
+		if proxyPeers == nil {
+			proxyPeers = make(map[string][]*nmdata.Peer)
+		}
+		proxyPeers[peer.ProxyMeta.Cluster] = append(proxyPeers[peer.ProxyMeta.Cluster], peer)
+	}
+	for _, peers := range proxyPeers {
+		slices.SortFunc(peers, func(a, b *nmdata.Peer) int { return strings.Compare(a.ID, b.ID) })
+	}
+	return proxyPeers
+}
+
+// existingGroupIDs returns the subset of groupIDs that resolve to a group,
+// preserving the input order.
+func (nmd *NetworkMapData) existingGroupIDs(groupIDs []string) []string {
+	out := make([]string, 0, len(groupIDs))
+	for _, groupID := range groupIDs {
+		if _, ok := nmd.Groups[groupID]; ok {
+			out = append(out, groupID)
+		}
+	}
+	return out
+}
diff --git a/shared/management/proto/management.pb.go b/shared/management/proto/management.pb.go
index 0450b0692..6b3b88810 100644
--- a/shared/management/proto/management.pb.go
+++ b/shared/management/proto/management.pb.go
@@ -128,6 +128,59 @@ func (PeerCapability) EnumDescriptor() ([]byte, []int) {
 	return file_management_proto_rawDescGZIP(), []int{1}
 }
 
+// LazyState is the management per-peer override for lazy connections.
+type LazyState int32
+
+const (
+	// Follow the account-wide lazy connection flag.
+	LazyState_LazyStateDefault LazyState = 0
+	// Force a lazy (on-demand) connection regardless of the account flag.
+	LazyState_LazyStateLazy LazyState = 1
+	// Force an always-active connection regardless of the account flag.
+	LazyState_LazyStateEager LazyState = 2
+)
+
+// Enum value maps for LazyState.
+var (
+	LazyState_name = map[int32]string{
+		0: "LazyStateDefault",
+		1: "LazyStateLazy",
+		2: "LazyStateEager",
+	}
+	LazyState_value = map[string]int32{
+		"LazyStateDefault": 0,
+		"LazyStateLazy":    1,
+		"LazyStateEager":   2,
+	}
+)
+
+func (x LazyState) Enum() *LazyState {
+	p := new(LazyState)
+	*p = x
+	return p
+}
+
+func (x LazyState) String() string {
+	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (LazyState) Descriptor() protoreflect.EnumDescriptor {
+	return file_management_proto_enumTypes[2].Descriptor()
+}
+
+func (LazyState) Type() protoreflect.EnumType {
+	return &file_management_proto_enumTypes[2]
+}
+
+func (x LazyState) Number() protoreflect.EnumNumber {
+	return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use LazyState.Descriptor instead.
+func (LazyState) EnumDescriptor() ([]byte, []int) {
+	return file_management_proto_rawDescGZIP(), []int{2}
+}
+
 type RuleProtocol int32
 
 const (
@@ -187,11 +240,11 @@ func (x RuleProtocol) String() string {
 }
 
 func (RuleProtocol) Descriptor() protoreflect.EnumDescriptor {
-	return file_management_proto_enumTypes[2].Descriptor()
+	return file_management_proto_enumTypes[3].Descriptor()
 }
 
 func (RuleProtocol) Type() protoreflect.EnumType {
-	return &file_management_proto_enumTypes[2]
+	return &file_management_proto_enumTypes[3]
 }
 
 func (x RuleProtocol) Number() protoreflect.EnumNumber {
@@ -200,7 +253,7 @@ func (x RuleProtocol) Number() protoreflect.EnumNumber {
 
 // Deprecated: Use RuleProtocol.Descriptor instead.
 func (RuleProtocol) EnumDescriptor() ([]byte, []int) {
-	return file_management_proto_rawDescGZIP(), []int{2}
+	return file_management_proto_rawDescGZIP(), []int{3}
 }
 
 type RuleDirection int32
@@ -233,11 +286,11 @@ func (x RuleDirection) String() string {
 }
 
 func (RuleDirection) Descriptor() protoreflect.EnumDescriptor {
-	return file_management_proto_enumTypes[3].Descriptor()
+	return file_management_proto_enumTypes[4].Descriptor()
 }
 
 func (RuleDirection) Type() protoreflect.EnumType {
-	return &file_management_proto_enumTypes[3]
+	return &file_management_proto_enumTypes[4]
 }
 
 func (x RuleDirection) Number() protoreflect.EnumNumber {
@@ -246,7 +299,7 @@ func (x RuleDirection) Number() protoreflect.EnumNumber {
 
 // Deprecated: Use RuleDirection.Descriptor instead.
 func (RuleDirection) EnumDescriptor() ([]byte, []int) {
-	return file_management_proto_rawDescGZIP(), []int{3}
+	return file_management_proto_rawDescGZIP(), []int{4}
 }
 
 type RuleAction int32
@@ -279,11 +332,11 @@ func (x RuleAction) String() string {
 }
 
 func (RuleAction) Descriptor() protoreflect.EnumDescriptor {
-	return file_management_proto_enumTypes[4].Descriptor()
+	return file_management_proto_enumTypes[5].Descriptor()
 }
 
 func (RuleAction) Type() protoreflect.EnumType {
-	return &file_management_proto_enumTypes[4]
+	return &file_management_proto_enumTypes[5]
 }
 
 func (x RuleAction) Number() protoreflect.EnumNumber {
@@ -292,7 +345,7 @@ func (x RuleAction) Number() protoreflect.EnumNumber {
 
 // Deprecated: Use RuleAction.Descriptor instead.
 func (RuleAction) EnumDescriptor() ([]byte, []int) {
-	return file_management_proto_rawDescGZIP(), []int{4}
+	return file_management_proto_rawDescGZIP(), []int{5}
 }
 
 type ExposeProtocol int32
@@ -334,11 +387,11 @@ func (x ExposeProtocol) String() string {
 }
 
 func (ExposeProtocol) Descriptor() protoreflect.EnumDescriptor {
-	return file_management_proto_enumTypes[5].Descriptor()
+	return file_management_proto_enumTypes[6].Descriptor()
 }
 
 func (ExposeProtocol) Type() protoreflect.EnumType {
-	return &file_management_proto_enumTypes[5]
+	return &file_management_proto_enumTypes[6]
 }
 
 func (x ExposeProtocol) Number() protoreflect.EnumNumber {
@@ -347,7 +400,7 @@ func (x ExposeProtocol) Number() protoreflect.EnumNumber {
 
 // Deprecated: Use ExposeProtocol.Descriptor instead.
 func (ExposeProtocol) EnumDescriptor() ([]byte, []int) {
-	return file_management_proto_rawDescGZIP(), []int{5}
+	return file_management_proto_rawDescGZIP(), []int{6}
 }
 
 type HostConfig_Protocol int32
@@ -389,11 +442,11 @@ func (x HostConfig_Protocol) String() string {
 }
 
 func (HostConfig_Protocol) Descriptor() protoreflect.EnumDescriptor {
-	return file_management_proto_enumTypes[6].Descriptor()
+	return file_management_proto_enumTypes[7].Descriptor()
 }
 
 func (HostConfig_Protocol) Type() protoreflect.EnumType {
-	return &file_management_proto_enumTypes[6]
+	return &file_management_proto_enumTypes[7]
 }
 
 func (x HostConfig_Protocol) Number() protoreflect.EnumNumber {
@@ -432,11 +485,11 @@ func (x DeviceAuthorizationFlowProvider) String() string {
 }
 
 func (DeviceAuthorizationFlowProvider) Descriptor() protoreflect.EnumDescriptor {
-	return file_management_proto_enumTypes[7].Descriptor()
+	return file_management_proto_enumTypes[8].Descriptor()
 }
 
 func (DeviceAuthorizationFlowProvider) Type() protoreflect.EnumType {
-	return &file_management_proto_enumTypes[7]
+	return &file_management_proto_enumTypes[8]
 }
 
 func (x DeviceAuthorizationFlowProvider) Number() protoreflect.EnumNumber {
@@ -3094,6 +3147,11 @@ type RemotePeerConfig struct {
 	// Peer fully qualified domain name
 	Fqdn         string `protobuf:"bytes,4,opt,name=fqdn,proto3" json:"fqdn,omitempty"`
 	AgentVersion string `protobuf:"bytes,5,opt,name=agentVersion,proto3" json:"agentVersion,omitempty"`
+	// lazyState is the management per-peer override for lazy (on-demand)
+	// connections to this remote peer. LazyStateDefault follows the account-wide
+	// flag; LazyStateLazy forces lazy; LazyStateEager forces an always-active
+	// connection. A local NB_LAZY_CONN/MDM override still wins over this.
+	LazyState LazyState `protobuf:"varint,6,opt,name=lazyState,proto3,enum=management.LazyState" json:"lazyState,omitempty"`
 }
 
 func (x *RemotePeerConfig) Reset() {
@@ -3163,6 +3221,13 @@ func (x *RemotePeerConfig) GetAgentVersion() string {
 	return ""
 }
 
+func (x *RemotePeerConfig) GetLazyState() LazyState {
+	if x != nil {
+		return x.LazyState
+	}
+	return LazyState_LazyStateDefault
+}
+
 // SSHConfig represents SSH configurations of a peer.
 type SSHConfig struct {
 	state         protoimpl.MessageState
@@ -5614,6 +5679,10 @@ type PeerCompact struct {
 	// (port 22022) is only added when this flag is set and the peer agent
 	// version supports it.
 	ServerSshAllowed bool `protobuf:"varint,13,opt,name=server_ssh_allowed,json=serverSshAllowed,proto3" json:"server_ssh_allowed,omitempty"`
+	// Mirror of types.Peer.ProxyMeta.Embedded. Connections involving an
+	// ephemeral proxy peer on either endpoint default to lazy, so this bit
+	// feeds the per-peer lazyState emitted in RemotePeerConfig.
+	ProxyEmbedded bool `protobuf:"varint,14,opt,name=proxy_embedded,json=proxyEmbedded,proto3" json:"proxy_embedded,omitempty"`
 }
 
 func (x *PeerCompact) Reset() {
@@ -5739,6 +5808,13 @@ func (x *PeerCompact) GetServerSshAllowed() bool {
 	return false
 }
 
+func (x *PeerCompact) GetProxyEmbedded() bool {
+	if x != nil {
+		return x.ProxyEmbedded
+	}
+	return false
+}
+
 // PolicyCompact is the compact form of a policy rule. Group references use
 // the public_ids; the client resolves
 // them against NetworkMapComponentsFull.groups. Direction is derived per-peer
@@ -5935,8 +6011,6 @@ func (x *PolicyCompact) GetSessionDisplayName() 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
@@ -5945,6 +6019,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() {
@@ -6000,6 +6075,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 {
@@ -6065,7 +6147,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() {
@@ -6121,6 +6204,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
@@ -7358,7 +7448,7 @@ var file_management_proto_rawDesc = []byte{
 	0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x48,
 	0x61, 0x73, 0x68, 0x12, 0x21, 0x0a, 0x0c, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x6e,
 	0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x69, 0x73, 0x70, 0x6c,
-	0x61, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x22, 0xbb, 0x01, 0x0a, 0x10, 0x52, 0x65, 0x6d, 0x6f, 0x74,
+	0x61, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x22, 0xf0, 0x01, 0x0a, 0x10, 0x52, 0x65, 0x6d, 0x6f, 0x74,
 	0x65, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, 0x77,
 	0x67, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x77,
 	0x67, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x1e, 0x0a, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77,
@@ -7370,716 +7460,731 @@ var file_management_proto_rawDesc = []byte{
 	0x66, 0x71, 0x64, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x71, 0x64, 0x6e,
 	0x12, 0x22, 0x0a, 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e,
 	0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72,
-	0x73, 0x69, 0x6f, 0x6e, 0x22, 0x7e, 0x0a, 0x09, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69,
-	0x67, 0x12, 0x1e, 0x0a, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18,
-	0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65,
-	0x64, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x02,
-	0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12,
-	0x33, 0x0a, 0x09, 0x6a, 0x77, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x03, 0x20, 0x01,
-	0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
-	0x4a, 0x57, 0x54, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x6a, 0x77, 0x74, 0x43, 0x6f,
-	0x6e, 0x66, 0x69, 0x67, 0x22, 0x20, 0x0a, 0x1e, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75,
-	0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x52,
-	0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xbf, 0x01, 0x0a, 0x17, 0x44, 0x65, 0x76, 0x69, 0x63,
-	0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c,
-	0x6f, 0x77, 0x12, 0x48, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x01,
-	0x20, 0x01, 0x28, 0x0e, 0x32, 0x2c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
-	0x74, 0x2e, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a,
-	0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64,
-	0x65, 0x72, 0x52, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x42, 0x0a, 0x0e,
-	0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02,
-	0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
-	0x74, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
-	0x52, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
-	0x22, 0x16, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x0a, 0x0a, 0x06,
-	0x48, 0x4f, 0x53, 0x54, 0x45, 0x44, 0x10, 0x00, 0x22, 0x1e, 0x0a, 0x1c, 0x50, 0x4b, 0x43, 0x45,
-	0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f,
-	0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x5b, 0x0a, 0x15, 0x50, 0x4b, 0x43, 0x45,
-	0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f,
-	0x77, 0x12, 0x42, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e,
-	0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
+	0x73, 0x69, 0x6f, 0x6e, 0x12, 0x33, 0x0a, 0x09, 0x6c, 0x61, 0x7a, 0x79, 0x53, 0x74, 0x61, 0x74,
+	0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
+	0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4c, 0x61, 0x7a, 0x79, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x09,
+	0x6c, 0x61, 0x7a, 0x79, 0x53, 0x74, 0x61, 0x74, 0x65, 0x22, 0x7e, 0x0a, 0x09, 0x53, 0x53, 0x48,
+	0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1e, 0x0a, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61,
+	0x62, 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x73, 0x73, 0x68, 0x45,
+	0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62,
+	0x4b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75,
+	0x62, 0x4b, 0x65, 0x79, 0x12, 0x33, 0x0a, 0x09, 0x6a, 0x77, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69,
+	0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
+	0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4a, 0x57, 0x54, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09,
+	0x6a, 0x77, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0x20, 0x0a, 0x1e, 0x44, 0x65, 0x76,
+	0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e,
+	0x46, 0x6c, 0x6f, 0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xbf, 0x01, 0x0a, 0x17,
+	0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74,
+	0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x48, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69,
+	0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x2c, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
+	0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74,
+	0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x2e, 0x70,
+	0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x52, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65,
+	0x72, 0x12, 0x42, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e,
+	0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
 	0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43,
 	0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43,
-	0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0xbc, 0x03, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64,
-	0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, 0x43, 0x6c, 0x69, 0x65,
-	0x6e, 0x74, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x43, 0x6c, 0x69, 0x65,
-	0x6e, 0x74, 0x49, 0x44, 0x12, 0x26, 0x0a, 0x0c, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x53, 0x65,
-	0x63, 0x72, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x02, 0x18, 0x01, 0x52, 0x0c,
-	0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, 0x16, 0x0a, 0x06,
-	0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x44, 0x6f,
-	0x6d, 0x61, 0x69, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x41, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65,
-	0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x41, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65,
-	0x12, 0x2e, 0x0a, 0x12, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x45, 0x6e,
-	0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x44, 0x65,
-	0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74,
-	0x12, 0x24, 0x0a, 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e,
-	0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e,
-	0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x18,
-	0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x12, 0x1e, 0x0a, 0x0a,
-	0x55, 0x73, 0x65, 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08,
-	0x52, 0x0a, 0x55, 0x73, 0x65, 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x34, 0x0a, 0x15,
-	0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64,
-	0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x15, 0x41, 0x75, 0x74,
-	0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69,
-	0x6e, 0x74, 0x12, 0x22, 0x0a, 0x0c, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x52,
-	0x4c, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65,
-	0x63, 0x74, 0x55, 0x52, 0x4c, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c,
-	0x65, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x18, 0x0b, 0x20, 0x01,
-	0x28, 0x08, 0x52, 0x12, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x6d, 0x70,
-	0x74, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x46,
-	0x6c, 0x61, 0x67, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x4c, 0x6f, 0x67, 0x69, 0x6e,
-	0x46, 0x6c, 0x61, 0x67, 0x22, 0x93, 0x02, 0x0a, 0x05, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x0e,
-	0x0a, 0x02, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x44, 0x12, 0x18,
-	0x0a, 0x07, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
-	0x07, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x20, 0x0a, 0x0b, 0x4e, 0x65, 0x74, 0x77,
-	0x6f, 0x72, 0x6b, 0x54, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x4e,
-	0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x65,
-	0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x65, 0x65, 0x72, 0x12, 0x16,
-	0x0a, 0x06, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06,
-	0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x12, 0x1e, 0x0a, 0x0a, 0x4d, 0x61, 0x73, 0x71, 0x75, 0x65,
-	0x72, 0x61, 0x64, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x4d, 0x61, 0x73, 0x71,
-	0x75, 0x65, 0x72, 0x61, 0x64, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x4e, 0x65, 0x74, 0x49, 0x44, 0x18,
-	0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x4e, 0x65, 0x74, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07,
-	0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x44,
-	0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x6b, 0x65, 0x65, 0x70, 0x52, 0x6f,
-	0x75, 0x74, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x6b, 0x65, 0x65, 0x70, 0x52,
-	0x6f, 0x75, 0x74, 0x65, 0x12, 0x24, 0x0a, 0x0d, 0x73, 0x6b, 0x69, 0x70, 0x41, 0x75, 0x74, 0x6f,
-	0x41, 0x70, 0x70, 0x6c, 0x79, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x73, 0x6b, 0x69,
-	0x70, 0x41, 0x75, 0x74, 0x6f, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x22, 0xde, 0x01, 0x0a, 0x09, 0x44,
-	0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x24, 0x0a, 0x0d, 0x53, 0x65, 0x72, 0x76,
-	0x69, 0x63, 0x65, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52,
-	0x0d, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x12, 0x47,
-	0x0a, 0x10, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75,
-	0x70, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
-	0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72,
-	0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x10, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65,
-	0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x38, 0x0a, 0x0b, 0x43, 0x75, 0x73, 0x74, 0x6f,
-	0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d,
-	0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d,
-	0x5a, 0x6f, 0x6e, 0x65, 0x52, 0x0b, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65,
-	0x73, 0x12, 0x28, 0x0a, 0x0d, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f,
-	0x72, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x42, 0x02, 0x18, 0x01, 0x52, 0x0d, 0x46, 0x6f,
-	0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xb8, 0x01, 0x0a, 0x0a,
-	0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x44, 0x6f,
-	0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x44, 0x6f, 0x6d, 0x61,
-	0x69, 0x6e, 0x12, 0x32, 0x0a, 0x07, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x18, 0x02, 0x20,
-	0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
-	0x2e, 0x53, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x52, 0x07, 0x52,
-	0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68,
-	0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x03,
-	0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61,
-	0x69, 0x6e, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x2a, 0x0a, 0x10, 0x4e, 0x6f,
-	0x6e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x74, 0x61, 0x74, 0x69, 0x76, 0x65, 0x18, 0x04,
-	0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x4e, 0x6f, 0x6e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69,
-	0x74, 0x61, 0x74, 0x69, 0x76, 0x65, 0x22, 0x74, 0x0a, 0x0c, 0x53, 0x69, 0x6d, 0x70, 0x6c, 0x65,
-	0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x01,
-	0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x54, 0x79,
-	0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x14,
-	0x0a, 0x05, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x43,
-	0x6c, 0x61, 0x73, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x54, 0x54, 0x4c, 0x18, 0x04, 0x20, 0x01, 0x28,
-	0x03, 0x52, 0x03, 0x54, 0x54, 0x4c, 0x12, 0x14, 0x0a, 0x05, 0x52, 0x44, 0x61, 0x74, 0x61, 0x18,
-	0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x52, 0x44, 0x61, 0x74, 0x61, 0x22, 0xb3, 0x01, 0x0a,
-	0x0f, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70,
-	0x12, 0x38, 0x0a, 0x0b, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x18,
-	0x01, 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, 0x4e,
-	0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x50, 0x72,
-	0x69, 0x6d, 0x61, 0x72, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x50, 0x72, 0x69,
-	0x6d, 0x61, 0x72, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18,
-	0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x32,
-	0x0a, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x45,
-	0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x53, 0x65,
-	0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c,
-	0x65, 0x64, 0x22, 0x48, 0x0a, 0x0a, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72,
-	0x12, 0x0e, 0x0a, 0x02, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x50,
-	0x12, 0x16, 0x0a, 0x06, 0x4e, 0x53, 0x54, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03,
-	0x52, 0x06, 0x4e, 0x53, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x6f, 0x72, 0x74,
-	0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xfb, 0x02, 0x0a,
-	0x0c, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x1a, 0x0a,
-	0x06, 0x50, 0x65, 0x65, 0x72, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x02, 0x18,
-	0x01, 0x52, 0x06, 0x50, 0x65, 0x65, 0x72, 0x49, 0x50, 0x12, 0x37, 0x0a, 0x09, 0x44, 0x69, 0x72,
-	0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x19, 0x2e, 0x6d,
-	0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x44, 0x69,
-	0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69,
-	0x6f, 0x6e, 0x12, 0x2e, 0x0a, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01,
-	0x28, 0x0e, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
-	0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x41, 0x63, 0x74, 0x69,
-	0x6f, 0x6e, 0x12, 0x34, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x04,
-	0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
-	0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08,
-	0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x6f, 0x72, 0x74,
-	0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x30, 0x0a, 0x08,
-	0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14,
-	0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74,
-	0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1a,
-	0x0a, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0c,
-	0x52, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x12, 0x26, 0x0a, 0x0e, 0x63, 0x75,
-	0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x08, 0x20, 0x01,
-	0x28, 0x0d, 0x52, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63,
-	0x6f, 0x6c, 0x12, 0x26, 0x0a, 0x0e, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66,
-	0x69, 0x78, 0x65, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0e, 0x73, 0x6f, 0x75, 0x72,
-	0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x22, 0x38, 0x0a, 0x0e, 0x4e, 0x65,
-	0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x14, 0x0a, 0x05,
-	0x6e, 0x65, 0x74, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6e, 0x65, 0x74,
-	0x49, 0x50, 0x12, 0x10, 0x0a, 0x03, 0x6d, 0x61, 0x63, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
-	0x03, 0x6d, 0x61, 0x63, 0x22, 0x1e, 0x0a, 0x06, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x12, 0x14,
-	0x0a, 0x05, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x46,
-	0x69, 0x6c, 0x65, 0x73, 0x22, 0x96, 0x01, 0x0a, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66,
-	0x6f, 0x12, 0x14, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x48,
-	0x00, 0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x32, 0x0a, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65,
-	0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
-	0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x2e, 0x52, 0x61, 0x6e,
-	0x67, 0x65, 0x48, 0x00, 0x52, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x1a, 0x2f, 0x0a, 0x05, 0x52,
-	0x61, 0x6e, 0x67, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x18, 0x01, 0x20,
-	0x01, 0x28, 0x0d, 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x65, 0x6e,
-	0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x03, 0x65, 0x6e, 0x64, 0x42, 0x0f, 0x0a, 0x0d,
-	0x70, 0x6f, 0x72, 0x74, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x87, 0x03,
-	0x0a, 0x11, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52,
-	0x75, 0x6c, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61, 0x6e,
-	0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x6f, 0x75, 0x72, 0x63,
-	0x65, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x12, 0x2e, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f,
-	0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
+	0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0x16, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65,
+	0x72, 0x12, 0x0a, 0x0a, 0x06, 0x48, 0x4f, 0x53, 0x54, 0x45, 0x44, 0x10, 0x00, 0x22, 0x1e, 0x0a,
+	0x1c, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69,
+	0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x5b, 0x0a,
+	0x15, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69,
+	0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x42, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64,
+	0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a,
+	0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x76,
+	0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0e, 0x50, 0x72, 0x6f, 0x76,
+	0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0xbc, 0x03, 0x0a, 0x0e, 0x50,
+	0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a,
+	0x08, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
+	0x08, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, 0x44, 0x12, 0x26, 0x0a, 0x0c, 0x43, 0x6c, 0x69,
+	0x65, 0x6e, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42,
+	0x02, 0x18, 0x01, 0x52, 0x0c, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65,
+	0x74, 0x12, 0x16, 0x0a, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28,
+	0x09, 0x52, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x41, 0x75, 0x64,
+	0x69, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x41, 0x75, 0x64,
+	0x69, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x2e, 0x0a, 0x12, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41,
+	0x75, 0x74, 0x68, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28,
+	0x09, 0x52, 0x12, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x45, 0x6e, 0x64,
+	0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x24, 0x0a, 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e,
+	0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x54, 0x6f,
+	0x6b, 0x65, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x53,
+	0x63, 0x6f, 0x70, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x53, 0x63, 0x6f, 0x70,
+	0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x55, 0x73, 0x65, 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x18,
+	0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x55, 0x73, 0x65, 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65,
+	0x6e, 0x12, 0x34, 0x0a, 0x15, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69,
+	0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x15, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45,
+	0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x22, 0x0a, 0x0c, 0x52, 0x65, 0x64, 0x69, 0x72,
+	0x65, 0x63, 0x74, 0x55, 0x52, 0x4c, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x52,
+	0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x52, 0x4c, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x44,
+	0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x4c, 0x6f, 0x67, 0x69,
+	0x6e, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65,
+	0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x4c,
+	0x6f, 0x67, 0x69, 0x6e, 0x46, 0x6c, 0x61, 0x67, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09,
+	0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x46, 0x6c, 0x61, 0x67, 0x22, 0x93, 0x02, 0x0a, 0x05, 0x52, 0x6f,
+	0x75, 0x74, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
+	0x02, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x02,
+	0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x20, 0x0a,
+	0x0b, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x54, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01,
+	0x28, 0x03, 0x52, 0x0b, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x54, 0x79, 0x70, 0x65, 0x12,
+	0x12, 0x0a, 0x04, 0x50, 0x65, 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x50,
+	0x65, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x18, 0x05, 0x20,
+	0x01, 0x28, 0x03, 0x52, 0x06, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x12, 0x1e, 0x0a, 0x0a, 0x4d,
+	0x61, 0x73, 0x71, 0x75, 0x65, 0x72, 0x61, 0x64, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52,
+	0x0a, 0x4d, 0x61, 0x73, 0x71, 0x75, 0x65, 0x72, 0x61, 0x64, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x4e,
+	0x65, 0x74, 0x49, 0x44, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x4e, 0x65, 0x74, 0x49,
+	0x44, 0x12, 0x18, 0x0a, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x03,
+	0x28, 0x09, 0x52, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x6b,
+	0x65, 0x65, 0x70, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09,
+	0x6b, 0x65, 0x65, 0x70, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x24, 0x0a, 0x0d, 0x73, 0x6b, 0x69,
+	0x70, 0x41, 0x75, 0x74, 0x6f, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08,
+	0x52, 0x0d, 0x73, 0x6b, 0x69, 0x70, 0x41, 0x75, 0x74, 0x6f, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x22,
+	0xde, 0x01, 0x0a, 0x09, 0x44, 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x24, 0x0a,
+	0x0d, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x18, 0x01,
+	0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x45, 0x6e, 0x61,
+	0x62, 0x6c, 0x65, 0x12, 0x47, 0x0a, 0x10, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65,
+	0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e,
+	0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53,
+	0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x10, 0x4e, 0x61, 0x6d, 0x65,
+	0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x38, 0x0a, 0x0b,
+	0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28,
+	0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43,
+	0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x52, 0x0b, 0x43, 0x75, 0x73, 0x74, 0x6f,
+	0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x73, 0x12, 0x28, 0x0a, 0x0d, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72,
+	0x64, 0x65, 0x72, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x42, 0x02, 0x18,
+	0x01, 0x52, 0x0d, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, 0x72, 0x74,
+	0x22, 0xb8, 0x01, 0x0a, 0x0a, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x12,
+	0x16, 0x0a, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
+	0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x32, 0x0a, 0x07, 0x52, 0x65, 0x63, 0x6f, 0x72,
+	0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
+	0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x63, 0x6f,
+	0x72, 0x64, 0x52, 0x07, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x53,
+	0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x44, 0x69, 0x73, 0x61, 0x62,
+	0x6c, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63,
+	0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12,
+	0x2a, 0x0a, 0x10, 0x4e, 0x6f, 0x6e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x74, 0x61, 0x74,
+	0x69, 0x76, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x4e, 0x6f, 0x6e, 0x41, 0x75,
+	0x74, 0x68, 0x6f, 0x72, 0x69, 0x74, 0x61, 0x74, 0x69, 0x76, 0x65, 0x22, 0x74, 0x0a, 0x0c, 0x53,
+	0x69, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x4e,
+	0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x12,
+	0x12, 0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x54,
+	0x79, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01,
+	0x28, 0x09, 0x52, 0x05, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x54, 0x54, 0x4c,
+	0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x03, 0x54, 0x54, 0x4c, 0x12, 0x14, 0x0a, 0x05, 0x52,
+	0x44, 0x61, 0x74, 0x61, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x52, 0x44, 0x61, 0x74,
+	0x61, 0x22, 0xb3, 0x01, 0x0a, 0x0f, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72,
+	0x47, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x38, 0x0a, 0x0b, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72,
+	0x76, 0x65, 0x72, 0x73, 0x18, 0x01, 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, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x12,
+	0x18, 0x0a, 0x07, 0x50, 0x72, 0x69, 0x6d, 0x61, 0x72, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08,
+	0x52, 0x07, 0x50, 0x72, 0x69, 0x6d, 0x61, 0x72, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x44, 0x6f, 0x6d,
+	0x61, 0x69, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x44, 0x6f, 0x6d, 0x61,
+	0x69, 0x6e, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d,
+	0x61, 0x69, 0x6e, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28,
+	0x08, 0x52, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73,
+	0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x48, 0x0a, 0x0a, 0x4e, 0x61, 0x6d, 0x65, 0x53,
+	0x65, 0x72, 0x76, 0x65, 0x72, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28,
+	0x09, 0x52, 0x02, 0x49, 0x50, 0x12, 0x16, 0x0a, 0x06, 0x4e, 0x53, 0x54, 0x79, 0x70, 0x65, 0x18,
+	0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x4e, 0x53, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a,
+	0x04, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x50, 0x6f, 0x72,
+	0x74, 0x22, 0xfb, 0x02, 0x0a, 0x0c, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75,
+	0x6c, 0x65, 0x12, 0x1a, 0x0a, 0x06, 0x50, 0x65, 0x65, 0x72, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01,
+	0x28, 0x09, 0x42, 0x02, 0x18, 0x01, 0x52, 0x06, 0x50, 0x65, 0x65, 0x72, 0x49, 0x50, 0x12, 0x37,
+	0x0a, 0x09, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28,
+	0x0e, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52,
+	0x75, 0x6c, 0x65, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x44, 0x69,
+	0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x2e, 0x0a, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f,
+	0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
 	0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52,
-	0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x74, 0x69,
-	0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65,
-	0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x34, 0x0a, 0x08, 0x70, 0x72, 0x6f,
-	0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61,
-	0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f,
-	0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12,
-	0x30, 0x0a, 0x08, 0x70, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x18, 0x05, 0x20, 0x01, 0x28,
-	0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50,
-	0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x70, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66,
-	0x6f, 0x12, 0x1c, 0x0a, 0x09, 0x69, 0x73, 0x44, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x18, 0x06,
-	0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x69, 0x73, 0x44, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x12,
-	0x18, 0x0a, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09,
-	0x52, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x26, 0x0a, 0x0e, 0x63, 0x75, 0x73,
-	0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x08, 0x20, 0x01, 0x28,
-	0x0d, 0x52, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f,
-	0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x18, 0x09, 0x20,
-	0x01, 0x28, 0x0c, 0x52, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x12, 0x18, 0x0a,
-	0x07, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x49, 0x44, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07,
-	0x52, 0x6f, 0x75, 0x74, 0x65, 0x49, 0x44, 0x22, 0xf2, 0x01, 0x0a, 0x0e, 0x46, 0x6f, 0x72, 0x77,
-	0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x34, 0x0a, 0x08, 0x70, 0x72,
-	0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d,
-	0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72,
-	0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c,
-	0x12, 0x3e, 0x0a, 0x0f, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x50,
-	0x6f, 0x72, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
-	0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52,
-	0x0f, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x6f, 0x72, 0x74,
-	0x12, 0x2c, 0x0a, 0x11, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x41, 0x64,
-	0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x11, 0x74, 0x72, 0x61,
-	0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x3c,
-	0x0a, 0x0e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x72, 0x74,
-	0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
-	0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0e, 0x74, 0x72,
-	0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x22, 0x8b, 0x02, 0x0a,
-	0x14, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65,
-	0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20,
-	0x01, 0x28, 0x0d, 0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x36, 0x0a, 0x08, 0x70, 0x72, 0x6f,
-	0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x6d, 0x61,
-	0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x50,
-	0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f,
-	0x6c, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03,
-	0x70, 0x69, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18,
-	0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12,
-	0x1f, 0x0a, 0x0b, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x05,
-	0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73,
-	0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09,
-	0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x1f, 0x0a, 0x0b, 0x6e, 0x61, 0x6d, 0x65,
-	0x5f, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6e,
-	0x61, 0x6d, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x12, 0x1f, 0x0a, 0x0b, 0x6c, 0x69, 0x73,
-	0x74, 0x65, 0x6e, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0a,
-	0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xa1, 0x01, 0x0a, 0x15, 0x45,
-	0x78, 0x70, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70,
-	0x6f, 0x6e, 0x73, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f,
-	0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x65, 0x72, 0x76,
-	0x69, 0x63, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x69,
-	0x63, 0x65, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x65,
-	0x72, 0x76, 0x69, 0x63, 0x65, 0x55, 0x72, 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61,
-	0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e,
-	0x12, 0x2c, 0x0a, 0x12, 0x70, 0x6f, 0x72, 0x74, 0x5f, 0x61, 0x75, 0x74, 0x6f, 0x5f, 0x61, 0x73,
-	0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x70, 0x6f,
-	0x72, 0x74, 0x41, 0x75, 0x74, 0x6f, 0x41, 0x73, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x22, 0x2c,
-	0x0a, 0x12, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x71,
-	0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01,
-	0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x15, 0x0a, 0x13,
-	0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f,
-	0x6e, 0x73, 0x65, 0x22, 0x2b, 0x0a, 0x11, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73,
-	0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61,
-	0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e,
-	0x22, 0x14, 0x0a, 0x12, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65,
-	0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x9a, 0x01, 0x0a, 0x12, 0x4e, 0x65, 0x74, 0x77, 0x6f,
-	0x72, 0x6b, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x76, 0x65, 0x6c, 0x6f, 0x70, 0x65, 0x12, 0x3a, 0x0a,
-	0x04, 0x66, 0x75, 0x6c, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x6d, 0x61,
-	0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b,
-	0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c,
-	0x6c, 0x48, 0x00, 0x52, 0x04, 0x66, 0x75, 0x6c, 0x6c, 0x12, 0x3d, 0x0a, 0x05, 0x64, 0x65, 0x6c,
-	0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
-	0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70,
-	0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x44, 0x65, 0x6c, 0x74, 0x61, 0x48,
-	0x00, 0x52, 0x05, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x42, 0x09, 0x0a, 0x07, 0x70, 0x61, 0x79, 0x6c,
-	0x6f, 0x61, 0x64, 0x22, 0x92, 0x0f, 0x0a, 0x18, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d,
-	0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c,
-	0x12, 0x16, 0x0a, 0x06, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04,
-	0x52, 0x06, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x12, 0x37, 0x0a, 0x0b, 0x70, 0x65, 0x65, 0x72,
-	0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e,
-	0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x43,
-	0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69,
-	0x67, 0x12, 0x34, 0x0a, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x03, 0x20, 0x01,
-	0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
-	0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52, 0x07,
-	0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x4d, 0x0a, 0x10, 0x61, 0x63, 0x63, 0x6f, 0x75,
-	0x6e, 0x74, 0x5f, 0x73, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28,
-	0x0b, 0x32, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41,
-	0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x43, 0x6f,
-	0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x0f, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x53, 0x65,
-	0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x41, 0x0a, 0x0c, 0x64, 0x6e, 0x73, 0x5f, 0x73, 0x65,
-	0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d,
-	0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x44, 0x4e, 0x53, 0x53, 0x65, 0x74,
-	0x74, 0x69, 0x6e, 0x67, 0x73, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x0b, 0x64, 0x6e,
-	0x73, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x64, 0x6e, 0x73,
-	0x5f, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x64,
-	0x6e, 0x73, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x2c, 0x0a, 0x12, 0x63, 0x75, 0x73, 0x74,
-	0x6f, 0x6d, 0x5f, 0x7a, 0x6f, 0x6e, 0x65, 0x5f, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x07,
-	0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65,
-	0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x25, 0x0a, 0x0e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x5f,
-	0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0d,
-	0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x2d, 0x0a,
-	0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d,
-	0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f,
-	0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x12, 0x2e, 0x0a, 0x13,
-	0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x5f, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65,
-	0x78, 0x65, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x11, 0x72, 0x6f, 0x75, 0x74, 0x65,
-	0x72, 0x50, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x12, 0x35, 0x0a, 0x08,
-	0x70, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x18, 0x0b, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19,
-	0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x6c, 0x69,
-	0x63, 0x79, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x08, 0x70, 0x6f, 0x6c, 0x69, 0x63,
-	0x69, 0x65, 0x73, 0x12, 0x30, 0x0a, 0x06, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x0c, 0x20,
-	0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
-	0x2e, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x06, 0x67,
-	0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x2c, 0x0a, 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18,
-	0x0d, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
-	0x6e, 0x74, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x61, 0x77, 0x52, 0x06, 0x72, 0x6f, 0x75,
-	0x74, 0x65, 0x73, 0x12, 0x4b, 0x0a, 0x11, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x65, 0x72, 0x76, 0x65,
-	0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x0e, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e,
-	0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65,
-	0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x61, 0x77, 0x52, 0x10,
-	0x6e, 0x61, 0x6d, 0x65, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73,
-	0x12, 0x40, 0x0a, 0x0f, 0x61, 0x6c, 0x6c, 0x5f, 0x64, 0x6e, 0x73, 0x5f, 0x72, 0x65, 0x63, 0x6f,
-	0x72, 0x64, 0x73, 0x18, 0x0f, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
-	0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x63,
-	0x6f, 0x72, 0x64, 0x52, 0x0d, 0x61, 0x6c, 0x6c, 0x44, 0x6e, 0x73, 0x52, 0x65, 0x63, 0x6f, 0x72,
-	0x64, 0x73, 0x12, 0x3b, 0x0a, 0x0d, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x7a, 0x6f,
-	0x6e, 0x65, 0x73, 0x18, 0x10, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
-	0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e,
-	0x65, 0x52, 0x0c, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5a, 0x6f, 0x6e, 0x65, 0x73, 0x12,
-	0x4b, 0x0a, 0x11, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x75,
-	0x72, 0x63, 0x65, 0x73, 0x18, 0x11, 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,
-	0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61, 0x77, 0x52, 0x10, 0x6e, 0x65, 0x74, 0x77,
-	0x6f, 0x72, 0x6b, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x12, 0x55, 0x0a, 0x0b,
-	0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x73, 0x5f, 0x6d, 0x61, 0x70, 0x18, 0x12, 0x20, 0x03, 0x28,
-	0x0b, 0x32, 0x34, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e,
-	0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65,
-	0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x73, 0x4d,
-	0x61, 0x70, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0a, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x73,
-	0x4d, 0x61, 0x70, 0x12, 0x71, 0x0a, 0x15, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f,
-	0x70, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x5f, 0x6d, 0x61, 0x70, 0x18, 0x13, 0x20, 0x03,
-	0x28, 0x0b, 0x32, 0x3d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
-	0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e,
-	0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63,
-	0x65, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x74, 0x72,
-	0x79, 0x52, 0x13, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x6f, 0x6c, 0x69, 0x63,
-	0x69, 0x65, 0x73, 0x4d, 0x61, 0x70, 0x12, 0x6a, 0x0a, 0x14, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f,
-	0x69, 0x64, 0x5f, 0x74, 0x6f, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x14,
-	0x20, 0x03, 0x28, 0x0b, 0x32, 0x3a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
-	0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70,
-	0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, 0x2e, 0x47, 0x72, 0x6f, 0x75, 0x70,
-	0x49, 0x64, 0x54, 0x6f, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79,
-	0x52, 0x10, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x54, 0x6f, 0x55, 0x73, 0x65, 0x72, 0x49,
-	0x64, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x5f, 0x75, 0x73,
-	0x65, 0x72, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x15, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0e, 0x61, 0x6c,
-	0x6c, 0x6f, 0x77, 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x73, 0x12, 0x6e, 0x0a, 0x14,
-	0x70, 0x6f, 0x73, 0x74, 0x75, 0x72, 0x65, 0x5f, 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x5f, 0x70,
-	0x65, 0x65, 0x72, 0x73, 0x18, 0x16, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3c, 0x2e, 0x6d, 0x61, 0x6e,
-	0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d,
-	0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c,
-	0x2e, 0x50, 0x6f, 0x73, 0x74, 0x75, 0x72, 0x65, 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x50, 0x65,
-	0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x12, 0x70, 0x6f, 0x73, 0x74, 0x75, 0x72,
-	0x65, 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x50, 0x65, 0x65, 0x72, 0x73, 0x12, 0x2c, 0x0a, 0x12,
-	0x64, 0x6e, 0x73, 0x5f, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x70, 0x6f,
-	0x72, 0x74, 0x18, 0x17, 0x20, 0x01, 0x28, 0x03, 0x52, 0x10, 0x64, 0x6e, 0x73, 0x46, 0x6f, 0x72,
-	0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x37, 0x0a, 0x0b, 0x70, 0x72,
-	0x6f, 0x78, 0x79, 0x5f, 0x70, 0x61, 0x74, 0x63, 0x68, 0x18, 0x18, 0x20, 0x01, 0x28, 0x0b, 0x32,
-	0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f,
-	0x78, 0x79, 0x50, 0x61, 0x74, 0x63, 0x68, 0x52, 0x0a, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x61,
-	0x74, 0x63, 0x68, 0x12, 0x22, 0x0a, 0x0d, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x5f, 0x63,
-	0x6c, 0x61, 0x69, 0x6d, 0x18, 0x19, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x75, 0x73, 0x65, 0x72,
-	0x49, 0x64, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x1a, 0x5c, 0x0a, 0x0f, 0x52, 0x6f, 0x75, 0x74, 0x65,
-	0x72, 0x73, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65,
-	0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x33, 0x0a, 0x05,
-	0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 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, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75,
-	0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x5d, 0x0a, 0x18, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63,
-	0x65, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x74, 0x72,
-	0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03,
-	0x6b, 0x65, 0x79, 0x12, 0x2b, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01,
-	0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
-	0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x64, 0x73, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65,
-	0x3a, 0x02, 0x38, 0x01, 0x1a, 0x5b, 0x0a, 0x15, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x54,
-	0x6f, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a,
-	0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12,
-	0x2c, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16,
-	0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x55, 0x73, 0x65, 0x72,
-	0x49, 0x44, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38,
-	0x01, 0x1a, 0x5f, 0x0a, 0x17, 0x50, 0x6f, 0x73, 0x74, 0x75, 0x72, 0x65, 0x46, 0x61, 0x69, 0x6c,
-	0x65, 0x64, 0x50, 0x65, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03,
-	0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2e,
-	0x0a, 0x05, 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, 0x50, 0x65, 0x65, 0x72, 0x49,
-	0x6e, 0x64, 0x65, 0x78, 0x53, 0x65, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02,
-	0x38, 0x01, 0x4a, 0x04, 0x08, 0x1a, 0x10, 0x33, 0x22, 0x87, 0x03, 0x0a, 0x0a, 0x50, 0x72, 0x6f,
-	0x78, 0x79, 0x50, 0x61, 0x74, 0x63, 0x68, 0x12, 0x32, 0x0a, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73,
-	0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
-	0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f,
-	0x6e, 0x66, 0x69, 0x67, 0x52, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x12, 0x41, 0x0a, 0x0d, 0x6f,
-	0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x02, 0x20, 0x03,
-	0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
-	0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
-	0x52, 0x0c, 0x6f, 0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x12, 0x3f,
-	0x0a, 0x0e, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x5f, 0x72, 0x75, 0x6c, 0x65, 0x73,
-	0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
-	0x65, 0x6e, 0x74, 0x2e, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65,
-	0x52, 0x0d, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12,
-	0x29, 0x0a, 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32,
-	0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x6f, 0x75,
-	0x74, 0x65, 0x52, 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x4f, 0x0a, 0x14, 0x72, 0x6f,
-	0x75, 0x74, 0x65, 0x5f, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x5f, 0x72, 0x75, 0x6c,
-	0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
-	0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77,
-	0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x12, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69,
-	0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x45, 0x0a, 0x10, 0x66,
-	0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x5f, 0x72, 0x75, 0x6c, 0x65, 0x73, 0x18,
-	0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
-	0x6e, 0x74, 0x2e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c,
-	0x65, 0x52, 0x0f, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c,
-	0x65, 0x73, 0x22, 0x94, 0x01, 0x0a, 0x16, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x53, 0x65,
-	0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x12, 0x41, 0x0a,
-	0x1d, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x65, 0x78, 0x70, 0x69,
-	0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x01,
-	0x20, 0x01, 0x28, 0x08, 0x52, 0x1a, 0x70, 0x65, 0x65, 0x72, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x45,
-	0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64,
-	0x12, 0x37, 0x0a, 0x18, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x65,
-	0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x01,
-	0x28, 0x03, 0x52, 0x15, 0x70, 0x65, 0x65, 0x72, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x45, 0x78, 0x70,
-	0x69, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4e, 0x73, 0x22, 0x95, 0x01, 0x0a, 0x0e, 0x41, 0x63,
-	0x63, 0x6f, 0x75, 0x6e, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x1e, 0x0a, 0x0a,
-	0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
-	0x52, 0x0a, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x12, 0x19, 0x0a, 0x08,
-	0x6e, 0x65, 0x74, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07,
-	0x6e, 0x65, 0x74, 0x43, 0x69, 0x64, 0x72, 0x12, 0x1e, 0x0a, 0x0b, 0x6e, 0x65, 0x74, 0x5f, 0x76,
-	0x36, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6e, 0x65,
-	0x74, 0x56, 0x36, 0x43, 0x69, 0x64, 0x72, 0x12, 0x10, 0x0a, 0x03, 0x64, 0x6e, 0x73, 0x18, 0x04,
-	0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x64, 0x6e, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x65, 0x72,
-	0x69, 0x61, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x73, 0x65, 0x72, 0x69, 0x61,
-	0x6c, 0x22, 0x21, 0x0a, 0x19, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43,
-	0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x44, 0x65, 0x6c, 0x74, 0x61, 0x4a, 0x04,
-	0x08, 0x01, 0x10, 0x65, 0x22, 0xfb, 0x03, 0x0a, 0x0b, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6d,
-	0x70, 0x61, 0x63, 0x74, 0x12, 0x1c, 0x0a, 0x0a, 0x77, 0x67, 0x5f, 0x70, 0x75, 0x62, 0x5f, 0x6b,
-	0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x77, 0x67, 0x50, 0x75, 0x62, 0x4b,
-	0x65, 0x79, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x70, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x02,
-	0x69, 0x70, 0x12, 0x12, 0x0a, 0x04, 0x69, 0x70, 0x76, 0x36, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c,
-	0x52, 0x04, 0x69, 0x70, 0x76, 0x36, 0x12, 0x1e, 0x0a, 0x0b, 0x73, 0x73, 0x68, 0x5f, 0x70, 0x75,
-	0x62, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x73, 0x73, 0x68,
-	0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x1b, 0x0a, 0x09, 0x64, 0x6e, 0x73, 0x5f, 0x6c, 0x61,
-	0x62, 0x65, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x64, 0x6e, 0x73, 0x4c, 0x61,
-	0x62, 0x65, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x5f, 0x76, 0x65, 0x72,
-	0x73, 0x69, 0x6f, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x61, 0x67, 0x65, 0x6e,
-	0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x2f, 0x0a, 0x14, 0x61, 0x64, 0x64, 0x65,
-	0x64, 0x5f, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x73, 0x73, 0x6f, 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e,
-	0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x61, 0x64, 0x64, 0x65, 0x64, 0x57, 0x69, 0x74,
-	0x68, 0x53, 0x73, 0x6f, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x38, 0x0a, 0x18, 0x6c, 0x6f, 0x67,
-	0x69, 0x6e, 0x5f, 0x65, 0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x6e,
-	0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x16, 0x6c, 0x6f, 0x67,
-	0x69, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62,
-	0x6c, 0x65, 0x64, 0x12, 0x2f, 0x0a, 0x14, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x6c, 0x6f, 0x67, 0x69,
-	0x6e, 0x5f, 0x75, 0x6e, 0x69, 0x78, 0x5f, 0x6e, 0x61, 0x6e, 0x6f, 0x18, 0x09, 0x20, 0x01, 0x28,
-	0x03, 0x52, 0x11, 0x6c, 0x61, 0x73, 0x74, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x55, 0x6e, 0x69, 0x78,
-	0x4e, 0x61, 0x6e, 0x6f, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x73, 0x68, 0x5f, 0x65, 0x6e, 0x61, 0x62,
-	0x6c, 0x65, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e,
-	0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74,
-	0x73, 0x5f, 0x69, 0x70, 0x76, 0x36, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x73, 0x75,
-	0x70, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x49, 0x70, 0x76, 0x36, 0x12, 0x38, 0x0a, 0x18, 0x73, 0x75,
-	0x70, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x5f, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x70, 0x72,
-	0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x08, 0x52, 0x16, 0x73, 0x75,
-	0x70, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66,
-	0x69, 0x78, 0x65, 0x73, 0x12, 0x2c, 0x0a, 0x12, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x73,
-	0x73, 0x68, 0x5f, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08,
-	0x52, 0x10, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x53, 0x73, 0x68, 0x41, 0x6c, 0x6c, 0x6f, 0x77,
-	0x65, 0x64, 0x22, 0xeb, 0x06, 0x0a, 0x0d, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x43, 0x6f, 0x6d,
-	0x70, 0x61, 0x63, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
-	0x52, 0x02, 0x69, 0x64, 0x12, 0x2e, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02,
-	0x20, 0x01, 0x28, 0x0e, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
-	0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x61, 0x63,
-	0x74, 0x69, 0x6f, 0x6e, 0x12, 0x34, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c,
-	0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
-	0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c,
-	0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x24, 0x0a, 0x0d, 0x62, 0x69,
-	0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28,
-	0x08, 0x52, 0x0d, 0x62, 0x69, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c,
-	0x12, 0x14, 0x0a, 0x05, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0d, 0x52,
-	0x05, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x12, 0x3b, 0x0a, 0x0b, 0x70, 0x6f, 0x72, 0x74, 0x5f, 0x72,
-	0x61, 0x6e, 0x67, 0x65, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61,
+	0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x34, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f,
+	0x63, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
+	0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f,
+	0x63, 0x6f, 0x6c, 0x52, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x12, 0x0a,
+	0x04, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x6f, 0x72,
+	0x74, 0x12, 0x30, 0x0a, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x18, 0x06, 0x20,
+	0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
+	0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49,
+	0x6e, 0x66, 0x6f, 0x12, 0x1a, 0x0a, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x18,
+	0x07, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x12,
+	0x26, 0x0a, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f,
+	0x6c, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50,
+	0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x26, 0x0a, 0x0e, 0x73, 0x6f, 0x75, 0x72, 0x63,
+	0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0c, 0x52,
+	0x0e, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x22,
+	0x38, 0x0a, 0x0e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73,
+	0x73, 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x65, 0x74, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x05, 0x6e, 0x65, 0x74, 0x49, 0x50, 0x12, 0x10, 0x0a, 0x03, 0x6d, 0x61, 0x63, 0x18, 0x02,
+	0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6d, 0x61, 0x63, 0x22, 0x1e, 0x0a, 0x06, 0x43, 0x68, 0x65,
+	0x63, 0x6b, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03,
+	0x28, 0x09, 0x52, 0x05, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x22, 0x96, 0x01, 0x0a, 0x08, 0x50, 0x6f,
+	0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x14, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01,
+	0x20, 0x01, 0x28, 0x0d, 0x48, 0x00, 0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x32, 0x0a, 0x05,
+	0x72, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61,
 	0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66,
-	0x6f, 0x2e, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x52, 0x0a, 0x70, 0x6f, 0x72, 0x74, 0x52, 0x61, 0x6e,
-	0x67, 0x65, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x67, 0x72,
-	0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0e, 0x73,
-	0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x32, 0x0a,
-	0x15, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x67, 0x72, 0x6f,
-	0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x13, 0x64, 0x65,
-	0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64,
-	0x73, 0x12, 0x5c, 0x0a, 0x11, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x5f,
-	0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2f, 0x2e, 0x6d,
-	0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79,
-	0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a,
-	0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x10, 0x61,
-	0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12,
-	0x27, 0x0a, 0x0f, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x5f, 0x75, 0x73,
-	0x65, 0x72, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72,
-	0x69, 0x7a, 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, 0x12, 0x44, 0x0a, 0x0f, 0x73, 0x6f, 0x75, 0x72,
-	0x63, 0x65, 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x0b, 0x20, 0x01, 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, 0x0e,
-	0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x4e,
-	0x0a, 0x14, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x72, 0x65,
-	0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x0c, 0x20, 0x01, 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, 0x13, 0x64, 0x65, 0x73, 0x74, 0x69,
-	0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x37,
-	0x0a, 0x18, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x70, 0x6f, 0x73, 0x74, 0x75, 0x72, 0x65,
-	0x5f, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x0d, 0x20, 0x03, 0x28, 0x09,
-	0x52, 0x15, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x6f, 0x73, 0x74, 0x75, 0x72, 0x65, 0x43,
-	0x68, 0x65, 0x63, 0x6b, 0x49, 0x64, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x73, 0x65, 0x73, 0x73, 0x69,
-	0x6f, 0x6e, 0x5f, 0x70, 0x75, 0x62, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09,
-	0x52, 0x0d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12,
-	0x30, 0x0a, 0x14, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x6c,
-	0x61, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x73,
-	0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x4e, 0x61, 0x6d,
-	0x65, 0x1a, 0x5d, 0x0a, 0x15, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x47,
-	0x72, 0x6f, 0x75, 0x70, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65,
-	0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2e, 0x0a, 0x05,
-	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,
+	0x6f, 0x2e, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x48, 0x00, 0x52, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65,
+	0x1a, 0x2f, 0x0a, 0x05, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61,
+	0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x12,
+	0x10, 0x0a, 0x03, 0x65, 0x6e, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x03, 0x65, 0x6e,
+	0x64, 0x42, 0x0f, 0x0a, 0x0d, 0x70, 0x6f, 0x72, 0x74, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x69,
+	0x6f, 0x6e, 0x22, 0x87, 0x03, 0x0a, 0x11, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69, 0x72, 0x65,
+	0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x73, 0x6f, 0x75, 0x72,
+	0x63, 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c,
+	0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x12, 0x2e, 0x0a, 0x06,
+	0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x16, 0x2e, 0x6d,
+	0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63,
+	0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x0a, 0x0b,
+	0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28,
+	0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x34,
+	0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e,
+	0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75,
+	0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74,
+	0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x30, 0x0a, 0x08, 0x70, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f,
+	0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
+	0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x70, 0x6f,
+	0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1c, 0x0a, 0x09, 0x69, 0x73, 0x44, 0x79, 0x6e, 0x61,
+	0x6d, 0x69, 0x63, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x69, 0x73, 0x44, 0x79, 0x6e,
+	0x61, 0x6d, 0x69, 0x63, 0x12, 0x18, 0x0a, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18,
+	0x07, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x26,
+	0x0a, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c,
+	0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72,
+	0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79,
+	0x49, 0x44, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79,
+	0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x49, 0x44, 0x18, 0x0a, 0x20,
+	0x01, 0x28, 0x09, 0x52, 0x07, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x49, 0x44, 0x22, 0xf2, 0x01, 0x0a,
+	0x0e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x12,
+	0x34, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28,
+	0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52,
+	0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f,
+	0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x3e, 0x0a, 0x0f, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61,
+	0x74, 0x69, 0x6f, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14,
+	0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74,
+	0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0f, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f,
+	0x6e, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x2c, 0x0a, 0x11, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61,
+	0x74, 0x65, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c,
+	0x52, 0x11, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x41, 0x64, 0x64, 0x72,
+	0x65, 0x73, 0x73, 0x12, 0x3c, 0x0a, 0x0e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65,
+	0x64, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61,
+	0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66,
+	0x6f, 0x52, 0x0e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x72,
+	0x74, 0x22, 0x8b, 0x02, 0x0a, 0x14, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x72, 0x76,
+	0x69, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x6f,
+	0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x36,
+	0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e,
+	0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x78,
+	0x70, 0x6f, 0x73, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72,
+	0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x69, 0x6e, 0x18, 0x03, 0x20,
+	0x01, 0x28, 0x09, 0x52, 0x03, 0x70, 0x69, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73,
+	0x77, 0x6f, 0x72, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73,
+	0x77, 0x6f, 0x72, 0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f,
+	0x75, 0x70, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x47,
+	0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18,
+	0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x1f, 0x0a,
+	0x0b, 0x6e, 0x61, 0x6d, 0x65, 0x5f, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x18, 0x07, 0x20, 0x01,
+	0x28, 0x09, 0x52, 0x0a, 0x6e, 0x61, 0x6d, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x12, 0x1f,
+	0x0a, 0x0b, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x08, 0x20,
+	0x01, 0x28, 0x0d, 0x52, 0x0a, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x22,
+	0xa1, 0x01, 0x0a, 0x15, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63,
+	0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x65, 0x72,
+	0x76, 0x69, 0x63, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
+	0x0b, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b,
+	0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28,
+	0x09, 0x52, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x55, 0x72, 0x6c, 0x12, 0x16, 0x0a,
+	0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64,
+	0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x2c, 0x0a, 0x12, 0x70, 0x6f, 0x72, 0x74, 0x5f, 0x61, 0x75,
+	0x74, 0x6f, 0x5f, 0x61, 0x73, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28,
+	0x08, 0x52, 0x10, 0x70, 0x6f, 0x72, 0x74, 0x41, 0x75, 0x74, 0x6f, 0x41, 0x73, 0x73, 0x69, 0x67,
+	0x6e, 0x65, 0x64, 0x22, 0x2c, 0x0a, 0x12, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f,
+	0x73, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d,
+	0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69,
+	0x6e, 0x22, 0x15, 0x0a, 0x13, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65,
+	0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x2b, 0x0a, 0x11, 0x53, 0x74, 0x6f, 0x70,
+	0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a,
+	0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64,
+	0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x14, 0x0a, 0x12, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70,
+	0x6f, 0x73, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x9a, 0x01, 0x0a, 0x12,
+	0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x76, 0x65, 0x6c, 0x6f,
+	0x70, 0x65, 0x12, 0x3a, 0x0a, 0x04, 0x66, 0x75, 0x6c, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b,
+	0x32, 0x24, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65,
+	0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e,
+	0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, 0x48, 0x00, 0x52, 0x04, 0x66, 0x75, 0x6c, 0x6c, 0x12, 0x3d,
+	0x0a, 0x05, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 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, 0x6e, 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, 0x12, 0x0f, 0x0a, 0x0b, 0x4e, 0x45, 0x54, 0x42, 0x49, 0x52,
-	0x44, 0x5f, 0x56, 0x4e, 0x43, 0x10, 0x07, 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,
+	0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x44,
+	0x65, 0x6c, 0x74, 0x61, 0x48, 0x00, 0x52, 0x05, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x42, 0x09, 0x0a,
+	0x07, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x22, 0x92, 0x0f, 0x0a, 0x18, 0x4e, 0x65, 0x74,
+	0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74,
+	0x73, 0x46, 0x75, 0x6c, 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x18,
+	0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x12, 0x37, 0x0a,
+	0x0b, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01,
+	0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
+	0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a, 0x70, 0x65, 0x65, 0x72,
+	0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x34, 0x0a, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72,
+	0x6b, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
+	0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x4e, 0x65, 0x74, 0x77,
+	0x6f, 0x72, 0x6b, 0x52, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x4d, 0x0a, 0x10,
+	0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x73, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73,
+	0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
+	0x65, 0x6e, 0x74, 0x2e, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x53, 0x65, 0x74, 0x74, 0x69,
+	0x6e, 0x67, 0x73, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x0f, 0x61, 0x63, 0x63, 0x6f,
+	0x75, 0x6e, 0x74, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x41, 0x0a, 0x0c, 0x64,
+	0x6e, 0x73, 0x5f, 0x73, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28,
+	0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x44,
+	0x4e, 0x53, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63,
+	0x74, 0x52, 0x0b, 0x64, 0x6e, 0x73, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x1d,
+	0x0a, 0x0a, 0x64, 0x6e, 0x73, 0x5f, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x06, 0x20, 0x01,
+	0x28, 0x09, 0x52, 0x09, 0x64, 0x6e, 0x73, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x2c, 0x0a,
+	0x12, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x7a, 0x6f, 0x6e, 0x65, 0x5f, 0x64, 0x6f, 0x6d,
+	0x61, 0x69, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x63, 0x75, 0x73, 0x74, 0x6f,
+	0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x25, 0x0a, 0x0e, 0x61,
+	0x67, 0x65, 0x6e, 0x74, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x08, 0x20,
+	0x03, 0x28, 0x09, 0x52, 0x0d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f,
+	0x6e, 0x73, 0x12, 0x2d, 0x0a, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28,
+	0x0b, 0x32, 0x17, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50,
+	0x65, 0x65, 0x72, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x05, 0x70, 0x65, 0x65, 0x72,
+	0x73, 0x12, 0x2e, 0x0a, 0x13, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x5f, 0x70, 0x65, 0x65, 0x72,
+	0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x11,
+	0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x50, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65,
+	0x73, 0x12, 0x35, 0x0a, 0x08, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x18, 0x0b, 0x20,
+	0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
+	0x2e, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x08,
+	0x70, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x12, 0x30, 0x0a, 0x06, 0x67, 0x72, 0x6f, 0x75,
+	0x70, 0x73, 0x18, 0x0c, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
+	0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x61,
+	0x63, 0x74, 0x52, 0x06, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x2c, 0x0a, 0x06, 0x72, 0x6f,
+	0x75, 0x74, 0x65, 0x73, 0x18, 0x0d, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e,
+	0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x61, 0x77,
+	0x52, 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x4b, 0x0a, 0x11, 0x6e, 0x61, 0x6d, 0x65,
+	0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x0e, 0x20,
+	0x03, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
+	0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70,
+	0x52, 0x61, 0x77, 0x52, 0x10, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47,
+	0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x40, 0x0a, 0x0f, 0x61, 0x6c, 0x6c, 0x5f, 0x64, 0x6e, 0x73,
+	0x5f, 0x72, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x18, 0x0f, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18,
+	0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x69, 0x6d, 0x70,
+	0x6c, 0x65, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x52, 0x0d, 0x61, 0x6c, 0x6c, 0x44, 0x6e, 0x73,
+	0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x12, 0x3b, 0x0a, 0x0d, 0x61, 0x63, 0x63, 0x6f, 0x75,
+	0x6e, 0x74, 0x5f, 0x7a, 0x6f, 0x6e, 0x65, 0x73, 0x18, 0x10, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16,
+	0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x75, 0x73, 0x74,
+	0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x52, 0x0c, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5a,
+	0x6f, 0x6e, 0x65, 0x73, 0x12, 0x4b, 0x0a, 0x11, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x5f,
+	0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x11, 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, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61, 0x77, 0x52,
+	0x10, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65,
+	0x73, 0x12, 0x55, 0x0a, 0x0b, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x73, 0x5f, 0x6d, 0x61, 0x70,
+	0x18, 0x12, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x34, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
+	0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f,
+	0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, 0x2e, 0x52, 0x6f, 0x75,
+	0x74, 0x65, 0x72, 0x73, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0a, 0x72, 0x6f,
+	0x75, 0x74, 0x65, 0x72, 0x73, 0x4d, 0x61, 0x70, 0x12, 0x71, 0x0a, 0x15, 0x72, 0x65, 0x73, 0x6f,
+	0x75, 0x72, 0x63, 0x65, 0x5f, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x5f, 0x6d, 0x61,
+	0x70, 0x18, 0x13, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
+	0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43,
+	0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, 0x2e, 0x52, 0x65,
+	0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x4d, 0x61,
+	0x70, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x13, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65,
+	0x50, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x4d, 0x61, 0x70, 0x12, 0x6a, 0x0a, 0x14, 0x67,
+	0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x5f, 0x74, 0x6f, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f,
+	0x69, 0x64, 0x73, 0x18, 0x14, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3a, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
+	0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61,
+	0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, 0x2e,
+	0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x54, 0x6f, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x73,
+	0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x10, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x54, 0x6f,
+	0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x61, 0x6c, 0x6c, 0x6f, 0x77,
+	0x65, 0x64, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x15, 0x20, 0x03, 0x28,
+	0x09, 0x52, 0x0e, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64,
+	0x73, 0x12, 0x6e, 0x0a, 0x14, 0x70, 0x6f, 0x73, 0x74, 0x75, 0x72, 0x65, 0x5f, 0x66, 0x61, 0x69,
+	0x6c, 0x65, 0x64, 0x5f, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x16, 0x20, 0x03, 0x28, 0x0b, 0x32,
+	0x3c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74,
+	0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74,
+	0x73, 0x46, 0x75, 0x6c, 0x6c, 0x2e, 0x50, 0x6f, 0x73, 0x74, 0x75, 0x72, 0x65, 0x46, 0x61, 0x69,
+	0x6c, 0x65, 0x64, 0x50, 0x65, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x12, 0x70,
+	0x6f, 0x73, 0x74, 0x75, 0x72, 0x65, 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x50, 0x65, 0x65, 0x72,
+	0x73, 0x12, 0x2c, 0x0a, 0x12, 0x64, 0x6e, 0x73, 0x5f, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64,
+	0x65, 0x72, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x17, 0x20, 0x01, 0x28, 0x03, 0x52, 0x10, 0x64,
+	0x6e, 0x73, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, 0x72, 0x74, 0x12,
+	0x37, 0x0a, 0x0b, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x70, 0x61, 0x74, 0x63, 0x68, 0x18, 0x18,
+	0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
+	0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x61, 0x74, 0x63, 0x68, 0x52, 0x0a, 0x70, 0x72,
+	0x6f, 0x78, 0x79, 0x50, 0x61, 0x74, 0x63, 0x68, 0x12, 0x22, 0x0a, 0x0d, 0x75, 0x73, 0x65, 0x72,
+	0x5f, 0x69, 0x64, 0x5f, 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x18, 0x19, 0x20, 0x01, 0x28, 0x09, 0x52,
+	0x0b, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x1a, 0x5c, 0x0a, 0x0f,
+	0x52, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x73, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12,
+	0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65,
+	0x79, 0x12, 0x33, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b,
+	0x32, 0x1d, 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, 0x4c, 0x69, 0x73, 0x74, 0x52,
+	0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x5d, 0x0a, 0x18, 0x52, 0x65,
+	0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x4d, 0x61,
+	0x70, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20,
+	0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2b, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75,
+	0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
+	0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x64, 0x73, 0x52, 0x05,
+	0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x5b, 0x0a, 0x15, 0x47, 0x72, 0x6f,
+	0x75, 0x70, 0x49, 0x64, 0x54, 0x6f, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x73, 0x45, 0x6e, 0x74,
+	0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
+	0x03, 0x6b, 0x65, 0x79, 0x12, 0x2c, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20,
+	0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
+	0x2e, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c,
+	0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x5f, 0x0a, 0x17, 0x50, 0x6f, 0x73, 0x74, 0x75, 0x72,
+	0x65, 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x50, 0x65, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72,
+	0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03,
+	0x6b, 0x65, 0x79, 0x12, 0x2e, 0x0a, 0x05, 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,
+	0x50, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x65, 0x74, 0x52, 0x05, 0x76, 0x61,
+	0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x4a, 0x04, 0x08, 0x1a, 0x10, 0x33, 0x22, 0x87, 0x03,
+	0x0a, 0x0a, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x61, 0x74, 0x63, 0x68, 0x12, 0x32, 0x0a, 0x05,
+	0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61,
+	0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50,
+	0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73,
+	0x12, 0x41, 0x0a, 0x0d, 0x6f, 0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x70, 0x65, 0x65, 0x72,
+	0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
+	0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x43,
+	0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0c, 0x6f, 0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x50, 0x65,
+	0x65, 0x72, 0x73, 0x12, 0x3f, 0x0a, 0x0e, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x5f,
+	0x72, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61,
+	0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c,
+	0x6c, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x0d, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52,
+	0x75, 0x6c, 0x65, 0x73, 0x12, 0x29, 0x0a, 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x04,
+	0x20, 0x03, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
+	0x74, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12,
+	0x4f, 0x0a, 0x14, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x5f, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c,
+	0x6c, 0x5f, 0x72, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e,
+	0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65,
+	0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x12, 0x72, 0x6f,
+	0x75, 0x74, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73,
+	0x12, 0x45, 0x0a, 0x10, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x5f, 0x72,
+	0x75, 0x6c, 0x65, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e,
+	0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69,
+	0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x0f, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69,
+	0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x22, 0x94, 0x01, 0x0a, 0x16, 0x41, 0x63, 0x63, 0x6f,
+	0x75, 0x6e, 0x74, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x43, 0x6f, 0x6d, 0x70, 0x61,
+	0x63, 0x74, 0x12, 0x41, 0x0a, 0x1d, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e,
+	0x5f, 0x65, 0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62,
+	0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1a, 0x70, 0x65, 0x65, 0x72, 0x4c,
+	0x6f, 0x67, 0x69, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e,
+	0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x37, 0x0a, 0x18, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x6c, 0x6f,
+	0x67, 0x69, 0x6e, 0x5f, 0x65, 0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x6e,
+	0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x15, 0x70, 0x65, 0x65, 0x72, 0x4c, 0x6f, 0x67,
+	0x69, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4e, 0x73, 0x22, 0x95,
+	0x01, 0x0a, 0x0e, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72,
+	0x6b, 0x12, 0x1e, 0x0a, 0x0a, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x18,
+	0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65,
+	0x72, 0x12, 0x19, 0x0a, 0x08, 0x6e, 0x65, 0x74, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x18, 0x02, 0x20,
+	0x01, 0x28, 0x09, 0x52, 0x07, 0x6e, 0x65, 0x74, 0x43, 0x69, 0x64, 0x72, 0x12, 0x1e, 0x0a, 0x0b,
+	0x6e, 0x65, 0x74, 0x5f, 0x76, 0x36, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28,
+	0x09, 0x52, 0x09, 0x6e, 0x65, 0x74, 0x56, 0x36, 0x43, 0x69, 0x64, 0x72, 0x12, 0x10, 0x0a, 0x03,
+	0x64, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x64, 0x6e, 0x73, 0x12, 0x16,
+	0x0a, 0x06, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06,
+	0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x22, 0x21, 0x0a, 0x19, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72,
+	0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x44, 0x65,
+	0x6c, 0x74, 0x61, 0x4a, 0x04, 0x08, 0x01, 0x10, 0x65, 0x22, 0xa2, 0x04, 0x0a, 0x0b, 0x50, 0x65,
+	0x65, 0x72, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x12, 0x1c, 0x0a, 0x0a, 0x77, 0x67, 0x5f,
+	0x70, 0x75, 0x62, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x77,
+	0x67, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x70, 0x18, 0x02, 0x20,
+	0x01, 0x28, 0x0c, 0x52, 0x02, 0x69, 0x70, 0x12, 0x12, 0x0a, 0x04, 0x69, 0x70, 0x76, 0x36, 0x18,
+	0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x69, 0x70, 0x76, 0x36, 0x12, 0x1e, 0x0a, 0x0b, 0x73,
+	0x73, 0x68, 0x5f, 0x70, 0x75, 0x62, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c,
+	0x52, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x1b, 0x0a, 0x09, 0x64,
+	0x6e, 0x73, 0x5f, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08,
+	0x64, 0x6e, 0x73, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x61, 0x67, 0x65, 0x6e,
+	0x74, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52,
+	0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x2f, 0x0a,
+	0x14, 0x61, 0x64, 0x64, 0x65, 0x64, 0x5f, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x73, 0x73, 0x6f, 0x5f,
+	0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x61, 0x64, 0x64,
+	0x65, 0x64, 0x57, 0x69, 0x74, 0x68, 0x53, 0x73, 0x6f, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x38,
+	0x0a, 0x18, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x65, 0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69,
+	0x6f, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08,
+	0x52, 0x16, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69, 0x6f,
+	0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x2f, 0x0a, 0x14, 0x6c, 0x61, 0x73, 0x74,
+	0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x75, 0x6e, 0x69, 0x78, 0x5f, 0x6e, 0x61, 0x6e, 0x6f,
+	0x18, 0x09, 0x20, 0x01, 0x28, 0x03, 0x52, 0x11, 0x6c, 0x61, 0x73, 0x74, 0x4c, 0x6f, 0x67, 0x69,
+	0x6e, 0x55, 0x6e, 0x69, 0x78, 0x4e, 0x61, 0x6e, 0x6f, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x73, 0x68,
+	0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a,
+	0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x75,
+	0x70, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x5f, 0x69, 0x70, 0x76, 0x36, 0x18, 0x0b, 0x20, 0x01, 0x28,
+	0x08, 0x52, 0x0c, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x49, 0x70, 0x76, 0x36, 0x12,
+	0x38, 0x0a, 0x18, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x5f, 0x73, 0x6f, 0x75, 0x72,
+	0x63, 0x65, 0x5f, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x18, 0x0c, 0x20, 0x01, 0x28,
+	0x08, 0x52, 0x16, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x53, 0x6f, 0x75, 0x72, 0x63,
+	0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x12, 0x2c, 0x0a, 0x12, 0x73, 0x65, 0x72,
+	0x76, 0x65, 0x72, 0x5f, 0x73, 0x73, 0x68, 0x5f, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x18,
+	0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x53, 0x73, 0x68,
+	0x41, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x70, 0x72, 0x6f, 0x78, 0x79,
+	0x5f, 0x65, 0x6d, 0x62, 0x65, 0x64, 0x64, 0x65, 0x64, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x08, 0x52,
+	0x0d, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x45, 0x6d, 0x62, 0x65, 0x64, 0x64, 0x65, 0x64, 0x22, 0xeb,
+	0x06, 0x0a, 0x0d, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74,
+	0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64,
+	0x12, 0x2e, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e,
+	0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75,
+	0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e,
+	0x12, 0x34, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x03, 0x20, 0x01,
+	0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
+	0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72,
+	0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x24, 0x0a, 0x0d, 0x62, 0x69, 0x64, 0x69, 0x72, 0x65,
+	0x63, 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x62,
+	0x69, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x12, 0x14, 0x0a, 0x05,
+	0x70, 0x6f, 0x72, 0x74, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x05, 0x70, 0x6f, 0x72,
+	0x74, 0x73, 0x12, 0x3b, 0x0a, 0x0b, 0x70, 0x6f, 0x72, 0x74, 0x5f, 0x72, 0x61, 0x6e, 0x67, 0x65,
+	0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
+	0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x2e, 0x52, 0x61,
+	0x6e, 0x67, 0x65, 0x52, 0x0a, 0x70, 0x6f, 0x72, 0x74, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x12,
+	0x28, 0x0a, 0x10, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f,
+	0x69, 0x64, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0e, 0x73, 0x6f, 0x75, 0x72, 0x63,
+	0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x32, 0x0a, 0x15, 0x64, 0x65, 0x73,
+	0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69,
+	0x64, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x13, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e,
+	0x61, 0x74, 0x69, 0x6f, 0x6e, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x5c, 0x0a,
+	0x11, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x5f, 0x67, 0x72, 0x6f, 0x75,
+	0x70, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
+	0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x43, 0x6f, 0x6d, 0x70,
+	0x61, 0x63, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x47, 0x72,
+	0x6f, 0x75, 0x70, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x10, 0x61, 0x75, 0x74, 0x68, 0x6f,
+	0x72, 0x69, 0x7a, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x27, 0x0a, 0x0f, 0x61,
+	0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x18, 0x0a,
+	0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64,
+	0x55, 0x73, 0x65, 0x72, 0x12, 0x44, 0x0a, 0x0f, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x72,
+	0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x0b, 0x20, 0x01, 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, 0x0e, 0x73, 0x6f, 0x75, 0x72,
+	0x63, 0x65, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x4e, 0x0a, 0x14, 0x64, 0x65,
+	0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72,
+	0x63, 0x65, 0x18, 0x0c, 0x20, 0x01, 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, 0x13, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69,
+	0x6f, 0x6e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x37, 0x0a, 0x18, 0x73, 0x6f,
+	0x75, 0x72, 0x63, 0x65, 0x5f, 0x70, 0x6f, 0x73, 0x74, 0x75, 0x72, 0x65, 0x5f, 0x63, 0x68, 0x65,
+	0x63, 0x6b, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x0d, 0x20, 0x03, 0x28, 0x09, 0x52, 0x15, 0x73, 0x6f,
+	0x75, 0x72, 0x63, 0x65, 0x50, 0x6f, 0x73, 0x74, 0x75, 0x72, 0x65, 0x43, 0x68, 0x65, 0x63, 0x6b,
+	0x49, 0x64, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x70,
+	0x75, 0x62, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x73, 0x65,
+	0x73, 0x73, 0x69, 0x6f, 0x6e, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x30, 0x0a, 0x14, 0x73,
+	0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x6e,
+	0x61, 0x6d, 0x65, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x73, 0x65, 0x73, 0x73, 0x69,
+	0x6f, 0x6e, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x1a, 0x5d, 0x0a,
+	0x15, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70,
+	0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20,
+	0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2e, 0x0a, 0x05, 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, 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, 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, 0x6e, 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, 0x12, 0x0f, 0x0a, 0x0b, 0x4e, 0x45, 0x54,
+	0x42, 0x49, 0x52, 0x44, 0x5f, 0x56, 0x4e, 0x43, 0x10, 0x07, 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, 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, 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, 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, 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, 0x4b, 0x0a, 0x0b,
-	0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x12, 0x1c, 0x2e, 0x6d, 0x61,
+	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, 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,
+	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, 0x22, 0x00, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
-	0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+	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, 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, 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, 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 (
@@ -8094,249 +8199,252 @@ func file_management_proto_rawDescGZIP() []byte {
 	return file_management_proto_rawDescData
 }
 
-var file_management_proto_enumTypes = make([]protoimpl.EnumInfo, 8)
+var file_management_proto_enumTypes = make([]protoimpl.EnumInfo, 9)
 var file_management_proto_msgTypes = make([]protoimpl.MessageInfo, 86)
 var file_management_proto_goTypes = []interface{}{
 	(JobStatus)(0),                         // 0: management.JobStatus
 	(PeerCapability)(0),                    // 1: management.PeerCapability
-	(RuleProtocol)(0),                      // 2: management.RuleProtocol
-	(RuleDirection)(0),                     // 3: management.RuleDirection
-	(RuleAction)(0),                        // 4: management.RuleAction
-	(ExposeProtocol)(0),                    // 5: management.ExposeProtocol
-	(HostConfig_Protocol)(0),               // 6: management.HostConfig.Protocol
-	(DeviceAuthorizationFlowProvider)(0),   // 7: management.DeviceAuthorizationFlow.provider
-	(*EncryptedMessage)(nil),               // 8: management.EncryptedMessage
-	(*JobRequest)(nil),                     // 9: management.JobRequest
-	(*JobResponse)(nil),                    // 10: management.JobResponse
-	(*BundleParameters)(nil),               // 11: management.BundleParameters
-	(*BundleResult)(nil),                   // 12: management.BundleResult
-	(*SyncRequest)(nil),                    // 13: management.SyncRequest
-	(*SyncResponse)(nil),                   // 14: management.SyncResponse
-	(*SyncMetaRequest)(nil),                // 15: management.SyncMetaRequest
-	(*LoginRequest)(nil),                   // 16: management.LoginRequest
-	(*PeerKeys)(nil),                       // 17: management.PeerKeys
-	(*Environment)(nil),                    // 18: management.Environment
-	(*File)(nil),                           // 19: management.File
-	(*Flags)(nil),                          // 20: management.Flags
-	(*PeerSystemMeta)(nil),                 // 21: management.PeerSystemMeta
-	(*LoginResponse)(nil),                  // 22: management.LoginResponse
-	(*ExtendAuthSessionRequest)(nil),       // 23: management.ExtendAuthSessionRequest
-	(*ExtendAuthSessionResponse)(nil),      // 24: management.ExtendAuthSessionResponse
-	(*ServerKeyResponse)(nil),              // 25: management.ServerKeyResponse
-	(*Empty)(nil),                          // 26: management.Empty
-	(*NetbirdConfig)(nil),                  // 27: management.NetbirdConfig
-	(*HostConfig)(nil),                     // 28: management.HostConfig
-	(*RelayConfig)(nil),                    // 29: management.RelayConfig
-	(*FlowConfig)(nil),                     // 30: management.FlowConfig
-	(*MetricsConfig)(nil),                  // 31: management.MetricsConfig
-	(*JWTConfig)(nil),                      // 32: management.JWTConfig
-	(*ProtectedHostConfig)(nil),            // 33: management.ProtectedHostConfig
-	(*PeerConfig)(nil),                     // 34: management.PeerConfig
-	(*AutoUpdateSettings)(nil),             // 35: management.AutoUpdateSettings
-	(*NetworkMap)(nil),                     // 36: management.NetworkMap
-	(*SSHAuth)(nil),                        // 37: management.SSHAuth
-	(*MachineUserIndexes)(nil),             // 38: management.MachineUserIndexes
-	(*VNCAuth)(nil),                        // 39: management.VNCAuth
-	(*SessionPubKey)(nil),                  // 40: management.SessionPubKey
-	(*RemotePeerConfig)(nil),               // 41: management.RemotePeerConfig
-	(*SSHConfig)(nil),                      // 42: management.SSHConfig
-	(*DeviceAuthorizationFlowRequest)(nil), // 43: management.DeviceAuthorizationFlowRequest
-	(*DeviceAuthorizationFlow)(nil),        // 44: management.DeviceAuthorizationFlow
-	(*PKCEAuthorizationFlowRequest)(nil),   // 45: management.PKCEAuthorizationFlowRequest
-	(*PKCEAuthorizationFlow)(nil),          // 46: management.PKCEAuthorizationFlow
-	(*ProviderConfig)(nil),                 // 47: management.ProviderConfig
-	(*Route)(nil),                          // 48: management.Route
-	(*DNSConfig)(nil),                      // 49: management.DNSConfig
-	(*CustomZone)(nil),                     // 50: management.CustomZone
-	(*SimpleRecord)(nil),                   // 51: management.SimpleRecord
-	(*NameServerGroup)(nil),                // 52: management.NameServerGroup
-	(*NameServer)(nil),                     // 53: management.NameServer
-	(*FirewallRule)(nil),                   // 54: management.FirewallRule
-	(*NetworkAddress)(nil),                 // 55: management.NetworkAddress
-	(*Checks)(nil),                         // 56: management.Checks
-	(*PortInfo)(nil),                       // 57: management.PortInfo
-	(*RouteFirewallRule)(nil),              // 58: management.RouteFirewallRule
-	(*ForwardingRule)(nil),                 // 59: management.ForwardingRule
-	(*ExposeServiceRequest)(nil),           // 60: management.ExposeServiceRequest
-	(*ExposeServiceResponse)(nil),          // 61: management.ExposeServiceResponse
-	(*RenewExposeRequest)(nil),             // 62: management.RenewExposeRequest
-	(*RenewExposeResponse)(nil),            // 63: management.RenewExposeResponse
-	(*StopExposeRequest)(nil),              // 64: management.StopExposeRequest
-	(*StopExposeResponse)(nil),             // 65: management.StopExposeResponse
-	(*NetworkMapEnvelope)(nil),             // 66: management.NetworkMapEnvelope
-	(*NetworkMapComponentsFull)(nil),       // 67: management.NetworkMapComponentsFull
-	(*ProxyPatch)(nil),                     // 68: management.ProxyPatch
-	(*AccountSettingsCompact)(nil),         // 69: management.AccountSettingsCompact
-	(*AccountNetwork)(nil),                 // 70: management.AccountNetwork
-	(*NetworkMapComponentsDelta)(nil),      // 71: management.NetworkMapComponentsDelta
-	(*PeerCompact)(nil),                    // 72: management.PeerCompact
-	(*PolicyCompact)(nil),                  // 73: management.PolicyCompact
-	(*ResourceCompact)(nil),                // 74: management.ResourceCompact
-	(*UserNameList)(nil),                   // 75: management.UserNameList
-	(*GroupCompact)(nil),                   // 76: management.GroupCompact
-	(*DNSSettingsCompact)(nil),             // 77: management.DNSSettingsCompact
-	(*RouteRaw)(nil),                       // 78: management.RouteRaw
-	(*NameServerGroupRaw)(nil),             // 79: management.NameServerGroupRaw
-	(*NetworkResourceRaw)(nil),             // 80: management.NetworkResourceRaw
-	(*NetworkRouterList)(nil),              // 81: management.NetworkRouterList
-	(*NetworkRouterEntry)(nil),             // 82: management.NetworkRouterEntry
-	(*PolicyIds)(nil),                      // 83: management.PolicyIds
-	(*UserIDList)(nil),                     // 84: management.UserIDList
-	(*PeerIndexSet)(nil),                   // 85: management.PeerIndexSet
-	nil,                                    // 86: management.SSHAuth.MachineUsersEntry
-	nil,                                    // 87: management.VNCAuth.MachineUsersEntry
-	(*PortInfo_Range)(nil),                 // 88: management.PortInfo.Range
-	nil,                                    // 89: management.NetworkMapComponentsFull.RoutersMapEntry
-	nil,                                    // 90: management.NetworkMapComponentsFull.ResourcePoliciesMapEntry
-	nil,                                    // 91: management.NetworkMapComponentsFull.GroupIdToUserIdsEntry
-	nil,                                    // 92: management.NetworkMapComponentsFull.PostureFailedPeersEntry
-	nil,                                    // 93: management.PolicyCompact.AuthorizedGroupsEntry
-	(*timestamppb.Timestamp)(nil),          // 94: google.protobuf.Timestamp
-	(*durationpb.Duration)(nil),            // 95: google.protobuf.Duration
+	(LazyState)(0),                         // 2: management.LazyState
+	(RuleProtocol)(0),                      // 3: management.RuleProtocol
+	(RuleDirection)(0),                     // 4: management.RuleDirection
+	(RuleAction)(0),                        // 5: management.RuleAction
+	(ExposeProtocol)(0),                    // 6: management.ExposeProtocol
+	(HostConfig_Protocol)(0),               // 7: management.HostConfig.Protocol
+	(DeviceAuthorizationFlowProvider)(0),   // 8: management.DeviceAuthorizationFlow.provider
+	(*EncryptedMessage)(nil),               // 9: management.EncryptedMessage
+	(*JobRequest)(nil),                     // 10: management.JobRequest
+	(*JobResponse)(nil),                    // 11: management.JobResponse
+	(*BundleParameters)(nil),               // 12: management.BundleParameters
+	(*BundleResult)(nil),                   // 13: management.BundleResult
+	(*SyncRequest)(nil),                    // 14: management.SyncRequest
+	(*SyncResponse)(nil),                   // 15: management.SyncResponse
+	(*SyncMetaRequest)(nil),                // 16: management.SyncMetaRequest
+	(*LoginRequest)(nil),                   // 17: management.LoginRequest
+	(*PeerKeys)(nil),                       // 18: management.PeerKeys
+	(*Environment)(nil),                    // 19: management.Environment
+	(*File)(nil),                           // 20: management.File
+	(*Flags)(nil),                          // 21: management.Flags
+	(*PeerSystemMeta)(nil),                 // 22: management.PeerSystemMeta
+	(*LoginResponse)(nil),                  // 23: management.LoginResponse
+	(*ExtendAuthSessionRequest)(nil),       // 24: management.ExtendAuthSessionRequest
+	(*ExtendAuthSessionResponse)(nil),      // 25: management.ExtendAuthSessionResponse
+	(*ServerKeyResponse)(nil),              // 26: management.ServerKeyResponse
+	(*Empty)(nil),                          // 27: management.Empty
+	(*NetbirdConfig)(nil),                  // 28: management.NetbirdConfig
+	(*HostConfig)(nil),                     // 29: management.HostConfig
+	(*RelayConfig)(nil),                    // 30: management.RelayConfig
+	(*FlowConfig)(nil),                     // 31: management.FlowConfig
+	(*MetricsConfig)(nil),                  // 32: management.MetricsConfig
+	(*JWTConfig)(nil),                      // 33: management.JWTConfig
+	(*ProtectedHostConfig)(nil),            // 34: management.ProtectedHostConfig
+	(*PeerConfig)(nil),                     // 35: management.PeerConfig
+	(*AutoUpdateSettings)(nil),             // 36: management.AutoUpdateSettings
+	(*NetworkMap)(nil),                     // 37: management.NetworkMap
+	(*SSHAuth)(nil),                        // 38: management.SSHAuth
+	(*MachineUserIndexes)(nil),             // 39: management.MachineUserIndexes
+	(*VNCAuth)(nil),                        // 40: management.VNCAuth
+	(*SessionPubKey)(nil),                  // 41: management.SessionPubKey
+	(*RemotePeerConfig)(nil),               // 42: management.RemotePeerConfig
+	(*SSHConfig)(nil),                      // 43: management.SSHConfig
+	(*DeviceAuthorizationFlowRequest)(nil), // 44: management.DeviceAuthorizationFlowRequest
+	(*DeviceAuthorizationFlow)(nil),        // 45: management.DeviceAuthorizationFlow
+	(*PKCEAuthorizationFlowRequest)(nil),   // 46: management.PKCEAuthorizationFlowRequest
+	(*PKCEAuthorizationFlow)(nil),          // 47: management.PKCEAuthorizationFlow
+	(*ProviderConfig)(nil),                 // 48: management.ProviderConfig
+	(*Route)(nil),                          // 49: management.Route
+	(*DNSConfig)(nil),                      // 50: management.DNSConfig
+	(*CustomZone)(nil),                     // 51: management.CustomZone
+	(*SimpleRecord)(nil),                   // 52: management.SimpleRecord
+	(*NameServerGroup)(nil),                // 53: management.NameServerGroup
+	(*NameServer)(nil),                     // 54: management.NameServer
+	(*FirewallRule)(nil),                   // 55: management.FirewallRule
+	(*NetworkAddress)(nil),                 // 56: management.NetworkAddress
+	(*Checks)(nil),                         // 57: management.Checks
+	(*PortInfo)(nil),                       // 58: management.PortInfo
+	(*RouteFirewallRule)(nil),              // 59: management.RouteFirewallRule
+	(*ForwardingRule)(nil),                 // 60: management.ForwardingRule
+	(*ExposeServiceRequest)(nil),           // 61: management.ExposeServiceRequest
+	(*ExposeServiceResponse)(nil),          // 62: management.ExposeServiceResponse
+	(*RenewExposeRequest)(nil),             // 63: management.RenewExposeRequest
+	(*RenewExposeResponse)(nil),            // 64: management.RenewExposeResponse
+	(*StopExposeRequest)(nil),              // 65: management.StopExposeRequest
+	(*StopExposeResponse)(nil),             // 66: management.StopExposeResponse
+	(*NetworkMapEnvelope)(nil),             // 67: management.NetworkMapEnvelope
+	(*NetworkMapComponentsFull)(nil),       // 68: management.NetworkMapComponentsFull
+	(*ProxyPatch)(nil),                     // 69: management.ProxyPatch
+	(*AccountSettingsCompact)(nil),         // 70: management.AccountSettingsCompact
+	(*AccountNetwork)(nil),                 // 71: management.AccountNetwork
+	(*NetworkMapComponentsDelta)(nil),      // 72: management.NetworkMapComponentsDelta
+	(*PeerCompact)(nil),                    // 73: management.PeerCompact
+	(*PolicyCompact)(nil),                  // 74: management.PolicyCompact
+	(*ResourceCompact)(nil),                // 75: management.ResourceCompact
+	(*UserNameList)(nil),                   // 76: management.UserNameList
+	(*GroupCompact)(nil),                   // 77: management.GroupCompact
+	(*DNSSettingsCompact)(nil),             // 78: management.DNSSettingsCompact
+	(*RouteRaw)(nil),                       // 79: management.RouteRaw
+	(*NameServerGroupRaw)(nil),             // 80: management.NameServerGroupRaw
+	(*NetworkResourceRaw)(nil),             // 81: management.NetworkResourceRaw
+	(*NetworkRouterList)(nil),              // 82: management.NetworkRouterList
+	(*NetworkRouterEntry)(nil),             // 83: management.NetworkRouterEntry
+	(*PolicyIds)(nil),                      // 84: management.PolicyIds
+	(*UserIDList)(nil),                     // 85: management.UserIDList
+	(*PeerIndexSet)(nil),                   // 86: management.PeerIndexSet
+	nil,                                    // 87: management.SSHAuth.MachineUsersEntry
+	nil,                                    // 88: management.VNCAuth.MachineUsersEntry
+	(*PortInfo_Range)(nil),                 // 89: management.PortInfo.Range
+	nil,                                    // 90: management.NetworkMapComponentsFull.RoutersMapEntry
+	nil,                                    // 91: management.NetworkMapComponentsFull.ResourcePoliciesMapEntry
+	nil,                                    // 92: management.NetworkMapComponentsFull.GroupIdToUserIdsEntry
+	nil,                                    // 93: management.NetworkMapComponentsFull.PostureFailedPeersEntry
+	nil,                                    // 94: management.PolicyCompact.AuthorizedGroupsEntry
+	(*timestamppb.Timestamp)(nil),          // 95: google.protobuf.Timestamp
+	(*durationpb.Duration)(nil),            // 96: google.protobuf.Duration
 }
 var file_management_proto_depIdxs = []int32{
-	11,  // 0: management.JobRequest.bundle:type_name -> management.BundleParameters
+	12,  // 0: management.JobRequest.bundle:type_name -> management.BundleParameters
 	0,   // 1: management.JobResponse.status:type_name -> management.JobStatus
-	12,  // 2: management.JobResponse.bundle:type_name -> management.BundleResult
-	21,  // 3: management.SyncRequest.meta:type_name -> management.PeerSystemMeta
-	27,  // 4: management.SyncResponse.netbirdConfig:type_name -> management.NetbirdConfig
-	34,  // 5: management.SyncResponse.peerConfig:type_name -> management.PeerConfig
-	41,  // 6: management.SyncResponse.remotePeers:type_name -> management.RemotePeerConfig
-	36,  // 7: management.SyncResponse.NetworkMap:type_name -> management.NetworkMap
-	56,  // 8: management.SyncResponse.Checks:type_name -> management.Checks
-	94,  // 9: management.SyncResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
-	66,  // 10: management.SyncResponse.NetworkMapEnvelope:type_name -> management.NetworkMapEnvelope
-	21,  // 11: management.SyncMetaRequest.meta:type_name -> management.PeerSystemMeta
-	21,  // 12: management.LoginRequest.meta:type_name -> management.PeerSystemMeta
-	17,  // 13: management.LoginRequest.peerKeys:type_name -> management.PeerKeys
-	55,  // 14: management.PeerSystemMeta.networkAddresses:type_name -> management.NetworkAddress
-	18,  // 15: management.PeerSystemMeta.environment:type_name -> management.Environment
-	19,  // 16: management.PeerSystemMeta.files:type_name -> management.File
-	20,  // 17: management.PeerSystemMeta.flags:type_name -> management.Flags
+	13,  // 2: management.JobResponse.bundle:type_name -> management.BundleResult
+	22,  // 3: management.SyncRequest.meta:type_name -> management.PeerSystemMeta
+	28,  // 4: management.SyncResponse.netbirdConfig:type_name -> management.NetbirdConfig
+	35,  // 5: management.SyncResponse.peerConfig:type_name -> management.PeerConfig
+	42,  // 6: management.SyncResponse.remotePeers:type_name -> management.RemotePeerConfig
+	37,  // 7: management.SyncResponse.NetworkMap:type_name -> management.NetworkMap
+	57,  // 8: management.SyncResponse.Checks:type_name -> management.Checks
+	95,  // 9: management.SyncResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
+	67,  // 10: management.SyncResponse.NetworkMapEnvelope:type_name -> management.NetworkMapEnvelope
+	22,  // 11: management.SyncMetaRequest.meta:type_name -> management.PeerSystemMeta
+	22,  // 12: management.LoginRequest.meta:type_name -> management.PeerSystemMeta
+	18,  // 13: management.LoginRequest.peerKeys:type_name -> management.PeerKeys
+	56,  // 14: management.PeerSystemMeta.networkAddresses:type_name -> management.NetworkAddress
+	19,  // 15: management.PeerSystemMeta.environment:type_name -> management.Environment
+	20,  // 16: management.PeerSystemMeta.files:type_name -> management.File
+	21,  // 17: management.PeerSystemMeta.flags:type_name -> management.Flags
 	1,   // 18: management.PeerSystemMeta.capabilities:type_name -> management.PeerCapability
-	27,  // 19: management.LoginResponse.netbirdConfig:type_name -> management.NetbirdConfig
-	34,  // 20: management.LoginResponse.peerConfig:type_name -> management.PeerConfig
-	56,  // 21: management.LoginResponse.Checks:type_name -> management.Checks
-	94,  // 22: management.LoginResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
-	21,  // 23: management.ExtendAuthSessionRequest.meta:type_name -> management.PeerSystemMeta
-	94,  // 24: management.ExtendAuthSessionResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
-	94,  // 25: management.ServerKeyResponse.expiresAt:type_name -> google.protobuf.Timestamp
-	28,  // 26: management.NetbirdConfig.stuns:type_name -> management.HostConfig
-	33,  // 27: management.NetbirdConfig.turns:type_name -> management.ProtectedHostConfig
-	28,  // 28: management.NetbirdConfig.signal:type_name -> management.HostConfig
-	29,  // 29: management.NetbirdConfig.relay:type_name -> management.RelayConfig
-	30,  // 30: management.NetbirdConfig.flow:type_name -> management.FlowConfig
-	31,  // 31: management.NetbirdConfig.metrics:type_name -> management.MetricsConfig
-	6,   // 32: management.HostConfig.protocol:type_name -> management.HostConfig.Protocol
-	95,  // 33: management.FlowConfig.interval:type_name -> google.protobuf.Duration
-	28,  // 34: management.ProtectedHostConfig.hostConfig:type_name -> management.HostConfig
-	42,  // 35: management.PeerConfig.sshConfig:type_name -> management.SSHConfig
-	35,  // 36: management.PeerConfig.autoUpdate:type_name -> management.AutoUpdateSettings
-	34,  // 37: management.NetworkMap.peerConfig:type_name -> management.PeerConfig
-	41,  // 38: management.NetworkMap.remotePeers:type_name -> management.RemotePeerConfig
-	48,  // 39: management.NetworkMap.Routes:type_name -> management.Route
-	49,  // 40: management.NetworkMap.DNSConfig:type_name -> management.DNSConfig
-	41,  // 41: management.NetworkMap.offlinePeers:type_name -> management.RemotePeerConfig
-	54,  // 42: management.NetworkMap.FirewallRules:type_name -> management.FirewallRule
-	58,  // 43: management.NetworkMap.routesFirewallRules:type_name -> management.RouteFirewallRule
-	59,  // 44: management.NetworkMap.forwardingRules:type_name -> management.ForwardingRule
-	37,  // 45: management.NetworkMap.sshAuth:type_name -> management.SSHAuth
-	39,  // 46: management.NetworkMap.vncAuth:type_name -> management.VNCAuth
-	86,  // 47: management.SSHAuth.machine_users:type_name -> management.SSHAuth.MachineUsersEntry
-	87,  // 48: management.VNCAuth.machine_users:type_name -> management.VNCAuth.MachineUsersEntry
-	40,  // 49: management.VNCAuth.session_pub_keys:type_name -> management.SessionPubKey
-	42,  // 50: management.RemotePeerConfig.sshConfig:type_name -> management.SSHConfig
-	32,  // 51: management.SSHConfig.jwtConfig:type_name -> management.JWTConfig
-	7,   // 52: management.DeviceAuthorizationFlow.Provider:type_name -> management.DeviceAuthorizationFlow.provider
-	47,  // 53: management.DeviceAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig
-	47,  // 54: management.PKCEAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig
-	52,  // 55: management.DNSConfig.NameServerGroups:type_name -> management.NameServerGroup
-	50,  // 56: management.DNSConfig.CustomZones:type_name -> management.CustomZone
-	51,  // 57: management.CustomZone.Records:type_name -> management.SimpleRecord
-	53,  // 58: management.NameServerGroup.NameServers:type_name -> management.NameServer
-	3,   // 59: management.FirewallRule.Direction:type_name -> management.RuleDirection
-	4,   // 60: management.FirewallRule.Action:type_name -> management.RuleAction
-	2,   // 61: management.FirewallRule.Protocol:type_name -> management.RuleProtocol
-	57,  // 62: management.FirewallRule.PortInfo:type_name -> management.PortInfo
-	88,  // 63: management.PortInfo.range:type_name -> management.PortInfo.Range
-	4,   // 64: management.RouteFirewallRule.action:type_name -> management.RuleAction
-	2,   // 65: management.RouteFirewallRule.protocol:type_name -> management.RuleProtocol
-	57,  // 66: management.RouteFirewallRule.portInfo:type_name -> management.PortInfo
-	2,   // 67: management.ForwardingRule.protocol:type_name -> management.RuleProtocol
-	57,  // 68: management.ForwardingRule.destinationPort:type_name -> management.PortInfo
-	57,  // 69: management.ForwardingRule.translatedPort:type_name -> management.PortInfo
-	5,   // 70: management.ExposeServiceRequest.protocol:type_name -> management.ExposeProtocol
-	67,  // 71: management.NetworkMapEnvelope.full:type_name -> management.NetworkMapComponentsFull
-	71,  // 72: management.NetworkMapEnvelope.delta:type_name -> management.NetworkMapComponentsDelta
-	34,  // 73: management.NetworkMapComponentsFull.peer_config:type_name -> management.PeerConfig
-	70,  // 74: management.NetworkMapComponentsFull.network:type_name -> management.AccountNetwork
-	69,  // 75: management.NetworkMapComponentsFull.account_settings:type_name -> management.AccountSettingsCompact
-	77,  // 76: management.NetworkMapComponentsFull.dns_settings:type_name -> management.DNSSettingsCompact
-	72,  // 77: management.NetworkMapComponentsFull.peers:type_name -> management.PeerCompact
-	73,  // 78: management.NetworkMapComponentsFull.policies:type_name -> management.PolicyCompact
-	76,  // 79: management.NetworkMapComponentsFull.groups:type_name -> management.GroupCompact
-	78,  // 80: management.NetworkMapComponentsFull.routes:type_name -> management.RouteRaw
-	79,  // 81: management.NetworkMapComponentsFull.nameserver_groups:type_name -> management.NameServerGroupRaw
-	51,  // 82: management.NetworkMapComponentsFull.all_dns_records:type_name -> management.SimpleRecord
-	50,  // 83: management.NetworkMapComponentsFull.account_zones:type_name -> management.CustomZone
-	80,  // 84: management.NetworkMapComponentsFull.network_resources:type_name -> management.NetworkResourceRaw
-	89,  // 85: management.NetworkMapComponentsFull.routers_map:type_name -> management.NetworkMapComponentsFull.RoutersMapEntry
-	90,  // 86: management.NetworkMapComponentsFull.resource_policies_map:type_name -> management.NetworkMapComponentsFull.ResourcePoliciesMapEntry
-	91,  // 87: management.NetworkMapComponentsFull.group_id_to_user_ids:type_name -> management.NetworkMapComponentsFull.GroupIdToUserIdsEntry
-	92,  // 88: management.NetworkMapComponentsFull.posture_failed_peers:type_name -> management.NetworkMapComponentsFull.PostureFailedPeersEntry
-	68,  // 89: management.NetworkMapComponentsFull.proxy_patch:type_name -> management.ProxyPatch
-	41,  // 90: management.ProxyPatch.peers:type_name -> management.RemotePeerConfig
-	41,  // 91: management.ProxyPatch.offline_peers:type_name -> management.RemotePeerConfig
-	54,  // 92: management.ProxyPatch.firewall_rules:type_name -> management.FirewallRule
-	48,  // 93: management.ProxyPatch.routes:type_name -> management.Route
-	58,  // 94: management.ProxyPatch.route_firewall_rules:type_name -> management.RouteFirewallRule
-	59,  // 95: management.ProxyPatch.forwarding_rules:type_name -> management.ForwardingRule
-	4,   // 96: management.PolicyCompact.action:type_name -> management.RuleAction
-	2,   // 97: management.PolicyCompact.protocol:type_name -> management.RuleProtocol
-	88,  // 98: management.PolicyCompact.port_ranges:type_name -> management.PortInfo.Range
-	93,  // 99: management.PolicyCompact.authorized_groups:type_name -> management.PolicyCompact.AuthorizedGroupsEntry
-	74,  // 100: management.PolicyCompact.source_resource:type_name -> management.ResourceCompact
-	74,  // 101: management.PolicyCompact.destination_resource:type_name -> management.ResourceCompact
-	53,  // 102: management.NameServerGroupRaw.nameservers:type_name -> management.NameServer
-	82,  // 103: management.NetworkRouterList.entries:type_name -> management.NetworkRouterEntry
-	38,  // 104: management.SSHAuth.MachineUsersEntry.value:type_name -> management.MachineUserIndexes
-	38,  // 105: management.VNCAuth.MachineUsersEntry.value:type_name -> management.MachineUserIndexes
-	81,  // 106: management.NetworkMapComponentsFull.RoutersMapEntry.value:type_name -> management.NetworkRouterList
-	83,  // 107: management.NetworkMapComponentsFull.ResourcePoliciesMapEntry.value:type_name -> management.PolicyIds
-	84,  // 108: management.NetworkMapComponentsFull.GroupIdToUserIdsEntry.value:type_name -> management.UserIDList
-	85,  // 109: management.NetworkMapComponentsFull.PostureFailedPeersEntry.value:type_name -> management.PeerIndexSet
-	75,  // 110: management.PolicyCompact.AuthorizedGroupsEntry.value:type_name -> management.UserNameList
-	8,   // 111: management.ManagementService.Login:input_type -> management.EncryptedMessage
-	8,   // 112: management.ManagementService.Sync:input_type -> management.EncryptedMessage
-	26,  // 113: management.ManagementService.GetServerKey:input_type -> management.Empty
-	26,  // 114: management.ManagementService.isHealthy:input_type -> management.Empty
-	8,   // 115: management.ManagementService.GetDeviceAuthorizationFlow:input_type -> management.EncryptedMessage
-	8,   // 116: management.ManagementService.GetPKCEAuthorizationFlow:input_type -> management.EncryptedMessage
-	8,   // 117: management.ManagementService.SyncMeta:input_type -> management.EncryptedMessage
-	8,   // 118: management.ManagementService.Logout:input_type -> management.EncryptedMessage
-	8,   // 119: management.ManagementService.Job:input_type -> management.EncryptedMessage
-	8,   // 120: management.ManagementService.ExtendAuthSession:input_type -> management.EncryptedMessage
-	8,   // 121: management.ManagementService.CreateExpose:input_type -> management.EncryptedMessage
-	8,   // 122: management.ManagementService.RenewExpose:input_type -> management.EncryptedMessage
-	8,   // 123: management.ManagementService.StopExpose:input_type -> management.EncryptedMessage
-	8,   // 124: management.ManagementService.Login:output_type -> management.EncryptedMessage
-	8,   // 125: management.ManagementService.Sync:output_type -> management.EncryptedMessage
-	25,  // 126: management.ManagementService.GetServerKey:output_type -> management.ServerKeyResponse
-	26,  // 127: management.ManagementService.isHealthy:output_type -> management.Empty
-	8,   // 128: management.ManagementService.GetDeviceAuthorizationFlow:output_type -> management.EncryptedMessage
-	8,   // 129: management.ManagementService.GetPKCEAuthorizationFlow:output_type -> management.EncryptedMessage
-	26,  // 130: management.ManagementService.SyncMeta:output_type -> management.Empty
-	26,  // 131: management.ManagementService.Logout:output_type -> management.Empty
-	8,   // 132: management.ManagementService.Job:output_type -> management.EncryptedMessage
-	8,   // 133: management.ManagementService.ExtendAuthSession:output_type -> management.EncryptedMessage
-	8,   // 134: management.ManagementService.CreateExpose:output_type -> management.EncryptedMessage
-	8,   // 135: management.ManagementService.RenewExpose:output_type -> management.EncryptedMessage
-	8,   // 136: management.ManagementService.StopExpose:output_type -> management.EncryptedMessage
-	124, // [124:137] is the sub-list for method output_type
-	111, // [111:124] is the sub-list for method input_type
-	111, // [111:111] is the sub-list for extension type_name
-	111, // [111:111] is the sub-list for extension extendee
-	0,   // [0:111] is the sub-list for field type_name
+	28,  // 19: management.LoginResponse.netbirdConfig:type_name -> management.NetbirdConfig
+	35,  // 20: management.LoginResponse.peerConfig:type_name -> management.PeerConfig
+	57,  // 21: management.LoginResponse.Checks:type_name -> management.Checks
+	95,  // 22: management.LoginResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
+	22,  // 23: management.ExtendAuthSessionRequest.meta:type_name -> management.PeerSystemMeta
+	95,  // 24: management.ExtendAuthSessionResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
+	95,  // 25: management.ServerKeyResponse.expiresAt:type_name -> google.protobuf.Timestamp
+	29,  // 26: management.NetbirdConfig.stuns:type_name -> management.HostConfig
+	34,  // 27: management.NetbirdConfig.turns:type_name -> management.ProtectedHostConfig
+	29,  // 28: management.NetbirdConfig.signal:type_name -> management.HostConfig
+	30,  // 29: management.NetbirdConfig.relay:type_name -> management.RelayConfig
+	31,  // 30: management.NetbirdConfig.flow:type_name -> management.FlowConfig
+	32,  // 31: management.NetbirdConfig.metrics:type_name -> management.MetricsConfig
+	7,   // 32: management.HostConfig.protocol:type_name -> management.HostConfig.Protocol
+	96,  // 33: management.FlowConfig.interval:type_name -> google.protobuf.Duration
+	29,  // 34: management.ProtectedHostConfig.hostConfig:type_name -> management.HostConfig
+	43,  // 35: management.PeerConfig.sshConfig:type_name -> management.SSHConfig
+	36,  // 36: management.PeerConfig.autoUpdate:type_name -> management.AutoUpdateSettings
+	35,  // 37: management.NetworkMap.peerConfig:type_name -> management.PeerConfig
+	42,  // 38: management.NetworkMap.remotePeers:type_name -> management.RemotePeerConfig
+	49,  // 39: management.NetworkMap.Routes:type_name -> management.Route
+	50,  // 40: management.NetworkMap.DNSConfig:type_name -> management.DNSConfig
+	42,  // 41: management.NetworkMap.offlinePeers:type_name -> management.RemotePeerConfig
+	55,  // 42: management.NetworkMap.FirewallRules:type_name -> management.FirewallRule
+	59,  // 43: management.NetworkMap.routesFirewallRules:type_name -> management.RouteFirewallRule
+	60,  // 44: management.NetworkMap.forwardingRules:type_name -> management.ForwardingRule
+	38,  // 45: management.NetworkMap.sshAuth:type_name -> management.SSHAuth
+	40,  // 46: management.NetworkMap.vncAuth:type_name -> management.VNCAuth
+	87,  // 47: management.SSHAuth.machine_users:type_name -> management.SSHAuth.MachineUsersEntry
+	88,  // 48: management.VNCAuth.machine_users:type_name -> management.VNCAuth.MachineUsersEntry
+	41,  // 49: management.VNCAuth.session_pub_keys:type_name -> management.SessionPubKey
+	43,  // 50: management.RemotePeerConfig.sshConfig:type_name -> management.SSHConfig
+	2,   // 51: management.RemotePeerConfig.lazyState:type_name -> management.LazyState
+	33,  // 52: management.SSHConfig.jwtConfig:type_name -> management.JWTConfig
+	8,   // 53: management.DeviceAuthorizationFlow.Provider:type_name -> management.DeviceAuthorizationFlow.provider
+	48,  // 54: management.DeviceAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig
+	48,  // 55: management.PKCEAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig
+	53,  // 56: management.DNSConfig.NameServerGroups:type_name -> management.NameServerGroup
+	51,  // 57: management.DNSConfig.CustomZones:type_name -> management.CustomZone
+	52,  // 58: management.CustomZone.Records:type_name -> management.SimpleRecord
+	54,  // 59: management.NameServerGroup.NameServers:type_name -> management.NameServer
+	4,   // 60: management.FirewallRule.Direction:type_name -> management.RuleDirection
+	5,   // 61: management.FirewallRule.Action:type_name -> management.RuleAction
+	3,   // 62: management.FirewallRule.Protocol:type_name -> management.RuleProtocol
+	58,  // 63: management.FirewallRule.PortInfo:type_name -> management.PortInfo
+	89,  // 64: management.PortInfo.range:type_name -> management.PortInfo.Range
+	5,   // 65: management.RouteFirewallRule.action:type_name -> management.RuleAction
+	3,   // 66: management.RouteFirewallRule.protocol:type_name -> management.RuleProtocol
+	58,  // 67: management.RouteFirewallRule.portInfo:type_name -> management.PortInfo
+	3,   // 68: management.ForwardingRule.protocol:type_name -> management.RuleProtocol
+	58,  // 69: management.ForwardingRule.destinationPort:type_name -> management.PortInfo
+	58,  // 70: management.ForwardingRule.translatedPort:type_name -> management.PortInfo
+	6,   // 71: management.ExposeServiceRequest.protocol:type_name -> management.ExposeProtocol
+	68,  // 72: management.NetworkMapEnvelope.full:type_name -> management.NetworkMapComponentsFull
+	72,  // 73: management.NetworkMapEnvelope.delta:type_name -> management.NetworkMapComponentsDelta
+	35,  // 74: management.NetworkMapComponentsFull.peer_config:type_name -> management.PeerConfig
+	71,  // 75: management.NetworkMapComponentsFull.network:type_name -> management.AccountNetwork
+	70,  // 76: management.NetworkMapComponentsFull.account_settings:type_name -> management.AccountSettingsCompact
+	78,  // 77: management.NetworkMapComponentsFull.dns_settings:type_name -> management.DNSSettingsCompact
+	73,  // 78: management.NetworkMapComponentsFull.peers:type_name -> management.PeerCompact
+	74,  // 79: management.NetworkMapComponentsFull.policies:type_name -> management.PolicyCompact
+	77,  // 80: management.NetworkMapComponentsFull.groups:type_name -> management.GroupCompact
+	79,  // 81: management.NetworkMapComponentsFull.routes:type_name -> management.RouteRaw
+	80,  // 82: management.NetworkMapComponentsFull.nameserver_groups:type_name -> management.NameServerGroupRaw
+	52,  // 83: management.NetworkMapComponentsFull.all_dns_records:type_name -> management.SimpleRecord
+	51,  // 84: management.NetworkMapComponentsFull.account_zones:type_name -> management.CustomZone
+	81,  // 85: management.NetworkMapComponentsFull.network_resources:type_name -> management.NetworkResourceRaw
+	90,  // 86: management.NetworkMapComponentsFull.routers_map:type_name -> management.NetworkMapComponentsFull.RoutersMapEntry
+	91,  // 87: management.NetworkMapComponentsFull.resource_policies_map:type_name -> management.NetworkMapComponentsFull.ResourcePoliciesMapEntry
+	92,  // 88: management.NetworkMapComponentsFull.group_id_to_user_ids:type_name -> management.NetworkMapComponentsFull.GroupIdToUserIdsEntry
+	93,  // 89: management.NetworkMapComponentsFull.posture_failed_peers:type_name -> management.NetworkMapComponentsFull.PostureFailedPeersEntry
+	69,  // 90: management.NetworkMapComponentsFull.proxy_patch:type_name -> management.ProxyPatch
+	42,  // 91: management.ProxyPatch.peers:type_name -> management.RemotePeerConfig
+	42,  // 92: management.ProxyPatch.offline_peers:type_name -> management.RemotePeerConfig
+	55,  // 93: management.ProxyPatch.firewall_rules:type_name -> management.FirewallRule
+	49,  // 94: management.ProxyPatch.routes:type_name -> management.Route
+	59,  // 95: management.ProxyPatch.route_firewall_rules:type_name -> management.RouteFirewallRule
+	60,  // 96: management.ProxyPatch.forwarding_rules:type_name -> management.ForwardingRule
+	5,   // 97: management.PolicyCompact.action:type_name -> management.RuleAction
+	3,   // 98: management.PolicyCompact.protocol:type_name -> management.RuleProtocol
+	89,  // 99: management.PolicyCompact.port_ranges:type_name -> management.PortInfo.Range
+	94,  // 100: management.PolicyCompact.authorized_groups:type_name -> management.PolicyCompact.AuthorizedGroupsEntry
+	75,  // 101: management.PolicyCompact.source_resource:type_name -> management.ResourceCompact
+	75,  // 102: management.PolicyCompact.destination_resource:type_name -> management.ResourceCompact
+	75,  // 103: management.GroupCompact.resources:type_name -> management.ResourceCompact
+	54,  // 104: management.NameServerGroupRaw.nameservers:type_name -> management.NameServer
+	83,  // 105: management.NetworkRouterList.entries:type_name -> management.NetworkRouterEntry
+	39,  // 106: management.SSHAuth.MachineUsersEntry.value:type_name -> management.MachineUserIndexes
+	39,  // 107: management.VNCAuth.MachineUsersEntry.value:type_name -> management.MachineUserIndexes
+	82,  // 108: management.NetworkMapComponentsFull.RoutersMapEntry.value:type_name -> management.NetworkRouterList
+	84,  // 109: management.NetworkMapComponentsFull.ResourcePoliciesMapEntry.value:type_name -> management.PolicyIds
+	85,  // 110: management.NetworkMapComponentsFull.GroupIdToUserIdsEntry.value:type_name -> management.UserIDList
+	86,  // 111: management.NetworkMapComponentsFull.PostureFailedPeersEntry.value:type_name -> management.PeerIndexSet
+	76,  // 112: management.PolicyCompact.AuthorizedGroupsEntry.value:type_name -> management.UserNameList
+	9,   // 113: management.ManagementService.Login:input_type -> management.EncryptedMessage
+	9,   // 114: management.ManagementService.Sync:input_type -> management.EncryptedMessage
+	27,  // 115: management.ManagementService.GetServerKey:input_type -> management.Empty
+	27,  // 116: management.ManagementService.isHealthy:input_type -> management.Empty
+	9,   // 117: management.ManagementService.GetDeviceAuthorizationFlow:input_type -> management.EncryptedMessage
+	9,   // 118: management.ManagementService.GetPKCEAuthorizationFlow:input_type -> management.EncryptedMessage
+	9,   // 119: management.ManagementService.SyncMeta:input_type -> management.EncryptedMessage
+	9,   // 120: management.ManagementService.Logout:input_type -> management.EncryptedMessage
+	9,   // 121: management.ManagementService.Job:input_type -> management.EncryptedMessage
+	9,   // 122: management.ManagementService.ExtendAuthSession:input_type -> management.EncryptedMessage
+	9,   // 123: management.ManagementService.CreateExpose:input_type -> management.EncryptedMessage
+	9,   // 124: management.ManagementService.RenewExpose:input_type -> management.EncryptedMessage
+	9,   // 125: management.ManagementService.StopExpose:input_type -> management.EncryptedMessage
+	9,   // 126: management.ManagementService.Login:output_type -> management.EncryptedMessage
+	9,   // 127: management.ManagementService.Sync:output_type -> management.EncryptedMessage
+	26,  // 128: management.ManagementService.GetServerKey:output_type -> management.ServerKeyResponse
+	27,  // 129: management.ManagementService.isHealthy:output_type -> management.Empty
+	9,   // 130: management.ManagementService.GetDeviceAuthorizationFlow:output_type -> management.EncryptedMessage
+	9,   // 131: management.ManagementService.GetPKCEAuthorizationFlow:output_type -> management.EncryptedMessage
+	27,  // 132: management.ManagementService.SyncMeta:output_type -> management.Empty
+	27,  // 133: management.ManagementService.Logout:output_type -> management.Empty
+	9,   // 134: management.ManagementService.Job:output_type -> management.EncryptedMessage
+	9,   // 135: management.ManagementService.ExtendAuthSession:output_type -> management.EncryptedMessage
+	9,   // 136: management.ManagementService.CreateExpose:output_type -> management.EncryptedMessage
+	9,   // 137: management.ManagementService.RenewExpose:output_type -> management.EncryptedMessage
+	9,   // 138: management.ManagementService.StopExpose:output_type -> management.EncryptedMessage
+	126, // [126:139] is the sub-list for method output_type
+	113, // [113:126] is the sub-list for method input_type
+	113, // [113:113] is the sub-list for extension type_name
+	113, // [113:113] is the sub-list for extension extendee
+	0,   // [0:113] is the sub-list for field type_name
 }
 
 func init() { file_management_proto_init() }
@@ -9313,7 +9421,7 @@ func file_management_proto_init() {
 		File: protoimpl.DescBuilder{
 			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
 			RawDescriptor: file_management_proto_rawDesc,
-			NumEnums:      8,
+			NumEnums:      9,
 			NumMessages:   86,
 			NumExtensions: 0,
 			NumServices:   1,
diff --git a/shared/management/proto/management.proto b/shared/management/proto/management.proto
index 3974dd7a5..dbad23637 100644
--- a/shared/management/proto/management.proto
+++ b/shared/management/proto/management.proto
@@ -542,6 +542,22 @@ message RemotePeerConfig {
   string fqdn = 4;
 
   string agentVersion = 5;
+
+  // lazyState is the management per-peer override for lazy (on-demand)
+  // connections to this remote peer. LazyStateDefault follows the account-wide
+  // flag; LazyStateLazy forces lazy; LazyStateEager forces an always-active
+  // connection. A local NB_LAZY_CONN/MDM override still wins over this.
+  LazyState lazyState = 6;
+}
+
+// LazyState is the management per-peer override for lazy connections.
+enum LazyState {
+  // Follow the account-wide lazy connection flag.
+  LazyStateDefault = 0;
+  // Force a lazy (on-demand) connection regardless of the account flag.
+  LazyStateLazy = 1;
+  // Force an always-active connection regardless of the account flag.
+  LazyStateEager = 2;
 }
 
 // SSHConfig represents SSH configurations of a peer.
@@ -1063,6 +1079,11 @@ message PeerCompact {
   // (port 22022) is only added when this flag is set and the peer agent
   // version supports it.
   bool server_ssh_allowed = 13;
+
+  // Mirror of types.Peer.ProxyMeta.Embedded. Connections involving an
+  // ephemeral proxy peer on either endpoint default to lazy, so this bit
+  // feeds the per-peer lazyState emitted in RemotePeerConfig.
+  bool proxy_embedded = 14;
 }
 
 // PolicyCompact is the compact form of a policy rule. Group references use
@@ -1128,13 +1149,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
@@ -1158,6 +1178,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 c8cf83bff..8ca8701d6 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
@@ -65,39 +46,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
 	}
@@ -153,245 +103,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 0bdb07339..dc1e7c53c 100644
--- a/shared/management/types/networkmap_components.go
+++ b/shared/management/types/networkmap_components.go
@@ -13,33 +13,34 @@ 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"
 	auth "github.com/netbirdio/netbird/shared/sessionauth"
 )
 
 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]
 }
 
@@ -144,8 +146,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)
@@ -176,11 +178,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)
@@ -188,7 +190,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,
@@ -209,7 +211,7 @@ func (c *NetworkMapComponents) IsEmpty() bool {
 
 // peerConnectionResult holds the output of getPeerConnectionResources.
 type peerConnectionResult struct {
-	peers              []*ComponentPeer
+	peers              []*nmdata.Peer
 	firewallRules      []*FirewallRule
 	authorizedUsers    map[string]map[string]struct{}
 	vncAuthorizedUsers map[string]map[string]struct{}
@@ -227,11 +229,11 @@ func (c *NetworkMapComponents) getPeerConnectionResources(ctx context.Context, t
 	state := NewPeerConnResolveState()
 
 	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
 			}
 			c.applyPolicyRule(ctx, rule, policy.SourcePostureChecks, targetPeer, targetPeerID, generateResources, state)
@@ -251,21 +253,21 @@ func (c *NetworkMapComponents) getPeerConnectionResources(ctx context.Context, t
 
 func (c *NetworkMapComponents) applyPolicyRule(
 	ctx context.Context,
-	rule *PolicyRule,
+	rule *nmdata.PolicyRule,
 	sourcePostureChecks []string,
-	targetPeer *ComponentPeer,
+	targetPeer *nmdata.Peer,
 	targetPeerID string,
-	generateResources func(*PolicyRule, []*ComponentPeer, int),
+	generateResources func(*nmdata.PolicyRule, []*nmdata.Peer, int),
 	state *PeerConnResolveState,
 ) {
 	sourcePeers, peerInSources := c.resolveRuleEndpoint(rule.SourceResource, rule.Sources, targetPeerID, sourcePostureChecks)
 	destinationPeers, peerInDestinations := c.resolveRuleEndpoint(rule.DestinationResource, rule.Destinations, targetPeerID, nil)
 
 	cb := RuleAuthCallbacks{
-		CollectSSHUsers: func(r *PolicyRule, t map[string]map[string]struct{}) {
+		CollectSSHUsers: func(r *nmdata.PolicyRule, t map[string]map[string]struct{}) {
 			c.collectAuthorizedUsers(ctx, r, t)
 		},
-		CollectVNCUsers: func(r *PolicyRule, t map[string]map[string]struct{}) {
+		CollectVNCUsers: func(r *nmdata.PolicyRule, t map[string]map[string]struct{}) {
 			c.collectAuthorizedUsers(ctx, r, t)
 		},
 		GetAllowedUserIDs: c.getAllowedUserIDs,
@@ -274,19 +276,19 @@ func (c *NetworkMapComponents) applyPolicyRule(
 }
 
 func (c *NetworkMapComponents) resolveRuleEndpoint(
-	resource Resource,
+	resource nmdata.Resource,
 	groups []string,
 	peerID string,
 	postureChecks []string,
-) ([]*ComponentPeer, bool) {
-	if resource.Type == ResourceTypePeer && resource.ID != "" {
+) ([]*nmdata.Peer, bool) {
+	if resource.Type == string(ResourceTypePeer) && resource.ID != "" {
 		return c.getPeerFromResource(resource, peerID, postureChecks)
 	}
 	return c.getAllPeersFromGroups(groups, peerID, postureChecks)
 }
 
 // collectAuthorizedUsers populates the target map with authorized user mappings from the rule.
-func (c *NetworkMapComponents) collectAuthorizedUsers(ctx context.Context, rule *PolicyRule, target map[string]map[string]struct{}) {
+func (c *NetworkMapComponents) collectAuthorizedUsers(ctx context.Context, rule *nmdata.PolicyRule, target map[string]map[string]struct{}) {
 	switch {
 	case len(rule.AuthorizedGroups) > 0:
 		MergeAuthorizedGroupUsers(ctx, rule.AuthorizedGroups, c.GroupIDToUserIDs, target)
@@ -306,13 +308,13 @@ 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) {
 			effectiveRule, protocol := NormalizePolicyRuleProtocol(rule)
 			rule = effectiveRule
 
@@ -362,15 +364,15 @@ func (c *NetworkMapComponents) connResourcesGenerator(targetPeer *ComponentPeer)
 					PortsJoined: portsJoined,
 				})
 			}
-		}, func() ([]*ComponentPeer, []*FirewallRule) {
+		}, func() ([]*nmdata.Peer, []*FirewallRule) {
 			return peers, rules
 		}
 }
 
-func (c *NetworkMapComponents) getAllPeersFromGroups(groups []string, peerID string, sourcePostureChecksIDs []string) ([]*ComponentPeer, bool) {
+func (c *NetworkMapComponents) getAllPeersFromGroups(groups []string, peerID string, sourcePostureChecksIDs []string) ([]*nmdata.Peer, bool) {
 	peerInGroups := false
 	uniquePeerIDs := c.getUniquePeerIDsFromGroupsIDs(groups)
-	filteredPeers := make([]*ComponentPeer, 0, len(uniquePeerIDs))
+	filteredPeers := make([]*nmdata.Peer, 0, len(uniquePeerIDs))
 
 	for _, p := range uniquePeerIDs {
 		peerInfo := c.GetPeerInfo(p)
@@ -422,28 +424,28 @@ func (c *NetworkMapComponents) getUniquePeerIDsFromGroupsIDs(groups []string) []
 	return ids
 }
 
-func (c *NetworkMapComponents) getPeerFromResource(resource Resource, peerID string, postureChecks []string) ([]*ComponentPeer, bool) {
+func (c *NetworkMapComponents) getPeerFromResource(resource nmdata.Resource, peerID string, postureChecks []string) ([]*nmdata.Peer, bool) {
 	if resource.ID == peerID {
 		if len(postureChecks) > 0 && !c.ValidatePostureChecksOnPeer(peerID, postureChecks) {
-			return []*ComponentPeer{}, false
+			return []*nmdata.Peer{}, false
 		}
-		return []*ComponentPeer{}, true
+		return []*nmdata.Peer{}, true
 	}
 
 	peerInfo := c.GetPeerInfo(resource.ID)
 	if peerInfo == nil {
-		return []*ComponentPeer{}, false
+		return []*nmdata.Peer{}, false
 	}
 	if len(postureChecks) > 0 && !c.ValidatePostureChecksOnPeer(resource.ID, postureChecks) {
-		return []*ComponentPeer{}, false
+		return []*nmdata.Peer{}, false
 	}
 
-	return []*ComponentPeer{peerInfo}, false
+	return []*nmdata.Peer{peerInfo}, false
 }
 
-func (c *NetworkMapComponents) filterPeersByLoginExpiration(aclPeers []*ComponentPeer) ([]*ComponentPeer, []*ComponentPeer) {
-	peersToConnect := make([]*ComponentPeer, 0, len(aclPeers))
-	var expiredPeers []*ComponentPeer
+func (c *NetworkMapComponents) filterPeersByLoginExpiration(aclPeers []*nmdata.Peer) ([]*nmdata.Peer, []*nmdata.Peer) {
+	peersToConnect := make([]*nmdata.Peer, 0, len(aclPeers))
+	var expiredPeers []*nmdata.Peer
 
 	for _, p := range aclPeers {
 		expired, _ := p.LoginExpired(c.AccountSettings.PeerLoginExpiration)
@@ -483,7 +485,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
 			}
@@ -493,7 +495,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
@@ -505,8 +507,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
@@ -518,14 +520,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...) {
@@ -542,7 +544,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)
@@ -551,9 +553,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
 		}
@@ -572,7 +574,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
 		}
@@ -605,8 +607,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]
@@ -619,8 +621,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 {
@@ -653,7 +655,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
 	}
@@ -670,7 +672,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}
@@ -681,7 +683,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)
 	}
@@ -689,7 +691,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)
@@ -704,11 +706,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)
 				}
@@ -719,15 +727,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
 			}
 
@@ -739,7 +747,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)
@@ -758,7 +766,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) {
@@ -766,7 +774,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 {
@@ -777,9 +785,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 {
@@ -806,14 +814,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) {
@@ -833,17 +844,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 {
@@ -854,9 +865,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,
@@ -864,24 +875,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)
 		}
 	}
@@ -899,7 +910,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)
@@ -927,11 +938,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 {
@@ -943,7 +960,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{}{}
 			}
 		}
@@ -953,13 +970,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 {
@@ -1009,8 +1026,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/policy_authorized_users.go b/shared/management/types/policy_authorized_users.go
index 80b28b879..423287aee 100644
--- a/shared/management/types/policy_authorized_users.go
+++ b/shared/management/types/policy_authorized_users.go
@@ -6,11 +6,12 @@ import (
 
 	log "github.com/sirupsen/logrus"
 
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	auth "github.com/netbirdio/netbird/shared/sessionauth"
 )
 
-// vncInternalPort is the internal port the VNC server listens on (behind DNAT from 5900).
-const vncInternalPort = 25900
+// VNCInternalPort is the internal port the VNC server listens on (behind DNAT from 5900).
+const VNCInternalPort = 25900
 
 // PeerConnResolveState carries the in-progress maps mutated by per-rule
 // resolution while walking an account's policies.
@@ -47,8 +48,8 @@ type VNCSessionPubKey struct {
 // direction-and-auth logic while keeping their own context/state plumbing for
 // authorized-user collection and allowed-user lookups.
 type RuleAuthCallbacks struct {
-	CollectSSHUsers   func(*PolicyRule, map[string]map[string]struct{})
-	CollectVNCUsers   func(*PolicyRule, map[string]map[string]struct{})
+	CollectSSHUsers   func(*nmdata.PolicyRule, map[string]map[string]struct{})
+	CollectVNCUsers   func(*nmdata.PolicyRule, map[string]map[string]struct{})
 	GetAllowedUserIDs func() map[string]struct{}
 }
 
@@ -58,13 +59,13 @@ type RuleAuthCallbacks struct {
 // resolver (Account vs NetworkMapComponents), which also decide the peer
 // representation the resource generator works with.
 func ApplyResolvedRuleToState[P any](
-	rule *PolicyRule,
+	rule *nmdata.PolicyRule,
 	sourcePeers []P,
 	destPeers []P,
 	peerInSources bool,
 	peerInDestinations bool,
 	targetPeerSSHEnabled bool,
-	generateResources func(*PolicyRule, []P, int),
+	generateResources func(*nmdata.PolicyRule, []P, int),
 	cb RuleAuthCallbacks,
 	state *PeerConnResolveState,
 ) {
@@ -72,15 +73,15 @@ func ApplyResolvedRuleToState[P any](
 
 	receivingPeer := peerInDestinations || (rule.Bidirectional && peerInSources)
 	switch {
-	case rule.Protocol == PolicyRuleProtocolNetbirdSSH:
+	case rule.Protocol == string(PolicyRuleProtocolNetbirdSSH):
 		if !receivingPeer {
 			return
 		}
 		state.SSHEnabled = true
 		cb.CollectSSHUsers(rule, state.AuthorizedUsers)
-	case rule.Protocol == PolicyRuleProtocolNetbirdVNC:
+	case rule.Protocol == string(PolicyRuleProtocolNetbirdVNC):
 		cb.handleVNCRule(rule, peerInSources, peerInDestinations, state)
-	case PolicyRuleImpliesLegacySSH(rule) && targetPeerSSHEnabled:
+	case nmdata.PolicyRuleImpliesLegacySSH(rule) && targetPeerSSHEnabled:
 		if !receivingPeer {
 			return
 		}
@@ -94,7 +95,7 @@ func ApplyResolvedRuleToState[P any](
 // peer that appears in the rule's sources also needs the SessionPubKey
 // pushed (otherwise the Noise_IK handshake against that peer would fail
 // because its authorizer wouldn't know the client's static key).
-func (cb RuleAuthCallbacks) handleVNCRule(rule *PolicyRule, peerInSources, peerInDestinations bool, state *PeerConnResolveState) {
+func (cb RuleAuthCallbacks) handleVNCRule(rule *nmdata.PolicyRule, peerInSources, peerInDestinations bool, state *PeerConnResolveState) {
 	receivingPeer := peerInDestinations || (rule.Bidirectional && peerInSources)
 	if !receivingPeer {
 		return
@@ -123,12 +124,12 @@ func MergeWildcardUsers(dst map[string]map[string]struct{}, users map[string]str
 // emitRuleDirections dispatches generateResources for each direction the rule
 // applies in for the target peer.
 func emitRuleDirections[P any](
-	rule *PolicyRule,
+	rule *nmdata.PolicyRule,
 	sourcePeers []P,
 	destPeers []P,
 	peerInSources bool,
 	peerInDestinations bool,
-	generateResources func(*PolicyRule, []P, int),
+	generateResources func(*nmdata.PolicyRule, []P, int),
 ) {
 	if rule.Bidirectional {
 		if peerInSources {
@@ -191,24 +192,35 @@ func EnsureWildcardUser(target map[string]map[string]struct{}, authorizedUser st
 	target[auth.Wildcard][authorizedUser] = struct{}{}
 }
 
-// NormalizePolicyRuleProtocol maps NetBird virtual protocols (netbird-ssh,
-// netbird-vnc) to TCP for the on-the-wire firewall view. For NetbirdVNC the
-// rule is also scoped to the embedded VNC port so a VNC-only rule doesn't
-// degrade into an unscoped TCP allow when the user left Ports empty.
-// Returns the effective rule (possibly a shallow copy with Ports overridden)
-// and the resulting protocol.
-func NormalizePolicyRuleProtocol(rule *PolicyRule) (*PolicyRule, PolicyRuleProtocolType) {
-	switch rule.Protocol {
-	case PolicyRuleProtocolNetbirdSSH:
-		return rule, PolicyRuleProtocolTCP
-	case PolicyRuleProtocolNetbirdVNC:
-		if len(rule.Ports) == 0 && len(rule.PortRanges) == 0 {
-			scoped := *rule
-			scoped.Ports = []string{strconv.Itoa(vncInternalPort)}
-			return &scoped, PolicyRuleProtocolTCP
-		}
-		return rule, PolicyRuleProtocolTCP
+// WirePolicyRuleProtocol maps the NetBird virtual protocols (netbird-ssh,
+// netbird-vnc) to the protocol that goes on the wire, and leaves every other
+// protocol as it is.
+func WirePolicyRuleProtocol(protocol PolicyRuleProtocolType) PolicyRuleProtocolType {
+	switch protocol {
+	case PolicyRuleProtocolNetbirdSSH, PolicyRuleProtocolNetbirdVNC:
+		return PolicyRuleProtocolTCP
 	default:
-		return rule, rule.Protocol
+		return protocol
 	}
 }
+
+// VNCScopedPorts returns the ports a netbird-vnc rule is scoped to when it
+// declares none of its own, so a VNC-only rule doesn't degrade into an
+// unscoped TCP allow.
+func VNCScopedPorts() []string {
+	return []string{strconv.Itoa(VNCInternalPort)}
+}
+
+// NormalizePolicyRuleProtocol maps a rule's protocol with
+// WirePolicyRuleProtocol and scopes a portless netbird-vnc rule to the
+// embedded VNC port. It returns the effective rule, which is a shallow copy
+// only when the ports had to be overridden.
+func NormalizePolicyRuleProtocol(rule *nmdata.PolicyRule) (*nmdata.PolicyRule, PolicyRuleProtocolType) {
+	protocol := WirePolicyRuleProtocol(PolicyRuleProtocolType(rule.Protocol))
+	if rule.Protocol != string(PolicyRuleProtocolNetbirdVNC) || len(rule.Ports) > 0 || len(rule.PortRanges) > 0 {
+		return rule, protocol
+	}
+	scoped := *rule
+	scoped.Ports = VNCScopedPorts()
+	return &scoped, protocol
+}
diff --git a/shared/management/types/policy_authorized_users_security_test.go b/shared/management/types/policy_authorized_users_security_test.go
index b7337445f..0a8944c6a 100644
--- a/shared/management/types/policy_authorized_users_security_test.go
+++ b/shared/management/types/policy_authorized_users_security_test.go
@@ -2,6 +2,8 @@ package types
 
 import (
 	"testing"
+
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 )
 
 // TestHandleVNCRule_BidirectionalDistributesPubkeyToSourcePeer covers the
@@ -13,15 +15,15 @@ import (
 // fix in handleVNCRule must distribute the pubkey to either side of a
 // bidirectional rule.
 func TestHandleVNCRule_BidirectionalDistributesPubkeyToSourcePeer(t *testing.T) {
-	rule := &PolicyRule{
-		Protocol:           PolicyRuleProtocolNetbirdVNC,
+	rule := &nmdata.PolicyRule{
+		Protocol:           string(PolicyRuleProtocolNetbirdVNC),
 		Bidirectional:      true,
 		AuthorizedUser:     "user1",
 		SessionPubKey:      "pubkey-base64",
 		SessionDisplayName: "Alice",
 	}
 	cb := RuleAuthCallbacks{
-		CollectVNCUsers: func(_ *PolicyRule, _ map[string]map[string]struct{}) {},
+		CollectVNCUsers: func(_ *nmdata.PolicyRule, _ map[string]map[string]struct{}) {},
 	}
 	state := NewPeerConnResolveState()
 
@@ -40,14 +42,14 @@ func TestHandleVNCRule_BidirectionalDistributesPubkeyToSourcePeer(t *testing.T)
 // a strictly source-to-destination rule still must not push the
 // SessionPubKey to peers that appear only in sources.
 func TestHandleVNCRule_UnidirectionalSourceGetsNoPubkey(t *testing.T) {
-	rule := &PolicyRule{
-		Protocol:       PolicyRuleProtocolNetbirdVNC,
+	rule := &nmdata.PolicyRule{
+		Protocol:       string(PolicyRuleProtocolNetbirdVNC),
 		Bidirectional:  false,
 		AuthorizedUser: "user1",
 		SessionPubKey:  "pubkey-base64",
 	}
 	cb := RuleAuthCallbacks{
-		CollectVNCUsers: func(_ *PolicyRule, _ map[string]map[string]struct{}) {},
+		CollectVNCUsers: func(_ *nmdata.PolicyRule, _ map[string]map[string]struct{}) {},
 	}
 	state := NewPeerConnResolveState()
 
@@ -62,14 +64,14 @@ func TestHandleVNCRule_UnidirectionalSourceGetsNoPubkey(t *testing.T) {
 // destination peers must always receive the SessionPubKey since they're
 // the ones that need to authenticate the incoming Noise handshake.
 func TestHandleVNCRule_DestinationAlwaysGetsPubkey(t *testing.T) {
-	rule := &PolicyRule{
-		Protocol:       PolicyRuleProtocolNetbirdVNC,
+	rule := &nmdata.PolicyRule{
+		Protocol:       string(PolicyRuleProtocolNetbirdVNC),
 		Bidirectional:  false,
 		AuthorizedUser: "user1",
 		SessionPubKey:  "pubkey-base64",
 	}
 	cb := RuleAuthCallbacks{
-		CollectVNCUsers: func(_ *PolicyRule, _ map[string]map[string]struct{}) {},
+		CollectVNCUsers: func(_ *nmdata.PolicyRule, _ map[string]map[string]struct{}) {},
 	}
 	state := NewPeerConnResolveState()
 
@@ -89,18 +91,18 @@ func TestHandleVNCRule_DestinationAlwaysGetsPubkey(t *testing.T) {
 func TestApplyResolvedRule_BidirectionalSSHEnablesSourcePeer(t *testing.T) {
 	collected := false
 	cb := RuleAuthCallbacks{
-		CollectSSHUsers: func(_ *PolicyRule, target map[string]map[string]struct{}) {
+		CollectSSHUsers: func(_ *nmdata.PolicyRule, target map[string]map[string]struct{}) {
 			collected = true
 			target["local"] = map[string]struct{}{"user1": {}}
 		},
 	}
-	rule := &PolicyRule{
-		Protocol:      PolicyRuleProtocolNetbirdSSH,
+	rule := &nmdata.PolicyRule{
+		Protocol:      string(PolicyRuleProtocolNetbirdSSH),
 		Bidirectional: true,
 	}
 	state := NewPeerConnResolveState()
 
-	ApplyResolvedRuleToState(rule, nil, nil, true /*peerInSources*/, false /*peerInDestinations*/, false, func(*PolicyRule, []*ComponentPeer, int) {}, cb, state)
+	ApplyResolvedRuleToState(rule, nil, nil, true /*peerInSources*/, false /*peerInDestinations*/, false, func(*nmdata.PolicyRule, []*nmdata.Peer, int) {}, cb, state)
 
 	if !state.SSHEnabled {
 		t.Fatal("expected SSH enabled on source-side peer of bidirectional SSH rule")
@@ -119,17 +121,17 @@ func TestApplyResolvedRule_BidirectionalSSHEnablesSourcePeer(t *testing.T) {
 func TestApplyResolvedRule_UnidirectionalSSHSkipsSourcePeer(t *testing.T) {
 	collected := false
 	cb := RuleAuthCallbacks{
-		CollectSSHUsers: func(_ *PolicyRule, _ map[string]map[string]struct{}) {
+		CollectSSHUsers: func(_ *nmdata.PolicyRule, _ map[string]map[string]struct{}) {
 			collected = true
 		},
 	}
-	rule := &PolicyRule{
-		Protocol:      PolicyRuleProtocolNetbirdSSH,
+	rule := &nmdata.PolicyRule{
+		Protocol:      string(PolicyRuleProtocolNetbirdSSH),
 		Bidirectional: false,
 	}
 	state := NewPeerConnResolveState()
 
-	ApplyResolvedRuleToState(rule, nil, nil, true /*peerInSources*/, false /*peerInDestinations*/, false, func(*PolicyRule, []*ComponentPeer, int) {}, cb, state)
+	ApplyResolvedRuleToState(rule, nil, nil, true /*peerInSources*/, false /*peerInDestinations*/, false, func(*nmdata.PolicyRule, []*nmdata.Peer, int) {}, cb, state)
 
 	if state.SSHEnabled {
 		t.Fatal("expected SSH NOT enabled on source-only peer of unidirectional SSH rule")
diff --git a/shared/management/types/policyrule.go b/shared/management/types/policyrule.go
index fdcbd5e6e..3d2226001 100644
--- a/shared/management/types/policyrule.go
+++ b/shared/management/types/policyrule.go
@@ -1,22 +1,41 @@
 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")
+	// PolicyRuleProtocolNetbirdVNC type of traffic
+	PolicyRuleProtocolNetbirdVNC = PolicyRuleProtocolType("netbird-vnc")
+)
 
 // RulePortRange represents a range of ports for a firewall rule.
 type RulePortRange struct {
@@ -39,204 +58,86 @@ 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
+	case "netbird-vnc":
+		return PolicyRuleProtocolNetbirdVNC, RulePortRange{Start: VNCInternalPort, End: VNCInternalPort}, 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
-
-	// SessionPubKey is the base64 X25519 public key used with Noise_IK to
-	// bind a VNC session to the AuthorizedUser. Set together with
-	// AuthorizedUser when the rule was created via temporary-access for a
-	// VNC scope; empty otherwise.
-	SessionPubKey string
-
-	// SessionDisplayName is a human-readable label for the user the
-	// SessionPubKey was issued to (typically display name, falling back
-	// to email or user id). The daemon surfaces it on the host's
-	// per-connection approval prompt so the user being asked can
-	// recognise who is requesting access.
-	SessionDisplayName 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,
-		SessionPubKey:       pm.SessionPubKey,
-		SessionDisplayName:  pm.SessionDisplayName,
-	}
-	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 ||
-		pm.SessionPubKey != other.SessionPubKey ||
-		pm.SessionDisplayName != other.SessionDisplayName {
-		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/shared/relay/client/client.go b/shared/relay/client/client.go
index 4fb30b8d9..38c9c7375 100644
--- a/shared/relay/client/client.go
+++ b/shared/relay/client/client.go
@@ -14,7 +14,7 @@ import (
 
 	log "github.com/sirupsen/logrus"
 
-	"github.com/netbirdio/netbird/client/netsweep"
+	"github.com/netbirdio/netbird/client/netevents/sweep"
 	auth "github.com/netbirdio/netbird/shared/relay/auth/hmac"
 	"github.com/netbirdio/netbird/shared/relay/client/dialer"
 	netErr "github.com/netbirdio/netbird/shared/relay/client/dialer/net"
@@ -151,6 +151,14 @@ type transportConn interface {
 	Protocol() string
 }
 
+// NetEvents is the OS network event view the relay consumes: availability
+// gating for the reconnect guard and dial registration for the network change
+// sweep.
+type NetEvents interface {
+	NetworkWatcher
+	StartDial(ctx context.Context) *sweep.Dial
+}
+
 // Client is a client for the relay server. It is responsible for establishing a connection to the relay server and
 // managing connections to other peers. All exported functions are safe to call concurrently. After close the connection,
 // the client can be reused by calling Connect again. When the client is closed, all connections are closed too.
@@ -186,9 +194,10 @@ type Client struct {
 	// the manager.
 	transportFallback *transportFallback
 
-	// sweeper cuts the relay connection on network change; the read loop
-	// reports the disconnect and the guard reconnects. Shared via the manager.
-	sweeper *netsweep.Sweeper
+	// netEvents registers the relay dial for the network change sweep; the
+	// read loop reports the disconnect and the guard reconnects. Shared via
+	// the manager.
+	netEvents NetEvents
 	// datagramFallbackTriggered guards a single fallback per connection so a
 	// burst of oversized datagrams triggers one reconnect, not many.
 	datagramFallbackTriggered atomic.Bool
@@ -400,7 +409,12 @@ func (c *Client) Close() error {
 func (c *Client) connect(ctx context.Context) (*RelayAddr, error) {
 	// A sweep cancels this context, so a dial started on the old network
 	// aborts instead of waiting out its handshake timeout.
-	dial := c.sweeper.StartDial(ctx)
+	var dial *sweep.Dial
+	if c.netEvents != nil {
+		dial = c.netEvents.StartDial(ctx)
+	} else {
+		dial = (*sweep.Sweeper)(nil).StartDial(ctx)
+	}
 	defer dial.Release()
 	ctx = dial.Ctx()
 
diff --git a/shared/relay/client/guard.go b/shared/relay/client/guard.go
index a62f8772d..c0294b82d 100644
--- a/shared/relay/client/guard.go
+++ b/shared/relay/client/guard.go
@@ -7,8 +7,6 @@ import (
 
 	"github.com/cenkalti/backoff/v4"
 	log "github.com/sirupsen/logrus"
-
-	"github.com/netbirdio/netbird/client/netstate"
 )
 
 const (
@@ -24,6 +22,13 @@ const (
 	verdictSettleWindow = 200 * time.Millisecond
 )
 
+// NetworkWatcher is the availability view the guard gates reconnects on.
+type NetworkWatcher interface {
+	Wait(ctx context.Context) (bool, error)
+	IsOnline() bool
+	WaitSettled(ctx context.Context, budget, settleWindow time.Duration) bool
+}
+
 // Guard manage the reconnection tries to the Relay server in case of disconnection event.
 type Guard struct {
 	// OnNewRelayClient is a channel that is used to notify the relay manager about a new relay client instance.
@@ -35,9 +40,8 @@ type Guard struct {
 	// attempts.
 	maxBackoffInterval time.Duration
 
-	// netState gates reconnect attempts on OS-reported network availability;
-	// nil disables gating.
-	netState *netstate.State
+	// netWatcher gates reconnect attempts on OS-reported network availability.
+	netWatcher NetworkWatcher
 
 	// lastErr is the error from the most recent failed reconnect attempt,
 	// surfaced as the home relay status while disconnected.
@@ -45,9 +49,8 @@ type Guard struct {
 }
 
 // NewGuard creates a new guard for the relay client. A non-positive
-// maxBackoffInterval falls back to defaultMaxBackoffInterval. A nil netState
-// disables network availability gating.
-func NewGuard(sp *ServerPicker, maxBackoffInterval time.Duration, netState *netstate.State) *Guard {
+// maxBackoffInterval falls back to defaultMaxBackoffInterval.
+func NewGuard(sp *ServerPicker, maxBackoffInterval time.Duration, netWatcher NetworkWatcher) *Guard {
 	if maxBackoffInterval <= 0 {
 		maxBackoffInterval = defaultMaxBackoffInterval
 	}
@@ -56,7 +59,7 @@ func NewGuard(sp *ServerPicker, maxBackoffInterval time.Duration, netState *nets
 		OnReconnected:      make(chan struct{}, 1),
 		serverPicker:       sp,
 		maxBackoffInterval: maxBackoffInterval,
-		netState:           netState,
+		netWatcher:         netWatcher,
 	}
 	return g
 }
@@ -97,12 +100,14 @@ func (g *Guard) StartReconnectTrys(ctx context.Context, relayClient *Client) {
 		select {
 		case <-ticker.C:
 			// suspend reconnect attempts while the OS reports no usable network
-			if waited, err := g.netState.Wait(ctx); err != nil {
-				return
-			} else if waited {
-				ticker.Stop()
-				ticker = g.exponentTicker(ctx)
-				continue
+			if g.netWatcher != nil {
+				if waited, err := g.netWatcher.Wait(ctx); err != nil {
+					return
+				} else if waited {
+					ticker.Stop()
+					ticker = g.exponentTicker(ctx)
+					continue
+				}
 			}
 			if err := g.retry(ctx); err != nil {
 				log.Errorf("failed to pick new Relay server: %s", err)
@@ -129,13 +134,18 @@ func (g *Guard) tryToQuickReconnect(parentCtx context.Context, rc *Client) bool
 		return false
 	}
 
-	if ok := g.waitForNetwork(parentCtx); !ok {
-		return false
-	}
-
-	// Still offline after the budget: leave the retry to the ticker.
-	if !g.netState.IsOnline() {
-		return false
+	if g.netWatcher != nil {
+		if ok := g.netWatcher.WaitSettled(parentCtx, quickReconnectBudget, verdictSettleWindow); !ok {
+			return false
+		}
+		// Still offline after the budget: leave the retry to the ticker.
+		if !g.netWatcher.IsOnline() {
+			return false
+		}
+	} else {
+		if cancelled := waitBeforeRetry(parentCtx); !cancelled {
+			return false
+		}
 	}
 
 	log.Infof("try to reconnect to Relay server: %s", rc.connectionURL)
@@ -200,47 +210,14 @@ func (g *Guard) exponentTicker(ctx context.Context) *backoff.Ticker {
 	return backoff.NewTicker(bo)
 }
 
-// waitForNetwork waits out the settle window while online, or waits for the
-// network to return while offline, within the budget. Returns false when ctx
-// is cancelled. Without an injected netState it degrades to a fixed
-// budget-long sleep, the pre-netstate behavior.
-func (g *Guard) waitForNetwork(ctx context.Context) bool {
-	budget := time.NewTimer(quickReconnectBudget)
-	defer budget.Stop()
+func waitBeforeRetry(ctx context.Context) bool {
+	timer := time.NewTimer(quickReconnectBudget)
+	defer timer.Stop()
 
-	settleWindow := verdictSettleWindow
-	if g.netState == nil {
-		settleWindow = quickReconnectBudget
-	}
-	settle := time.NewTimer(settleWindow)
-	defer settle.Stop()
-
-	for {
-		// Channel first, flag second: a flip in between still fires the channel.
-		changedCh := g.netState.Changed()
-		if g.netState.IsOnline() {
-			select {
-			case <-settle.C:
-				return true
-			case <-changedCh:
-			case <-ctx.Done():
-				return false
-			}
-		} else {
-			select {
-			case <-budget.C:
-				return true
-			case <-changedCh:
-			case <-ctx.Done():
-				return false
-			}
-		}
-		if !settle.Stop() {
-			select {
-			case <-settle.C:
-			default:
-			}
-		}
-		settle.Reset(settleWindow)
+	select {
+	case <-timer.C:
+		return true
+	case <-ctx.Done():
+		return false
 	}
 }
diff --git a/shared/relay/client/guard_test.go b/shared/relay/client/guard_test.go
deleted file mode 100644
index 0e05783e0..000000000
--- a/shared/relay/client/guard_test.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package client
-
-import (
-	"context"
-	"testing"
-	"time"
-
-	"github.com/stretchr/testify/assert"
-
-	"github.com/netbirdio/netbird/client/netstate"
-)
-
-func TestWaitForNetworkSettlesAfterOutage(t *testing.T) {
-	ns := netstate.New()
-	ns.Set(false)
-	g := NewGuard(nil, 0, ns)
-
-	const outage = 2 * verdictSettleWindow
-	start := time.Now()
-	go func() {
-		time.Sleep(outage)
-		ns.Set(true)
-	}()
-
-	ok := g.waitForNetwork(context.Background())
-	elapsed := time.Since(start)
-
-	assert.True(t, ok, "recovered network must let the quick reconnect proceed")
-	assert.GreaterOrEqual(t, elapsed, outage+verdictSettleWindow, "reconnect must wait a full settle window after the network returns")
-}
diff --git a/shared/relay/client/manager.go b/shared/relay/client/manager.go
index 80e38ae2d..50fcc0b8f 100644
--- a/shared/relay/client/manager.go
+++ b/shared/relay/client/manager.go
@@ -12,8 +12,6 @@ import (
 
 	log "github.com/sirupsen/logrus"
 
-	"github.com/netbirdio/netbird/client/netstate"
-	"github.com/netbirdio/netbird/client/netsweep"
 	relayAuth "github.com/netbirdio/netbird/shared/relay/auth/hmac"
 )
 
@@ -67,15 +65,9 @@ func WithMaxBackoffInterval(d time.Duration) ManagerOption {
 	return func(m *Manager) { m.maxBackoffInterval = d }
 }
 
-// WithNetworkState injects the OS network availability state that gates the
-// reconnect guard; without it reconnect attempts are not gated.
-func WithNetworkState(netState *netstate.State) ManagerOption {
-	return func(m *Manager) { m.netState = netState }
-}
-
-// WithSweeper injects the network change sweeper.
-func WithSweeper(sweeper *netsweep.Sweeper) ManagerOption {
-	return func(m *Manager) { m.sweeper = sweeper }
+// WithNetEvents injects the OS network event handling.
+func WithNetEvents(events NetEvents) ManagerOption {
+	return func(m *Manager) { m.netEvents = events }
 }
 
 // Manager is a manager for the relay client instances. It establishes one persistent connection to the given relay URL
@@ -105,8 +97,7 @@ type Manager struct {
 
 	mtu                uint16
 	maxBackoffInterval time.Duration
-	netState           *netstate.State
-	sweeper            *netsweep.Sweeper
+	netEvents          NetEvents
 
 	cleanupInterval      time.Duration
 	keepUnusedServerTime time.Duration
@@ -143,9 +134,9 @@ func NewManager(ctx context.Context, serverURLs []string, peerID string, mtu uin
 	for _, opt := range opts {
 		opt(m)
 	}
-	m.serverPicker.Sweeper = m.sweeper
+	m.serverPicker.NetEvents = m.netEvents
 	m.serverPicker.ServerURLs.Store(serverURLs)
-	m.reconnectGuard = NewGuard(m.serverPicker, m.maxBackoffInterval, m.netState)
+	m.reconnectGuard = NewGuard(m.serverPicker, m.maxBackoffInterval, m.netEvents)
 	return m
 }
 
@@ -370,7 +361,7 @@ func (m *Manager) openConnVia(ctx context.Context, serverAddress, peerKey string
 
 	relayClient := NewClientWithServerIP(serverAddress, serverIP, m.tokenStore, m.peerID, m.mtu)
 	relayClient.SetTransportFallback(m.transportFallback)
-	relayClient.sweeper = m.sweeper
+	relayClient.netEvents = m.netEvents
 	err := relayClient.Connect(m.ctx)
 	if err != nil {
 		rt.Lock()
diff --git a/shared/relay/client/picker.go b/shared/relay/client/picker.go
index 72789fadc..17b1390b1 100644
--- a/shared/relay/client/picker.go
+++ b/shared/relay/client/picker.go
@@ -9,7 +9,6 @@ import (
 
 	log "github.com/sirupsen/logrus"
 
-	"github.com/netbirdio/netbird/client/netsweep"
 	auth "github.com/netbirdio/netbird/shared/relay/auth/hmac"
 )
 
@@ -31,7 +30,7 @@ type ServerPicker struct {
 	MTU               uint16
 	ConnectionTimeout time.Duration
 	TransportFallback *transportFallback
-	Sweeper           *netsweep.Sweeper
+	NetEvents         NetEvents
 }
 
 func (sp *ServerPicker) PickServer(parentCtx context.Context) (*Client, error) {
@@ -75,7 +74,7 @@ func (sp *ServerPicker) startConnection(ctx context.Context, resultChan chan con
 	log.Infof("try to connecting to relay server: %s", url)
 	relayClient := NewClient(url, sp.TokenStore, sp.PeerID, sp.MTU)
 	relayClient.SetTransportFallback(sp.TransportFallback)
-	relayClient.sweeper = sp.Sweeper
+	relayClient.netEvents = sp.NetEvents
 	err := relayClient.Connect(ctx)
 	resultChan <- connResult{
 		RelayClient: relayClient,
diff --git a/shared/signal/client/grpc.go b/shared/signal/client/grpc.go
index 73c482e8f..a0bb2f080 100644
--- a/shared/signal/client/grpc.go
+++ b/shared/signal/client/grpc.go
@@ -19,8 +19,7 @@ import (
 	"google.golang.org/grpc/status"
 
 	nbgrpc "github.com/netbirdio/netbird/client/grpc"
-	"github.com/netbirdio/netbird/client/netstate"
-	"github.com/netbirdio/netbird/client/netsweep"
+	"github.com/netbirdio/netbird/client/netevents"
 	"github.com/netbirdio/netbird/encryption"
 	"github.com/netbirdio/netbird/shared/management/client"
 	"github.com/netbirdio/netbird/shared/signal/proto"
@@ -67,12 +66,9 @@ type GrpcClient struct {
 	connStateCallback     ConnStateNotifier
 	connStateCallbackLock sync.RWMutex
 
-	// netState gates the Receive retry loop on OS-reported network
-	// availability; nil (the default) disables gating.
-	netState *netstate.State
-
-	// sweeper cuts the transport connections on network change; nil disables it.
-	sweeper *netsweep.Sweeper
+	// netMgr gates the Receive retry loop on OS-reported network
+	// availability and sweeps the transport on network change.
+	netMgr *netevents.Manager
 
 	onReconnectedListenerFn func()
 
@@ -100,15 +96,9 @@ type GrpcClient struct {
 // Option configures optional GrpcClient behavior.
 type Option func(*GrpcClient)
 
-// WithNetworkState injects the OS network availability state that gates the
-// Receive retry loop; without it gating is disabled.
-func WithNetworkState(netState *netstate.State) Option {
-	return func(c *GrpcClient) { c.netState = netState }
-}
-
-// WithSweeper injects the network change sweeper.
-func WithSweeper(sweeper *netsweep.Sweeper) Option {
-	return func(c *GrpcClient) { c.sweeper = sweeper }
+// WithNetEvents injects the OS network event handling.
+func WithNetEvents(events *netevents.Manager) Option {
+	return func(c *GrpcClient) { c.netMgr = events }
 }
 
 // NewClient creates a new Signal client
@@ -126,8 +116,8 @@ func NewClient(ctx context.Context, addr string, key wgtypes.Key, tlsEnabled boo
 	}
 
 	var extraOpts []grpc.DialOption
-	if c.sweeper != nil {
-		extraOpts = append(extraOpts, nbgrpc.WithSweeper(c.sweeper))
+	if c.netMgr != nil {
+		extraOpts = append(extraOpts, nbgrpc.WithSweeper(c.netMgr))
 	}
 
 	var conn *grpc.ClientConn
@@ -198,17 +188,20 @@ func defaultBackoff(ctx context.Context) backoff.BackOff {
 // The connection retry logic will try to reconnect for 30 min and if wasn't successful will propagate the error to the function caller.
 func (c *GrpcClient) Receive(ctx context.Context, msgHandler func(msg *proto.Message) error) error {
 
-	backOff := c.sweeper.QuickRetryBackoff(ctx, defaultBackoff(ctx), c.netState)
+	backOff := c.netMgr.QuickRetryBackoff(ctx, defaultBackoff(ctx))
 
 	operation := func() error {
 		// suspend reconnect attempts while the OS reports no usable network.
 		// Wait only errors on a cancelled context, which means shutdown, so
 		// stop the loop without reporting a failure.
-		if waited, err := c.netState.Wait(ctx); err != nil {
+		if waited, err := c.netMgr.Wait(ctx); err != nil {
 			log.Debugf("signal connection context has been canceled while offline, this usually indicates shutdown")
 			return nil
 		} else if waited {
 			backOff.Reset()
+			// dials attempted while offline grew the channel's internal backoff;
+			// reset it too, or the reconnect waits out that timer first
+			c.signalConn.ResetConnectBackoff()
 		}
 
 		c.notifyStreamDisconnected()
@@ -281,7 +274,7 @@ func (c *GrpcClient) Receive(ctx context.Context, msgHandler func(msg *proto.Mes
 		return nil
 	}
 
-	err := nbgrpc.Retry(ctx, operation, backOff, c.netState)
+	err := nbgrpc.Retry(ctx, operation, backOff, c.netMgr)
 	if err != nil {
 		log.Errorf("exiting the Signal service connection retry loop due to the unrecoverable error: %v", err)
 		return err
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)
-		})
-	}
-}