diff --git a/.github/workflows/agent-network-e2e.yml b/.github/workflows/agent-network-e2e.yml index 88b98293d..9501c5fba 100644 --- a/.github/workflows/agent-network-e2e.yml +++ b/.github/workflows/agent-network-e2e.yml @@ -12,6 +12,13 @@ on: AWS issues it. Leave empty for the Sonnet 4.6 default. required: false default: "" + test_pattern: + description: >- + Package pattern to run. Defaults to the whole suite; narrow it to one + package (e.g. ./e2e/agentnetwork/...) when a run only needs that + package's answer and not the sixteen minutes the container suite costs. + required: false + default: "./e2e/..." concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -77,4 +84,8 @@ jobs: GOOGLE_VERTEX_PROJECT: ${{ secrets.E2E_GOOGLE_VERTEX_PROJECT }} GOOGLE_VERTEX_REGION: ${{ secrets.E2E_GOOGLE_VERTEX_REGION }} GOOGLE_VERTEX_MODEL: ${{ secrets.E2E_GOOGLE_VERTEX_MODEL }} - run: go test -tags e2e -timeout 40m -v ./e2e/... + # Read through an env var rather than interpolated into the run + # script: a dispatch input reaching a shell command directly is a + # script-injection seam, however trusted the dispatcher. + TEST_PATTERN: ${{ inputs.test_pattern || './e2e/...' }} + run: go test -tags e2e -timeout 40m -v "$TEST_PATTERN" 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/mobile-build-validation.yml b/.github/workflows/mobile-build-validation.yml deleted file mode 100644 index 322f129c9..000000000 --- a/.github/workflows/mobile-build-validation.yml +++ /dev/null @@ -1,72 +0,0 @@ -name: Mobile - -on: - push: - branches: - - main - - "release-*" - pull_request: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }} - cancel-in-progress: true - -jobs: - android_build: - name: "Android / Build" - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - - name: Install Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 - with: - go-version-file: "go.mod" - - name: Setup Android SDK - uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 - with: - cmdline-tools-version: 8512546 - - name: Setup Java - uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 - with: - java-version: "11" - distribution: "adopt" - - name: NDK Cache - id: ndk-cache - uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 - with: - path: /usr/local/lib/android/sdk/ndk - key: ndk-cache-23.1.7779620 - - name: Setup NDK - run: /usr/local/lib/android/sdk/cmdline-tools/7.0/bin/sdkmanager --install "ndk;23.1.7779620" - - name: install gomobile - run: go install golang.org/x/mobile/cmd/gomobile@v0.0.0-20251113184115-a159579294ab - - name: gomobile init - run: gomobile init - - name: build android netbird lib - run: PATH=$PATH:$(go env GOPATH) gomobile bind -o $GITHUB_WORKSPACE/netbird.aar -javapkg=io.netbird.gomobile -ldflags="-checklinkname=0 -X golang.zx2c4.com/wireguard/ipc.socketDirectory=/data/data/io.netbird.client/cache/wireguard -X github.com/netbirdio/netbird/version.version=buildtest" $GITHUB_WORKSPACE/client/android - env: - CGO_ENABLED: 0 - ANDROID_NDK_HOME: /usr/local/lib/android/sdk/ndk/23.1.7779620 - ios_build: - name: "iOS / Build" - runs-on: macos-latest - steps: - - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - - name: Install Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 - with: - go-version-file: "go.mod" - - name: install gomobile - run: go install golang.org/x/mobile/cmd/gomobile@v0.0.0-20251113184115-a159579294ab - - name: gomobile init - run: gomobile init - - name: build iOS netbird lib - run: PATH=$PATH:$(go env GOPATH) gomobile bind -target=ios -bundleid=io.netbird.framework -ldflags="-X github.com/netbirdio/netbird/version.version=buildtest" -o ./NetBirdSDK.xcframework ./client/ios/NetBirdSDK - env: - CGO_ENABLED: 0 diff --git a/.github/workflows/no-new-replace.yml b/.github/workflows/no-new-replace.yml new file mode 100644 index 000000000..b906ce450 --- /dev/null +++ b/.github/workflows/no-new-replace.yml @@ -0,0 +1,78 @@ +name: No New Replace Directives + +on: + pull_request: + paths: + - "go.mod" + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }} + cancel-in-progress: true + +jobs: + check-replace-directives: + name: check-replace-directives + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Install Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: go.mod + + - name: Compare replace directives against the base branch + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + + # A replace directive only applies when this module is the main + # module. Anything importing netbird as a library, the embedded + # clients among them, resolves the replaced path upstream instead and + # fails to build against whatever the replacement provides. Requiring + # a fork under its own module path avoids that; a replace does not. + # + # go.mod is parsed rather than diffed so that reordering, comments and + # single-line versus block syntax do not register as changes. + # + # Versions are part of the key because a replace can be scoped to one + # version of a module. Keyed on paths alone, retargeting such a + # directive at a different version would read as unchanged. + list_replaces() { + go mod edit -json "$1" \ + | jq -r ' + def ref: .Path + (if (.Version // "") == "" then "" else " " + .Version end); + (.Replace // [])[] | "\(.Old | ref) => \(.New | ref)" + ' \ + | sort + } + + git show "${BASE_SHA}:go.mod" > /tmp/base-go.mod + list_replaces /tmp/base-go.mod > /tmp/base-replaces + list_replaces go.mod > /tmp/head-replaces + + added=$(comm -13 /tmp/base-replaces /tmp/head-replaces) + if [ -n "$added" ]; then + echo "::error::This PR adds a replace directive to go.mod:" + echo "$added" | sed 's/^/ /' + echo "" + echo "A replace directive applies only to the main module, so it does not" + echo "reach anything that imports netbird as a library. Require the module" + echo "under a path you control instead, as done for github.com/netbirdio/go-nat." + exit 1 + fi + + removed=$(comm -23 /tmp/base-replaces /tmp/head-replaces) + if [ -n "$removed" ]; then + echo "This PR removes replace directives:" + echo "$removed" | sed 's/^/ /' + fi + echo "No new replace directives." 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/agent-network/README.md b/agent-network/README.md index 1997ea299..5211fe8f9 100644 --- a/agent-network/README.md +++ b/agent-network/README.md @@ -40,6 +40,35 @@ You can then use this private endpoint to configure your AI agents, whether that Full step-by-step setup: **https://docs.netbird.io/agent-network/quickstart** +## Client settings that don't follow the endpoint + +Most of an agent's traffic follows the base URL you hand it, but a few +client-side checks call their vendor directly and never reach the proxy. On a +network that blocks direct egress they fail even though inference works, so +they are worth setting once when you roll the endpoint out. + +For Claude Code: + +- **Fast mode** checks availability against `api.anthropic.com` rather than the + configured base URL. Set `CLAUDE_CODE_SKIP_FAST_MODE_ORG_CHECK=1` when the + agent authenticates with `ANTHROPIC_AUTH_TOKEN` alone (the usual shape when + the proxy injects the real provider key) or when a TLS-inspecting proxy + answers the check itself. Set + `CLAUDE_CODE_SKIP_FAST_MODE_NETWORK_ERRORS=1` when the network refuses the + connection outright. Fast mode is an Anthropic-API feature, so it is + unavailable on a Bedrock- or Vertex-backed endpoint whatever you set. +- **Model discovery** is off by default. Set + `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1` for the picker to list the + models your policies authorise; the proxy filters the response to that set. + The client gives discovery a three-second budget and treats any redirect as + a failure, so the endpoint must serve `/v1/models` directly. +- **The WebFetch domain safety check** also calls `api.anthropic.com` directly + and is unaffected by the variables above. + +Allowing direct egress to `api.anthropic.com` covers the network cases but not +the credential one, where the check reaches Anthropic and is rejected because +the agent presents a proxy-issued key. + ## Architecture Agent Network is built on two existing NetBird capabilities: 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/android/split_tunnel.go b/client/android/split_tunnel.go new file mode 100644 index 000000000..59ac539f9 --- /dev/null +++ b/client/android/split_tunnel.go @@ -0,0 +1,106 @@ +package android + +// Split tunnelling modes, stored as strings so an unknown value written by a +// newer build degrades to "off" rather than to some other mode's behaviour. +const ( + SplitTunnelModeOff = "off" + SplitTunnelModeExclude = "exclude" + SplitTunnelModeInclude = "include" +) + +type splitTunnelSection struct { + Mode string `json:"mode"` + Excluded []string `json:"excluded"` + Included []string `json:"included"` +} + +// PackageList wraps []string for gomobile compatibility. +type PackageList struct { + items []string +} + +// NewPackageList creates an empty list to fill via Add. +func NewPackageList() *PackageList { + return &PackageList{} +} + +// Add appends a package name, ignoring empty ones. +func (l *PackageList) Add(s string) { + if s == "" { + return + } + l.items = append(l.items, s) +} + +// Size returns the number of entries. +func (l *PackageList) Size() int { + return len(l.items) +} + +// Get returns the entry at index i, or an empty string when out of range. +func (l *PackageList) Get(i int) string { + if i < 0 || i >= len(l.items) { + return "" + } + return l.items[i] +} + +// SplitTunnelSettings is one profile's choice of which applications the tunnel +// carries. The two selections are kept apart because the platform applies one +// or the other and never both, and so that switching mode does not throw away +// the picks made in the other one. +type SplitTunnelSettings struct { + Mode string + Excluded *PackageList + Included *PackageList +} + +// NewSplitTunnelSettings creates settings that carry every application. +func NewSplitTunnelSettings() *SplitTunnelSettings { + return &SplitTunnelSettings{ + Mode: SplitTunnelModeOff, + Excluded: NewPackageList(), + Included: NewPackageList(), + } +} + +func packagesOf(list *PackageList) []string { + if list == nil { + return nil + } + out := make([]string, 0, len(list.items)) + out = append(out, list.items...) + return out +} + +func normalizeSplitTunnelMode(mode string) string { + switch mode { + case SplitTunnelModeExclude, SplitTunnelModeInclude: + return mode + default: + return SplitTunnelModeOff + } +} + +func settingsFromSection(section splitTunnelSection) *SplitTunnelSettings { + out := NewSplitTunnelSettings() + out.Mode = normalizeSplitTunnelMode(section.Mode) + for _, pkg := range section.Excluded { + out.Excluded.Add(pkg) + } + for _, pkg := range section.Included { + out.Included.Add(pkg) + } + return out +} + +func sectionFromSettings(settings *SplitTunnelSettings) splitTunnelSection { + if settings == nil { + settings = NewSplitTunnelSettings() + } + return splitTunnelSection{ + Mode: normalizeSplitTunnelMode(settings.Mode), + Excluded: packagesOf(settings.Excluded), + Included: packagesOf(settings.Included), + } +} diff --git a/client/android/split_tunnel_store.go b/client/android/split_tunnel_store.go new file mode 100644 index 000000000..f54e0c8ef --- /dev/null +++ b/client/android/split_tunnel_store.go @@ -0,0 +1,34 @@ +//go:build android + +package android + +const splitTunnelNamespace = "split-tunnel" + +// SplitTunnelStore reads and writes a profile's split tunnelling settings. +type SplitTunnelStore struct { + prefs prefsStore +} + +// NewSplitTunnelStore opens the split tunnelling store of the given profile. +func NewSplitTunnelStore(configDir, profileID string) (*SplitTunnelStore, error) { + prefs, err := newProfilePrefs(configDir, profileID) + if err != nil { + return nil, err + } + return &SplitTunnelStore{prefs: prefs}, nil +} + +// Load returns the stored settings, or settings that carry every application +// when the profile has none saved. +func (s *SplitTunnelStore) Load() (*SplitTunnelSettings, error) { + var section splitTunnelSection + if _, err := s.prefs.Get(splitTunnelNamespace, §ion); err != nil { + return nil, err + } + return settingsFromSection(section), nil +} + +// Save replaces the stored settings. +func (s *SplitTunnelStore) Save(settings *SplitTunnelSettings) error { + return s.prefs.Put(splitTunnelNamespace, sectionFromSettings(settings)) +} diff --git a/client/android/split_tunnel_test.go b/client/android/split_tunnel_test.go new file mode 100644 index 000000000..b8465e8ef --- /dev/null +++ b/client/android/split_tunnel_test.go @@ -0,0 +1,109 @@ +package android + +import ( + "reflect" + "testing" +) + +func TestNormalizeSplitTunnelMode(t *testing.T) { + tests := []struct { + name string + mode string + want string + }{ + {name: "exclude is kept", mode: SplitTunnelModeExclude, want: SplitTunnelModeExclude}, + {name: "include is kept", mode: SplitTunnelModeInclude, want: SplitTunnelModeInclude}, + {name: "off is kept", mode: SplitTunnelModeOff, want: SplitTunnelModeOff}, + {name: "empty falls back to off", mode: "", want: SplitTunnelModeOff}, + {name: "a mode from a newer build falls back to off", mode: "only-work-apps", want: SplitTunnelModeOff}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := normalizeSplitTunnelMode(tt.mode); got != tt.want { + t.Errorf("normalizeSplitTunnelMode(%q) = %q, want %q", tt.mode, got, tt.want) + } + }) + } +} + +func TestSettingsFromSection(t *testing.T) { + got := settingsFromSection(splitTunnelSection{ + Mode: SplitTunnelModeExclude, + Excluded: []string{"com.example.a", "com.example.b"}, + Included: []string{"com.example.c"}, + }) + + if got.Mode != SplitTunnelModeExclude { + t.Errorf("mode = %q, want %q", got.Mode, SplitTunnelModeExclude) + } + if got.Excluded.Size() != 2 || got.Excluded.Get(0) != "com.example.a" { + t.Errorf("excluded = %v, want the two stored packages", packagesOf(got.Excluded)) + } + if got.Included.Size() != 1 || got.Included.Get(0) != "com.example.c" { + t.Errorf("included = %v, want the stored package", packagesOf(got.Included)) + } +} + +// A profile that has never stored anything decodes into an empty section, and +// must come back as settings that carry every application rather than as nil +// lists the caller would have to guard against. +func TestSettingsFromEmptySectionCarriesEverything(t *testing.T) { + got := settingsFromSection(splitTunnelSection{}) + + if got.Mode != SplitTunnelModeOff { + t.Errorf("mode = %q, want %q", got.Mode, SplitTunnelModeOff) + } + if got.Excluded == nil || got.Included == nil { + t.Fatal("both selections must be usable lists, not nil") + } + if got.Excluded.Size() != 0 || got.Included.Size() != 0 { + t.Errorf("selections = %v/%v, want both empty", packagesOf(got.Excluded), packagesOf(got.Included)) + } +} + +func TestSectionFromSettingsRoundTrip(t *testing.T) { + settings := NewSplitTunnelSettings() + settings.Mode = SplitTunnelModeInclude + settings.Included.Add("com.example.a") + settings.Excluded.Add("com.example.b") + + section := sectionFromSettings(settings) + back := settingsFromSection(section) + + if back.Mode != SplitTunnelModeInclude { + t.Errorf("mode = %q, want %q", back.Mode, SplitTunnelModeInclude) + } + if !reflect.DeepEqual(packagesOf(back.Included), []string{"com.example.a"}) { + t.Errorf("included = %v, want [com.example.a]", packagesOf(back.Included)) + } + // The inactive selection survives, so switching mode back does not make the + // user pick their applications again. + if !reflect.DeepEqual(packagesOf(back.Excluded), []string{"com.example.b"}) { + t.Errorf("excluded = %v, want [com.example.b]", packagesOf(back.Excluded)) + } +} + +func TestSectionFromNilSettings(t *testing.T) { + section := sectionFromSettings(nil) + + if section.Mode != SplitTunnelModeOff { + t.Errorf("mode = %q, want %q", section.Mode, SplitTunnelModeOff) + } + if len(section.Excluded) != 0 || len(section.Included) != 0 { + t.Errorf("selections = %v/%v, want both empty", section.Excluded, section.Included) + } +} + +func TestPackageListIgnoresEmptyAndBounds(t *testing.T) { + list := NewPackageList() + list.Add("com.example.a") + list.Add("") + + if list.Size() != 1 { + t.Errorf("size = %d, want 1", list.Size()) + } + if list.Get(-1) != "" || list.Get(5) != "" { + t.Error("out of range access must return an empty string") + } +} diff --git a/client/cmd/root.go b/client/cmd/root.go index ccad78942..be6479440 100644 --- a/client/cmd/root.go +++ b/client/cmd/root.go @@ -23,6 +23,7 @@ import ( "github.com/netbirdio/netbird/client/anonymize" daddr "github.com/netbirdio/netbird/client/internal/daemonaddr" + "github.com/netbirdio/netbird/client/internal/localmetrics" "github.com/netbirdio/netbird/client/internal/profilemanager" ) @@ -31,6 +32,8 @@ const ( dnsResolverAddress = "dns-resolver-address" enableRosenpassFlag = "enable-rosenpass" rosenpassPermissiveFlag = "rosenpass-permissive" + enableLocalMetricsFlag = "enable-local-metrics" + localMetricsAddressFlag = "local-metrics-address" preSharedKeyFlag = "preshared-key" interfaceNameFlag = "interface-name" wireguardPortFlag = "wireguard-port" @@ -80,6 +83,8 @@ var ( updateSettingsDisabled bool captureEnabled bool networksDisabled bool + localMetricsEnabled bool + localMetricsAddr string rootCmd = &cobra.Command{ Use: "netbird", @@ -215,6 +220,8 @@ func init() { upCmd.PersistentFlags().BoolVar(&rosenpassEnabled, enableRosenpassFlag, false, "[Experimental] Enable Rosenpass feature. If enabled, the connection will be post-quantum secured via Rosenpass.") upCmd.PersistentFlags().BoolVar(&rosenpassPermissive, rosenpassPermissiveFlag, false, "[Experimental] Enable Rosenpass in permissive mode to allow this peer to accept WireGuard connections without requiring Rosenpass functionality from peers that do not have Rosenpass enabled.") upCmd.PersistentFlags().BoolVar(&autoConnectDisabled, disableAutoConnectFlag, false, "Disables auto-connect feature. If enabled, then the client won't connect automatically when the service starts.") + upCmd.PersistentFlags().BoolVar(&localMetricsEnabled, enableLocalMetricsFlag, false, "Enables a local Prometheus /metrics endpoint exposing connection state (peers, latency, P2P vs relay).") + upCmd.PersistentFlags().StringVar(&localMetricsAddr, localMetricsAddressFlag, localmetrics.DefaultListenAddress, "Listen address of the local Prometheus /metrics endpoint.") upCmd.PersistentFlags().BoolVar(&lazyConnEnabled, enableLazyConnectionFlag, false, "Deprecated: no longer used. Lazy connections are controlled by the server and the NB_LAZY_CONN environment variable.") _ = upCmd.PersistentFlags().MarkDeprecated(enableLazyConnectionFlag, "no longer used; lazy connections are controlled by the server and the NB_LAZY_CONN environment variable") diff --git a/client/cmd/testutil_test.go b/client/cmd/testutil_test.go index f40056f83..328a15454 100644 --- a/client/cmd/testutil_test.go +++ b/client/cmd/testutil_test.go @@ -124,7 +124,7 @@ func startManagement(t *testing.T, config *config.Config, testFile string) (*grp updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := mgmt.NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersmanager), config) + networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersmanager), config, nil) accountManager, err := mgmt.BuildManager(ctx, config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore) if err != nil { diff --git a/client/cmd/up.go b/client/cmd/up.go index 9f4fa8c33..5bc41a964 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -398,26 +398,10 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ return nil } -func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, profileName, username string) *proto.SetConfigRequest { - var req proto.SetConfigRequest - req.ProfileName = profileName - req.Username = username - - req.ManagementUrl = managementURL - req.AdminURL = adminURL - req.NatExternalIPs = natExternalIPs - req.CustomDNSAddress = customDNSAddressConverted - req.ExtraIFaceBlacklist = extraIFaceBlackList - req.DnsLabels = dnsLabelsValidated.ToPunycodeList() - req.CleanDNSLabels = dnsLabels != nil && len(dnsLabels) == 0 - req.CleanNATExternalIPs = natExternalIPs != nil && len(natExternalIPs) == 0 - - if cmd.Flag(enableRosenpassFlag).Changed { - req.RosenpassEnabled = &rosenpassEnabled - } - if cmd.Flag(rosenpassPermissiveFlag).Changed { - req.RosenpassPermissive = &rosenpassPermissive - } +// setSSHSetConfigFields copies the SSH server flags the user actually +// passed into req, leaving the rest unset so the daemon keeps the +// persisted values. +func setSSHSetConfigFields(req *proto.SetConfigRequest, cmd *cobra.Command) { if cmd.Flag(serverSSHAllowedFlag).Changed { req.ServerSSHAllowed = &serverSSHAllowed } @@ -440,6 +424,30 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro sshJWTCacheTTL32 := int32(sshJWTCacheTTL) req.SshJWTCacheTTL = &sshJWTCacheTTL32 } +} + +func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, profileName, username string) *proto.SetConfigRequest { + var req proto.SetConfigRequest + req.ProfileName = profileName + req.Username = username + + req.ManagementUrl = managementURL + req.AdminURL = adminURL + req.NatExternalIPs = natExternalIPs + req.CustomDNSAddress = customDNSAddressConverted + req.ExtraIFaceBlacklist = extraIFaceBlackList + req.DnsLabels = dnsLabelsValidated.ToPunycodeList() + req.CleanDNSLabels = dnsLabels != nil && len(dnsLabels) == 0 + req.CleanNATExternalIPs = natExternalIPs != nil && len(natExternalIPs) == 0 + + if cmd.Flag(enableRosenpassFlag).Changed { + req.RosenpassEnabled = &rosenpassEnabled + } + if cmd.Flag(rosenpassPermissiveFlag).Changed { + req.RosenpassPermissive = &rosenpassPermissive + } + setSSHSetConfigFields(&req, cmd) + if cmd.Flag(interfaceNameFlag).Changed { if err := parseInterfaceName(interfaceName); err != nil { log.Errorf("parse interface name: %v", err) @@ -499,6 +507,13 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro req.DisableIpv6 = &disableIPv6 } + if cmd.Flag(enableLocalMetricsFlag).Changed { + req.EnableLocalMetrics = &localMetricsEnabled + } + if cmd.Flag(localMetricsAddressFlag).Changed { + req.LocalMetricsAddress = &localMetricsAddr + } + return &req } @@ -616,9 +631,45 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil ic.DisableIPv6 = &disableIPv6 } + if cmd.Flag(enableLocalMetricsFlag).Changed { + ic.LocalMetricsEnabled = &localMetricsEnabled + } + + if cmd.Flag(localMetricsAddressFlag).Changed { + ic.LocalMetricsAddress = &localMetricsAddr + } + return &ic, nil } +// setSSHLoginFields copies the SSH server flags the user actually passed +// into req, leaving the rest unset so the daemon keeps the persisted +// values. +func setSSHLoginFields(req *proto.LoginRequest, cmd *cobra.Command) { + if cmd.Flag(serverSSHAllowedFlag).Changed { + req.ServerSSHAllowed = &serverSSHAllowed + } + if cmd.Flag(enableSSHRootFlag).Changed { + req.EnableSSHRoot = &enableSSHRoot + } + if cmd.Flag(enableSSHSFTPFlag).Changed { + req.EnableSSHSFTP = &enableSSHSFTP + } + if cmd.Flag(enableSSHLocalPortForwardFlag).Changed { + req.EnableSSHLocalPortForwarding = &enableSSHLocalPortForward + } + if cmd.Flag(enableSSHRemotePortForwardFlag).Changed { + req.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward + } + if cmd.Flag(disableSSHAuthFlag).Changed { + req.DisableSSHAuth = &disableSSHAuth + } + if cmd.Flag(sshJWTCacheTTLFlag).Changed { + sshJWTCacheTTL32 := int32(sshJWTCacheTTL) + req.SshJWTCacheTTL = &sshJWTCacheTTL32 + } +} + func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte, cmd *cobra.Command) (*proto.LoginRequest, error) { loginRequest := proto.LoginRequest{ SetupKey: providedSetupKey, @@ -645,39 +696,20 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte loginRequest.RosenpassPermissive = &rosenpassPermissive } - if cmd.Flag(serverSSHAllowedFlag).Changed { - loginRequest.ServerSSHAllowed = &serverSSHAllowed - } - - if cmd.Flag(enableSSHRootFlag).Changed { - loginRequest.EnableSSHRoot = &enableSSHRoot - } - - if cmd.Flag(enableSSHSFTPFlag).Changed { - loginRequest.EnableSSHSFTP = &enableSSHSFTP - } - - if cmd.Flag(enableSSHLocalPortForwardFlag).Changed { - loginRequest.EnableSSHLocalPortForwarding = &enableSSHLocalPortForward - } - - if cmd.Flag(enableSSHRemotePortForwardFlag).Changed { - loginRequest.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward - } - - if cmd.Flag(disableSSHAuthFlag).Changed { - loginRequest.DisableSSHAuth = &disableSSHAuth - } - - if cmd.Flag(sshJWTCacheTTLFlag).Changed { - sshJWTCacheTTL32 := int32(sshJWTCacheTTL) - loginRequest.SshJWTCacheTTL = &sshJWTCacheTTL32 - } + setSSHLoginFields(&loginRequest, cmd) if cmd.Flag(disableAutoConnectFlag).Changed { loginRequest.DisableAutoConnect = &autoConnectDisabled } + if cmd.Flag(enableLocalMetricsFlag).Changed { + loginRequest.EnableLocalMetrics = &localMetricsEnabled + } + + if cmd.Flag(localMetricsAddressFlag).Changed { + loginRequest.LocalMetricsAddress = &localMetricsAddr + } + if cmd.Flag(interfaceNameFlag).Changed { if err := parseInterfaceName(interfaceName); err != nil { return nil, err diff --git a/client/embed/embed.go b/client/embed/embed.go index 1b2d84d7e..5a3d11f24 100644 --- a/client/embed/embed.go +++ b/client/embed/embed.go @@ -85,12 +85,24 @@ 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 // when the embedded client must never act as a stepping stone into // the host's local network (e.g. the proxy's overlay peer). BlockLANAccess bool + // LazyConnectionEnabled is a tri-state local override for lazy connections, + // mirroring the NB_LAZY_CONN env var. Nil defers to the management feature + // flag; a set value overrides it in both directions. A short-lived client + // that reaches only a few known peers can set this to false, so its peers + // connect eagerly and the first request does not wait for the connection to + // be established. + LazyConnectionEnabled *bool // WireguardPort is the port for the tunnel interface. Use 0 for a random port. WireguardPort *int // MTU is the MTU for the tunnel interface. @@ -203,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, @@ -220,6 +234,15 @@ func New(opts Options) (*Client, error) { config.PrivateKey = opts.PrivateKey } + if opts.LazyConnectionEnabled != nil { + // Runtime-only override, read back through lazyconn.ParseState; a set value + // wins over the management feature flag in both directions. + config.LazyConnection = "off" + if *opts.LazyConnectionEnabled { + config.LazyConnection = "on" + } + } + if opts.Performance.PreallocatedBuffersPerPool != nil { wgdevice.SetPreallocatedBuffersPerPool(*opts.Performance.PreallocatedBuffersPerPool) } 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 1b37a9486..1e9afd634 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,16 +188,19 @@ func (e *ConnMgr) SetExcludeList(ctx context.Context, peerIDs map[string]bool) { } } -// AddPeerConn stores the peer connection and registers it with the lazy connection manager. +// 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. // active marks a peer whose connection was already established, e.g. one re-added after a // network map modification: it is registered as active and its connection stays open, so the // remote side does not have to signal a wake for a connection it still considers established. -func (e *ConnMgr) AddPeerConn(ctx context.Context, peerKey string, conn *peer.Conn, active bool) (exists bool) { +func (e *ConnMgr) AddPeerConn(ctx context.Context, peerKey string, conn *peer.Conn, permanent, active bool) (exists bool) { if success := e.peerStore.AddPeerConn(peerKey, conn); !success { return true } - if !e.isStartedWithLazyMgr() { + if !e.isStartedWithLazyMgr() || permanent { e.openConn(ctx, conn) return } @@ -312,6 +311,8 @@ func (e *ConnMgr) Close() { e.lazyConnMgrMu.Lock() e.lazyConnMgr = nil e.lazyConnMgrMu.Unlock() + + e.appliedExcludeList = nil } func (e *ConnMgr) initLazyManager(engineCtx context.Context) { @@ -325,6 +326,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() @@ -332,46 +335,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 e45ecca44..ca50f912f 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 @@ -723,7 +711,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 @@ -732,7 +720,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 1d31c75ca..7bb71c53b 100644 --- a/client/internal/debug/debug.go +++ b/client/internal/debug/debug.go @@ -737,6 +737,8 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder) configContent.WriteString(fmt.Sprintf("BlockLANAccess: %v\n", g.internalConfig.BlockLANAccess)) configContent.WriteString(fmt.Sprintf("BlockInbound: %v\n", g.internalConfig.BlockInbound)) configContent.WriteString(fmt.Sprintf("DisableIPv6: %v\n", g.internalConfig.DisableIPv6)) + configContent.WriteString(fmt.Sprintf("LocalMetricsEnabled: %v\n", g.internalConfig.LocalMetricsEnabled)) + configContent.WriteString(fmt.Sprintf("LocalMetricsAddress: %s\n", g.internalConfig.LocalMetricsAddress)) configContent.WriteString(fmt.Sprintf("SyncMessageVersion: %v\n", g.internalConfig.SyncMessageVersion)) if g.internalConfig.DisableNotifications != nil { diff --git a/client/internal/dns/host_windows.go b/client/internal/dns/host_windows.go index 53380b2aa..948000a3d 100644 --- a/client/internal/dns/host_windows.go +++ b/client/internal/dns/host_windows.go @@ -6,8 +6,10 @@ import ( "fmt" "io" "net/netip" + "os" "os/exec" "slices" + "strconv" "strings" "syscall" "time" @@ -34,10 +36,16 @@ var ( // Registry locations of the host DNS configuration this package programs, // exported so a diagnostic reader reports the same locations that are written. const ( - // NRPTKeyPrefix starts the name of every NRPT rule key this client creates. - // Older versions used different layouts under the same prefix: a single - // unsuffixed key, then one key per domain, now one key per batch of domains. - NRPTKeyPrefix = "NetBird-Match" + // NRPTKeyPrefix starts the name of every NRPT rule key this client creates: + // the match rules, the catch-all, and the .local exemption. Cleanup + // enumerates by this prefix, so a new kind of rule is removed by existing + // code as long as its key starts here. + NRPTKeyPrefix = "NetBird-" + + // nrptMatchKeyName names the match-domain rules. Older versions used + // different layouts under the same name: a single unsuffixed key, then one + // key per domain, now one key per batch of domains. + nrptMatchKeyName = NRPTKeyPrefix + "Match" // DNSPolicyConfigRoot holds the NRPT rules of the local policy store. DNSPolicyConfigRoot = `SYSTEM\CurrentControlSet\Services\Dnscache\Parameters\DnsPolicyConfig` @@ -53,8 +61,24 @@ const ( ) const ( - dnsPolicyConfigMatchPath = DNSPolicyConfigRoot + `\` + NRPTKeyPrefix - gpoDnsPolicyConfigMatchPath = GPODNSPolicyConfigRoot + `\` + NRPTKeyPrefix + dnsPolicyConfigMatchPath = DNSPolicyConfigRoot + `\` + nrptMatchKeyName + gpoDnsPolicyConfigMatchPath = GPODNSPolicyConfigRoot + `\` + nrptMatchKeyName + + dnsPolicyConfigExemptLocalPath = DNSPolicyConfigRoot + `\` + NRPTKeyPrefix + `ExemptLocal` + gpoDnsPolicyConfigExemptLocalPath = GPODNSPolicyConfigRoot + `\` + NRPTKeyPrefix + `ExemptLocal` + + nrptCatchAllNamespace = "." + // nrptLocalNamespace is reserved for multicast DNS by RFC 6762: a unicast + // resolver must not answer for it. The catch-all rule would hand it to us + // anyway, so it gets an exemption rule of its own. + nrptLocalNamespace = ".local" + + // envLegacyDNSResolution restores the pre-catch-all behaviour: the adapter's + // NameServer alone, leaving the OS free to query other adapters' resolvers in + // parallel. An escape hatch for setups that depend on a resolver of theirs + // still being reachable while connected, at the cost of the leak and of the + // race the catch-all rule exists to close. + envLegacyDNSResolution = "NB_USE_LEGACY_DNS_RESOLUTION" dnsPolicyConfigVersionKey = "Version" dnsPolicyConfigVersionValue = 2 @@ -293,6 +317,13 @@ func (r *registryConfigurator) disableWINSForInterface() error { } func (r *registryConfigurator) applyDNSConfig(config HostDNSConfig, stateManager *statemanager.Manager) error { + // Clear every rule the previous apply installed before installing any new + // one, including a leftover catch-all: removal is unconditional so a rule + // from an earlier run cannot survive into a config that no longer wants it. + if err := r.removeDNSMatchPolicies(); err != nil { + log.Errorf("cleanup old dns match policies: %s", err) + } + if config.RouteAll { if err := r.addDNSSetupForAll(config.ServerIP); err != nil { return fmt.Errorf("add dns setup: %w", err) @@ -318,8 +349,22 @@ func (r *registryConfigurator) applyDNSConfig(config HostDNSConfig, stateManager matchDomains = append(matchDomains, "."+strings.TrimSuffix(dConf.Domain, ".")) } - if err := r.removeDNSMatchPolicies(); err != nil { - log.Errorf("cleanup old dns match policies: %s", err) + // The root namespace is a match domain like any other: it just happens to + // match every name. Without it the adapter's NameServer only adds one more + // resolver to the set Windows queries in parallel, keeping whichever answer + // comes back first — which leaks every query to the local network and lets a + // resolver other than ours answer for a name we are authoritative for. + if config.RouteAll { + if parseBoolEnv(envLegacyDNSResolution) { + log.Infof("%s is set, leaving DNS resolution shared with the other adapters' resolvers instead of forcing it through %s", envLegacyDNSResolution, config.ServerIP) + } else { + matchDomains = append(matchDomains, nrptCatchAllNamespace) + log.Infof("routing every namespace through %s: DNS resolution is now exclusive to NetBird", config.ServerIP) + + if err := r.addDNSExemptLocalPolicy(); err != nil { + return fmt.Errorf("add dns exempt policy: %w", err) + } + } } if len(matchDomains) != 0 { @@ -397,6 +442,42 @@ func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr return nil } +// addDNSExemptLocalPolicy carves .local back out of the catch-all. RFC 6762 +// reserves it for multicast DNS, so forwarding those names to a unicast +// upstream answers NXDOMAIN for hosts that do exist - printers, NAS boxes, and +// anything else announcing itself on the link - and the answer is authoritative +// enough that Windows stops looking. A rule naming the namespace with no +// servers hands it back to the DNS client untouched. A more specific rule still +// wins, so a match domain under .local keeps going through us. +func (r *registryConfigurator) addDNSExemptLocalPolicy() error { + var noServers netip.Addr + + if err := r.configureDNSPolicy(dnsPolicyConfigExemptLocalPath, []string{nrptLocalNamespace}, noServers); err != nil { + return fmt.Errorf("configure exempt policy for %s: %w", nrptLocalNamespace, err) + } + + if r.gpo { + if err := r.configureDNSPolicy(gpoDnsPolicyConfigExemptLocalPath, []string{nrptLocalNamespace}, noServers); err != nil { + return fmt.Errorf("configure gpo exempt policy for %s: %w", nrptLocalNamespace, err) + } + if err := refreshGroupPolicy(); err != nil { + log.Warnf("failed to refresh group policy: %v", err) + } + } + + log.Infof("added NRPT exemption for %s, leaving it to the OS resolver", nrptLocalNamespace) + return nil +} + +// configureDNSPolicy writes one NRPT rule. An invalid ip writes an exemption +// rule: the namespace with an empty server list, which tells the DNS client to +// resolve those names the way it would without any rule at all. +// +// The empty string is the whole difference, and it has to be written: dropping +// the value and clearing ConfigOptions instead produces a rule Windows treats +// as a no-op, keeps out of Get-DnsClientNrptPolicy -Effective, and ignores in +// favour of the catch-all. 0x8 says the server list is the meaningful part of +// the rule, and an empty list then means "no server, resolve normally". func (r *registryConfigurator) configureDNSPolicy(policyPath string, domains []string, ip netip.Addr) error { if err := removeRegistryKeyFromDNSPolicyConfig(policyPath); err != nil { return fmt.Errorf("remove existing dns policy: %w", err) @@ -416,7 +497,11 @@ func (r *registryConfigurator) configureDNSPolicy(policyPath string, domains []s return fmt.Errorf("set %s: %w", dnsPolicyConfigNameKey, err) } - if err := regKey.SetStringValue(dnsPolicyConfigGenericDNSServersKey, ip.String()); err != nil { + var servers string + if ip.IsValid() { + servers = ip.String() + } + if err := regKey.SetStringValue(dnsPolicyConfigGenericDNSServersKey, servers); err != nil { return fmt.Errorf("set %s: %w", dnsPolicyConfigGenericDNSServersKey, err) } @@ -514,8 +599,11 @@ func (r *registryConfigurator) getInterfaceRegistryKey() (registry.Key, error) { } func (r *registryConfigurator) restoreHostDNS() error { + // Propagated, unlike in applyDNSConfig: there we are about to write fresh + // rules over whatever survived, here we are leaving, and a rule left behind + // keeps sending every query to an address that is about to disappear. if err := r.removeDNSMatchPolicies(); err != nil { - log.Errorf("remove dns match policies: %s", err) + return fmt.Errorf("remove dns match policies: %w", err) } if err := r.deleteInterfaceRegistryKeyProperty(interfaceConfigSearchListKey); err != nil { @@ -598,9 +686,17 @@ func listNRPTRuleKeys(root string) ([]string, error) { func removeRegistryKeyFromDNSPolicyConfig(regKeyPath string) error { k, err := registry.OpenKey(registry.LOCAL_MACHINE, regKeyPath, registry.QUERY_VALUE) - if err != nil { - log.Debugf("failed to open HKEY_LOCAL_MACHINE\\%s: %v", regKeyPath, err) + switch { + case errors.Is(err, registry.ErrNotExist), errors.Is(err, syscall.ERROR_PATH_NOT_FOUND): + // nothing to remove, which is the normal case for a rule this config + // never installed + log.Debugf("HKEY_LOCAL_MACHINE\\%s does not exist", regKeyPath) return nil + case err != nil: + // anything else has to reach the caller: reporting success here would + // leave the rule in force while claiming it was removed, which is how a + // stale rule outlives the interface it points at + return fmt.Errorf("open HKEY_LOCAL_MACHINE\\%s: %w", regKeyPath, err) } closer(k) @@ -636,6 +732,20 @@ func refreshGroupPolicy() error { return nil } +func parseBoolEnv(key string) bool { + val := os.Getenv(key) + if val == "" { + return false + } + + parsed, err := strconv.ParseBool(val) + if err != nil { + log.Warnf("failed to parse %s=%q: %v", key, val, err) + return false + } + return parsed +} + func closer(closer io.Closer) { if err := closer.Close(); err != nil { log.Errorf("failed to close: %s", err) diff --git a/client/internal/dns/host_windows_test.go b/client/internal/dns/host_windows_test.go index 861613c95..7aef64590 100644 --- a/client/internal/dns/host_windows_test.go +++ b/client/internal/dns/host_windows_test.go @@ -94,6 +94,145 @@ func TestNRPTEntriesCleanupOnConfigChange(t *testing.T) { assert.False(t, exists, "NRPT rule 2 should NOT exist after reducing to 75 domains") } +// TestNRPTCatchAllRule verifies that RouteAll adds the root namespace to the +// match rule instead of a rule of its own, that .local is carved back out with +// an empty server list, and that both go away when RouteAll is cleared or the +// host DNS is restored. +func TestNRPTCatchAllRule(t *testing.T) { + if testing.Short() { + t.Skip("skipping registry integration test in short mode") + } + + defer cleanupRegistryKeys(t) + cleanupRegistryKeys(t) + + testIP := netip.MustParseAddr("100.64.0.1") + testGUID := "{12345678-1234-1234-1234-123456789ABC}" + interfacePath := InterfaceConfigPath + `\` + testGUID + testKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, interfacePath, registry.SET_VALUE) + require.NoError(t, err, "Should create test interface registry key") + require.NoError(t, testKey.Close(), "close test interface registry key") + defer func() { + assert.NoError(t, registry.DeleteKey(registry.LOCAL_MACHINE, interfacePath), "delete test interface registry key") + }() + + cfg := ®istryConfigurator{guid: testGUID} + + matchOnly := HostDNSConfig{ + ServerIP: testIP, + Domains: []DomainConfig{{Domain: "example.com", MatchOnly: true}}, + } + primary := HostDNSConfig{ + ServerIP: testIP, + RouteAll: true, + Domains: []DomainConfig{{Domain: "example.com", MatchOnly: true}}, + } + firstRule := fmt.Sprintf("%s-0", dnsPolicyConfigMatchPath) + + // The root namespace is not a rule of its own: it rides in the match rule, + // which is the point of it not being a special case. + require.NoError(t, cfg.applyDNSConfig(matchOnly, nil)) + names := ruleNamespaces(t, firstRule) + assert.Contains(t, names, ".example.com") + assert.NotContains(t, names, nrptCatchAllNamespace, "a match-only config must not claim every namespace") + + require.NoError(t, cfg.applyDNSConfig(primary, nil)) + names = ruleNamespaces(t, firstRule) + assert.Contains(t, names, ".example.com") + assert.Contains(t, names, nrptCatchAllNamespace, "RouteAll should add the root namespace to the match rule") + + k, err := registry.OpenKey(registry.LOCAL_MACHINE, firstRule, registry.QUERY_VALUE) + require.NoError(t, err) + servers, _, err := k.GetStringValue(dnsPolicyConfigGenericDNSServersKey) + require.NoError(t, err) + assert.Equal(t, testIP.String(), servers, "every namespace in the rule resolves through our resolver") + require.NoError(t, k.Close(), "close match rule key") + + // .local is carved back out: RFC 6762 reserves it for mDNS, so it needs a + // rule of its own — it is the one rule with a different server list. + ek, err := registry.OpenKey(registry.LOCAL_MACHINE, dnsPolicyConfigExemptLocalPath, registry.QUERY_VALUE) + require.NoError(t, err, "exemption rule should exist once the root namespace is claimed") + + exemptNames, _, err := ek.GetStringsValue(dnsPolicyConfigNameKey) + require.NoError(t, err) + assert.Equal(t, []string{nrptLocalNamespace}, exemptNames, "the exemption should name only the mDNS namespace") + + exemptServers, _, err := ek.GetStringValue(dnsPolicyConfigGenericDNSServersKey) + require.NoError(t, err, "the value has to be present, empty: without it Windows drops the rule") + assert.Empty(t, exemptServers, "an exemption rule lists no servers") + + exemptOpts, _, err := ek.GetIntegerValue(dnsPolicyConfigConfigOptionsKey) + require.NoError(t, err) + assert.EqualValues(t, dnsPolicyConfigConfigOptionsValue, exemptOpts, "same options as a normal rule; the empty server list is what makes it an exemption") + require.NoError(t, ek.Close(), "close exemption rule key") + + require.NoError(t, cfg.applyDNSConfig(matchOnly, nil)) + names = ruleNamespaces(t, firstRule) + assert.NotContains(t, names, nrptCatchAllNamespace, "clearing RouteAll should drop the root namespace") + + exists, err := registryKeyExists(dnsPolicyConfigExemptLocalPath) + require.NoError(t, err) + assert.False(t, exists, "exemption rule should go with the namespace it carves out of") + + require.NoError(t, cfg.applyDNSConfig(primary, nil)) + require.NoError(t, cfg.restoreHostDNS()) + exists, err = registryKeyExists(firstRule) + require.NoError(t, err) + assert.False(t, exists, "restore should leave no rule behind") +} + +// ruleNamespaces returns the namespaces an NRPT rule key claims. +func ruleNamespaces(t *testing.T, path string) []string { + t.Helper() + k, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.QUERY_VALUE) + require.NoError(t, err, "rule key %s should exist", path) + defer k.Close() + + names, _, err := k.GetStringsValue(dnsPolicyConfigNameKey) + require.NoError(t, err) + return names +} + +// TestNRPTCatchAllRuleLegacyEnv verifies that NB_USE_LEGACY_DNS_RESOLUTION +// leaves the root namespace unclaimed, so no rule is written for a RouteAll +// config that carries no match domains. +func TestNRPTCatchAllRuleLegacyEnv(t *testing.T) { + if testing.Short() { + t.Skip("skipping registry integration test in short mode") + } + + defer cleanupRegistryKeys(t) + cleanupRegistryKeys(t) + + t.Setenv(envLegacyDNSResolution, "true") + + testGUID := "{12345678-1234-1234-1234-123456789ABC}" + interfacePath := InterfaceConfigPath + `\` + testGUID + testKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, interfacePath, registry.SET_VALUE) + require.NoError(t, err, "Should create test interface registry key") + require.NoError(t, testKey.Close(), "close test interface registry key") + defer func() { + assert.NoError(t, registry.DeleteKey(registry.LOCAL_MACHINE, interfacePath), "delete test interface registry key") + }() + + cfg := ®istryConfigurator{guid: testGUID} + config := HostDNSConfig{ + ServerIP: netip.MustParseAddr("100.64.0.1"), + RouteAll: true, + } + + require.NoError(t, cfg.applyDNSConfig(config, nil)) + + // RouteAll with no match domains and the switch set leaves nothing to write. + exists, err := registryKeyExists(fmt.Sprintf("%s-0", dnsPolicyConfigMatchPath)) + require.NoError(t, err) + assert.False(t, exists, "no rule should be written when the legacy env var is set") + + exists, err = registryKeyExists(dnsPolicyConfigExemptLocalPath) + require.NoError(t, err) + assert.False(t, exists, "no exemption without a claimed root namespace") +} + func registryKeyExists(path string) (bool, error) { k, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.QUERY_VALUE) if err != nil { diff --git a/client/internal/dns/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/ebpf/ebpf/manager_linux.go b/client/internal/ebpf/ebpf/manager_linux.go index 64a3e5b54..7520a6387 100644 --- a/client/internal/ebpf/ebpf/manager_linux.go +++ b/client/internal/ebpf/ebpf/manager_linux.go @@ -2,21 +2,17 @@ package ebpf import ( _ "embed" - "fmt" "net" "sync" "github.com/cilium/ebpf/link" "github.com/cilium/ebpf/rlimit" log "github.com/sirupsen/logrus" - "golang.org/x/sys/unix" "github.com/netbirdio/netbird/client/internal/ebpf/manager" ) const ( - xdpProgName = "nb_xdp_prog" - mapKeyFeatures uint32 = 0 featureFlagWGProxy = 0b00000001 @@ -72,50 +68,21 @@ func (tf *GeneralManager) loadXdp() error { return err } - // lo has no native XDP, so the program runs in generic mode. Unless it - // declares multi-buffer support the kernel must linearize every non-linear - // skb before running it. Loopback packets are up to 64 KB, so that is a - // contiguous GFP_ATOMIC allocation per packet, and when it fails the packet - // is dropped before the program runs, stalling local TCP connections. - // Multi-buffer XDP in generic mode requires kernel 6.3, so fall back to a - // plain attach when the kernel rejects it. - err = tf.attachXdp(iFace.Index, true) - if err == nil { - return nil - } - log.Debugf("failed to attach multi-buffer xdp program, retrying without it: %s", err) - - return tf.attachXdp(iFace.Index, false) -} - -func (tf *GeneralManager) attachXdp(iFaceIndex int, multiBuffer bool) error { - spec, err := loadBpf() + // load pre-compiled programs into the kernel. + err = loadBpfObjects(&tf.bpfObjs, nil) if err != nil { - return fmt.Errorf("load bpf spec: %w", err) - } - - if multiBuffer { - prog, ok := spec.Programs[xdpProgName] - if !ok { - return fmt.Errorf("program %s not found in bpf spec", xdpProgName) - } - prog.Flags |= unix.BPF_F_XDP_HAS_FRAGS - } - - if err := spec.LoadAndAssign(&tf.bpfObjs, nil); err != nil { - return fmt.Errorf("load bpf objects: %w", err) + return err } tf.link, err = link.AttachXDP(link.XDPOptions{ Program: tf.bpfObjs.NbXdpProg, - Interface: iFaceIndex, + Interface: iFace.Index, }) + if err != nil { - if closeErr := tf.bpfObjs.Close(); closeErr != nil { - log.Debugf("failed to close bpf objects after xdp attach error: %s", closeErr) - } + _ = tf.bpfObjs.Close() tf.link = nil - return fmt.Errorf("attach xdp: %w", err) + return err } return nil } 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 890d5fa7d..265e6413b 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -59,7 +59,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" @@ -182,9 +182,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. @@ -208,9 +208,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 @@ -345,7 +345,7 @@ func NewEngine( syncMsgMux: &sync.Mutex{}, config: config, mobileDep: mobileDep, - netState: services.NetState, + netMgr: services.NetMgr, STUNs: []*stun.URI{}, TURNs: []*stun.URI{}, networkSerial: 0, @@ -1508,8 +1508,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 { @@ -1575,8 +1579,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() @@ -1594,8 +1597,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 @@ -1839,16 +1841,16 @@ 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, false) - if err != nil { + if err := e.addNewPeer(p, false); err != nil { return err } } return nil } -// addNewPeer add peer if connection doesn't exist. active registers the peer with an -// already established connection instead of an idle lazy one. +// addNewPeer add peer if connection doesn't exist. A peer that is not lazy by +// policy gets an always-active connection instead. active registers the peer with +// an already established connection instead of an idle lazy one. func (e *Engine) addNewPeer(peerConfig *mgmProto.RemotePeerConfig, active bool) error { peerKey := peerConfig.GetWgPubKey() peerIPs := make([]netip.Prefix, 0, len(peerConfig.GetAllowedIps())) @@ -1883,7 +1885,8 @@ func (e *Engine) addNewPeer(peerConfig *mgmProto.RemotePeerConfig, active bool) log.Warnf("error adding peer %s to status recorder, got error: %v", peerKey, err) } - if exists := e.connMgr.AddPeerConn(e.ctx, peerKey, conn, active); exists { + permanent := !e.connMgr.PeerLazyDefault(peerConfig.GetLazyState()) + if exists := e.connMgr.AddPeerConn(e.ctx, peerKey, conn, permanent, active); exists { conn.Close(false) return fmt.Errorf("peer already exists: %s", peerKey) } @@ -1916,8 +1919,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{ @@ -2675,46 +2678,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 2caa1f453..596388230 100644 --- a/client/internal/engine_test.go +++ b/client/internal/engine_test.go @@ -280,7 +280,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 4ba14bf44..717544f6a 100644 --- a/client/internal/metrics/influxdb.go +++ b/client/internal/metrics/influxdb.go @@ -45,30 +45,13 @@ func (m *influxDBMetrics) RecordConnectionStages( isReconnection bool, timestamps ConnectionStageTimestamps, ) { - var signalingReceivedToConnection, connectionToWgHandshake, totalDuration float64 - - if !timestamps.SignalingReceived.IsZero() && !timestamps.ConnectionReady.IsZero() { - signalingReceivedToConnection = timestamps.ConnectionReady.Sub(timestamps.SignalingReceived).Seconds() - } - - if !timestamps.ConnectionReady.IsZero() && !timestamps.WgHandshakeSuccess.IsZero() { - connectionToWgHandshake = timestamps.WgHandshakeSuccess.Sub(timestamps.ConnectionReady).Seconds() - } - - if !timestamps.SignalingReceived.IsZero() && !timestamps.WgHandshakeSuccess.IsZero() { - totalDuration = timestamps.WgHandshakeSuccess.Sub(timestamps.SignalingReceived).Seconds() - } - - attemptType := "initial" - if isReconnection { - attemptType = "reconnection" - } + signalingReceivedToConnection, connectionToWgHandshake, totalDuration := timestamps.Durations() connTypeStr := connectionType.String() tags := fmt.Sprintf("deployment_type=%s,connection_type=%s,attempt_type=%s,version=%s,os=%s,arch=%s,peer_id=%s,connection_pair_id=%s", agentInfo.DeploymentType.String(), connTypeStr, - attemptType, + attemptType(isReconnection), agentInfo.Version, agentInfo.OS, agentInfo.Arch, @@ -94,7 +77,7 @@ func (m *influxDBMetrics) RecordConnectionStages( m.trimLocked() log.Tracef("peer connection metrics [%s, %s, %s]: signalingReceived→connection: %.3fs, connection→wg_handshake: %.3fs, total: %.3fs", - agentInfo.DeploymentType.String(), connTypeStr, attemptType, signalingReceivedToConnection, connectionToWgHandshake, totalDuration) + agentInfo.DeploymentType.String(), connTypeStr, attemptType(isReconnection), signalingReceivedToConnection, connectionToWgHandshake, totalDuration) } func (m *influxDBMetrics) RecordSyncDuration(_ context.Context, agentInfo AgentInfo, duration time.Duration) { diff --git a/client/internal/metrics/metrics.go b/client/internal/metrics/metrics.go index cfe477107..5edf1d9c7 100644 --- a/client/internal/metrics/metrics.go +++ b/client/internal/metrics/metrics.go @@ -89,6 +89,21 @@ type ConnectionStageTimestamps struct { WgHandshakeSuccess time.Time } +// Durations returns the stage durations in seconds. A duration is zero when +// either of its timestamps is missing. +func (c ConnectionStageTimestamps) Durations() (signalingToConnection, connectionToWgHandshake, total float64) { + if !c.SignalingReceived.IsZero() && !c.ConnectionReady.IsZero() { + signalingToConnection = c.ConnectionReady.Sub(c.SignalingReceived).Seconds() + } + if !c.ConnectionReady.IsZero() && !c.WgHandshakeSuccess.IsZero() { + connectionToWgHandshake = c.WgHandshakeSuccess.Sub(c.ConnectionReady).Seconds() + } + if !c.SignalingReceived.IsZero() && !c.WgHandshakeSuccess.IsZero() { + total = c.WgHandshakeSuccess.Sub(c.SignalingReceived).Seconds() + } + return signalingToConnection, connectionToWgHandshake, total +} + // String returns a human-readable representation of the connection stage timestamps func (c ConnectionStageTimestamps) String() string { return fmt.Sprintf("ConnectionStageTimestamps{SignalingReceived=%v, ConnectionReady=%v, WgHandshakeSuccess=%v}", @@ -279,3 +294,11 @@ func (c *ClientMetrics) stopPushLocked() { c.wg.Wait() c.push.Store(nil) } + +// attemptType returns the metric label for an initial vs reconnection attempt. +func attemptType(isReconnection bool) string { + if isReconnection { + return "reconnection" + } + return "initial" +} diff --git a/client/internal/metrics/metrics_default.go b/client/internal/metrics/metrics_default.go index 927ab51d1..3798adab6 100644 --- a/client/internal/metrics/metrics_default.go +++ b/client/internal/metrics/metrics_default.go @@ -2,10 +2,24 @@ package metrics +import "github.com/prometheus/client_golang/prometheus" + // NewClientMetrics creates a new ClientMetrics instance func NewClientMetrics(agentInfo AgentInfo) *ClientMetrics { return &ClientMetrics{ - impl: newInfluxDBMetrics(), + impl: newPrometheusMetrics(newInfluxDBMetrics()), agentInfo: agentInfo, } } + +// PrometheusGatherer returns the registry with the mirrored Prometheus +// metrics, or nil when unavailable. +func (c *ClientMetrics) PrometheusGatherer() prometheus.Gatherer { + if c == nil { + return nil + } + if pm, ok := c.impl.(*prometheusMetrics); ok { + return pm.Gatherer() + } + return nil +} diff --git a/client/internal/metrics/prometheus.go b/client/internal/metrics/prometheus.go new file mode 100644 index 000000000..7f5020ea9 --- /dev/null +++ b/client/internal/metrics/prometheus.go @@ -0,0 +1,119 @@ +//go:build !js + +package metrics + +import ( + "context" + "io" + "strconv" + "time" + + "github.com/prometheus/client_golang/prometheus" +) + +// prometheusMetrics mirrors recorded client metrics into a Prometheus +// registry for the local /metrics endpoint, then delegates to the wrapped +// implementation. Export and Reset pass through untouched: Prometheus +// metrics are cumulative and pull-based. +type prometheusMetrics struct { + next metricsImplementation + registry *prometheus.Registry + + connectionStages *prometheus.HistogramVec + syncDuration prometheus.Histogram + syncPhaseDuration *prometheus.HistogramVec + loginDuration *prometheus.HistogramVec +} + +func newPrometheusMetrics(next metricsImplementation) *prometheusMetrics { + connectionBuckets := []float64{.05, .1, .25, .5, 1, 2.5, 5, 10, 30, 60} + + m := &prometheusMetrics{ + next: next, + registry: prometheus.NewRegistry(), + connectionStages: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: "netbird_peer_connection_stage_duration_seconds", + Help: "Duration of peer connection establishment stages.", + Buckets: connectionBuckets, + }, []string{"stage", "connection_type", "attempt_type"}), + syncDuration: prometheus.NewHistogram(prometheus.HistogramOpts{ + Name: "netbird_sync_duration_seconds", + Help: "Duration of management sync message processing.", + Buckets: prometheus.DefBuckets, + }), + syncPhaseDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: "netbird_sync_phase_duration_seconds", + Help: "Duration of individual sync processing phases.", + Buckets: prometheus.DefBuckets, + }, []string{"phase"}), + loginDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: "netbird_login_duration_seconds", + Help: "Duration of logins to the management service.", + Buckets: prometheus.DefBuckets, + }, []string{"success"}), + } + + m.registry.MustRegister(m.connectionStages, m.syncDuration, m.syncPhaseDuration, m.loginDuration) + return m +} + +// Gatherer returns the registry holding the mirrored metrics. +func (m *prometheusMetrics) Gatherer() prometheus.Gatherer { + return m.registry +} + +// RecordConnectionStages implements metricsImplementation. +func (m *prometheusMetrics) RecordConnectionStages( + ctx context.Context, + agentInfo AgentInfo, + connectionPairID string, + connectionType ConnectionType, + isReconnection bool, + timestamps ConnectionStageTimestamps, +) { + attempt := attemptType(isReconnection) + connType := connectionType.String() + + signalingToConnection, connectionToWgHandshake, total := timestamps.Durations() + if signalingToConnection > 0 { + m.connectionStages.WithLabelValues("signaling_to_connection", connType, attempt).Observe(signalingToConnection) + } + if connectionToWgHandshake > 0 { + m.connectionStages.WithLabelValues("connection_to_wg_handshake", connType, attempt).Observe(connectionToWgHandshake) + } + if total > 0 { + m.connectionStages.WithLabelValues("total", connType, attempt).Observe(total) + } + + m.next.RecordConnectionStages(ctx, agentInfo, connectionPairID, connectionType, isReconnection, timestamps) +} + +// RecordSyncDuration implements metricsImplementation. +func (m *prometheusMetrics) RecordSyncDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration) { + m.syncDuration.Observe(duration.Seconds()) + m.next.RecordSyncDuration(ctx, agentInfo, duration) +} + +// RecordSyncPhase implements metricsImplementation. +func (m *prometheusMetrics) RecordSyncPhase(ctx context.Context, agentInfo AgentInfo, phase string, duration time.Duration) { + m.syncPhaseDuration.WithLabelValues(phase).Observe(duration.Seconds()) + m.next.RecordSyncPhase(ctx, agentInfo, phase, duration) +} + +// RecordLoginDuration implements metricsImplementation. +func (m *prometheusMetrics) RecordLoginDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration, success bool) { + m.loginDuration.WithLabelValues(strconv.FormatBool(success)).Observe(duration.Seconds()) + m.next.RecordLoginDuration(ctx, agentInfo, duration, success) +} + +// Export implements metricsImplementation by delegating to the wrapped +// implementation; Prometheus metrics are pulled via the registry instead. +func (m *prometheusMetrics) Export(w io.Writer) error { + return m.next.Export(w) +} + +// Reset implements metricsImplementation by delegating to the wrapped +// implementation; Prometheus metrics must not be cleared on push. +func (m *prometheusMetrics) Reset() { + m.next.Reset() +} diff --git a/client/internal/peer/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/handshaker.go b/client/internal/peer/handshaker.go index 56e82e6e3..6ecb2a947 100644 --- a/client/internal/peer/handshaker.go +++ b/client/internal/peer/handshaker.go @@ -81,14 +81,19 @@ type Handshaker struct { func NewHandshaker(log *log.Entry, config ConnConfig, signaler *Signaler, ice *WorkerICE, relay *WorkerRelay, metricsStages *MetricsStages) *Handshaker { h := &Handshaker{ - log: log, - config: config, - signaler: signaler, - ice: ice, - relay: relay, - metricsStages: metricsStages, - remoteOffersCh: make(chan OfferAnswer), - remoteAnswerCh: make(chan OfferAnswer), + log: log, + config: config, + signaler: signaler, + ice: ice, + relay: relay, + metricsStages: metricsStages, + // Buffered by one so an offer or answer that arrives between Open launching + // the Listen goroutine and it reaching its receive is held rather than + // dropped. A peer activated by an incoming signal receives the remote's + // message in that window; an unbuffered channel skips it as "receiver not + // ready", and the connection cannot proceed until the remote re-sends. + remoteOffersCh: make(chan OfferAnswer, 1), + remoteAnswerCh: make(chan OfferAnswer, 1), } // assume remote supports ICE until we learn otherwise from received offers h.remoteICESupported.Store(ice != nil) @@ -162,29 +167,38 @@ func (h *Handshaker) SendOffer() error { return h.sendOffer() } -// OnRemoteOffer handles an offer from the remote peer and returns true if the message was accepted, false otherwise -// doesn't block, discards the message if connection wasn't ready +// OnRemoteOffer hands an offer to Listen without blocking, keeping only the most +// recent one if several arrive before Listen reads them. func (h *Handshaker) OnRemoteOffer(offer OfferAnswer) { - select { - case h.remoteOffersCh <- offer: - return - default: - h.log.Warnf("skipping remote offer message because receiver not ready") - // connection might not be ready yet to receive so we ignore the message - return - } + enqueueLatest(h.remoteOffersCh, offer) } -// OnRemoteAnswer handles an offer from the remote peer and returns true if the message was accepted, false otherwise -// doesn't block, discards the message if connection wasn't ready +// OnRemoteAnswer hands an answer to Listen without blocking, keeping only the most +// recent one if several arrive before Listen reads them. func (h *Handshaker) OnRemoteAnswer(answer OfferAnswer) { + enqueueLatest(h.remoteAnswerCh, answer) +} + +// enqueueLatest delivers msg on a one-slot channel without blocking. When the slot +// already holds an unread message the older one is discarded in favor of msg, so a +// message arriving before Listen starts reading is held rather than dropped, and +// the newest wins if several arrive first. Safe because there is a single producer +// (the engine loop): after draining the stale value the send always has room. +func enqueueLatest(ch chan OfferAnswer, msg OfferAnswer) { select { - case h.remoteAnswerCh <- answer: + case ch <- msg: return default: - // connection might not be ready yet to receive so we ignore the message - h.log.Warnf("skipping remote answer message because receiver not ready") - return + } + + select { + case <-ch: + default: + } + + select { + case ch <- msg: + default: } } diff --git a/client/internal/peer/handshaker_test.go b/client/internal/peer/handshaker_test.go new file mode 100644 index 000000000..5e203d78b --- /dev/null +++ b/client/internal/peer/handshaker_test.go @@ -0,0 +1,63 @@ +package peer + +import ( + "testing" + "time" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +func newTestHandshaker(t *testing.T) *Handshaker { + t.Helper() + // The tests exercise the answer path, whose Listen branch dispatches to the + // relay listener without sending an answer, so no signaler/ICE/relay is needed. + return NewHandshaker(log.WithField("test", t.Name()), ConnConfig{}, nil, nil, nil, nil) +} + +// TestHandshakerHoldsSignalArrivingBeforeListen covers the case where a peer is +// activated by an incoming signal: the remote's offer/answer arrives in the same +// step that opens the connection, before the Listen loop starts reading. The +// message must be held rather than dropped, or the connection cannot proceed until +// the remote re-sends. This is the path taken when an eager peer connects to a +// lazily-managed one. +func TestHandshakerHoldsSignalArrivingBeforeListen(t *testing.T) { + h := newTestHandshaker(t) + + processed := make(chan *OfferAnswer, 4) + h.AddRelayListener(func(o *OfferAnswer) { processed <- o }) + + // Delivered before Listen is reading, as when the peer is woken by the remote's + // signal and the message is delivered right after Open. + h.OnRemoteAnswer(OfferAnswer{WgListenPort: 51820}) + + go h.Listen(t.Context()) + + select { + case <-processed: + case <-time.After(2 * time.Second): + assert.Fail(t, "remote-answer dispatch: signal delivered before Listen was ready was dropped") + } +} + +// TestHandshakerKeepsLatestSignalBeforeListen covers several signals arriving +// before Listen reads: the newest must win (matching the latest-offer contract), +// rather than the first being kept and later ones discarded. +func TestHandshakerKeepsLatestSignalBeforeListen(t *testing.T) { + h := newTestHandshaker(t) + + processed := make(chan *OfferAnswer, 4) + h.AddRelayListener(func(o *OfferAnswer) { processed <- o }) + + h.OnRemoteAnswer(OfferAnswer{WgListenPort: 1111}) + h.OnRemoteAnswer(OfferAnswer{WgListenPort: 2222}) + + go h.Listen(t.Context()) + + select { + case got := <-processed: + assert.Equal(t, 2222, got.WgListenPort, "remote-answer dispatch: the latest queued signal should be processed") + case <-time.After(2 * time.Second): + assert.Fail(t, "remote-answer dispatch: queued signal was dropped") + } +} diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go index 24e3e7fac..bf36b944b 100644 --- a/client/internal/peer/status.go +++ b/client/internal/peer/status.go @@ -1167,6 +1167,18 @@ func (d *Status) GetResolvedDomainsStates() map[domain.Domain]ResolvedDomainInfo return maps.Clone(d.resolvedDomainsStates) } +// GetPeerStates returns a snapshot of all known peer states, including offline peers. +func (d *Status) GetPeerStates() []State { + d.mux.RLock() + defer d.mux.RUnlock() + + states := make([]State, 0, d.numOfPeers()) + for _, state := range d.peers { + states = append(states, state) + } + return append(states, d.offlinePeers...) +} + // GetFullStatus gets full status func (d *Status) GetFullStatus() FullStatus { fullStatus := FullStatus{ diff --git a/client/internal/peer/status_test.go b/client/internal/peer/status_test.go index 29404d413..82dff0d6f 100644 --- a/client/internal/peer/status_test.go +++ b/client/internal/peer/status_test.go @@ -129,6 +129,28 @@ func TestStatus_PeerStateByIP_RemovedPeer(t *testing.T) { req.False(ok, "removed peer must not resolve by IPv6 tunnel address") } +// TestStatus_GetPeerStates_IncludesOfflinePeers keeps the snapshot in line with +// GetFullStatus: offline peers are known peers, so a consumer counting peers +// must see the same total the status command reports. +func TestStatus_GetPeerStates_IncludesOfflinePeers(t *testing.T) { + status := NewRecorder("https://mgm") + req := require.New(t) + + req.NoError(status.AddPeer("pk-online", "online.netbird", "100.64.0.10", "fd00::1")) + status.ReplaceOfflinePeers([]State{ + {PubKey: "pk-offline", FQDN: "offline.netbird", IP: "100.64.0.20", ConnStatus: StatusIdle}, + }) + + states := status.GetPeerStates() + req.Len(states, 2, "snapshot must carry both the online and the offline peer") + + keys := make([]string, 0, len(states)) + for _, s := range states { + keys = append(keys, s.PubKey) + } + req.ElementsMatch([]string{"pk-online", "pk-offline"}, keys, "snapshot must carry both peers") +} + func TestStatus_UpdatePeerFQDN(t *testing.T) { key := "abc" fqdn := "peer-a.netbird.local" diff --git a/client/internal/peer/worker_ice.go b/client/internal/peer/worker_ice.go index 67f76f2e6..83cac13f5 100644 --- a/client/internal/peer/worker_ice.go +++ b/client/internal/peer/worker_ice.go @@ -389,6 +389,17 @@ func (w *WorkerICE) injectPortForwardedCandidate(srflxCandidate ice.Candidate) { return } + // A forwarded candidate only makes sense for an IPv4 mapping, which + // translates a port on the gateway's address. An IPv6 pinhole translates + // nothing: it unblocks the address ICE already gathers as a host candidate, + // so there is no second address to advertise. Injecting one here would also + // paste an IPv6 address onto whichever server-reflexive candidate arrived + // first, which is usually IPv4. + if mapping.ExternalIP != nil && mapping.ExternalIP.To4() == nil { + w.log.Debugf("skipping port-forwarded candidate: %s mapping is IPv6-only", mapping.NATType) + return + } + w.muxAgent.Lock() if w.portForwardAttempted { w.muxAgent.Unlock() diff --git a/client/internal/portforward/manager.go b/client/internal/portforward/manager.go index b0680160c..7d5a4cb9e 100644 --- a/client/internal/portforward/manager.go +++ b/client/internal/portforward/manager.go @@ -10,10 +10,8 @@ import ( "sync" "time" - "github.com/libp2p/go-nat" + "github.com/netbirdio/go-nat" log "github.com/sirupsen/logrus" - - "github.com/netbirdio/netbird/client/internal/portforward/pcp" ) const ( @@ -168,6 +166,11 @@ func (m *Manager) setup(ctx context.Context) (nat.NAT, *Mapping, error) { if err != nil { return nil, nil, fmt.Errorf("create port mapping: %w", err) } + + // Only meaningful once a mapping has been attempted: that is what opens the + // pinhole and records its outcome. + logIPv6Pinhole(gateway) + return gateway, mapping, nil } @@ -265,7 +268,9 @@ func (m *Manager) checkHealthAndRecreate(ctx context.Context, gateway nat.NAT) b return false } - pcpNAT, ok := gateway.(*pcp.NAT) + // Assert on the interface, not on a concrete type: a dual-stack gateway is + // a wrapper around the IPv4 NAT, so a type assertion misses it. + checker, ok := gateway.(nat.HealthChecker) if !ok { return false } @@ -273,7 +278,7 @@ func (m *Manager) checkHealthAndRecreate(ctx context.Context, gateway nat.NAT) b ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() - epoch, serverRestarted, err := pcpNAT.CheckServerHealth(ctx) + epoch, serverRestarted, err := checker.CheckServerHealth(ctx) if err != nil { log.Debugf("PCP health check failed: %v", err) return false @@ -340,3 +345,18 @@ func (m *Manager) startTearDown(ctx context.Context) { func isPermanentLeaseRequired(err error) bool { return err != nil && upnpErrPermanentLeaseOnly.MatchString(err.Error()) } + +// logIPv6Pinhole reports the outcome of the IPv6 pinhole. Pinholes are best +// effort and never fail a mapping on their own, so this is the only way to see +// whether one was actually opened. +func logIPv6Pinhole(gateway nat.NAT) { + reporter, ok := gateway.(nat.IPv6PinholeReporter) + if !ok { + return + } + if err := reporter.IPv6PinholeError(); err != nil { + log.Warnf("IPv6 pinhole: %v", err) + return + } + log.Infof("IPv6 pinhole open") +} diff --git a/client/internal/portforward/pcp/client.go b/client/internal/portforward/pcp/client.go deleted file mode 100644 index f6d243ef9..000000000 --- a/client/internal/portforward/pcp/client.go +++ /dev/null @@ -1,408 +0,0 @@ -package pcp - -import ( - "context" - "crypto/rand" - "errors" - "fmt" - "net" - "net/netip" - "sync" - "time" - - log "github.com/sirupsen/logrus" -) - -const ( - defaultTimeout = 3 * time.Second - responseBufferSize = 128 - - // RFC 6887 Section 8.1.1 retry timing - initialRetryDelay = 3 * time.Second - maxRetryDelay = 1024 * time.Second - maxRetries = 4 // 3s + 6s + 12s + 24s = 45s total worst case -) - -// Client is a PCP protocol client. -// All methods are safe for concurrent use. -type Client struct { - gateway netip.Addr - timeout time.Duration - - mu sync.Mutex - // localIP caches the resolved local IP address. - localIP netip.Addr - // lastEpoch is the last observed server epoch value. - lastEpoch uint32 - // epochTime tracks when lastEpoch was received for state loss detection. - epochTime time.Time - // externalIP caches the external IP from the last successful MAP response. - externalIP netip.Addr - // epochStateLost is set when epoch indicates server restart. - epochStateLost bool -} - -// NewClient creates a new PCP client for the gateway at the given IP. -func NewClient(gateway net.IP) *Client { - addr, ok := netip.AddrFromSlice(gateway) - if !ok { - log.Debugf("invalid gateway IP: %v", gateway) - } - return &Client{ - gateway: addr.Unmap(), - timeout: defaultTimeout, - } -} - -// NewClientWithTimeout creates a new PCP client with a custom timeout. -func NewClientWithTimeout(gateway net.IP, timeout time.Duration) *Client { - addr, ok := netip.AddrFromSlice(gateway) - if !ok { - log.Debugf("invalid gateway IP: %v", gateway) - } - return &Client{ - gateway: addr.Unmap(), - timeout: timeout, - } -} - -// SetLocalIP sets the local IP address to use in PCP requests. -func (c *Client) SetLocalIP(ip net.IP) { - addr, ok := netip.AddrFromSlice(ip) - if !ok { - log.Debugf("invalid local IP: %v", ip) - } - c.mu.Lock() - c.localIP = addr.Unmap() - c.mu.Unlock() -} - -// Gateway returns the gateway IP address. -func (c *Client) Gateway() net.IP { - return c.gateway.AsSlice() -} - -// Announce sends a PCP ANNOUNCE request to discover PCP support. -// Returns the server's epoch time on success. -func (c *Client) Announce(ctx context.Context) (epoch uint32, err error) { - localIP, err := c.getLocalIP() - if err != nil { - return 0, fmt.Errorf("get local IP: %w", err) - } - - req := buildAnnounceRequest(localIP) - resp, err := c.sendRequest(ctx, req) - if err != nil { - return 0, fmt.Errorf("send announce: %w", err) - } - - parsed, err := parseResponse(resp) - if err != nil { - return 0, fmt.Errorf("parse announce response: %w", err) - } - - if parsed.ResultCode != ResultSuccess { - return 0, fmt.Errorf("PCP ANNOUNCE failed: %s", ResultCodeString(parsed.ResultCode)) - } - - c.mu.Lock() - if c.updateEpochLocked(parsed.Epoch) { - log.Warnf("PCP server epoch indicates state loss - mappings may need refresh") - } - c.mu.Unlock() - return parsed.Epoch, nil -} - -// AddPortMapping requests a port mapping from the PCP server. -func (c *Client) AddPortMapping(ctx context.Context, protocol string, internalPort int, lifetime time.Duration) (*MapResponse, error) { - return c.addPortMappingWithHint(ctx, protocol, internalPort, internalPort, netip.Addr{}, lifetime) -} - -// AddPortMappingWithHint requests a port mapping with suggested external port and IP. -// Use lifetime <= 0 to delete a mapping. -func (c *Client) AddPortMappingWithHint(ctx context.Context, protocol string, internalPort, suggestedExtPort int, suggestedExtIP net.IP, lifetime time.Duration) (*MapResponse, error) { - var extIP netip.Addr - if suggestedExtIP != nil { - var ok bool - extIP, ok = netip.AddrFromSlice(suggestedExtIP) - if !ok { - log.Debugf("invalid suggested external IP: %v", suggestedExtIP) - } - extIP = extIP.Unmap() - } - return c.addPortMappingWithHint(ctx, protocol, internalPort, suggestedExtPort, extIP, lifetime) -} - -func (c *Client) addPortMappingWithHint(ctx context.Context, protocol string, internalPort, suggestedExtPort int, suggestedExtIP netip.Addr, lifetime time.Duration) (*MapResponse, error) { - localIP, err := c.getLocalIP() - if err != nil { - return nil, fmt.Errorf("get local IP: %w", err) - } - - proto, err := protocolNumber(protocol) - if err != nil { - return nil, fmt.Errorf("parse protocol: %w", err) - } - - var nonce [12]byte - if _, err := rand.Read(nonce[:]); err != nil { - return nil, fmt.Errorf("generate nonce: %w", err) - } - - // Convert lifetime to seconds. Lifetime 0 means delete, so only apply - // default for positive durations that round to 0 seconds. - var lifetimeSec uint32 - if lifetime > 0 { - lifetimeSec = uint32(lifetime.Seconds()) - if lifetimeSec == 0 { - lifetimeSec = DefaultLifetime - } - } - - req := buildMapRequest(localIP, nonce, proto, uint16(internalPort), uint16(suggestedExtPort), suggestedExtIP, lifetimeSec) - - resp, err := c.sendRequest(ctx, req) - if err != nil { - return nil, fmt.Errorf("send map request: %w", err) - } - - mapResp, err := parseMapResponse(resp) - if err != nil { - return nil, fmt.Errorf("parse map response: %w", err) - } - - if mapResp.Nonce != nonce { - return nil, fmt.Errorf("nonce mismatch in response") - } - - if mapResp.Protocol != proto { - return nil, fmt.Errorf("protocol mismatch: requested %d, got %d", proto, mapResp.Protocol) - } - if mapResp.InternalPort != uint16(internalPort) { - return nil, fmt.Errorf("internal port mismatch: requested %d, got %d", internalPort, mapResp.InternalPort) - } - - if mapResp.ResultCode != ResultSuccess { - return nil, &Error{ - Code: mapResp.ResultCode, - Message: ResultCodeString(mapResp.ResultCode), - } - } - - c.mu.Lock() - if c.updateEpochLocked(mapResp.Epoch) { - log.Warnf("PCP server epoch indicates state loss - mappings may need refresh") - } - c.cacheExternalIPLocked(mapResp.ExternalIP) - c.mu.Unlock() - return mapResp, nil -} - -// DeletePortMapping removes a port mapping by requesting zero lifetime. -func (c *Client) DeletePortMapping(ctx context.Context, protocol string, internalPort int) error { - if _, err := c.addPortMappingWithHint(ctx, protocol, internalPort, 0, netip.Addr{}, 0); err != nil { - var pcpErr *Error - if errors.As(err, &pcpErr) && pcpErr.Code == ResultNotAuthorized { - return nil - } - return fmt.Errorf("delete mapping: %w", err) - } - return nil -} - -// GetExternalAddress returns the external IP address. -// First checks for a cached value from previous MAP responses. -// If not cached, creates a short-lived mapping to discover the external IP. -func (c *Client) GetExternalAddress(ctx context.Context) (net.IP, error) { - c.mu.Lock() - if c.externalIP.IsValid() { - ip := c.externalIP.AsSlice() - c.mu.Unlock() - return ip, nil - } - c.mu.Unlock() - - // Use an ephemeral port in the dynamic range (49152-65535). - // Port 0 is not valid with UDP/TCP protocols per RFC 6887. - ephemeralPort := 49152 + int(uint16(time.Now().UnixNano()))%(65535-49152) - - // Use minimal lifetime (1 second) for discovery. - resp, err := c.AddPortMapping(ctx, "udp", ephemeralPort, time.Second) - if err != nil { - return nil, fmt.Errorf("create temporary mapping: %w", err) - } - - if err := c.DeletePortMapping(ctx, "udp", ephemeralPort); err != nil { - log.Debugf("cleanup temporary PCP mapping: %v", err) - } - - return resp.ExternalIP.AsSlice(), nil -} - -// LastEpoch returns the last observed server epoch value. -// A decrease in epoch indicates the server may have restarted and mappings may be lost. -func (c *Client) LastEpoch() uint32 { - c.mu.Lock() - defer c.mu.Unlock() - return c.lastEpoch -} - -// EpochStateLost returns true if epoch state loss was detected and clears the flag. -func (c *Client) EpochStateLost() bool { - c.mu.Lock() - defer c.mu.Unlock() - lost := c.epochStateLost - c.epochStateLost = false - return lost -} - -// updateEpoch updates the epoch tracking and detects potential state loss. -// Returns true if state loss was detected (server likely restarted). -// Caller must hold c.mu. -func (c *Client) updateEpochLocked(newEpoch uint32) bool { - now := time.Now() - stateLost := false - - // RFC 6887 Section 8.5: Detect invalid epoch indicating server state loss. - // client_delta = time since last response - // server_delta = epoch change since last response - // Invalid if: client_delta+2 < server_delta - server_delta/16 - // OR: server_delta+2 < client_delta - client_delta/16 - // The +2 handles quantization, /16 (6.25%) handles clock drift. - if !c.epochTime.IsZero() && c.lastEpoch > 0 { - clientDelta := uint32(now.Sub(c.epochTime).Seconds()) - serverDelta := newEpoch - c.lastEpoch - - // Check for epoch going backwards or jumping unexpectedly. - // Subtraction is safe: serverDelta/16 is always <= serverDelta. - if clientDelta+2 < serverDelta-(serverDelta/16) || - serverDelta+2 < clientDelta-(clientDelta/16) { - stateLost = true - c.epochStateLost = true - } - } - - c.lastEpoch = newEpoch - c.epochTime = now - return stateLost -} - -// cacheExternalIP stores the external IP from a successful MAP response. -// Caller must hold c.mu. -func (c *Client) cacheExternalIPLocked(ip netip.Addr) { - if ip.IsValid() && !ip.IsUnspecified() { - c.externalIP = ip - } -} - -// sendRequest sends a PCP request with retries per RFC 6887 Section 8.1.1. -func (c *Client) sendRequest(ctx context.Context, req []byte) ([]byte, error) { - addr := &net.UDPAddr{IP: c.gateway.AsSlice(), Port: Port} - - var lastErr error - delay := initialRetryDelay - - for range maxRetries { - resp, err := c.sendOnce(ctx, addr, req) - if err == nil { - return resp, nil - } - lastErr = err - - if ctx.Err() != nil { - return nil, ctx.Err() - } - - // RFC 6887 Section 8.1.1: RT = (1 + RAND) * MIN(2 * RTprev, MRT) - // RAND is random between -0.1 and +0.1 - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-time.After(retryDelayWithJitter(delay)): - } - delay = min(delay*2, maxRetryDelay) - } - - return nil, fmt.Errorf("PCP request failed after %d retries: %w", maxRetries, lastErr) -} - -// retryDelayWithJitter applies RFC 6887 jitter: multiply by (1 + RAND) where RAND is [-0.1, +0.1]. -func retryDelayWithJitter(d time.Duration) time.Duration { - var b [1]byte - _, _ = rand.Read(b[:]) - // Convert byte to range [-0.1, +0.1]: (b/255 * 0.2) - 0.1 - jitter := (float64(b[0])/255.0)*0.2 - 0.1 - return time.Duration(float64(d) * (1 + jitter)) -} - -func (c *Client) sendOnce(ctx context.Context, addr *net.UDPAddr, req []byte) ([]byte, error) { - // Use ListenUDP instead of DialUDP to validate response source address per RFC 6887 §8.3. - conn, err := net.ListenUDP("udp", nil) - if err != nil { - return nil, fmt.Errorf("listen: %w", err) - } - defer func() { - if err := conn.Close(); err != nil { - log.Debugf("close UDP connection: %v", err) - } - }() - - timeout := c.timeout - if deadline, ok := ctx.Deadline(); ok { - if remaining := time.Until(deadline); remaining < timeout { - timeout = remaining - } - } - - if err := conn.SetDeadline(time.Now().Add(timeout)); err != nil { - return nil, fmt.Errorf("set deadline: %w", err) - } - - if _, err := conn.WriteToUDP(req, addr); err != nil { - return nil, fmt.Errorf("write: %w", err) - } - - resp := make([]byte, responseBufferSize) - n, from, err := conn.ReadFromUDP(resp) - if err != nil { - return nil, fmt.Errorf("read: %w", err) - } - - // RFC 6887 §8.3: Validate response came from expected PCP server. - if !from.IP.Equal(addr.IP) { - return nil, fmt.Errorf("response from unexpected source %s (expected %s)", from.IP, addr.IP) - } - - return resp[:n], nil -} - -func (c *Client) getLocalIP() (netip.Addr, error) { - c.mu.Lock() - defer c.mu.Unlock() - - if !c.localIP.IsValid() { - return netip.Addr{}, fmt.Errorf("local IP not set for gateway %s", c.gateway) - } - return c.localIP, nil -} - -func protocolNumber(protocol string) (uint8, error) { - switch protocol { - case "udp", "UDP": - return ProtoUDP, nil - case "tcp", "TCP": - return ProtoTCP, nil - default: - return 0, fmt.Errorf("unsupported protocol: %s", protocol) - } -} - -// Error represents a PCP error response. -type Error struct { - Code uint8 - Message string -} - -func (e *Error) Error() string { - return fmt.Sprintf("PCP error: %s (%d)", e.Message, e.Code) -} diff --git a/client/internal/portforward/pcp/client_test.go b/client/internal/portforward/pcp/client_test.go deleted file mode 100644 index 79f44a426..000000000 --- a/client/internal/portforward/pcp/client_test.go +++ /dev/null @@ -1,187 +0,0 @@ -package pcp - -import ( - "context" - "net" - "net/netip" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestAddrConversion(t *testing.T) { - tests := []struct { - name string - addr netip.Addr - }{ - {"IPv4", netip.MustParseAddr("192.168.1.100")}, - {"IPv4 loopback", netip.MustParseAddr("127.0.0.1")}, - {"IPv6", netip.MustParseAddr("2001:db8::1")}, - {"IPv6 loopback", netip.MustParseAddr("::1")}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - b16 := addrTo16(tt.addr) - - recovered := addrFrom16(b16) - assert.Equal(t, tt.addr, recovered, "address should round-trip") - }) - } -} - -func TestBuildAnnounceRequest(t *testing.T) { - clientIP := netip.MustParseAddr("192.168.1.100") - req := buildAnnounceRequest(clientIP) - - require.Len(t, req, headerSize) - assert.Equal(t, byte(Version), req[0], "version") - assert.Equal(t, byte(OpAnnounce), req[1], "opcode") - - // Check client IP is properly encoded as IPv4-mapped IPv6 - assert.Equal(t, byte(0xff), req[18], "IPv4-mapped prefix byte 10") - assert.Equal(t, byte(0xff), req[19], "IPv4-mapped prefix byte 11") - assert.Equal(t, byte(192), req[20], "IP octet 1") - assert.Equal(t, byte(168), req[21], "IP octet 2") - assert.Equal(t, byte(1), req[22], "IP octet 3") - assert.Equal(t, byte(100), req[23], "IP octet 4") -} - -func TestBuildMapRequest(t *testing.T) { - clientIP := netip.MustParseAddr("192.168.1.100") - nonce := [12]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12} - req := buildMapRequest(clientIP, nonce, ProtoUDP, 51820, 51820, netip.Addr{}, 3600) - - require.Len(t, req, mapRequestSize) - assert.Equal(t, byte(Version), req[0], "version") - assert.Equal(t, byte(OpMap), req[1], "opcode") - - // Lifetime at bytes 4-7 - assert.Equal(t, uint32(3600), (uint32(req[4])<<24)|(uint32(req[5])<<16)|(uint32(req[6])<<8)|uint32(req[7]), "lifetime") - - // Nonce at bytes 24-35 - assert.Equal(t, nonce[:], req[24:36], "nonce") - - // Protocol at byte 36 - assert.Equal(t, byte(ProtoUDP), req[36], "protocol") - - // Internal port at bytes 40-41 - assert.Equal(t, uint16(51820), (uint16(req[40])<<8)|uint16(req[41]), "internal port") - - // External port at bytes 42-43 - assert.Equal(t, uint16(51820), (uint16(req[42])<<8)|uint16(req[43]), "external port") -} - -func TestParseResponse(t *testing.T) { - // Construct a valid ANNOUNCE response - resp := make([]byte, headerSize) - resp[0] = Version - resp[1] = OpAnnounce | OpReply - // Result code = 0 (success) - // Lifetime = 0 - // Epoch = 12345 - resp[8] = 0 - resp[9] = 0 - resp[10] = 0x30 - resp[11] = 0x39 - - parsed, err := parseResponse(resp) - require.NoError(t, err) - assert.Equal(t, uint8(Version), parsed.Version) - assert.Equal(t, uint8(OpAnnounce|OpReply), parsed.Opcode) - assert.Equal(t, uint8(ResultSuccess), parsed.ResultCode) - assert.Equal(t, uint32(12345), parsed.Epoch) -} - -func TestParseResponseErrors(t *testing.T) { - t.Run("too short", func(t *testing.T) { - _, err := parseResponse([]byte{1, 2, 3}) - assert.Error(t, err) - }) - - t.Run("wrong version", func(t *testing.T) { - resp := make([]byte, headerSize) - resp[0] = 1 // Wrong version - resp[1] = OpReply - _, err := parseResponse(resp) - assert.Error(t, err) - }) - - t.Run("missing reply bit", func(t *testing.T) { - resp := make([]byte, headerSize) - resp[0] = Version - resp[1] = OpAnnounce // Missing OpReply bit - _, err := parseResponse(resp) - assert.Error(t, err) - }) -} - -func TestResultCodeString(t *testing.T) { - assert.Equal(t, "SUCCESS", ResultCodeString(ResultSuccess)) - assert.Equal(t, "NOT_AUTHORIZED", ResultCodeString(ResultNotAuthorized)) - assert.Equal(t, "ADDRESS_MISMATCH", ResultCodeString(ResultAddressMismatch)) - assert.Contains(t, ResultCodeString(255), "UNKNOWN") -} - -func TestProtocolNumber(t *testing.T) { - proto, err := protocolNumber("udp") - require.NoError(t, err) - assert.Equal(t, uint8(ProtoUDP), proto) - - proto, err = protocolNumber("tcp") - require.NoError(t, err) - assert.Equal(t, uint8(ProtoTCP), proto) - - proto, err = protocolNumber("UDP") - require.NoError(t, err) - assert.Equal(t, uint8(ProtoUDP), proto) - - _, err = protocolNumber("icmp") - assert.Error(t, err) -} - -func TestClientCreation(t *testing.T) { - gateway := netip.MustParseAddr("192.168.1.1").AsSlice() - - client := NewClient(gateway) - assert.Equal(t, net.IP(gateway), client.Gateway()) - assert.Equal(t, defaultTimeout, client.timeout) - - clientWithTimeout := NewClientWithTimeout(gateway, 5*time.Second) - assert.Equal(t, 5*time.Second, clientWithTimeout.timeout) -} - -func TestNATType(t *testing.T) { - n := NewNAT(netip.MustParseAddr("192.168.1.1").AsSlice(), netip.MustParseAddr("192.168.1.100").AsSlice()) - assert.Equal(t, "PCP", n.Type()) -} - -// Integration test - skipped unless PCP_TEST_GATEWAY env is set -func TestClientIntegration(t *testing.T) { - t.Skip("Integration test - run manually with PCP_TEST_GATEWAY=") - - gateway := netip.MustParseAddr("10.0.1.1").AsSlice() // Change to your test gateway - localIP := netip.MustParseAddr("10.0.1.100").AsSlice() // Change to your local IP - - client := NewClient(gateway) - client.SetLocalIP(localIP) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - // Test ANNOUNCE - epoch, err := client.Announce(ctx) - require.NoError(t, err) - t.Logf("Server epoch: %d", epoch) - - // Test MAP - resp, err := client.AddPortMapping(ctx, "udp", 51820, 1*time.Hour) - require.NoError(t, err) - t.Logf("Mapping: internal=%d external=%d externalIP=%s", - resp.InternalPort, resp.ExternalPort, resp.ExternalIP) - - // Cleanup - err = client.DeletePortMapping(ctx, "udp", 51820) - require.NoError(t, err) -} diff --git a/client/internal/portforward/pcp/nat.go b/client/internal/portforward/pcp/nat.go deleted file mode 100644 index 0e635b6c8..000000000 --- a/client/internal/portforward/pcp/nat.go +++ /dev/null @@ -1,222 +0,0 @@ -package pcp - -import ( - "context" - "fmt" - "net" - "net/netip" - "runtime" - "sync" - "time" - - log "github.com/sirupsen/logrus" - - "github.com/libp2p/go-nat" - "github.com/libp2p/go-netroute" -) - -var _ nat.NAT = (*NAT)(nil) - -// NAT implements the go-nat NAT interface using PCP. -// Supports dual-stack (IPv4 and IPv6) when available. -// All methods are safe for concurrent use. -// -// TODO: IPv6 pinholes use the local IPv6 address. If the address changes -// (e.g., due to SLAAC rotation or network change), the pinhole becomes stale -// and needs to be recreated with the new address. -type NAT struct { - client *Client - - mu sync.RWMutex - // client6 is the IPv6 PCP client, nil if IPv6 is unavailable. - client6 *Client - // localIP6 caches the local IPv6 address used for PCP requests. - localIP6 netip.Addr -} - -// NewNAT creates a new NAT instance backed by PCP. -func NewNAT(gateway, localIP net.IP) *NAT { - client := NewClient(gateway) - client.SetLocalIP(localIP) - return &NAT{ - client: client, - } -} - -// Type returns "PCP" as the NAT type. -func (n *NAT) Type() string { - return "PCP" -} - -// GetDeviceAddress returns the gateway IP address. -func (n *NAT) GetDeviceAddress() (net.IP, error) { - return n.client.Gateway(), nil -} - -// GetExternalAddress returns the external IP address. -func (n *NAT) GetExternalAddress() (net.IP, error) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - return n.client.GetExternalAddress(ctx) -} - -// GetInternalAddress returns the local IP address used to communicate with the gateway. -func (n *NAT) GetInternalAddress() (net.IP, error) { - addr, err := n.client.getLocalIP() - if err != nil { - return nil, err - } - return addr.AsSlice(), nil -} - -// AddPortMapping creates a port mapping on both IPv4 and IPv6 (if available). -func (n *NAT) AddPortMapping(ctx context.Context, protocol string, internalPort int, _ string, timeout time.Duration) (int, error) { - resp, err := n.client.AddPortMapping(ctx, protocol, internalPort, timeout) - if err != nil { - return 0, fmt.Errorf("add mapping: %w", err) - } - - n.mu.RLock() - client6 := n.client6 - localIP6 := n.localIP6 - n.mu.RUnlock() - - if client6 == nil { - return int(resp.ExternalPort), nil - } - - if _, err := client6.AddPortMapping(ctx, protocol, internalPort, timeout); err != nil { - log.Warnf("IPv6 PCP mapping failed (continuing with IPv4): %v", err) - return int(resp.ExternalPort), nil - } - - log.Infof("created IPv6 PCP pinhole: %s:%d", localIP6, internalPort) - return int(resp.ExternalPort), nil -} - -// DeletePortMapping removes a port mapping from both IPv4 and IPv6. -func (n *NAT) DeletePortMapping(ctx context.Context, protocol string, internalPort int) error { - err := n.client.DeletePortMapping(ctx, protocol, internalPort) - - n.mu.RLock() - client6 := n.client6 - n.mu.RUnlock() - - if client6 != nil { - if err6 := client6.DeletePortMapping(ctx, protocol, internalPort); err6 != nil { - log.Warnf("IPv6 PCP delete mapping failed: %v", err6) - } - } - - if err != nil { - return fmt.Errorf("delete mapping: %w", err) - } - return nil -} - -// CheckServerHealth sends an ANNOUNCE to verify the server is still responsive. -// Returns the current epoch and whether the server may have restarted (epoch state loss detected). -func (n *NAT) CheckServerHealth(ctx context.Context) (epoch uint32, serverRestarted bool, err error) { - epoch, err = n.client.Announce(ctx) - if err != nil { - return 0, false, fmt.Errorf("announce: %w", err) - } - return epoch, n.client.EpochStateLost(), nil -} - -// DiscoverPCP attempts to discover a PCP-capable gateway. -// Returns a NAT interface if PCP is supported, or an error otherwise. -// Discovers both IPv4 and IPv6 gateways when available. -func DiscoverPCP(ctx context.Context) (nat.NAT, error) { - gateway, localIP, err := getDefaultGateway() - if err != nil { - return nil, fmt.Errorf("get default gateway: %w", err) - } - - client := NewClient(gateway) - client.SetLocalIP(localIP) - if _, err := client.Announce(ctx); err != nil { - return nil, fmt.Errorf("PCP announce: %w", err) - } - - result := &NAT{client: client} - discoverIPv6(ctx, result) - - return result, nil -} - -func discoverIPv6(ctx context.Context, result *NAT) { - gateway6, localIP6, err := getDefaultGateway6() - if err != nil { - log.Debugf("IPv6 gateway discovery failed: %v", err) - return - } - - client6 := NewClient(gateway6) - client6.SetLocalIP(localIP6) - if _, err := client6.Announce(ctx); err != nil { - log.Debugf("PCP IPv6 announce failed: %v", err) - return - } - - addr, ok := netip.AddrFromSlice(localIP6) - if !ok { - log.Debugf("invalid IPv6 local IP: %v", localIP6) - return - } - result.mu.Lock() - result.client6 = client6 - result.localIP6 = addr - result.mu.Unlock() - log.Debugf("PCP IPv6 gateway discovered: %s (local: %s)", gateway6, localIP6) -} - -// getDefaultGateway returns the default IPv4 gateway and local IP using the system routing table. -func getDefaultGateway() (gateway net.IP, localIP net.IP, err error) { - router, err := netroute.New() - if err != nil { - return nil, nil, err - } - - dst := net.IPv4zero - if runtime.GOOS == "linux" || runtime.GOOS == "android" { - // go-netroute v0.4.0 rejects unspecified destinations client-side on Linux/Android. - // TODO: on android/ios, use platform APIs (ConnectivityManager.getLinkProperties / - // NWPathMonitor) when netlink-based lookup is restricted or unavailable. - dst = net.IPv4(0, 0, 0, 1) - } - _, gateway, localIP, err = router.Route(dst) - if err != nil { - return nil, nil, err - } - - if gateway == nil { - return nil, nil, nat.ErrNoNATFound - } - - return gateway, localIP, nil -} - -// getDefaultGateway6 returns the default IPv6 gateway IP address using the system routing table. -func getDefaultGateway6() (gateway net.IP, localIP net.IP, err error) { - router, err := netroute.New() - if err != nil { - return nil, nil, err - } - - dst := net.IPv6zero - if runtime.GOOS == "linux" || runtime.GOOS == "android" { - // ::2 - dst = net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2} - } - _, gateway, localIP, err = router.Route(dst) - if err != nil { - return nil, nil, err - } - - if gateway == nil { - return nil, nil, nat.ErrNoNATFound - } - - return gateway, localIP, nil -} diff --git a/client/internal/portforward/pcp/protocol.go b/client/internal/portforward/pcp/protocol.go deleted file mode 100644 index d81c50c8c..000000000 --- a/client/internal/portforward/pcp/protocol.go +++ /dev/null @@ -1,225 +0,0 @@ -// Package pcp implements the Port Control Protocol (RFC 6887). -// -// # Implemented Features -// -// - ANNOUNCE opcode: Discovers PCP server support -// - MAP opcode: Creates/deletes port mappings (IPv4 NAT) and firewall pinholes (IPv6) -// - Dual-stack: Simultaneous IPv4 and IPv6 support via separate clients -// - Nonce validation: Prevents response spoofing -// - Epoch tracking: Detects server restarts per Section 8.5 -// - RFC-compliant retry timing: 3s initial, exponential backoff to 1024s max (Section 8.1.1) -// -// # Not Implemented -// -// - PEER opcode: For outbound peer connections (not needed for inbound NAT traversal) -// - THIRD_PARTY option: For managing mappings on behalf of other devices -// - PREFER_FAILURE option: Requires exact external port or fail (IPv4 NAT only, not needed for IPv6 pinholing) -// - FILTER option: To restrict remote peer addresses -// -// These optional features are omitted because the primary use case is simple -// port forwarding for WireGuard, which only requires MAP with default behavior. -package pcp - -import ( - "encoding/binary" - "fmt" - "net/netip" -) - -const ( - // Version is the PCP protocol version (RFC 6887). - Version = 2 - - // Port is the standard PCP server port. - Port = 5351 - - // DefaultLifetime is the default requested mapping lifetime in seconds. - DefaultLifetime = 7200 // 2 hours - - // Header sizes - headerSize = 24 - mapPayloadSize = 36 - mapRequestSize = headerSize + mapPayloadSize // 60 bytes -) - -// Opcodes -const ( - OpAnnounce = 0 - OpMap = 1 - OpPeer = 2 - OpReply = 0x80 // OR'd with opcode in responses -) - -// Protocol numbers for MAP requests -const ( - ProtoUDP = 17 - ProtoTCP = 6 -) - -// Result codes (RFC 6887 Section 7.4) -const ( - ResultSuccess = 0 - ResultUnsuppVersion = 1 - ResultNotAuthorized = 2 - ResultMalformedRequest = 3 - ResultUnsuppOpcode = 4 - ResultUnsuppOption = 5 - ResultMalformedOption = 6 - ResultNetworkFailure = 7 - ResultNoResources = 8 - ResultUnsuppProtocol = 9 - ResultUserExQuota = 10 - ResultCannotProvideExt = 11 - ResultAddressMismatch = 12 - ResultExcessiveRemotePeers = 13 -) - -// ResultCodeString returns a human-readable string for a result code. -func ResultCodeString(code uint8) string { - switch code { - case ResultSuccess: - return "SUCCESS" - case ResultUnsuppVersion: - return "UNSUPP_VERSION" - case ResultNotAuthorized: - return "NOT_AUTHORIZED" - case ResultMalformedRequest: - return "MALFORMED_REQUEST" - case ResultUnsuppOpcode: - return "UNSUPP_OPCODE" - case ResultUnsuppOption: - return "UNSUPP_OPTION" - case ResultMalformedOption: - return "MALFORMED_OPTION" - case ResultNetworkFailure: - return "NETWORK_FAILURE" - case ResultNoResources: - return "NO_RESOURCES" - case ResultUnsuppProtocol: - return "UNSUPP_PROTOCOL" - case ResultUserExQuota: - return "USER_EX_QUOTA" - case ResultCannotProvideExt: - return "CANNOT_PROVIDE_EXTERNAL" - case ResultAddressMismatch: - return "ADDRESS_MISMATCH" - case ResultExcessiveRemotePeers: - return "EXCESSIVE_REMOTE_PEERS" - default: - return fmt.Sprintf("UNKNOWN(%d)", code) - } -} - -// Response represents a parsed PCP response header. -type Response struct { - Version uint8 - Opcode uint8 - ResultCode uint8 - Lifetime uint32 - Epoch uint32 -} - -// MapResponse contains the full response to a MAP request. -type MapResponse struct { - Response - Nonce [12]byte - Protocol uint8 - InternalPort uint16 - ExternalPort uint16 - ExternalIP netip.Addr -} - -// addrTo16 converts an address to its 16-byte IPv4-mapped IPv6 representation. -func addrTo16(addr netip.Addr) [16]byte { - if addr.Is4() { - return netip.AddrFrom4(addr.As4()).As16() - } - return addr.As16() -} - -// addrFrom16 extracts an address from a 16-byte representation, unmapping IPv4. -func addrFrom16(b [16]byte) netip.Addr { - return netip.AddrFrom16(b).Unmap() -} - -// buildAnnounceRequest creates a PCP ANNOUNCE request packet. -func buildAnnounceRequest(clientIP netip.Addr) []byte { - req := make([]byte, headerSize) - req[0] = Version - req[1] = OpAnnounce - mapped := addrTo16(clientIP) - copy(req[8:24], mapped[:]) - return req -} - -// buildMapRequest creates a PCP MAP request packet. -func buildMapRequest(clientIP netip.Addr, nonce [12]byte, protocol uint8, internalPort, suggestedExtPort uint16, suggestedExtIP netip.Addr, lifetime uint32) []byte { - req := make([]byte, mapRequestSize) - - // Header - req[0] = Version - req[1] = OpMap - binary.BigEndian.PutUint32(req[4:8], lifetime) - mapped := addrTo16(clientIP) - copy(req[8:24], mapped[:]) - - // MAP payload - copy(req[24:36], nonce[:]) - req[36] = protocol - binary.BigEndian.PutUint16(req[40:42], internalPort) - binary.BigEndian.PutUint16(req[42:44], suggestedExtPort) - if suggestedExtIP.IsValid() { - extMapped := addrTo16(suggestedExtIP) - copy(req[44:60], extMapped[:]) - } - - return req -} - -// parseResponse parses the common PCP response header. -func parseResponse(data []byte) (*Response, error) { - if len(data) < headerSize { - return nil, fmt.Errorf("response too short: %d bytes", len(data)) - } - - resp := &Response{ - Version: data[0], - Opcode: data[1], - ResultCode: data[3], // Byte 2 is reserved, byte 3 is result code (RFC 6887 §7.2) - Lifetime: binary.BigEndian.Uint32(data[4:8]), - Epoch: binary.BigEndian.Uint32(data[8:12]), - } - - if resp.Version != Version { - return nil, fmt.Errorf("unsupported PCP version: %d", resp.Version) - } - - if resp.Opcode&OpReply == 0 { - return nil, fmt.Errorf("response missing reply bit: opcode=0x%02x", resp.Opcode) - } - - return resp, nil -} - -// parseMapResponse parses a complete MAP response. -func parseMapResponse(data []byte) (*MapResponse, error) { - if len(data) < mapRequestSize { - return nil, fmt.Errorf("MAP response too short: %d bytes", len(data)) - } - - resp, err := parseResponse(data) - if err != nil { - return nil, fmt.Errorf("parse header: %w", err) - } - - mapResp := &MapResponse{ - Response: *resp, - Protocol: data[36], - InternalPort: binary.BigEndian.Uint16(data[40:42]), - ExternalPort: binary.BigEndian.Uint16(data[42:44]), - ExternalIP: addrFrom16([16]byte(data[44:60])), - } - copy(mapResp.Nonce[:], data[24:36]) - - return mapResp, nil -} diff --git a/client/internal/portforward/pinhole_test.go b/client/internal/portforward/pinhole_test.go new file mode 100644 index 000000000..46b07a9e7 --- /dev/null +++ b/client/internal/portforward/pinhole_test.go @@ -0,0 +1,116 @@ +//go:build !js + +package portforward + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/netbirdio/go-nat" + log "github.com/sirupsen/logrus" + "github.com/sirupsen/logrus/hooks/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockPinholeNAT is a gateway that also reports an IPv6 pinhole outcome, the +// shape a dual-stack gateway has. +type mockPinholeNAT struct { + *mockNAT + pinholeErr error +} + +func (m *mockPinholeNAT) IPv6PinholeError() error { + return m.pinholeErr +} + +func TestSetupLogsPinholeOutcome(t *testing.T) { + pinholeErr := errors.New("pcp ipv6: NOT_AUTHORIZED") + + tests := []struct { + name string + pinholeErr error + mappingErr error + wantLevel log.Level + wantText string + }{ + { + name: "an open pinhole is reported", + wantLevel: log.InfoLevel, + wantText: "IPv6 pinhole open", + }, + { + name: "a failed pinhole is reported without failing the mapping", + // The IPv4 mapping is what the caller asked for, so the pinhole + // failure surfaces only in the log. + pinholeErr: pinholeErr, + wantLevel: log.WarnLevel, + wantText: pinholeErr.Error(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gateway := &mockPinholeNAT{mockNAT: newMockNAT(), pinholeErr: tt.pinholeErr} + hook := stubGatewayDiscovery(t, gateway) + + m := NewManager() + m.wgPort = 51820 + + _, mapping, err := m.setup(context.Background()) + + require.NoError(t, err) + require.NotNil(t, mapping) + + entry := findEntry(hook, tt.wantText) + require.NotNil(t, entry, "no log entry mentioning %q", tt.wantText) + assert.Equal(t, tt.wantLevel, entry.Level) + }) + } + + t.Run("a failed mapping reports no pinhole outcome", func(t *testing.T) { + // Nothing opened the pinhole, so whatever it currently reports says + // nothing about this attempt. + gateway := &mockPinholeNAT{mockNAT: newMockNAT()} + gateway.addMappingErr = errors.New("gateway refused") + hook := stubGatewayDiscovery(t, gateway) + + m := NewManager() + m.wgPort = 51820 + + _, _, err := m.setup(context.Background()) + + require.Error(t, err) + assert.Nil(t, findEntry(hook, "IPv6 pinhole")) + }) +} + +// stubGatewayDiscovery makes discovery return gateway and captures log output. +func stubGatewayDiscovery(t *testing.T, gateway nat.NAT) *test.Hook { + t.Helper() + + orig := discoverGateway + discoverGateway = func(context.Context) (nat.NAT, error) { return gateway, nil } + t.Cleanup(func() { discoverGateway = orig }) + + hook := test.NewGlobal() + origLevel := log.GetLevel() + log.SetLevel(log.DebugLevel) + t.Cleanup(func() { + hook.Reset() + log.SetLevel(origLevel) + }) + + return hook +} + +func findEntry(hook *test.Hook, substr string) *log.Entry { + for _, entry := range hook.AllEntries() { + if strings.Contains(entry.Message, substr) { + return entry + } + } + return nil +} diff --git a/client/internal/portforward/state.go b/client/internal/portforward/state.go index b1315cdc0..a21368e58 100644 --- a/client/internal/portforward/state.go +++ b/client/internal/portforward/state.go @@ -4,27 +4,94 @@ package portforward import ( "context" + "errors" "fmt" + "time" - "github.com/libp2p/go-nat" + "github.com/netbirdio/go-nat" + "github.com/netbirdio/go-nat/pcp" log "github.com/sirupsen/logrus" - - "github.com/netbirdio/netbird/client/internal/portforward/pcp" ) // discoverGateway is the function used for NAT gateway discovery. // It can be replaced in tests to avoid real network operations. -// Tries PCP first, then falls back to NAT-PMP/UPnP. var discoverGateway = defaultDiscoverGateway -func defaultDiscoverGateway(ctx context.Context) (nat.NAT, error) { - pcpGateway, err := pcp.DiscoverPCP(ctx) - if err == nil { - return pcpGateway, nil - } - log.Debugf("PCP discovery failed: %v, trying NAT-PMP/UPnP", err) +// pinholeDiscoveryTimeout is the slice of the discovery budget held back for +// the IPv6 pinhole probe. +// +// Sizing it is coarser than it looks: PCP retransmits on a 3s socket timeout +// and a 3s first backoff, so a second attempt needs about 9s. Anything from +// roughly 1s to 8s therefore buys exactly one attempt, and this only sets how +// long that attempt waits. A PCP server sits on the local link and answers in +// milliseconds, so 3s is margin rather than need, and the rest is left to +// gateway discovery, whose multicast SSDP search alone takes 5s. A probe lost +// to a dropped packet is retried by the next discovery round. +// +// It is a variable so tests can shorten it. +var pinholeDiscoveryTimeout = 3 * time.Second - return nat.DiscoverGateway(ctx) +// Discovery entry points, as variables so tests can drive the fallback without +// touching the network. +var ( + discoverNATGateway = nat.DiscoverGateway + + discoverPCPPinhole = func(ctx context.Context) (nat.NAT, error) { + pinhole, err := pcp.DiscoverPCP(ctx) + if err != nil { + return nil, err + } + return pinhole, nil + } +) + +// defaultDiscoverGateway finds a gateway that can make the WireGuard port +// reachable. DiscoverGateway prefers PCP for IPv4, races UPnP and NAT-PMP +// behind it, and attaches an IPv6 pinhole independently of which IPv4 protocol +// wins. +// +// It reports no gateway on a network offering only IPv6, having no IPv4 mapping +// to attach a pinhole to. Such a network still needs one: there is no +// translation to traverse, but the router drops inbound IPv6 until something +// opens it. Fall back to PCP alone, which yields a gateway holding just the +// pinhole. +func defaultDiscoverGateway(ctx context.Context) (nat.NAT, error) { + gatewayCtx, cancel := reserveForPinhole(ctx) + defer cancel() + + gateway, err := discoverNATGateway(gatewayCtx) + if err == nil { + return gateway, nil + } + if !errors.Is(err, nat.ErrNoNATFound) { + return nil, err + } + + pinhole, pinholeErr := discoverPCPPinhole(ctx) + if pinholeErr != nil { + log.Debugf("no IPv6 pinhole after %v: %v", err, pinholeErr) + return nil, err + } + + log.Infof("no IPv4 gateway, continuing with an IPv6 pinhole only") + return pinhole, nil +} + +// reserveForPinhole shortens ctx so that a pinhole probe still has time to run +// afterwards. Finding nothing takes gateway discovery everything it is given, +// so on the unshortened context the probe would start already expired. A budget +// too small to divide is left to gateway discovery, which is the likelier win. +func reserveForPinhole(ctx context.Context) (context.Context, context.CancelFunc) { + deadline, ok := ctx.Deadline() + if !ok { + return context.WithCancel(ctx) + } + + remaining := time.Until(deadline) + if remaining <= pinholeDiscoveryTimeout { + return context.WithCancel(ctx) + } + return context.WithTimeout(ctx, remaining-pinholeDiscoveryTimeout) } // State is persisted only for crash recovery cleanup diff --git a/client/internal/portforward/state_test.go b/client/internal/portforward/state_test.go new file mode 100644 index 000000000..8a584eecb --- /dev/null +++ b/client/internal/portforward/state_test.go @@ -0,0 +1,140 @@ +//go:build !js + +package portforward + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/netbirdio/go-nat" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// stubDiscovery replaces both discovery entry points for the duration of a +// test. gatewayDelay simulates gateway discovery spending everything it is +// given before reporting that it found nothing. +func stubDiscovery(t *testing.T, gateway nat.NAT, gatewayErr error, gatewayDelay time.Duration, pinhole nat.NAT, pinholeErr error) { + t.Helper() + + origGateway, origPinhole := discoverNATGateway, discoverPCPPinhole + discoverNATGateway = func(ctx context.Context) (nat.NAT, error) { + if gatewayDelay > 0 { + select { + case <-time.After(gatewayDelay): + case <-ctx.Done(): + } + } + return gateway, gatewayErr + } + discoverPCPPinhole = func(ctx context.Context) (nat.NAT, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + return pinhole, pinholeErr + } + + t.Cleanup(func() { discoverNATGateway, discoverPCPPinhole = origGateway, origPinhole }) +} + +func TestDefaultDiscoverGateway(t *testing.T) { + ipv4Gateway := &mockNAT{natType: "PCP+PCPv6"} + ipv6Pinhole := &mockNAT{natType: "PCP"} + otherErr := errors.New("routing table unavailable") + + t.Run("an IPv4 gateway is used as is", func(t *testing.T) { + stubDiscovery(t, ipv4Gateway, nil, 0, ipv6Pinhole, nil) + + got, err := defaultDiscoverGateway(context.Background()) + + require.NoError(t, err) + assert.Same(t, ipv4Gateway, got) + }) + + t.Run("no IPv4 gateway still opens an IPv6 pinhole", func(t *testing.T) { + stubDiscovery(t, nil, nat.ErrNoNATFound, 0, ipv6Pinhole, nil) + + got, err := defaultDiscoverGateway(context.Background()) + + require.NoError(t, err) + assert.Same(t, ipv6Pinhole, got) + }) + + t.Run("no gateway and no pinhole reports the original failure", func(t *testing.T) { + stubDiscovery(t, nil, nat.ErrNoNATFound, 0, nil, errors.New("no IPv6 route")) + + got, err := defaultDiscoverGateway(context.Background()) + + assert.Nil(t, got) + assert.ErrorIs(t, err, nat.ErrNoNATFound, "the pinhole failure must not mask why no gateway was found") + }) + + t.Run("a failure other than no-gateway is reported as is", func(t *testing.T) { + stubDiscovery(t, nil, otherErr, 0, ipv6Pinhole, nil) + + got, err := defaultDiscoverGateway(context.Background()) + + assert.Nil(t, got) + assert.ErrorIs(t, err, otherErr) + }) + + t.Run("the pinhole survives gateway discovery using its whole budget", func(t *testing.T) { + // On one shared context the probe would start already expired, which is + // how this failed against a real gateway. + reserve := 50 * time.Millisecond + origReserve := pinholeDiscoveryTimeout + pinholeDiscoveryTimeout = reserve + t.Cleanup(func() { pinholeDiscoveryTimeout = origReserve }) + + budget := 4 * reserve + ctx, cancel := context.WithTimeout(context.Background(), budget) + defer cancel() + + stubDiscovery(t, nil, nat.ErrNoNATFound, budget, ipv6Pinhole, nil) + + got, err := defaultDiscoverGateway(ctx) + + require.NoError(t, err) + assert.Same(t, ipv6Pinhole, got) + }) +} + +func TestReserveForPinhole(t *testing.T) { + origReserve := pinholeDiscoveryTimeout + pinholeDiscoveryTimeout = time.Second + t.Cleanup(func() { pinholeDiscoveryTimeout = origReserve }) + + t.Run("a budget is divided", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + gatewayCtx, cancelGateway := reserveForPinhole(ctx) + defer cancelGateway() + + deadline, ok := gatewayCtx.Deadline() + require.True(t, ok) + assert.InDelta(t, 9*time.Second, time.Until(deadline), float64(500*time.Millisecond)) + }) + + t.Run("a budget too small to divide is left whole", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + + gatewayCtx, cancelGateway := reserveForPinhole(ctx) + defer cancelGateway() + + deadline, ok := gatewayCtx.Deadline() + require.True(t, ok) + assert.InDelta(t, 500*time.Millisecond, time.Until(deadline), float64(100*time.Millisecond)) + }) + + t.Run("no deadline stays unbounded", func(t *testing.T) { + gatewayCtx, cancelGateway := reserveForPinhole(context.Background()) + defer cancelGateway() + + _, ok := gatewayCtx.Deadline() + assert.False(t, ok) + }) +} diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go index e1668238e..eacc6fd5f 100644 --- a/client/internal/profilemanager/config.go +++ b/client/internal/profilemanager/config.go @@ -103,6 +103,9 @@ type ConfigInput struct { DNSLabels domain.List MTU *uint16 + + LocalMetricsEnabled *bool + LocalMetricsAddress *string } // Config Configuration type @@ -144,6 +147,11 @@ type Config struct { DNSLabels domain.List + // LocalMetricsEnabled enables the local Prometheus /metrics endpoint. + LocalMetricsEnabled bool + // LocalMetricsAddress is the listen address of the local /metrics endpoint. + LocalMetricsAddress string + // SSHKey is a private SSH key in a PEM format SSHKey string @@ -388,6 +396,18 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } + if input.LocalMetricsEnabled != nil && *input.LocalMetricsEnabled != config.LocalMetricsEnabled { + log.Infof("switching local metrics to %t", *input.LocalMetricsEnabled) + config.LocalMetricsEnabled = *input.LocalMetricsEnabled + updated = true + } + + if input.LocalMetricsAddress != nil && *input.LocalMetricsAddress != config.LocalMetricsAddress { + log.Infof("switching local metrics address to %s", *input.LocalMetricsAddress) + config.LocalMetricsAddress = *input.LocalMetricsAddress + updated = true + } + if input.NetworkMonitor != nil && (config.NetworkMonitor == nil || *input.NetworkMonitor != *config.NetworkMonitor) { log.Infof("switching Network Monitor to %t", *input.NetworkMonitor) config.NetworkMonitor = input.NetworkMonitor @@ -718,6 +738,12 @@ func (config *Config) applyMDMPolicy(policy *mdm.Policy) { applyBool(mdm.KeyDisableAutoConnect, func(v bool) { config.DisableAutoConnect = v }) applyBool(mdm.KeyRosenpassEnabled, func(v bool) { config.RosenpassEnabled = v }) applyBool(mdm.KeyRosenpassPermissive, func(v bool) { config.RosenpassPermissive = v }) + applyBool(mdm.KeyEnableLocalMetrics, func(v bool) { config.LocalMetricsEnabled = v }) + + if v, ok := policy.GetString(mdm.KeyLocalMetricsAddress); ok { + config.LocalMetricsAddress = v + logApplied(mdm.KeyLocalMetricsAddress, v) + } if v, ok := policy.GetInt(mdm.KeyWireguardPort); ok { // REG_DWORD is 32-bit; UDP port range is 1-65535. Clamp at the diff --git a/client/internal/profilemanager/config_mdm_test.go b/client/internal/profilemanager/config_mdm_test.go index c6a688ab2..f8dfddb33 100644 --- a/client/internal/profilemanager/config_mdm_test.go +++ b/client/internal/profilemanager/config_mdm_test.go @@ -130,6 +130,32 @@ func TestApply_MDMBoolKeysOverrideOnDiskValue(t *testing.T) { assert.True(t, cfg.Policy().HasKey(mdm.KeyRosenpassEnabled)) } +func TestApply_MDMLocalMetrics(t *testing.T) { + tmp := filepath.Join(t.TempDir(), "config.json") + + // Seed without MDM. + withMDMPolicy(t, mdm.NewPolicy(nil)) + _, err := UpdateOrCreateConfig(ConfigInput{ + ConfigPath: tmp, + LocalMetricsEnabled: boolPtr(false), + }) + require.NoError(t, err) + + withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + mdm.KeyEnableLocalMetrics: true, + mdm.KeyLocalMetricsAddress: "127.0.0.1:9292", + })) + + cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: tmp}) + require.NoError(t, err) + require.NotNil(t, cfg) + + assert.True(t, cfg.LocalMetricsEnabled, "MDM override should flip on-disk false to true") + assert.Equal(t, "127.0.0.1:9292", cfg.LocalMetricsAddress) + assert.True(t, cfg.Policy().HasKey(mdm.KeyEnableLocalMetrics)) + assert.True(t, cfg.Policy().HasKey(mdm.KeyLocalMetricsAddress)) +} + func TestApply_MDMLazyConnection(t *testing.T) { cases := []struct { name string diff --git a/client/internal/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..bbbb969c9 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -4,12 +4,14 @@ package NetBirdSDK import ( "context" + "errors" "fmt" "net/netip" "os" "sort" "strings" "sync" + "sync/atomic" "time" log "github.com/sirupsen/logrus" @@ -22,8 +24,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" @@ -38,6 +39,8 @@ const ( AnonymizeLevelStrict = nbAnonymize.LevelStrictString ) +var errClientAlreadyRunning = errors.New("client is already running") + // RouteListener export internal RouteListener for mobile type NetworkChangeListener interface { listener.NetworkChangeListener @@ -75,31 +78,35 @@ type Client struct { cacheDir string logFilePath string recorder *peer.Status - ctxCancel context.CancelFunc - ctxCancelLock *sync.Mutex deviceName string osName string osVersion string networkChangeListener listener.NetworkChangeListener onHostDnsFn func([]string) dnsManager dns.IosDnsManager - loginComplete bool - // 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 + loginComplete atomic.Bool + // netMgr outlives engine restarts: it mirrors the OS connectivity, not + // the engine lifecycle. Run injects its state and sweeper into each new + // ConnectClient. + netMgr *netevents.Manager // preloadedConfig holds config loaded from JSON (used on tvOS where file writes are blocked) preloadedConfig *profilemanager.Config + // stateMu guards the run lifecycle as one unit: the cancel installed by + // the current run, the channel it closes on exit, and the state it + // published. One run at a time: startRun refuses a second Run while the + // previous one has not exited, and the platform serializes Stop before + // Start, so no generation tracking is needed. stateMu sync.RWMutex connectClient *internal.ConnectClient config *profilemanager.Config + runDone chan struct{} + ctxCancel context.CancelFunc } // NewClient instantiate a new Client 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 +115,10 @@ func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osV deviceName: deviceName, osName: osName, osVersion: osVersion, - recorder: peer.NewRecorder(""), - ctxCancelLock: &sync.Mutex{}, + recorder: recorder, networkChangeListener: networkChangeListener, dnsManager: dnsManager, - netState: netstate.New(), - sweeper: netsweep.New(), + netMgr: netevents.NewManager(recorder), } } @@ -159,17 +164,21 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error { c.recorder.UpdateManagementAddress(cfg.ManagementURL.String()) c.recorder.UpdateRosenpass(cfg.RosenpassEnabled, cfg.RosenpassPermissive) - var ctx context.Context //nolint ctxWithValues := context.WithValue(context.Background(), system.DeviceNameCtxKey, c.deviceName) //nolint ctxWithValues = context.WithValue(ctxWithValues, system.OsNameCtxKey, c.osName) //nolint ctxWithValues = context.WithValue(ctxWithValues, system.OsVersionCtxKey, c.osVersion) - c.ctxCancelLock.Lock() - ctx, c.ctxCancel = context.WithCancel(ctxWithValues) - defer c.ctxCancel() - c.ctxCancelLock.Unlock() + runCtx, runCancel := context.WithCancel(ctxWithValues) + defer runCancel() + + done, err := c.startRun(runCancel) + if err != nil { + return err + } + defer c.finishRun(done) + ctx := runCtx // No login pre-flight here. The engine's own loginToManagement (connect.go) performs // the authoritative Login immediately before the first Sync, so a LoginSync() call at @@ -190,7 +199,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 +212,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,20 +224,43 @@ 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 +// Stop cancels the running client and waits for the run loop to exit, so a +// caller that restarts immediately cannot race the outgoing teardown. func (c *Client) Stop() { - c.ctxCancelLock.Lock() - defer c.ctxCancelLock.Unlock() - if c.ctxCancel == nil { + done := c.cancelRun() + if done == nil { return } - c.ctxCancel() - c.setState(nil, nil) + select { + case <-done: + case <-time.After(stopRunWaitTimeout): + log.Warnf("Stop: timed out waiting for the run loop to exit") + } +} + +// StopWithoutWait cancels the running client without waiting for the run loop. +// Use it where the caller is on a deadline the wait could overrun, such as +// NEPacketTunnelProvider.stopTunnel, which iOS gives only a few seconds +// before it kills the extension. +func (c *Client) StopWithoutWait() { + c.cancelRun() +} + +func (c *Client) cancelRun() chan struct{} { + c.stateMu.RLock() + done := c.runDone + cancel := c.ctxCancel + c.stateMu.RUnlock() + + if cancel != nil { + cancel() + } + + return done } // DebugBundle generates a debug bundle, uploads it and returns the upload key. @@ -379,16 +412,14 @@ func (c *Client) IsLoginRequiredCached() bool { } func (c *Client) IsLoginRequired() bool { - var ctx context.Context //nolint ctxWithValues := context.WithValue(context.Background(), system.DeviceNameCtxKey, c.deviceName) //nolint ctxWithValues = context.WithValue(ctxWithValues, system.OsNameCtxKey, c.osName) //nolint ctxWithValues = context.WithValue(ctxWithValues, system.OsVersionCtxKey, c.osVersion) - c.ctxCancelLock.Lock() - defer c.ctxCancelLock.Unlock() - ctx, c.ctxCancel = context.WithCancel(ctxWithValues) + ctx, cancel := context.WithCancel(ctxWithValues) + defer cancel() var cfg *profilemanager.Config var err error @@ -436,17 +467,22 @@ func (c *Client) IsLoginRequired() bool { // loginForMobileAuthTimeout is the timeout for requesting auth info from the server const loginForMobileAuthTimeout = 30 * time.Second +const stopRunWaitTimeout = 20 * time.Second + func (c *Client) LoginForMobile() string { - var ctx context.Context //nolint ctxWithValues := context.WithValue(context.Background(), system.DeviceNameCtxKey, c.deviceName) //nolint ctxWithValues = context.WithValue(ctxWithValues, system.OsNameCtxKey, c.osName) //nolint ctxWithValues = context.WithValue(ctxWithValues, system.OsVersionCtxKey, c.osVersion) - c.ctxCancelLock.Lock() - defer c.ctxCancelLock.Unlock() - ctx, c.ctxCancel = context.WithCancel(ctxWithValues) + ctx, cancel := context.WithCancel(ctxWithValues) + loginDone := false + defer func() { + if !loginDone { + cancel() + } + }() // Use DirectUpdateOrCreateConfig to avoid atomic file operations (temp file + rename) // which are blocked by the tvOS sandbox in App Group containers @@ -473,7 +509,9 @@ func (c *Client) LoginForMobile() string { } // This could cause a potential race condition with loading the extension which need to be handled on swift side + loginDone = true go func() { + defer cancel() tokenInfo, err := oAuthFlow.WaitToken(ctx, flowInfo) if err != nil { log.Errorf("LoginForMobile: WaitToken failed: %v", err) @@ -490,18 +528,18 @@ func (c *Client) LoginForMobile() string { log.Errorf("LoginForMobile: Login failed: %v", err) return } - c.loginComplete = true + c.loginComplete.Store(true) }() return flowInfo.VerificationURIComplete } func (c *Client) IsLoginComplete() bool { - return c.loginComplete + return c.loginComplete.Load() } func (c *Client) ClearLoginComplete() { - c.loginComplete = false + c.loginComplete.Store(false) } func (c *Client) GetRoutesSelectionDetails() (*RoutesSelectionDetails, error) { @@ -721,13 +759,36 @@ func (c *Client) DeselectRoute(id string) error { return nil } -// setState stores the running engine state so DebugBundle can reuse the live -// config and ConnectClient. It is cleared on Stop. -func (c *Client) setState(cfg *profilemanager.Config, cc *internal.ConnectClient) { +func (c *Client) startRun(cancel context.CancelFunc) (chan struct{}, error) { c.stateMu.Lock() defer c.stateMu.Unlock() + + if c.runDone != nil { + return nil, errClientAlreadyRunning + } + + done := make(chan struct{}) + c.runDone = done + c.ctxCancel = cancel + return done, nil +} + +func (c *Client) finishRun(done chan struct{}) { + c.stateMu.Lock() + c.connectClient = nil + c.config = nil + c.runDone = nil + c.ctxCancel = nil + c.stateMu.Unlock() + + close(done) +} + +func (c *Client) setState(cfg *profilemanager.Config, cc *internal.ConnectClient) { + c.stateMu.Lock() c.config = cfg c.connectClient = cc + c.stateMu.Unlock() } // stateSnapshot returns the current config and ConnectClient under the lock. diff --git a/client/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 29288b511..eb9db07c4 100644 --- a/client/mdm/canonical_loaders.go +++ b/client/mdm/canonical_loaders.go @@ -27,6 +27,8 @@ var allKeys = []string{ KeyRosenpassEnabled, KeyRosenpassPermissive, KeyWireguardPort, + KeyEnableLocalMetrics, + KeyLocalMetricsAddress, KeySplitTunnelMode, KeySplitTunnelApps, KeyLazyConnection, diff --git a/client/mdm/canonical_loaders_test.go b/client/mdm/canonical_loaders_test.go new file mode 100644 index 000000000..330a15c47 --- /dev/null +++ b/client/mdm/canonical_loaders_test.go @@ -0,0 +1,52 @@ +//go:build windows || darwin + +package mdm + +import ( + "go/ast" + "go/parser" + "go/token" + "slices" + "strconv" + "testing" +) + +// TestAllKeysCoversEveryPolicyKey guards against the drift that adding a Key* +// constant without listing it in allKeys causes: the desktop loaders resolve +// value names through canonicalKey, so an unlisted key is silently discarded as +// unknown. policy.go is parsed rather than hand-mirrored so the test cannot go +// stale in the same way. +func TestAllKeysCoversEveryPolicyKey(t *testing.T) { + file, err := parser.ParseFile(token.NewFileSet(), "policy.go", nil, 0) + if err != nil { + t.Fatalf("parse policy.go: %v", err) + } + + for _, decl := range file.Decls { + gen, ok := decl.(*ast.GenDecl) + if !ok || gen.Tok != token.CONST { + continue + } + for _, spec := range gen.Specs { + value, ok := spec.(*ast.ValueSpec) + if !ok || len(value.Names) != 1 || len(value.Values) != 1 { + continue + } + name := value.Names[0].Name + if len(name) < 4 || name[:3] != "Key" { + continue + } + lit, ok := value.Values[0].(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + continue + } + key, err := strconv.Unquote(lit.Value) + if err != nil { + t.Fatalf("unquote %s: %v", name, err) + } + if !slices.Contains(allKeys, key) { + t.Errorf("%s (%q) is missing from allKeys, so the desktop loaders discard it as unknown", name, key) + } + } + } +} diff --git a/client/mdm/policy.go b/client/mdm/policy.go index 1feff28f8..6c64acfc8 100644 --- a/client/mdm/policy.go +++ b/client/mdm/policy.go @@ -47,6 +47,8 @@ const ( KeyRosenpassEnabled = "rosenpassEnabled" KeyRosenpassPermissive = "rosenpassPermissive" KeyWireguardPort = "wireguardPort" + KeyEnableLocalMetrics = "enableLocalMetrics" + KeyLocalMetricsAddress = "localMetricsAddress" // Split tunnel is modeled as a single conceptual policy with two // registry/plist values. KeySplitTunnelMode is the discriminator diff --git a/client/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 b438a310a..089f3b95b 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -343,6 +343,8 @@ type LoginRequest struct { DisableSSHAuth *bool `protobuf:"varint,38,opt,name=disableSSHAuth,proto3,oneof" json:"disableSSHAuth,omitempty"` SshJWTCacheTTL *int32 `protobuf:"varint,39,opt,name=sshJWTCacheTTL,proto3,oneof" json:"sshJWTCacheTTL,omitempty"` DisableIpv6 *bool `protobuf:"varint,40,opt,name=disable_ipv6,json=disableIpv6,proto3,oneof" json:"disable_ipv6,omitempty"` + EnableLocalMetrics *bool `protobuf:"varint,41,opt,name=enable_local_metrics,json=enableLocalMetrics,proto3,oneof" json:"enable_local_metrics,omitempty"` + LocalMetricsAddress *string `protobuf:"bytes,42,opt,name=local_metrics_address,json=localMetricsAddress,proto3,oneof" json:"local_metrics_address,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -658,6 +660,20 @@ func (x *LoginRequest) GetDisableIpv6() bool { return false } +func (x *LoginRequest) GetEnableLocalMetrics() bool { + if x != nil && x.EnableLocalMetrics != nil { + return *x.EnableLocalMetrics + } + return false +} + +func (x *LoginRequest) GetLocalMetricsAddress() string { + if x != nil && x.LocalMetricsAddress != nil { + return *x.LocalMetricsAddress + } + return "" +} + type LoginResponse struct { state protoimpl.MessageState `protogen:"open.v1"` NeedsSSOLogin bool `protobuf:"varint,1,opt,name=needsSSOLogin,proto3" json:"needsSSOLogin,omitempty"` @@ -4233,6 +4249,8 @@ type SetConfigRequest struct { DisableSSHAuth *bool `protobuf:"varint,33,opt,name=disableSSHAuth,proto3,oneof" json:"disableSSHAuth,omitempty"` SshJWTCacheTTL *int32 `protobuf:"varint,34,opt,name=sshJWTCacheTTL,proto3,oneof" json:"sshJWTCacheTTL,omitempty"` DisableIpv6 *bool `protobuf:"varint,35,opt,name=disable_ipv6,json=disableIpv6,proto3,oneof" json:"disable_ipv6,omitempty"` + EnableLocalMetrics *bool `protobuf:"varint,36,opt,name=enable_local_metrics,json=enableLocalMetrics,proto3,oneof" json:"enable_local_metrics,omitempty"` + LocalMetricsAddress *string `protobuf:"bytes,37,opt,name=local_metrics_address,json=localMetricsAddress,proto3,oneof" json:"local_metrics_address,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -4512,6 +4530,20 @@ func (x *SetConfigRequest) GetDisableIpv6() bool { return false } +func (x *SetConfigRequest) GetEnableLocalMetrics() bool { + if x != nil && x.EnableLocalMetrics != nil { + return *x.EnableLocalMetrics + } + return false +} + +func (x *SetConfigRequest) GetLocalMetricsAddress() string { + if x != nil && x.LocalMetricsAddress != nil { + return *x.LocalMetricsAddress + } + return "" +} + type SetConfigResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -7032,7 +7064,7 @@ var File_daemon_proto protoreflect.FileDescriptor const file_daemon_proto_rawDesc = "" + "\n" + "\fdaemon.proto\x12\x06daemon\x1a google/protobuf/descriptor.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/duration.proto\"\x0e\n" + - "\fEmptyRequest\"\xef\x12\n" + + "\fEmptyRequest\"\x92\x14\n" + "\fLoginRequest\x12\x1a\n" + "\bsetupKey\x18\x01 \x01(\tR\bsetupKey\x12&\n" + "\fpreSharedKey\x18\x02 \x01(\tB\x02\x18\x01R\fpreSharedKey\x12$\n" + @@ -7077,7 +7109,9 @@ const file_daemon_proto_rawDesc = "" + "\x1denableSSHRemotePortForwarding\x18% \x01(\bH\x18R\x1denableSSHRemotePortForwarding\x88\x01\x01\x12+\n" + "\x0edisableSSHAuth\x18& \x01(\bH\x19R\x0edisableSSHAuth\x88\x01\x01\x12+\n" + "\x0esshJWTCacheTTL\x18' \x01(\x05H\x1aR\x0esshJWTCacheTTL\x88\x01\x01\x12&\n" + - "\fdisable_ipv6\x18( \x01(\bH\x1bR\vdisableIpv6\x88\x01\x01B\x13\n" + + "\fdisable_ipv6\x18( \x01(\bH\x1bR\vdisableIpv6\x88\x01\x01\x125\n" + + "\x14enable_local_metrics\x18) \x01(\bH\x1cR\x12enableLocalMetrics\x88\x01\x01\x127\n" + + "\x15local_metrics_address\x18* \x01(\tH\x1dR\x13localMetricsAddress\x88\x01\x01B\x13\n" + "\x11_rosenpassEnabledB\x10\n" + "\x0e_interfaceNameB\x10\n" + "\x0e_wireguardPortB\x17\n" + @@ -7105,7 +7139,9 @@ const file_daemon_proto_rawDesc = "" + "\x1e_enableSSHRemotePortForwardingB\x11\n" + "\x0f_disableSSHAuthB\x11\n" + "\x0f_sshJWTCacheTTLB\x0f\n" + - "\r_disable_ipv6\"\xb5\x01\n" + + "\r_disable_ipv6B\x17\n" + + "\x15_enable_local_metricsB\x18\n" + + "\x16_local_metrics_address\"\xb5\x01\n" + "\rLoginResponse\x12$\n" + "\rneedsSSOLogin\x18\x01 \x01(\bR\rneedsSSOLogin\x12\x1a\n" + "\buserCode\x18\x02 \x01(\tR\buserCode\x12(\n" + @@ -7400,7 +7436,7 @@ const file_daemon_proto_rawDesc = "" + "\f_profileNameB\v\n" + "\t_username\"'\n" + "\x15SwitchProfileResponse\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\"\x98\x11\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"\xbb\x12\n" + "\x10SetConfigRequest\x12\x1a\n" + "\busername\x18\x01 \x01(\tR\busername\x12 \n" + "\vprofileName\x18\x02 \x01(\tR\vprofileName\x12$\n" + @@ -7440,7 +7476,9 @@ const file_daemon_proto_rawDesc = "" + "\x1denableSSHRemotePortForwarding\x18 \x01(\bH\x15R\x1denableSSHRemotePortForwarding\x88\x01\x01\x12+\n" + "\x0edisableSSHAuth\x18! \x01(\bH\x16R\x0edisableSSHAuth\x88\x01\x01\x12+\n" + "\x0esshJWTCacheTTL\x18\" \x01(\x05H\x17R\x0esshJWTCacheTTL\x88\x01\x01\x12&\n" + - "\fdisable_ipv6\x18# \x01(\bH\x18R\vdisableIpv6\x88\x01\x01B\x13\n" + + "\fdisable_ipv6\x18# \x01(\bH\x18R\vdisableIpv6\x88\x01\x01\x125\n" + + "\x14enable_local_metrics\x18$ \x01(\bH\x19R\x12enableLocalMetrics\x88\x01\x01\x127\n" + + "\x15local_metrics_address\x18% \x01(\tH\x1aR\x13localMetricsAddress\x88\x01\x01B\x13\n" + "\x11_rosenpassEnabledB\x10\n" + "\x0e_interfaceNameB\x10\n" + "\x0e_wireguardPortB\x17\n" + @@ -7465,7 +7503,9 @@ const file_daemon_proto_rawDesc = "" + "\x1e_enableSSHRemotePortForwardingB\x11\n" + "\x0f_disableSSHAuthB\x11\n" + "\x0f_sshJWTCacheTTLB\x0f\n" + - "\r_disable_ipv6\"\x13\n" + + "\r_disable_ipv6B\x17\n" + + "\x15_enable_local_metricsB\x18\n" + + "\x16_local_metrics_address\"\x13\n" + "\x11SetConfigResponse\"Q\n" + "\x11AddProfileRequest\x12\x1a\n" + "\busername\x18\x01 \x01(\tR\busername\x12 \n" + diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto index a3e3f4500..ad59a78f8 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -242,6 +242,9 @@ message LoginRequest { optional bool disableSSHAuth = 38; optional int32 sshJWTCacheTTL = 39; optional bool disable_ipv6 = 40; + + optional bool enable_local_metrics = 41; + optional string local_metrics_address = 42; } message LoginResponse { @@ -766,6 +769,9 @@ message SetConfigRequest { optional bool disableSSHAuth = 33; optional int32 sshJWTCacheTTL = 34; optional bool disable_ipv6 = 35; + + optional bool enable_local_metrics = 36; + optional string local_metrics_address = 37; } message SetConfigResponse{} diff --git a/client/server/mdm.go b/client/server/mdm.go index 9836c6bea..552fba94f 100644 --- a/client/server/mdm.go +++ b/client/server/mdm.go @@ -233,6 +233,24 @@ func conflictString(key, got string) conflictCheck { } } +// conflictStringPtr is conflictString for optional proto fields, where an +// explicit empty value is still a request to change the setting. If p is +// nil the field is treated as matching (no override requested); otherwise +// the check returns true only when the policy contains the key and its +// value equals *p. +func conflictStringPtr(key string, p *string) conflictCheck { + return conflictCheck{ + key: key, + check: func(pol *mdm.Policy) bool { + if p == nil { + return true + } + want, ok := pol.GetString(key) + return ok && want == *p + }, + } +} + // conflictInt64 builds a conflictCheck for an integer MDM key. If p is // nil the field is treated as matching; otherwise the check returns // true only when the policy contains the key and its int value equals *p. @@ -301,6 +319,8 @@ func mdmManagedFieldConflicts(msg *proto.SetConfigRequest, policy *mdm.Policy) [ conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes), conflictBool(mdm.KeyBlockInbound, msg.BlockInbound), conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort), + conflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics), + conflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress), }) } @@ -346,7 +366,9 @@ func setConfigRequestHasConfigOverrides(msg *proto.SetConfigRequest) bool { msg.EnableSSHLocalPortForwarding != nil || msg.EnableSSHRemotePortForwarding != nil || msg.DisableSSHAuth != nil || - msg.SshJWTCacheTTL != nil + msg.SshJWTCacheTTL != nil || + msg.EnableLocalMetrics != nil || + msg.LocalMetricsAddress != nil } // loginRequestHasConfigOverrides reports whether the LoginRequest @@ -381,7 +403,9 @@ func loginRequestHasConfigOverrides(msg *proto.LoginRequest) bool { msg.BlockLanAccess != nil || msg.DisableNotifications != nil || len(msg.DnsLabels) > 0 || msg.CleanDNSLabels || - msg.BlockInbound != nil + msg.BlockInbound != nil || + msg.EnableLocalMetrics != nil || + msg.LocalMetricsAddress != nil } // loginRequestMDMConflicts mirrors mdmManagedFieldConflicts but for the @@ -422,6 +446,8 @@ func loginRequestMDMConflicts(msg *proto.LoginRequest, policy *mdm.Policy) []str conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes), conflictBool(mdm.KeyBlockInbound, msg.BlockInbound), conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort), + conflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics), + conflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress), }) } diff --git a/client/server/network.go b/client/server/network.go index c390b8180..69eaabf8a 100644 --- a/client/server/network.go +++ b/client/server/network.go @@ -232,4 +232,3 @@ func toNetIDs(routes []string) []route.NetID { } return netIDs } - diff --git a/client/server/server.go b/client/server/server.go index f33e19075..23dccc9b1 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -23,6 +23,9 @@ import ( "github.com/netbirdio/netbird/client/internal/auth" "github.com/netbirdio/netbird/client/internal/expose" + "github.com/prometheus/client_golang/prometheus" + + "github.com/netbirdio/netbird/client/internal/localmetrics" "github.com/netbirdio/netbird/client/internal/profilemanager" sleephandler "github.com/netbirdio/netbird/client/internal/sleep/handler" "github.com/netbirdio/netbird/client/mdm" @@ -108,6 +111,7 @@ type Server struct { statusRecorder *peer.Status sessionWatcher *internal.SessionWatcher + localMetrics *localmetrics.Manager probeThrottle *probeThrottle persistSyncResponse bool @@ -171,9 +175,28 @@ func New(ctx context.Context, logFile string, configFile string, profilesDisable s.sleepHandler = sleephandler.New(agent) s.startSleepDetector() + s.localMetrics = localmetrics.NewManager(ctx, s.statusRecorder, s.clientMetricsGatherer) + return s } +// clientMetricsGatherer returns the Prometheus gatherer of the running +// engine's client metrics, or nil when no engine is running. +func (s *Server) clientMetricsGatherer() prometheus.Gatherer { + s.mutex.Lock() + connectClient := s.connectClient + s.mutex.Unlock() + + if connectClient == nil { + return nil + } + engine := connectClient.Engine() + if engine == nil { + return nil + } + return engine.GetClientMetrics().PrometheusGatherer() +} + func (s *Server) Start() error { s.mutex.Lock() defer s.mutex.Unlock() @@ -254,6 +277,7 @@ func (s *Server) Start() error { s.statusRecorder.UpdateManagementAddress(config.ManagementURL.String()) s.statusRecorder.UpdateRosenpass(config.RosenpassEnabled, config.RosenpassPermissive) + s.localMetrics.Reconcile(config.LocalMetricsEnabled, config.LocalMetricsAddress) if s.sessionWatcher == nil { s.sessionWatcher = internal.NewSessionWatcher(s.rootCtx, s.statusRecorder) @@ -477,11 +501,18 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques return nil, err } - if _, err := profilemanager.UpdateConfig(config); err != nil { + updatedConf, err := profilemanager.UpdateConfig(config) + if err != nil { log.Errorf("failed to update profile config: %v", err) return nil, fmt.Errorf("failed to update profile config: %w", err) } + if activeProf, err := s.profileManager.GetActiveProfileState(); err == nil { + if activePath, err := activeProf.FilePath(); err == nil && activePath == config.ConfigPath { + s.localMetrics.Reconcile(updatedConf.LocalMetricsEnabled, updatedConf.LocalMetricsAddress) + } + } + return &proto.SetConfigResponse{}, nil } @@ -551,6 +582,8 @@ func (s *Server) setConfigInputFromRequest(msg *proto.SetConfigRequest) (profile config.RosenpassEnabled = msg.RosenpassEnabled config.RosenpassPermissive = msg.RosenpassPermissive + config.LocalMetricsEnabled = msg.EnableLocalMetrics + config.LocalMetricsAddress = msg.LocalMetricsAddress config.DisableAutoConnect = msg.DisableAutoConnect config.ServerSSHAllowed = msg.ServerSSHAllowed config.NetworkMonitor = msg.NetworkMonitor @@ -657,6 +690,8 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro s.config = config s.mutex.Unlock() + s.localMetrics.Reconcile(config.LocalMetricsEnabled, config.LocalMetricsAddress) + // A probe that errors leaves the login undecided: Management unreachable, a // restart mid-request, an internal error. Those are returned for the caller // to retry, because turning them into an SSO prompt asks the user to solve @@ -1007,6 +1042,7 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR s.statusRecorder.UpdateManagementAddress(s.config.ManagementURL.String()) s.statusRecorder.UpdateRosenpass(s.config.RosenpassEnabled, s.config.RosenpassPermissive) + s.localMetrics.Reconcile(s.config.LocalMetricsEnabled, s.config.LocalMetricsAddress) s.clientRunning = true s.clientRunningChan = make(chan struct{}) @@ -1184,6 +1220,7 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi } s.config = config + s.localMetrics.Reconcile(config.LocalMetricsEnabled, config.LocalMetricsAddress) if msg != nil && msg.ProfileName != nil { s.publishProfileListChanged(*msg.ProfileName) diff --git a/client/server/server_privileged_test.go b/client/server/server_privileged_test.go index 0366ccb31..aa6e99026 100644 --- a/client/server/server_privileged_test.go +++ b/client/server/server_privileged_test.go @@ -200,7 +200,7 @@ func startManagement(t *testing.T, signalAddr string, counter *int) (*grpc.Serve requestBuffer := server.NewAccountRequestBuffer(context.Background(), store) peersUpdateManager := update_channel.NewPeersUpdateManager(metrics) - networkMapController := controller.NewController(context.Background(), store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config) + networkMapController := controller.NewController(context.Background(), store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config, nil) accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore) if err != nil { return nil, "", err diff --git a/client/server/setconfig_mdm_test.go b/client/server/setconfig_mdm_test.go index ae323ea8c..ad3b7ade7 100644 --- a/client/server/setconfig_mdm_test.go +++ b/client/server/setconfig_mdm_test.go @@ -136,6 +136,51 @@ func TestSetConfig_MDMReject_MultipleFields(t *testing.T) { }, v.GetFields()) } +func TestSetConfig_MDMReject_LocalMetrics(t *testing.T) { + withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + mdm.KeyEnableLocalMetrics: true, + mdm.KeyLocalMetricsAddress: "127.0.0.1:9191", + })) + + s, ctx, profName, username, _ := setupServerWithProfile(t) + + enabled := false + addr := "0.0.0.0:9999" + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + EnableLocalMetrics: &enabled, + LocalMetricsAddress: &addr, + }) + + v := extractViolation(t, err) + assert.ElementsMatch(t, []string{ + mdm.KeyEnableLocalMetrics, + mdm.KeyLocalMetricsAddress, + }, v.GetFields()) +} + +// An explicitly empty address still changes the effective listen address +// (the manager falls back to the default), so presence must be honored +// rather than collapsed to "field not set". +func TestSetConfig_MDMReject_LocalMetricsEmptyAddress(t *testing.T) { + withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + mdm.KeyLocalMetricsAddress: "127.0.0.1:9999", + })) + + s, ctx, profName, username, _ := setupServerWithProfile(t) + + addr := "" + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + LocalMetricsAddress: &addr, + }) + + v := extractViolation(t, err) + assert.ElementsMatch(t, []string{mdm.KeyLocalMetricsAddress}, v.GetFields()) +} + func TestSetConfig_MDMReject_AllOrNothing(t *testing.T) { // MDM enforces ManagementURL only; user request touches both the // enforced field AND a non-enforced field (RosenpassEnabled). diff --git a/client/server/setconfig_test.go b/client/server/setconfig_test.go index db7a26f03..d8309f519 100644 --- a/client/server/setconfig_test.go +++ b/client/server/setconfig_test.go @@ -76,6 +76,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { disableIPv6 := true mtu := int64(1280) sshJWTCacheTTL := int32(300) + enableLocalMetrics := true + localMetricsAddress := "127.0.0.1:9292" req := &proto.SetConfigRequest{ ProfileName: profName, @@ -107,6 +109,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { DnsRouteInterval: durationpb.New(2 * time.Minute), Mtu: &mtu, SshJWTCacheTTL: &sshJWTCacheTTL, + EnableLocalMetrics: &enableLocalMetrics, + LocalMetricsAddress: &localMetricsAddress, } _, err = s.SetConfig(ctx, req) @@ -153,6 +157,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { require.Equal(t, uint16(mtu), cfg.MTU) require.NotNil(t, cfg.SSHJWTCacheTTL) require.Equal(t, int(sshJWTCacheTTL), *cfg.SSHJWTCacheTTL) + require.Equal(t, enableLocalMetrics, cfg.LocalMetricsEnabled) + require.Equal(t, localMetricsAddress, cfg.LocalMetricsAddress) verifyAllFieldsCovered(t, req) } @@ -205,6 +211,8 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) { "EnableSSHRemotePortForwarding": true, "DisableSSHAuth": true, "SshJWTCacheTTL": true, + "EnableLocalMetrics": true, + "LocalMetricsAddress": true, } val := reflect.ValueOf(req).Elem() @@ -264,6 +272,8 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) { "enable-ssh-remote-port-forwarding": "EnableSSHRemotePortForwarding", "disable-ssh-auth": "DisableSSHAuth", "ssh-jwt-cache-ttl": "SshJWTCacheTTL", + "enable-local-metrics": "EnableLocalMetrics", + "local-metrics-address": "LocalMetricsAddress", } // SetConfigRequest fields that don't have CLI flags (settable only via UI or other means). diff --git a/client/server/ssh_gate.go b/client/server/ssh_gate.go index ca1b4c4ee..3b62f5e56 100644 --- a/client/server/ssh_gate.go +++ b/client/server/ssh_gate.go @@ -14,6 +14,7 @@ import ( "github.com/netbirdio/netbird/client/internal/daemonaddr" "github.com/netbirdio/netbird/client/internal/ipcauth" + "github.com/netbirdio/netbird/client/internal/localmetrics" "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/util" @@ -30,6 +31,8 @@ import ( // management identity hands SSH authorization decisions, including which // keys and users are accepted, to whoever controls that identity. Changing // the management URL and deregistering the peer are both ways to do that. +// - Binding the local metrics endpoint to a non-loopback address publishes +// peer names and connectivity state to the network without authentication. // // Everything else stays unauthenticated, so this is not an authorization model: // it only refuses the changes that would let a local user become root. A caller @@ -39,27 +42,33 @@ import ( // user-to-root boundary. Fields are nil or empty when the request leaves them // untouched. type privilegedConfigChange struct { - managementURL string - serverSSHAllowed *bool - enableSSHRoot *bool - disableSSHAuth *bool + managementURL string + serverSSHAllowed *bool + enableSSHRoot *bool + disableSSHAuth *bool + enableLocalMetrics *bool + localMetricsAddress *string } func privilegedChangeFromSetConfig(msg *proto.SetConfigRequest) privilegedConfigChange { return privilegedConfigChange{ - managementURL: msg.GetManagementUrl(), - serverSSHAllowed: msg.ServerSSHAllowed, - enableSSHRoot: msg.EnableSSHRoot, - disableSSHAuth: msg.DisableSSHAuth, + managementURL: msg.GetManagementUrl(), + serverSSHAllowed: msg.ServerSSHAllowed, + enableSSHRoot: msg.EnableSSHRoot, + disableSSHAuth: msg.DisableSSHAuth, + enableLocalMetrics: msg.EnableLocalMetrics, + localMetricsAddress: msg.LocalMetricsAddress, } } func privilegedChangeFromLogin(msg *proto.LoginRequest) privilegedConfigChange { return privilegedConfigChange{ - managementURL: msg.GetManagementUrl(), - serverSSHAllowed: msg.ServerSSHAllowed, - enableSSHRoot: msg.EnableSSHRoot, - disableSSHAuth: msg.DisableSSHAuth, + managementURL: msg.GetManagementUrl(), + serverSSHAllowed: msg.ServerSSHAllowed, + enableSSHRoot: msg.EnableSSHRoot, + disableSSHAuth: msg.DisableSSHAuth, + enableLocalMetrics: msg.EnableLocalMetrics, + localMetricsAddress: msg.LocalMetricsAddress, } } @@ -83,6 +92,12 @@ func requirePrivilegeForConfigChange(ctx context.Context, stored *profilemanager return denyPrivileged(ctx, "enabling the NetBird SSH server", ipcauth.UpCommand("--allow-server-ssh")) } + if addr, exposes := exposesLocalMetrics(stored, change); exposes { + return denyPrivileged(ctx, + "exposing the local metrics endpoint on a non-loopback address", + ipcauth.UpCommand("--enable-local-metrics --local-metrics-address "+addr)) + } + // Only guard the management binding while the SSH server is enabled: that is // when the management identity decides who may open a shell here. if !sshServerEnabled(stored) { @@ -245,6 +260,48 @@ func sshServerCurrentlyAllowed(cfg *profilemanager.Config) *bool { return &enabled } +// exposesLocalMetrics reports whether the change would leave the metrics +// endpoint enabled on an address that is not confirmed loopback, and returns +// that address. A request that restates the stored state is not a change, so a +// settings form resubmitted after an administrator opened the endpoint is not +// refused. +func exposesLocalMetrics(stored *profilemanager.Config, change privilegedConfigChange) (string, bool) { + storedEnabled, storedAddr := storedLocalMetrics(stored) + + enabled := storedEnabled + if change.enableLocalMetrics != nil { + enabled = *change.enableLocalMetrics + } + addr := storedAddr + if change.localMetricsAddress != nil { + addr = metricsAddrOrDefault(*change.localMetricsAddress) + } + + if !enabled || localmetrics.IsLoopback(addr) { + return "", false + } + if storedEnabled && storedAddr == addr { + return "", false + } + return addr, true +} + +// storedLocalMetrics reads the metrics settings from the stored config, +// tolerating a config that does not exist yet. +func storedLocalMetrics(cfg *profilemanager.Config) (bool, string) { + if cfg == nil { + return false, localmetrics.DefaultListenAddress + } + return cfg.LocalMetricsEnabled, metricsAddrOrDefault(cfg.LocalMetricsAddress) +} + +func metricsAddrOrDefault(addr string) string { + if addr == "" { + return localmetrics.DefaultListenAddress + } + return addr +} + // sameManagementURL reports whether requested addresses the same management // server as stored, comparing scheme, host and effective port so that an // equivalent spelling ("https://api.netbird.io" for a stored diff --git a/client/server/ssh_gate_test.go b/client/server/ssh_gate_test.go index cbd345f16..d71cd86ef 100644 --- a/client/server/ssh_gate_test.go +++ b/client/server/ssh_gate_test.go @@ -61,6 +61,8 @@ func noIdentityCtx() context.Context { return context.Background() } func boolPtr(v bool) *bool { return &v } +func strPtr(v string) *string { return &v } + func mustURL(t *testing.T, raw string) *url.URL { t.Helper() u, err := url.Parse(raw) @@ -194,6 +196,102 @@ func TestRequirePrivilegeForConfigChange_SSHFlags(t *testing.T) { } } +func TestRequirePrivilegeForConfigChange_LocalMetrics(t *testing.T) { + exposed := &profilemanager.Config{LocalMetricsEnabled: true, LocalMetricsAddress: "0.0.0.0:9191"} + + tests := []struct { + name string + stored *profilemanager.Config + change privilegedConfigChange + privileged bool + wantDeny bool + }{ + { + name: "binding a non-loopback address unprivileged is refused", + stored: &profilemanager.Config{}, + change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("0.0.0.0:9191")}, + wantDeny: true, + }, + { + name: "binding a non-loopback address as root is allowed", + stored: &profilemanager.Config{}, + change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("0.0.0.0:9191")}, + privileged: true, + }, + { + name: "enabling on the default loopback address is not guarded", + stored: &profilemanager.Config{}, + change: privilegedConfigChange{enableLocalMetrics: boolPtr(true)}, + }, + { + name: "enabling on an explicit loopback address is not guarded", + stored: &profilemanager.Config{}, + change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("127.0.0.1:9999")}, + }, + { + name: "enabling on the IPv6 loopback address is not guarded", + stored: &profilemanager.Config{}, + change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("[::1]:9191")}, + }, + { + // The address alone does nothing while the endpoint stays off. + name: "a non-loopback address without enabling is not guarded", + stored: &profilemanager.Config{}, + change: privilegedConfigChange{localMetricsAddress: strPtr("0.0.0.0:9191")}, + }, + { + name: "widening an already enabled loopback endpoint is refused", + stored: &profilemanager.Config{LocalMetricsEnabled: true, LocalMetricsAddress: "127.0.0.1:9191"}, + change: privilegedConfigChange{localMetricsAddress: strPtr("0.0.0.0:9191")}, + wantDeny: true, + }, + { + name: "restating an already exposed endpoint is not a change", + stored: exposed, + change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("0.0.0.0:9191")}, + }, + { + name: "turning an exposed endpoint off is not guarded", + stored: exposed, + change: privilegedConfigChange{enableLocalMetrics: boolPtr(false)}, + }, + { + name: "re-enabling an exposed endpoint that was turned off is refused", + stored: &profilemanager.Config{LocalMetricsEnabled: false, LocalMetricsAddress: "0.0.0.0:9191"}, + change: privilegedConfigChange{enableLocalMetrics: boolPtr(true)}, + wantDeny: true, + }, + { + // Fail closed: an address that cannot be parsed is not confirmed loopback. + name: "an unparseable address is refused", + stored: &profilemanager.Config{}, + change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("not-an-address")}, + wantDeny: true, + }, + { + name: "a profile with no config yet counts as off, so exposing is refused", + stored: nil, + change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("0.0.0.0:9191")}, + wantDeny: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := userCtx() + if tt.privileged { + ctx = rootCtx() + } + err := requirePrivilegeForConfigChange(ctx, tt.stored, tt.change) + if tt.wantDeny { + assertDenied(t, err) + return + } + assertAllowed(t, err) + }) + } +} + func TestRequirePrivilegeForConfigChange_ManagementURL(t *testing.T) { sshOn := func(raw string) *profilemanager.Config { return &profilemanager.Config{ServerSSHAllowed: boolPtr(true), ManagementURL: mustURL(t, raw)} diff --git a/client/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/system/info_js.go b/client/system/info_js.go index f32532881..3323fb542 100644 --- a/client/system/info_js.go +++ b/client/system/info_js.go @@ -15,7 +15,7 @@ func UpdateStaticInfoAsync() { } // GetInfo retrieves system information for WASM environment -func GetInfo(_ context.Context) *Info { +func GetInfo(ctx context.Context) *Info { info := &Info{ GoOS: runtime.GOOS, Kernel: runtime.GOARCH, @@ -30,6 +30,13 @@ func GetInfo(_ context.Context) *Info { collectBrowserInfo(info) collectLocationInfo(info) collectSystemInfo(info) + + // A caller-provided device name wins, as on the other platforms. A peer + // registered over an API keeps reporting the name it was registered with, + // so its meta does not change on the first sync. + if name := extractDeviceName(ctx, info.Hostname); name != "" { + info.Hostname = name + } return info } diff --git a/client/system/info_js_test.go b/client/system/info_js_test.go new file mode 100644 index 000000000..e2a33ada0 --- /dev/null +++ b/client/system/info_js_test.go @@ -0,0 +1,27 @@ +//go:build js + +package system + +import ( + "context" + "testing" +) + +// TestGetInfoHonorsDeviceName covers a caller-provided device name reaching the +// reported hostname, so a peer registered over an API keeps reporting the name +// it was registered with instead of renaming itself on its first sync. +func TestGetInfoHonorsDeviceName(t *testing.T) { + ctx := context.WithValue(context.Background(), DeviceNameCtxKey, "session-name") + if got := GetInfo(ctx).Hostname; got != "session-name" { + t.Errorf("hostname should carry the caller's device name, got %q", got) + } +} + +// TestGetInfoWithoutDeviceNameKeepsFallback covers the embed layer's habit of +// always setting the context value: an empty name must not blank the hostname. +func TestGetInfoWithoutDeviceNameKeepsFallback(t *testing.T) { + ctx := context.WithValue(context.Background(), DeviceNameCtxKey, "") + if got := GetInfo(ctx).Hostname; got == "" { + t.Error("an empty device name must not blank the hostname") + } +} diff --git a/client/system/process_test.go b/client/system/process_test.go index 9d0a6b935..de1cfc1db 100644 --- a/client/system/process_test.go +++ b/client/system/process_test.go @@ -1,3 +1,5 @@ +//go:build windows || (linux && !android) || (darwin && !ios) || freebsd + package system import ( 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/components/ReadySignal.tsx b/client/ui/frontend/src/components/ReadySignal.tsx new file mode 100644 index 000000000..0d040cabc --- /dev/null +++ b/client/ui/frontend/src/components/ReadySignal.tsx @@ -0,0 +1,18 @@ +import { useEffect, useRef } from "react"; +import { Events } from "@wailsio/runtime"; +import { useStatus } from "@/contexts/StatusContext.tsx"; + +const EVENT_WINDOW_PAINTED = "netbird:window-painted"; + +export const ReadySignal = () => { + const { isReady } = useStatus(); + const sent = useRef(false); + + useEffect(() => { + if (!isReady || sent.current) return; + sent.current = true; + void Events.Emit(EVENT_WINDOW_PAINTED); + }, [isReady]); + + return null; +}; diff --git a/client/ui/frontend/src/contexts/SettingsContext.tsx b/client/ui/frontend/src/contexts/SettingsContext.tsx index 3f4b2d0d2..a7574c7e5 100644 --- a/client/ui/frontend/src/contexts/SettingsContext.tsx +++ b/client/ui/frontend/src/contexts/SettingsContext.tsx @@ -22,12 +22,18 @@ 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"; + 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 +69,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 +85,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 +107,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 +159,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 +229,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 +301,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/layouts/AppLayout.tsx b/client/ui/frontend/src/layouts/AppLayout.tsx index 1588d9d08..0c2837b53 100644 --- a/client/ui/frontend/src/layouts/AppLayout.tsx +++ b/client/ui/frontend/src/layouts/AppLayout.tsx @@ -5,6 +5,7 @@ import { DebugBundleProvider } from "@/contexts/DebugBundleContext.tsx"; import { ProfileProvider } from "@/contexts/ProfileContext.tsx"; import { DialogProvider } from "@/contexts/DialogContext.tsx"; import { RestrictionsProvider } from "@/contexts/RestrictionsContext.tsx"; +import { ReadySignal } from "@/components/ReadySignal.tsx"; export const AppLayout = () => { return ( @@ -16,6 +17,7 @@ export const AppLayout = () => { + diff --git a/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx b/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx index ef8d6862f..e57040a7a 100644 --- a/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx +++ b/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx @@ -18,6 +18,11 @@ import { formatRemaining } from "@/lib/formatters"; const DEFAULT_SECONDS = 360; const WINDOW_WIDTH = 360; const SOON_THRESHOLD_SECONDS = 60 * 60; +const DEADLINE_TOLERANCE_MS = 5 * 1000; +// The final-warning deadline reaches the Go side as RFC3339 truncated to whole +// seconds, while the status snapshot carries millisecond precision, so an +// unchanged deadline can look up to 999 ms newer than the exact URL value. +const EXACT_DEADLINE_TOLERANCE_MS = 999; export default function SessionExpirationDialog() { const { t } = useTranslation(); @@ -29,11 +34,19 @@ export default function SessionExpirationDialog() { const n = Number.parseInt(raw, 10); return Number.isFinite(n) && n > 0 ? n : DEFAULT_SECONDS; }, [params]); + const initialDeadline = useMemo(() => { + const raw = params.get("deadline"); + if (!raw) return null; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) && n > 0 ? n : null; + }, [params]); const [remaining, setRemaining] = useState(initialSeconds); const [busy, setBusy] = useState(false); const busyRef = useRef(busy); busyRef.current = busy; + const openedDeadlineRef = useRef(initialDeadline ?? Date.now() + initialSeconds * 1000); + const exactDeadlineRef = useRef(initialDeadline !== null); const expired = remaining <= 0; const expiredRef = useRef(expired); expiredRef.current = expired; @@ -45,23 +58,45 @@ export default function SessionExpirationDialog() { useEffect(() => { setRemaining(initialSeconds); - }, [initialSeconds]); + openedDeadlineRef.current = initialDeadline ?? Date.now() + initialSeconds * 1000; + exactDeadlineRef.current = initialDeadline !== null; + }, [initialSeconds, initialDeadline]); + // Recompute from the absolute deadline instead of decrementing per tick: webview + // timers get suspended for tens of seconds (App Nap / hidden-window throttling), + // so a tick counter drifts behind the wall clock by the suspended time. useEffect(() => { const id = globalThis.setInterval(() => { - setRemaining((s) => (s <= 1 ? 0 : s - 1)); + setRemaining(Math.max(0, Math.ceil((openedDeadlineRef.current - Date.now()) / 1000))); }, 1000); return () => globalThis.clearInterval(id); }, [initialSeconds]); + // Auto-close only when the session was actually renewed elsewhere (tray action, CLI, + // main window): the daemon keeps emitting Connected snapshots regardless of session + // state, so the signal is the deadline jumping past the one this dialog was opened for. + // With the exact deadline from the URL any jump past its sub-second precision loss + // counts; the seconds-derived fallback needs a wider tolerance for the Go-side + // truncation and mount latency. // Don't auto-close while busy (aborts our WaitExtend) or expired (hides the state). useEffect(() => { - const off = Events.On("netbird:status", (ev: { data: { status?: string } }) => { - if (busyRef.current || expiredRef.current) return; - if (ev?.data?.status === "Connected") { - WindowManager.CloseSessionExpiration().catch(console.error); - } - }); + const off = Events.On( + "netbird:status", + (ev: { data: { status?: string; sessionExpiresAt?: string | null } }) => { + if (busyRef.current || expiredRef.current) return; + if (ev?.data?.status !== "Connected") return; + const raw = ev?.data?.sessionExpiresAt; + if (!raw) return; + const renewed = Date.parse(raw); + if (!Number.isFinite(renewed)) return; + const tolerance = exactDeadlineRef.current + ? EXACT_DEADLINE_TOLERANCE_MS + : DEADLINE_TOLERANCE_MS; + if (renewed - openedDeadlineRef.current > tolerance) { + WindowManager.CloseSessionExpiration().catch(console.error); + } + }, + ); return () => { off(); }; diff --git a/client/ui/frontend/src/modules/settings/SettingsSSH.tsx b/client/ui/frontend/src/modules/settings/SettingsSSH.tsx index bd91e520c..d74afae73 100644 --- a/client/ui/frontend/src/modules/settings/SettingsSSH.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsSSH.tsx @@ -1,3 +1,4 @@ +import { type TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import { CopyToClipboard } from "@/components/CopyToClipboard"; import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch"; @@ -6,51 +7,91 @@ import { Input } from "@/components/inputs/Input"; import { Label } from "@/components/typography/Label"; import { cn } from "@/lib/cn"; import { SectionGroup } from "@/modules/settings/SettingsSection.tsx"; -import { useSettings } from "@/contexts/SettingsContext.tsx"; +import { type GuardedField, useSettings } from "@/contexts/SettingsContext.tsx"; import { usePrivilege } from "@/hooks/usePrivilege.ts"; -import { Privilege } from "@bindings/services/models.js"; +import type { Privilege } from "@bindings/services/models.js"; import { type ChangeEvent, type ReactNode, useEffect, useId, useState } from "react"; export function SettingsSSH() { const { t } = useTranslation(); - const { config, setField } = useSettings(); + 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 isSSHServerEnabled = config.serverSshAllowed; + const authorize = async (field: GuardedField, value: boolean) => { + setAuthorizing(field); + try { + await saveGuardedField(field, value); + } finally { + setAuthorizing(null); + } + }; + // The daemon restricts only the direction that hands out shells 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 all three settings that is switching the field on. + // + // 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. const guarded = ( - 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 = false, ) => { + 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)), + }; }; - 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)); @@ -84,7 +125,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")} @@ -98,7 +139,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")} @@ -130,7 +171,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")} @@ -163,41 +204,81 @@ export function SettingsSSH() { ); } -// 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. -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.ssh.privilege.actorAdministrator") + : t("settings.ssh.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.ssh.privilege.authorizePending")}; + } + if (oneWay) { + return ( + + + {inverted + ? t("settings.ssh.privilege.oneWayInverted", { actor }) + : t("settings.ssh.privilege.oneWay", { actor })} + + + ); + } if (!command) return null; + return ( + + {t("settings.ssh.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.ssh.privilege.hint", { actor }) - : inverted - ? t("settings.ssh.privilege.oneWayInverted", { actor }) - : t("settings.ssh.privilege.oneWay", { actor })} - - - - {command} - - + {children}
); } diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json index d02589591..11e085927 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" }, @@ -764,7 +764,19 @@ "message": "Sensible Informationen anonymisieren" }, "settings.troubleshooting.anonymize.help": { - "message": "Versteckt öffentliche IP-Adressen und nicht-NetBird-Domains in Logs." + "message": "Verbirgt IP-Adressen, Domains und andere sensible Werte." + }, + "settings.troubleshooting.anonymize.info": { + "message": "Der Standardmodus lässt interne IPv4-Adressen und Peer-Namen für den Support lesbar. Der strikte Modus anonymisiert zusätzlich private (RFC 1918), CGNAT- und Link-Local-IP-Adressen, Peer-Namen und öffentliche WireGuard-Schlüssel. Wiederkehrende Werte erhalten denselben Platzhalter, sodass Peers unterscheidbar bleiben. Verwenden Sie den strikten Modus, wenn Sie das Debug-Paket außerhalb Ihrer Organisation weitergeben." + }, + "settings.troubleshooting.anonymize.none": { + "message": "Keine" + }, + "settings.troubleshooting.anonymize.default": { + "message": "Standard" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "Strikt" }, "settings.troubleshooting.systemInfo.label": { "message": "Systeminformationen einschließen" @@ -1338,5 +1350,29 @@ }, "error.unknown": { "message": "Vorgang fehlgeschlagen." + }, + "error.elevation_unavailable": { + "message": "NetBird konnte auf diesem System nicht die nötigen Rechte anfordern. Führen Sie stattdessen dies aus:" + }, + "error.elevation_failed": { + "message": "Die Änderung konnte mit erhöhten Rechten nicht angewendet werden. Führen Sie stattdessen dies aus:" + }, + "settings.ssh.privilege.actorRoot": { + "message": "root-Rechte" + }, + "settings.ssh.privilege.actorAdministrator": { + "message": "Administratorrechte" + }, + "settings.ssh.privilege.hint": { + "message": "Erfordert {actor}. Führen Sie stattdessen dies aus:" + }, + "settings.ssh.privilege.oneWay": { + "message": "Sie können dies deaktivieren, zum erneuten Aktivieren sind {actor} erforderlich." + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "Sie können dies aktivieren, zum erneuten Deaktivieren sind {actor} erforderlich." + }, + "settings.ssh.privilege.authorizePending": { + "message": "Warten auf Autorisierung…" } } diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json index 694444497..36f00e4bd 100644 --- a/client/ui/i18n/locales/en/common.json +++ b/client/ui/i18n/locales/en/common.json @@ -1799,16 +1799,36 @@ "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.ssh.privilege.actorRoot": { + "message": "root", + "description": "Fills {actor} in the settings.ssh.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.ssh.privilege.actorAdministrator": { + "message": "administrator privileges", + "description": "Fills {actor} in the settings.ssh.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.ssh.privilege.hint": { "message": "Requires {actor}. Run this instead:", "description": "Help text under an SSH 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." }, "settings.ssh.privilege.oneWay": { - "message": "You can switch this off, but switching it back on needs {actor}:", - "description": "Warning under an SSH 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 an SSH setting 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.ssh.privilege.oneWayInverted": { - "message": "You can switch this on, but switching it back off needs {actor}:", - "description": "Warning under the SSH authentication setting, 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.ssh.privilege.oneWay, for the SSH authentication setting once it has been switched off: switching it off again is what needs the privileges." + }, + "settings.ssh.privilege.authorizePending": { + "message": "Waiting for authorization…", + "description": "Replaces the help text under a guarded SSH setting while the authorization prompt is open, which can take a few seconds to appear. Keep the trailing ellipsis." } } diff --git a/client/ui/i18n/locales/es/common.json b/client/ui/i18n/locales/es/common.json index 3420b612b..41872d7a0 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" }, @@ -764,7 +764,19 @@ "message": "Anonimizar información sensible" }, "settings.troubleshooting.anonymize.help": { - "message": "Oculta las direcciones IP públicas y los dominios ajenos a NetBird de los registros." + "message": "Oculta direcciones IP, dominios y otros valores sensibles." + }, + "settings.troubleshooting.anonymize.info": { + "message": "El modo predeterminado mantiene legibles las direcciones IPv4 internas y los nombres de los peers para el soporte. El modo estricto anonimiza además las direcciones IP privadas (RFC 1918), CGNAT y de enlace local, los nombres de los peers y las claves públicas de WireGuard. Los valores recurrentes se asignan al mismo marcador de posición, por lo que los peers siguen siendo distinguibles. Use el modo estricto cuando comparta el paquete de diagnóstico fuera de su organización." + }, + "settings.troubleshooting.anonymize.none": { + "message": "Ninguno" + }, + "settings.troubleshooting.anonymize.default": { + "message": "Predeterminado" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "Estricto" }, "settings.troubleshooting.systemInfo.label": { "message": "Incluir información del sistema" @@ -1338,5 +1350,29 @@ }, "error.unknown": { "message": "La operación falló." + }, + "error.elevation_unavailable": { + "message": "NetBird no pudo solicitar a este sistema los privilegios necesarios. Ejecute esto en su lugar:" + }, + "error.elevation_failed": { + "message": "No se pudo aplicar el cambio con privilegios elevados. Ejecute esto en su lugar:" + }, + "settings.ssh.privilege.actorRoot": { + "message": "privilegios de root" + }, + "settings.ssh.privilege.actorAdministrator": { + "message": "privilegios de administrador" + }, + "settings.ssh.privilege.hint": { + "message": "Requiere {actor}. Ejecute esto en su lugar:" + }, + "settings.ssh.privilege.oneWay": { + "message": "Puede desactivarlo, pero volver a activarlo requiere {actor}." + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "Puede activarlo, pero volver a desactivarlo requiere {actor}." + }, + "settings.ssh.privilege.authorizePending": { + "message": "Esperando la autorización…" } } diff --git a/client/ui/i18n/locales/fr/common.json b/client/ui/i18n/locales/fr/common.json index a83f85c12..920ef8343 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" }, @@ -764,7 +764,19 @@ "message": "Anonymiser les informations sensibles" }, "settings.troubleshooting.anonymize.help": { - "message": "Masque les adresses IP publiques et les domaines non-NetBird dans les journaux." + "message": "Masque les adresses IP, les domaines et d'autres valeurs sensibles." + }, + "settings.troubleshooting.anonymize.info": { + "message": "Le mode par défaut garde les adresses IPv4 internes et les noms des pairs lisibles pour le support. Le mode strict anonymise en plus les adresses IP privées (RFC 1918), CGNAT et de lien local, les noms des pairs et les clés publiques WireGuard. Les valeurs récurrentes reçoivent le même espace réservé, les pairs restent donc distinguables. Utilisez le mode strict lorsque vous partagez le lot de diagnostic en dehors de votre organisation." + }, + "settings.troubleshooting.anonymize.none": { + "message": "Aucune" + }, + "settings.troubleshooting.anonymize.default": { + "message": "Par défaut" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "Strict" }, "settings.troubleshooting.systemInfo.label": { "message": "Inclure les informations système" @@ -1338,5 +1350,29 @@ }, "error.unknown": { "message": "L’opération a échoué." + }, + "error.elevation_unavailable": { + "message": "NetBird n’a pas pu demander à ce système les privilèges nécessaires. Exécutez plutôt ceci :" + }, + "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.ssh.privilege.actorRoot": { + "message": "les privilèges root" + }, + "settings.ssh.privilege.actorAdministrator": { + "message": "les privilèges administrateur" + }, + "settings.ssh.privilege.hint": { + "message": "Nécessite {actor}. Exécutez plutôt ceci :" + }, + "settings.ssh.privilege.oneWay": { + "message": "Vous pouvez le désactiver, mais le réactiver nécessite {actor}." + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "Vous pouvez l’activer, mais le désactiver de nouveau nécessite {actor}." + }, + "settings.ssh.privilege.authorizePending": { + "message": "En attente de l’autorisation…" } } diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json index b291f7a01..82996e3d3 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" }, @@ -764,7 +764,19 @@ "message": "Érzékeny információk anonimizálása" }, "settings.troubleshooting.anonymize.help": { - "message": "Elrejti a nyilvános IP-címeket és a nem-NetBird tartományokat a naplókban." + "message": "Elrejti az IP-címeket, a tartományokat és más érzékeny értékeket." + }, + "settings.troubleshooting.anonymize.info": { + "message": "Az Alapértelmezett szint a belső IPv4-címeket és a peer-neveket olvashatóan hagyja a támogatás számára. A Szigorú ezen felül anonimizálja a privát (RFC 1918), CGNAT és link-local IP-címeket, a peer-neveket és a WireGuard nyilvános kulcsokat. Az ismétlődő értékek ugyanazt a helyettesítőt kapják, így a peerek megkülönböztethetők maradnak. Használja a Szigorú szintet, ha a hibakeresési csomagot a szervezetén kívül osztja meg." + }, + "settings.troubleshooting.anonymize.none": { + "message": "Nincs" + }, + "settings.troubleshooting.anonymize.default": { + "message": "Alapértelmezett" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "Szigorú" }, "settings.troubleshooting.systemInfo.label": { "message": "Rendszerinformációk beillesztése" @@ -1338,5 +1350,29 @@ }, "error.unknown": { "message": "A művelet meghiúsult." + }, + "error.elevation_unavailable": { + "message": "A NetBird nem tudta bekérni a rendszertől a szükséges jogosultságokat. Futtassa inkább ezt:" + }, + "error.elevation_failed": { + "message": "A módosítást emelt szintű jogosultságokkal sem sikerült alkalmazni. Futtassa inkább ezt:" + }, + "settings.ssh.privilege.actorRoot": { + "message": "root jogosultság" + }, + "settings.ssh.privilege.actorAdministrator": { + "message": "rendszergazdai jogosultság" + }, + "settings.ssh.privilege.hint": { + "message": "{actor} szükséges hozzá. Futtassa inkább ezt:" + }, + "settings.ssh.privilege.oneWay": { + "message": "Kikapcsolhatja, de a visszakapcsolásához {actor} szükséges." + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "Bekapcsolhatja, de az ismételt kikapcsolásához {actor} szükséges." + }, + "settings.ssh.privilege.authorizePending": { + "message": "Várakozás az engedélyezésre…" } } diff --git a/client/ui/i18n/locales/it/common.json b/client/ui/i18n/locales/it/common.json index a68a8b32b..b8166aa6e 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" }, @@ -764,7 +764,19 @@ "message": "Anonimizza informazioni sensibili" }, "settings.troubleshooting.anonymize.help": { - "message": "Nasconde gli indirizzi IP pubblici e i domini non NetBird dai log." + "message": "Nasconde indirizzi IP, domini e altri valori sensibili." + }, + "settings.troubleshooting.anonymize.info": { + "message": "La modalità predefinita mantiene leggibili gli indirizzi IPv4 interni e i nomi dei peer per il supporto. La modalità rigorosa anonimizza inoltre gli indirizzi IP privati (RFC 1918), CGNAT e link-local, i nomi dei peer e le chiavi pubbliche WireGuard. I valori ricorrenti vengono associati allo stesso segnaposto, quindi i peer restano distinguibili. Usa la modalità rigorosa quando condividi il pacchetto di debug al di fuori della tua organizzazione." + }, + "settings.troubleshooting.anonymize.none": { + "message": "Nessuna" + }, + "settings.troubleshooting.anonymize.default": { + "message": "Predefinito" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "Rigoroso" }, "settings.troubleshooting.systemInfo.label": { "message": "Includi informazioni di sistema" @@ -1338,5 +1350,29 @@ }, "error.unknown": { "message": "Operazione non riuscita." + }, + "error.elevation_unavailable": { + "message": "NetBird non ha potuto richiedere a questo sistema i privilegi necessari. Esegua invece questo:" + }, + "error.elevation_failed": { + "message": "Non è stato possibile applicare la modifica con privilegi elevati. Esegua invece questo:" + }, + "settings.ssh.privilege.actorRoot": { + "message": "i privilegi di root" + }, + "settings.ssh.privilege.actorAdministrator": { + "message": "i privilegi di amministratore" + }, + "settings.ssh.privilege.hint": { + "message": "Richiede {actor}. Esegua invece questo:" + }, + "settings.ssh.privilege.oneWay": { + "message": "Può disabilitarlo, ma riabilitarlo richiede {actor}." + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "Può abilitarlo, ma disabilitarlo di nuovo richiede {actor}." + }, + "settings.ssh.privilege.authorizePending": { + "message": "In attesa dell'autorizzazione…" } } diff --git a/client/ui/i18n/locales/ja/common.json b/client/ui/i18n/locales/ja/common.json index ec69de9a5..6ffe05e1c 100644 --- a/client/ui/i18n/locales/ja/common.json +++ b/client/ui/i18n/locales/ja/common.json @@ -764,7 +764,19 @@ "message": "機密情報を匿名化" }, "settings.troubleshooting.anonymize.help": { - "message": "ログからパブリック IP アドレスと NetBird 以外のドメインを隠します。" + "message": "IP アドレス、ドメイン、その他の機密性の高い値を隠します。" + }, + "settings.troubleshooting.anonymize.info": { + "message": "「デフォルト」では、サポートのために内部 IPv4 アドレスとピア名は読める状態のまま残ります。「厳格」では、さらにプライベート (RFC 1918)、CGNAT、リンクローカルの IP アドレス、ピア名、WireGuard 公開鍵も匿名化されます。繰り返し現れる値は同じプレースホルダーに置き換えられるため、ピアは区別できます。デバッグバンドルを組織外に共有する場合は「厳格」を使用してください。" + }, + "settings.troubleshooting.anonymize.none": { + "message": "なし" + }, + "settings.troubleshooting.anonymize.default": { + "message": "デフォルト" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "厳格" }, "settings.troubleshooting.systemInfo.label": { "message": "システム情報を含める" @@ -1338,5 +1350,29 @@ }, "error.unknown": { "message": "操作に失敗しました。" + }, + "error.elevation_unavailable": { + "message": "NetBird はこのシステムに必要な権限を要求できませんでした。代わりに次のコマンドを実行してください:" + }, + "error.elevation_failed": { + "message": "昇格した権限でも変更を適用できませんでした。代わりに次のコマンドを実行してください:" + }, + "settings.ssh.privilege.actorRoot": { + "message": "root 権限" + }, + "settings.ssh.privilege.actorAdministrator": { + "message": "管理者権限" + }, + "settings.ssh.privilege.hint": { + "message": "{actor}が必要です。代わりに次のコマンドを実行してください:" + }, + "settings.ssh.privilege.oneWay": { + "message": "無効にはできますが、再度有効にするには{actor}が必要です。" + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "有効にはできますが、再度無効にするには{actor}が必要です。" + }, + "settings.ssh.privilege.authorizePending": { + "message": "承認を待っています…" } } diff --git a/client/ui/i18n/locales/pt/common.json b/client/ui/i18n/locales/pt/common.json index ef1bfd372..123e7a042 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" }, @@ -764,7 +764,19 @@ "message": "Anonimizar informações sensíveis" }, "settings.troubleshooting.anonymize.help": { - "message": "Oculta endereços IP públicos e domínios que não são do NetBird nos logs." + "message": "Oculta endereços IP, domínios e outros valores sensíveis." + }, + "settings.troubleshooting.anonymize.info": { + "message": "O modo padrão mantém os endereços IPv4 internos e os nomes dos peers legíveis para o suporte. O modo estrito anonimiza também os endereços IP privados (RFC 1918), CGNAT e link-local, os nomes dos peers e as chaves públicas do WireGuard. Valores recorrentes recebem o mesmo marcador, então os peers continuam distinguíveis. Use o modo estrito ao compartilhar o pacote de depuração fora da sua organização." + }, + "settings.troubleshooting.anonymize.none": { + "message": "Nenhum" + }, + "settings.troubleshooting.anonymize.default": { + "message": "Padrão" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "Estrito" }, "settings.troubleshooting.systemInfo.label": { "message": "Incluir informações do sistema" @@ -1338,5 +1350,29 @@ }, "error.unknown": { "message": "A operação falhou." + }, + "error.elevation_unavailable": { + "message": "O NetBird não conseguiu solicitar a este sistema os privilégios necessários. Execute isto em vez disso:" + }, + "error.elevation_failed": { + "message": "Não foi possível aplicar a alteração com privilégios elevados. Execute isto em vez disso:" + }, + "settings.ssh.privilege.actorRoot": { + "message": "privilégios de root" + }, + "settings.ssh.privilege.actorAdministrator": { + "message": "privilégios de administrador" + }, + "settings.ssh.privilege.hint": { + "message": "Requer {actor}. Execute isto em vez disso:" + }, + "settings.ssh.privilege.oneWay": { + "message": "Você pode desativar isto, mas ativar novamente requer {actor}." + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "Você pode ativar isto, mas desativar novamente requer {actor}." + }, + "settings.ssh.privilege.authorizePending": { + "message": "Aguardando a autorização…" } } diff --git a/client/ui/i18n/locales/ru/common.json b/client/ui/i18n/locales/ru/common.json index a876387f4..3881a3783 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": "Общие" }, @@ -764,7 +764,19 @@ "message": "Анонимизировать конфиденциальную информацию" }, "settings.troubleshooting.anonymize.help": { - "message": "Скрывает публичные IP-адреса и сторонние (не относящиеся к NetBird) домены в журналах." + "message": "Скрывает IP-адреса, домены и другие конфиденциальные значения." + }, + "settings.troubleshooting.anonymize.info": { + "message": "Режим «По умолчанию» оставляет внутренние IPv4-адреса и имена пиров читаемыми для поддержки. Режим «Строгий» дополнительно анонимизирует частные (RFC 1918), CGNAT и link-local IP-адреса, имена пиров и публичные ключи WireGuard. Повторяющиеся значения заменяются одним и тем же заполнителем, поэтому пиры остаются различимыми. Используйте режим «Строгий», когда передаёте отладочный пакет за пределы вашей организации." + }, + "settings.troubleshooting.anonymize.none": { + "message": "Нет" + }, + "settings.troubleshooting.anonymize.default": { + "message": "По умолчанию" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "Строгий" }, "settings.troubleshooting.systemInfo.label": { "message": "Включить сведения о системе" @@ -1338,5 +1350,29 @@ }, "error.unknown": { "message": "Не удалось выполнить операцию." + }, + "error.elevation_unavailable": { + "message": "NetBird не смог запросить у этой системы нужные права. Выполните вместо этого:" + }, + "error.elevation_failed": { + "message": "Не удалось применить изменение с повышенными правами. Выполните вместо этого:" + }, + "settings.ssh.privilege.actorRoot": { + "message": "права root" + }, + "settings.ssh.privilege.actorAdministrator": { + "message": "права администратора" + }, + "settings.ssh.privilege.hint": { + "message": "Требуются {actor}. Выполните вместо этого:" + }, + "settings.ssh.privilege.oneWay": { + "message": "Отключить можно, но чтобы включить снова, нужны {actor}." + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "Включить можно, но чтобы отключить снова, нужны {actor}." + }, + "settings.ssh.privilege.authorizePending": { + "message": "Ожидание авторизации…" } } diff --git a/client/ui/i18n/locales/zh-CN/common.json b/client/ui/i18n/locales/zh-CN/common.json index 542b2b045..b1ff3370d 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": "常规" }, @@ -764,7 +764,19 @@ "message": "匿名化敏感信息" }, "settings.troubleshooting.anonymize.help": { - "message": "从日志中隐藏公共 IP 地址和非 NetBird 域名。" + "message": "隐藏 IP 地址、域名和其他敏感值。" + }, + "settings.troubleshooting.anonymize.info": { + "message": "默认级别保留内部 IPv4 地址和对等节点名称,便于支持人员阅读。严格级别还会匿名化私有 (RFC 1918)、CGNAT 和链路本地 IP 地址、对等节点名称以及 WireGuard 公钥。相同的值会映射到相同的占位符,因此对等节点仍可区分。向组织外部分享调试包时请使用严格级别。" + }, + "settings.troubleshooting.anonymize.none": { + "message": "无" + }, + "settings.troubleshooting.anonymize.default": { + "message": "默认" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "严格" }, "settings.troubleshooting.systemInfo.label": { "message": "包含系统信息" @@ -1338,5 +1350,29 @@ }, "error.unknown": { "message": "操作失败。" + }, + "error.elevation_unavailable": { + "message": "NetBird 无法向此系统请求所需的权限。请改为运行:" + }, + "error.elevation_failed": { + "message": "即使使用提升的权限也无法应用此更改。请改为运行:" + }, + "settings.ssh.privilege.actorRoot": { + "message": "root 权限" + }, + "settings.ssh.privilege.actorAdministrator": { + "message": "管理员权限" + }, + "settings.ssh.privilege.hint": { + "message": "需要{actor}。请改为运行:" + }, + "settings.ssh.privilege.oneWay": { + "message": "您可以关闭此项,但重新开启需要{actor}。" + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "您可以开启此项,但再次关闭需要{actor}。" + }, + "settings.ssh.privilege.authorizePending": { + "message": "正在等待授权…" } } diff --git a/client/ui/main.go b/client/ui/main.go index 5f740f5ec..5652efcf2 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) @@ -139,13 +148,11 @@ func main() { prefStore: prefStore, }) - window := newMainWindow(app, prefStore) - - // Settings is created eagerly (hidden) so the first gear click paints - // instantly and React keeps per-tab state across reopens. The other - // auxiliary windows stay lazy + destroy-on-close so Wails's macOS - // dock-reopen handler can't resurrect them. - windowManager := services.NewWindowManager(app, window, bundle, prefStore, iconWindow) + windowManager := services.NewWindowManager(app, nil, bundle, prefStore, iconWindow) + windowManager.SetMainFactory(func(startURL string) *application.WebviewWindow { + return newMainWindow(app, prefStore, windowManager, startURL) + }) + registerDockReopenHook(app, windowManager) // Minimal WMs (XEmbed-tray path) neither center small windows nor restore // position across hide -> show, dropping them top-left. Gate Go-side // re-centering on that environment; nil leaves placement to the WM on full @@ -168,7 +175,7 @@ func main() { // RegisterStatusNotifierItem hits a watcher we control. startStatusNotifierWatcher() - tray = NewTray(app, window, TrayServices{ + tray = NewTray(app, nil, TrayServices{ Connection: connection, Settings: settings, Profiles: profiles, @@ -279,10 +286,12 @@ func newApplication(onSecondInstance func()) *application.App { ActivationPolicy: application.ActivationPolicyAccessory, }, Linux: application.LinuxOptions{ - ProgramName: "netbird", + ProgramName: "netbird", + DisableQuitOnLastWindowClosed: true, }, Windows: application.WindowsOptions{ - WndProcInterceptor: endSessionInterceptor(), + WndProcInterceptor: endSessionInterceptor(), + DisableQuitOnLastWindowClosed: true, }, SingleInstance: &application.SingleInstanceOptions{ UniqueID: "io.netbird.ui", @@ -338,9 +347,7 @@ func registerServices(app *application.App, conn *Conn, s registeredServices) { app.RegisterService(application.NewService(s.compat)) } -// newMainWindow creates the hidden main window, sized to the user's last view -// mode, and installs the hide-on-close and macOS dock-reopen hooks. -func newMainWindow(app *application.App, prefStore *preferences.Store) *application.WebviewWindow { +func newMainWindow(app *application.App, prefStore *preferences.Store, wm *services.WindowManager, startURL string) *application.WebviewWindow { // Width matches the last view mode so Advanced-mode users don't see the // window pop from 380px to 900px on launch. Height is mode-agnostic. initialWidth := 380 @@ -357,7 +364,7 @@ func newMainWindow(app *application.App, prefStore *preferences.Store) *applicat InitialPosition: application.WindowCentered, Hidden: true, BackgroundColour: services.WindowBackgroundColour, - URL: "/", + URL: startURL, DisableResize: true, MinimiseButtonState: application.ButtonHidden, MaximiseButtonState: application.ButtonHidden, @@ -368,29 +375,25 @@ func newMainWindow(app *application.App, prefStore *preferences.Store) *applicat }, }) - // Hide instead of quit on close; "really quit" is reached via tray -> Quit. - window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) { + window.RegisterHook(events.Common.WindowClosing, func(_ *application.WindowEvent) { if services.ShuttingDown() { return } - e.Cancel() - window.Hide() + wm.ForgetMain() }) - // On macOS, Wails' default applicationShouldHandleReopen handler Show()s - // every hidden window on dock-icon click, resurrecting hide-on-close - // surfaces like Settings. Cancel it in a hook (hooks run before listeners) - // and show only the main window. No-op elsewhere — the event never fires. - if runtime.GOOS == "darwin" { - app.Event.RegisterApplicationEventHook(events.Mac.ApplicationShouldHandleReopen, func(e *application.ApplicationEvent) { - e.Cancel() - if e.Context().HasVisibleWindows() { - return - } - window.Show() - window.Focus() - }) - } - return window } + +func registerDockReopenHook(app *application.App, wm *services.WindowManager) { + if runtime.GOOS != "darwin" { + return + } + app.Event.RegisterApplicationEventHook(events.Mac.ApplicationShouldHandleReopen, func(e *application.ApplicationEvent) { + if e.Context().HasVisibleWindows() { + return + } + e.Cancel() + wm.ShowMain() + }) +} 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..f425428b5 --- /dev/null +++ b/client/ui/services/guarded.go @@ -0,0 +1,231 @@ +//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" +) + +// 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 the SSH server +// running at another management identity hands the decision of who may open a +// shell on it to whoever runs that server, which is the same power as enabling +// the SSH 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"` +} + +// 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..d20b390cd --- /dev/null +++ b/client/ui/services/oneshot.go @@ -0,0 +1,239 @@ +//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 }), +} + +// 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 74e6f913c..91aac0467 100644 --- a/client/ui/services/settings.go +++ b/client/ui/services/settings.go @@ -44,12 +44,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"` @@ -128,6 +135,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 { @@ -135,6 +145,7 @@ func NewSettings(conn DaemonConn, translator ErrorTranslator, prefs LanguagePref conn: conn, classifier: errorClassifier{translator: translator, prefs: prefs}, daemonAddr: daemonAddr, + elevator: osElevator{}, } } @@ -180,10 +191,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, @@ -215,19 +226,92 @@ 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), + } + // 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 the one -// users hit in the SSH 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 the ones users hit in the SSH 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 @@ -237,26 +321,40 @@ 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"), } } +// 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 { @@ -289,6 +387,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/client/ui/services/windowmanager.go b/client/ui/services/windowmanager.go index 5f7aaa7bd..94dba6038 100644 --- a/client/ui/services/windowmanager.go +++ b/client/ui/services/windowmanager.go @@ -8,6 +8,7 @@ import ( "sync" "time" + log "github.com/sirupsen/logrus" "github.com/wailsapp/wails/v3/pkg/application" "github.com/wailsapp/wails/v3/pkg/events" @@ -29,6 +30,12 @@ const EventBrowserLoginCancel = "browser-login:cancel" // EventSettingsOpen tells the mounted settings window which tab to show. const EventSettingsOpen = "netbird:settings:open" +const EventWindowPainted = "netbird:window-painted" + +const paintedFallback = 2 * time.Second + +const headlessTeardownDelay = 2 * time.Second + var WindowBackgroundColour = application.NewRGB(24, 26, 29) // bg-nb-gray-950 // WindowHeight is shared by the main and Settings windows. @@ -94,9 +101,6 @@ func DialogWindowOptions(name, title, url string, linuxIcon []byte) application. } } -// WindowManager owns the auxiliary windows (main is created in main.go). Settings is created -// eagerly and hidden on close to keep React state; the rest are created on open, destroyed on -// close, so the macOS dock-reopen handler finds no hidden window to resurrect. type WindowManager struct { app *application.App mainWindow *application.WebviewWindow @@ -112,15 +116,35 @@ type WindowManager struct { // hiddenForLogin holds windows hidden while the BrowserLogin popup is open, restored on close. hiddenForLogin []application.Window mu sync.Mutex + createMu sync.Mutex + newMain func(startURL string) *application.WebviewWindow + ready map[uint]bool + showPending map[uint]bool + pendingTab map[uint]string + pendingEmits map[uint][]string + fallbackTimers map[uint]*time.Timer + headlessMain bool + headlessTimer *time.Timer // recenterOnShow is set only on the minimal-WM/XEmbed path, where the WM neither centers nor // restores position; nil on full desktops so re-centering can't fight a user-moved window. recenterOnShow func() bool } -// NewWindowManager wires the manager to the main app; translator/prefs may be nil (tests). The -// Settings window is created here (hidden) so the first OpenSettings is instant. func NewWindowManager(app *application.App, mainWindow *application.WebviewWindow, translator ErrorTranslator, prefs LanguagePreference, linuxIcon []byte) *WindowManager { - s := &WindowManager{app: app, mainWindow: mainWindow, translator: translator, prefs: prefs, linuxIcon: linuxIcon} + s := &WindowManager{ + app: app, + mainWindow: mainWindow, + translator: translator, + prefs: prefs, + linuxIcon: linuxIcon, + ready: map[uint]bool{}, + showPending: map[uint]bool{}, + pendingTab: map[uint]string{}, + pendingEmits: map[uint][]string{}, + fallbackTimers: map[uint]*time.Timer{}, + } + s.watchPainted() + s.watchTriggerLogin() // Re-title live windows on language flip. Wired internally so the binding generator // doesn't try to expose the interface param. if sub, ok := prefs.(LanguageSubscriber); ok && sub != nil { @@ -136,7 +160,11 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo } }() } - s.settings = app.Window.NewWithOptions(application.WebviewWindowOptions{ + return s +} + +func (s *WindowManager) newSettingsWindow() *application.WebviewWindow { + w := s.app.Window.NewWithOptions(application.WebviewWindowOptions{ Name: "settings", Title: s.title("window.title.settings"), Width: 900, @@ -150,18 +178,15 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo URL: "/#/settings", Mac: AppleMacOSAppearanceOptions(), Windows: MicrosoftWindowsAppearanceOptions(), - Linux: LinuxAppearanceOptions(linuxIcon), + Linux: LinuxAppearanceOptions(s.linuxIcon), }) - // Hide (not destroy) on close to keep React state; reset to General for a flash-free reopen. - s.settings.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) { - if ShuttingDown() { - return - } - e.Cancel() - s.app.Event.Emit(EventSettingsOpen, "general") - s.settings.Hide() + w.RegisterHook(events.Common.WindowClosing, func(_ *application.WindowEvent) { + s.mu.Lock() + s.settings = nil + s.forgetWindowLocked(w) + s.mu.Unlock() }) - return s + return w } // OpenSettings shows the settings window on tab (empty → General), switching tab via @@ -171,11 +196,20 @@ func (s *WindowManager) OpenSettings(tab string) { if target == "" { target = "general" } - s.app.Event.Emit(EventSettingsOpen, target) - s.settings.Show() - s.settings.Focus() - // Re-center (minimal-WM only; see centerWhenReady). - s.centerWhenReady(s.settings) + + w, _ := s.ensureWindow(&s.settings, s.newSettingsWindow) + + s.mu.Lock() + ready := s.ready[w.ID()] + if !ready { + s.pendingTab[w.ID()] = target + } + s.mu.Unlock() + + if ready { + s.app.Event.Emit(EventSettingsOpen, target) + } + s.showWhenReady(w) } // OpenBrowserLogin shows the SSO popup, creating it on first use. @@ -258,11 +292,15 @@ func (s *WindowManager) CloseBrowserLogin() { } // OpenSessionExpiration shows the countdown warning on the cursor's display; seconds seeds -// the countdown. Singleton, destroyed on close. -func (s *WindowManager) OpenSessionExpiration(seconds int) { +// the countdown and deadlineUnixMilli (0 when unknown) is the absolute deadline the dialog +// compares renewal snapshots against. Singleton, destroyed on close. +func (s *WindowManager) OpenSessionExpiration(seconds int, deadlineUnixMilli int64) { s.mu.Lock() defer s.mu.Unlock() startURL := "/#/dialog/session-expiration?seconds=" + strconv.Itoa(seconds) + if deadlineUnixMilli > 0 { + startURL += "&deadline=" + strconv.FormatInt(deadlineUnixMilli, 10) + } if s.sessionExpiration == nil { opts := DialogWindowOptions("session-expiration", s.title("window.title.sessionExpiration"), startURL, s.linuxIcon) opts.Screen = s.getScreenBasedOnCursorPosition() @@ -440,13 +478,295 @@ func (s *WindowManager) OpenMain() { // ShowMain brings the main window forward (re-centering on minimal WMs). The single entry // point every surface (tray, SIGUSR1, welcome) should use so centering applies uniformly. func (s *WindowManager) ShowMain() { - if s.mainWindow == nil { + s.showWhenReady(s.MainWindow()) +} + +// ShowMainAndEmit brings the main window forward and emits event once its frontend is ready. +func (s *WindowManager) ShowMainAndEmit(event string) { + w := s.MainWindow() + if w == nil { return } - s.mainWindow.Show() - s.mainWindow.Focus() - // Re-center (minimal-WM only; see centerWhenReady). - s.centerWhenReady(s.mainWindow) + + id := w.ID() + s.mu.Lock() + ready := s.ready[id] + if !ready { + s.pendingEmits[id] = append(s.pendingEmits[id], event) + } + s.mu.Unlock() + + s.showWhenReady(w) + if ready { + s.app.Event.Emit(event) + } +} + +func (s *WindowManager) MainWindow() *application.WebviewWindow { + w, _ := s.ensureMain("/") + return w +} + +func (s *WindowManager) ensureMain(startURL string) (*application.WebviewWindow, bool) { + s.mu.Lock() + factory := s.newMain + s.mu.Unlock() + if factory == nil { + return s.ensureWindow(&s.mainWindow, nil) + } + return s.ensureWindow(&s.mainWindow, func() *application.WebviewWindow { + return factory(startURL) + }) +} + +func (s *WindowManager) ensureWindow(slot **application.WebviewWindow, factory func() *application.WebviewWindow) (*application.WebviewWindow, bool) { + s.createMu.Lock() + defer s.createMu.Unlock() + + s.mu.Lock() + w := *slot + s.mu.Unlock() + if w != nil || factory == nil { + return w, false + } + + w = factory() + s.armReady(w) + + s.mu.Lock() + *slot = w + s.mu.Unlock() + return w, true +} + +func (s *WindowManager) armReady(w *application.WebviewWindow) { + if w == nil { + return + } + w.RegisterHook(events.Common.WindowRuntimeReady, func(_ *application.WindowEvent) { + timer := time.AfterFunc(paintedFallback, func() { + log.Warnf("window %q never reported a first render, showing it anyway", w.Name()) + s.markReady(w) + }) + s.mu.Lock() + s.fallbackTimers[w.ID()] = timer + s.mu.Unlock() + }) +} + +func (s *WindowManager) watchPainted() { + s.app.Event.On(EventWindowPainted, func(e *application.CustomEvent) { + if w := s.windowByName(e.Sender); w != nil { + s.markReady(w) + } + }) +} + +func (s *WindowManager) watchTriggerLogin() { + s.app.Event.On(EventTriggerLogin, func(_ *application.CustomEvent) { + s.mu.Lock() + if s.headlessTimer != nil { + s.headlessTimer.Stop() + s.headlessTimer = nil + } + w := s.mainWindow + ready := w != nil && s.ready[w.ID()] + s.mu.Unlock() + if ready { + return + } + + w, created := s.ensureMain("/") + if w == nil { + return + } + + s.mu.Lock() + if created { + s.headlessMain = true + } + pending := !s.ready[w.ID()] + if pending { + s.pendingEmits[w.ID()] = append(s.pendingEmits[w.ID()], EventTriggerLogin) + } + s.mu.Unlock() + + if !pending { + s.app.Event.Emit(EventTriggerLogin) + } + }) + + s.app.Event.On(EventBrowserLoginCancel, func(_ *application.CustomEvent) { + s.scheduleHeadlessTeardown() + }) + + s.app.Event.On(EventStatusSnapshot, func(e *application.CustomEvent) { + st, ok := e.Data.(Status) + if !ok { + return + } + switch st.Status { + case StatusConnected, StatusLoginFailed, StatusDaemonUnavailable: + s.scheduleHeadlessTeardown() + } + }) +} + +func (s *WindowManager) scheduleHeadlessTeardown() { + s.mu.Lock() + defer s.mu.Unlock() + if !s.headlessMain || s.mainWindow == nil { + return + } + if s.headlessTimer != nil { + s.headlessTimer.Stop() + } + s.headlessTimer = time.AfterFunc(headlessTeardownDelay, s.closeHeadlessMain) +} + +func (s *WindowManager) closeHeadlessMain() { + s.mu.Lock() + w := s.mainWindow + headless := s.headlessMain + s.headlessTimer = nil + s.mu.Unlock() + if !headless || w == nil { + return + } + w.Close() +} + +func (s *WindowManager) forgetWindowLocked(w *application.WebviewWindow) { + if w == nil { + return + } + + id := w.ID() + if timer := s.fallbackTimers[id]; timer != nil { + timer.Stop() + } + delete(s.fallbackTimers, id) + delete(s.ready, id) + delete(s.showPending, id) + delete(s.pendingTab, id) + delete(s.pendingEmits, id) + + kept := s.hiddenForLogin[:0] + for _, hidden := range s.hiddenForLogin { + if hidden != application.Window(w) { + kept = append(kept, hidden) + } + } + s.hiddenForLogin = kept +} + +func (s *WindowManager) windowByName(name string) *application.WebviewWindow { + s.mu.Lock() + defer s.mu.Unlock() + switch name { + case "main": + return s.mainWindow + case "settings": + return s.settings + default: + return nil + } +} + +func (s *WindowManager) markReady(w *application.WebviewWindow) { + id := w.ID() + s.mu.Lock() + already := s.ready[id] + s.ready[id] = true + wanted := s.showPending[id] + tab, hasTab := s.pendingTab[id] + emits := s.pendingEmits[id] + if timer := s.fallbackTimers[id]; timer != nil { + timer.Stop() + delete(s.fallbackTimers, id) + } + delete(s.showPending, id) + delete(s.pendingTab, id) + delete(s.pendingEmits, id) + s.mu.Unlock() + + if already { + return + } + + if hasTab { + s.app.Event.Emit(EventSettingsOpen, tab) + } + + if wanted { + s.showNow(w) + } + + for _, event := range emits { + s.app.Event.Emit(event) + } +} + +func (s *WindowManager) showWhenReady(w *application.WebviewWindow) { + if w == nil { + return + } + + id := w.ID() + s.mu.Lock() + ready := s.ready[id] + if !ready { + s.showPending[id] = true + } + s.mu.Unlock() + + if ready { + s.showNow(w) + } +} + +func (s *WindowManager) showNow(w *application.WebviewWindow) { + s.mu.Lock() + if w == s.mainWindow { + s.headlessMain = false + if s.headlessTimer != nil { + s.headlessTimer.Stop() + s.headlessTimer = nil + } + } + s.mu.Unlock() + w.Show() + w.Focus() + s.centerWhenReady(w) +} + +func (s *WindowManager) ShowMainAt(url string) { + w, created := s.ensureMain(url) + if w == nil { + return + } + if !created { + w.SetURL(url) + } + s.showWhenReady(w) +} + +func (s *WindowManager) SetMainFactory(f func(startURL string) *application.WebviewWindow) { + s.mu.Lock() + defer s.mu.Unlock() + s.newMain = f +} + +func (s *WindowManager) ForgetMain() { + s.mu.Lock() + defer s.mu.Unlock() + s.forgetWindowLocked(s.mainWindow) + s.mainWindow = nil + s.headlessMain = false + if s.headlessTimer != nil { + s.headlessTimer.Stop() + s.headlessTimer = nil + } } // SetRecenterOnShow installs the recenterOnShow predicate (see the field). diff --git a/client/ui/tray.go b/client/ui/tray.go index 148dd50b3..c392a0b62 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -174,7 +174,7 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe // in the right locale — no English flash then re-paint. loc: svc.Localizer, } - t.updater = newTrayUpdater(app, window, svc.Update, svc.Notifier, t.loc, func() { t.applyIcon() }, func() { t.relayoutMenu() }) + t.updater = newTrayUpdater(app, t.showMainAt, svc.Update, svc.Notifier, t.loc, func() { t.applyIcon() }, func() { t.relayoutMenu() }) t.tray = app.SystemTray.New() // Seed panel-theme detection before the first paint so the initial icon // matches the panel's light/dark scheme (Linux only). @@ -241,9 +241,6 @@ func (t *Tray) ShowWindow() { w.Focus() return } - if t.window == nil { - return - } // Route through WindowManager so the main window is centered on first // show — minimal WMs (fluxbox, the XEmbed tray path) otherwise drop it in // the top-left corner. @@ -251,8 +248,49 @@ func (t *Tray) ShowWindow() { t.svc.WindowManager.ShowMain() return } - t.window.Show() - t.window.Focus() + if w := t.mainWindow(); w != nil { + w.Show() + w.Focus() + } +} + +func (t *Tray) mainWindow() *application.WebviewWindow { + if t.svc.WindowManager == nil { + return t.window + } + return t.svc.WindowManager.MainWindow() +} + +func (t *Tray) showMainAt(url string) { + if t.svc.WindowManager != nil { + t.svc.WindowManager.ShowMainAt(url) + return + } + if w := t.mainWindow(); w != nil { + w.SetURL(url) + w.Show() + w.Focus() + } +} + +func (t *Tray) showMain() { + if t.svc.WindowManager != nil { + t.svc.WindowManager.ShowMain() + return + } + if w := t.mainWindow(); w != nil { + w.Show() + w.Focus() + } +} + +func (t *Tray) showMainAndEmit(event string) { + if t.svc.WindowManager != nil { + t.svc.WindowManager.ShowMainAndEmit(event) + return + } + t.showMain() + t.app.Event.Emit(event) } // applyLanguage re-renders every translated surface in the Localizer's current @@ -479,7 +517,8 @@ func (t *Tray) handleConnect(upItem *application.MenuItem) { // NeedsLogin/SessionExpired/LoginFailed won't honor a plain Up RPC — they // need the Login → WaitSSOLogin → Up sequence. Emit EventTriggerLogin so // the React startLogin() (which owns the BrowserLogin popup) drives it; - // the hidden main webview is alive and subscribed, so only the popup shows. + // the WindowManager materialises a hidden main webview when none is live, + // so only the popup shows. t.statusMu.Lock() needsLogin := strings.EqualFold(t.lastStatus, services.StatusNeedsLogin) || strings.EqualFold(t.lastStatus, services.StatusSessionExpired) || diff --git a/client/ui/tray_events.go b/client/ui/tray_events.go index 12da68a5c..f23b5d715 100644 --- a/client/ui/tray_events.go +++ b/client/ui/tray_events.go @@ -76,7 +76,8 @@ func (t *Tray) onSystemEvent(ev *application.CustomEvent) { if se.Metadata != nil && se.Metadata[authsession.MetaWarning] == "true" { if se.Metadata[authsession.MetaFinal] == "true" { - t.openSessionExpiration() + deadline, _ := authsession.ParseExpiresAt(se.Metadata[authsession.MetaExpiresAt]) + t.openSessionExpiration(deadline) return } t.notifySessionWarning( diff --git a/client/ui/tray_session.go b/client/ui/tray_session.go index f25419894..91c38be08 100644 --- a/client/ui/tray_session.go +++ b/client/ui/tray_session.go @@ -30,10 +30,7 @@ const ( // handleSessionExpired notifies and brings the window forward so the user can reconnect. func (t *Tray) handleSessionExpired() { t.notify(t.loc.T("notify.sessionExpired.title"), t.loc.T("notify.sessionExpired.body"), notifyIDSessionExpired) - if t.window != nil { - t.window.Show() - t.window.Focus() - } + t.showMain() } // applySessionExpiry refreshes the cached SSO deadline and reports whether it changed. @@ -287,12 +284,23 @@ func (t *Tray) dismissSessionWarning() { } // openSessionExpiration fires the fallback dialog when the earlier warning notification wasn't dismissed. -// Idempotent on the WindowManager side. -func (t *Tray) openSessionExpiration() { +// deadline is the absolute expiry from the warning event's metadata; when zero (older daemon, +// malformed metadata) the cached status-snapshot deadline fills in. Idempotent on the +// WindowManager side. +func (t *Tray) openSessionExpiration(deadline time.Time) { if t.svc.WindowManager == nil { return } - t.svc.WindowManager.OpenSessionExpiration(finalWarningCountdownSeconds) + if deadline.IsZero() { + t.sessionMu.Lock() + deadline = t.sessionExpiresAt + t.sessionMu.Unlock() + } + var deadlineMs int64 + if !deadline.IsZero() { + deadlineMs = deadline.UnixMilli() + } + t.svc.WindowManager.OpenSessionExpiration(finalWarningCountdownSeconds, deadlineMs) } // openSessionExtendFlow opens the SessionExpiration window seeded with the cached deadline's remaining time, @@ -307,11 +315,11 @@ func (t *Tray) openSessionExtendFlow() { } seconds := int(time.Until(deadline).Seconds()) if seconds <= 0 { - t.app.Event.Emit(services.EventTriggerLogin) + t.showMainAndEmit(services.EventTriggerLogin) return } if t.svc.WindowManager == nil { return } - t.svc.WindowManager.OpenSessionExpiration(seconds) + t.svc.WindowManager.OpenSessionExpiration(seconds, deadline.UnixMilli()) } diff --git a/client/ui/tray_update.go b/client/ui/tray_update.go index 27037eccb..3ce1f9600 100644 --- a/client/ui/tray_update.go +++ b/client/ui/tray_update.go @@ -4,6 +4,7 @@ package main import ( "context" + neturl "net/url" "sync" "time" @@ -19,7 +20,7 @@ import ( // trayUpdater owns the tray UI that reacts to auto-update. Composed inside Tray. type trayUpdater struct { app *application.App - window *application.WebviewWindow + showMainAt func(url string) update *services.Update notifier *Notifier loc *Localizer @@ -36,10 +37,10 @@ type trayUpdater struct { progressWindowOpen bool } -func newTrayUpdater(app *application.App, window *application.WebviewWindow, update *services.Update, notifier *Notifier, loc *Localizer, onIconChange func(), onMenuChange func()) *trayUpdater { +func newTrayUpdater(app *application.App, showMainAt func(url string), update *services.Update, notifier *Notifier, loc *Localizer, onIconChange func(), onMenuChange func()) *trayUpdater { u := &trayUpdater{ app: app, - window: window, + showMainAt: showMainAt, update: update, notifier: notifier, loc: loc, @@ -185,14 +186,12 @@ func (u *trayUpdater) sendUpdateNotification(st updater.State) { // openProgressWindow points the main window at the /update progress page and // brings it forward. func (u *trayUpdater) openProgressWindow(version string) { - if u.window == nil { + if u.showMainAt == nil { return } url := "/#/update" if version != "" { - url += "?version=" + version + url += "?version=" + neturl.QueryEscape(version) } - u.window.SetURL(url) - u.window.Show() - u.window.Focus() + u.showMainAt(url) } diff --git a/client/wasm/cmd/main.go b/client/wasm/cmd/main.go index 4683f4033..260a528f0 100644 --- a/client/wasm/cmd/main.go +++ b/client/wasm/cmd/main.go @@ -56,8 +56,7 @@ func startClient(ctx context.Context, nbClient *netbird.Client) error { // parseClientOptions extracts NetBird options from JavaScript object func parseClientOptions(jsOptions js.Value) (netbird.Options, error) { options := netbird.Options{ - DeviceName: "dashboard-client", - LogLevel: defaultLogLevel, + LogLevel: defaultLogLevel, } if jwtToken := jsOptions.Get("jwtToken"); !jwtToken.IsNull() && !jwtToken.IsUndefined() { @@ -87,13 +86,41 @@ func parseClientOptions(jsOptions js.Value) (netbird.Options, error) { options.DeviceName = deviceName.String() } - if disableIPv6 := jsOptions.Get("disableIPv6"); !disableIPv6.IsNull() && !disableIPv6.IsUndefined() { - options.DisableIPv6 = disableIPv6.Bool() + disableIPv6, err := boolOption(jsOptions, "disableIPv6") + if err != nil { + return options, err + } + if disableIPv6 != nil { + options.DisableIPv6 = *disableIPv6 } + // The caller decides whether this client uses lazy connections; left unset it + // defers to the management feature flag. A short-lived, interactive caller + // turns it off so its sessions reach the few peers their grant covers eagerly, + // instead of the first request waiting for the connection to be established. + lazyConnectionEnabled, err := boolOption(jsOptions, "lazyConnectionEnabled") + if err != nil { + return options, err + } + options.LazyConnectionEnabled = lazyConnectionEnabled + return options, nil } +// boolOption reads a boolean option, returning nil when the caller left it out. +// js.Value.Bool panics on any other type, so a wrong type is reported instead. +func boolOption(jsOptions js.Value, name string) (*bool, error) { + v := jsOptions.Get(name) + if v.IsNull() || v.IsUndefined() { + return nil, nil + } + if v.Type() != js.TypeBoolean { + return nil, fmt.Errorf("option %s must be a boolean, got %s", name, v.Type()) + } + b := v.Bool() + return &b, nil +} + // createStartMethod creates the start method for the client func createStartMethod(client *netbird.Client) js.Func { return js.FuncOf(func(this js.Value, args []js.Value) any { diff --git a/client/wasm/cmd/main_test.go b/client/wasm/cmd/main_test.go new file mode 100644 index 000000000..3ec5a8f6a --- /dev/null +++ b/client/wasm/cmd/main_test.go @@ -0,0 +1,64 @@ +//go:build js + +package main + +import ( + "syscall/js" + "testing" +) + +// TestParseClientOptionsBooleans covers the boolean options against the value +// kinds a JS caller can pass: js.Value.Bool panics on anything but a boolean, +// so a wrong type has to be rejected before it reaches the client. +func TestParseClientOptionsBooleans(t *testing.T) { + t.Run("unset leaves the lazy override empty", func(t *testing.T) { + options, err := parseClientOptions(js.Global().Get("Object").New()) + if err != nil { + t.Fatalf("parse options: %v", err) + } + if options.LazyConnectionEnabled != nil { + t.Errorf("lazy override should stay unset, got %v", *options.LazyConnectionEnabled) + } + if options.DisableIPv6 { + t.Error("disableIPv6 should default to false") + } + }) + + t.Run("null defers to the management flag", func(t *testing.T) { + jsOptions := js.Global().Get("Object").New() + jsOptions.Set("lazyConnectionEnabled", js.Null()) + options, err := parseClientOptions(jsOptions) + if err != nil { + t.Fatalf("parse options: %v", err) + } + if options.LazyConnectionEnabled != nil { + t.Errorf("lazy override should stay unset, got %v", *options.LazyConnectionEnabled) + } + }) + + t.Run("booleans are carried through", func(t *testing.T) { + jsOptions := js.Global().Get("Object").New() + jsOptions.Set("lazyConnectionEnabled", false) + jsOptions.Set("disableIPv6", true) + options, err := parseClientOptions(jsOptions) + if err != nil { + t.Fatalf("parse options: %v", err) + } + if options.LazyConnectionEnabled == nil || *options.LazyConnectionEnabled { + t.Errorf("lazy override should be false, got %v", options.LazyConnectionEnabled) + } + if !options.DisableIPv6 { + t.Error("disableIPv6 should be true") + } + }) + + t.Run("a non-boolean is rejected", func(t *testing.T) { + for _, value := range []any{"true", 1, js.Global().Get("Object").New()} { + jsOptions := js.Global().Get("Object").New() + jsOptions.Set("lazyConnectionEnabled", value) + if _, err := parseClientOptions(jsOptions); err == nil { + t.Errorf("value %v should be rejected", value) + } + } + }) +} diff --git a/e2e/agentnetwork/custom_pricing_test.go b/e2e/agentnetwork/custom_pricing_test.go index e3750258f..90e198d3d 100644 --- a/e2e/agentnetwork/custom_pricing_test.go +++ b/e2e/agentnetwork/custom_pricing_test.go @@ -23,9 +23,10 @@ import ( // model the client asks for. The proxy prices off the REQUEST model, not the // upstream response model, so a made-up model id billed at operator rates lets // these tests assert exact costs without a real vendor key. +// Sourced from the harness so the counts can't drift from the mock's config. const ( - vllmPromptTokens = 11 - vllmCompletionTokens = 2 + vllmPromptTokens = harness.VLLMChatInputTokens + vllmCompletionTokens = harness.VLLMChatOutputTokens ) // pricedEnv is a connected single-provider agent-network deployment pointed at @@ -162,30 +163,90 @@ func chatOnce(t *testing.T, ctx context.Context, env pricedEnv, model, sessionID break } } - time.Sleep(5 * time.Second) + if !waitBeforeRetry(ctx, 5*time.Second) { + break + } } require.Equal(t, 200, code, "chat for %s must return 200; body: %s\n=== proxy logs ===\n%s", model, body, env.proxy.Logs(context.Background())) return body } -// findAccessLogBySession polls the access-log page for the row carrying sessionID. -func findAccessLogBySession(t *testing.T, ctx context.Context, sessionID string) api.AgentNetworkAccessLog { - t.Helper() - var row api.AgentNetworkAccessLog - require.Eventually(t, func() bool { - logs, lerr := srv.ListAccessLogs(ctx) - if lerr != nil { - return false - } - for _, r := range logs.Data { - if r.SessionId != nil && *r.SessionId == sessionID { - row = r - return true +// accessLogIngestWindow is how long a single request's access-log row is given +// to appear before the caller gives up on it. +// accessLogIngestWindow bounds how long a row may take to appear after its +// request returned. The proxy streams each entry to management with a 10s send +// timeout of its own, so a request whose send hits one full timeout and is +// retried has not yet missed anything real — 30s left barely three send +// attempts of headroom and lost the race on a loaded runner. +const accessLogIngestWindow = 60 * time.Second + +// accessLogPollInterval is how long the lookup waits between pages. Ingest is +// asynchronous, so the row lands somewhere inside the window rather than on +// any particular poll. +const accessLogPollInterval = 2 * time.Second + +// lookupAccessLogBySession polls the access-log page for the row carrying +// sessionID and reports whether it arrived within the window. It never fails +// the test: callers that can recover — by firing a fresh request under a new +// session — need to see the miss rather than die on it. +func lookupAccessLogBySession(ctx context.Context, sessionID string, within time.Duration) (api.AgentNetworkAccessLog, bool) { + deadline := time.Now().Add(within) + for { + // Each poll is bounded by what is left of the window rather than by the + // caller's context: a single stalled request would otherwise hold the + // loop open long past the ingest window it is meant to enforce, and the + // caller would read the delay as a missing row. + if logs, lerr := listAccessLogsBy(ctx, deadline); lerr == nil { + for _, r := range logs.Data { + if r.SessionId != nil && *r.SessionId == sessionID { + return r, true + } } } - return false - }, 30*time.Second, 2*time.Second, "session id %q must be recorded in an access-log row", sessionID) + // The wait is bounded by the window as well, so the answer arrives when + // the caller's budget runs out rather than a poll interval later: a + // full interval slept past the deadline reports "no row" up to two + // seconds late, which reads as a slower lookup than the one asked for. + wait := time.Until(deadline) + if wait > accessLogPollInterval { + wait = accessLogPollInterval + } + if wait <= 0 { + return api.AgentNetworkAccessLog{}, false + } + timer := time.NewTimer(wait) + select { + case <-ctx.Done(): + timer.Stop() + return api.AgentNetworkAccessLog{}, false + case <-timer.C: + } + // Checked after the wait rather than before the request: a poll issued + // past the deadline carries no budget and would fail on arrival. + if !time.Now().Before(deadline) { + return api.AgentNetworkAccessLog{}, false + } + } +} + +// listAccessLogsBy fetches one access-log page under a context that expires at +// deadline, so no single call can outlive the window its caller is polling +// within. The parent's cancellation still applies: the child inherits it. +func listAccessLogsBy(ctx context.Context, deadline time.Time) (api.AgentNetworkAccessLogsResponse, error) { + reqCtx, cancel := context.WithDeadline(ctx, deadline) + defer cancel() + return srv.ListAccessLogs(reqCtx) +} + +// findAccessLogBySession polls the access-log page for the row carrying +// sessionID, failing the test if it never lands. Use it for a request whose row +// must exist; where a missing row is a recoverable race, use +// lookupAccessLogBySession and retry. +func findAccessLogBySession(t *testing.T, ctx context.Context, sessionID string) api.AgentNetworkAccessLog { + t.Helper() + row, ok := lookupAccessLogBySession(ctx, sessionID, accessLogIngestWindow) + require.True(t, ok, "session id %q must be recorded in an access-log row", sessionID) return row } @@ -319,6 +380,11 @@ func TestPriceChangeUpdatesRecordedCost(t *testing.T) { outRateA = 0.020 inRateB = 0.050 // 5x / 4x the original, so a repriced row is unmistakable outRateB = 0.080 + // Per-attempt ingest wait, shorter than the default so a request that + // produces no row costs one retry rather than most of the budget, and an + // overall deadline long enough to hold several attempts. + repriceIngestWindow = 20 * time.Second + repriceDeadline = 180 * time.Second ) env := provisionPricedProvider(t, ctx, "reprice", []api.AgentNetworkProviderModel{ @@ -353,27 +419,61 @@ func TestPriceChangeUpdatesRecordedCost(t *testing.T) { // reading its cost, so an un-ingested row is never mistaken for "still rate A". // The expected new input cost is unmistakably higher than rate A, so a // lingering old-rate row can't satisfy the check. + // + // Every way an iteration can come up short — the request failing, its row not + // landing, or the row still carrying rate A — is a symptom of the same + // in-flight rebuild, so each one retries under a fresh session rather than + // ending the test. Only the outer deadline is fatal. wantInputB := float64(vllmPromptTokens) / 1000 * inRateB var repriced api.AgentNetworkAccessLog var lastSession string - deadline := time.Now().Add(90 * time.Second) + // The cost last read, kept separately: repriced is the zero value on every + // path that gives up, so reporting its cost would say "$0.000000" whether + // the rows were still at rate A or no row was ever read. + var lastCost float64 + var sawRow bool + deadline := time.Now().Add(repriceDeadline) + // Everything inside the loop runs under the deadline rather than the + // test's own context. An attempt started just before it would otherwise + // run well past it: the chat container is capped at 90s of its own and the + // row lookup at another 20s, so the loop could report a repricing failure + // nearly two minutes after the window it was given had closed. + repriceCtx, cancelReprice := context.WithDeadline(ctx, deadline) + defer cancelReprice() for time.Now().Before(deadline) { lastSession = fmt.Sprintf("e2e-session-reprice-b-%d", time.Now().UnixNano()) - code, _, cerr := env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireChat, customModel, "Reply with exactly: pong", lastSession) + code, _, cerr := env.client.Chat(repriceCtx, env.endpoint, env.proxyIP, harness.WireChat, customModel, "Reply with exactly: pong", lastSession) if cerr != nil || code != 200 { - time.Sleep(5 * time.Second) + if !waitBeforeRetry(repriceCtx, 5*time.Second) { + break + } + continue + } + row, ok := lookupAccessLogBySession(repriceCtx, lastSession, repriceIngestWindow) + if !ok { + // No row for this request. The proxy now publishes a rebuilt chain + // before the route that reaches it, so a request can no longer be + // served unattributed mid-update; this retry covers the ingest + // window alone. Fire another one under a fresh session. + t.Logf("no access-log row for session %q within %s; retrying under a fresh session", lastSession, repriceIngestWindow) continue } - row := findAccessLogBySession(t, ctx, lastSession) if inDelta(row.InputCostUsd, wantInputB, 1e-6) { repriced = row break } // Still priced at the old rate — the push hasn't landed yet; retry. - time.Sleep(5 * time.Second) + lastCost, sawRow = row.InputCostUsd, true + if !waitBeforeRetry(repriceCtx, 5*time.Second) { + break + } } - require.NotEmpty(t, repriced.Id, "a request after the price change must be priced at the new rate B; last input_cost_usd=$%.6f, wanted $%.6f\n=== proxy logs ===\n%s", - repriced.InputCostUsd, wantInputB, env.proxy.Logs(context.Background())) + lastSeen := "no row was ever read" + if sawRow { + lastSeen = fmt.Sprintf("last input_cost_usd=$%.6f", lastCost) + } + require.NotEmpty(t, repriced.Id, "a request after the price change must be priced at the new rate B; %s, wanted $%.6f\n=== proxy logs ===\n%s", + lastSeen, wantInputB, env.proxy.Logs(context.Background())) assertOpenAICostAtRates(t, repriced, inRateB, outRateB) verifyUsageRowForSession(t, lastSession, inRateB, outRateB) @@ -630,3 +730,47 @@ func inDelta(a, b, tol float64) bool { } return d <= tol } + +// TestCustomDatedModelKeepsItsOwnPrice covers the review fix that anchored the +// release-date fallback to Claude ids. Pricing looks every model up through +// that helper, so while it matched a bare trailing date any operator id ending +// in eight digits inherited the rate of its undated sibling — a silent +// mis-bill on models NetBird knows nothing about. +func TestCustomDatedModelKeepsItsOwnPrice(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + const ( + baseModel = "internal-llm" + datedModel = "internal-llm-20250101" + baseIn = 0.010 + baseOut = 0.020 + // An order of magnitude apart, so a row billed at the wrong entry is + // unmistakable rather than a rounding argument. + datedIn = 0.100 + datedOut = 0.200 + ) + + env := provisionPricedProvider(t, ctx, "customdated", []api.AgentNetworkProviderModel{ + {Id: baseModel, InputPer1k: baseIn, OutputPer1k: baseOut}, + {Id: datedModel, InputPer1k: datedIn, OutputPer1k: datedOut}, + }) + + t.Run("the undated id bills at its own rate", func(t *testing.T) { + session := fmt.Sprintf("e2e-session-customdated-base-%d", time.Now().UnixNano()) + chatOnce(t, ctx, env, baseModel, session) + assertOpenAICostAtRates(t, findAccessLogBySession(t, ctx, session), baseIn, baseOut) + }) + + t.Run("the dated id keeps its own rate", func(t *testing.T) { + session := fmt.Sprintf("e2e-session-customdated-dated-%d", time.Now().UnixNano()) + chatOnce(t, ctx, env, datedModel, session) + row := findAccessLogBySession(t, ctx, session) + assertOpenAICostAtRates(t, row, datedIn, datedOut) + + // Spelled out because it is the regression: inheriting the sibling's + // rate would bill this request at a tenth of its price. + assert.Greater(t, row.InputCostUsd, float64(vllmPromptTokens)/1000*baseIn*2, + "a custom dated id must not inherit the undated entry's rate") + }) +} diff --git a/e2e/agentnetwork/discovery_live_test.go b/e2e/agentnetwork/discovery_live_test.go new file mode 100644 index 000000000..22c9f31c2 --- /dev/null +++ b/e2e/agentnetwork/discovery_live_test.go @@ -0,0 +1,447 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "encoding/json" + "os" + "sort" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + sharedllm "github.com/netbirdio/netbird/shared/llm" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// TestLiveModelDiscovery drives model discovery against the REAL vendor +// endpoints — OpenAI, Anthropic, Bedrock and Vertex — rather than the mock. +// +// The mock upstream proves the filter's mechanics: it advertises ids we chose, +// so a listing narrowing to the ones we authorised is arithmetic we already +// controlled both sides of. What it cannot prove is that the filter survives +// contact with a real catalogue — ids we never enumerated, dated builds whose +// suffix the vendor picks, surfaces that answer a listing request with +// something other than a listing. That is what this covers, and it is the part +// a QA engineer would otherwise have to walk through by hand. +// +// One proxy serves every case. Each provider gets its own group, policy and +// client, because a model-less request matches exactly ONE route +// (matchModelless): with two providers authorised for the same caller, the +// listing would go to whichever won the tiebreak and the other would go +// untested. Group-scoping the caller makes each provider the only candidate +// for its own client. +func TestLiveModelDiscovery(t *testing.T) { + cases := liveDiscoveryCases() + if len(cases) == 0 { + t.Skip("no provider keys set; source ~/.llm-keys to run live model discovery") + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + + t.Logf("[discovery] live matrix: %s", strings.Join(caseNames(cases), ", ")) + + // Provision every provider, group and policy before the proxy starts: the + // proxy takes a configuration snapshot at connect time and does not + // reconcile provider changes made afterwards. + keys := make(map[string]string, len(cases)) + for i := range cases { + keys[cases[i].name] = provisionLiveDiscovery(t, ctx, &cases[i]) + } + + endpoint, firstIP, firstClient, px := connectClient(t, ctx, "disc-live", keys[cases[0].name]) + clients := map[string]*harness.Client{cases[0].name: firstClient} + ips := map[string]string{cases[0].name: firstIP} + for _, tc := range cases[1:] { + cl := joinClient(t, ctx, px, endpoint, keys[tc.name]) + ip, err := cl.ResolveProxyIP(ctx, endpoint) + require.NoError(t, err, "resolve endpoint from the %s client", tc.name) + clients[tc.name] = cl + ips[tc.name] = ip + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + runLiveDiscoveryCase(t, ctx, tc, clients[tc.name], endpoint, ips[tc.name]) + }) + } +} + +// discoveryOutcome is what a discovery request must produce end to end. The +// three are genuinely different contracts, not degrees of success: only the +// first puts a bounded listing in front of the caller. +type discoveryOutcome int + +const ( + // outcomeFiltered: the proxy routes the request and bounds the response to + // what the caller may use. + outcomeFiltered discoveryOutcome = iota + // outcomeDenied: no provider of this shape can serve the surface, so the + // proxy refuses rather than rewriting the request onto an upstream that + // would 404 it. The caller gets a NetBird error, not a vendor one. + outcomeDenied + // outcomeUpstreamNoListing: the proxy routes the request to the configured + // upstream, and the vendor does not implement the endpoint there. Proxy + // side correct, product side a dead end — see the Bedrock case. + outcomeUpstreamNoListing +) + +// liveDiscoveryCase is one provider's discovery surface and what the proxy +// must make of it. +type liveDiscoveryCase struct { + name string + catalogID string + upstream string + apiKey string + + // path is the discovery endpoint the client calls. Not every surface uses + // /v1/models: Bedrock lists inference profiles instead. + path string + // headers the vendor requires on a bare GET (Anthropic versions its API + // through a header, and rejects a request without one). + headers []string + + // models the provider record enumerates. Empty models a gateway record, + // which enumerates nothing and claims everything. + models []string + // allowlist, when non-empty, is a guardrail narrowing the policy below the + // provider's own enumeration — the second of the two bounds discovery + // applies, and the only one a provider record alone cannot demonstrate. + allowlist []string + + // outcome is what this surface must produce end to end. + outcome discoveryOutcome + + // permitted is every id allowed to survive filtering, in the form the + // provider record registers it. A surviving id counts as permitted when it + // matches one of these outright or after Anthropic date-normalisation. + permitted []string + // wantHidden are ids the upstream is known to advertise and the bound must + // remove. Only set where we enumerate the model ourselves, so the + // expectation cannot rot when a vendor changes its catalogue. + wantHidden []string +} + +// liveDiscoveryCases builds the matrix from whichever provider credentials are +// present, mirroring availableProviders' env-var gating so a partial key set +// still yields partial coverage. +func liveDiscoveryCases() []liveDiscoveryCase { + var cases []liveDiscoveryCase + + // OpenAI enumerates TWO real models and the policy permits one. That is + // the only case here where both bounds are observable at once: the + // upstream advertises dozens of ids, the provider record cuts them to two, + // and the guardrail cuts those to one. + if k := os.Getenv("OPENAI_TOKEN"); k != "" { + cases = append(cases, liveDiscoveryCase{ + name: "openai", catalogID: "openai_api", upstream: "https://api.openai.com", apiKey: k, + path: "/v1/models", + models: []string{"gpt-4o-mini", "gpt-4o"}, + allowlist: []string{"gpt-4o-mini"}, + outcome: outcomeFiltered, + permitted: []string{"gpt-4o-mini"}, + wantHidden: []string{"gpt-4o"}, + }) + } + + // Anthropic is the surface Claude Code actually calls. Its listing returns + // DATED build ids (claude-haiku-4-5-20251001) while the provider record + // registers the undated id, so this is the case that proves the filter's + // date-normalisation against ids the vendor chose rather than ids we wrote. + if k := os.Getenv("ANTHROPIC_TOKEN"); k != "" { + cases = append(cases, liveDiscoveryCase{ + name: "anthropic", catalogID: "anthropic_api", upstream: "https://api.anthropic.com", apiKey: k, + path: "/v1/models", + headers: []string{"anthropic-version: 2023-06-01"}, + models: []string{"claude-haiku-4-5"}, + outcome: outcomeFiltered, + permitted: []string{"claude-haiku-4-5"}, + }) + } + + // Bedrock lists inference profiles, not models: matchModelless routes + // /inference-profiles to a Bedrock route and refuses /v1/models for one. + // + // The listing is served by the CONTROL PLANE (bedrock.), not the + // runtime host a provider record must point at for InvokeModel — the + // runtime host answers . The router now sends + // the listing, and only the listing, to the control plane, so this case + // asserts a real filtered listing rather than the 404 it used to get. + // + // The mock upstream cannot show any of this: it answers + // /inference-profiles on the same listener as everything else, so a + // mock-based test passes whichever host the request went to. + if k := os.Getenv("AWS_BEARER_TOKEN_BEDROCK"); k != "" { + region := os.Getenv("AWS_REGION") + if region == "" { + region = "eu-central-1" + } + model := os.Getenv("AWS_BEDROCK_MODEL") + if model == "" { + model = "global.anthropic.claude-sonnet-4-6" + } + cases = append(cases, liveDiscoveryCase{ + name: "bedrock", catalogID: "bedrock_api", + upstream: "https://bedrock-runtime." + region + ".amazonaws.com", apiKey: k, + path: "/inference-profiles", + // Registered verbatim, as an operator would copy it from AWS: the + // region prefix is what makes the id invocable, and the listing + // returns ids in exactly this form. + models: []string{model}, + outcome: outcomeFiltered, + permitted: []string{model}, + }) + } + + // Vertex carries the model in the rawPredict path and serves no listing + // endpoint at all, so the proxy must refuse discovery rather than rewrite + // it onto an upstream that would 404. + if sa := os.Getenv("GOOGLE_VERTEX_SA_BASE64"); sa != "" { + if project := os.Getenv("GOOGLE_VERTEX_PROJECT"); project != "" { + region := os.Getenv("GOOGLE_VERTEX_REGION") + if region == "" { + region = "global" + } + host := "aiplatform.googleapis.com" + if region != "global" { + host = region + "-aiplatform.googleapis.com" + } + cases = append(cases, liveDiscoveryCase{ + name: "vertex", catalogID: "vertex_ai_api", upstream: "https://" + host, + apiKey: "keyfile::" + sa, + path: "/v1/models", + outcome: outcomeDenied, + }) + } + } + + return cases +} + +// provisionLiveDiscovery creates the group, provider, optional guardrail and +// policy for one case, and returns the setup key a client joins that group +// with. Scoping each provider to its own group is what keeps it the only +// candidate for its own client's model-less request. +func provisionLiveDiscovery(t *testing.T, ctx context.Context, tc *liveDiscoveryCase) string { + t.Helper() + + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-disc-live-" + tc.name}) + require.NoError(t, err, "create group for %s", tc.name) + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-disc-live-" + tc.name, + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grp.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key for %s", tc.name) + require.NotEmpty(t, sk.Key, "setup key plaintext for %s", tc.name) + + req := api.AgentNetworkProviderRequest{ + Name: "e2e-disc-live-" + tc.name, + ProviderId: tc.catalogID, + UpstreamUrl: tc.upstream, + ApiKey: &tc.apiKey, + Enabled: ptr(true), + } + if len(tc.models) > 0 { + models := make([]api.AgentNetworkProviderModel, 0, len(tc.models)) + for _, id := range tc.models { + models = append(models, api.AgentNetworkProviderModel{Id: id, InputPer1k: 0.001, OutputPer1k: 0.002}) + } + req.Models = &models + } + prov, err := srv.CreateProvider(ctx, req) + require.NoError(t, err, "create provider %s", tc.name) + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + polReq := api.AgentNetworkPolicyRequest{ + Name: "e2e-disc-live-" + tc.name, + Enabled: ptr(true), + SourceGroups: []string{grp.Id}, + DestinationProviderIds: []string{prov.Id}, + } + if len(tc.allowlist) > 0 { + var gr api.AgentNetworkGuardrailRequest + gr.Name = "e2e-disc-live-" + tc.name + gr.Checks.ModelAllowlist.Enabled = true + gr.Checks.ModelAllowlist.Models = tc.allowlist + g, gerr := srv.CreateGuardrail(ctx, gr) + require.NoError(t, gerr, "create guardrail for %s", tc.name) + t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), g.Id) }) + polReq.GuardrailIds = &[]string{g.Id} + } + pol, err := srv.CreatePolicy(ctx, polReq) + require.NoError(t, err, "create policy for %s", tc.name) + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + return sk.Key +} + +// runLiveDiscoveryCase issues the discovery request and reports everything the +// vendor said before asserting on any of it. The log is the point on the first +// run: a live catalogue is the one input we do not control, so a failure has to +// arrive with the response that caused it rather than just a count. +func runLiveDiscoveryCase(t *testing.T, ctx context.Context, tc liveDiscoveryCase, cl *harness.Client, endpoint, proxyIP string) { + t.Helper() + + // A single request is enough for the two non-listing outcomes, and retrying + // them would burn the retry window waiting for a status that is never + // coming. + if tc.outcome != outcomeFiltered { + code, body, err := cl.Get(ctx, endpoint, proxyIP, tc.path, tc.headers) + require.NoError(t, err, "request must reach the proxy") + t.Logf("[discovery] %s GET %s -> %d; body: %s", tc.name, tc.path, code, truncate(body, 2000)) + assert.NotEqual(t, 200, code, + "%s serves no bounded listing, so a 200 here would mean the caller was handed a picker nothing narrows; body: %s", + tc.name, truncate(body, 2000)) + + // Which side refused is the whole distinction between these two + // outcomes, and a NetBird error is the thing that tells them apart: the + // middleware chain stamps its own name on anything it generates. + if tc.outcome == outcomeDenied { + assert.True(t, isProxyError(body), + "%s serves no listing endpoint at all, so the proxy must refuse the request itself rather than forward it to an upstream that would answer for us; body: %s", + tc.name, truncate(body, 2000)) + return + } + assert.False(t, isProxyError(body), + "%s discovery must be routed to the configured upstream and refused by the vendor, not blocked by the proxy; body: %s", + tc.name, truncate(body, 2000)) + return + } + + code, body := callUntil(t, func() (int, string, error) { + return cl.Get(ctx, endpoint, proxyIP, tc.path, tc.headers) + }, 200) + // Status only, not the body. A Bedrock listing embeds inference-profile + // ARNs carrying the 12-digit AWS account id, and these job logs are + // readable by anyone who can see the run. The ids line below is the finding + // anyway. The failure paths below are the same log: a listing that fails to + // arrive is an AWS refusal naming the resource it refused, and that name is + // an ARN carrying the same account id. + t.Logf("[discovery] %s GET %s -> %d", tc.name, tc.path, code) + require.Equal(t, 200, code, "%s discovery must be served; response was %s", tc.name, bodyShape(body)) + + ids, ok := listingIDs(body) + require.Truef(t, ok, + "%s answered discovery with something other than a {\"data\":[{\"id\":…}]} listing, which the filter forwards untouched — the caller would get an unbounded picker; response was %s", + tc.name, bodyShape(body)) + sort.Strings(ids) + t.Logf("[discovery] %s: %d ids after filtering: %s", tc.name, len(ids), strings.Join(ids, ", ")) + + require.NotEmpty(t, ids, "%s filtered the listing down to nothing; the caller would see an empty picker", tc.name) + + permitted := make(map[string]struct{}, len(tc.permitted)*2) + for _, id := range tc.permitted { + permitted[id] = struct{}{} + permitted[sharedllm.NormalizeAnthropicModel(id)] = struct{}{} + } + for _, id := range ids { + _, direct := permitted[id] + _, dated := permitted[sharedllm.NormalizeAnthropicModel(id)] + // Bedrock ids carry a region prefix and version suffix the record may + // not repeat; the proxy's filter tries the same forms. + _, bedrock := permitted[sharedllm.NormalizeBedrockModel(id)] + assert.Truef(t, direct || dated || bedrock, + "%s offered %q, which no policy on this route permits — every entry the picker shows must be a request the guardrail would allow", tc.name, id) + } + for _, hidden := range tc.wantHidden { + assert.NotContainsf(t, ids, hidden, + "%s offered %q, which the provider enumerates but the policy does not permit", tc.name, hidden) + } +} + +// isProxyError reports whether a response body was generated by the middleware +// chain rather than forwarded from a vendor. Every chain-generated error names +// the middleware that raised it, which no upstream's error body does — so this +// separates "the proxy refused" from "the proxy routed it and the vendor +// refused", the two failures that otherwise look alike from the client side. +func isProxyError(body string) bool { + return strings.Contains(body, `"middleware":`) +} + +// listingIDs pulls the model ids out of a listing response. ok is false when +// the body is neither envelope the proxy's filter recognises — the two must +// stay in step, or this test reports "not a listing" for a response the proxy +// filtered perfectly well. +func listingIDs(body string) ([]string, bool) { + var doc struct { + // OpenAI's shape, which Anthropic adopted. + Data []struct { + ID string `json:"id"` + } `json:"data"` + // Bedrock returns inference-profile summaries under a key of its own, + // with the id under a field of its own. + Summaries []struct { + ID string `json:"inferenceProfileId"` + } `json:"inferenceProfileSummaries"` + } + if err := json.Unmarshal([]byte(body), &doc); err != nil { + return nil, false + } + switch { + case doc.Data != nil: + ids := make([]string, 0, len(doc.Data)) + for _, entry := range doc.Data { + ids = append(ids, entry.ID) + } + return ids, true + case doc.Summaries != nil: + ids := make([]string, 0, len(doc.Summaries)) + for _, entry := range doc.Summaries { + ids = append(ids, entry.ID) + } + return ids, true + } + return nil, false +} + +func caseNames(cases []liveDiscoveryCase) []string { + names := make([]string, 0, len(cases)) + for _, c := range cases { + names = append(names, c.name) + } + return names +} + +// bodyShape describes a response without quoting any of it: its size and the +// top-level keys it arrived under. That is what a discovery failure is +// diagnosed from — which envelope the vendor answered with — and it is all +// that may go in a message rendered into a public job log, because the values +// underneath can carry an ARN and its account id. +func bodyShape(body string) string { + var doc map[string]json.RawMessage + if err := json.Unmarshal([]byte(body), &doc); err != nil { + return strconv.Itoa(len(body)) + " bytes, not a JSON object" + } + keys := make([]string, 0, len(doc)) + for key := range doc { + keys = append(keys, key) + } + sort.Strings(keys) + if len(keys) == 0 { + return strconv.Itoa(len(body)) + " bytes, an empty JSON object" + } + return strconv.Itoa(len(body)) + " bytes, keyed by: " + strings.Join(keys, ", ") +} + +// truncate bounds a logged response body. A live catalogue can run to tens of +// kilobytes, and the useful part is the front. +func truncate(s string, limit int) string { + if len(s) <= limit { + return s + } + return s[:limit] + "… (" + strconv.Itoa(len(s)-limit) + " more bytes)" +} diff --git a/e2e/agentnetwork/discovery_multipolicy_test.go b/e2e/agentnetwork/discovery_multipolicy_test.go new file mode 100644 index 000000000..447c1314c --- /dev/null +++ b/e2e/agentnetwork/discovery_multipolicy_test.go @@ -0,0 +1,170 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// TestDiscoveryBoundToCallersPolicies covers a model listing on a provider two +// teams reach under different allowlists. +// +// Bounding the listing by the provider's enumerated models alone is not enough +// once more than one policy is in play: the caller would be offered every model +// any team may use, and each one outside their own policy is a request the +// guardrail refuses a moment later — the empty-or-wrong picker this endpoint +// exists to avoid, just moved one level up. +// +// The client joins the main group only. Both models are enumerated by the same +// provider and both are advertised by the upstream, so a listing that leaked +// the other team's model would visibly contain it. +func TestDiscoveryBoundToCallersPolicies(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + grpMain, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-disc-mp-main"}) + require.NoError(t, err, "create main group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grpMain.Id) }) + + grpOther, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-disc-mp-other"}) + require.NoError(t, err, "create other group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grpOther.Id) }) + + ephemeral := false + mkKey := func(name, groupID string) string { + sk, kerr := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: name, + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{groupID}, + Ephemeral: &ephemeral, + }) + require.NoError(t, kerr, "mint setup key %s", name) + require.NotEmpty(t, sk.Key, "setup key plaintext") + return sk.Key + } + // One client per group. The second is what makes the first assertion mean + // something: without a client that DOES see the other team's model, its + // absence from the main client's listing could equally be a policy that + // never propagated. + keyMain := mkKey("e2e-disc-mp-main-client", grpMain.Id) + keyOther := mkKey("e2e-disc-mp-other-client", grpOther.Id) + + // One provider enumerating both models the upstream advertises, so the + // listing is narrowed by policy rather than by what the provider serves. + staticKey := "static-e2e-token" + prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "e2e-disc-mp", + ProviderId: "openai_api", + UpstreamUrl: vllm.URL, + ApiKey: &staticKey, + Enabled: ptr(true), + Models: &[]api.AgentNetworkProviderModel{ + {Id: harness.VLLMModel, InputPer1k: 0.001, OutputPer1k: 0.001}, + {Id: harness.VLLMUnlistedModel, InputPer1k: 0.001, OutputPer1k: 0.001}, + }, + }) + require.NoError(t, err, "create provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + mkGuardrail := func(name, model string) api.AgentNetworkGuardrail { + var gr api.AgentNetworkGuardrailRequest + gr.Name = name + gr.Checks.ModelAllowlist.Enabled = true + gr.Checks.ModelAllowlist.Models = []string{model} + g, gerr := srv.CreateGuardrail(ctx, gr) + require.NoError(t, gerr, "create guardrail %s", name) + t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), g.Id) }) + return g + } + gMain := mkGuardrail("e2e-disc-mp-main", harness.VLLMModel) + gOther := mkGuardrail("e2e-disc-mp-other", harness.VLLMUnlistedModel) + + enabled := true + polMain, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-disc-mp-main", + Enabled: &enabled, + SourceGroups: []string{grpMain.Id}, + DestinationProviderIds: []string{prov.Id}, + GuardrailIds: &[]string{gMain.Id}, + }) + require.NoError(t, err, "create main policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polMain.Id) }) + + // The other team's policy, on the same provider, permitting the model the + // client must never be offered. + polOther, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-disc-mp-other", + Enabled: &enabled, + SourceGroups: []string{grpOther.Id}, + DestinationProviderIds: []string{prov.Id}, + GuardrailIds: &[]string{gOther.Id}, + }) + require.NoError(t, err, "create other policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polOther.Id) }) + + endpoint, proxyIP, clMain, px := connectClient(t, ctx, "disc-mp", keyMain) + clOther := joinClient(t, ctx, px, endpoint, keyOther) + + listing := func(t *testing.T, cl *harness.Client, ip string) string { + t.Helper() + code, body := callUntil(t, func() (int, string, error) { + return cl.Get(ctx, endpoint, ip, "/v1/models?limit=1000", nil) + }, 200) + require.Equal(t, 200, code, "discovery must be served; body: %s", body) + return body + } + + otherIP, err := clOther.ResolveProxyIP(ctx, endpoint) + require.NoError(t, err, "resolve endpoint from the other client") + + // The other team's client first: seeing its own model proves polOther is + // live, so the main client's listing is narrowed by policy scoping rather + // than by the other policy having failed to apply at all. + otherBody := listing(t, clOther, otherIP) + assert.Contains(t, otherBody, harness.VLLMUnlistedModel, + "the other group's policy must be in force, or this test proves nothing") + assert.NotContains(t, otherBody, harness.VLLMModel, + "and it must not be offered the main group's model either — isolation runs both ways") + + mainBody := listing(t, clMain, proxyIP) + assert.Contains(t, mainBody, harness.VLLMModel, + "the model the caller's own policy permits must reach the picker") + assert.NotContains(t, mainBody, harness.VLLMUnlistedModel, + "a model only another group's policy permits must not be offered to this caller") +} + +// joinClient starts a second tunnel client against an already-running proxy, so +// a test can drive the same endpoint as two different group memberships without +// paying for a second proxy. +func joinClient(t *testing.T, ctx context.Context, px *harness.Proxy, endpoint, setupKey string) *harness.Client { + t.Helper() + + cl, err := harness.StartClient(ctx, srv, setupKey) + require.NoError(t, err, "start second client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "second client must connect to management") + _, err = cl.ResolveProxyIP(ctx, endpoint) + require.NoError(t, err, "second client could not resolve the endpoint") + // Guarded rather than passed straight to require: px.Logs pulls the whole + // proxy container log, which is only worth fetching when the wait failed. + if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { + require.NoError(t, err, "second client did not see the proxy peer\n=== proxy logs ===\n%s", + px.Logs(context.Background())) + } + return cl +} diff --git a/e2e/agentnetwork/gateway_protocol_test.go b/e2e/agentnetwork/gateway_protocol_test.go new file mode 100644 index 000000000..c21a4fc53 --- /dev/null +++ b/e2e/agentnetwork/gateway_protocol_test.go @@ -0,0 +1,455 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// Models each catalog surface is registered with in the matrix below. They +// differ per provider so the router's choice is unambiguous: a request that +// lands on the wrong provider record fails the surface assertion instead of +// passing by coincidence. +const ( + matrixAnthropicModel = "claude-sonnet-5" + matrixBedrockModel = "anthropic.claude-sonnet-5" + // matrixBedrockPathModel is what a Bedrock SDK client puts in the URL: a + // cross-region inference profile with a release date and version suffix. + // The proxy must normalise it back to matrixBedrockModel to route and price. + matrixBedrockPathModel = "us.anthropic.claude-sonnet-5-20250101-v1:0" + // matrixVertexModel differs from the Anthropic record's model on purpose: + // a shared id would leave two routes claiming it and make which one serves + // /v1/messages depend on declaration order. + matrixVertexModel = "claude-haiku-4-5" + matrixVertexProject = "e2e-project" + matrixVertexRegion = "us-east5" +) + +// gatewayEnv is a connected client plus a set of provider records, all pointed +// at one mock upstream, so several wire shapes can be driven over a single +// tunnel. +type gatewayEnv struct { + endpoint string + proxyIP string + client *harness.Client + proxy *harness.Proxy + vllm *harness.VLLM + // providerIDs maps the catalog id to the created provider record id. + providerIDs map[string]string +} + +// provisionGatewayMatrix brings up one mock upstream and one provider record +// per catalog surface, all authorised for the same group by a single policy. +// Sharing one proxy and client keeps the wire-shape cases to one tunnel setup; +// each case still creates its own session id so its access-log row is findable. +func provisionGatewayMatrix(t *testing.T, ctx context.Context) gatewayEnv { + t.Helper() + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-gw-matrix"}) + require.NoError(t, err, "create group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-gw-matrix-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grp.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + // The mock ignores auth, so a dummy credential satisfies each catalog + // entry's auth template. Vertex is the exception: its api_key is a GCP + // service-account keyfile the proxy mints an OAuth token from, and a dummy + // one cannot mint. That is deliberate — the Vertex case below asserts on + // routing, which happens before the token mint. + dummyKey := "sk-gw-e2e" + dummyKeyfile := "keyfile::" + "e2e-not-a-real-service-account-key" + + specs := []struct { + name string + catalogID string + apiKey string + models []api.AgentNetworkProviderModel + }{ + { + name: "openai", catalogID: "openai_api", apiKey: dummyKey, + models: []api.AgentNetworkProviderModel{{Id: harness.VLLMModel, InputPer1k: 0.001, OutputPer1k: 0.002}}, + }, + { + name: "anthropic", catalogID: "anthropic_api", apiKey: dummyKey, + models: []api.AgentNetworkProviderModel{{Id: matrixAnthropicModel, InputPer1k: 0.003, OutputPer1k: 0.015}}, + }, + { + name: "bedrock", catalogID: "bedrock_api", apiKey: dummyKey, + models: []api.AgentNetworkProviderModel{{Id: matrixBedrockModel, InputPer1k: 0.003, OutputPer1k: 0.015}}, + }, + { + name: "vertex", catalogID: "vertex_ai_api", apiKey: dummyKeyfile, + models: []api.AgentNetworkProviderModel{{Id: matrixVertexModel, InputPer1k: 0.001, OutputPer1k: 0.005}}, + }, + } + + providerIDs := make(map[string]string, len(specs)) + ids := make([]string, 0, len(specs)) + for _, spec := range specs { + key := spec.apiKey + models := spec.models + prov, perr := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "e2e-gw-" + spec.name, + ProviderId: spec.catalogID, + UpstreamUrl: vllm.URL, + ApiKey: &key, + Enabled: ptr(true), + Models: &models, + }) + require.NoError(t, perr, "create %s provider", spec.name) + id := prov.Id + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), id) }) + providerIDs[spec.catalogID] = id + ids = append(ids, id) + } + + // Uncapped token limit: never blocks the handful of tokens driven here, but + // switches on usage metering so consumption and cost land in the row. + enabled := true + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-gw-matrix", + Enabled: &enabled, + SourceGroups: []string{grp.Id}, + DestinationProviderIds: ids, + Limits: &api.AgentNetworkPolicyLimits{ + TokenLimit: api.AgentNetworkPolicyTokenLimit{ + Enabled: true, + GroupCap: 10_000_000, + UserCap: 10_000_000, + WindowSeconds: 60, + }, + }, + }) + require.NoError(t, err, "create policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + endpoint, proxyIP, cl, px := connectClient(t, ctx, "gw-matrix", sk.Key) + return gatewayEnv{ + endpoint: endpoint, + proxyIP: proxyIP, + client: cl, + proxy: px, + vllm: vllm, + providerIDs: providerIDs, + } +} + +// connectClient starts a proxy and a tunnel client for the shared account and +// waits until the client can reach the proxy peer, returning the endpoint and +// the proxy's tunnel IP to pin requests to. +func connectClient(t *testing.T, ctx context.Context, name, setupKey string) (string, string, *harness.Client, *harness.Proxy) { + t.Helper() + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings") + require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-"+name+"-proxy") + require.NoError(t, err, "mint proxy token") + px, err := harness.StartProxy(ctx, srv, proxyToken) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, setupKey) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + // The probe resolves the endpoint and its first packet wakes the lazy proxy + // peer, so WaitProxyPeer then observes it connected. + proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) + require.NoError(t, err, "resolve endpoint to proxy IP") + if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { + t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background())) + } + return settings.Endpoint, proxyIP, cl, px +} + +// callUntil retries an HTTP call through the tunnel until it returns one of the +// wanted statuses or the deadline passes, absorbing the DNS and tunnel jitter +// the first call through a fresh tunnel can hit. The last status and body are +// returned either way so the caller can assert with real detail. +func callUntil(t *testing.T, call func() (int, string, error), want ...int) (int, string) { + t.Helper() + wanted := make(map[int]struct{}, len(want)) + for _, w := range want { + wanted[w] = struct{}{} + } + + var code int + var body string + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + c, b, err := call() + if err == nil { + code, body = c, b + if _, ok := wanted[code]; ok { + return code, body + } + } + time.Sleep(5 * time.Second) + } + return code, body +} + +// TestGatewayProtocolProviderMatrix drives one request per wire shape over a +// single tunnel, with a provider record per catalog surface behind it. It is +// the regression net for the routing and parser-selection changes: each case +// asserts the surface the request was metered under and the token counts that +// surface's own usage block carries, so a request parsed by the wrong provider's +// parser meters zero and fails rather than passing on a coincidence. +func TestGatewayProtocolProviderMatrix(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + + env := provisionGatewayMatrix(t, ctx) + diag := func() string { + return fmt.Sprintf("\n=== upstream logs ===\n%s\n=== proxy logs ===\n%s", + env.vllm.Logs(context.Background()), env.proxy.Logs(context.Background())) + } + + t.Run("openai chat completions", func(t *testing.T) { + session := "e2e-gw-openai" + code, body := callUntil(t, func() (int, string, error) { + return env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireChat, harness.VLLMModel, "ping", session) + }, 200) + require.Equal(t, 200, code, "openai chat must succeed; body: %s%s", body, diag()) + require.Contains(t, body, "chat.completion", "body must be an OpenAI completion; got: %s", body) + + row := findAccessLogBySession(t, ctx, session) + require.NotNil(t, row.Provider) + assert.Equal(t, "openai", *row.Provider, "the OpenAI chat path must meter under the openai surface") + assert.Equal(t, int64(harness.VLLMChatInputTokens), row.InputTokens, "OpenAI usage block must be read") + assert.Equal(t, int64(harness.VLLMChatOutputTokens), row.OutputTokens) + }) + + t.Run("anthropic messages", func(t *testing.T) { + session := "e2e-gw-anthropic" + code, body := callUntil(t, func() (int, string, error) { + return env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireMessages, matrixAnthropicModel, "ping", session) + }, 200) + require.Equal(t, 200, code, "anthropic messages must succeed; body: %s%s", body, diag()) + + row := findAccessLogBySession(t, ctx, session) + require.NotNil(t, row.Provider) + assert.Equal(t, "anthropic", *row.Provider, "the /v1/messages path must meter under the anthropic surface") + // These counts only appear if the Anthropic parser read the response: + // its usage fields are named differently from the OpenAI block. + assert.Equal(t, int64(harness.VLLMMessagesInputTokens), row.InputTokens, + "Anthropic input_tokens must be read; zero here means the wrong parser ran") + assert.Equal(t, int64(harness.VLLMMessagesOutputTokens), row.OutputTokens) + assert.Positive(t, row.CachedInputTokens, "the Anthropic cache-read bucket must be recorded") + assert.Positive(t, row.CostUsd, "a metered request must carry a cost") + require.NotNil(t, row.ResolvedProviderId) + assert.Equal(t, env.providerIDs["anthropic_api"], *row.ResolvedProviderId, + "a vendor-tagged request must not cross to another provider's record") + }) + + t.Run("bedrock invoke normalises the path model", func(t *testing.T) { + session := "e2e-gw-bedrock" + code, body := callUntil(t, func() (int, string, error) { + return env.client.Bedrock(ctx, env.endpoint, env.proxyIP, matrixBedrockPathModel, "ping", session) + }, 200) + require.Equal(t, 200, code, "bedrock invoke must succeed; body: %s%s", body, diag()) + + row := findAccessLogBySession(t, ctx, session) + require.NotNil(t, row.Provider) + assert.Equal(t, "bedrock", *row.Provider, "a native Bedrock path must meter under the bedrock surface") + require.NotNil(t, row.Model) + assert.Equal(t, matrixBedrockModel, *row.Model, + "the inference-profile prefix, release date and version suffix must be normalised away") + assert.Equal(t, int64(harness.VLLMMessagesInputTokens), row.InputTokens) + }) + + t.Run("anthropic token counting", func(t *testing.T) { + code, body := callUntil(t, func() (int, string, error) { + return env.client.PostJSON(ctx, env.endpoint, env.proxyIP, "/v1/messages/count_tokens", + fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":"ping"}]}`, matrixAnthropicModel), + []string{"anthropic-version: 2023-06-01"}) + }, 200) + assert.Equal(t, 200, code, "token counting must route rather than deny; body: %s%s", body, diag()) + }) + + t.Run("bedrock token counting", func(t *testing.T) { + code, body := callUntil(t, func() (int, string, error) { + return env.client.PostJSON(ctx, env.endpoint, env.proxyIP, + "/model/"+matrixBedrockPathModel+"/count-tokens", + `{"input":{"converse":{"messages":[{"role":"user","content":[{"text":"ping"}]}]}}}`, nil) + }, 200) + assert.Equal(t, 200, code, + "the Bedrock count-tokens action must route; denying it pushes counting onto the billable inference path; body: %s%s", + body, diag()) + }) + + t.Run("vertex token counting reaches its provider", func(t *testing.T) { + // The dummy service-account key cannot mint an OAuth token, so the + // request stops at the upstream credential. Both outcomes render as + // 403, so the deny code is what distinguishes them: upstream_auth_failed + // means the path resolved to the Vertex route and only the credential + // failed, while model_not_routable would mean the method segment was + // swallowed into the model id and no route ever claimed it. + path := fmt.Sprintf("/v1/projects/%s/locations/%s/publishers/anthropic/models/%s/count-tokens:rawPredict", + matrixVertexProject, matrixVertexRegion, matrixVertexModel) + _, body := callUntil(t, func() (int, string, error) { + return env.client.PostJSON(ctx, env.endpoint, env.proxyIP, path, + `{"anthropic_version":"vertex-2023-10-16","messages":[{"role":"user","content":"ping"}]}`, nil) + }, 403) + assert.NotContains(t, body, "model_not_routable", + "the count-tokens method segment must not be parsed as part of the model id; body: %s%s", body, diag()) + assert.Contains(t, body, "llm_policy.upstream_auth_failed", + "the request must reach the Vertex route and fail only at the credential; body: %s%s", body, diag()) + }) + + t.Run("connection warming probe", func(t *testing.T) { + code, body := callUntil(t, func() (int, string, error) { + return env.client.Get(ctx, env.endpoint, env.proxyIP, "/api/hello", nil) + }, 200) + assert.NotEqual(t, 403, code, + "the warm-up probe carries no model and must not be refused as unroutable; body: %s%s", body, diag()) + }) + + t.Run("unknown model denies in the caller's error shape", func(t *testing.T) { + code, body := callUntil(t, func() (int, string, error) { + return env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireMessages, + "claude-not-a-real-model-9", "ping", "e2e-gw-unknown") + }, 403) + require.Equal(t, 403, code, "a model no provider claims must still be refused; body: %s%s", body, diag()) + + // The NetBird fields stay where they were for existing consumers. + assert.Contains(t, body, "llm_policy.model_not_routable", "the deny code must be preserved") + // And the vendor's own envelope rides alongside, so the client can show + // the reason instead of an unexplained API error. + assert.Contains(t, body, `"type":"error"`, "an Anthropic caller must get the Anthropic error envelope") + assert.Contains(t, body, "permission_error", "403 must map to the vendor's permission error type") + }) +} + +// TestModelDiscoveryWithModelAllowlist covers gateway model discovery on an +// account that restricts models, which is the configuration that broke: the +// listing carries no model, and the per-model allowlist fails closed on an +// undetermined one, so discovery denied for exactly the accounts using the +// feature. It also asserts the allowlist still refuses a model outside it, so +// the exemption cannot be read as a way around the gate. +func TestModelDiscoveryWithModelAllowlist(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-gw-discovery"}) + require.NoError(t, err, "create group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-gw-discovery-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grp.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + // One provider enumerating a single model, while the upstream's own listing + // advertises two. The proxy must serve the shorter list. + dummyKey := "sk-discovery-e2e" + prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "e2e-gw-discovery", + ProviderId: "openai_api", + UpstreamUrl: vllm.URL, + ApiKey: &dummyKey, + Enabled: ptr(true), + Models: &[]api.AgentNetworkProviderModel{ + {Id: harness.VLLMModel, InputPer1k: 0.001, OutputPer1k: 0.002}, + }, + }) + require.NoError(t, err, "create provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + // The model allowlist is what makes this a regression test: without a + // guardrail enabled, discovery was never gated in the first place. + var gr api.AgentNetworkGuardrailRequest + gr.Name = "e2e-gw-discovery-allowlist" + gr.Checks.ModelAllowlist.Enabled = true + gr.Checks.ModelAllowlist.Models = []string{harness.VLLMModel} + guard, err := srv.CreateGuardrail(ctx, gr) + require.NoError(t, err, "create guardrail") + t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), guard.Id) }) + + enabled := true + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-gw-discovery", + Enabled: &enabled, + SourceGroups: []string{grp.Id}, + DestinationProviderIds: []string{prov.Id}, + GuardrailIds: &[]string{guard.Id}, + }) + require.NoError(t, err, "create policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + endpoint, proxyIP, cl, px := connectClient(t, ctx, "gw-discovery", sk.Key) + diag := func() string { + return fmt.Sprintf("\n=== upstream logs ===\n%s\n=== proxy logs ===\n%s", + vllm.Logs(context.Background()), px.Logs(context.Background())) + } + + t.Run("listing is served and bounded by policy", func(t *testing.T) { + code, body := callUntil(t, func() (int, string, error) { + return cl.Get(ctx, endpoint, proxyIP, "/v1/models?limit=1000", nil) + }, 200) + require.Equal(t, 200, code, + "discovery must not be refused because the request carries no model; body: %s%s", body, diag()) + + assert.Contains(t, body, harness.VLLMModel, "the authorised model must reach the picker") + assert.NotContains(t, body, harness.VLLMUnlistedModel, + "a model the policy does not authorise must not be offered; body: %s", body) + }) + + t.Run("allowlist still refuses a model outside it", func(t *testing.T) { + code, body := callUntil(t, func() (int, string, error) { + return cl.Chat(ctx, endpoint, proxyIP, harness.WireChat, + harness.VLLMUnlistedModel, "ping", "e2e-gw-discovery-blocked") + }, 403) + require.Equal(t, 403, code, + "exempting model-less endpoints must not exempt inference; body: %s%s", body, diag()) + assert.True(t, + strings.Contains(body, "llm_policy.model_blocked") || strings.Contains(body, "llm_policy.model_not_routable"), + "the refusal must name a model policy code; body: %s", body) + }) + + t.Run("allowlisted model still routes", func(t *testing.T) { + code, body := callUntil(t, func() (int, string, error) { + return cl.Chat(ctx, endpoint, proxyIP, harness.WireChat, + harness.VLLMModel, "ping", "e2e-gw-discovery-allowed") + }, 200) + require.Equal(t, 200, code, "the allowlisted model must still be served; body: %s%s", body, diag()) + }) +} diff --git a/e2e/agentnetwork/gateway_review_test.go b/e2e/agentnetwork/gateway_review_test.go new file mode 100644 index 000000000..556bc4a53 --- /dev/null +++ b/e2e/agentnetwork/gateway_review_test.go @@ -0,0 +1,242 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// The cases in this file cover behaviour that arrived from code review, after +// the gateway-protocol end-to-end tests were written. Each had unit coverage +// only; none needed a new harness capability, which is why they belong here +// rather than on a manual checklist. + +// TestNonInferenceEndpointsAreAuthorised covers the two review findings on the +// endpoints that carry no body: the per-model lookup must be authorised +// against the same allowlist that bounds the listing beside it, and only a read +// method may claim the non-inference exemption that skips the token pre-flight. +func TestNonInferenceEndpointsAreAuthorised(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + + env := provisionDiscoveryProvider(t, ctx) + + t.Run("lookup of an authorised model succeeds", func(t *testing.T) { + code, body := callUntil(t, func() (int, string, error) { + return env.client.Get(ctx, env.endpoint, env.proxyIP, "/v1/models/"+harness.VLLMModel, nil) + }, 200) + assert.Equal(t, 200, code, "an allowlisted model must remain reachable; body: %s", body) + }) + + t.Run("lookup of an unauthorised model is refused", func(t *testing.T) { + code, body, err := env.client.Get(ctx, env.endpoint, env.proxyIP, "/v1/models/"+harness.VLLMUnlistedModel, nil) + require.NoError(t, err, "request must reach the proxy") + assert.Equal(t, 403, code, + "a model the policy does not authorise must not be confirmed by the detail lookup; body: %s", body) + }) + + // A write must not claim the exemption that lets the listing skip the token + // pre-flight. The body names no model on purpose: that is what a request + // probing for the exemption looks like, and it is the case the method gate + // exists to refuse. (A POST that does name a model is a different thing — + // it routes and meters as the inference request it is.) + for _, path := range []string{"/v1/models", "/v1/models/" + harness.VLLMModel, "/api/hello"} { + t.Run("write to "+path+" is refused", func(t *testing.T) { + code, body, err := env.client.PostJSON(ctx, env.endpoint, env.proxyIP, path, + `{"messages":[{"role":"user","content":"hi"}]}`, nil) + require.NoError(t, err, "request must reach the proxy") + assert.NotEqual(t, 200, code, + "a write to a non-inference path must not be served unmetered; body: %s", body) + }) + } + + // A request carrying the sub-agent attribution headers must still be served + // and metered normally. Asserting the ids themselves is not possible yet: + // the parser lifts them onto the request's metadata, but nothing persists + // them, so they have no queryable surface to check against. + t.Run("sub-agent headers do not disturb the request", func(t *testing.T) { + sessionID := fmt.Sprintf("e2e-session-agentid-%d", time.Now().UnixNano()) + code, body, err := env.client.PostJSON(ctx, env.endpoint, env.proxyIP, "/v1/chat/completions", + fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":"Reply with exactly: pong"}]}`, harness.VLLMModel), + []string{ + "x-session-id: " + sessionID, + "x-claude-code-agent-id: agent-child-7", + "x-claude-code-parent-agent-id: agent-root-1", + }) + require.NoError(t, err, "request must reach the proxy") + require.Equal(t, 200, code, "the request must succeed; body: %s", body) + + row := findAccessLogBySession(t, ctx, sessionID) + assert.Positive(t, row.InputTokens, "the request must still be metered normally") + }) +} + +// TestDatedModelIdRouting covers both halves of the dated-id rule that review +// tightened: a dated id still reaches an undated registration, but a route +// pinned to one dated build must never serve a different one. +func TestDatedModelIdRouting(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + + const ( + undated = "claude-sonnet-9" + datedA = "claude-sonnet-9-20250101" + datedB = "claude-sonnet-9-20250202" + ) + + t.Run("a dated id reaches its undated registration", func(t *testing.T) { + env := provisionModelProvider(t, ctx, "dated-undated", "anthropic_api", undated) + + sessionID := fmt.Sprintf("e2e-session-dated-%d", time.Now().UnixNano()) + code, body := callUntil(t, func() (int, string, error) { + return env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireMessages, datedA, "Reply with exactly: pong", sessionID) + }, 200) + require.Equal(t, 200, code, "a pinned release of a registered family must route; body: %s", body) + + row := findAccessLogBySession(t, ctx, sessionID) + assert.Positive(t, row.InputTokens, "the dated request must price at the registered rate, not zero") + }) + + t.Run("a route pinned to one dated build refuses another", func(t *testing.T) { + env := provisionModelProvider(t, ctx, "dated-pinned", "anthropic_api", datedA) + + code, body := callUntil(t, func() (int, string, error) { + return env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireMessages, datedA, "Reply with exactly: pong", "") + }, 200) + require.Equal(t, 200, code, "the exact dated id must still route; body: %s", body) + + code, body, err := env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireMessages, datedB, "Reply with exactly: pong", "") + require.NoError(t, err, "request must reach the proxy") + assert.Equal(t, 403, code, + "a provider pinned to one dated build must not serve another; body: %s", body) + }) +} + +// TestBedrockInferenceProfilesReachTheUpstream covers the startup lookup a +// Bedrock client makes. The proxy forwards it to the configured upstream rather +// than denying it, so what comes back is the upstream's answer — never a +// NetBird policy rejection. +func TestBedrockInferenceProfilesReachTheUpstream(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + + env := provisionModelProvider(t, ctx, "infprofiles", "bedrock_api", "anthropic.claude-sonnet-5") + + code, body := callUntil(t, func() (int, string, error) { + return env.client.Get(ctx, env.endpoint, env.proxyIP, "/inference-profiles", nil) + }, 200) + + assert.Equal(t, 200, code, "the lookup must reach the upstream; body: %s", body) + assert.NotContains(t, body, "llm_policy.", + "the proxy must not answer a control-plane lookup with a policy denial") + assert.Contains(t, body, "inferenceProfileSummaries", + "the upstream's own answer must come back untouched") +} + +// provisionDiscoveryProvider brings up one mock-backed provider enumerating a +// single model, with an allowlist guardrail in effect, plus a connected client. +func provisionDiscoveryProvider(t *testing.T, ctx context.Context) pricedEnv { + t.Helper() + env := provisionModelProvider(t, ctx, "noninference", "openai_api", harness.VLLMModel) + + var gr api.AgentNetworkGuardrailRequest + gr.Name = "e2e-noninference-allowlist-" + fmt.Sprint(time.Now().UnixNano()) + gr.Checks.ModelAllowlist.Enabled = true + gr.Checks.ModelAllowlist.Models = []string{harness.VLLMModel} + guard, err := srv.CreateGuardrail(ctx, gr) + require.NoError(t, err, "create guardrail") + t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), guard.Id) }) + + enabled := true + _, err = srv.UpdatePolicy(ctx, env.policyID, api.AgentNetworkPolicyRequest{ + Name: "e2e-noninference", + Enabled: &enabled, + SourceGroups: []string{env.groupID}, + DestinationProviderIds: []string{env.providerID}, + GuardrailIds: &[]string{guard.Id}, + }) + require.NoError(t, err, "attach guardrail to policy") + return env +} + +// provisionModelProvider brings up the mock, one provider under the given +// catalog id enumerating exactly one model, an authorising policy, and a +// connected proxy + client. +func provisionModelProvider(t *testing.T, ctx context.Context, name, catalogID, model string) pricedEnv { + t.Helper() + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + suffix := strings.ToLower(name) + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-gwr-" + suffix}) + require.NoError(t, err, "create group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-gwr-" + suffix + "-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grp.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + dummyKey := "sk-gwr-e2e" + prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "e2e-gwr-" + suffix, + ProviderId: catalogID, + UpstreamUrl: vllm.URL, + ApiKey: &dummyKey, + Enabled: ptr(true), + Models: &[]api.AgentNetworkProviderModel{ + {Id: model, InputPer1k: 0.001, OutputPer1k: 0.002}, + }, + }) + require.NoError(t, err, "create provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + enabled := true + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-gwr-" + suffix, + Enabled: &enabled, + SourceGroups: []string{grp.Id}, + DestinationProviderIds: []string{prov.Id}, + Limits: &api.AgentNetworkPolicyLimits{ + TokenLimit: api.AgentNetworkPolicyTokenLimit{ + Enabled: true, + GroupCap: 10_000_000, + UserCap: 10_000_000, + WindowSeconds: 60, + }, + }, + }) + require.NoError(t, err, "create policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + endpoint, proxyIP, cl, px := connectClient(t, ctx, "gwr-"+suffix, sk.Key) + return pricedEnv{ + providerID: prov.Id, + groupID: grp.Id, + policyID: pol.Id, + upstream: vllm.URL, + endpoint: endpoint, + proxyIP: proxyIP, + client: cl, + proxy: px, + } +} diff --git a/e2e/agentnetwork/main_test.go b/e2e/agentnetwork/main_test.go index cc366b3fb..687af1d4d 100644 --- a/e2e/agentnetwork/main_test.go +++ b/e2e/agentnetwork/main_test.go @@ -54,3 +54,19 @@ func run(m *testing.M) int { return m.Run() } + +// waitBeforeRetry pauses between attempts of a polling loop and reports +// whether the caller should keep going. A cancelled context ends the loop +// where a plain sleep would keep retrying against it: every call fails +// instantly once ctx is done, so the loop would spend its whole remaining +// window sleeping between failures nobody is waiting for any more. +func waitBeforeRetry(ctx context.Context, d time.Duration) bool { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } +} diff --git a/e2e/agentnetwork/streaming_test.go b/e2e/agentnetwork/streaming_test.go new file mode 100644 index 000000000..a5fa8df3f --- /dev/null +++ b/e2e/agentnetwork/streaming_test.go @@ -0,0 +1,209 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// streamedModel is priced high enough that a mis-metered request is obvious in +// the recorded cost, and named so it cannot collide with another test's route. +const streamedModel = "e2e-streamed-model" + +const ( + streamInRate = 0.010 + streamOutRate = 0.020 + // The cache-read bucket is priced separately from input, so a run that + // folded the two together fails the per-bucket assertions below. + streamCacheReadRate = 0.001 +) + +// TestStreamingResponseMetersInputTokens is the end-to-end guard for the +// metering bug this endpoint's gateway-protocol work fixed. +// +// On a streamed answer the input-token count exists only in the opening +// message_start event; every later frame reports output. A response read with +// the wrong vendor's parser — the shape a gateway record produces when it names +// one API surface and serves another — never looks at that event, so input +// metered as zero and the bulk of the bill silently vanished. Nothing in the +// suite sent stream: true before this test, so the whole branch went unrun. +// +// The provider points at the mock's streaming listener, which answers every +// request as SSE with token counts that differ from the buffered surface. That +// difference is the point: passing these assertions is only possible if the +// stream accumulator ran. +func TestStreamingResponseMetersInputTokens(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + env := provisionStreamingProvider(t, ctx, "anthropic_api") + + sessionID := fmt.Sprintf("e2e-session-stream-%d", time.Now().UnixNano()) + code, body := chatStreamUntil(t, ctx, env, harness.WireMessages, streamedModel, sessionID) + require.Equal(t, 200, code, "streamed chat must succeed; body: %s", body) + assert.Contains(t, body, "message_start", + "the client must receive the event stream itself, not a buffered rewrite of it") + + row := findAccessLogBySession(t, ctx, sessionID) + + assert.Equal(t, harness.VLLMStreamInputTokens, int(row.InputTokens), + "input tokens live in message_start; zero here is the bug this test exists for") + assert.Equal(t, harness.VLLMStreamOutputTokens, int(row.OutputTokens), + "output tokens ride message_delta and supersede the message_start seed") + assert.Equal(t, harness.VLLMStreamCacheReadTokens, int(row.CachedInputTokens), + "the Anthropic cache bucket rides message_start too, and only its own parser reads it") + + // The Anthropic surface bills cache reads additively, so the input bucket + // prices the full input count rather than a remainder. + wantInput := float64(harness.VLLMStreamInputTokens) / 1000 * streamInRate + wantOutput := float64(harness.VLLMStreamOutputTokens) / 1000 * streamOutRate + wantCacheRead := float64(harness.VLLMStreamCacheReadTokens) / 1000 * streamCacheReadRate + assert.InDelta(t, wantInput, row.InputCostUsd, 1e-6, "input cost must price the streamed input tokens") + assert.InDelta(t, wantOutput, row.OutputCostUsd, 1e-6, "output cost must price the streamed output tokens") + // The total, not merely a positive number: input and output alone are + // positive, so a cache bucket parsed and then never billed would pass any + // weaker assertion. The gap is 7e-6, well outside the delta. + assert.InDelta(t, wantInput+wantOutput+wantCacheRead, row.CostUsd, 1e-6, + "the recorded cost must be every bucket the surface bills, cache reads included") +} + +// TestStreamingOnGatewayTypedProvider drives the same streamed Anthropic call +// through a provider record whose catalog id names the OpenAI surface — the +// exact misconfiguration that hid the bug, since gateway records commonly pin +// one parser while the upstream serves another shape entirely. +// +// The router must choose the parser from the request path rather than the +// record's provider id, or the Anthropic usage block goes unread and input +// meters at zero all over again. +func TestStreamingOnGatewayTypedProvider(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + env := provisionStreamingProvider(t, ctx, "openai_api") + + sessionID := fmt.Sprintf("e2e-session-stream-gw-%d", time.Now().UnixNano()) + code, body := chatStreamUntil(t, ctx, env, harness.WireMessages, streamedModel, sessionID) + require.Equal(t, 200, code, "streamed chat through a gateway record must succeed; body: %s", body) + + row := findAccessLogBySession(t, ctx, sessionID) + + assert.Equal(t, harness.VLLMStreamInputTokens, int(row.InputTokens), + "a record typed openai_api must still read the Anthropic usage block it is actually serving") + assert.Equal(t, harness.VLLMStreamOutputTokens, int(row.OutputTokens), + "output tokens must survive the surface mismatch too") + assert.InDelta(t, float64(harness.VLLMStreamInputTokens)/1000*streamInRate, row.InputCostUsd, 1e-6, + "the request must be priced on the surface it spoke, not the one the record names") +} + +// provisionStreamingProvider brings up the mock, one provider pointed at its +// streaming listener under the given catalog id, a policy authorising it, and a +// connected proxy + client. +func provisionStreamingProvider(t *testing.T, ctx context.Context, catalogID string) pricedEnv { + t.Helper() + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + name := "stream-" + catalogID + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-" + name}) + require.NoError(t, err, "create group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-" + name + "-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grp.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + // Deleting the group does not delete the key it auto-joins, so the key + // needs a cleanup of its own. + t.Cleanup(func() { _ = srv.API().SetupKeys.Delete(context.Background(), sk.Id) }) + require.NotEmpty(t, sk.Key, "setup key plaintext") + + dummyKey := "sk-stream-e2e" + cacheRead := streamCacheReadRate + prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: name, + ProviderId: catalogID, + UpstreamUrl: vllm.StreamURL, + ApiKey: &dummyKey, + Enabled: ptr(true), + Models: &[]api.AgentNetworkProviderModel{{ + Id: streamedModel, + InputPer1k: streamInRate, + OutputPer1k: streamOutRate, + CacheReadPer1k: &cacheRead, + }}, + }) + require.NoError(t, err, "create provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + enabled := true + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-" + name, + Enabled: &enabled, + SourceGroups: []string{grp.Id}, + DestinationProviderIds: []string{prov.Id}, + Limits: &api.AgentNetworkPolicyLimits{ + TokenLimit: api.AgentNetworkPolicyTokenLimit{ + Enabled: true, + GroupCap: 10_000_000, + UserCap: 10_000_000, + WindowSeconds: 60, + }, + }, + }) + require.NoError(t, err, "create policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + endpoint, proxyIP, cl, px := connectClient(t, ctx, name, sk.Key) + return pricedEnv{ + providerID: prov.Id, + groupID: grp.Id, + policyID: pol.Id, + upstream: vllm.StreamURL, + endpoint: endpoint, + proxyIP: proxyIP, + client: cl, + proxy: px, + } +} + +// chatStreamUntil drives one streamed chat, retrying to absorb the tunnel and +// DNS jitter a first call through a fresh peer can hit. +func chatStreamUntil(t *testing.T, ctx context.Context, env pricedEnv, kind, model, sessionID string) (int, string) { + t.Helper() + var code int + var body string + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + c, b, cerr := env.client.ChatStream(ctx, env.endpoint, env.proxyIP, kind, model, "Reply with exactly: pong", sessionID) + if cerr == nil { + code, body = c, b + if code == 200 { + break + } + } + if !waitBeforeRetry(ctx, 5*time.Second) { + break + } + } + if code != 200 { + t.Logf("=== proxy logs ===\n%s", env.proxy.Logs(context.Background())) + } + return code, body +} diff --git a/e2e/harness/client.go b/e2e/harness/client.go index 0d7f016a6..73931027d 100644 --- a/e2e/harness/client.go +++ b/e2e/harness/client.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "net/http" "os/exec" "strconv" "strings" @@ -199,12 +200,18 @@ func (cl *Client) pollStatus(ctx context.Context, timeout time.Duration, want st const ( // curlExitCouldNotResolve is curl's exit code for a DNS resolution failure, distinct from connection-level failures. curlExitCouldNotResolve = 6 - // dnsProbeRetryWindow bounds DNS-failure retries: the synthesized zone lands a beat after management connects, so early NXDOMAIN is propagation; a zone still absent after this window is a real failure. - dnsProbeRetryWindow = 30 * time.Second - dnsProbeRetryInterval = 2 * time.Second + // curlExitCouldNotConnect is curl's exit code for a connection that never + // established. The probe exists to WAKE the lazy proxy peer, so the first + // attempt legitimately arrives before WireGuard has brought the tunnel up + // and fails here — which is propagation, exactly like an early NXDOMAIN, + // and belongs inside the retry window rather than failing the test outright. + curlExitCouldNotConnect = 7 + // endpointProbeRetryWindow bounds retries of the transient failures above: the synthesized zone and the tunnel both land a beat after management connects. Still failing after this window is a real failure. + endpointProbeRetryWindow = 30 * time.Second + endpointProbeRetryInterval = 2 * time.Second ) -// ResolveProxyIP GETs https:/// from the client's netns: any HTTP status proves DNS + tunnel and wakes the lazy proxy peer; only DNS failures retry, within dnsProbeRetryWindow. Returns the connected IP for --resolve pinning. +// ResolveProxyIP GETs https:/// from the client's netns: any HTTP status proves DNS + tunnel and wakes the lazy proxy peer; DNS and connect failures retry, within endpointProbeRetryWindow. Returns the connected IP for --resolve pinning. func (cl *Client) ResolveProxyIP(ctx context.Context, endpoint string) (string, error) { args := []string{ "run", "--rm", @@ -215,7 +222,7 @@ func (cl *Client) ResolveProxyIP(ctx context.Context, endpoint string) (string, "-w", "%{remote_ip}", "https://" + endpoint + "/", } - deadline := time.Now().Add(dnsProbeRetryWindow) + deadline := time.Now().Add(endpointProbeRetryWindow) for { cmd := exec.CommandContext(ctx, "docker", args...) var stdout, stderr strings.Builder @@ -231,21 +238,29 @@ func (cl *Client) ResolveProxyIP(ctx context.Context, endpoint string) (string, } var exitErr *exec.ExitError - if !errors.As(err, &exitErr) || exitErr.ExitCode() != curlExitCouldNotResolve { + if !errors.As(err, &exitErr) || !isTransientProbeExit(exitErr.ExitCode()) { return "", fmt.Errorf("no HTTP response from %s: %w (%s)", endpoint, err, strings.TrimSpace(stderr.String())) } - dnsErr := fmt.Errorf("DNS resolution failed for %s: %s", endpoint, strings.TrimSpace(stderr.String())) - if time.Until(deadline) < dnsProbeRetryInterval { - return "", dnsErr + probeErr := fmt.Errorf("endpoint %s not reachable yet: %s", endpoint, strings.TrimSpace(stderr.String())) + if time.Until(deadline) < endpointProbeRetryInterval { + return "", probeErr } select { case <-ctx.Done(): - return "", fmt.Errorf("%w (%w)", dnsErr, ctx.Err()) - case <-time.After(dnsProbeRetryInterval): + return "", fmt.Errorf("%w (%w)", probeErr, ctx.Err()) + case <-time.After(endpointProbeRetryInterval): } } } +// isTransientProbeExit reports whether a curl exit code describes a state the +// endpoint is expected to pass THROUGH on its way up, rather than a settled +// failure. Anything else — TLS refusal, a protocol error, a bad argument — +// would still be failing after the retry window, so it fails immediately. +func isTransientProbeExit(code int) bool { + return code == curlExitCouldNotResolve || code == curlExitCouldNotConnect +} + // Wire shapes for Chat. const ( // WireChat is the OpenAI-compatible /v1/chat/completions shape. @@ -292,6 +307,27 @@ func (cl *Client) ChatPrefixed(ctx context.Context, endpoint, proxyIP, pathPrefi return cl.post(ctx, endpoint, proxyIP, pathPrefix+path, body, withSessionID(headers, sessionID)) } +// ChatStream is Chat with "stream": true in the request body, so the proxy's +// request parser marks the call as streaming and its response parser takes the +// SSE accumulator rather than the buffered-body path. Pair it with a provider +// pointed at VLLM.StreamURL, which answers every request as an event stream. +func (cl *Client) ChatStream(ctx context.Context, endpoint, proxyIP, kind, model, prompt, sessionID string) (int, string, error) { + var path, body string + var headers []string + switch kind { + case WireMessages: + path = "/v1/messages" + headers = []string{"anthropic-version: 2023-06-01"} + body = fmt.Sprintf(`{"model":%q,"max_tokens":2048,"stream":true,"messages":[{"role":"user","content":%q}]}`, model, prompt) + default: + path = "/v1/chat/completions" + // include_usage is what makes a real OpenAI stream emit its final usage + // frame; without it the last chunk carries no tokens at all. + body = fmt.Sprintf(`{"model":%q,"stream":true,"stream_options":{"include_usage":true},"messages":[{"role":"user","content":%q}]}`, model, prompt) + } + return cl.post(ctx, endpoint, proxyIP, path, body, withSessionID(headers, sessionID)) +} + // Vertex issues an Anthropic-on-Vertex rawPredict POST over the tunnel. Unlike // Chat, the model is carried in the request path (project/region/model), so the // proxy routes by path and mints the service-account OAuth token; the body uses @@ -322,10 +358,29 @@ func withSessionID(headers []string, sessionID string) []string { return append(headers, "x-session-id: "+sessionID) } -// post runs curl in a throwaway container sharing the client's network -// namespace so the request traverses the WireGuard tunnel, pinning the endpoint -// to the proxy IP. It returns the HTTP status and response body. +// Get issues a GET to the agent-network endpoint over the client's tunnel. +// Model discovery and the connection-warming probe are read-only endpoints +// that carry no body, so they can't go through the chat helpers. +func (cl *Client) Get(ctx context.Context, endpoint, proxyIP, path string, extraHeaders []string) (int, string, error) { + return cl.do(ctx, http.MethodGet, endpoint, proxyIP, path, "", extraHeaders) +} + +// PostJSON issues an arbitrary JSON POST over the client's tunnel, for wire +// shapes the typed helpers don't cover (token counting, say). +func (cl *Client) PostJSON(ctx context.Context, endpoint, proxyIP, path, body string, extraHeaders []string) (int, string, error) { + return cl.do(ctx, http.MethodPost, endpoint, proxyIP, path, body, extraHeaders) +} + +// post issues a JSON POST. Retained as the shorthand the chat helpers use. func (cl *Client) post(ctx context.Context, endpoint, proxyIP, path, body string, extraHeaders []string) (int, string, error) { + return cl.do(ctx, http.MethodPost, endpoint, proxyIP, path, body, extraHeaders) +} + +// do runs curl in a throwaway container sharing the client's network +// namespace so the request traverses the WireGuard tunnel, pinning the endpoint +// to the proxy IP. It returns the HTTP status and response body. An empty body +// sends no payload, which is what a GET needs. +func (cl *Client) do(ctx context.Context, method, endpoint, proxyIP, path, body string, extraHeaders []string) (int, string, error) { url := "https://" + endpoint + path args := []string{ "run", "--rm", @@ -334,13 +389,15 @@ func (cl *Client) post(ctx context.Context, endpoint, proxyIP, path, body string "-sk", "--connect-timeout", "5", "--max-time", "90", "--resolve", endpoint + ":443:" + proxyIP, "-o", "/dev/stderr", "-w", "%{http_code}", - "-X", "POST", url, + "-X", method, url, "-H", "Content-Type: application/json", } for _, h := range extraHeaders { args = append(args, "-H", h) } - args = append(args, "--data", body) + if body != "" { + args = append(args, "--data", body) + } cmd := exec.CommandContext(ctx, "docker", args...) // -w writes the status code to stdout; -o /dev/stderr writes the body to // stderr so we can capture both separately. diff --git a/e2e/harness/vllm.go b/e2e/harness/vllm.go index 2f3d306cc..cf9316325 100644 --- a/e2e/harness/vllm.go +++ b/e2e/harness/vllm.go @@ -18,18 +18,63 @@ const ( vllmImage = "nginx:alpine" vllmAlias = "vllm" vllmPort = "8000/tcp" + // vllmStreamPort serves the same wire shapes as an SSE stream. See the + // nginx config for why streaming lives on its own listener. + vllmStreamPort = "8001/tcp" // VLLMModel is the served model id the mock advertises and echoes back. It // matches a real small model commonly served by vLLM so the provider's // enumerated model and the client's request line up. VLLMModel = "Qwen/Qwen2.5-0.5B-Instruct" + // VLLMUnlistedModel is a second id the mock's model listing advertises but + // no test provider enumerates, so a filtered listing is observably shorter + // than the upstream's own. + VLLMUnlistedModel = "Qwen/Qwen2.5-7B-Instruct" +) + +// Token counts the mock reports per wire shape. Tests assert on these rather +// than on "> 0" so a response parsed with the wrong provider's parser (which +// would read a different field, or none) fails loudly instead of passing on +// a coincidental non-zero. +const ( + // VLLMChatInputTokens / VLLMChatOutputTokens ride the OpenAI usage block. + VLLMChatInputTokens = 11 + VLLMChatOutputTokens = 2 + // VLLMMessagesInputTokens / VLLMMessagesOutputTokens ride the Anthropic + // usage block, whose field names the OpenAI parser cannot read. + VLLMMessagesInputTokens = 17 + VLLMMessagesOutputTokens = 3 +) + +// Token counts the streaming surface reports. They differ from the +// non-streaming ones on purpose: a test that asserts these numbers proves the +// SSE accumulator ran, rather than a buffered JSON body having been parsed. +// +// Input and cache-read arrive on message_start; output arrives on +// message_delta and supersedes the seed value message_start carries. Any +// parser that cannot read message_start reports zero input tokens — which is +// exactly the bug these counts exist to catch. +const ( + VLLMStreamInputTokens = 29 + VLLMStreamOutputTokens = 5 + VLLMStreamCacheReadTokens = 7 ) // vllmNginxConf emulates a vLLM OpenAI-compatible server over plain HTTP (vLLM's -// default: no TLS, port 8000). It answers /v1/models with a one-model list and -// any chat/completions path with a canned OpenAI-shaped chat completion carrying -// a non-zero usage block, so the proxy's OpenAI parser records real token -// consumption. Running actual vLLM in CI is infeasible (GPU + multi-GB model +// default: no TLS, port 8000), and additionally answers the wire shapes the +// other catalog surfaces speak so one mock can stand in for every provider the +// proxy routes to. Running actual vLLM in CI is infeasible (GPU + multi-GB model // download), so this stands in for the wire contract the proxy depends on. +// +// Each shape answers with its own vendor's usage block, so a response parsed +// under the wrong surface meters zero rather than passing by accident: +// +// - /v1/chat/completions (and any unmatched path): OpenAI chat completion. +// - /v1/messages: Anthropic Messages, snake_case usage plus a cache bucket. +// - /model/{id}/invoke: Bedrock InvokeModel, which carries the Anthropic body. +// - the token-counting endpoints: a count, with no usage block at all. +// +// The model listing advertises two models so a policy that authorises one +// produces an observably shorter list than the upstream's own. const vllmNginxConf = `pid /tmp/nginx.pid; events {} http { @@ -37,13 +82,75 @@ http { listen 8000; location = /v1/models { default_type application/json; - return 200 '{"object":"list","data":[{"id":"Qwen/Qwen2.5-0.5B-Instruct","object":"model","owned_by":"vllm"}]}'; + return 200 '{"object":"list","data":[{"id":"Qwen/Qwen2.5-0.5B-Instruct","object":"model","owned_by":"vllm"},{"id":"Qwen/Qwen2.5-7B-Instruct","object":"model","owned_by":"vllm"}]}'; + } + location = /v1/messages { + default_type application/json; + return 200 '{"id":"msg_e2e","type":"message","role":"assistant","model":"claude-sonnet-5","content":[{"type":"text","text":"pong"}],"stop_reason":"end_turn","usage":{"input_tokens":17,"output_tokens":3,"cache_read_input_tokens":5}}'; + } + location = /v1/messages/count_tokens { + default_type application/json; + return 200 '{"input_tokens":7}'; + } + location ~ ^/model/.+/invoke$ { + default_type application/json; + return 200 '{"id":"msg_e2e_bedrock","type":"message","role":"assistant","content":[{"type":"text","text":"pong"}],"stop_reason":"end_turn","usage":{"input_tokens":17,"output_tokens":3,"cache_read_input_tokens":5}}'; + } + location ~ ^/model/.+/count-tokens$ { + default_type application/json; + return 200 '{"inputTokens":9}'; + } + location = /api/hello { + return 200; + } + location = /inference-profiles { + default_type application/json; + return 200 '{"inferenceProfileSummaries":[{"inferenceProfileId":"us.anthropic.claude-sonnet-5","status":"ACTIVE"}]}'; } location / { default_type application/json; return 200 '{"id":"chatcmpl-e2e-vllm","object":"chat.completion","created":1700000000,"model":"Qwen/Qwen2.5-0.5B-Instruct","choices":[{"index":0,"message":{"role":"assistant","content":"pong"},"finish_reason":"stop"}],"usage":{"prompt_tokens":11,"completion_tokens":2,"total_tokens":13}}'; } } + + # The streaming surface, on its own port so the response content type is a + # property of the listener rather than of a per-request branch: nginx sets + # Content-Type from default_type, which cannot be varied inside an "if", and + # a second Content-Type via add_header would leave the proxy reading the + # wrong one. A provider record pointed at this port streams every answer. + # + # Input and cache-read tokens ride message_start, output rides message_delta + # — the split that makes a stream different from a buffered body, and the + # reason a parser that ignores message_start meters input as zero. + server { + listen 8001; + location = /v1/messages { + default_type text/event-stream; + return 200 'event: message_start +data: {"type":"message_start","message":{"id":"msg_e2e_stream","type":"message","role":"assistant","model":"claude-sonnet-5","content":[],"usage":{"input_tokens":29,"output_tokens":1,"cache_read_input_tokens":7}}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"pong"}} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}} + +event: message_stop +data: {"type":"message_stop"} + +'; + } + location / { + default_type text/event-stream; + return 200 'data: {"choices":[{"delta":{"content":"pong"}}]} + +data: {"choices":[],"usage":{"prompt_tokens":29,"completion_tokens":5,"total_tokens":34}} + +data: [DONE] + +'; + } + } } ` @@ -55,6 +162,10 @@ type VLLM struct { workDir string // URL is the upstream URL the vllm provider points at (http://:8000). URL string + // StreamURL is the same mock's streaming listener. A provider pointed here + // answers every request as SSE, so the proxy's streaming accumulator runs + // instead of its buffered-body parser. + StreamURL string } // StartVLLM runs the mock vLLM server on the shared network over plain HTTP. @@ -73,14 +184,17 @@ func StartVLLM(ctx context.Context, c *Combined) (*VLLM, error) { req := testcontainers.ContainerRequest{ Image: vllmImage, - ExposedPorts: []string{vllmPort}, + ExposedPorts: []string{vllmPort, vllmStreamPort}, Networks: []string{c.network.Name}, NetworkAliases: map[string][]string{c.network.Name: {vllmAlias}}, Cmd: []string{"nginx", "-c", "/conf/nginx.conf", "-g", "daemon off;"}, HostConfigModifier: func(hc *container.HostConfig) { hc.Binds = append(hc.Binds, workDir+":/conf:ro") }, - WaitingFor: wait.ForListeningPort(vllmPort).WithStartupTimeout(60 * time.Second), + WaitingFor: wait.ForAll( + wait.ForListeningPort(vllmPort), + wait.ForListeningPort(vllmStreamPort), + ).WithStartupTimeout(60 * time.Second), } ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ @@ -92,7 +206,12 @@ func StartVLLM(ctx context.Context, c *Combined) (*VLLM, error) { return nil, fmt.Errorf("start vllm container: %w", err) } - return &VLLM{container: ctr, workDir: workDir, URL: "http://" + vllmAlias + ":8000"}, nil + return &VLLM{ + container: ctr, + workDir: workDir, + URL: "http://" + vllmAlias + ":8000", + StreamURL: "http://" + vllmAlias + ":8001", + }, nil } // Logs returns the vLLM container logs, for diagnostics on failure. diff --git a/go.mod b/go.mod index e8d65e568..cede9c22d 100644 --- a/go.mod +++ b/go.mod @@ -71,17 +71,18 @@ require ( github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-secure-stdlib/base62 v0.1.2 github.com/hashicorp/go-version v1.7.0 - github.com/jackc/pgx/v5 v5.5.5 + github.com/jackc/pgx/v5 v5.10.0 github.com/libdns/route53 v1.5.0 - github.com/libp2p/go-nat v0.2.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/management-integrations/integrations v0.0.0-20260416123949-2355d972be42 + github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8 + github.com/netbirdio/management-integrations/integrations v0.0.0-20260803100840-78e79ba20f87 github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 github.com/oapi-codegen/runtime v1.1.2 github.com/okta/okta-sdk-golang/v2 v2.18.0 @@ -99,6 +100,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 @@ -236,8 +238,8 @@ require ( github.com/huin/goupnp v1.2.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect - github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect - github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackpal/go-nat-pmp v1.0.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect @@ -249,6 +251,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 @@ -289,7 +292,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 @@ -339,6 +341,6 @@ replace github.com/dexidp/dex/api/v2 => github.com/netbirdio/dex/api/v2 v2.0.0-2 replace github.com/mailru/easyjson => github.com/netbirdio/easyjson v0.9.0 -replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260810103952-24e716aea4db +replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260825085513-5f07a01f7a78 tool go.uber.org/mock/mockgen diff --git a/go.sum b/go.sum index 99adaa2cb..e5bf6248d 100644 --- a/go.sum +++ b/go.sum @@ -341,12 +341,12 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= -github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= -github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= -github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= @@ -407,14 +407,14 @@ github.com/libdns/libdns v0.2.2 h1:O6ws7bAfRPaBsgAYt8MDe2HcNBGC29hkZ9MX2eUSX3s= github.com/libdns/libdns v0.2.2/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= github.com/libdns/route53 v1.5.0 h1:2SKdpPFl/qgWsXQvsLNJJAoX7rSxlk7zgoL4jnWdXVA= github.com/libdns/route53 v1.5.0/go.mod h1:joT4hKmaTNKHEwb7GmZ65eoDz1whTu7KKYPS8ZqIh6Q= -github.com/libp2p/go-nat v0.2.0 h1:Tyz+bUFAYqGyJ/ppPPymMGbIgNRH+WqC5QrT5fKrrGk= -github.com/libp2p/go-nat v0.2.0/go.mod h1:3MJr+GRpRkyT65EpVPBstXLvOlAPzUVlG6Pwg9ohLJk= github.com/libp2p/go-netroute v0.4.0 h1:sZZx9hyANYUx9PZyqcgE/E1GUG3iEtTZHUEvdtXT7/Q= github.com/libp2p/go-netroute v0.4.0/go.mod h1:Nkd5ShYgSMS5MUKy/MU2T57xFoOKvvLR92Lic48LEyA= github.com/lrh3321/ipset-go v0.0.0-20250619021614-54a0a98ace81 h1:J56rFEfUTFT9j9CiRXhi1r8lUJ4W5idG3CiaBZGojNU= 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= @@ -480,16 +480,18 @@ github.com/netbirdio/dex/api/v2 v2.0.0-20260512110716-8d70ad8647c1 h1:neE7z+FPUk github.com/netbirdio/dex/api/v2 v2.0.0-20260512110716-8d70ad8647c1/go.mod h1:awuTyT29CYALpEyET0S307EgNlPWrc7fFKRAyhsO45M= github.com/netbirdio/easyjson v0.9.0 h1:6Nw2lghSVuy8RSkAYDhDv1thBVEmfVbKZnV7T7Z6Aus= github.com/netbirdio/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8 h1:pBxXEsxcsO3qVUND//5j1kelYlO57x5IrRviNF0+0iA= +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= github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ= -github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260810103952-24e716aea4db h1:gBOE2r4AW1soSmpYJC5/n9/1L8UQ8+HLjed8CY/TzZY= -github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260810103952-24e716aea4db/go.mod h1:bsdahLwBQxXjlmdPPeQyrTcDJfcqAr/ymFj0RXhwtWI= +github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260825085513-5f07a01f7a78 h1:B/jRv24jnFeoA+VccxoCx6K94PUgsqR9wnshpeu9M+8= +github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260825085513-5f07a01f7a78/go.mod h1:/6QR46/nhGCSADHbS++XtDb9dkTnenTHlGskTPRo9S0= github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a h1:3CWK+yTvRKOcC0Q8VCTGy4l60TEb27CQVS7LkMxwjmw= github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= diff --git a/infrastructure_files/getting-started-enterprise.sh b/infrastructure_files/getting-started-enterprise.sh index 7418cb8e8..3f7cf6357 100755 --- a/infrastructure_files/getting-started-enterprise.sh +++ b/infrastructure_files/getting-started-enterprise.sh @@ -15,16 +15,25 @@ NETBIRD_EULA_URL="https://netbird.io/self-hosted-EULA" # server trusts X-Forwarded-* headers from this address only. TRAEFIK_IP="172.30.0.10" +LICENSE_VERDICT="unknown" +LICENSE_LOG_LINES="" + check_docker_compose() { - if command -v docker-compose &> /dev/null; then - echo "docker-compose" - return + if ! command -v docker &> /dev/null && ! command -v docker-compose &> /dev/null; then + echo "Docker is not installed or not in PATH. Please follow the steps from the official guide: https://docs.docker.com/engine/install/" > /dev/stderr + exit 1 fi - if docker compose --help &> /dev/null; then + + if docker compose version &> /dev/null; then echo "docker compose" return fi - echo "docker-compose is not installed or not in PATH. See https://docs.docker.com/engine/install/" > /dev/stderr + if command -v docker-compose &> /dev/null && docker-compose version &> /dev/null; then + echo "docker-compose" + return + fi + + echo "Docker Compose is not installed or not in PATH. Please follow the steps from the official guide: https://docs.docker.com/compose/install/" > /dev/stderr exit 1 } @@ -221,6 +230,90 @@ wait_postgres() { set -e } +wait_for_license_verdict() { + local counter=0 + local logs="" + + echo -n "Waiting for the server to validate the license" + while [[ $counter -lt 60 ]]; do + logs=$($DOCKER_COMPOSE_COMMAND logs --no-color --tail=all netbird-server 2>/dev/null || true) + + if grep -qi "license invalidated" <<< "$logs"; then + echo " rejected" + LICENSE_VERDICT="rejected" + LICENSE_LOG_LINES=$(grep -i "license" <<< "$logs" | tail -n 5 || true) + return 0 + fi + + if grep -qi "license validated" <<< "$logs"; then + echo " ok" + LICENSE_VERDICT="ok" + return 0 + fi + + echo -n " ." + sleep 2 + counter=$((counter + 1)) + done + + echo " no verdict in 120s" + LICENSE_VERDICT="unknown" + LICENSE_LOG_LINES=$(grep -iE "failed to validate license|error validating license" <<< "$logs" | tail -n 3 || true) + return 0 +} + +report_license_verdict() { + if [[ "$LICENSE_VERDICT" == "ok" ]]; then + return 0 + fi + + if [[ "$LICENSE_VERDICT" == "unknown" ]]; then + echo "" + echo " ⚠ The server logged no license verdict within 120s." + if [[ -n "$LICENSE_LOG_LINES" ]]; then + echo " It was still reporting validation errors:" + while IFS= read -r line; do + [[ -n "$line" ]] && echo " $line" + done <<< "$LICENSE_LOG_LINES" + fi + echo "" + echo " Check the verdict with:" + echo "" + echo " $DOCKER_COMPOSE_COMMAND logs netbird-server | grep -i license" + return 0 + fi + + local unreachable="false" + if grep -qi "couldn't be validated with the license server" <<< "$LICENSE_LOG_LINES"; then + unreachable="true" + fi + + echo "" + if [[ "$unreachable" == "true" ]]; then + echo " ⚠ The server could not validate the license:" + else + echo " ⚠ The server rejected the license key:" + fi + while IFS= read -r line; do + [[ -n "$line" ]] && echo " $line" + done <<< "$LICENSE_LOG_LINES" + echo "" + echo " The stack is up, and only the license check did not pass." + echo "" + if [[ "$unreachable" == "true" ]]; then + echo " The license server could not be reached, so the key itself was" + echo " never checked. Confirm this host has outbound access to the" + echo " license server, then restart:" + else + echo " Check the reason the server gave above, verify that" + echo " NETBIRD_LICENSE_KEY in .env matches the key you were issued," + echo " then restart:" + fi + echo "" + echo " $DOCKER_COMPOSE_COMMAND up -d" + return 0 +} + init_environment() { check_openssl DOCKER_COMPOSE_COMMAND=$(check_docker_compose) @@ -299,6 +392,9 @@ init_environment() { echo "Starting remaining services ..." $DOCKER_COMPOSE_COMMAND up -d + echo "" + wait_for_license_verdict + echo "" echo "Done." echo "" @@ -309,6 +405,12 @@ init_environment() { echo "" echo "Tail logs:" echo " cd $(pwd) && $DOCKER_COMPOSE_COMMAND logs -f netbird-server traefik" + + report_license_verdict + + if [[ "$LICENSE_VERDICT" == "rejected" ]]; then + exit 1 + fi } # ------------------------------------------------------------------ diff --git a/infrastructure_files/getting-started.sh b/infrastructure_files/getting-started.sh index 0fc5b23c5..5efc0181e 100755 --- a/infrastructure_files/getting-started.sh +++ b/infrastructure_files/getting-started.sh @@ -60,18 +60,21 @@ check_docker_sock_perms() { } check_docker_compose() { - if command -v docker-compose &> /dev/null - then - echo "docker-compose" - return - fi - if docker compose --help &> /dev/null - then - echo "docker compose" - return + if ! command -v docker &> /dev/null && ! command -v docker-compose &> /dev/null; then + echo "Docker is not installed or not in PATH. Please follow the steps from the official guide: https://docs.docker.com/engine/install/" > /dev/stderr + exit 1 fi - echo "docker-compose is not installed or not in PATH. Please follow the steps from the official guide: https://docs.docker.com/engine/install/" > /dev/stderr + if docker compose version &> /dev/null; then + echo "docker compose" + return + fi + if command -v docker-compose &> /dev/null && docker-compose version &> /dev/null; then + echo "docker-compose" + return + fi + + echo "Docker Compose is not installed or not in PATH. Please follow the steps from the official guide: https://docs.docker.com/compose/install/" > /dev/stderr exit 1 } @@ -98,19 +101,39 @@ get_main_ip_address() { } check_nb_domain() { - DOMAIN=$1 - if [[ "$DOMAIN-x" == "-x" ]]; then + local domain="$1" + + if [[ -z "$domain" ]]; then echo "The NETBIRD_DOMAIN variable cannot be empty." > /dev/stderr return 1 fi - - if [[ "$DOMAIN" == "netbird.example.com" ]]; then + if [[ "$domain" == "use-ip" ]]; then + return 0 + fi + if [[ "$domain" == "netbird.example.com" ]]; then echo "The NETBIRD_DOMAIN cannot be netbird.example.com" > /dev/stderr return 1 fi + if [[ "$domain" =~ ^[0-9.]+$ ]]; then + echo "'$domain' is an IP address. Use 'use-ip' to install on this host's IP over HTTP, or an FQDN to get a TLS certificate." > /dev/stderr + return 1 + fi + if [[ ! "$domain" =~ ^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$ ]]; then + echo "'$domain' is not a valid FQDN. It needs at least one dot (e.g. netbird.my-domain.com), with no scheme, port or trailing dot." > /dev/stderr + return 1 + fi return 0 } +check_domain_resolves() { + local domain="$1" + if command -v getent &> /dev/null && getent hosts "$domain" &> /dev/null; then return 0; fi + if command -v host &> /dev/null && host "$domain" &> /dev/null; then return 0; fi + if command -v dig &> /dev/null && [[ -n "$(dig +short "$domain" 2>/dev/null)" ]]; then return 0; fi + if command -v nslookup &> /dev/null && nslookup "$domain" &> /dev/null; then return 0; fi + return 1 +} + # Non-interactive configuration # ------------------------------ # Every prompt below can be pre-answered with an environment variable, so the @@ -170,7 +193,22 @@ read_nb_domain() { read -r READ_NETBIRD_DOMAIN < /dev/tty if ! check_nb_domain "$READ_NETBIRD_DOMAIN"; then read_nb_domain + return fi + + if [[ "$READ_NETBIRD_DOMAIN" != "use-ip" ]] && ! check_domain_resolves "$READ_NETBIRD_DOMAIN"; then + local confirm="" + echo "" > /dev/stderr + echo "Warning: '$READ_NETBIRD_DOMAIN' does not resolve via DNS from this host." > /dev/stderr + echo "TLS certificate issuance and client connections will fail until it does." > /dev/stderr + echo -n "Continue anyway? [y/N]: " > /dev/stderr + read -r confirm < /dev/tty + if [[ ! "$confirm" =~ ^[Yy]$ ]]; then + read_nb_domain + return + fi + fi + echo "$READ_NETBIRD_DOMAIN" return 0 } @@ -439,12 +477,23 @@ configure_domain() { # Domain is validated (not a free-form value), so it keeps its own guard # rather than going through resolve(): a valid NETBIRD_DOMAIN is used as-is, # otherwise we prompt, or abort when there is no terminal to prompt on. + local prompted="false" if ! check_nb_domain "$NETBIRD_DOMAIN"; then if ! tty_available; then - echo "NETBIRD_DOMAIN is required for a non-interactive install." > /dev/stderr + if [[ -n "$NETBIRD_DOMAIN" ]]; then + echo "NETBIRD_DOMAIN='$NETBIRD_DOMAIN' cannot be used for a non-interactive install." > /dev/stderr + else + echo "NETBIRD_DOMAIN is required for a non-interactive install." > /dev/stderr + fi exit 1 fi NETBIRD_DOMAIN=$(read_nb_domain) + prompted="true" + fi + + if [[ "$prompted" == "false" && "$NETBIRD_DOMAIN" != "use-ip" ]] && ! check_domain_resolves "$NETBIRD_DOMAIN"; then + echo "Warning: '$NETBIRD_DOMAIN' does not resolve via DNS from this host." > /dev/stderr + echo "TLS certificate issuance and client connections will fail until it does." > /dev/stderr fi if [[ "$NETBIRD_DOMAIN" == "use-ip" ]]; then diff --git a/infrastructure_files/migrate-to-enterprise.sh b/infrastructure_files/migrate-to-enterprise.sh index 744ba5375..2b10250c9 100755 --- a/infrastructure_files/migrate-to-enterprise.sh +++ b/infrastructure_files/migrate-to-enterprise.sh @@ -40,6 +40,10 @@ ENTERPRISE_CONFIG_FILE="config.yaml.enterprise" # completed successfully. ROLLBACK_STATE="disarmed" ENV_EXISTED="unknown" +# Verdict the server logs about the license key on startup: ok, rejected, or +# unknown when neither line appeared before the timeout. +LICENSE_VERDICT="unknown" +LICENSE_LOG_LINES="" ENV_BACKUP="" PG_VOLUME_NAME="" BACKUP_DIR="" @@ -59,15 +63,21 @@ ENTERPRISE_CONFIG="no" NETBIRD_EULA_URL="https://netbird.io/self-hosted-EULA" check_docker_compose() { - if command -v docker-compose &> /dev/null; then - echo "docker-compose" - return + if ! command -v docker &> /dev/null && ! command -v docker-compose &> /dev/null; then + echo "Docker is not installed or not in PATH. Please follow the steps from the official guide: https://docs.docker.com/engine/install/" > /dev/stderr + exit 1 fi - if docker compose --help &> /dev/null; then + + if docker compose version &> /dev/null; then echo "docker compose" return fi - echo "docker-compose is not installed or not in PATH." > /dev/stderr + if command -v docker-compose &> /dev/null && docker-compose version &> /dev/null; then + echo "docker-compose" + return + fi + + echo "Docker Compose is not installed or not in PATH. Please follow the steps from the official guide: https://docs.docker.com/compose/install/" > /dev/stderr exit 1 } @@ -1000,6 +1010,39 @@ init_migration() { check_stale_postgres_volume } +wait_for_license_verdict() { + local counter=0 + local logs="" + + echo -n "Waiting for the server to validate the license" + while [[ $counter -lt 60 ]]; do + + logs=$($DOCKER_COMPOSE_COMMAND logs --no-color --tail=all "$COMBINED_SERVICE" 2>/dev/null || true) + + if grep -qi "license invalidated" <<< "$logs"; then + echo " rejected" + LICENSE_VERDICT="rejected" + LICENSE_LOG_LINES=$(grep -i "license" <<< "$logs" | tail -n 5 || true) + return 0 + fi + + if grep -qi "license validated" <<< "$logs"; then + echo " ok" + LICENSE_VERDICT="ok" + return 0 + fi + + echo -n " ." + sleep 2 + counter=$((counter + 1)) + done + + echo " no verdict in 120s" + LICENSE_VERDICT="unknown" + LICENSE_LOG_LINES=$(grep -iE "failed to validate license|error validating license" <<< "$logs" | tail -n 3 || true) + return 0 +} + apply_changes() { # From here on a failure must roll the deployment back. ROLLBACK_STATE="armed" @@ -1100,9 +1143,57 @@ apply_changes() { echo "Bringing up all services ..." $DOCKER_COMPOSE_COMMAND up -d + echo "" + wait_for_license_verdict + echo "" echo "Migration complete." + if [[ "$LICENSE_VERDICT" == "rejected" ]]; then + local unreachable="false" + if grep -qi "couldn't be validated with the license server" <<< "$LICENSE_LOG_LINES"; then + unreachable="true" + fi + + echo "" + if [[ "$unreachable" == "true" ]]; then + echo " ⚠ The server could not validate the license:" + else + echo " ⚠ The server rejected the license key:" + fi + while IFS= read -r line; do + [[ -n "$line" ]] && echo " $line" + done <<< "$LICENSE_LOG_LINES" + echo "" + echo " The migration itself completed: the images and any migrated data" + echo " are in place, and only the license check did not pass." + echo "" + if [[ "$unreachable" == "true" ]]; then + echo " The license server could not be reached, so the key itself was" + echo " never checked. Confirm this host has outbound access to the" + echo " license server, then restart:" + else + echo " Check the reason the server gave above, verify that" + echo " NB_LICENSE_KEY in .env matches the key you were issued, then" + echo " restart:" + fi + echo "" + echo " $DOCKER_COMPOSE_COMMAND up -d" + elif [[ "$LICENSE_VERDICT" == "unknown" ]]; then + echo "" + echo " ⚠ The server logged no license verdict within 120s." + if [[ -n "$LICENSE_LOG_LINES" ]]; then + echo " It was still reporting validation errors:" + while IFS= read -r line; do + [[ -n "$line" ]] && echo " $line" + done <<< "$LICENSE_LOG_LINES" + fi + echo "" + echo " Check the verdict with:" + echo "" + echo " $DOCKER_COMPOSE_COMMAND logs $COMBINED_SERVICE | grep -i license" + fi + # Nothing left to undo. ROLLBACK_STATE="disarmed" } @@ -1122,6 +1213,11 @@ print_summary() { fi [[ "$ENABLE_FLOW" == "yes" ]] && echo " Traffic flow: enabled" [[ "$ENABLE_FLOW" != "yes" ]] && echo " Traffic flow: disabled" + case "$LICENSE_VERDICT" in + ok) echo " License: validated by the server" ;; + rejected) echo " License: REJECTED - see above, the install is not usable yet" ;; + *) echo " License: not confirmed (no verdict in the logs yet)" ;; + esac echo "" echo " Generated files (next to your docker-compose.yml):" echo " $OVERRIDE_FILE" @@ -1176,3 +1272,10 @@ trap 'exit 130' INT TERM init_migration apply_changes print_summary + +# A rejected license leaves a migrated but unusable install. Say so in the exit +# code too, or a wrapper script reads this run as a clean success. +if [[ "$LICENSE_VERDICT" == "rejected" ]]; then + exit 1 +fi +exit 0 diff --git a/infrastructure_files/observability/grafana/dashboards/client.json b/infrastructure_files/observability/grafana/dashboards/client.json new file mode 100644 index 000000000..05306a972 --- /dev/null +++ b/infrastructure_files/observability/grafana/dashboards/client.json @@ -0,0 +1,1107 @@ +{ + "__inputs": [ + { + "name": "DS_PROMETHEUS", + "label": "Prometheus", + "description": "", + "type": "datasource", + "pluginId": "prometheus", + "pluginName": "Prometheus" + } + ], + "__elements": {}, + "__requires": [ + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "11.1.1" + }, + { + "type": "datasource", + "id": "prometheus", + "name": "Prometheus", + "version": "1.0.0" + }, + { + "type": "panel", + "id": "stat", + "name": "Stat", + "version": "" + }, + { + "type": "panel", + "id": "timeseries", + "name": "Time series", + "version": "" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "panels": [], + "title": "Connection state", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 1 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 0, + "y": 1 + }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "netbird_management_connected{job=~\"$job\",instance=~\"$instance\"}", + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Management connected", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 1 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 6, + "y": 1 + }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "netbird_signal_connected{job=~\"$job\",instance=~\"$instance\"}", + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Signal connected", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 1 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 12, + "y": 1 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "netbird_peers{job=~\"$job\",instance=~\"$instance\"}", + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Known peers", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 1 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 18, + "y": 1 + }, + "id": 5, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(netbird_peers_connected{job=~\"$job\",instance=~\"$instance\"})", + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Connected peers", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 8, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 5 + }, + "id": 6, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "netbird_peers_connected{job=~\"$job\",instance=~\"$instance\"}", + "instant": false, + "legendFormat": "{{connection_type}}", + "range": true, + "refId": "A" + } + ], + "title": "Connected peers by connection type", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 8, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 5 + }, + "id": 7, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "netbird_peer_latency_seconds{job=~\"$job\",instance=~\"$instance\"}", + "instant": false, + "legendFormat": "{{peer}}", + "range": true, + "refId": "A" + } + ], + "title": "Peer latency", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 13 + }, + "id": 8, + "panels": [], + "title": "Peer connection establishment", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 8, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 14 + }, + "id": 9, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.5,sum(increase(netbird_peer_connection_stage_duration_seconds_bucket{job=~\"$job\",instance=~\"$instance\",stage=\"total\"}[$__rate_interval])) by (le,connection_type))", + "instant": false, + "legendFormat": "{{connection_type}}", + "range": true, + "refId": "A" + } + ], + "title": "Connection establishment duration (p50)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 8, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 14 + }, + "id": 10, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.5,sum(increase(netbird_peer_connection_stage_duration_seconds_bucket{job=~\"$job\",instance=~\"$instance\"}[$__rate_interval])) by (le,stage))", + "instant": false, + "legendFormat": "{{stage}}", + "range": true, + "refId": "A" + } + ], + "title": "Connection establishment stages (p50)", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 22 + }, + "id": 11, + "panels": [], + "title": "Management interactions", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 8, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 23 + }, + "id": 12, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.5,sum(increase(netbird_sync_duration_seconds_bucket{job=~\"$job\",instance=~\"$instance\"}[$__rate_interval])) by (le))", + "instant": false, + "legendFormat": "sync", + "range": true, + "refId": "A" + } + ], + "title": "Sync processing duration (p50)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 8, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 23 + }, + "id": 13, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.5,sum(increase(netbird_sync_phase_duration_seconds_bucket{job=~\"$job\",instance=~\"$instance\"}[$__rate_interval])) by (le,phase))", + "instant": false, + "legendFormat": "{{phase}}", + "range": true, + "refId": "A" + } + ], + "title": "Sync phase duration (p50)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 8, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 23 + }, + "id": 14, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.5,sum(increase(netbird_login_duration_seconds_bucket{job=~\"$job\",instance=~\"$instance\"}[$__rate_interval])) by (le,success))", + "instant": false, + "legendFormat": "success={{success}}", + "range": true, + "refId": "A" + } + ], + "title": "Login duration (p50)", + "type": "timeseries" + } + ], + "schemaVersion": 39, + "tags": [ + "netbird", + "client" + ], + "templating": { + "list": [ + { + "current": {}, + "hide": 0, + "includeAll": false, + "multi": false, + "name": "datasource", + "options": [], + "query": "prometheus", + "queryValue": "", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "type": "datasource" + }, + { + "current": {}, + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "definition": "label_values(netbird_management_connected,job)", + "hide": 0, + "includeAll": true, + "multi": true, + "name": "job", + "options": [], + "query": { + "qryType": 1, + "query": "label_values(netbird_management_connected,job)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + }, + { + "current": {}, + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "definition": "label_values(netbird_management_connected{job=~\"$job\"},instance)", + "hide": 0, + "includeAll": true, + "multi": true, + "name": "instance", + "options": [], + "query": { + "qryType": 1, + "query": "label_values(netbird_management_connected{job=~\"$job\"},instance)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + } + ] + }, + "time": { + "from": "now-24h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Netbird / Client", + "uid": "netbird-client-v001", + "version": 1, + "weekStart": "" +} diff --git a/integration_tests/management/network_map_db/account_settings_test.go b/integration_tests/management/network_map_db/account_settings_test.go new file mode 100644 index 000000000..d7927aaf1 --- /dev/null +++ b/integration_tests/management/network_map_db/account_settings_test.go @@ -0,0 +1,58 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "testing" + "time" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/stretchr/testify/assert" +) + +func TestGetAccountSettings(t *testing.T) { + ctx := context.TODO() + + execQuery(t, ctx, + `insert into accounts (id, settings_peer_login_expiration_enabled, settings_peer_login_expiration, settings_peer_inactivity_expiration_enabled, + settings_peer_inactivity_expiration, settings_dns_domain, settings_ipv6_enabled_groups, settings_routing_peer_dns_resolution_enabled, + settings_lazy_connection_enabled, settings_auto_update_version, settings_auto_update_always, settings_metrics_push_enabled) + values('account-3',null,null,null,null,null,null,null,null,null,null,null)`) + + accountSettings, err := conn(t, ctx).GetAccountSettings(ctx, "account-1") + assert.NoError(t, err) + assert.Equal(t, accountSettings, nmdata.AccountSettingsInfo{ + PeerLoginExpirationEnabled: true, + PeerLoginExpiration: 86400000000000 * time.Nanosecond, + PeerInactivityExpirationEnabled: false, + PeerInactivityExpiration: 86400000000000 * time.Nanosecond, + DNSDomain: "", + IPv6EnabledGroups: []string{"group-one-resource-id"}, + RoutingPeerDNSResolutionEnabled: false, + LazyConnectionEnabled: false, + AutoUpdateVersion: "disabled", + AutoUpdateAlways: false, + MetricsPushEnabled: false, + }) + + accountSettings, err = conn(t, ctx).GetAccountSettings(ctx, "account-2") + assert.NoError(t, err) + assert.Equal(t, accountSettings, nmdata.AccountSettingsInfo{ + PeerLoginExpirationEnabled: true, + PeerLoginExpiration: 86400000000000 * time.Nanosecond, + PeerInactivityExpirationEnabled: false, + PeerInactivityExpiration: 86400000000000 * time.Nanosecond, + DNSDomain: "", + IPv6EnabledGroups: []string{"group-two-resources-id"}, + RoutingPeerDNSResolutionEnabled: false, + LazyConnectionEnabled: false, + AutoUpdateVersion: "disabled", + AutoUpdateAlways: false, + MetricsPushEnabled: false, + }) + + accountSettings, err = conn(t, ctx).GetAccountSettings(ctx, "account-3") + assert.NoError(t, err) + assert.Equal(t, accountSettings, nmdata.AccountSettingsInfo{}) +} diff --git a/integration_tests/management/network_map_db/base_data.sql b/integration_tests/management/network_map_db/base_data.sql new file mode 100644 index 000000000..136df00ac --- /dev/null +++ b/integration_tests/management/network_map_db/base_data.sql @@ -0,0 +1,53 @@ +insert into accounts (id, network_identifier, network_net, network_net_v6, network_dns, network_serial,dns_settings_disabled_management_groups, + settings_peer_login_expiration_enabled, settings_peer_login_expiration, settings_peer_inactivity_expiration_enabled, + settings_peer_inactivity_expiration, settings_dns_domain, settings_ipv6_enabled_groups, settings_routing_peer_dns_resolution_enabled, + settings_lazy_connection_enabled, settings_auto_update_version, settings_auto_update_always, settings_metrics_push_enabled) +VALUES('account-1','network-1','{"IP":"100.103.0.0","Mask":"//8AAA=="}','{"IP":"fdde:e995:fd38:a465::","Mask":"//////////8AAAAAAAAAAA=="}','',1,'["disabled-group-1","disabled-group-2"]', + true, 86400000000000, false, + 86400000000000, null, '["group-one-resource-id"]', false, + false, 'disabled', false, false); +insert into accounts (id, network_identifier, network_net, network_net_v6, network_dns, network_serial,dns_settings_disabled_management_groups, + settings_peer_login_expiration_enabled, settings_peer_login_expiration, settings_peer_inactivity_expiration_enabled, + settings_peer_inactivity_expiration, settings_dns_domain, settings_ipv6_enabled_groups, settings_routing_peer_dns_resolution_enabled, + settings_lazy_connection_enabled, settings_auto_update_version, settings_auto_update_always, settings_metrics_push_enabled) +VALUES('account-2','network-2','{"IP":"110.0.0.0","Mask":"//8AAA=="}','{"IP":"fddf:e995:fd38:a465::","Mask":"//////////8AAAAAAAAAAA=="}','',2,null, + true, 86400000000000, false, + 86400000000000, null, '["group-two-resources-id"]', false, + false, 'disabled', false, false); +insert into groups (id, account_id, name, resources, public_id) VALUES('group-one-resource-id','account-1','group-1-name', '[{"ID":"host-id-1","Type":"host"}]','group-one-resource-id-public'); +insert into groups (id, account_id, name, resources, public_id) VALUES('group-two-resources-id','account-1','group-2-name', '[{"ID":"subnet-id-1","Type":"subnet"}, {"ID":"host-id-2","Type":"host"}]','group-two-resources-id-public'); +insert into groups (id, account_id, name, resources, public_id) VALUES('group-no-resources-id','account-1','group-3-name', null,'group-no-resources-id-public'); +insert into group_peers (account_id, peer_id, group_id) VALUES('account-1','peer-id-1','group-one-resource-id'); +insert into group_peers (account_id, peer_id, group_id) VALUES('account-1','peer-id-2','group-two-resources-id'); +insert into group_peers (account_id, peer_id, group_id) VALUES('account-1','peer-id-3','group-two-resources-id'); +insert into peers (id, account_id, "key", ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6, + peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster, + meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, + meta_capabilities, meta_flags, meta_sync_message_version, + location_country_code, location_city_name, location_connection_ip) + values('peer-id-1','account-1','key-1','ssh-key-1','peer-1','["extra-peer-1"]','user-id-1',true,true,'2026-08-06 13:25:59.12999','"10.10.10.1"','"fdf4:ba80:6aa5:89f1:44d7:8701:8699:4940"', + false,true,true,'cluster-1.netbird.services', + '0.76.0','linux','26.4.1','6.8.0-134-generic','[{"NetIP":"fe80::8b4c:973f:a76b:3771/64","Mac":"00:15:5d:24:0c:ac"},{"NetIP":"192.168.16.1/20","Mac":"00:15:5d:24:0c:ac"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]', + '[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',1, + 'DE','Berlin','"46.201.148.187"'); +insert into peers (id,account_id,"key", ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6, + peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster, + meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, + meta_capabilities, meta_flags, meta_sync_message_version, + location_country_code, location_city_name, location_connection_ip) + values('peer-id-2','account-1','key-2','ssh-key-2','peer-2','["extra-peer-2"]','user-id-2',true,true,'2026-08-06 14:25:59.12999','"10.10.100.1"','"fdf5:ba80:6aa5:89f1:44d7:8701:8699:4940"', + false,true,true,'cluster-2.netbird.services', + '0.76.1','linux','26.4.2','6.8.0-135-generic','[{"NetIP":"fe81::8b4c:973f:a76b:3771/64","Mac":"00:15:5d:24:0c:ad"},{"NetIP":"192.168.17.1/20","Mac":"00:15:5d:24:0c:ad"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]', + '[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',0, + 'DE','Berlin','"46.201.149.187"'); +insert into peers (id,account_id,"key", ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6, + peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster, + meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, + meta_capabilities, meta_flags, meta_sync_message_version, + location_country_code, location_city_name, location_connection_ip) + values('peer-id-3','account-1','key-3','ssh-key-3','peer-3','["extra-peer-3"]','user-id-3',true,true,'2026-08-06 12:25:59.12999','"10.10.200.1"','"fdf6:ba80:6aa5:89f1:44d7:8701:8699:4940"', + false,true,true,'cluster-3.netbird.services', + '0.76.2','linux','26.4.3','6.8.0-136-generic','[{"NetIP":"fe82::8b4c:973f:a76b:3771/64","Mac":"00:15:5d:24:0c:ae"},{"NetIP":"192.168.18.1/20","Mac":"00:15:5d:24:0c:ae"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]', + '[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',1, + 'DE','Berlin','"46.201.150.187"'); + diff --git a/integration_tests/management/network_map_db/dns_settings_test.go b/integration_tests/management/network_map_db/dns_settings_test.go new file mode 100644 index 000000000..95ac84aed --- /dev/null +++ b/integration_tests/management/network_map_db/dns_settings_test.go @@ -0,0 +1,25 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "testing" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/stretchr/testify/assert" +) + +func TestGetDnsSettings(t *testing.T) { + ctx := context.TODO() + + settings, err := conn(t, ctx).GetDnsSettings(ctx, "account-1") + assert.NoError(t, err) + assert.Equal(t, settings, nmdata.DNSSettings{ + DisabledManagementGroups: []string{"disabled-group-1", "disabled-group-2"}, + }) + + settings, err = conn(t, ctx).GetDnsSettings(ctx, "account-2") + assert.NoError(t, err) + assert.Equal(t, settings, nmdata.DNSSettings{}) +} diff --git a/integration_tests/management/network_map_db/dns_test.go b/integration_tests/management/network_map_db/dns_test.go new file mode 100644 index 000000000..33023061d --- /dev/null +++ b/integration_tests/management/network_map_db/dns_test.go @@ -0,0 +1,80 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "testing" + + "github.com/miekg/dns" + "github.com/netbirdio/netbird/shared/management/networkmap" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/stretchr/testify/assert" +) + +func TestGetAppliedZoneCandidatesViaPgxConnection(t *testing.T) { + ctx := context.TODO() + + execQuery(t, ctx, + `insert into zones (id, account_id, domain, enabled, enable_search_domain, distribution_groups) + VALUES('zone-1','account-1','test-1.com',true,true,'["group-one-resource-id"]')`) + execQuery(t, ctx, + `insert into zones (id, account_id, domain, enabled, enable_search_domain, distribution_groups) + VALUES('zone-2','account-1','test-2.com',true,false,'["group-two-resources-id"]')`) + execQuery(t, ctx, + `insert into zones (id, account_id, domain, enabled, enable_search_domain, distribution_groups) + VALUES('zone-3','account-1','test-3.com',false,true,'["group-one-resource-id"]')`) + execQuery(t, ctx, + `insert into records (id, account_id, zone_id, name, type, ttl, content) + VALUES('record-1','account-1','zone-1','test.test-1.com','A',1800,'1.1.1.1')`) + execQuery(t, ctx, + `insert into records (id, account_id, zone_id, name, type, ttl, content) + VALUES('record-2','account-1','zone-1','test2.test-1.com','A',1800,'1.1.1.2')`) + execQuery(t, ctx, + `insert into records (id, account_id, zone_id, name, type, ttl, content) + VALUES('record-3','account-1','zone-1','test3.test-1.com','CNAME',1800,'test4.test-1.com')`) + execQuery(t, ctx, + `insert into records (id, account_id, zone_id, name, type, ttl, content) + VALUES('record-4','account-1','zone-2','test2.test-2.com','CNAME',1800,'test3.test-2.com')`) + execQuery(t, ctx, + `insert into records (id, account_id, zone_id, name, type, ttl, content) + VALUES('record-5','account-1','zone-3','test.test-3.com','A',1800,'1.1.1.3')`) + + zoneCandidates, err := conn(t, ctx).GetAppliedZoneCandidates(ctx, "account-1") + assert.NoError(t, err) + + // Zone domains and record names are fully qualified, and the zone is served + // non-authoritatively — the account-side builder + // (types.buildAppliedZoneCandidates) states the same shape, and both feed the + // one client-facing map, so the two have to agree. + assert.Contains(t, zoneCandidates, networkmap.AppliedZoneCandidate{ + DistributionGroups: []string{"group-one-resource-id"}, + Zone: nmdata.CustomZone{ + Domain: "test-1.com.", + SearchDomainDisabled: false, + NonAuthoritative: true, + Records: []nmdata.SimpleRecord{ + {Name: "test.test-1.com.", Type: int(dns.TypeA), Class: "IN", TTL: 1800, RData: "1.1.1.1"}, + {Name: "test2.test-1.com.", Type: int(dns.TypeA), Class: "IN", TTL: 1800, RData: "1.1.1.2"}, + {Name: "test3.test-1.com.", Type: int(dns.TypeCNAME), Class: "IN", TTL: 1800, RData: "test4.test-1.com."}, + }, + }, + }) + assert.Contains(t, zoneCandidates, networkmap.AppliedZoneCandidate{ + DistributionGroups: []string{"group-two-resources-id"}, + Zone: nmdata.CustomZone{ + Domain: "test-2.com.", + SearchDomainDisabled: true, + NonAuthoritative: true, + Records: []nmdata.SimpleRecord{ + {Name: "test2.test-2.com.", Type: int(dns.TypeCNAME), Class: "IN", TTL: 1800, RData: "test3.test-2.com."}, + }, + }, + }) + + // A zone an admin switched off reaches no peer. + for _, candidate := range zoneCandidates { + assert.NotEqual(t, "test-3.com.", candidate.Zone.Domain, "disabled zone must not be a candidate") + assert.NotEqual(t, "test-3.com", candidate.Zone.Domain, "disabled zone must not be a candidate") + } +} diff --git a/integration_tests/management/network_map_db/domain_test.go b/integration_tests/management/network_map_db/domain_test.go new file mode 100644 index 000000000..8434a76c3 --- /dev/null +++ b/integration_tests/management/network_map_db/domain_test.go @@ -0,0 +1,39 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "database/sql" + "testing" + + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" + "github.com/stretchr/testify/assert" +) + +func TestGetDomains(t *testing.T) { + ctx := context.TODO() + + execQuery(t, ctx, + `insert into domains (id, account_id, domain, target_cluster) + VALUES('domain-1','account-1','test-1.com','target-1.cluster.local')`) + execQuery(t, ctx, + `insert into domains (id, account_id, domain, target_cluster) + VALUES('domain-2','account-1','test-2.com','target-2.cluster.local')`) + execQuery(t, ctx, + `insert into domains (id, account_id, domain, target_cluster) + VALUES('domain-3','account-1',null,null)`) + + domains, err := conn(t, ctx).GetDomains(ctx, "account-1") + assert.NoError(t, err) + assert.Len(t, domains, 2) + + assert.Contains(t, domains, networkmapdb.Domain{ + Domain: sql.NullString{String: "test-1.com", Valid: true}, + TargetCluster: sql.NullString{String: "target-1.cluster.local", Valid: true}, + }) + assert.Contains(t, domains, networkmapdb.Domain{ + Domain: sql.NullString{String: "test-2.com", Valid: true}, + TargetCluster: sql.NullString{String: "target-2.cluster.local", Valid: true}, + }) +} diff --git a/integration_tests/management/network_map_db/group_test.go b/integration_tests/management/network_map_db/group_test.go new file mode 100644 index 000000000..3ccf96eb0 --- /dev/null +++ b/integration_tests/management/network_map_db/group_test.go @@ -0,0 +1,54 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "testing" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetGroups(t *testing.T) { + ctx := context.TODO() + + groups, resourceToGroupIdx, err := conn(t, ctx).GetGroups(ctx, "account-1") + assert.NoError(t, err) + assert.Contains(t, + groups, + nmdata.Group{ID: "group-one-resource-id", Name: "group-1-name", PublicID: "group-one-resource-id-public", Resources: []nmdata.Resource{{ID: "host-id-1", Type: "host"}}, Peers: []string{"peer-id-1"}}, + ) + assert.NotNil(t, resourceToGroupIdx["host-id-1"]["group-one-resource-id"]) + assert.Contains(t, + groups, + nmdata.Group{ID: "group-two-resources-id", Name: "group-2-name", PublicID: "group-two-resources-id-public", + Resources: []nmdata.Resource{{ID: "subnet-id-1", Type: "subnet"}, {ID: "host-id-2", Type: "host"}}, + Peers: []string{"peer-id-2", "peer-id-3"}}, + ) + assert.NotNil(t, resourceToGroupIdx["host-id-2"]["group-two-resources-id"]) + assert.NotNil(t, resourceToGroupIdx["subnet-id-1"]["group-two-resources-id"]) + assert.Contains(t, + groups, + nmdata.Group{ID: "group-no-resources-id", Name: "group-3-name", PublicID: "group-no-resources-id-public"}) +} + +// Verify handling of empty fields in groups table +// Verify that group's PublicID gets populated on retrieval +// TODO (dmitri) PublicID should not be populated with delta updates, +// which require stable PublicIDs +func TestGetGroupsWithoutExpectedFields(t *testing.T) { + ctx := context.TODO() + + execQuery(t, ctx, + "insert into accounts (id) VALUES('random-id')") + + execQuery(t, ctx, + "insert into groups (id, account_id) VALUES('g2-test-group-id-1','random-id')") + + groups, _, err := conn(t, ctx).GetGroups(ctx, "random-id") + assert.NoError(t, err) + require.Len(t, groups, 1) + assert.NotEmpty(t, groups[0].PublicID) +} diff --git a/integration_tests/management/network_map_db/main_test.go b/integration_tests/management/network_map_db/main_test.go new file mode 100644 index 000000000..78c8c8ec8 --- /dev/null +++ b/integration_tests/management/network_map_db/main_test.go @@ -0,0 +1,99 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + _ "embed" + "os" + "testing" + "time" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" + networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql" + networkmap_sqlite "github.com/netbirdio/netbird/management/internals/network_map_db/sqlite" + "github.com/netbirdio/netbird/management/server/types" +) + +//go:embed base_data.sql +var baseData string + +var ( + pgstore *networkmap_pgsql.PgStore + sqlitestore *networkmap_sqlite.SqliteStore + engine string +) + +func TestMain(m *testing.M) { + var cleanup func() + kind, _ := os.LookupEnv("NETBIRD_STORE_ENGINE") + switch kind { + case string(types.PostgresStoreEngine): + engine = string(types.PostgresStoreEngine) + pgstore, cleanup = createPGTestStore(baseData) + pgstore.UsingTimeZone(time.UTC) + case "", string(types.SqliteStoreEngine): + engine = string(types.SqliteStoreEngine) + sqlitestore, cleanup = createSqliteTestStore(baseData) + default: + log.Fatalf("unsupported db '%s' in NETBIRD_STORE_ENGINE env var", kind) + } + + code := m.Run() + + cleanup() + os.Exit(code) +} + +func conn(t *testing.T, ctx context.Context) networkmapdb.NetworkMapDBStoreConn { + t.Helper() + switch engine { + case string(types.PostgresStoreEngine): + c, err := pgstore.Pool.Acquire(ctx) + assert.NoError(t, err) + return pgstore.UsingConnection(c.Conn()) + case string(types.SqliteStoreEngine): + return sqlitestore.UsingConn() + } + log.Fatalf("unknown db engine kind %s", engine) + return nil +} + +func store(t *testing.T) networkmapdb.NetworkMapDBStore { + t.Helper() + switch engine { + case string(types.PostgresStoreEngine): + return pgstore + case string(types.SqliteStoreEngine): + return sqlitestore + } + log.Fatalf("unknown db engine kind %s", engine) + return nil +} + +func execQuery(t *testing.T, ctx context.Context, q string) { + t.Helper() + switch engine { + case string(types.PostgresStoreEngine): + _, err := pgstore.Pool.Exec(ctx, q) + assert.NoError(t, err) + case string(types.SqliteStoreEngine): + _, err := sqlitestore.Db.ExecContext(ctx, q) + assert.NoError(t, err) + } +} + +// use to parse time in time.RFC3339Nano format +// returns the time in the UTC time zone +func mustParseTime(t string) *time.Time { + tt, err := time.Parse(time.RFC3339Nano, t) + if err != nil { + panic(err) + } + + utc := tt.UTC() + return &utc +} diff --git a/integration_tests/management/network_map_db/nameserver_test.go b/integration_tests/management/network_map_db/nameserver_test.go new file mode 100644 index 000000000..d6243a6e3 --- /dev/null +++ b/integration_tests/management/network_map_db/nameserver_test.go @@ -0,0 +1,61 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "net/netip" + "testing" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/stretchr/testify/assert" +) + +func TestGetNameServerGroups(t *testing.T) { + ctx := context.TODO() + + execQuery(t, ctx, + `insert into name_server_groups (id, public_id, name, description, name_servers, groups, domains, enabled, search_domains_enabled, "primary", account_id) + VALUES('nsgroup-1','nsgroup-1-public','nsgroup-1','nsgroup-1','[{"IP":"192.168.31.2","NSType":1,"Port":53}]','["group-one-resource-id"]','["test-1.com"]',TRUE,FALSE,TRUE,'account-1')`) + execQuery(t, ctx, + `insert into name_server_groups (id, public_id, name, description, name_servers, groups, domains, enabled, search_domains_enabled,"primary",account_id) + VALUES('nsgroup-2','nsgroup-2-public','nsgroup-2','nsgroup-2','[{"IP":"192.168.32.3","NSType":1,"Port":53}]','["group-one-resource-id","group-no-resources-id"]','["test-1.com","test-2.com"]',TRUE,FALSE,TRUE,'account-1')`) + execQuery(t, ctx, + `insert into name_server_groups (id, public_id, name, description, name_servers, groups, domains, enabled, search_domains_enabled,"primary",account_id) + VALUES('nsgroup-3','nsgroup-3-public',null,null,null,null,null,TRUE,FALSE,FALSE,'account-1')`) + + nsgroups, err := conn(t, ctx).GetNameServerGroups(ctx, "account-1") + assert.NoError(t, err) + + assert.Contains(t, nsgroups, nmdata.NameServerGroup{ + ID: "nsgroup-1", + PublicID: "nsgroup-1-public", + Name: "nsgroup-1", + Description: "nsgroup-1", + NameServers: []nmdata.NameServer{{IP: netip.MustParseAddr("192.168.31.2"), NSType: 1, Port: 53}}, + Groups: []string{"group-one-resource-id"}, + Domains: []string{"test-1.com"}, + Primary: true, + SearchDomainsEnabled: false, + Enabled: true, + }) + assert.Contains(t, nsgroups, nmdata.NameServerGroup{ + ID: "nsgroup-2", + PublicID: "nsgroup-2-public", + Name: "nsgroup-2", + Description: "nsgroup-2", + NameServers: []nmdata.NameServer{{IP: netip.MustParseAddr("192.168.32.3"), NSType: 1, Port: 53}}, + Groups: []string{"group-one-resource-id", "group-no-resources-id"}, + Domains: []string{"test-1.com", "test-2.com"}, + Primary: true, + SearchDomainsEnabled: false, + Enabled: true, + }) + assert.Contains(t, nsgroups, nmdata.NameServerGroup{ + ID: "nsgroup-3", + PublicID: "nsgroup-3-public", + Primary: false, + SearchDomainsEnabled: false, + Enabled: true, + }) +} diff --git a/integration_tests/management/network_map_db/network_map_data.sql b/integration_tests/management/network_map_db/network_map_data.sql new file mode 100644 index 000000000..d94e2f4aa --- /dev/null +++ b/integration_tests/management/network_map_db/network_map_data.sql @@ -0,0 +1,108 @@ +insert into accounts (id, network_identifier, network_net, network_net_v6, network_dns, network_serial,dns_settings_disabled_management_groups, + settings_peer_login_expiration_enabled, settings_peer_login_expiration, settings_peer_inactivity_expiration_enabled, + settings_peer_inactivity_expiration, settings_dns_domain, settings_ipv6_enabled_groups, settings_routing_peer_dns_resolution_enabled, + settings_lazy_connection_enabled, settings_auto_update_version, settings_auto_update_always, settings_metrics_push_enabled) +VALUES('account-33','network-331','{"IP":"100.103.0.0","Mask":"//8AAA=="}','{"IP":"fdde:e995:fd38:a465::","Mask":"//////////8AAAAAAAAAAA=="}','',1,'["disabled-group-1","disabled-group-2"]', + true, 86400000000000, false, + 86400000000000, null, '["33-group-one-resource-id"]', false, + false, 'disabled', false, false); +insert into groups (id, account_id, name, resources, public_id) VALUES('33-group-one-resource-id','account-33','group-1-name', '[{"ID":"host-id-1","Type":"host"}]','group-one-resource-id-public'); +insert into groups (id, account_id, name, resources, public_id) VALUES('33-group-two-resources-id','account-33','group-2-name', '[{"ID":"subnet-id-1","Type":"subnet"}, {"ID":"host-id-2","Type":"host"}]','33-group-two-resources-id-public'); +insert into groups (id, account_id, name, resources, public_id) VALUES('33-group-no-resources-id','account-33','group-3-name', null,'33-group-no-resources-id-public'); +insert into group_peers (account_id, peer_id, group_id) VALUES('account-33','peer-id-331','33-group-one-resource-id'); +insert into group_peers (account_id, peer_id, group_id) VALUES('account-33','peer-id-332','33-group-two-resources-id'); +insert into group_peers (account_id, peer_id, group_id) VALUES('account-33','peer-id-333','33-group-two-resources-id'); +insert into peers (id, account_id, "key", ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6, + peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster, + meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, + meta_capabilities, meta_flags, meta_sync_message_version, + location_country_code, location_city_name, location_connection_ip) + values('peer-id-331','account-33','key-331','ssh-key-1','peer-1','["extra-peer-1"]','user-id-1',true,true,'2026-08-06 13:25:59.12999','"10.10.10.1"','"fdf4:ba80:6aa5:89f1:44d7:8701:8699:4940"', + false,true,true,'cluster-1.netbird.services', + '0.76.0','linux','26.4.1','6.8.0-134-generic','[{"NetIP":"fe80::8b4c:973f:a76b:3771/64","Mac":"00:15:5d:24:0c:ac"},{"NetIP":"192.168.16.1/20","Mac":"00:15:5d:24:0c:ac"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]', + '[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',1, + 'DE','Berlin','"46.201.148.187"'); +insert into peers (id,account_id,"key", ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6, + peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster, + meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, + meta_capabilities, meta_flags, meta_sync_message_version, + location_country_code, location_city_name, location_connection_ip) + values('peer-id-332','account-33','key-332','ssh-key-2','peer-2','["extra-peer-2"]','user-id-2',true,true,'2026-08-06 14:25:59.12999','"10.10.100.1"','"fdf5:ba80:6aa5:89f1:44d7:8701:8699:4940"', + false,true,true,'cluster-2.netbird.services', + '0.76.1','linux','26.4.2','6.8.0-135-generic','[{"NetIP":"fe81::8b4c:973f:a76b:3771/64","Mac":"00:15:5d:24:0c:ad"},{"NetIP":"192.168.17.1/20","Mac":"00:15:5d:24:0c:ad"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]', + '[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',0, + 'DE','Berlin','"46.201.149.187"'); +insert into peers (id,account_id,"key", ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6, + peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster, + meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, + meta_capabilities, meta_flags, meta_sync_message_version, + location_country_code, location_city_name, location_connection_ip) + values('peer-id-333','account-33','key-333','ssh-key-3','peer-3','["extra-peer-3"]','user-id-3',true,true,'2026-08-06 12:25:59.12999','"10.10.200.1"','"fdf6:ba80:6aa5:89f1:44d7:8701:8699:4940"', + false,true,true,'cluster-3.netbird.services', + '0.76.2','linux','26.4.3','6.8.0-136-generic','[{"NetIP":"fe82::8b4c:973f:a76b:3771/64","Mac":"00:15:5d:24:0c:ae"},{"NetIP":"192.168.18.1/20","Mac":"00:15:5d:24:0c:ae"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]', + '[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',1, + 'DE','Berlin','"46.201.150.187"'); + +insert into zones (id, account_id, domain, enabled, enable_search_domain, distribution_groups) + VALUES('zone-331','account-33','test-331.com',true,true,'["33-group-one-resource-id"]'); +insert into zones (id, account_id, domain, enabled, enable_search_domain, distribution_groups) + VALUES('zone-332','account-33','disabled-331.com',false,true,'["33-group-one-resource-id"]'); +insert into zones (id, account_id, domain, enabled, enable_search_domain, distribution_groups) + VALUES('zone-333','account-33','search-off-331.com',true,false,'["33-group-two-resources-id"]'); +insert into records (id, account_id, zone_id, name, type, ttl, content) + VALUES('record-333','account-33','zone-332','test.disabled-331.com','A',1800,'1.1.1.9'); +insert into records (id, account_id, zone_id, name, type, ttl, content) + VALUES('record-334','account-33','zone-333','test.search-off-331.com','A',1800,'1.1.1.3'); +insert into records (id, account_id, zone_id, name, type, ttl, content) + VALUES('record-335','account-33','zone-333','alias.search-off-331.com','CNAME',1800,'test.search-off-331.com'); +insert into records (id, account_id, zone_id, name, type, ttl, content) + VALUES('record-331','account-33','zone-331','test.test-331.com','A',1800,'1.1.1.1'); +insert into records (id, account_id, zone_id, name, type, ttl, content) + VALUES('record-332','account-33','zone-331','test2.test-331.com','A',1800,'1.1.1.2'); + +insert into domains (id, account_id, domain, target_cluster) + VALUES('domain-331','account-33','test-331.com','target-1.cluster.local'); + +insert into name_server_groups (id, public_id, name, description, name_servers, groups, domains, enabled, search_domains_enabled, "primary", account_id) + VALUES('nsgroup-331','nsgroup-1-public','nsgroup-1','nsgroup-1','[{"IP":"192.168.31.2","NSType":1,"Port":53}]','["33-group-one-resource-id"]','["test-1.com"]',TRUE,FALSE,TRUE,'account-33'); +insert into name_server_groups (id, public_id, name, description, name_servers, groups, domains, enabled, search_domains_enabled,"primary",account_id) + VALUES('nsgroup-332','nsgroup-2-public','nsgroup-2','nsgroup-2','[{"IP":"192.168.32.3","NSType":1,"Port":53}]','["33-group-one-resource-id","33-group-no-resources-id"]','["test-1.com","test-2.com"]',TRUE,FALSE,TRUE,'account-33'); + +insert into network_resources (id, account_id, network_id, public_id, name, description, type, domain, prefix, enabled) + VALUES('net-resource-331','account-33','network-331','net-resource-public-1','network-resource-1','network-resource-1','subnet','','"10.0.0.0/16"',TRUE); +insert into network_resources (id, account_id, network_id, public_id, name, description, type, domain, prefix, enabled) + VALUES('net-resource-332','account-33','network-332','net-resource-public-2','network-resource-2','network-resource-2','domain','test.com','',TRUE); + +insert into network_routers (id, account_id, public_id, peer, network_id, masquerade, metric, enabled, peer_groups) + VALUES('test-nr-id-331','account-33','public-id-1','peer-id-331','network-id-1',TRUE,999,TRUE,'["33-group-one-resource-id"]'); +insert into network_routers (id, account_id, public_id, peer, network_id, masquerade, metric, enabled, peer_groups) + VALUES('test-nr-id-332','account-33','public-id-2','','network-id-2',TRUE,333,TRUE,'["33-group-two-resources-id","33-group-no-resources-id"]'); + +insert into networks (id, account_id, public_id) VALUES('network-331','account-33','network-1-public'); +insert into networks (id, account_id, public_id) VALUES('network-332','account-33','network-2-public'); + +insert into policies (id, public_id, account_id, enabled, source_posture_checks) + values('policy-331','policy-1-public','account-33',true,'["posture-checks-1","posture-checks-2"]'); +insert into policy_rules (id, policy_id, enabled, action, protocol, bidirectional, sources, destinations, + source_resource, destination_resource, ports, port_ranges, + authorized_groups, authorized_user) + values('policy-331-rule-1','policy-331',true,'accept','tcp',true,'["33-group-one-resource-id","33-group-two-resources-id"]','["33-group-one-resource-id","33-group-two-resources-id"]', + '{"ID":"host-id-1","Type":"host"}','{"ID":"domain-331","Type":"domain"}','["8080","8443"]', '[{"Start":8080,"End":8090}]', + '{"33-group-one-resource-id":["user-1", "user-2"]}','user-3'); + +insert into posture_checks (id, account_id, public_id, checks) + VALUES('posturecheck-331','account-33','posturecheck-1-public', + '{"NBVersionCheck":{"MinVersion":"0.25.0"}, + "OSVersionCheck":{"Darwin":{"MinVersion":"12.0"}}, + "GeoLocationCheck":{"Locations":[{"CountryCode":"FI","CityName":""}],"Action":"allow"}, + "PeerNetworkRangeCheck":{"Action":"deny","Ranges":["192.168.0.1/24"]}}'); + +insert into routes (id, account_id, public_id, network, domains, keep_route, net_id, description, + peer, peer_groups, network_type, masquerade, metric, enabled, + groups, access_control_groups, skip_auto_apply) + VALUES('route-331','account-33','route-1-public','"172.0.0.0/16"','["test-1.com"]',true,'route-331-net-id','route-1', + 'peer-id-331','["33-group-one-resource-id"]',1,true,9999,true, + '["33-group-one-resource-id"]','["33-group-one-resource-id"]',false); + +insert into services (id, account_id, enabled, private, access_groups, proxy_cluster, domain) + values('service-331','account-33',true,true,'["33-group-one-resource-id"]','test-1.com','test-332.com'); diff --git a/integration_tests/management/network_map_db/network_map_data_golden.json b/integration_tests/management/network_map_db/network_map_data_golden.json new file mode 100644 index 000000000..bb0ccd30b --- /dev/null +++ b/integration_tests/management/network_map_db/network_map_data_golden.json @@ -0,0 +1,546 @@ +{ + "Peers": { + "peer-id-331": { + "ID": "peer-id-331", + "Key": "key-331", + "SSHKey": "ssh-key-1", + "DNSLabel": "peer-1", + "UserID": "user-id-1", + "SSHEnabled": true, + "LoginExpirationEnabled": true, + "LastLogin": "2026-08-06T13:25:59.12999Z", + "IP": "10.10.10.1", + "IPv6": "fdf4:ba80:6aa5:89f1:44d7:8701:8699:4940", + "RequiresApproval": false, + "ExtraDNSLabels": [ + "extra-peer-1" + ], + "Meta": { + "WtVersion": "0.76.0", + "GoOS": "linux", + "OSVersion": "26.4.1", + "KernelVersion": "6.8.0-134-generic", + "NetworkAddresses": [ + { + "NetIP": "fe80::8b4c:973f:a76b:3771/64" + }, + { + "NetIP": "192.168.16.1/20" + } + ], + "Files": [ + { + "Path": "/usr/bin/netbird", + "ProcessIsRunning": false + } + ], + "Capabilities": [ + 1, + 2 + ], + "Flags": { + "ServerSSHAllowed": true, + "DisableIPv6": false + }, + "SyncMessageVersion": 1 + }, + "ProxyMeta": { + "Embedded": true, + "Cluster": "cluster-1.netbird.services" + }, + "Location": { + "CountryCode": "DE", + "CityName": "Berlin", + "ConnectionIP": "46.201.148.187" + } + }, + "peer-id-332": { + "ID": "peer-id-332", + "Key": "key-332", + "SSHKey": "ssh-key-2", + "DNSLabel": "peer-2", + "UserID": "user-id-2", + "SSHEnabled": true, + "LoginExpirationEnabled": true, + "LastLogin": "2026-08-06T14:25:59.12999Z", + "IP": "10.10.100.1", + "IPv6": "fdf5:ba80:6aa5:89f1:44d7:8701:8699:4940", + "RequiresApproval": false, + "ExtraDNSLabels": [ + "extra-peer-2" + ], + "Meta": { + "WtVersion": "0.76.1", + "GoOS": "linux", + "OSVersion": "26.4.2", + "KernelVersion": "6.8.0-135-generic", + "NetworkAddresses": [ + { + "NetIP": "fe81::8b4c:973f:a76b:3771/64" + }, + { + "NetIP": "192.168.17.1/20" + } + ], + "Files": [ + { + "Path": "/usr/bin/netbird", + "ProcessIsRunning": false + } + ], + "Capabilities": [ + 1, + 2 + ], + "Flags": { + "ServerSSHAllowed": true, + "DisableIPv6": false + }, + "SyncMessageVersion": 0 + }, + "ProxyMeta": { + "Embedded": true, + "Cluster": "cluster-2.netbird.services" + }, + "Location": { + "CountryCode": "DE", + "CityName": "Berlin", + "ConnectionIP": "46.201.149.187" + } + }, + "peer-id-333": { + "ID": "peer-id-333", + "Key": "key-333", + "SSHKey": "ssh-key-3", + "DNSLabel": "peer-3", + "UserID": "user-id-3", + "SSHEnabled": true, + "LoginExpirationEnabled": true, + "LastLogin": "2026-08-06T12:25:59.12999Z", + "IP": "10.10.200.1", + "IPv6": "fdf6:ba80:6aa5:89f1:44d7:8701:8699:4940", + "RequiresApproval": false, + "ExtraDNSLabels": [ + "extra-peer-3" + ], + "Meta": { + "WtVersion": "0.76.2", + "GoOS": "linux", + "OSVersion": "26.4.3", + "KernelVersion": "6.8.0-136-generic", + "NetworkAddresses": [ + { + "NetIP": "fe82::8b4c:973f:a76b:3771/64" + }, + { + "NetIP": "192.168.18.1/20" + } + ], + "Files": [ + { + "Path": "/usr/bin/netbird", + "ProcessIsRunning": false + } + ], + "Capabilities": [ + 1, + 2 + ], + "Flags": { + "ServerSSHAllowed": true, + "DisableIPv6": false + }, + "SyncMessageVersion": 1 + }, + "ProxyMeta": { + "Embedded": true, + "Cluster": "cluster-3.netbird.services" + }, + "Location": { + "CountryCode": "DE", + "CityName": "Berlin", + "ConnectionIP": "46.201.150.187" + } + } + }, + "Groups": { + "33-group-no-resources-id": { + "ID": "33-group-no-resources-id", + "Name": "group-3-name", + "PublicID": "33-group-no-resources-id-public", + "Peers": null, + "Resources": null + }, + "33-group-one-resource-id": { + "ID": "33-group-one-resource-id", + "Name": "group-1-name", + "PublicID": "group-one-resource-id-public", + "Peers": [ + "peer-id-331" + ], + "Resources": [ + { + "ID": "host-id-1", + "Type": "host" + } + ] + }, + "33-group-two-resources-id": { + "ID": "33-group-two-resources-id", + "Name": "group-2-name", + "PublicID": "33-group-two-resources-id-public", + "Peers": [ + "peer-id-332", + "peer-id-333" + ], + "Resources": [ + { + "ID": "subnet-id-1", + "Type": "subnet" + }, + { + "ID": "host-id-2", + "Type": "host" + } + ] + } + }, + "Policies": [ + { + "ID": "policy-331", + "PublicID": "policy-1-public", + "Enabled": true, + "SourcePostureChecks": [ + "posture-checks-1", + "posture-checks-2" + ], + "Rules": [ + { + "ID": "policy-331", + "PolicyID": "policy-331", + "Enabled": true, + "Action": "accept", + "Protocol": "tcp", + "Bidirectional": true, + "Sources": [ + "33-group-one-resource-id", + "33-group-two-resources-id" + ], + "Destinations": [ + "33-group-one-resource-id", + "33-group-two-resources-id" + ], + "SourceResource": { + "ID": "host-id-1", + "Type": "host" + }, + "DestinationResource": { + "ID": "domain-331", + "Type": "domain" + }, + "Ports": [ + "8080", + "8443" + ], + "PortRanges": [ + { + "Start": 8080, + "End": 8090 + } + ], + "AuthorizedGroups": { + "33-group-one-resource-id": [ + "user-1", + "user-2" + ] + }, + "AuthorizedUser": "user-3" + } + ] + } + ], + "Routes": [ + { + "ID": "route-331", + "AccountID": "account-33", + "PublicID": "route-1-public", + "Network": "172.0.0.0/16", + "Domains": [ + "test-1.com" + ], + "KeepRoute": true, + "NetID": "route-331-net-id", + "Description": "route-1", + "Peer": "peer-id-331", + "PeerID": "peer-id-331", + "PeerGroups": [ + "33-group-one-resource-id" + ], + "NetworkType": 1, + "Masquerade": true, + "Metric": 9999, + "Enabled": true, + "Groups": [ + "33-group-one-resource-id" + ], + "AccessControlGroups": [ + "33-group-one-resource-id" + ], + "SkipAutoApply": false + } + ], + "NameServerGroups": [ + { + "ID": "nsgroup-331", + "PublicID": "nsgroup-1-public", + "Name": "nsgroup-1", + "Description": "nsgroup-1", + "NameServers": [ + { + "IP": "192.168.31.2", + "NSType": 1, + "Port": 53 + } + ], + "Groups": [ + "33-group-one-resource-id" + ], + "Primary": true, + "Domains": [ + "test-1.com" + ], + "Enabled": true, + "SearchDomainsEnabled": false + }, + { + "ID": "nsgroup-332", + "PublicID": "nsgroup-2-public", + "Name": "nsgroup-2", + "Description": "nsgroup-2", + "NameServers": [ + { + "IP": "192.168.32.3", + "NSType": 1, + "Port": 53 + } + ], + "Groups": [ + "33-group-one-resource-id", + "33-group-no-resources-id" + ], + "Primary": true, + "Domains": [ + "test-1.com", + "test-2.com" + ], + "Enabled": true, + "SearchDomainsEnabled": false + } + ], + "NetworkResources": [ + { + "ID": "net-resource-331", + "NetworkID": "network-331", + "AccountID": "account-33", + "PublicID": "net-resource-public-1", + "Name": "network-resource-1", + "Description": "network-resource-1", + "Type": "subnet", + "Address": "", + "Domain": "", + "Prefix": "10.0.0.0/16", + "Enabled": true + }, + { + "ID": "net-resource-332", + "NetworkID": "network-332", + "AccountID": "account-33", + "PublicID": "net-resource-public-2", + "Name": "network-resource-2", + "Description": "network-resource-2", + "Type": "domain", + "Address": "", + "Domain": "test.com", + "Prefix": "", + "Enabled": true + } + ], + "Network": { + "Identifier": "network-331", + "Net": { + "IP": "100.103.0.0", + "Mask": "//8AAA==" + }, + "NetV6": { + "IP": "fdde:e995:fd38:a465::", + "Mask": "//////////8AAAAAAAAAAA==" + }, + "Dns": "", + "Serial": 1 + }, + "DNSSettings": { + "DisabledManagementGroups": [ + "disabled-group-1", + "disabled-group-2" + ] + }, + "AccountSettings": { + "PeerLoginExpirationEnabled": true, + "PeerLoginExpiration": 86400000000000, + "PeerInactivityExpirationEnabled": false, + "PeerInactivityExpiration": 86400000000000, + "DNSDomain": "", + "IPv6EnabledGroups": [ + "33-group-one-resource-id" + ], + "RoutingPeerDNSResolutionEnabled": false, + "LazyConnectionEnabled": false, + "AutoUpdateVersion": "disabled", + "AutoUpdateAlways": false, + "MetricsPushEnabled": false + }, + "PostureChecks": { + "posturecheck-331": { + "ID": "posturecheck-331", + "Checks": { + "NBVersionCheck": { + "MinVersion": "0.25.0" + }, + "OSVersionCheck": { + "Android": null, + "Darwin": { + "MinVersion": "12.0" + }, + "Ios": null, + "Linux": null, + "Windows": null + }, + "GeoLocationCheck": { + "Locations": [ + { + "CountryCode": "FI", + "CityName": "" + } + ], + "Action": "allow" + }, + "PeerNetworkRangeCheck": { + "Action": "deny", + "Ranges": [ + "192.168.0.1/24" + ] + }, + "ProcessCheck": null + } + } + }, + "PostureValidation": null, + "AllowedUserIDs": {}, + "NetworkXIDToPublicID": { + "network-331": "network-1-public", + "network-332": "network-2-public" + }, + "PostureCheckXIDToPublicID": { + "posturecheck-331": "posturecheck-1-public" + }, + "ValidatedPeers": { + "peer-id-1": {}, + "peer-id-2": {}, + "peer-id-3": {} + }, + "ResourcePolicies": {}, + "Routers": { + "network-id-1": { + "peer-id-331": { + "PublicID": "public-id-1", + "PeerGroups": [ + "33-group-one-resource-id" + ], + "Masquerade": true, + "Metric": 999, + "Enabled": true + } + }, + "network-id-2": { + "peer-id-332": { + "PublicID": "public-id-2", + "PeerGroups": [ + "33-group-two-resources-id", + "33-group-no-resources-id" + ], + "Masquerade": true, + "Metric": 333, + "Enabled": true + }, + "peer-id-333": { + "PublicID": "public-id-2", + "PeerGroups": [ + "33-group-two-resources-id", + "33-group-no-resources-id" + ], + "Masquerade": true, + "Metric": 333, + "Enabled": true + } + } + }, + "GroupIDToUserIDs": {}, + "DNSDomain": "", + "ProxyTargetedDomainResourceIDs": {}, + "AppliedZoneCandidates": [ + { + "DistributionGroups": [ + "33-group-one-resource-id" + ], + "Zone": { + "Domain": "test-331.com.", + "Records": [ + { + "Name": "test.test-331.com.", + "Type": 1, + "Class": "IN", + "TTL": 1800, + "RData": "1.1.1.1" + }, + { + "Name": "test2.test-331.com.", + "Type": 1, + "Class": "IN", + "TTL": 1800, + "RData": "1.1.1.2" + } + ], + "SearchDomainDisabled": false, + "NonAuthoritative": true + } + }, + { + "DistributionGroups": [ + "33-group-two-resources-id" + ], + "Zone": { + "Domain": "search-off-331.com.", + "Records": [ + { + "Name": "test.search-off-331.com.", + "Type": 1, + "Class": "IN", + "TTL": 1800, + "RData": "1.1.1.3" + }, + { + "Name": "alias.search-off-331.com.", + "Type": 5, + "Class": "IN", + "TTL": 1800, + "RData": "test.search-off-331.com." + } + ], + "SearchDomainDisabled": true, + "NonAuthoritative": true + } + } + ], + "PrivateServiceCandidates": null, + "Services": null +} \ No newline at end of file diff --git a/integration_tests/management/network_map_db/network_map_data_test.go b/integration_tests/management/network_map_db/network_map_data_test.go new file mode 100644 index 000000000..00c0ec03f --- /dev/null +++ b/integration_tests/management/network_map_db/network_map_data_test.go @@ -0,0 +1,74 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + _ "embed" + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" + "github.com/netbirdio/netbird/management/server/integrations/integrated_validator" + "github.com/netbirdio/netbird/management/server/settings" + "github.com/netbirdio/netbird/management/server/types" + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +//go:embed network_map_data.sql +var nmapData string + +//go:embed network_map_data_golden.json +var goldenNMap string + +const EnvUpdateGoldenData = "NMAP_UPDATE_GOLDEN_DATA" + +func TestGetNetworkMapData(t *testing.T) { + ctx := context.TODO() + + // The two mocks are generated by different mock frameworks, so each needs a + // controller of its own kind. + extraSettingsManager := settings.NewMockManager(gomock.NewController(t)) + extraSettingsManager.EXPECT().GetExtraSettings(gomock.Any(), gomock.Any()).Return(&types.ExtraSettings{}, nil) + + peerValidators := integrated_validator.NewMockIntegratedValidator(gomock.NewController(t)) + peerValidators.EXPECT().GetValidatedPeers(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return( + map[string]struct{}{ + "peer-id-1": {}, + "peer-id-2": {}, + "peer-id-3": {}, + }, nil) + + storeImpl := networkmapdb.NetworkMapDBStoreImpl{ + Store: store(t), + ExtraSettingsManager: extraSettingsManager, + IntegratedPeerValidator: peerValidators, + } + + for _, query := range strings.Split(nmapData, ";") { + if err := store(t).Exec(ctx, query); err != nil { + log.Fatalf("error initializing nmap test: %s", err.Error()) + } + } + + nmap, err := storeImpl.GetNetworkMapData(ctx, "account-33") + assert.NoError(t, err) + + serializedNMap, err := json.MarshalIndent(nmap, "", " ") + assert.NoError(t, err) + + if _, ok := os.LookupEnv(EnvUpdateGoldenData); ok { + _, filename, _, _ := runtime.Caller(0) + tosavepath := filepath.Join(filepath.Dir(filename), "network_map_data_golden.json") + err = os.WriteFile(tosavepath, serializedNMap, 0644) + assert.NoError(t, err) + goldenNMap = string(serializedNMap) + } + assert.Equal(t, goldenNMap, string(serializedNMap)) +} diff --git a/integration_tests/management/network_map_db/network_resource_test.go b/integration_tests/management/network_map_db/network_resource_test.go new file mode 100644 index 000000000..4325ed3ba --- /dev/null +++ b/integration_tests/management/network_map_db/network_resource_test.go @@ -0,0 +1,65 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "net/netip" + "testing" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/stretchr/testify/assert" +) + +func TestGetNetworkResources(t *testing.T) { + ctx := context.TODO() + + execQuery(t, ctx, + `insert into network_resources (id, account_id, network_id, public_id, name, description, type, domain, prefix, enabled) + VALUES('net-resource-1','account-1','network-1','net-resource-public-1','network-resource-1','network-resource-1','subnet','','"10.0.0.0/16"',TRUE)`) + execQuery(t, ctx, + `insert into network_resources (id, account_id, network_id, public_id, name, description, type, domain, prefix, enabled) + VALUES('net-resource-2','account-1','network-2','net-resource-public-2','network-resource-2','network-resource-2','domain','test.com','',TRUE)`) + execQuery(t, ctx, + `insert into network_resources (id, account_id, network_id, public_id, name, description, type, domain, prefix, enabled) + VALUES('net-resource-3','account-1','network-3','net-resource-public-3','network-resource-3','network-resource-3','host','','"10.0.0.1/32"',TRUE)`) + + resources, err := conn(t, ctx).GetNetworkResources(ctx, "account-1") + assert.NoError(t, err) + + assert.Contains(t, resources, nmdata.NetworkResource{ + ID: "net-resource-1", + AccountID: "account-1", + NetworkID: "network-1", + PublicID: "net-resource-public-1", + Name: "network-resource-1", + Description: "network-resource-1", + Type: "subnet", + Domain: "", + Prefix: netip.MustParsePrefix("10.0.0.0/16"), + Enabled: true, + }) + assert.Contains(t, resources, nmdata.NetworkResource{ + ID: "net-resource-2", + AccountID: "account-1", + NetworkID: "network-2", + PublicID: "net-resource-public-2", + Name: "network-resource-2", + Description: "network-resource-2", + Type: "domain", + Domain: "test.com", + Enabled: true, + }) + assert.Contains(t, resources, nmdata.NetworkResource{ + ID: "net-resource-3", + AccountID: "account-1", + NetworkID: "network-3", + PublicID: "net-resource-public-3", + Name: "network-resource-3", + Description: "network-resource-3", + Type: "host", + Domain: "", + Prefix: netip.MustParsePrefix("10.0.0.1/32"), + Enabled: true, + }) +} diff --git a/integration_tests/management/network_map_db/network_router_test.go b/integration_tests/management/network_map_db/network_router_test.go new file mode 100644 index 000000000..fa7ea2a04 --- /dev/null +++ b/integration_tests/management/network_map_db/network_router_test.go @@ -0,0 +1,33 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "testing" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/stretchr/testify/assert" +) + +func TestGetNetworkRouters(t *testing.T) { + ctx := context.TODO() + + execQuery(t, ctx, + `insert into network_routers (id, account_id, public_id, peer, network_id, masquerade, metric, enabled, peer_groups) + VALUES('test-nr-id-1','account-1','public-id-1','peer-id-1','network-id-1',TRUE,999,TRUE,'["group-one-resource-id"]')`) + execQuery(t, ctx, + `insert into network_routers (id, account_id, public_id, peer, network_id, masquerade, metric, enabled, peer_groups) + VALUES('test-nr-id-2','account-1','public-id-2','','network-id-2',TRUE,333,TRUE,'["group-two-resources-id","group-no-resources-id"]')`) + + routers, err := conn(t, ctx).GetNetworkRouters(ctx, "account-1") + assert.NoError(t, err) + assert.NotEmpty(t, routers) + + assert.Equal(t, routers["network-id-1"], + map[string]*nmdata.NetworkRouter{"peer-id-1": {PublicID: "public-id-1", Masquerade: true, Metric: 999, Enabled: true, PeerGroups: []string{"group-one-resource-id"}}}) + assert.Equal(t, routers["network-id-2"], + map[string]*nmdata.NetworkRouter{ + "peer-id-2": {PublicID: "public-id-2", Masquerade: true, Metric: 333, Enabled: true, PeerGroups: []string{"group-two-resources-id", "group-no-resources-id"}}, + "peer-id-3": {PublicID: "public-id-2", Masquerade: true, Metric: 333, Enabled: true, PeerGroups: []string{"group-two-resources-id", "group-no-resources-id"}}}) +} diff --git a/integration_tests/management/network_map_db/network_test.go b/integration_tests/management/network_map_db/network_test.go new file mode 100644 index 000000000..fbccee504 --- /dev/null +++ b/integration_tests/management/network_map_db/network_test.go @@ -0,0 +1,56 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "encoding/json" + "net" + "testing" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/stretchr/testify/assert" +) + +func TestGetNetwork(t *testing.T) { + ctx := context.TODO() + + network, err := conn(t, ctx).GetNetwork(ctx, "account-1") + assert.NoError(t, err) + assert.Equal(t, network, nmdata.Network{ + Identifier: "network-1", + Net: mustParseCIDR("100.103.0.0/16"), + NetV6: mustParseCIDR("fdde:e995:fd38:a465::/64"), + Serial: 1, + }) + + network, err = conn(t, ctx).GetNetwork(ctx, "account-2") + assert.NoError(t, err) + assert.Equal(t, network, nmdata.Network{ + Identifier: "network-2", + Net: mustParseCIDR("110.0.0.0/16"), + NetV6: mustParseCIDR("fddf:e995:fd38:a465::/64"), + Serial: 2, + }) +} + +func mustParseCIDR(s string) net.IPNet { + var toret net.IPNet + + _, net, err := net.ParseCIDR(s) + if err != nil { + panic(err) + } + + jn, err := json.Marshal(net) + if err != nil { + panic(err) + } + + err = json.Unmarshal(jn, &toret) + if err != nil { + panic(err) + } + + return toret +} diff --git a/integration_tests/management/network_map_db/networks_test.go b/integration_tests/management/network_map_db/networks_test.go new file mode 100644 index 000000000..5af771522 --- /dev/null +++ b/integration_tests/management/network_map_db/networks_test.go @@ -0,0 +1,26 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetNetworks(t *testing.T) { + ctx := context.TODO() + + execQuery(t, ctx, + `insert into networks (id, account_id, public_id) VALUES('network-1','account-1','network-1-public')`) + execQuery(t, ctx, + `insert into networks (id, account_id, public_id) VALUES('network-2','account-1','network-2-public')`) + + networksIdx, err := conn(t, ctx).GetNetworkXIDToPublicIdMap(ctx, "account-1") + assert.NoError(t, err) + assert.Equal(t, networksIdx, map[string]string{ + "network-1": "network-1-public", + "network-2": "network-2-public", + }) +} diff --git a/integration_tests/management/network_map_db/peer_test.go b/integration_tests/management/network_map_db/peer_test.go new file mode 100644 index 000000000..e33c3ea3a --- /dev/null +++ b/integration_tests/management/network_map_db/peer_test.go @@ -0,0 +1,166 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "net" + "net/netip" + "testing" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/stretchr/testify/assert" +) + +func TestGetPeers(t *testing.T) { + ctx := context.TODO() + + peers, clusterToPeersIdx, err := conn(t, ctx).GetPeers(ctx, "account-1") + assert.NoError(t, err) + + // shouldn't be returned in the index, as it's not connected + execQuery(t, ctx, + `insert into peers (id,account_id,"key",ssh_key,proxy_meta_embedded,peer_status_connected) + values('peer-4','account-1','key-4','ssh-key-4',true,false)`) + // shouldn't be returned in the index as it doesn't have cluster set + execQuery(t, ctx, + `insert into peers (id,account_id,"key",ssh_key,proxy_meta_embedded,peer_status_connected) + values('peer-5','account-1','key-5','ssh-key-5',false,true)`) + + peer1 := nmdata.Peer{ + ID: "peer-id-1", + Key: "key-1", + SSHKey: "ssh-key-1", + DNSLabel: "peer-1", + ExtraDNSLabels: []string{"extra-peer-1"}, + UserID: "user-id-1", + SSHEnabled: true, + LoginExpirationEnabled: true, + LastLogin: mustParseTime("2026-08-06T13:25:59.12999+00:00"), + IP: netip.MustParseAddr("10.10.10.1"), + IPv6: netip.MustParseAddr("fdf4:ba80:6aa5:89f1:44d7:8701:8699:4940"), + RequiresApproval: false, + Meta: nmdata.PeerSystemMeta{ + WtVersion: "0.76.0", + GoOS: "linux", + OSVersion: "26.4.1", + KernelVersion: "6.8.0-134-generic", + NetworkAddresses: []nmdata.NetworkAddress{ + {NetIP: netip.MustParsePrefix("fe80::8b4c:973f:a76b:3771/64")}, + {NetIP: netip.MustParsePrefix("192.168.16.1/20")}, + }, + Files: []nmdata.File{ + {Path: "/usr/bin/netbird", ProcessIsRunning: false}, + }, + Capabilities: []int32{1, 2}, + Flags: nmdata.Flags{ + ServerSSHAllowed: true, + DisableIPv6: false, + }, + SyncMessageVersion: 1, + }, + ProxyMeta: nmdata.ProxyMeta{ + Embedded: true, + Cluster: "cluster-1.netbird.services", + }, + Location: nmdata.PeerLocation{ + CountryCode: "DE", + CityName: "Berlin", + ConnectionIP: net.ParseIP("46.201.148.187"), + }, + } + peer2 := nmdata.Peer{ + ID: "peer-id-2", + Key: "key-2", + SSHKey: "ssh-key-2", + DNSLabel: "peer-2", + ExtraDNSLabels: []string{"extra-peer-2"}, + UserID: "user-id-2", + SSHEnabled: true, + LoginExpirationEnabled: true, + LastLogin: mustParseTime("2026-08-06T14:25:59.12999+00:00"), + IP: netip.MustParseAddr("10.10.100.1"), + IPv6: netip.MustParseAddr("fdf5:ba80:6aa5:89f1:44d7:8701:8699:4940"), + RequiresApproval: false, + Meta: nmdata.PeerSystemMeta{ + WtVersion: "0.76.1", + GoOS: "linux", + OSVersion: "26.4.2", + KernelVersion: "6.8.0-135-generic", + NetworkAddresses: []nmdata.NetworkAddress{ + {NetIP: netip.MustParsePrefix("fe81::8b4c:973f:a76b:3771/64")}, + {NetIP: netip.MustParsePrefix("192.168.17.1/20")}, + }, + Files: []nmdata.File{ + {Path: "/usr/bin/netbird", ProcessIsRunning: false}, + }, + Capabilities: []int32{1, 2}, + Flags: nmdata.Flags{ + ServerSSHAllowed: true, + DisableIPv6: false, + }, + SyncMessageVersion: 0, + }, + ProxyMeta: nmdata.ProxyMeta{ + Embedded: true, + Cluster: "cluster-2.netbird.services", + }, + Location: nmdata.PeerLocation{ + CountryCode: "DE", + CityName: "Berlin", + ConnectionIP: net.ParseIP("46.201.149.187"), + }, + } + peer3 := nmdata.Peer{ + ID: "peer-id-3", + Key: "key-3", + SSHKey: "ssh-key-3", + DNSLabel: "peer-3", + ExtraDNSLabels: []string{"extra-peer-3"}, + UserID: "user-id-3", + SSHEnabled: true, + LoginExpirationEnabled: true, + LastLogin: mustParseTime("2026-08-06T12:25:59.12999+00:00"), + IP: netip.MustParseAddr("10.10.200.1"), + IPv6: netip.MustParseAddr("fdf6:ba80:6aa5:89f1:44d7:8701:8699:4940"), + RequiresApproval: false, + Meta: nmdata.PeerSystemMeta{ + WtVersion: "0.76.2", + GoOS: "linux", + OSVersion: "26.4.3", + KernelVersion: "6.8.0-136-generic", + NetworkAddresses: []nmdata.NetworkAddress{ + {NetIP: netip.MustParsePrefix("fe82::8b4c:973f:a76b:3771/64")}, + {NetIP: netip.MustParsePrefix("192.168.18.1/20")}, + }, + Files: []nmdata.File{ + {Path: "/usr/bin/netbird", ProcessIsRunning: false}, + }, + Capabilities: []int32{1, 2}, + Flags: nmdata.Flags{ + ServerSSHAllowed: true, + DisableIPv6: false, + }, + SyncMessageVersion: 1, + }, + ProxyMeta: nmdata.ProxyMeta{ + Embedded: true, + Cluster: "cluster-3.netbird.services", + }, + Location: nmdata.PeerLocation{ + CountryCode: "DE", + CityName: "Berlin", + ConnectionIP: net.ParseIP("46.201.150.187"), + }, + } + + assert.Contains(t, peers, peer1) + assert.Contains(t, peers, peer2) + assert.Contains(t, peers, peer3) + + assert.Equal(t, clusterToPeersIdx, map[string][]*nmdata.Peer{ + "cluster-1.netbird.services": {&peer1}, + "cluster-2.netbird.services": {&peer2}, + "cluster-3.netbird.services": {&peer3}, + }) +} diff --git a/integration_tests/management/network_map_db/pg_test_store.go b/integration_tests/management/network_map_db/pg_test_store.go new file mode 100644 index 000000000..1710747b5 --- /dev/null +++ b/integration_tests/management/network_map_db/pg_test_store.go @@ -0,0 +1,121 @@ +//go:build integration + +package networkmap_pgsql + +import ( + "context" + "fmt" + "regexp" + "strings" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/google/uuid" + networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql" + gormstore "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/testutil" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +func createPGTestStore(baseData string) (*networkmap_pgsql.PgStore, func()) { + _, tmpdsn, err := testutil.CreatePostgresTestContainer() + if err != nil { + log.Fatalf("error starting postres container %v", err) + } + + var db *gorm.DB + for i := range 5 { + db, err = gorm.Open(postgres.Open(tmpdsn), &gorm.Config{}) + + if err == nil { + break + } + + if i < 5 { + waitTime := time.Duration(100*(i+1)) * time.Millisecond + time.Sleep(waitTime) + continue + } + + log.Fatalf("error connecting to postres db %v", err) + } + + var cleanup func() + dsn, cleanup, err := createRandomDB(tmpdsn, db) + sqlDB, _ := db.DB() + if sqlDB != nil { + sqlDB.Close() + } + if err != nil { + log.Fatalf("error creating postres db %v", err) + } + + _, err = gormstore.NewPostgresqlStoreForTests(context.TODO(), dsn, nil, false) + if err != nil { + log.Fatalf("error running migrations %v", err) + } + + ctx := context.TODO() + pgstore, err := networkmap_pgsql.NewPostgresqlStore(ctx, dsn) + if err != nil { + log.Fatal("error creating postgres store %w", err) + } + + for _, query := range strings.Split(baseData, ";") { + if _, err := pgstore.Pool.Exec(ctx, query); err != nil { + log.Fatalf("error initializing db: %s", err.Error()) + } + } + + return pgstore, cleanup +} + +func createRandomDB(dsn string, db *gorm.DB) (string, func(), error) { + dbName := fmt.Sprintf("test_db_%s", strings.ReplaceAll(uuid.New().String(), "-", "_")) + + if err := db.Exec(fmt.Sprintf("CREATE DATABASE %s", dbName)).Error; err != nil { + return "", nil, fmt.Errorf("failed to create database: %v", err) + } + + originalDSN := dsn + + cleanup := func() { + var dropDB *gorm.DB + var err error + + dropDB, err = gorm.Open(postgres.Open(originalDSN), &gorm.Config{ + SkipDefaultTransaction: true, + PrepareStmt: false, + }) + if err != nil { + log.Errorf("failed to connect for dropping database %s: %v", dbName, err) + return + } + defer func() { + if sqlDB, _ := dropDB.DB(); sqlDB != nil { + sqlDB.Close() + } + }() + + if sqlDB, _ := dropDB.DB(); sqlDB != nil { + sqlDB.SetMaxOpenConns(1) + sqlDB.SetMaxIdleConns(0) + sqlDB.SetConnMaxLifetime(time.Second) + } + + err = dropDB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s WITH (FORCE)", dbName)).Error + + if err != nil { + log.Errorf("failed to drop database %s: %v", dbName, err) + } + } + + return replaceDBName(dsn, dbName), cleanup, nil +} + +func replaceDBName(dsn, newDBName string) string { + re := regexp.MustCompile(`(?P
[:/@])(?P[^/?]+)(?P\?|$)`)
+	return re.ReplaceAllString(dsn, `${pre}`+newDBName+`${post}`)
+}
diff --git a/integration_tests/management/network_map_db/policy_test.go b/integration_tests/management/network_map_db/policy_test.go
new file mode 100644
index 000000000..1f4c543da
--- /dev/null
+++ b/integration_tests/management/network_map_db/policy_test.go
@@ -0,0 +1,146 @@
+//go:build integration
+
+package networkmap_pgsql
+
+import (
+	"context"
+	"testing"
+
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestGetPolicies(t *testing.T) {
+	ctx := context.TODO()
+
+	execQuery(t, ctx,
+		`insert into policies (id, public_id, account_id, enabled, source_posture_checks)
+		 values('policy-1','policy-1-public','account-1',true,'["posture-checks-1","posture-checks-2"]')`)
+	execQuery(t, ctx,
+		`insert into policy_rules (id, policy_id, enabled, action, protocol, bidirectional, sources, destinations,
+		                           source_resource, destination_resource, ports, port_ranges,
+								   authorized_groups, authorized_user)
+		 values('policy-1-rule-1','policy-1',true,'accept','tcp',true,'["group-one-resource-id","group-two-resources-id"]','["group-one-resource-id","group-two-resources-id"]',
+		        '{"ID":"host-id-1","Type":"host"}','{"ID":"domain-1","Type":"domain"}','["8080","8443"]', '[{"Start":8080,"End":8090}]',
+				'{"group-one-resource-id":["user-1", "user-2"]}','user-3')`)
+	execQuery(t, ctx,
+		`insert into policies (id, public_id, account_id, enabled, source_posture_checks)
+		 values('policy-2','policy-2-public','account-1',true,'["posture-checks-3","posture-checks-4"]')`)
+	execQuery(t, ctx,
+		`insert into policy_rules (id, policy_id, enabled, action, protocol, bidirectional, sources, destinations,
+		                           source_resource, destination_resource, ports, port_ranges,
+								   authorized_groups, authorized_user)
+		 values('policy-2-rule-1','policy-2',true,'accept','tcp',true,'["group-one-resource-id"]','["group-two-resources-id"]',
+		        '{"ID":"host-id-3","Type":"host"}','{"ID":"domain-3","Type":"domain"}','["8080","8443"]', '[{"Start":8080,"End":8090}]',
+				'{"group-one-resource-id":["user-6", "user-7"]}','user-8')`)
+	// policy with a rule with null fields
+	execQuery(t, ctx,
+		`insert into policies (id, public_id, account_id, enabled, source_posture_checks)
+		 values('policy-3','policy-3-public','account-1',true,null)`)
+	execQuery(t, ctx,
+		`insert into policy_rules (id, policy_id, enabled, action, protocol, bidirectional, sources, destinations,
+		                           source_resource, destination_resource, ports, port_ranges,
+								   authorized_groups, authorized_user)
+		 values('policy-3-rule-1','policy-3',true,null,null,null,null,null,null,null,null,null,null,null)`)
+	// policy with a disabled rule, destination resource and groups should not be in indexes
+	execQuery(t, ctx,
+		`insert into policies (id, public_id, account_id, enabled, source_posture_checks)
+		 values('policy-4','policy-4-public','account-1',true,null)`)
+	execQuery(t, ctx,
+		`insert into policy_rules (id, policy_id, enabled, action, protocol, bidirectional, sources, destinations,
+		                           source_resource, destination_resource, ports, port_ranges,
+								   authorized_groups, authorized_user)
+		 values('policy-4-rule-1','policy-4',false,null,null,null,null,'["group-two-resources-id"]',
+		        null,'{"ID":"domain-3","Type":"domain"}',null,null,null,null)`)
+
+	policies, policyToDestinationResourceIdx, policyToDestinationGroupIdx, err := conn(t, ctx).GetPolicies(ctx, "account-1")
+	assert.NoError(t, err)
+
+	assert.Contains(t, policies, nmdata.Policy{
+		ID:                  "policy-1",
+		PublicID:            "policy-1-public",
+		Enabled:             true,
+		SourcePostureChecks: []string{"posture-checks-1", "posture-checks-2"},
+		Rules: []*nmdata.PolicyRule{
+			{
+				ID:                  "policy-1",
+				PolicyID:            "policy-1",
+				Enabled:             true,
+				Action:              "accept",
+				Protocol:            "tcp",
+				Bidirectional:       true,
+				Sources:             []string{"group-one-resource-id", "group-two-resources-id"},
+				Destinations:        []string{"group-one-resource-id", "group-two-resources-id"},
+				SourceResource:      nmdata.Resource{ID: "host-id-1", Type: "host"},
+				DestinationResource: nmdata.Resource{ID: "domain-1", Type: "domain"},
+				Ports:               []string{"8080", "8443"},
+				PortRanges:          []nmdata.RulePortRange{{Start: 8080, End: 8090}},
+				AuthorizedGroups:    map[string][]string{"group-one-resource-id": {"user-1", "user-2"}},
+				AuthorizedUser:      "user-3",
+			},
+		},
+	})
+
+	assert.Contains(t, policies, nmdata.Policy{
+		ID:                  "policy-2",
+		PublicID:            "policy-2-public",
+		Enabled:             true,
+		SourcePostureChecks: []string{"posture-checks-3", "posture-checks-4"},
+		Rules: []*nmdata.PolicyRule{
+			{
+				ID:                  "policy-2",
+				PolicyID:            "policy-2",
+				Enabled:             true,
+				Action:              "accept",
+				Protocol:            "tcp",
+				Bidirectional:       true,
+				Sources:             []string{"group-one-resource-id"},
+				Destinations:        []string{"group-two-resources-id"},
+				SourceResource:      nmdata.Resource{ID: "host-id-3", Type: "host"},
+				DestinationResource: nmdata.Resource{ID: "domain-3", Type: "domain"},
+				Ports:               []string{"8080", "8443"},
+				PortRanges:          []nmdata.RulePortRange{{Start: 8080, End: 8090}},
+				AuthorizedGroups:    map[string][]string{"group-one-resource-id": {"user-6", "user-7"}},
+				AuthorizedUser:      "user-8",
+			},
+		},
+	})
+
+	assert.Contains(t, policies, nmdata.Policy{
+		ID:                  "policy-3",
+		PublicID:            "policy-3-public",
+		Enabled:             true,
+		SourcePostureChecks: nil,
+		Rules: []*nmdata.PolicyRule{
+			{
+				ID:       "policy-3",
+				PolicyID: "policy-3",
+				Enabled:  true,
+			},
+		},
+	})
+	assert.Contains(t, policies, nmdata.Policy{
+		ID:                  "policy-4",
+		PublicID:            "policy-4-public",
+		Enabled:             true,
+		SourcePostureChecks: nil,
+		Rules: []*nmdata.PolicyRule{
+			{
+				ID:                  "policy-4",
+				PolicyID:            "policy-4",
+				Enabled:             false,
+				Destinations:        []string{"group-two-resources-id"},
+				DestinationResource: nmdata.Resource{ID: "domain-3", Type: "domain"},
+			},
+		},
+	})
+
+	assert.Equal(t, policyToDestinationGroupIdx, map[string]map[string]any{
+		"policy-1": {"group-one-resource-id": struct{}{}, "group-two-resources-id": struct{}{}},
+		"policy-2": {"group-two-resources-id": struct{}{}},
+	})
+	assert.Equal(t, policyToDestinationResourceIdx, map[string]map[string]any{
+		"policy-1": {"domain-1": struct{}{}},
+		"policy-2": {"domain-3": struct{}{}},
+	})
+}
diff --git a/integration_tests/management/network_map_db/posture_test.go b/integration_tests/management/network_map_db/posture_test.go
new file mode 100644
index 000000000..2b4bb3f3d
--- /dev/null
+++ b/integration_tests/management/network_map_db/posture_test.go
@@ -0,0 +1,61 @@
+//go:build integration
+
+package networkmap_pgsql
+
+import (
+	"context"
+	"net/netip"
+	"testing"
+
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestGetPostureChecks(t *testing.T) {
+	ctx := context.TODO()
+
+	execQuery(t, ctx,
+		`insert into posture_checks (id, account_id, public_id, checks)
+		VALUES('posturecheck-1','account-1','posturecheck-1-public',
+		'{"NBVersionCheck":{"MinVersion":"0.25.0"},
+		  "OSVersionCheck":{"Darwin":{"MinVersion":"12.0"}},
+		  "GeoLocationCheck":{"Locations":[{"CountryCode":"FI","CityName":""}],"Action":"allow"},
+		  "PeerNetworkRangeCheck":{"Action":"deny","Ranges":["192.168.0.1/24"]}}')`)
+
+	execQuery(t, ctx,
+		`insert into posture_checks (id, account_id, public_id, checks)
+		VALUES('posturecheck-2','account-1','posturecheck-2-public',
+		'{"NBVersionCheck":{"MinVersion":"0.25.0"},
+		  "OSVersionCheck":{"Android":{"MinVersion":"0"}},
+		  "GeoLocationCheck":{"Locations":[{"CountryCode":"US","CityName":"Harker Heights"}],"Action":"allow"},
+		  "PeerNetworkRangeCheck":{"Action":"allow","Ranges":["0.0.0.0/0"]}}')`)
+	execQuery(t, ctx,
+		`insert into posture_checks (id, account_id, public_id, checks)
+		VALUES('posturecheck-3','account-1','posturecheck-3-public', null)`)
+
+	postureChecks, idToPublicIDIdx, err := conn(t, ctx).GetPostureChecks(ctx, "account-1")
+	assert.NoError(t, err)
+	assert.Equal(t, idToPublicIDIdx, map[string]string{
+		"posturecheck-1": "posturecheck-1-public",
+		"posturecheck-2": "posturecheck-2-public",
+		"posturecheck-3": "posturecheck-3-public",
+	})
+	assert.Contains(t, postureChecks, nmdata.PostureChecks{
+		ID: "posturecheck-1",
+		Checks: nmdata.ChecksDefinition{
+			NBVersionCheck:        &nmdata.NBVersionCheck{MinVersion: "0.25.0"},
+			OSVersionCheck:        &nmdata.OSVersionCheck{Darwin: &nmdata.MinVersionCheck{MinVersion: "12.0"}},
+			GeoLocationCheck:      &nmdata.GeoLocationCheck{Locations: []nmdata.GeoLocation{{CountryCode: "FI"}}, Action: "allow"},
+			PeerNetworkRangeCheck: &nmdata.PeerNetworkRangeCheck{Action: "deny", Ranges: []netip.Prefix{netip.MustParsePrefix("192.168.0.1/24")}},
+		}})
+	assert.Contains(t, postureChecks, nmdata.PostureChecks{
+		ID: "posturecheck-2",
+		Checks: nmdata.ChecksDefinition{
+			NBVersionCheck:        &nmdata.NBVersionCheck{MinVersion: "0.25.0"},
+			OSVersionCheck:        &nmdata.OSVersionCheck{Android: &nmdata.MinVersionCheck{MinVersion: "0"}},
+			GeoLocationCheck:      &nmdata.GeoLocationCheck{Locations: []nmdata.GeoLocation{{CountryCode: "US", CityName: "Harker Heights"}}, Action: "allow"},
+			PeerNetworkRangeCheck: &nmdata.PeerNetworkRangeCheck{Action: "allow", Ranges: []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")}},
+		}})
+	assert.Contains(t, postureChecks, nmdata.PostureChecks{
+		ID: "posturecheck-3"})
+}
diff --git a/integration_tests/management/network_map_db/route_test.go b/integration_tests/management/network_map_db/route_test.go
new file mode 100644
index 000000000..12e9302f9
--- /dev/null
+++ b/integration_tests/management/network_map_db/route_test.go
@@ -0,0 +1,87 @@
+//go:build integration
+
+package networkmap_pgsql
+
+import (
+	"context"
+	"net/netip"
+	"testing"
+
+	"github.com/netbirdio/netbird/shared/management/domain"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestGetRoutes(t *testing.T) {
+	ctx := context.TODO()
+
+	execQuery(t, ctx,
+		`insert into routes (id, account_id, public_id, network, domains, keep_route, net_id, description,
+	                         peer, peer_groups, network_type, masquerade, metric, enabled, 
+	                         groups, access_control_groups, skip_auto_apply)
+		VALUES('route-1','account-1','route-1-public','"172.0.0.0/16"','["test-1.com"]',true,'route-1-net-id','route-1',
+		        'peer-id-1','["group-one-resource-id"]',1,true,9999,true,
+				'["group-one-resource-id"]','["group-one-resource-id"]',false)`)
+	execQuery(t, ctx,
+		`insert into routes (id, account_id, public_id, network, domains, keep_route, net_id, description,
+	                         peer, peer_groups, network_type, masquerade, metric, enabled, 
+	                         groups, access_control_groups, skip_auto_apply)
+		VALUES('route-2','account-1','route-2-public','"172.10.0.0/16"','["test-1.com","test-2.com"]',true,'route-2-net-id','route-2',
+		        'peer-id-2','["group-two-resources-id"]',1,true,9999,true,
+				'["group-two-resources-id"]','["group-two-resources-id"]',false)`)
+	execQuery(t, ctx,
+		`insert into routes (id, account_id, public_id, network, domains, keep_route, net_id, description,
+	                         peer, peer_groups, network_type, masquerade, metric, enabled, 
+	                         groups, access_control_groups, skip_auto_apply)
+		VALUES('route-3','account-1','route-3-public',null,null,null,null,'route-3',
+		        null,null,null,null,null,null,null,null,null)`)
+
+	routes, err := conn(t, ctx).GetRoutes(ctx, "account-1")
+	assert.NoError(t, err)
+	assert.Contains(t, routes, nmdata.Route{
+		ID:                  "route-1",
+		AccountID:           "account-1",
+		PublicID:            "route-1-public",
+		Network:             netip.MustParsePrefix("172.0.0.0/16"),
+		Domains:             domain.List{"test-1.com"},
+		KeepRoute:           true,
+		NetID:               "route-1-net-id",
+		Description:         "route-1",
+		Peer:                "peer-id-1",
+		PeerID:              "peer-id-1",
+		PeerGroups:          []string{"group-one-resource-id"},
+		NetworkType:         1,
+		Masquerade:          true,
+		Metric:              9999,
+		Enabled:             true,
+		Groups:              []string{"group-one-resource-id"},
+		AccessControlGroups: []string{"group-one-resource-id"},
+		SkipAutoApply:       false,
+	})
+	assert.Contains(t, routes, nmdata.Route{
+		ID:                  "route-2",
+		AccountID:           "account-1",
+		PublicID:            "route-2-public",
+		Network:             netip.MustParsePrefix("172.10.0.0/16"),
+		Domains:             domain.List{"test-1.com", "test-2.com"},
+		KeepRoute:           true,
+		NetID:               "route-2-net-id",
+		Description:         "route-2",
+		Peer:                "peer-id-2",
+		PeerID:              "peer-id-2",
+		PeerGroups:          []string{"group-two-resources-id"},
+		NetworkType:         1,
+		Masquerade:          true,
+		Metric:              9999,
+		Enabled:             true,
+		Groups:              []string{"group-two-resources-id"},
+		AccessControlGroups: []string{"group-two-resources-id"},
+		SkipAutoApply:       false,
+	})
+	assert.Contains(t, routes, nmdata.Route{
+		ID:          "route-3",
+		AccountID:   "account-1",
+		PublicID:    "route-3-public",
+		Description: "route-3",
+	})
+}
diff --git a/integration_tests/management/network_map_db/service_test.go b/integration_tests/management/network_map_db/service_test.go
new file mode 100644
index 000000000..effc7a707
--- /dev/null
+++ b/integration_tests/management/network_map_db/service_test.go
@@ -0,0 +1,109 @@
+//go:build integration
+
+package networkmap_pgsql
+
+import (
+	"context"
+	"database/sql"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+)
+
+func TestGetPrivateServices(t *testing.T) {
+	ctx := context.TODO()
+
+	execQuery(t, ctx,
+		`insert into services (id, account_id, enabled, private, access_groups, proxy_cluster, domain)
+		 values('service-1','account-1',true,true,'["group-one-resource-id"]','test-1.com','test-2.com')`)
+	execQuery(t, ctx,
+		`insert into services (id, account_id, enabled, private, access_groups, proxy_cluster, domain)
+		 values('service-2','account-1',true,true,'["group-one-resource-id","group-two-resources-id"]','test-3.com','test-4.com')`)
+	execQuery(t, ctx,
+		`insert into services (id, account_id, enabled, private, access_groups, proxy_cluster, domain)
+		 values('service-3','account-1',null,null,null,null,null)`)
+
+	services, err := conn(t, ctx).GetPrivateServices(ctx, "account-1")
+	assert.NoError(t, err)
+	assert.Contains(t, services, networkmapdb.Service{
+		Enabled:      sql.NullBool{Bool: true, Valid: true},
+		Private:      sql.NullBool{Bool: true, Valid: true},
+		AccessGroups: []string{"group-one-resource-id"},
+		ProxyCluster: sql.NullString{String: "test-1.com", Valid: true},
+		Domain:       sql.NullString{String: "test-2.com", Valid: true},
+	})
+	assert.Contains(t, services, networkmapdb.Service{
+		Enabled:      sql.NullBool{Bool: true, Valid: true},
+		Private:      sql.NullBool{Bool: true, Valid: true},
+		AccessGroups: []string{"group-one-resource-id", "group-two-resources-id"},
+		ProxyCluster: sql.NullString{String: "test-3.com", Valid: true},
+		Domain:       sql.NullString{String: "test-4.com", Valid: true},
+	})
+	assert.Contains(t, services, networkmapdb.Service{
+		Enabled:      sql.NullBool{Bool: false, Valid: false},
+		Private:      sql.NullBool{Bool: false, Valid: false},
+		AccessGroups: []string{},
+		ProxyCluster: sql.NullString{String: "", Valid: false},
+		Domain:       sql.NullString{String: "", Valid: false},
+	})
+}
+
+func TestGetProxyTargetedDomainResourceIDs(t *testing.T) {
+	ctx := context.TODO()
+
+	execQuery(t, ctx,
+		`insert into services (id, account_id, enabled, terminated)
+		 values('service-4','account-1',true,false)`)
+	execQuery(t, ctx,
+		`insert into targets (target_id, account_id, service_id, enabled, target_type)
+		 values('target-1','account-1','service-4',true,'domain')`)
+	// id shouldn't be returned as the taget_type is not "domain"
+	execQuery(t, ctx,
+		`insert into targets (target_id, account_id, service_id, enabled, target_type)
+		 values('target-2','account-1','service-4',true,'cluster')`)
+	// id shouldn't be included as the target is disabled
+	execQuery(t, ctx,
+		`insert into targets (target_id, account_id, service_id, enabled, target_type)
+		 values('target-3','account-1','service-4',false,'domain')`)
+	// id shouldn't be included as the service is disabled
+	execQuery(t, ctx,
+		`insert into services (id, account_id, enabled, terminated)
+		 values('service-5','account-1',false,false)`)
+	execQuery(t, ctx,
+		`insert into targets (target_id, account_id, service_id, enabled, target_type)
+		 values('target-4','account-1','service-5',false,'domain')`)
+	// id shouldn't be included as the service is terminated (explicitly)
+	execQuery(t, ctx,
+		`insert into services (id, account_id, enabled, terminated)
+		 values('service-6','account-1',true,true)`)
+	execQuery(t, ctx,
+		`insert into targets (target_id, account_id, service_id, enabled, target_type)
+		 values('target-5','account-1','service-6',true,'domain')`)
+	// id shouldn't be included as the service is terminated (implicitly)
+	execQuery(t, ctx,
+		`insert into services (id, account_id, enabled, terminated)
+		 values('service-7','account-1',true,null)`)
+	execQuery(t, ctx,
+		`insert into targets (target_id, account_id, service_id, enabled, target_type)
+		 values('target-6','account-1','service-7',true,'domain')`)
+	execQuery(t, ctx,
+		`insert into services (id, account_id, enabled, terminated)
+		 values('service-8','account-1',true,false)`)
+	execQuery(t, ctx,
+		`insert into targets (target_id, account_id, service_id, enabled, target_type)
+		 values('target-7','account-1','service-8',true,'domain')`)
+	// id shouldn't be returned as the taget_id is null
+	execQuery(t, ctx,
+		`insert into targets (target_id, account_id, service_id, enabled, target_type)
+		 values(null,'account-1','service-4',true,'cluster')`)
+
+	servtargetedDomains, err := conn(t, ctx).GetProxyTargetedDomainResourceIDs(ctx, "account-1")
+	assert.NoError(t, err)
+	assert.Equal(t, servtargetedDomains, map[string]struct{}{
+		"target-1": {},
+		"target-6": {},
+		"target-7": {},
+	})
+}
diff --git a/integration_tests/management/network_map_db/sqlite_test_store.go b/integration_tests/management/network_map_db/sqlite_test_store.go
new file mode 100644
index 000000000..1c70c93d4
--- /dev/null
+++ b/integration_tests/management/network_map_db/sqlite_test_store.go
@@ -0,0 +1,48 @@
+//go:build integration
+
+package networkmap_pgsql
+
+import (
+	"context"
+	"fmt"
+	"runtime"
+	"strings"
+
+	networkmap_sqlite "github.com/netbirdio/netbird/management/internals/network_map_db/sqlite"
+	gormstore "github.com/netbirdio/netbird/management/server/store"
+	"github.com/netbirdio/netbird/management/server/types"
+	log "github.com/sirupsen/logrus"
+	"gorm.io/driver/sqlite"
+	"gorm.io/gorm"
+)
+
+func createSqliteTestStore(baseData string) (*networkmap_sqlite.SqliteStore, func()) {
+	storeSqliteFileName := ":memory:"
+	storeStr := fmt.Sprintf("%s?cache=shared", storeSqliteFileName)
+	if runtime.GOOS == "windows" {
+		// Vo avoid `The process cannot access the file because it is being used by another process` on Windows
+		storeStr = storeSqliteFileName
+	}
+
+	db, err := gorm.Open(sqlite.Open(storeStr), &gorm.Config{})
+	if err != nil {
+		log.Fatalf("error initializing db: %s", err.Error())
+	}
+	_, err = gormstore.NewSqlStore(context.TODO(), db, types.SqliteStoreEngine, nil, false)
+	if err != nil {
+		log.Fatalf("error initializing db: %s", err.Error())
+	}
+
+	sqldb, err := db.DB()
+	if err != nil {
+		log.Fatalf("error initializing db: %s", err.Error())
+
+	}
+	for _, query := range strings.Split(baseData, ";") {
+		if _, err := sqldb.Exec(query); err != nil {
+			log.Fatalf("error initializing db: %s", err.Error())
+		}
+	}
+
+	return &networkmap_sqlite.SqliteStore{Db: sqldb}, func() {}
+}
diff --git a/integration_tests/management/network_map_db/user_test.go b/integration_tests/management/network_map_db/user_test.go
new file mode 100644
index 000000000..132f749e2
--- /dev/null
+++ b/integration_tests/management/network_map_db/user_test.go
@@ -0,0 +1,57 @@
+//go:build integration
+
+package networkmap_pgsql
+
+import (
+	"context"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestGetAllowedUsers(t *testing.T) {
+	ctx := context.TODO()
+
+	execQuery(t, ctx,
+		`insert into users (id, name, account_id, auto_groups, blocked, is_service_user)
+		VALUES('user-1','user-1','account-1','["group-one-resource-id"]',false,false)`)
+	execQuery(t, ctx,
+		`insert into users (id, name, account_id, auto_groups, blocked, is_service_user)
+		VALUES('user-2','user-2','account-1','["group-one-resource-id","group-two-resources-id"]',false,false)`)
+	execQuery(t, ctx,
+		`insert into users (id, name, account_id, auto_groups, blocked, is_service_user)
+		VALUES('user-3','user-3','account-1','["group-two-resources-id"]',false,false)`)
+	// shouldn't be included as it's blocked
+	execQuery(t, ctx,
+		`insert into users (id, name, account_id, auto_groups, blocked, is_service_user)
+		VALUES('user-4','user-4','account-1','["group-two-resources-id"]',true,false)`)
+	// shouldn't be included as it's a service_user
+	execQuery(t, ctx,
+		`insert into users (id, name, account_id, auto_groups, blocked, is_service_user)
+		VALUES('user-5','user-5','account-1','["group-two-resources-id"]',false,true)`)
+	execQuery(t, ctx,
+		`insert into groups (id, name, account_id)
+		VALUES('all-group-1','All','account-1')`)
+	execQuery(t, ctx,
+		`insert into groups (id, name, account_id)
+		VALUES('all-group-2','All','account-1')`)
+	execQuery(t, ctx,
+		`insert into groups (id, name, account_id)
+		VALUES('all-group-3','All','account-1')`)
+
+	userIdx, groupIdToUserIds, err := conn(t, ctx).GetAllowedUsers(ctx, "account-1")
+	assert.NoError(t, err)
+
+	assert.Equal(t, userIdx, map[string]struct{}{
+		"user-1": {},
+		"user-2": {},
+		"user-3": {},
+	})
+	assert.Equal(t, groupIdToUserIds, map[string][]string{
+		"group-one-resource-id":  {"user-1", "user-2"},
+		"group-two-resources-id": {"user-2", "user-3"},
+		"all-group-1":            {"user-1", "user-2", "user-3"},
+		"all-group-2":            {"user-1", "user-2", "user-3"},
+		"all-group-3":            {"user-1", "user-2", "user-3"},
+	})
+}
diff --git a/magefiles/magefile.go b/magefiles/magefile.go
new file mode 100644
index 000000000..34ab3c08f
--- /dev/null
+++ b/magefiles/magefile.go
@@ -0,0 +1,10 @@
+//mage:multiline
+
+// Set the general description you want to have displayed with mage -l here.
+package main
+
+// mg contains helpful utility functions, like Deps
+
+// Default target to run when none is specified
+// If not set, running mage will list available targets
+//var Default = Integrationtest.All
diff --git a/magefiles/test.go b/magefiles/test.go
new file mode 100644
index 000000000..2d08e8e01
--- /dev/null
+++ b/magefiles/test.go
@@ -0,0 +1,74 @@
+package main
+
+import (
+	"errors"
+	"strings"
+
+	"github.com/magefile/mage/mg"
+	"github.com/magefile/mage/sh"
+)
+
+var defaultcli = []string{"test", "-tags=integration", "-timeout=20m"}
+
+type Integrationtest mg.Namespace
+
+func (i Integrationtest) All(gotestflags *string) error {
+	var errs []error
+	if err := i.Api(gotestflags); err != nil {
+		errs = append(errs, err)
+	}
+	if err := i.NmapDb(gotestflags); err != nil {
+		errs = append(errs, err)
+	}
+	if len(errs) > 0 {
+		return errors.Join(errs...)
+	}
+	return nil
+}
+
+func (Integrationtest) NmapDb(gotestflags *string) error {
+	cli := defaultcli
+	if gotestflags != nil {
+		cli = append(cli, strings.Split(*gotestflags, " ")...)
+	}
+	cli = append(cli, "./integration_tests/management/network_map_db/...")
+
+	return sh.RunV("go", cli...)
+}
+
+func (Integrationtest) NmapDbPostgres(gotestflags *string) error {
+	cli := defaultcli
+	if gotestflags != nil {
+		cli = append(cli, strings.Split(*gotestflags, " ")...)
+	}
+	cli = append(cli, "./integration_tests/management/network_map_db/...")
+
+	return sh.RunWithV(map[string]string{"NETBIRD_STORE_ENGINE": "postgres"}, "go", cli...)
+}
+
+func (Integrationtest) NmapDbSqlite(gotestflags *string) error {
+	cli := defaultcli
+	if gotestflags != nil {
+		cli = append(cli, strings.Split(*gotestflags, " ")...)
+	}
+	cli = append(cli, "./integration_tests/management/network_map_db/...")
+	return sh.RunWithV(map[string]string{"NETBIRD_STORE_ENGINE": "sqlite"}, "go", cli...)
+}
+
+func (Integrationtest) RegenerateNmapGoldenData(gotestflags *string) error {
+	cli := defaultcli
+	if gotestflags != nil {
+		cli = append(cli, strings.Split(*gotestflags, " ")...)
+	}
+	cli = append(cli, "./integration_tests/management/network_map_db/...")
+	return sh.RunWithV(map[string]string{"NMAP_UPDATE_GOLDEN_DATA": "true", "NETBIRD_STORE_ENGINE": "sqlite"}, "go", cli...)
+}
+
+func (Integrationtest) Api(gotestflags *string) error {
+	cli := defaultcli
+	if gotestflags != nil {
+		cli = append(cli, strings.Split(*gotestflags, " ")...)
+	}
+	cli = append(cli, "./management/server/http/...")
+	return sh.RunV("go", cli...)
+}
diff --git a/management/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go
index 07f1938c5..d72ba439d 100644
--- a/management/internals/controllers/network_map/controller/controller.go
+++ b/management/internals/controllers/network_map/controller/controller.go
@@ -18,8 +18,10 @@ import (
 	"github.com/netbirdio/netbird/management/internals/controllers/network_map"
 	"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache"
 	"github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral"
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
 	"github.com/netbirdio/netbird/management/internals/server/config"
 	"github.com/netbirdio/netbird/management/internals/shared/grpc"
+	"github.com/netbirdio/netbird/management/internals/shared/requestbuffer"
 	"github.com/netbirdio/netbird/management/server/account"
 	"github.com/netbirdio/netbird/management/server/integrations/integrated_validator"
 	"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
@@ -30,12 +32,16 @@ import (
 	"github.com/netbirdio/netbird/management/server/telemetry"
 	"github.com/netbirdio/netbird/management/server/types"
 	sharedgrpc "github.com/netbirdio/netbird/shared/management/grpc"
+	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	"github.com/netbirdio/netbird/shared/management/proto"
 	"github.com/netbirdio/netbird/shared/management/status"
 	"github.com/netbirdio/netbird/util"
 	"github.com/netbirdio/netbird/version"
 )
 
+const defaultNetworkMapDataBufferInterval = 100 * time.Millisecond
+
 type Controller struct {
 	repo    Repository
 	metrics *metrics
@@ -61,6 +67,9 @@ type Controller struct {
 	serverSupportedSyncMessageVersion sharedgrpc.SyncMessageVersion
 
 	perAccountServerSupportedSyncMessageVersions map[string]sharedgrpc.SyncMessageVersion
+
+	nmdataStore  *networkmapdb.NetworkMapDBStoreImpl
+	nmdataBuffer *requestbuffer.Buffer[*networkmap.NetworkMapData]
 }
 
 type bufferUpdate struct {
@@ -78,13 +87,13 @@ type bufferAffectedUpdate struct {
 
 var _ network_map.Controller = (*Controller)(nil)
 
-func NewController(ctx context.Context, store store.Store, metrics telemetry.AppMetrics, peersUpdateManager network_map.PeersUpdateManager, requestBuffer account.RequestBuffer, integratedPeerValidator integrated_validator.IntegratedValidator, settingsManager settings.Manager, dnsDomain string, proxyController port_forwarding.Controller, ephemeralPeersManager ephemeral.Manager, config *config.Config) *Controller {
+func NewController(ctx context.Context, store store.Store, metrics telemetry.AppMetrics, peersUpdateManager network_map.PeersUpdateManager, requestBuffer account.RequestBuffer, integratedPeerValidator integrated_validator.IntegratedValidator, settingsManager settings.Manager, dnsDomain string, proxyController port_forwarding.Controller, ephemeralPeersManager ephemeral.Manager, config *config.Config, nmdataStore *networkmapdb.NetworkMapDBStoreImpl) *Controller {
 	nMetrics, err := newMetrics(metrics.UpdateChannelMetrics())
 	if err != nil {
 		log.Fatal(fmt.Errorf("error creating metrics: %w", err))
 	}
 
-	return &Controller{
+	c := &Controller{
 		repo:                    newRepository(store),
 		metrics:                 nMetrics,
 		accountManagerMetrics:   metrics.AccountManagerMetrics(),
@@ -99,7 +108,16 @@ func NewController(ctx context.Context, store store.Store, metrics telemetry.App
 		EphemeralPeersManager:                        ephemeralPeersManager,
 		serverSupportedSyncMessageVersion:            sharedgrpc.SyncMessageVersionFromConfig(config.HighestSupportedSyncMessageVersion),
 		perAccountServerSupportedSyncMessageVersions: sharedgrpc.SyncMessageVersionsFromMap(config.PerAccountHighestSupportedSyncMessageVersion),
+		nmdataStore:                                  nmdataStore,
 	}
+
+	if nmdataStore != nil {
+		interval := requestbuffer.Interval(ctx, "NB_NETWORK_MAP_DATA_BUFFER_INTERVAL", defaultNetworkMapDataBufferInterval)
+		log.WithContext(ctx).Infof("set network map data request buffer interval to %s", interval)
+		c.nmdataBuffer = requestbuffer.New(ctx, "network map data request buffer", interval, c.fetchNetworkMapData)
+	}
+
+	return c
 }
 
 func (c *Controller) OnPeerConnected(ctx context.Context, accountID string, peerID string) (chan *network_map.UpdateMessage, error) {
@@ -125,12 +143,12 @@ func (c *Controller) OnPeerDisconnected(ctx context.Context, accountID string, p
 
 // injectAllProxyPolicies prepares an account for the per-peer network-map
 // computation. It prepends the in-memory agent-network services synthesised
-// from the account's current provider/policy state to account.Services so
-// the existing InjectProxyPolicies + injectPrivateServicePolicies walks pick
-// them up alongside persisted reverse-proxy services. Synthesised services
-// are never persisted; the account is loaded fresh per cycle so re-prepending
-// is safe and idempotent. Accounts without agent-network providers get an
-// empty synth slice — no behaviour change.
+// from the account's current provider/policy state to account.Services, so the
+// twin store built from the account carries them alongside the persisted
+// reverse-proxy services and synthesises their ACLs. Synthesised services are
+// never persisted; the account is loaded fresh per cycle so re-prepending is
+// safe and idempotent. Accounts without agent-network providers get an empty
+// synth slice — no behaviour change.
 func (c *Controller) injectAllProxyPolicies(ctx context.Context, account *types.Account) {
 	synth, err := c.repo.SynthesizeAgentNetworkServices(ctx, account.Id)
 	if err != nil {
@@ -138,7 +156,26 @@ func (c *Controller) injectAllProxyPolicies(ctx context.Context, account *types.
 	} else if len(synth) > 0 {
 		account.Services = append(synth, account.Services...)
 	}
-	account.InjectProxyPolicies(ctx)
+}
+
+// proxyServicesFromRepo is the store-path counterpart of
+// injectAllProxyPolicies: the network-map store reads the policies table, which
+// never holds the proxy ACLs, so the twin gets the services they are
+// synthesised from — the synthesised agent-network ones first, exactly as the
+// account path orders them.
+func (c *Controller) proxyServicesFromRepo(ctx context.Context, accountID string) []*nmdata.Service {
+	persisted, err := c.repo.GetAccountServices(ctx, accountID)
+	if err != nil {
+		log.WithContext(ctx).Errorf("failed to get services for account %s: %v", accountID, err)
+		return nil
+	}
+
+	synth, err := c.repo.SynthesizeAgentNetworkServices(ctx, accountID)
+	if err != nil {
+		log.WithContext(ctx).Warnf("synthesise agent-network services for account %s: %v", accountID, err)
+	}
+
+	return types.TwinServices(append(synth, persisted...))
 }
 
 func (c *Controller) CountStreams() int {
@@ -147,6 +184,11 @@ func (c *Controller) CountStreams() int {
 
 func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error {
 	log.WithContext(ctx).Tracef("updating peers for account %s from %s", accountID, util.GetCallerName())
+
+	if nmData := c.getNetworkMapData(ctx, accountID); nmData != nil {
+		return c.sendUpdateAccountPeersFromData(ctx, accountID, reason, nmData)
+	}
+
 	account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
 	if err != nil {
 		return fmt.Errorf("failed to get account: %v", err)
@@ -167,7 +209,7 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
 		return nil
 	}
 
-	approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
+	approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
 	if err != nil {
 		return fmt.Errorf("failed to get validate peers: %v", err)
 	}
@@ -255,7 +297,7 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
 				// proxyNetworkMap rides the envelope as a ProxyPatch sidecar;
 				// the client merges it into Calculate()'s output the same
 				// way the legacy server did via NetworkMap.Merge.
-				update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort)
+				update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort)
 				c.metrics.CountToComponentSyncResponseDuration(time.Since(start))
 
 				c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
@@ -276,7 +318,7 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
 			}
 
 			start = time.Now()
-			update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, nmap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort)
+			update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, nmap, dnsDomain, postureChecks, dnsCache, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort)
 			c.metrics.CountToSyncResponseDuration(time.Since(start))
 
 			c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
@@ -294,6 +336,277 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
 	return nil
 }
 
+// sendUpdateAccountPeersFromData is the account-free variant of
+// sendUpdateAccountPeers: everything is computed from the network-map DB
+// store's twin data; only extra settings and validated peers are resolved at
+// runtime. Proxy network maps and policy injection, private-service zones,
+// group-to-user SSH mappings and forced routing-peer DNS resolution have no
+// DB-backed source yet and are omitted.
+func (c *Controller) sendUpdateAccountPeersFromData(ctx context.Context, accountID string, reason types.UpdateReason, nmData *networkmap.NetworkMapData) error {
+	peersToUpdate := c.connectedPeersFromData(nmData, nil)
+	if len(peersToUpdate) == 0 {
+		return nil
+	}
+	return c.sendUpdatesFromData(ctx, accountID, nmData, peersToUpdate, &reason)
+}
+
+// sendUpdateForAffectedPeersFromData is the account-free variant of
+// sendUpdateForAffectedPeers.
+func (c *Controller) sendUpdateForAffectedPeersFromData(ctx context.Context, accountID string, peerIDs []string, nmData *networkmap.NetworkMapData) error {
+	if len(peerIDs) == 0 {
+		log.WithContext(ctx).Tracef("sendUpdateForAffectedPeersFromData: no affected peers")
+		return nil
+	}
+
+	peersToUpdate := c.connectedPeersFromData(nmData, peerIDs)
+	if len(peersToUpdate) == 0 {
+		log.WithContext(ctx).Tracef("sendUpdateForAffectedPeersFromData: no peers to update (affected peers not found in data or no channels)")
+		return nil
+	}
+
+	log.WithContext(ctx).Tracef("sendUpdateForAffectedPeersFromData: sending network map to %d connected peers", len(peersToUpdate))
+
+	return c.sendUpdatesFromData(ctx, accountID, nmData, peersToUpdate, nil)
+}
+
+// connectedPeersFromData returns the peers with an open update channel. An
+// empty affected list means all peers; a non-empty list restricts the result
+// to those peer IDs.
+func (c *Controller) connectedPeersFromData(nmData *networkmap.NetworkMapData, affected []string) []*nmdata.Peer {
+	if len(affected) == 0 {
+		result := make([]*nmdata.Peer, 0, len(nmData.Peers))
+		for _, peer := range nmData.Peers {
+			if c.peersUpdateManager.HasChannel(peer.ID) {
+				result = append(result, peer)
+			}
+		}
+		return result
+	}
+
+	result := make([]*nmdata.Peer, 0, len(affected))
+	for _, peerID := range affected {
+		peer := nmData.Peers[peerID]
+		if peer == nil {
+			continue
+		}
+		if c.peersUpdateManager.HasChannel(peerID) {
+			result = append(result, peer)
+		}
+	}
+	return result
+}
+
+func (c *Controller) sendUpdatesFromData(ctx context.Context, accountID string, nmData *networkmap.NetworkMapData, peersToUpdate []*nmdata.Peer, reason *types.UpdateReason) error {
+	globalStart := time.Now()
+
+	extraSettings, err := c.settingsManager.GetExtraSettings(ctx, accountID)
+	if err != nil {
+		return fmt.Errorf("failed to get flow enabled status: %v", err)
+	}
+
+	dnsCache := &cache.DNSConfigCache{}
+	dnsDomain := c.getDNSDomainFromData(nmData.AccountSettings)
+	peersCustomZone := networkmap.PeersCustomZone(ctx, accountID, dnsDomain, nmData.Peers, IPv6AllowedPeersFromData(nmData))
+
+	dnsFwdPort := ComputeForwarderPortFromData(nmData.Peers, network_map.DnsForwarderPortMinVersion)
+
+	var wg sync.WaitGroup
+	semaphore := make(chan struct{}, 10)
+
+	for _, peer := range peersToUpdate {
+		if reason != nil && c.accountManagerMetrics != nil {
+			c.accountManagerMetrics.CountNmapTriggered(string(reason.Resource), string(reason.Operation))
+		}
+
+		wg.Add(1)
+		semaphore <- struct{}{}
+		go func(p *nmdata.Peer) {
+			defer wg.Done()
+			defer func() { <-semaphore }()
+
+			start := time.Now()
+
+			postureChecks := peerPostureChecksFromData(nmData, p.ID)
+
+			c.metrics.CountCalcPostureChecksDuration(time.Since(start))
+			start = time.Now()
+
+			peerGroups := maps.Keys(nmData.GetPeerGroups(p.ID))
+			var update *proto.SyncResponse
+
+			commonSyncMessageVersion := sharedgrpc.HighestCommonSyncMessageVersion(
+				c.perAccountOrGlobalSupportedSyncMessageVersions(accountID),
+				sharedgrpc.SyncMessageVersionFromConfig(&p.Meta.SyncMessageVersion))
+
+			log.WithContext(ctx).
+				WithFields(log.Fields{
+					"sync_message_version":        commonSyncMessageVersion,
+					"server_sync_message_version": c.perAccountOrGlobalSupportedSyncMessageVersions(accountID),
+					"peer_sync_message_version":   sharedgrpc.SyncMessageVersionFromConfig(&p.Meta.SyncMessageVersion),
+				}).Debug("common highest sync message version")
+
+			if commonSyncMessageVersion == sharedgrpc.ComponentNetworkMap {
+				components := nmData.GetPeerNetworkMapComponents(p.ID, peersCustomZone)
+
+				c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start))
+
+				start = time.Now()
+				update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, components, nil, dnsDomain, postureChecks, nmData.AccountSettings, extraSettings, peerGroups, dnsFwdPort)
+				c.metrics.CountToComponentSyncResponseDuration(time.Since(start))
+
+				c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
+					Update:      update,
+					MessageType: network_map.MessageTypeNetworkMap,
+				})
+
+				return
+			}
+
+			nmap := NetworkMapFromData(ctx, nmData, p.ID, peersCustomZone, c.accountManagerMetrics)
+
+			c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start))
+
+			start = time.Now()
+			update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, nmap, dnsDomain, postureChecks, dnsCache, nmData.AccountSettings, extraSettings, peerGroups, dnsFwdPort)
+			c.metrics.CountToSyncResponseDuration(time.Since(start))
+
+			c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
+				Update:      update,
+				MessageType: network_map.MessageTypeNetworkMap,
+			})
+		}(peer)
+	}
+
+	wg.Wait()
+	if c.accountManagerMetrics != nil {
+		c.accountManagerMetrics.CountUpdateAccountPeersDuration(time.Since(globalStart))
+	}
+
+	return nil
+}
+
+func (c *Controller) getNetworkMapData(ctx context.Context, accountID string) *networkmap.NetworkMapData {
+	if c.nmdataBuffer == nil {
+		return nil
+	}
+
+	nmData, err := c.nmdataBuffer.Get(ctx, accountID)
+	if err != nil {
+		log.WithContext(ctx).Errorf("failed to get network map data for account %s, falling back to account-based computation: %v", accountID, err)
+		return nil
+	}
+
+	return nmData
+}
+
+// fetchNetworkMapData reads the twin once per buffer window. Its result is
+// shared by every waiter of that window, so the mutating steps run here, before
+// it is handed out: the twin the callers see is read-only. Injected proxy
+// policies carry no posture checks, so precomputing after the injection yields
+// the same validation as precomputing before it.
+func (c *Controller) fetchNetworkMapData(ctx context.Context, accountID string) (*networkmap.NetworkMapData, error) {
+	nmData, err := c.nmdataStore.GetNetworkMapData(ctx, accountID)
+	if err != nil {
+		return nil, err
+	}
+
+	nmData.Services = c.proxyServicesFromRepo(ctx, accountID)
+	nmData.InjectProxyPolicies()
+	nmData.PrecomputePostureValidation()
+
+	return nmData, nil
+}
+
+func (c *Controller) getDNSDomainFromData(settings *nmdata.AccountSettingsInfo) string {
+	if settings == nil || settings.DNSDomain == "" {
+		return c.dnsDomain
+	}
+	return settings.DNSDomain
+}
+
+func IPv6AllowedPeersFromData(nmData *networkmap.NetworkMapData) map[string]struct{} {
+	result := make(map[string]struct{})
+	// An account with no IPv6-enabled group runs no overlay at all, so the
+	// embedded-proxy carve-out below has nothing to reach and stays shut.
+	if nmData.AccountSettings == nil || len(nmData.AccountSettings.IPv6EnabledGroups) == 0 {
+		return result
+	}
+	for _, groupID := range nmData.AccountSettings.IPv6EnabledGroups {
+		group := nmData.Groups[groupID]
+		if group == nil {
+			continue
+		}
+		for _, peerID := range group.Peers {
+			result[peerID] = struct{}{}
+		}
+	}
+	for id, p := range nmData.Peers {
+		if p != nil && p.ProxyMeta.Embedded {
+			result[id] = struct{}{}
+		}
+	}
+	return result
+}
+
+func NetworkMapFromData(ctx context.Context, nmData *networkmap.NetworkMapData, peerID string, peersCustomZone nmdata.CustomZone, metrics *telemetry.AccountManagerMetrics) *types.NetworkMap {
+	start := time.Now()
+
+	components := nmData.GetPeerNetworkMapComponents(peerID, peersCustomZone)
+	if components.IsEmpty() {
+		return &types.NetworkMap{Network: components.Network}
+	}
+	nm := types.CalculateNetworkMapFromComponents(ctx, components)
+
+	if metrics != nil {
+		objectCount := int64(len(nm.Peers) + len(nm.OfflinePeers) + len(nm.Routes) + len(nm.FirewallRules) + len(nm.RoutesFirewallRules))
+		metrics.CountNetworkMapObjects(objectCount)
+		metrics.CountGetPeerNetworkMapDuration(time.Since(start))
+	}
+
+	return nm
+}
+
+// peerPostureChecksFromData mirrors getPeerPostureChecks on the twin store.
+func peerPostureChecksFromData(nmData *networkmap.NetworkMapData, peerID string) []*nmdata.PostureChecks {
+	if len(nmData.PostureChecks) == 0 {
+		return nil
+	}
+
+	peerPostureChecks := make(map[string]*nmdata.PostureChecks)
+	for _, policy := range nmData.Policies {
+		if policy == nil || !policy.Enabled || len(policy.SourcePostureChecks) == 0 {
+			continue
+		}
+		if !isPeerInPolicySourcesFromData(nmData, peerID, policy) {
+			continue
+		}
+		for _, checkID := range policy.SourcePostureChecks {
+			if twin := nmData.PostureChecks[checkID]; twin != nil {
+				peerPostureChecks[checkID] = twin
+			}
+		}
+	}
+
+	return maps.Values(peerPostureChecks)
+}
+
+func isPeerInPolicySourcesFromData(nmData *networkmap.NetworkMapData, peerID string, policy *nmdata.Policy) bool {
+	for _, rule := range policy.Rules {
+		if rule == nil || !rule.Enabled {
+			continue
+		}
+		if rule.SourceResource.Type == string(types.ResourceTypePeer) && rule.SourceResource.ID == peerID {
+			return true
+		}
+		for _, groupID := range rule.Sources {
+			if group := nmData.Groups[groupID]; group != nil && slices.Contains(group.Peers, peerID) {
+				return true
+			}
+		}
+	}
+	return false
+}
+
 func (c *Controller) perAccountOrGlobalSupportedSyncMessageVersions(accountId string) sharedgrpc.SyncMessageVersion {
 	if perAccount, ok := c.perAccountServerSupportedSyncMessageVersions[accountId]; ok {
 		return perAccount
@@ -326,6 +639,10 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s
 		return nil
 	}
 
+	if nmData := c.getNetworkMapData(ctx, accountID); nmData != nil {
+		return c.sendUpdateForAffectedPeersFromData(ctx, accountID, peerIDs, nmData)
+	}
+
 	account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
 	if err != nil {
 		return fmt.Errorf("failed to get account: %v", err)
@@ -341,7 +658,7 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s
 
 	log.WithContext(ctx).Tracef("sendUpdateForAffectedPeers: sending network map to %d connected peers", len(peersToUpdate))
 
-	approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
+	approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
 	if err != nil {
 		return fmt.Errorf("failed to get validate peers: %v", err)
 	}
@@ -428,7 +745,7 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s
 				// proxyNetworkMap rides the envelope as a ProxyPatch sidecar;
 				// the client merges it into Calculate()'s output the same
 				// way the legacy server did via NetworkMap.Merge.
-				update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort)
+				update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort)
 				c.metrics.CountToComponentSyncResponseDuration(time.Since(start))
 
 				c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
@@ -449,7 +766,7 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s
 			}
 
 			start = time.Now()
-			update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, nmap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort)
+			update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, nmap, dnsDomain, postureChecks, dnsCache, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort)
 			c.metrics.CountToSyncResponseDuration(time.Since(start))
 
 			c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
@@ -506,7 +823,7 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe
 		return fmt.Errorf("peer %s doesn't exists in account %s", peerId, accountId)
 	}
 
-	approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
+	approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
 	if err != nil {
 		return fmt.Errorf("failed to get validated peers: %v", err)
 	}
@@ -566,7 +883,7 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe
 		// proxyNetworkMap rides the envelope as a ProxyPatch sidecar;
 		// the client merges it into Calculate()'s output the same
 		// way the legacy server did via NetworkMap.Merge.
-		update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, peer, nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, account.Settings, extraSettings, maps.Keys(peerGroups), dnsFwdPort)
+		update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(peer), nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSettings, maps.Keys(peerGroups), dnsFwdPort)
 
 		c.peersUpdateManager.SendUpdate(ctx, peer.ID, &network_map.UpdateMessage{
 			Update:      update,
@@ -583,7 +900,7 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe
 		nmap.Merge(proxyNetworkMap)
 	}
 
-	update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, peer, nil, nil, nmap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSettings, maps.Keys(peerGroups), dnsFwdPort)
+	update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(peer), nil, nil, nmap, dnsDomain, postureChecks, dnsCache, types.TwinAccountSettings(account.Settings), extraSettings, maps.Keys(peerGroups), dnsFwdPort)
 
 	c.peersUpdateManager.SendUpdate(ctx, peer.ID, &network_map.UpdateMessage{
 		Update:      update,
@@ -637,13 +954,17 @@ func (c *Controller) BufferUpdateAccountPeers(ctx context.Context, accountID str
 // data the legacy server folds in via NetworkMap.Merge). The gRPC layer
 // encodes both into the wire envelope. Callers must gate on capability
 // themselves before dispatching here — this method does NOT branch on it.
-func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, peer *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*posture.Checks, int64, error) {
+func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, peer *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) {
 	if isRequiresApproval {
 		network, err := c.repo.GetAccountNetwork(ctx, accountID)
 		if err != nil {
 			return nil, nil, nil, nil, 0, err
 		}
-		return peer, &types.NetworkMapComponents{Network: network.Copy()}, nil, nil, 0, nil
+		return peer, &types.NetworkMapComponents{Network: types.TwinNetwork(network)}, nil, nil, 0, nil
+	}
+
+	if nmData := c.getNetworkMapData(ctx, accountID); nmData != nil {
+		return c.getValidatedPeerWithComponentsFromData(ctx, accountID, peer, nmData)
 	}
 
 	account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
@@ -651,9 +972,14 @@ func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequi
 		return nil, nil, nil, nil, 0, err
 	}
 
+	// it's possible that the peer gets deleted between the call to "sendInitialSync()" and here, bail out in this case
+	if _, ok := account.Peers[peer.ID]; !ok {
+		return nil, nil, nil, nil, 0, fmt.Errorf("peer '%s' no longer exists", peer.ID)
+	}
+
 	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
 	}
@@ -690,6 +1016,21 @@ func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequi
 	return peer, components, proxyNetworkMaps[peer.ID], postureChecks, dnsFwdPort, nil
 }
 
+// getValidatedPeerWithComponentsFromData is the account-free variant of
+// GetValidatedPeerWithComponents. The proxy network map fragment is omitted
+// like on the other nmdata paths.
+func (c *Controller) getValidatedPeerWithComponentsFromData(ctx context.Context, accountID string, peer *nbpeer.Peer, nmData *networkmap.NetworkMapData) (*nbpeer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) {
+	postureChecks := peerPostureChecksFromData(nmData, peer.ID)
+
+	dnsDomain := c.getDNSDomainFromData(nmData.AccountSettings)
+	peersCustomZone := networkmap.PeersCustomZone(ctx, accountID, dnsDomain, nmData.Peers, IPv6AllowedPeersFromData(nmData))
+
+	components := nmData.GetPeerNetworkMapComponents(peer.ID, peersCustomZone)
+	dnsFwdPort := ComputeForwarderPortFromData(nmData.Peers, network_map.DnsForwarderPortMinVersion)
+
+	return peer, components, nil, postureChecks, dnsFwdPort, nil
+}
+
 // BufferUpdateAffectedPeers accumulates peer IDs and flushes them after the buffer interval.
 func (c *Controller) BufferUpdateAffectedPeers(ctx context.Context, accountID string, peerIDs []string, reason types.UpdateReason) error {
 	if len(peerIDs) == 0 {
@@ -788,7 +1129,7 @@ func (b *bufferAffectedUpdate) setTimer(d time.Duration, f func()) {
 	b.next.Reset(d)
 }
 
-func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, peerID string) (*types.NetworkMap, []*posture.Checks, int64, error) {
+func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, peerID string) (*types.NetworkMap, []*nmdata.PostureChecks, int64, error) {
 	if isRequiresApproval {
 		network, err := c.repo.GetAccountNetwork(ctx, accountID)
 		if err != nil {
@@ -796,11 +1137,15 @@ func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresAppr
 		}
 
 		emptyMap := &types.NetworkMap{
-			Network: network.Copy(),
+			Network: types.TwinNetwork(network),
 		}
 		return emptyMap, nil, 0, nil
 	}
 
+	if nmData := c.getNetworkMapData(ctx, accountID); nmData != nil {
+		return c.getValidatedPeerWithMapFromData(ctx, accountID, peerID, nmData)
+	}
+
 	account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
 	if err != nil {
 		return nil, nil, 0, err
@@ -808,7 +1153,7 @@ func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresAppr
 
 	c.injectAllProxyPolicies(ctx, account)
 
-	approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
+	approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
 	if err != nil {
 		return nil, nil, 0, err
 	}
@@ -848,6 +1193,21 @@ func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresAppr
 	return networkMap, postureChecks, dnsFwdPort, nil
 }
 
+// getValidatedPeerWithMapFromData is the account-free variant of
+// GetValidatedPeerWithMap. The proxy network map fragment is omitted like on
+// the other nmdata paths.
+func (c *Controller) getValidatedPeerWithMapFromData(ctx context.Context, accountID string, peerID string, nmData *networkmap.NetworkMapData) (*types.NetworkMap, []*nmdata.PostureChecks, int64, error) {
+	postureChecks := peerPostureChecksFromData(nmData, peerID)
+
+	dnsDomain := c.getDNSDomainFromData(nmData.AccountSettings)
+	peersCustomZone := networkmap.PeersCustomZone(ctx, accountID, dnsDomain, nmData.Peers, IPv6AllowedPeersFromData(nmData))
+
+	networkMap := NetworkMapFromData(ctx, nmData, peerID, peersCustomZone, c.accountManagerMetrics)
+	dnsFwdPort := ComputeForwarderPortFromData(nmData.Peers, network_map.DnsForwarderPortMinVersion)
+
+	return networkMap, postureChecks, dnsFwdPort, nil
+}
+
 // GetDNSDomain returns the configured dnsDomain
 func (c *Controller) GetDNSDomain(settings *types.Settings) string {
 	if settings == nil {
@@ -861,7 +1221,7 @@ func (c *Controller) GetDNSDomain(settings *types.Settings) string {
 }
 
 // getPeerPostureChecks returns the posture checks applied for a given peer.
-func (c *Controller) getPeerPostureChecks(account *types.Account, peerID string) ([]*posture.Checks, error) {
+func (c *Controller) getPeerPostureChecks(account *types.Account, peerID string) ([]*nmdata.PostureChecks, error) {
 	peerPostureChecks := make(map[string]*posture.Checks)
 
 	if len(account.PostureChecks) == 0 {
@@ -878,7 +1238,7 @@ func (c *Controller) getPeerPostureChecks(account *types.Account, peerID string)
 		}
 	}
 
-	return maps.Values(peerPostureChecks), nil
+	return types.TwinPostureChecksList(maps.Values(peerPostureChecks)), nil
 }
 
 func (c *Controller) StartWarmup(ctx context.Context) {
@@ -910,20 +1270,36 @@ func (c *Controller) StartWarmup(ctx context.Context) {
 // computeForwarderPort checks if all peers in the account have updated to a specific version or newer.
 // If all peers have the required version, it returns the new well-known port (22054), otherwise returns 0.
 func computeForwarderPort(peers []*nbpeer.Peer, requiredVersion string) int64 {
-	if len(peers) == 0 {
+	versions := make([]string, 0, len(peers))
+	for _, peer := range peers {
+		versions = append(versions, peer.Meta.WtVersion)
+	}
+	return computeForwarderPortFromVersions(versions, requiredVersion)
+}
+
+func ComputeForwarderPortFromData(peers map[string]*nmdata.Peer, requiredVersion string) int64 {
+	versions := make([]string, 0, len(peers))
+	for _, peer := range peers {
+		versions = append(versions, peer.Meta.WtVersion)
+	}
+	return computeForwarderPortFromVersions(versions, requiredVersion)
+}
+
+func computeForwarderPortFromVersions(wtVersions []string, requiredVersion string) int64 {
+	if len(wtVersions) == 0 {
 		return int64(network_map.OldForwarderPort)
 	}
 
 	reqVer := semver.Canonical(requiredVersion)
 
 	// Check if all peers have the required version or newer
-	for _, peer := range peers {
+	for _, wtVersion := range wtVersions {
 
 		// Development version is always supported
-		if version.IsDevelopmentVersion(peer.Meta.WtVersion) {
+		if version.IsDevelopmentVersion(wtVersion) {
 			continue
 		}
-		peerVersion := semver.Canonical("v" + peer.Meta.WtVersion)
+		peerVersion := semver.Canonical("v" + wtVersion)
 		if peerVersion == "" {
 			// If any peer doesn't have version info, return 0
 			return int64(network_map.OldForwarderPort)
@@ -941,7 +1317,7 @@ func computeForwarderPort(peers []*nbpeer.Peer, requiredVersion string) int64 {
 
 // addPolicyPostureChecks adds posture checks from a policy to the peer posture checks map if the peer is in the policy's source groups.
 func addPolicyPostureChecks(account *types.Account, peerID string, policy *types.Policy, peerPostureChecks map[string]*posture.Checks) error {
-	isInGroup, err := isPeerInPolicySourceGroups(account, peerID, policy)
+	isInGroup, err := isPeerInPolicySources(account, peerID, policy)
 	if err != nil {
 		return err
 	}
@@ -961,13 +1337,17 @@ func addPolicyPostureChecks(account *types.Account, peerID string, policy *types
 	return nil
 }
 
-// isPeerInPolicySourceGroups checks if a peer is present in any of the policy rule source groups.
-func isPeerInPolicySourceGroups(account *types.Account, peerID string, policy *types.Policy) (bool, error) {
+// isPeerInPolicySources checks if a peer is a source of the policy, directly or through a source group.
+func isPeerInPolicySources(account *types.Account, peerID string, policy *types.Policy) (bool, error) {
 	for _, rule := range policy.Rules {
 		if !rule.Enabled {
 			continue
 		}
 
+		if rule.SourceResource.Type == types.ResourceTypePeer && rule.SourceResource.ID == peerID {
+			return true, nil
+		}
+
 		for _, sourceGroup := range rule.Sources {
 			group := account.GetGroup(sourceGroup)
 			if group == nil {
@@ -1057,7 +1437,12 @@ func (c *Controller) GetNetworkMap(ctx context.Context, peerID string) (*types.N
 		groups[groupID] = group.Peers
 	}
 
-	validatedPeers, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
+	extraSettings, err := c.settingsManager.GetExtraSettings(ctx, account.Id)
+	if err != nil {
+		return nil, err
+	}
+
+	validatedPeers, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), extraSettings)
 	if err != nil {
 		return nil, err
 	}
diff --git a/management/internals/controllers/network_map/controller/controller_test.go b/management/internals/controllers/network_map/controller/controller_test.go
index 90e7b6e18..dfbbb2915 100644
--- a/management/internals/controllers/network_map/controller/controller_test.go
+++ b/management/internals/controllers/network_map/controller/controller_test.go
@@ -1,10 +1,15 @@
 package controller
 
 import (
+	"context"
 	"testing"
 
 	"github.com/netbirdio/netbird/management/internals/controllers/network_map"
+	"github.com/netbirdio/netbird/management/server/account"
 	nbpeer "github.com/netbirdio/netbird/management/server/peer"
+	"github.com/netbirdio/netbird/management/server/types"
+	"github.com/stretchr/testify/assert"
+	"go.uber.org/mock/gomock"
 )
 
 func TestComputeForwarderPort(t *testing.T) {
@@ -107,3 +112,22 @@ func TestComputeForwarderPort(t *testing.T) {
 		t.Errorf("Expected %d for peers with unknown version, got %d", network_map.OldForwarderPort, result)
 	}
 }
+
+func TestGetValidatedPeerWithComponents_DeletedPeer(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	mockrequestBuffer := account.NewMockRequestBuffer(ctrl)
+
+	c := Controller{
+		requestBuffer: mockrequestBuffer,
+	}
+
+	mockrequestBuffer.EXPECT().GetAccountWithBackpressure(gomock.Any(), gomock.Any()).Return(&types.Account{}, nil)
+	peer, components, netmap, posturechecks, dnsforwardPort, err := c.GetValidatedPeerWithComponents(context.TODO(), false, "test-account-id", &nbpeer.Peer{ID: "test-peer-id"})
+
+	assert.Nil(t, peer)
+	assert.Nil(t, components)
+	assert.Nil(t, netmap)
+	assert.Nil(t, posturechecks)
+	assert.Equal(t, int64(0), dnsforwardPort)
+	assert.NotNil(t, err)
+}
diff --git a/management/internals/controllers/network_map/controller/ipv6_allowed_test.go b/management/internals/controllers/network_map/controller/ipv6_allowed_test.go
new file mode 100644
index 000000000..c80f3b734
--- /dev/null
+++ b/management/internals/controllers/network_map/controller/ipv6_allowed_test.go
@@ -0,0 +1,47 @@
+package controller
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+// The account-side builder (types.Account.peerIPv6AllowedSet) is the reference:
+// an account with no IPv6-enabled group runs no IPv6 overlay at all, embedded
+// proxy peers included — see TestPeerIPv6AllowedEmbeddedProxy. Both builders
+// gate the same AAAA records, so the store-backed one has to agree.
+func TestIPv6AllowedPeersFromData(t *testing.T) {
+	data := func(enabledGroups []string) *networkmap.NetworkMapData {
+		return &networkmap.NetworkMapData{
+			AccountSettings: &nmdata.AccountSettingsInfo{IPv6EnabledGroups: enabledGroups},
+			Peers: map[string]*nmdata.Peer{
+				"peer1":  {ID: "peer1"},
+				"lonely": {ID: "lonely"},
+				"proxy":  {ID: "proxy", ProxyMeta: nmdata.ProxyMeta{Embedded: true, Cluster: "netbird.test"}},
+			},
+			Groups: map[string]*nmdata.Group{
+				"group-devs": {ID: "group-devs", Peers: []string{"peer1"}},
+			},
+		}
+	}
+
+	t.Run("embedded proxy allowed when any v6 group exists, without group membership", func(t *testing.T) {
+		allowed := IPv6AllowedPeersFromData(data([]string{"group-devs"}))
+		assert.Contains(t, allowed, "proxy", "embedded proxy participates in v6 overlay")
+		assert.Contains(t, allowed, "peer1", "regular peer in enabled group still allowed")
+	})
+
+	t.Run("embedded proxy denied when no v6 group enabled", func(t *testing.T) {
+		allowed := IPv6AllowedPeersFromData(data(nil))
+		assert.NotContains(t, allowed, "proxy", "v6 disabled account-wide denies embedded proxies too")
+		assert.Empty(t, allowed, "no peer participates in the v6 overlay")
+	})
+
+	t.Run("non-embedded peer outside any enabled group is not pulled in", func(t *testing.T) {
+		allowed := IPv6AllowedPeersFromData(data([]string{"group-devs"}))
+		assert.NotContains(t, allowed, "lonely", "embedded-proxy bypass must not leak to regular peers")
+	})
+}
diff --git a/management/internals/controllers/network_map/controller/posture_twin_test.go b/management/internals/controllers/network_map/controller/posture_twin_test.go
new file mode 100644
index 000000000..98e0991d0
--- /dev/null
+++ b/management/internals/controllers/network_map/controller/posture_twin_test.go
@@ -0,0 +1,68 @@
+package controller
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+	"github.com/netbirdio/netbird/shared/management/types"
+)
+
+func postureSelectionData(policies ...*nmdata.Policy) *networkmap.NetworkMapData {
+	return &networkmap.NetworkMapData{
+		Groups:   map[string]*nmdata.Group{"g-src": {ID: "g-src", Peers: []string{"peer-group"}}},
+		Policies: policies,
+		PostureChecks: map[string]*nmdata.PostureChecks{
+			"pc1": {ID: "pc1", Checks: nmdata.ChecksDefinition{NBVersionCheck: &nmdata.NBVersionCheck{MinVersion: "0.30.0"}}},
+		},
+	}
+}
+
+func gatedPolicy(id string, rule *nmdata.PolicyRule, checkIDs ...string) *nmdata.Policy {
+	return &nmdata.Policy{ID: id, Enabled: true, SourcePostureChecks: checkIDs, Rules: []*nmdata.PolicyRule{rule}}
+}
+
+func checkIDs(checks []*nmdata.PostureChecks) []string {
+	ids := make([]string, 0, len(checks))
+	for _, c := range checks {
+		ids = append(ids, c.ID)
+	}
+	return ids
+}
+
+func TestPeerPostureChecksFromData_SelectsPolicySourcePeers(t *testing.T) {
+	groupRule := &nmdata.PolicyRule{ID: "r-group", Enabled: true, Sources: []string{"g-src"}, Destinations: []string{"g-dst"}}
+	directRule := &nmdata.PolicyRule{ID: "r-direct", Enabled: true, SourceResource: nmdata.Resource{ID: "peer-direct", Type: string(types.ResourceTypePeer)}, Destinations: []string{"g-dst"}}
+
+	t.Run("source group member and direct source peer both get the checks", func(t *testing.T) {
+		nmData := postureSelectionData(gatedPolicy("p1", groupRule, "pc1"), gatedPolicy("p2", directRule, "pc1"))
+
+		assert.Equal(t, []string{"pc1"}, checkIDs(peerPostureChecksFromData(nmData, "peer-group")))
+		assert.Equal(t, []string{"pc1"}, checkIDs(peerPostureChecksFromData(nmData, "peer-direct")))
+		assert.Empty(t, peerPostureChecksFromData(nmData, "peer-elsewhere"))
+	})
+
+	t.Run("source resource of a non-peer type never matches a peer", func(t *testing.T) {
+		hostRule := &nmdata.PolicyRule{ID: "r-host", Enabled: true, SourceResource: nmdata.Resource{ID: "peer-direct", Type: string(types.ResourceTypeHost)}, Destinations: []string{"g-dst"}}
+		nmData := postureSelectionData(gatedPolicy("p1", hostRule, "pc1"))
+
+		assert.Empty(t, peerPostureChecksFromData(nmData, "peer-direct"))
+	})
+
+	t.Run("same check through two policies is returned once", func(t *testing.T) {
+		nmData := postureSelectionData(gatedPolicy("p1", groupRule, "pc1"), gatedPolicy("p2", groupRule, "pc1"))
+
+		assert.Equal(t, []string{"pc1"}, checkIDs(peerPostureChecksFromData(nmData, "peer-group")))
+	})
+
+	t.Run("disabled policy, disabled rule and dangling check are ignored", func(t *testing.T) {
+		disabledPolicy := gatedPolicy("p-off", groupRule, "pc1")
+		disabledPolicy.Enabled = false
+		disabledRule := &nmdata.PolicyRule{ID: "r-off", Enabled: false, Sources: []string{"g-src"}}
+		nmData := postureSelectionData(disabledPolicy, gatedPolicy("p-rule-off", disabledRule, "pc1"), gatedPolicy("p-dangling", groupRule, "pc-missing"))
+
+		assert.Empty(t, peerPostureChecksFromData(nmData, "peer-group"))
+	})
+}
diff --git a/management/internals/controllers/network_map/controller/repository.go b/management/internals/controllers/network_map/controller/repository.go
index c0fcefc7d..5c3195f16 100644
--- a/management/internals/controllers/network_map/controller/repository.go
+++ b/management/internals/controllers/network_map/controller/repository.go
@@ -3,14 +3,16 @@ package controller
 import (
 	"context"
 
+	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
 	"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
 	"github.com/netbirdio/netbird/management/internals/modules/zones"
-	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
 	"github.com/netbirdio/netbird/management/server/peer"
 	"github.com/netbirdio/netbird/management/server/store"
 	"github.com/netbirdio/netbird/management/server/types"
 )
 
+//go:generate go tool mockgen -source=./repository.go -package=controller -destination=repository_mock.go
+
 type Repository interface {
 	GetAccountNetwork(ctx context.Context, accountID string) (*types.Network, error)
 	GetAccountPeers(ctx context.Context, accountID string) ([]*peer.Peer, error)
@@ -22,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 {
@@ -60,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/controller/repository_mock.go b/management/internals/controllers/network_map/controller/repository_mock.go
new file mode 100644
index 000000000..5246eef4b
--- /dev/null
+++ b/management/internals/controllers/network_map/controller/repository_mock.go
@@ -0,0 +1,150 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: ./repository.go
+//
+// Generated by this command:
+//
+//	mockgen -source=./repository.go -package=controller -destination=repository_mock.go
+//
+
+// Package controller is a generated GoMock package.
+package controller
+
+import (
+	context "context"
+	reflect "reflect"
+
+	service "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
+	zones "github.com/netbirdio/netbird/management/internals/modules/zones"
+	peer "github.com/netbirdio/netbird/management/server/peer"
+	types "github.com/netbirdio/netbird/management/server/types"
+	gomock "go.uber.org/mock/gomock"
+)
+
+// MockRepository is a mock of Repository interface.
+type MockRepository struct {
+	ctrl     *gomock.Controller
+	recorder *MockRepositoryMockRecorder
+	isgomock struct{}
+}
+
+// MockRepositoryMockRecorder is the mock recorder for MockRepository.
+type MockRepositoryMockRecorder struct {
+	mock *MockRepository
+}
+
+// NewMockRepository creates a new mock instance.
+func NewMockRepository(ctrl *gomock.Controller) *MockRepository {
+	mock := &MockRepository{ctrl: ctrl}
+	mock.recorder = &MockRepositoryMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockRepository) EXPECT() *MockRepositoryMockRecorder {
+	return m.recorder
+}
+
+// GetAccountByPeerID mocks base method.
+func (m *MockRepository) GetAccountByPeerID(ctx context.Context, peerID string) (*types.Account, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetAccountByPeerID", ctx, peerID)
+	ret0, _ := ret[0].(*types.Account)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// GetAccountByPeerID indicates an expected call of GetAccountByPeerID.
+func (mr *MockRepositoryMockRecorder) GetAccountByPeerID(ctx, peerID any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountByPeerID", reflect.TypeOf((*MockRepository)(nil).GetAccountByPeerID), ctx, peerID)
+}
+
+// GetAccountNetwork mocks base method.
+func (m *MockRepository) GetAccountNetwork(ctx context.Context, accountID string) (*types.Network, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetAccountNetwork", ctx, accountID)
+	ret0, _ := ret[0].(*types.Network)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// GetAccountNetwork indicates an expected call of GetAccountNetwork.
+func (mr *MockRepositoryMockRecorder) GetAccountNetwork(ctx, accountID any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountNetwork", reflect.TypeOf((*MockRepository)(nil).GetAccountNetwork), ctx, accountID)
+}
+
+// GetAccountPeers mocks base method.
+func (m *MockRepository) GetAccountPeers(ctx context.Context, accountID string) ([]*peer.Peer, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetAccountPeers", ctx, accountID)
+	ret0, _ := ret[0].([]*peer.Peer)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// GetAccountPeers indicates an expected call of GetAccountPeers.
+func (mr *MockRepositoryMockRecorder) GetAccountPeers(ctx, accountID any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountPeers", reflect.TypeOf((*MockRepository)(nil).GetAccountPeers), ctx, accountID)
+}
+
+// GetAccountZones mocks base method.
+func (m *MockRepository) GetAccountZones(ctx context.Context, accountID string) ([]*zones.Zone, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetAccountZones", ctx, accountID)
+	ret0, _ := ret[0].([]*zones.Zone)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// GetAccountZones indicates an expected call of GetAccountZones.
+func (mr *MockRepositoryMockRecorder) GetAccountZones(ctx, accountID any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountZones", reflect.TypeOf((*MockRepository)(nil).GetAccountZones), ctx, accountID)
+}
+
+// GetPeerByID mocks base method.
+func (m *MockRepository) GetPeerByID(ctx context.Context, accountID, peerID string) (*peer.Peer, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetPeerByID", ctx, accountID, peerID)
+	ret0, _ := ret[0].(*peer.Peer)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// GetPeerByID indicates an expected call of GetPeerByID.
+func (mr *MockRepositoryMockRecorder) GetPeerByID(ctx, accountID, peerID any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerByID", reflect.TypeOf((*MockRepository)(nil).GetPeerByID), ctx, accountID, peerID)
+}
+
+// GetPeersByIDs mocks base method.
+func (m *MockRepository) GetPeersByIDs(ctx context.Context, accountID string, peerIDs []string) (map[string]*peer.Peer, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetPeersByIDs", ctx, accountID, peerIDs)
+	ret0, _ := ret[0].(map[string]*peer.Peer)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// GetPeersByIDs indicates an expected call of GetPeersByIDs.
+func (mr *MockRepositoryMockRecorder) GetPeersByIDs(ctx, accountID, peerIDs any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeersByIDs", reflect.TypeOf((*MockRepository)(nil).GetPeersByIDs), ctx, accountID, peerIDs)
+}
+
+// SynthesizeAgentNetworkServices mocks base method.
+func (m *MockRepository) SynthesizeAgentNetworkServices(ctx context.Context, accountID string) ([]*service.Service, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "SynthesizeAgentNetworkServices", ctx, accountID)
+	ret0, _ := ret[0].([]*service.Service)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// SynthesizeAgentNetworkServices indicates an expected call of SynthesizeAgentNetworkServices.
+func (mr *MockRepositoryMockRecorder) SynthesizeAgentNetworkServices(ctx, accountID any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SynthesizeAgentNetworkServices", reflect.TypeOf((*MockRepository)(nil).SynthesizeAgentNetworkServices), ctx, accountID)
+}
diff --git a/management/internals/controllers/network_map/interface.go b/management/internals/controllers/network_map/interface.go
index b535321d1..1e8c219b3 100644
--- a/management/internals/controllers/network_map/interface.go
+++ b/management/internals/controllers/network_map/interface.go
@@ -7,8 +7,8 @@ import (
 
 	nbdns "github.com/netbirdio/netbird/dns"
 	nbpeer "github.com/netbirdio/netbird/management/server/peer"
-	"github.com/netbirdio/netbird/management/server/posture"
 	"github.com/netbirdio/netbird/management/server/types"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 )
 
 const (
@@ -23,8 +23,8 @@ type Controller interface {
 	BufferUpdateAffectedPeers(ctx context.Context, accountID string, peerIDs []string, reason types.UpdateReason) error
 	UpdateAccountPeer(ctx context.Context, accountId string, peerId string) error
 	BufferUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error
-	GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, peerID string) (*types.NetworkMap, []*posture.Checks, int64, error)
-	GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, p *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*posture.Checks, int64, error)
+	GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, peerID string) (*types.NetworkMap, []*nmdata.PostureChecks, int64, error)
+	GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, p *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*nmdata.PostureChecks, int64, error)
 	GetDNSDomain(settings *types.Settings) string
 	StartWarmup(context.Context)
 	GetNetworkMap(ctx context.Context, peerID string) (*types.NetworkMap, error)
diff --git a/management/internals/controllers/network_map/interface_mock.go b/management/internals/controllers/network_map/interface_mock.go
index 42051f172..8b104dfa0 100644
--- a/management/internals/controllers/network_map/interface_mock.go
+++ b/management/internals/controllers/network_map/interface_mock.go
@@ -14,8 +14,8 @@ import (
 	reflect "reflect"
 
 	peer "github.com/netbirdio/netbird/management/server/peer"
-	posture "github.com/netbirdio/netbird/management/server/posture"
 	types "github.com/netbirdio/netbird/management/server/types"
+	nmdata "github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	gomock "go.uber.org/mock/gomock"
 )
 
@@ -127,13 +127,13 @@ func (mr *MockControllerMockRecorder) GetNetworkMap(ctx, peerID any) *gomock.Cal
 }
 
 // GetValidatedPeerWithComponents mocks base method.
-func (m *MockController) GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, p *peer.Peer) (*peer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*posture.Checks, int64, error) {
+func (m *MockController) GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, p *peer.Peer) (*peer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) {
 	m.ctrl.T.Helper()
 	ret := m.ctrl.Call(m, "GetValidatedPeerWithComponents", ctx, isRequiresApproval, accountID, p)
 	ret0, _ := ret[0].(*peer.Peer)
 	ret1, _ := ret[1].(*types.NetworkMapComponents)
 	ret2, _ := ret[2].(*types.NetworkMap)
-	ret3, _ := ret[3].([]*posture.Checks)
+	ret3, _ := ret[3].([]*nmdata.PostureChecks)
 	ret4, _ := ret[4].(int64)
 	ret5, _ := ret[5].(error)
 	return ret0, ret1, ret2, ret3, ret4, ret5
@@ -146,11 +146,11 @@ func (mr *MockControllerMockRecorder) GetValidatedPeerWithComponents(ctx, isRequ
 }
 
 // GetValidatedPeerWithMap mocks base method.
-func (m *MockController) GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID, peerID string) (*types.NetworkMap, []*posture.Checks, int64, error) {
+func (m *MockController) GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID, peerID string) (*types.NetworkMap, []*nmdata.PostureChecks, int64, error) {
 	m.ctrl.T.Helper()
 	ret := m.ctrl.Call(m, "GetValidatedPeerWithMap", ctx, isRequiresApproval, accountID, peerID)
 	ret0, _ := ret[0].(*types.NetworkMap)
-	ret1, _ := ret[1].([]*posture.Checks)
+	ret1, _ := ret[1].([]*nmdata.PostureChecks)
 	ret2, _ := ret[2].(int64)
 	ret3, _ := ret[3].(error)
 	return ret0, ret1, ret2, ret3
diff --git a/management/internals/controllers/network_map/nmaptest/canonicalize.go b/management/internals/controllers/network_map/nmaptest/canonicalize.go
new file mode 100644
index 000000000..ec6614d81
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/canonicalize.go
@@ -0,0 +1,380 @@
+package nmaptest
+
+import (
+	"bytes"
+	"cmp"
+	"fmt"
+	"slices"
+	"sort"
+	"strconv"
+	"strings"
+
+	"github.com/netbirdio/netbird/shared/management/proto"
+)
+
+// normalizeIDSpace replaces policy and route identifiers with positional
+// placeholders so a comparison can reach everything else.
+//
+// This exists only because the envelope round-trip currently substitutes each
+// internal xid with the object's public id, which is a tracked defect and not a
+// licence to differ: those identifiers reach the server again inside flow
+// events, which resolve them by internal id, so the substitution silently
+// breaks flow attribution for component-format peers. TestIDSpaceMatches
+// asserts the equality that must eventually hold; this erasure keeps the other
+// 40-odd cases reporting on semantics meanwhile. When the id space is unified,
+// delete this and the calls to it — every case should still pass.
+//
+// Cardinality and cross-references survive the erasure: two rules under one
+// policy still share a token and a route firewall rule still points at its
+// route, so a path that drops a policy, merges two policies, or misattributes a
+// rule to the wrong route still fails.
+func normalizeIDSpace(nm *proto.NetworkMap) {
+	if nm == nil {
+		return
+	}
+	policies := newTokenizer("policy")
+	routes := newTokenizer("route")
+
+	for _, i := range orderBy(nm.Routes, routeKeyWithoutID) {
+		nm.Routes[i].ID = routes.get(nm.Routes[i].ID)
+	}
+	for _, i := range orderBy(nm.FirewallRules, firewallKeyWithoutPolicy) {
+		r := nm.FirewallRules[i]
+		if len(r.PolicyID) > 0 {
+			r.PolicyID = []byte(policies.get(string(r.PolicyID)))
+		}
+	}
+	for _, i := range orderBy(nm.RoutesFirewallRules, routeFirewallKeyWithoutIDs) {
+		r := nm.RoutesFirewallRules[i]
+		if len(r.PolicyID) > 0 {
+			r.PolicyID = []byte(policies.get(string(r.PolicyID)))
+		}
+		r.RouteID = routes.get(r.RouteID)
+	}
+}
+
+// tokenizer maps identifiers to positional placeholders in order of first use.
+type tokenizer struct {
+	prefix string
+	seen   map[string]string
+}
+
+func newTokenizer(prefix string) *tokenizer {
+	return &tokenizer{prefix: prefix, seen: make(map[string]string)}
+}
+
+func (t *tokenizer) get(id string) string {
+	if id == "" {
+		return ""
+	}
+	if tok, ok := t.seen[id]; ok {
+		return tok
+	}
+	tok := fmt.Sprintf("%s#%d", t.prefix, len(t.seen))
+	t.seen[id] = tok
+	return tok
+}
+
+// orderBy returns indices sorted by key, so placeholder numbering does not
+// depend on the identifiers being erased.
+func orderBy[T any](items []T, key func(T) string) []int {
+	idx := make([]int, len(items))
+	for i := range idx {
+		idx[i] = i
+	}
+	sort.SliceStable(idx, func(a, b int) bool { return key(items[idx[a]]) < key(items[idx[b]]) })
+	return idx
+}
+
+func routeKeyWithoutID(r *proto.Route) string {
+	if r == nil {
+		return ""
+	}
+	return fmt.Sprintf("%s|%s|%s|%d|%d|%t|%t|%v",
+		r.Network, r.NetID, r.Peer, r.Metric, r.NetworkType, r.Masquerade, r.KeepRoute, r.Domains)
+}
+
+func firewallKeyWithoutPolicy(r *proto.FirewallRule) string {
+	if r == nil {
+		return ""
+	}
+	return fmt.Sprintf("%s|%d|%d|%d|%s|%s|%v",
+		r.PeerIP, r.Direction, r.Action, r.Protocol, r.Port, portInfoKey(r.PortInfo), r.SourcePrefixes) //nolint:staticcheck
+}
+
+func routeFirewallKeyWithoutIDs(r *proto.RouteFirewallRule) string {
+	if r == nil {
+		return ""
+	}
+	return fmt.Sprintf("%s|%d|%d|%s|%v|%v|%t|%d",
+		r.Destination, r.Protocol, r.Action, portInfoKey(r.PortInfo), r.Domains, r.SourceRanges, r.IsDynamic, r.CustomProtocol)
+}
+
+// canonicalize sorts every repeated field of the NetworkMap by a stable key.
+// The producing paths iterate Go maps while building these slices, so order
+// can differ between runs even when the content is identical; comparing
+// without this reports noise.
+func canonicalize(nm *proto.NetworkMap) {
+	if nm == nil {
+		return
+	}
+	slices.SortFunc(nm.RemotePeers, cmpRemotePeer)
+	slices.SortFunc(nm.OfflinePeers, cmpRemotePeer)
+	slices.SortFunc(nm.Routes, cmpRoute)
+	slices.SortFunc(nm.FirewallRules, cmpFirewallRule)
+	slices.SortFunc(nm.RoutesFirewallRules, cmpRouteFirewallRule)
+	slices.SortFunc(nm.ForwardingRules, cmpForwardingRule)
+
+	for _, r := range nm.FirewallRules {
+		slices.SortFunc(r.SourcePrefixes, bytes.Compare)
+	}
+	for _, r := range nm.RoutesFirewallRules {
+		slices.Sort(r.SourceRanges)
+	}
+	canonicalizeDNSConfig(nm.DNSConfig)
+	canonicalizeSSHAuth(nm.SshAuth)
+}
+
+func canonicalizeDNSConfig(d *proto.DNSConfig) {
+	if d == nil {
+		return
+	}
+	for _, g := range d.NameServerGroups {
+		if g == nil {
+			continue
+		}
+		slices.Sort(g.Domains)
+		slices.SortFunc(g.NameServers, func(a, b *proto.NameServer) int {
+			if a == nil || b == nil {
+				return boolCmp(a == nil, b == nil)
+			}
+			if c := cmp.Compare(a.IP, b.IP); c != 0 {
+				return c
+			}
+			if c := cmp.Compare(a.Port, b.Port); c != 0 {
+				return c
+			}
+			return cmp.Compare(a.NSType, b.NSType)
+		})
+	}
+	slices.SortFunc(d.NameServerGroups, func(a, b *proto.NameServerGroup) int {
+		return cmp.Compare(nsgKey(a), nsgKey(b))
+	})
+	for _, z := range d.CustomZones {
+		if z == nil {
+			continue
+		}
+		slices.SortFunc(z.Records, cmpSimpleRecord)
+	}
+	slices.SortFunc(d.CustomZones, func(a, b *proto.CustomZone) int {
+		if a == nil || b == nil {
+			return boolCmp(a == nil, b == nil)
+		}
+		return cmp.Compare(a.Domain, b.Domain)
+	})
+}
+
+// canonicalizeSSHAuth sorts AuthorizedUsers and re-keys MachineUsers.Indexes
+// against the new ordering, preserving which machine user maps to which hashes.
+func canonicalizeSSHAuth(s *proto.SSHAuth) {
+	if s == nil || len(s.AuthorizedUsers) == 0 {
+		return
+	}
+	type hashed struct {
+		bytes []byte
+		old   uint32
+	}
+	entries := make([]hashed, len(s.AuthorizedUsers))
+	for i, b := range s.AuthorizedUsers {
+		entries[i] = hashed{bytes: b, old: uint32(i)}
+	}
+	slices.SortFunc(entries, func(a, b hashed) int { return bytes.Compare(a.bytes, b.bytes) })
+
+	remap := make(map[uint32]uint32, len(entries))
+	sorted := make([][]byte, len(entries))
+	for newIdx, e := range entries {
+		remap[e.old] = uint32(newIdx)
+		sorted[newIdx] = e.bytes
+	}
+	s.AuthorizedUsers = sorted
+
+	for _, mu := range s.MachineUsers {
+		if mu == nil {
+			continue
+		}
+		for i, oldIdx := range mu.Indexes {
+			if newIdx, ok := remap[oldIdx]; ok {
+				mu.Indexes[i] = newIdx
+			}
+		}
+		slices.Sort(mu.Indexes)
+	}
+}
+
+func boolCmp(a, b bool) int {
+	if a == b {
+		return 0
+	}
+	if a {
+		return 1
+	}
+	return -1
+}
+
+func nsgKey(g *proto.NameServerGroup) string {
+	if g == nil {
+		return ""
+	}
+	var parts []string
+	for _, ns := range g.NameServers {
+		if ns == nil {
+			continue
+		}
+		parts = append(parts, ns.IP+":"+strconv.FormatInt(ns.Port, 10)+":"+strconv.FormatInt(ns.NSType, 10))
+	}
+	slices.Sort(parts)
+	key := strings.Join(parts, ",")
+	domains := append([]string(nil), g.Domains...)
+	slices.Sort(domains)
+	key += "|" + strings.Join(domains, "|")
+	if g.Primary {
+		key += "|P"
+	}
+	if g.SearchDomainsEnabled {
+		key += "|S"
+	}
+	return key
+}
+
+func cmpSimpleRecord(a, b *proto.SimpleRecord) int {
+	if a == nil || b == nil {
+		return boolCmp(a == nil, b == nil)
+	}
+	if c := cmp.Compare(a.Name, b.Name); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.Type, b.Type); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.Class, b.Class); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.RData, b.RData); c != 0 {
+		return c
+	}
+	return cmp.Compare(a.TTL, b.TTL)
+}
+
+func cmpRemotePeer(a, b *proto.RemotePeerConfig) int {
+	if a == nil || b == nil {
+		return boolCmp(a == nil, b == nil)
+	}
+	return cmp.Compare(a.WgPubKey, b.WgPubKey)
+}
+
+func cmpRoute(a, b *proto.Route) int {
+	if a == nil || b == nil {
+		return boolCmp(a == nil, b == nil)
+	}
+	if c := cmp.Compare(a.ID, b.ID); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.NetID, b.NetID); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.Network, b.Network); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.Peer, b.Peer); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.Metric, b.Metric); c != 0 {
+		return c
+	}
+	return slices.Compare(a.Domains, b.Domains)
+}
+
+func cmpFirewallRule(a, b *proto.FirewallRule) int {
+	if a == nil || b == nil {
+		return boolCmp(a == nil, b == nil)
+	}
+	if c := bytes.Compare(a.PolicyID, b.PolicyID); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.PeerIP, b.PeerIP); c != 0 { //nolint:staticcheck
+		return c
+	}
+	if c := cmp.Compare(int32(a.Direction), int32(b.Direction)); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(int32(a.Action), int32(b.Action)); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(int32(a.Protocol), int32(b.Protocol)); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.Port, b.Port); c != 0 {
+		return c
+	}
+	return cmp.Compare(portInfoKey(a.PortInfo), portInfoKey(b.PortInfo))
+}
+
+func cmpRouteFirewallRule(a, b *proto.RouteFirewallRule) int {
+	if a == nil || b == nil {
+		return boolCmp(a == nil, b == nil)
+	}
+	if c := bytes.Compare(a.PolicyID, b.PolicyID); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.RouteID, b.RouteID); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.Destination, b.Destination); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(int32(a.Protocol), int32(b.Protocol)); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(portInfoKey(a.PortInfo), portInfoKey(b.PortInfo)); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(int32(a.Action), int32(b.Action)); c != 0 {
+		return c
+	}
+	if c := slices.Compare(a.Domains, b.Domains); c != 0 {
+		return c
+	}
+	if c := slices.Compare(a.SourceRanges, b.SourceRanges); c != 0 {
+		return c
+	}
+	if c := cmp.Compare(a.CustomProtocol, b.CustomProtocol); c != 0 {
+		return c
+	}
+	return boolCmp(a.IsDynamic, b.IsDynamic)
+}
+
+func cmpForwardingRule(a, b *proto.ForwardingRule) int {
+	if a == nil || b == nil {
+		return boolCmp(a == nil, b == nil)
+	}
+	if c := cmp.Compare(int32(a.Protocol), int32(b.Protocol)); c != 0 {
+		return c
+	}
+	return bytes.Compare(a.TranslatedAddress, b.TranslatedAddress)
+}
+
+func portInfoKey(pi *proto.PortInfo) string {
+	if pi == nil {
+		return ""
+	}
+	switch sel := pi.PortSelection.(type) {
+	case *proto.PortInfo_Port:
+		return "P" + strconv.FormatUint(uint64(sel.Port), 10)
+	case *proto.PortInfo_Range_:
+		if sel.Range == nil {
+			return "R"
+		}
+		return "R" + strconv.FormatUint(uint64(sel.Range.Start), 10) + "-" + strconv.FormatUint(uint64(sel.Range.End), 10)
+	}
+	return ""
+}
diff --git a/management/internals/controllers/network_map/nmaptest/fixture.go b/management/internals/controllers/network_map/nmaptest/fixture.go
new file mode 100644
index 000000000..d56285f95
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/fixture.go
@@ -0,0 +1,218 @@
+package nmaptest
+
+import (
+	"crypto/sha256"
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"net"
+	"os"
+
+	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+// LoadNetworkMapData reads a fixture holding the NetworkMapData the store
+// would return for one account. Unknown fields are rejected so fixture typos
+// fail loudly instead of silently testing a default.
+func LoadNetworkMapData(path string) (*networkmap.NetworkMapData, error) {
+	f, err := os.Open(path)
+	if err != nil {
+		return nil, fmt.Errorf("open fixture: %w", err)
+	}
+	defer f.Close()
+
+	dec := json.NewDecoder(f)
+	dec.DisallowUnknownFields()
+	var nmData networkmap.NetworkMapData
+	if err := dec.Decode(&nmData); err != nil {
+		return nil, fmt.Errorf("decode fixture %s: %w", path, err)
+	}
+	return &nmData, nil
+}
+
+var defaultNetworkNet = func() net.IPNet {
+	_, ipnet, err := net.ParseCIDR("100.64.0.0/10")
+	if err != nil {
+		panic(err)
+	}
+	return *ipnet
+}()
+
+// applyFixtureDefaults fills the boilerplate a fixture may omit. Map-keyed
+// objects inherit their key as ID, peers get a deterministic WG-shaped key
+// and their ID as DNS label, PublicIDs default to the internal ID (the
+// envelope encoder puts public IDs on the wire and silently degrades on
+// empty ones), and a nil ValidatedPeers validates every peer — production
+// fills it through the integrated validator, not the store.
+func applyFixtureDefaults(nmData *networkmap.NetworkMapData) {
+	if nmData.Network == nil {
+		nmData.Network = &nmdata.Network{}
+	}
+	if nmData.Network.Identifier == "" {
+		nmData.Network.Identifier = "network"
+	}
+	if nmData.Network.Net.IP == nil {
+		nmData.Network.Net = defaultNetworkNet
+	}
+	if nmData.AccountSettings == nil {
+		nmData.AccountSettings = &nmdata.AccountSettingsInfo{}
+	}
+	if nmData.DNSSettings == nil {
+		nmData.DNSSettings = &nmdata.DNSSettings{}
+	}
+
+	for id, p := range nmData.Peers {
+		if p == nil {
+			continue
+		}
+		if p.ID == "" {
+			p.ID = id
+		}
+		if p.Key == "" {
+			p.Key = derivedWgKey(p.ID)
+		}
+		if p.DNSLabel == "" {
+			p.DNSLabel = p.ID
+		}
+	}
+
+	for id, g := range nmData.Groups {
+		if g == nil {
+			continue
+		}
+		if g.ID == "" {
+			g.ID = id
+		}
+		if g.Name == "" {
+			g.Name = g.ID
+		}
+		if g.PublicID == "" {
+			g.PublicID = g.ID
+		}
+	}
+
+	for _, policy := range nmData.Policies {
+		defaultPolicyIDs(policy)
+	}
+	resolveResourcePolicyRefs(nmData)
+
+	for _, r := range nmData.Routes {
+		if r != nil && r.PublicID == "" {
+			r.PublicID = r.ID
+		}
+	}
+	for _, nsg := range nmData.NameServerGroups {
+		if nsg != nil && nsg.PublicID == "" {
+			nsg.PublicID = nsg.ID
+		}
+	}
+	for _, res := range nmData.NetworkResources {
+		if res == nil {
+			continue
+		}
+		if res.PublicID == "" {
+			res.PublicID = res.ID
+		}
+		defaultXIDMapping(&nmData.NetworkXIDToPublicID, res.NetworkID)
+	}
+	for networkID, routers := range nmData.Routers {
+		defaultXIDMapping(&nmData.NetworkXIDToPublicID, networkID)
+		for _, router := range routers {
+			if router != nil && router.PublicID == "" {
+				router.PublicID = networkID
+			}
+		}
+	}
+
+	for id, pc := range nmData.PostureChecks {
+		if pc == nil {
+			continue
+		}
+		if pc.ID == "" {
+			pc.ID = id
+		}
+		defaultXIDMapping(&nmData.PostureCheckXIDToPublicID, pc.ID)
+	}
+
+	if nmData.ValidatedPeers == nil {
+		nmData.ValidatedPeers = make(map[string]struct{}, len(nmData.Peers))
+		for id := range nmData.Peers {
+			nmData.ValidatedPeers[id] = struct{}{}
+		}
+	}
+}
+
+// resolveResourcePolicyRefs lets a fixture name an account policy by ID in
+// ResourcePolicies — {"ID": "pol-x"} with no rules — instead of repeating it.
+// The real store puts the same policy pointer in both places, which is what
+// resolving the reference reproduces.
+func resolveResourcePolicyRefs(nmData *networkmap.NetworkMapData) {
+	byID := make(map[string]*nmdata.Policy, len(nmData.Policies))
+	for _, policy := range nmData.Policies {
+		if policy != nil && policy.ID != "" {
+			byID[policy.ID] = policy
+		}
+	}
+
+	for _, policies := range nmData.ResourcePolicies {
+		for i, policy := range policies {
+			if policy == nil {
+				continue
+			}
+			if len(policy.Rules) == 0 {
+				if full, ok := byID[policy.ID]; ok {
+					policies[i] = full
+					continue
+				}
+			}
+			defaultPolicyIDs(policy)
+		}
+	}
+}
+
+func defaultPolicyIDs(policy *nmdata.Policy) {
+	if policy == nil {
+		return
+	}
+	if policy.PublicID == "" {
+		policy.PublicID = policy.ID
+	}
+	for i, rule := range policy.Rules {
+		if rule == nil {
+			continue
+		}
+		if rule.PolicyID == "" {
+			rule.PolicyID = policy.ID
+		}
+		if rule.ID == "" {
+			// Production gives a rule its policy's id (management/server/policy.go:205,
+			// "when policy can contain multiple rules, need refactor"), so a
+			// single-rule policy — the only shape the product can create today —
+			// must be modelled that way or the wire ids come out unrealistic.
+			rule.ID = policy.ID
+			if len(policy.Rules) > 1 {
+				rule.ID = fmt.Sprintf("%s-rule-%d", policy.ID, i)
+			}
+		}
+	}
+}
+
+func defaultXIDMapping(m *map[string]string, id string) {
+	if id == "" {
+		return
+	}
+	if *m == nil {
+		*m = make(map[string]string)
+	}
+	if _, ok := (*m)[id]; !ok {
+		(*m)[id] = id
+	}
+}
+
+// derivedWgKey returns a deterministic base64 key of 32 bytes, valid for the
+// envelope decoder's WG-key identity.
+func derivedWgKey(peerID string) string {
+	sum := sha256.Sum256([]byte(peerID))
+	return base64.StdEncoding.EncodeToString(sum[:])
+}
diff --git a/management/internals/controllers/network_map/nmaptest/golden_test.go b/management/internals/controllers/network_map/nmaptest/golden_test.go
new file mode 100644
index 000000000..75c0d57d2
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/golden_test.go
@@ -0,0 +1,12 @@
+package nmaptest_test
+
+import (
+	"path/filepath"
+	"testing"
+
+	"github.com/netbirdio/netbird/management/internals/controllers/network_map/nmaptest"
+)
+
+func TestNetworkMapGolden(t *testing.T) {
+	nmaptest.RunGoldenDir(t, filepath.Join("testdata", "cases"))
+}
diff --git a/management/internals/controllers/network_map/nmaptest/legacyaccount.go b/management/internals/controllers/network_map/nmaptest/legacyaccount.go
new file mode 100644
index 000000000..d6a653f7a
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/legacyaccount.go
@@ -0,0 +1,543 @@
+package nmaptest
+
+import (
+	"context"
+	"strings"
+	"testing"
+
+	"github.com/miekg/dns"
+	"github.com/stretchr/testify/require"
+
+	nbdns "github.com/netbirdio/netbird/dns"
+	"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache"
+	"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
+	"github.com/netbirdio/netbird/management/internals/modules/zones"
+	"github.com/netbirdio/netbird/management/internals/modules/zones/records"
+	resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
+	routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
+	networkTypes "github.com/netbirdio/netbird/management/server/networks/types"
+	nbpeer "github.com/netbirdio/netbird/management/server/peer"
+	"github.com/netbirdio/netbird/management/server/posture"
+	"github.com/netbirdio/netbird/management/server/types"
+	"github.com/netbirdio/netbird/management/server/types/legacynmap"
+	nbroute "github.com/netbirdio/netbird/route"
+	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+	"github.com/netbirdio/netbird/shared/management/proto"
+	sharedtypes "github.com/netbirdio/netbird/shared/management/types"
+)
+
+// legacyInput is the account and the four derived arguments main's computation
+// took alongside it. The controller resolved them from the account before
+// calling; the twin carries them as fields, so the fixture is the source for
+// both halves.
+type legacyInput struct {
+	account          *types.Account
+	accountZones     []*zones.Zone
+	validatedPeers   map[string]struct{}
+	resourcePolicies map[string][]*types.Policy
+	routers          map[string]map[string]*routerTypes.NetworkRouter
+	groupIDToUserIDs map[string][]string
+}
+
+// legacyInputFromData rebuilds the Account the fixture stands for. A fixture is
+// the value the store returns, and the store's twins carry exactly the state
+// the computation reads, so inverting them reproduces the account main would
+// have loaded — which is what lets one expectation measure all three paths.
+//
+// The inverse is only defined for what a twin carries: fields the builders drop
+// (peer names, policy descriptions, user records behind AllowedUserIDs) come
+// back as the zero value or a minimal stand-in, because no path reads them.
+func legacyInputFromData(accountID string, nmData *networkmap.NetworkMapData) legacyInput {
+	account := &types.Account{
+		Id:               accountID,
+		Network:          accountNetwork(nmData.Network),
+		Settings:         accountSettings(nmData.AccountSettings),
+		DNSSettings:      types.DNSSettings{DisabledManagementGroups: nmData.DNSSettings.DisabledManagementGroups},
+		Peers:            make(map[string]*nbpeer.Peer, len(nmData.Peers)),
+		Groups:           make(map[string]*types.Group, len(nmData.Groups)),
+		Policies:         make([]*types.Policy, 0, len(nmData.Policies)),
+		Routes:           make(map[nbroute.ID]*nbroute.Route, len(nmData.Routes)),
+		NameServerGroups: make(map[string]*nbdns.NameServerGroup, len(nmData.NameServerGroups)),
+		NetworkResources: make([]*resourceTypes.NetworkResource, 0, len(nmData.NetworkResources)),
+		PostureChecks:    make([]*posture.Checks, 0, len(nmData.PostureChecks)),
+		Users:            make(map[string]*types.User, len(nmData.AllowedUserIDs)),
+		Services:         accountServices(nmData.Services),
+	}
+
+	for id, p := range nmData.Peers {
+		account.Peers[id] = accountPeer(id, p)
+	}
+	for id, g := range nmData.Groups {
+		account.Groups[id] = accountGroup(id, g)
+	}
+
+	policiesByID := make(map[string]*types.Policy, len(nmData.Policies))
+	for _, p := range nmData.Policies {
+		policy := accountPolicy(p)
+		if policy == nil {
+			continue
+		}
+		account.Policies = append(account.Policies, policy)
+		policiesByID[policy.ID] = policy
+	}
+
+	for _, r := range nmData.Routes {
+		route := accountRoute(r)
+		if route != nil {
+			account.Routes[route.ID] = route
+		}
+	}
+	for _, nsg := range nmData.NameServerGroups {
+		group := accountNSG(nsg)
+		if group != nil {
+			account.NameServerGroups[group.ID] = group
+		}
+	}
+	for _, res := range nmData.NetworkResources {
+		if resource := accountNetworkResource(res); resource != nil {
+			account.NetworkResources = append(account.NetworkResources, resource)
+		}
+	}
+	for id, pc := range nmData.PostureChecks {
+		if check := accountPostureChecks(id, pc, nmData.PostureCheckXIDToPublicID[id]); check != nil {
+			account.PostureChecks = append(account.PostureChecks, check)
+		}
+	}
+	for xid, publicID := range nmData.NetworkXIDToPublicID {
+		account.Networks = append(account.Networks, &networkTypes.Network{ID: xid, PublicID: publicID})
+	}
+	// The twin keeps only the ids of the users a peer may be shared with; the
+	// legacy side derives the same set from the account's user records, so a
+	// bare non-blocked regular user per id is enough.
+	for userID := range nmData.AllowedUserIDs {
+		account.Users[userID] = &types.User{Id: userID}
+	}
+
+	// Main's network-map controller synthesised the reverse-proxy ACLs onto the
+	// account and only then derived the resource-policy map, so the frozen copy
+	// has to be fed in that order to stand for what main produced.
+	account.Policies = append(account.Policies, legacynmap.SynthesizeProxyPolicies(account)...)
+
+	return legacyInput{
+		account:          account,
+		accountZones:     accountZones(nmData.AppliedZoneCandidates),
+		validatedPeers:   nmData.ValidatedPeers,
+		resourcePolicies: account.GetResourcePoliciesMap(),
+		routers:          accountRouters(nmData.Routers),
+		groupIDToUserIDs: nmData.GroupIDToUserIDs,
+	}
+}
+
+// computeLegacy runs the fixture through main's frozen path and its own proto
+// encoder, the one comparison surface the three modes share.
+func computeLegacy(t *testing.T, ctx context.Context, legacy legacyInput, peerID string, zone nmdata.CustomZone, dnsDomain string, dnsFwdPort int64) *proto.NetworkMap {
+	t.Helper()
+
+	require.NotNil(t, legacy.account, "legacy mode needs an account rebuilt from the fixture")
+	peer := legacy.account.Peers[peerID]
+	require.NotNil(t, peer, "target peer %q not in rebuilt account", peerID)
+
+	nm := legacynmap.GetPeerNetworkMapFromComponents(
+		legacy.account, ctx, peerID, legacyCustomZone(zone), legacy.accountZones, legacy.validatedPeers,
+		legacy.resourcePolicies, legacy.routers, nil, legacy.groupIDToUserIDs,
+	)
+	require.NotNil(t, nm, "legacy path returned no network map for peer %q", peerID)
+
+	return legacynmap.ToProtoNetworkMap(
+		ctx, peer, nm, dnsDomain, legacy.account.Settings, nil, &cache.DNSConfigCache{}, dnsFwdPort,
+	)
+}
+
+// legacyCustomZone converts the peers custom zone the runner computes once for
+// every mode into the shape main's path took.
+func legacyCustomZone(z nmdata.CustomZone) nbdns.CustomZone {
+	zoneRecords := make([]nbdns.SimpleRecord, 0, len(z.Records))
+	for _, r := range z.Records {
+		zoneRecords = append(zoneRecords, nbdns.SimpleRecord{
+			Name:  r.Name,
+			Type:  r.Type,
+			Class: r.Class,
+			TTL:   r.TTL,
+			RData: r.RData,
+		})
+	}
+	return nbdns.CustomZone{
+		Domain:               z.Domain,
+		Records:              zoneRecords,
+		SearchDomainDisabled: z.SearchDomainDisabled,
+		NonAuthoritative:     z.NonAuthoritative,
+	}
+}
+
+func accountNetwork(n *nmdata.Network) *types.Network {
+	if n == nil {
+		return nil
+	}
+	return &types.Network{
+		Identifier: n.Identifier,
+		Net:        n.Net,
+		NetV6:      n.NetV6,
+		Dns:        n.Dns,
+		Serial:     uint64(n.Serial),
+	}
+}
+
+func accountSettings(s *nmdata.AccountSettingsInfo) *types.Settings {
+	if s == nil {
+		return nil
+	}
+	return &types.Settings{
+		PeerLoginExpirationEnabled:      s.PeerLoginExpirationEnabled,
+		PeerLoginExpiration:             s.PeerLoginExpiration,
+		PeerInactivityExpirationEnabled: s.PeerInactivityExpirationEnabled,
+		PeerInactivityExpiration:        s.PeerInactivityExpiration,
+		DNSDomain:                       s.DNSDomain,
+		IPv6EnabledGroups:               s.IPv6EnabledGroups,
+		RoutingPeerDNSResolutionEnabled: s.RoutingPeerDNSResolutionEnabled,
+		LazyConnectionEnabled:           s.LazyConnectionEnabled,
+		AutoUpdateVersion:               s.AutoUpdateVersion,
+		AutoUpdateAlways:                s.AutoUpdateAlways,
+		MetricsPushEnabled:              s.MetricsPushEnabled,
+	}
+}
+
+func accountPeer(id string, p *nmdata.Peer) *nbpeer.Peer {
+	if p == nil {
+		return nil
+	}
+	networkAddresses := make([]nbpeer.NetworkAddress, 0, len(p.Meta.NetworkAddresses))
+	for _, na := range p.Meta.NetworkAddresses {
+		networkAddresses = append(networkAddresses, nbpeer.NetworkAddress{NetIP: na.NetIP})
+	}
+	files := make([]nbpeer.File, 0, len(p.Meta.Files))
+	for _, f := range p.Meta.Files {
+		files = append(files, nbpeer.File{Path: f.Path, ProcessIsRunning: f.ProcessIsRunning})
+	}
+	return &nbpeer.Peer{
+		ID:                     id,
+		Key:                    p.Key,
+		SSHKey:                 p.SSHKey,
+		DNSLabel:               p.DNSLabel,
+		UserID:                 p.UserID,
+		SSHEnabled:             p.SSHEnabled,
+		LoginExpirationEnabled: p.LoginExpirationEnabled,
+		LastLogin:              p.LastLogin,
+		IP:                     p.IP,
+		IPv6:                   p.IPv6,
+		ExtraDNSLabels:         p.ExtraDNSLabels,
+		ProxyMeta:              nbpeer.ProxyMeta{Embedded: p.ProxyMeta.Embedded, Cluster: p.ProxyMeta.Cluster},
+		// Connected is what SynthesizePrivateServiceZones gates its records on,
+		// and a fixture peer stands for a peer the store returned, so it is one
+		// the account would have reported connected.
+		Status: &nbpeer.PeerStatus{RequiresApproval: p.RequiresApproval, Connected: true},
+		Meta: nbpeer.PeerSystemMeta{
+			WtVersion:          p.Meta.WtVersion,
+			GoOS:               p.Meta.GoOS,
+			OSVersion:          p.Meta.OSVersion,
+			KernelVersion:      p.Meta.KernelVersion,
+			NetworkAddresses:   networkAddresses,
+			Files:              files,
+			Capabilities:       p.Meta.Capabilities,
+			SyncMessageVersion: p.Meta.SyncMessageVersion,
+			Flags: nbpeer.Flags{
+				ServerSSHAllowed: p.Meta.Flags.ServerSSHAllowed,
+				DisableIPv6:      p.Meta.Flags.DisableIPv6,
+			},
+		},
+		Location: nbpeer.Location{
+			CountryCode:  p.Location.CountryCode,
+			CityName:     p.Location.CityName,
+			ConnectionIP: p.Location.ConnectionIP,
+		},
+	}
+}
+
+func accountGroup(id string, g *nmdata.Group) *types.Group {
+	if g == nil {
+		return nil
+	}
+	return &types.Group{
+		ID:       id,
+		Name:     g.Name,
+		PublicID: g.PublicID,
+		Peers:    g.Peers,
+	}
+}
+
+func accountPolicy(p *nmdata.Policy) *types.Policy {
+	if p == nil {
+		return nil
+	}
+	rules := make([]*types.PolicyRule, 0, len(p.Rules))
+	for _, r := range p.Rules {
+		if r == nil {
+			continue
+		}
+		var portRanges []sharedtypes.RulePortRange
+		if r.PortRanges != nil {
+			portRanges = make([]sharedtypes.RulePortRange, len(r.PortRanges))
+			for i, pr := range r.PortRanges {
+				portRanges[i] = sharedtypes.RulePortRange{Start: pr.Start, End: pr.End}
+			}
+		}
+		rules = append(rules, &types.PolicyRule{
+			ID:                  r.ID,
+			PolicyID:            r.PolicyID,
+			Enabled:             r.Enabled,
+			Action:              sharedtypes.PolicyTrafficActionType(r.Action),
+			Protocol:            sharedtypes.PolicyRuleProtocolType(r.Protocol),
+			Bidirectional:       r.Bidirectional,
+			Sources:             r.Sources,
+			Destinations:        r.Destinations,
+			SourceResource:      types.Resource{ID: r.SourceResource.ID, Type: sharedtypes.ResourceType(r.SourceResource.Type)},
+			DestinationResource: types.Resource{ID: r.DestinationResource.ID, Type: sharedtypes.ResourceType(r.DestinationResource.Type)},
+			Ports:               r.Ports,
+			PortRanges:          portRanges,
+			AuthorizedGroups:    r.AuthorizedGroups,
+			AuthorizedUser:      r.AuthorizedUser,
+		})
+	}
+	return &types.Policy{
+		ID:                  p.ID,
+		PublicID:            p.PublicID,
+		Enabled:             p.Enabled,
+		SourcePostureChecks: p.SourcePostureChecks,
+		Rules:               rules,
+	}
+}
+
+func accountRoute(r *nmdata.Route) *nbroute.Route {
+	if r == nil {
+		return nil
+	}
+	return &nbroute.Route{
+		ID:                  nbroute.ID(r.ID),
+		AccountID:           r.AccountID,
+		PublicID:            r.PublicID,
+		Network:             r.Network,
+		Domains:             r.Domains,
+		KeepRoute:           r.KeepRoute,
+		NetID:               nbroute.NetID(r.NetID),
+		Description:         r.Description,
+		Peer:                r.Peer,
+		PeerID:              r.PeerID,
+		PeerGroups:          r.PeerGroups,
+		NetworkType:         nbroute.NetworkType(r.NetworkType),
+		Masquerade:          r.Masquerade,
+		Metric:              r.Metric,
+		Enabled:             r.Enabled,
+		Groups:              r.Groups,
+		AccessControlGroups: r.AccessControlGroups,
+		SkipAutoApply:       r.SkipAutoApply,
+	}
+}
+
+func accountNSG(n *nmdata.NameServerGroup) *nbdns.NameServerGroup {
+	if n == nil {
+		return nil
+	}
+	nameServers := make([]nbdns.NameServer, 0, len(n.NameServers))
+	for _, ns := range n.NameServers {
+		nameServers = append(nameServers, nbdns.NameServer{
+			IP:     ns.IP,
+			NSType: nbdns.NameServerType(ns.NSType),
+			Port:   ns.Port,
+		})
+	}
+	return &nbdns.NameServerGroup{
+		ID:                   n.ID,
+		PublicID:             n.PublicID,
+		Name:                 n.Name,
+		Description:          n.Description,
+		NameServers:          nameServers,
+		Groups:               n.Groups,
+		Primary:              n.Primary,
+		Domains:              n.Domains,
+		Enabled:              n.Enabled,
+		SearchDomainsEnabled: n.SearchDomainsEnabled,
+	}
+}
+
+func accountNetworkResource(r *nmdata.NetworkResource) *resourceTypes.NetworkResource {
+	if r == nil {
+		return nil
+	}
+	return &resourceTypes.NetworkResource{
+		ID:          r.ID,
+		NetworkID:   r.NetworkID,
+		AccountID:   r.AccountID,
+		PublicID:    r.PublicID,
+		Name:        r.Name,
+		Description: r.Description,
+		Type:        resourceTypes.NetworkResourceType(r.Type),
+		Address:     r.Address,
+		Domain:      r.Domain,
+		Prefix:      r.Prefix,
+		Enabled:     r.Enabled,
+	}
+}
+
+func accountPostureChecks(id string, pc *nmdata.PostureChecks, publicID string) *posture.Checks {
+	if pc == nil {
+		return nil
+	}
+	out := &posture.Checks{ID: id, PublicID: publicID}
+	def := pc.Checks
+	if def.NBVersionCheck != nil {
+		out.Checks.NBVersionCheck = &posture.NBVersionCheck{MinVersion: def.NBVersionCheck.MinVersion}
+	}
+	if def.OSVersionCheck != nil {
+		oc := &posture.OSVersionCheck{}
+		if def.OSVersionCheck.Android != nil {
+			oc.Android = &posture.MinVersionCheck{MinVersion: def.OSVersionCheck.Android.MinVersion}
+		}
+		if def.OSVersionCheck.Darwin != nil {
+			oc.Darwin = &posture.MinVersionCheck{MinVersion: def.OSVersionCheck.Darwin.MinVersion}
+		}
+		if def.OSVersionCheck.Ios != nil {
+			oc.Ios = &posture.MinVersionCheck{MinVersion: def.OSVersionCheck.Ios.MinVersion}
+		}
+		if def.OSVersionCheck.Linux != nil {
+			oc.Linux = &posture.MinKernelVersionCheck{MinKernelVersion: def.OSVersionCheck.Linux.MinKernelVersion}
+		}
+		if def.OSVersionCheck.Windows != nil {
+			oc.Windows = &posture.MinKernelVersionCheck{MinKernelVersion: def.OSVersionCheck.Windows.MinKernelVersion}
+		}
+		out.Checks.OSVersionCheck = oc
+	}
+	if def.GeoLocationCheck != nil {
+		gc := &posture.GeoLocationCheck{Action: def.GeoLocationCheck.Action}
+		for _, loc := range def.GeoLocationCheck.Locations {
+			gc.Locations = append(gc.Locations, posture.Location{CountryCode: loc.CountryCode, CityName: loc.CityName})
+		}
+		out.Checks.GeoLocationCheck = gc
+	}
+	if def.PeerNetworkRangeCheck != nil {
+		out.Checks.PeerNetworkRangeCheck = &posture.PeerNetworkRangeCheck{
+			Action: def.PeerNetworkRangeCheck.Action,
+			Ranges: def.PeerNetworkRangeCheck.Ranges,
+		}
+	}
+	if def.ProcessCheck != nil {
+		procs := make([]posture.Process, 0, len(def.ProcessCheck.Processes))
+		for _, p := range def.ProcessCheck.Processes {
+			procs = append(procs, posture.Process{LinuxPath: p.LinuxPath, MacPath: p.MacPath, WindowsPath: p.WindowsPath})
+		}
+		out.Checks.ProcessCheck = &posture.ProcessCheck{Processes: procs}
+	}
+	return out
+}
+
+func accountServices(services []*nmdata.Service) []*service.Service {
+	if len(services) == 0 {
+		return nil
+	}
+	out := make([]*service.Service, 0, len(services))
+	for _, svc := range services {
+		if svc == nil {
+			continue
+		}
+		targets := make([]*service.Target, 0, len(svc.Targets))
+		for _, t := range svc.Targets {
+			if t == nil {
+				continue
+			}
+			target := &service.Target{
+				Enabled:    t.Enabled,
+				Port:       t.Port,
+				Protocol:   t.Protocol,
+				TargetId:   t.TargetID,
+				TargetType: service.TargetType(t.TargetType),
+			}
+			if t.Path != "" {
+				path := t.Path
+				target.Path = &path
+			}
+			targets = append(targets, target)
+		}
+		out = append(out, &service.Service{
+			ID:           svc.ID,
+			Enabled:      svc.Enabled,
+			Private:      svc.Private,
+			Mode:         svc.Mode,
+			ProxyCluster: svc.ProxyCluster,
+			AccessGroups: svc.AccessGroups,
+			Targets:      targets,
+		})
+	}
+	return out
+}
+
+// accountZones inverts buildAppliedZoneCandidates. Records come back with the
+// record type the builder mapped them from; a candidate only ever carries the
+// three types it converts.
+func accountZones(candidates []networkmap.AppliedZoneCandidate) []*zones.Zone {
+	if len(candidates) == 0 {
+		return nil
+	}
+	out := make([]*zones.Zone, 0, len(candidates))
+	for _, candidate := range candidates {
+		zoneRecords := make([]*records.Record, 0, len(candidate.Zone.Records))
+		for _, r := range candidate.Zone.Records {
+			recordType, ok := zoneRecordType(r.Type)
+			if !ok {
+				continue
+			}
+			zoneRecords = append(zoneRecords, &records.Record{
+				Name:    strings.TrimSuffix(r.Name, "."),
+				Type:    recordType,
+				Content: r.RData,
+				TTL:     r.TTL,
+			})
+		}
+		out = append(out, &zones.Zone{
+			ID:                 candidate.Zone.Domain,
+			Domain:             strings.TrimSuffix(candidate.Zone.Domain, "."),
+			Enabled:            true,
+			EnableSearchDomain: !candidate.Zone.SearchDomainDisabled,
+			DistributionGroups: candidate.DistributionGroups,
+			Records:            zoneRecords,
+		})
+	}
+	return out
+}
+
+func zoneRecordType(recordType int) (records.RecordType, bool) {
+	switch uint16(recordType) {
+	case dns.TypeA:
+		return records.RecordTypeA, true
+	case dns.TypeAAAA:
+		return records.RecordTypeAAAA, true
+	case dns.TypeCNAME:
+		return records.RecordTypeCNAME, true
+	default:
+		return "", false
+	}
+}
+
+func accountRouters(routers map[string]map[string]*nmdata.NetworkRouter) map[string]map[string]*routerTypes.NetworkRouter {
+	if len(routers) == 0 {
+		return nil
+	}
+	out := make(map[string]map[string]*routerTypes.NetworkRouter, len(routers))
+	for networkID, inner := range routers {
+		converted := make(map[string]*routerTypes.NetworkRouter, len(inner))
+		for peerID, router := range inner {
+			if router == nil {
+				continue
+			}
+			converted[peerID] = &routerTypes.NetworkRouter{
+				NetworkID:  networkID,
+				PublicID:   router.PublicID,
+				Peer:       peerID,
+				PeerGroups: router.PeerGroups,
+				Masquerade: router.Masquerade,
+				Metric:     router.Metric,
+				Enabled:    router.Enabled,
+			}
+		}
+		out[networkID] = converted
+	}
+	return out
+}
diff --git a/management/internals/controllers/network_map/nmaptest/runner.go b/management/internals/controllers/network_map/nmaptest/runner.go
new file mode 100644
index 000000000..c70bd7298
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/runner.go
@@ -0,0 +1,332 @@
+// Package nmaptest measures network map generation on the dedicated store
+// path against committed expectations. A case stands in for the store load
+// with a NetworkMapData fixture — the value NetworkMapDBStoreImpl returns for
+// one account — then runs the production per-peer pipeline the controller
+// uses, PeersCustomZone → GetPeerNetworkMapComponents → proto conversion, in
+// both wire shapes: the full map (grpc.ToSyncResponse) and the component
+// envelope expanded client-side (grpc.ToComponentSyncResponse →
+// networkmap.EnvelopeToNetworkMap). A third mode inverts the fixture back into
+// the Account it stands for and runs main's frozen path over it (legacynmap),
+// so every case is pinned to what main shipped as well.
+//
+// The expectation files are the point of the framework. They state what the
+// output should be, so a failing case means the code disagrees with the
+// expectation and the answer is normally to fix the code; an expectation
+// changes only through a deliberate reviewed edit. Nothing in this package
+// writes to testdata — there is no flag that records current behaviour into an
+// expectation, because that is how a defect becomes the baseline. Cases whose
+// expectation encodes correct behaviour the code does not yet deliver stay red
+// on purpose.
+//
+// A case lives in testdata/cases// as case.json (manifest: description,
+// peers, optional accountID, dnsDomain, modes), nmdata.json (the fixture the
+// mocked store returns, using Go field names; zero values may be omitted and
+// applyFixtureDefaults fills the boilerplate) and golden/.json.
+//
+// There is ONE expectation per peer, shared by every mode, because all three
+// must arrive at the same client-facing map. Full and envelope are not even
+// different computations — CalculateNetworkMapFromComponents is
+// components.Calculate and both assemble the proto with the same encode
+// helpers — so the only variable between them is what the envelope round-trip
+// did in transit, and a difference there is a round-trip fidelity defect.
+// Legacy is a different computation, main's, reached from a rebuilt account;
+// a difference there is this tree having drifted from what main shipped.
+// Results are canonicalized before comparison, since repeated proto fields
+// come from map iteration.
+package nmaptest
+
+import (
+	"bytes"
+	"context"
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"slices"
+	"strings"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/stretchr/testify/require"
+	"golang.org/x/exp/maps"
+	"google.golang.org/protobuf/encoding/protojson"
+	"google.golang.org/protobuf/testing/protocmp"
+
+	"github.com/netbirdio/netbird/management/internals/controllers/network_map"
+	"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
+	"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache"
+	mgmtgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
+	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+	"github.com/netbirdio/netbird/shared/management/proto"
+)
+
+// Mode selects the wire shape a case is verified through. Both end in a
+// *proto.NetworkMap, the one comparison surface shared by every path.
+type Mode string
+
+const (
+	// ModeFull is the legacy wire shape: the server runs Calculate and sends
+	// the expanded map (grpc.ToSyncResponse).
+	ModeFull Mode = "full"
+	// ModeEnvelope is the component wire shape: the server encodes components
+	// into a NetworkMapEnvelope (grpc.ToComponentSyncResponse) and the map is
+	// expanded the way the client engine does (networkmap.EnvelopeToNetworkMap).
+	ModeEnvelope Mode = "envelope"
+	// ModeLegacy is main's frozen path: the fixture is inverted back into the
+	// Account it stands for and run through legacynmap, the copy of what main
+	// shipped. It is the outside measurement — the other two modes share this
+	// tree's computation, so only this one can catch the whole tree drifting.
+	ModeLegacy Mode = "legacy"
+
+	defaultAccountID = "account"
+	defaultDNSDomain = "netbird.test"
+)
+
+var defaultModes = []Mode{ModeFull, ModeEnvelope, ModeLegacy}
+
+// Case is one nmap-generation scenario: store data for a single account, the
+// peers whose network maps are computed, and the directory holding one expected
+// *proto.NetworkMap per peer — shared by every mode.
+type Case struct {
+	Name      string
+	AccountID string
+	DNSDomain string
+	Peers     []string
+	Modes     []Mode
+	Data      *networkmap.NetworkMapData
+	GoldenDir string
+}
+
+type manifest struct {
+	Description string
+	AccountID   string
+	DNSDomain   string
+	Peers       []string
+	Modes       []Mode
+}
+
+// RunGoldenDir discovers and runs every fixture case under dir. A case is a
+// directory containing case.json (manifest), nmdata.json (store fixture) and
+// golden/.json (expected proto.NetworkMap, protojson).
+func RunGoldenDir(t *testing.T, dir string) {
+	t.Helper()
+
+	entries, err := os.ReadDir(dir)
+	require.NoError(t, err, "read cases dir")
+
+	ran := 0
+	for _, entry := range entries {
+		if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
+			continue
+		}
+		caseDir := filepath.Join(dir, entry.Name())
+		c, err := loadCase(caseDir)
+		require.NoError(t, err, "load case %s", entry.Name())
+		ran++
+		t.Run(entry.Name(), func(t *testing.T) {
+			RunCase(t, c)
+		})
+	}
+	require.NotZero(t, ran, "no cases found under %s", dir)
+}
+
+func loadCase(caseDir string) (Case, error) {
+	raw, err := os.ReadFile(filepath.Join(caseDir, "case.json"))
+	if err != nil {
+		return Case{}, fmt.Errorf("read manifest: %w", err)
+	}
+	dec := json.NewDecoder(bytes.NewReader(raw))
+	dec.DisallowUnknownFields()
+	var m manifest
+	if err := dec.Decode(&m); err != nil {
+		return Case{}, fmt.Errorf("decode manifest: %w", err)
+	}
+
+	data, err := LoadNetworkMapData(filepath.Join(caseDir, "nmdata.json"))
+	if err != nil {
+		return Case{}, err
+	}
+
+	return Case{
+		Name:      filepath.Base(caseDir),
+		AccountID: m.AccountID,
+		DNSDomain: m.DNSDomain,
+		Peers:     m.Peers,
+		Modes:     m.Modes,
+		Data:      data,
+		GoldenDir: filepath.Join(caseDir, "golden"),
+	}, nil
+}
+
+// RunCase computes each target peer's network map through every enabled mode
+// and compares the canonicalized result against the peer's expectation file.
+// It mirrors the controller's store path: fill fixture defaults, precompute
+// posture validation once, then run the per-peer pipeline.
+func RunCase(t *testing.T, c Case) {
+	t.Helper()
+
+	require.NotNil(t, c.Data, "case %s: Data is required", c.Name)
+	require.NotEmpty(t, c.Peers, "case %s: Peers is required", c.Name)
+	require.NotEmpty(t, c.GoldenDir, "case %s: GoldenDir is required", c.Name)
+	if c.AccountID == "" {
+		c.AccountID = defaultAccountID
+	}
+	if c.DNSDomain == "" {
+		c.DNSDomain = defaultDNSDomain
+	}
+	if len(c.Modes) == 0 {
+		c.Modes = defaultModes
+	}
+
+	ctx := context.Background()
+	nmData := c.Data
+	applyFixtureDefaults(nmData)
+	nmData.PrecomputePostureValidation()
+
+	dnsDomain := c.DNSDomain
+	if nmData.AccountSettings.DNSDomain != "" {
+		dnsDomain = nmData.AccountSettings.DNSDomain
+	}
+
+	zone := networkmap.PeersCustomZone(ctx, c.AccountID, dnsDomain, nmData.Peers, controller.IPv6AllowedPeersFromData(nmData))
+	dnsFwdPort := controller.ComputeForwarderPortFromData(nmData.Peers, network_map.DnsForwarderPortMinVersion)
+
+	for _, mode := range c.Modes {
+		if mode == ModeEnvelope {
+			requireEnvelopeSafeKeys(t, nmData, c.Name)
+			break
+		}
+	}
+
+	// Built before any mode runs: the first per-peer computation injects the
+	// synthesised proxy ACLs into the twin's policies, and the legacy side
+	// synthesises its own, so inverting a twin that already carries them would
+	// hand the legacy path each ACL twice.
+	var legacy legacyInput
+	if slices.Contains(c.Modes, ModeLegacy) {
+		legacy = legacyInputFromData(c.AccountID, nmData)
+	}
+
+	for _, peerID := range c.Peers {
+		peer := nmData.Peers[peerID]
+		require.NotNil(t, peer, "case %s: target peer %q not in fixture", c.Name, peerID)
+
+		for _, mode := range c.Modes {
+			t.Run(peerID+"/"+string(mode), func(t *testing.T) {
+				got := computeMode(t, ctx, mode, nmData, peerID, zone, dnsDomain, dnsFwdPort, legacy)
+				canonicalize(got)
+				compareGolden(t, filepath.Join(c.GoldenDir, peerID+".json"), got, mode)
+			})
+		}
+	}
+}
+
+// computeMode produces the peer's proto.NetworkMap the way the controller does
+// for that wire shape.
+func computeMode(t *testing.T, ctx context.Context, mode Mode, nmData *networkmap.NetworkMapData,
+	peerID string, zone nmdata.CustomZone, dnsDomain string, dnsFwdPort int64, legacy legacyInput) *proto.NetworkMap {
+	t.Helper()
+
+	peer := nmData.Peers[peerID]
+	require.NotNil(t, peer, "target peer %q not in fixture", peerID)
+
+	switch mode {
+	case ModeLegacy:
+		return computeLegacy(t, ctx, legacy, peerID, zone, dnsDomain, dnsFwdPort)
+	case ModeFull:
+		nmap := controller.NetworkMapFromData(ctx, nmData, peerID, zone, nil)
+		return mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, peer, nil, nil, nmap, dnsDomain, nil,
+			&cache.DNSConfigCache{}, nmData.AccountSettings, nil, nil, dnsFwdPort).NetworkMap
+	case ModeEnvelope:
+		components := nmData.GetPeerNetworkMapComponents(peerID, zone)
+		peerGroups := maps.Keys(nmData.GetPeerGroups(peerID))
+		resp := mgmtgrpc.ToComponentSyncResponse(ctx, nil, nil, nil, peer, nil, nil, components, nil,
+			dnsDomain, nil, nmData.AccountSettings, nil, peerGroups, dnsFwdPort)
+		res, err := networkmap.EnvelopeToNetworkMap(ctx, resp.NetworkMapEnvelope, peer.Key, dnsDomain)
+		require.NoError(t, err, "expand envelope")
+		return res.NetworkMap
+	default:
+		t.Fatalf("unknown mode %q", mode)
+		return nil
+	}
+}
+
+// requireEnvelopeSafeKeys fails fast on peer keys the envelope decoder would
+// silently drop: it re-keys peers by base64 of the raw 32-byte WG public key.
+func requireEnvelopeSafeKeys(t *testing.T, nmData *networkmap.NetworkMapData, caseName string) {
+	t.Helper()
+	for id, p := range nmData.Peers {
+		if p == nil {
+			continue
+		}
+		raw, err := base64.StdEncoding.DecodeString(p.Key)
+		if err != nil || len(raw) != 32 {
+			t.Fatalf("case %s: peer %q Key must be base64 of 32 bytes for mode %q (the envelope decoder drops it otherwise); use a real WireGuard public key or restrict the case to mode %q",
+				caseName, id, ModeEnvelope, ModeFull)
+		}
+	}
+}
+
+// compareGolden measures got against the committed expectation file. One
+// expectation serves every mode, because the modes run the same computation and
+// must therefore agree. The expectation is the authority: a mismatch means the
+// code does not produce what this case says it should, so it is reported as a
+// failure and not quietly absorbed.
+//
+// The full and legacy modes are compared verbatim, identifiers included, so the
+// expectation pins real ids and stays readable. The envelope mode has
+// identifiers erased on both sides first, because it currently rewrites them —
+// a tracked defect that TestIDSpaceMatches asserts against on its own, so it
+// does not have to drown out every other case here.
+// Nothing here writes to testdata. Expectation files are authored by hand and
+// only ever change through a reviewed edit, so there is no mode in which a run
+// can create or replace one. When a file is missing the computed map is printed
+// for the author to read and, if it is genuinely correct, save deliberately.
+func compareGolden(t *testing.T, path string, got *proto.NetworkMap, mode Mode) {
+	t.Helper()
+
+	if mode == ModeEnvelope {
+		normalizeIDSpace(got)
+		canonicalize(got)
+	}
+
+	raw, err := os.ReadFile(path)
+	if err != nil {
+		rendered, mErr := renderNetworkMap(got)
+		require.NoError(t, mErr)
+		t.Fatalf("no expectation file %s: %v\nThis case has nothing to measure against — write the "+
+			"proto.NetworkMap this peer should receive. Mode %s currently produces:\n%s\nRead it before "+
+			"saving any of it: if the code is wrong, so is this.", path, err, mode, rendered)
+	}
+	want := &proto.NetworkMap{}
+	require.NoError(t, protojson.Unmarshal(raw, want), "parse expectation %s", path)
+	canonicalize(want)
+	if mode == ModeEnvelope {
+		normalizeIDSpace(want)
+		canonicalize(want)
+	}
+
+	if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" {
+		t.Errorf("mode %s does not produce what %s expects (-want +got):\n%s\n"+
+			"Every mode has to deliver the same client-facing map for the same account state. "+
+			"The expectation file is the committed statement of correct output — fix the code, or change the "+
+			"expectation deliberately if the intended behaviour really moved.", mode, path, diff)
+	}
+}
+
+// renderNetworkMap renders stable protojson: protojson output whitespace is
+// deliberately unstable, so it is reformatted through json.Indent.
+func renderNetworkMap(nm *proto.NetworkMap) ([]byte, error) {
+	raw, err := protojson.Marshal(nm)
+	if err != nil {
+		return nil, err
+	}
+	var buf bytes.Buffer
+	if err := json.Indent(&buf, raw, "", "  "); err != nil {
+		return nil, err
+	}
+	buf.WriteByte('\n')
+	return buf.Bytes(), nil
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/case.json
new file mode 100644
index 000000000..4747e9640
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/case.json
@@ -0,0 +1,7 @@
+{
+  "description": "Two groups joined by one allow-all policy; peer-c has SSH enabled so the legacy-SSH path fills SshAuth from AllowedUserIDs.",
+  "peers": [
+    "peer-a",
+    "peer-c"
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/golden/peer-a.json
new file mode 100644
index 000000000..e2b69276c
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/golden/peer-a.json
@@ -0,0 +1,65 @@
+{
+  "Serial": "5",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "4deEImv8zGvsyBmmfC2G0eQkbyMzyGuz/YK7pcYETwM=",
+      "allowedIps": [
+        "100.64.0.3/32"
+      ],
+      "sshConfig": {
+        "sshPubKey": "c3NoLXBlZXItYw=="
+      },
+      "fqdn": "peer-c.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-c.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.3"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.3",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLWFsbA=="
+    },
+    {
+      "PeerIP": "100.64.0.3",
+      "Direction": "OUT",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLWFsbA=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/golden/peer-c.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/golden/peer-c.json
new file mode 100644
index 000000000..4c358b163
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/golden/peer-c.json
@@ -0,0 +1,102 @@
+{
+  "Serial": "5",
+  "peerConfig": {
+    "address": "100.64.0.3/10",
+    "sshConfig": {
+      "sshEnabled": true
+    },
+    "fqdn": "peer-c.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "AvldyrZ12Pf90jzf3AXmhPwg3UcI+jtJHfbpBlupvko=",
+      "allowedIps": [
+        "100.64.0.2/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-b.netbird.test",
+      "agentVersion": "0.60.0"
+    },
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-b.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          },
+          {
+            "Name": "peer-c.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.3"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLWFsbA=="
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLWFsbA=="
+    },
+    {
+      "PeerIP": "100.64.0.2",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLWFsbA=="
+    },
+    {
+      "PeerIP": "100.64.0.2",
+      "Direction": "OUT",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLWFsbA=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub",
+    "AuthorizedUsers": [
+      "u9dHvAXZJKiXITuwP9jD/A=="
+    ],
+    "machineUsers": {
+      "*": {
+        "indexes": [
+          0
+        ]
+      }
+    }
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/nmdata.json
new file mode 100644
index 000000000..7d78e5c61
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/basic-policy/nmdata.json
@@ -0,0 +1,31 @@
+{
+  "Network": {"Serial": 5},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-c": {"IP": "100.64.0.3", "SSHEnabled": true, "SSHKey": "ssh-peer-c", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a", "peer-b"]},
+    "grp-ops": {"Peers": ["peer-c"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-all",
+      "PublicID": "pol-all-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "all",
+          "Bidirectional": true,
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-ops"]
+        }
+      ]
+    }
+  ],
+  "AllowedUserIDs": {"user-ops": {}}
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/case.json
new file mode 100644
index 000000000..e4c46c63d
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/case.json
@@ -0,0 +1,7 @@
+{
+  "description": "Nameserver group and applied custom zones distributed to grp-dev; peer-a (with an extra DNS label) receives them, peer-c is outside that group and receives only the zone distributed to grp-ops. Zone flags travel per zone: both grp-dev zones are match-only (NonAuthoritative), only search-off.internal. disables the search domain, and the built-in peer zone stays authoritative.",
+  "peers": [
+    "peer-a",
+    "peer-c"
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/golden/peer-a.json
new file mode 100644
index 000000000..f06a19d9d
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/golden/peer-a.json
@@ -0,0 +1,115 @@
+{
+  "Serial": "8",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "AvldyrZ12Pf90jzf3AXmhPwg3UcI+jtJHfbpBlupvko=",
+      "allowedIps": [
+        "100.64.0.2/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-b.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "NameServerGroups": [
+      {
+        "NameServers": [
+          {
+            "IP": "8.8.8.8",
+            "Port": "53"
+          }
+        ],
+        "Primary": true
+      }
+    ],
+    "CustomZones": [
+      {
+        "Domain": "corp.internal.",
+        "NonAuthoritative": true,
+        "Records": [
+          {
+            "Name": "db.corp.internal.",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "10.10.0.5"
+          }
+        ]
+      },
+      {
+        "Domain": "search-off.internal.",
+        "SearchDomainDisabled": true,
+        "NonAuthoritative": true,
+        "Records": [
+          {
+            "Name": "alias.search-off.internal.",
+            "Type": "5",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "app.search-off.internal."
+          },
+          {
+            "Name": "app.search-off.internal.",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "10.10.0.6"
+          }
+        ]
+      },
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-b.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          },
+          {
+            "Name": "www.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.2",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLW1lc2g="
+    },
+    {
+      "PeerIP": "100.64.0.2",
+      "Direction": "OUT",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLW1lc2g="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/golden/peer-c.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/golden/peer-c.json
new file mode 100644
index 000000000..7e04dca40
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/golden/peer-c.json
@@ -0,0 +1,47 @@
+{
+  "Serial": "8",
+  "peerConfig": {
+    "address": "100.64.0.3/10",
+    "sshConfig": {},
+    "fqdn": "peer-c.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-c.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.3"
+          }
+        ]
+      },
+      {
+        "Domain": "ops-only.internal.",
+        "NonAuthoritative": true,
+        "Records": [
+          {
+            "Name": "tool.ops-only.internal.",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "10.10.0.7"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/nmdata.json
new file mode 100644
index 000000000..b9741ef16
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/dns-config/nmdata.json
@@ -0,0 +1,74 @@
+{
+  "Network": {"Serial": 8},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "ExtraDNSLabels": ["www"], "Meta": {"WtVersion": "0.60.0"}},
+    "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-c": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a", "peer-b"]},
+    "grp-ops": {"Peers": ["peer-c"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-mesh",
+      "PublicID": "pol-mesh-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "all",
+          "Bidirectional": true,
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-dev"]
+        }
+      ]
+    }
+  ],
+  "NameServerGroups": [
+    {
+      "ID": "nsg-1",
+      "Name": "dns-primary",
+      "NameServers": [{"IP": "8.8.8.8", "Port": 53}],
+      "Groups": ["grp-dev"],
+      "Primary": true,
+      "Enabled": true
+    }
+  ],
+  "AppliedZoneCandidates": [
+    {
+      "DistributionGroups": ["grp-dev"],
+      "Zone": {
+        "Domain": "corp.internal.",
+        "NonAuthoritative": true,
+        "Records": [
+          {"Name": "db.corp.internal.", "Type": 1, "Class": "IN", "TTL": 300, "RData": "10.10.0.5"}
+        ]
+      }
+    },
+    {
+      "DistributionGroups": ["grp-dev"],
+      "Zone": {
+        "Domain": "search-off.internal.",
+        "NonAuthoritative": true,
+        "SearchDomainDisabled": true,
+        "Records": [
+          {"Name": "app.search-off.internal.", "Type": 1, "Class": "IN", "TTL": 300, "RData": "10.10.0.6"},
+          {"Name": "alias.search-off.internal.", "Type": 5, "Class": "IN", "TTL": 300, "RData": "app.search-off.internal."}
+        ]
+      }
+    },
+    {
+      "DistributionGroups": ["grp-ops"],
+      "Zone": {
+        "Domain": "ops-only.internal.",
+        "NonAuthoritative": true,
+        "Records": [
+          {"Name": "tool.ops-only.internal.", "Type": 1, "Class": "IN", "TTL": 300, "RData": "10.10.0.7"}
+        ]
+      }
+    }
+  ]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/case.json
new file mode 100644
index 000000000..a5776d0e7
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "Domain network resource: the route carries the domain list and the 192.0.2.0/32 placeholder network with NetworkType 3 (dynamic), and peer-r's route firewall rules must be marked dynamic and repeat the domain. Two ports on the policy must produce one rule per port. A domain resource contributes no DNS custom zone of its own — resolution happens through the routing peer's forwarder.",
+  "peers": ["peer-a", "peer-r"]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/golden/peer-a.json
new file mode 100644
index 000000000..f83e7a2f2
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/golden/peer-a.json
@@ -0,0 +1,59 @@
+{
+  "Serial": "22",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "allowedIps": [
+        "100.64.0.9/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-r.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-domain:peer-r",
+      "Network": "192.0.2.0/32",
+      "NetworkType": "3",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "app-domain",
+      "Domains": [
+        "app.internal"
+      ],
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/golden/peer-r.json
new file mode 100644
index 000000000..41ae3dd33
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/golden/peer-r.json
@@ -0,0 +1,92 @@
+{
+  "Serial": "22",
+  "peerConfig": {
+    "address": "100.64.0.9/10",
+    "sshConfig": {},
+    "fqdn": "peer-r.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-domain:peer-r",
+      "Network": "192.0.2.0/32",
+      "NetworkType": "3",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "app-domain",
+      "Domains": [
+        "app.internal"
+      ],
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-r.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.9"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRules": [
+    {
+      "sourceRanges": [
+        "100.64.0.1/32"
+      ],
+      "destination": "192.0.2.0/32",
+      "protocol": "TCP",
+      "portInfo": {
+        "port": 443
+      },
+      "isDynamic": true,
+      "domains": [
+        "app.internal"
+      ],
+      "PolicyID": "cG9sLWFwcA==",
+      "RouteID": "res-domain:peer-r"
+    },
+    {
+      "sourceRanges": [
+        "100.64.0.1/32"
+      ],
+      "destination": "192.0.2.0/32",
+      "protocol": "TCP",
+      "portInfo": {
+        "port": 80
+      },
+      "isDynamic": true,
+      "domains": [
+        "app.internal"
+      ],
+      "PolicyID": "cG9sLWFwcA==",
+      "RouteID": "res-domain:peer-r"
+    }
+  ],
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/nmdata.json
new file mode 100644
index 000000000..db6dc8eda
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-domain-resource/nmdata.json
@@ -0,0 +1,43 @@
+{
+  "Network": {"Serial": 22},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-app",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["80", "443"],
+          "Sources": ["grp-dev"],
+          "DestinationResource": {"ID": "res-domain", "Type": "domain"}
+        }
+      ]
+    }
+  ],
+  "ResourcePolicies": {"res-domain": [{"ID": "pol-app"}]},
+  "NetworkResources": [
+    {
+      "ID": "res-domain",
+      "NetworkID": "net-1",
+      "Name": "app-domain",
+      "Type": "domain",
+      "Domain": "app.internal",
+      "Enabled": true
+    }
+  ],
+  "Routers": {
+    "net-1": {
+      "peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true}
+    }
+  }
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/case.json
new file mode 100644
index 000000000..fa1e5c24b
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "Host network resource (single /32) behind one directly-assigned router. peer-a is in the resource policy's source group and must receive one route to 10.10.0.7/32 via peer-r with KeepRoute set and NetID taken from the resource name; peer-r as the router must receive the same route plus a route firewall rule whose SourceRanges are the policy's source peers. A client never gets route firewall rules.",
+  "peers": ["peer-a", "peer-r"]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/golden/peer-a.json
new file mode 100644
index 000000000..8bf83f20e
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/golden/peer-a.json
@@ -0,0 +1,56 @@
+{
+  "Serial": "20",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "allowedIps": [
+        "100.64.0.9/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-r.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-host:peer-r",
+      "Network": "10.10.0.7/32",
+      "NetworkType": "1",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "web-host",
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/golden/peer-r.json
new file mode 100644
index 000000000..ef3b5a6c8
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/golden/peer-r.json
@@ -0,0 +1,69 @@
+{
+  "Serial": "20",
+  "peerConfig": {
+    "address": "100.64.0.9/10",
+    "sshConfig": {},
+    "fqdn": "peer-r.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-host:peer-r",
+      "Network": "10.10.0.7/32",
+      "NetworkType": "1",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "web-host",
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-r.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.9"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRules": [
+    {
+      "sourceRanges": [
+        "100.64.0.1/32"
+      ],
+      "destination": "10.10.0.7/32",
+      "protocol": "TCP",
+      "portInfo": {
+        "port": 443
+      },
+      "PolicyID": "cG9sLXdlYg==",
+      "RouteID": "res-host:peer-r"
+    }
+  ],
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/nmdata.json
new file mode 100644
index 000000000..fdd35a439
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-host-resource/nmdata.json
@@ -0,0 +1,43 @@
+{
+  "Network": {"Serial": 20},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-web",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["443"],
+          "Sources": ["grp-dev"],
+          "DestinationResource": {"ID": "res-host", "Type": "host"}
+        }
+      ]
+    }
+  ],
+  "ResourcePolicies": {"res-host": [{"ID": "pol-web"}]},
+  "NetworkResources": [
+    {
+      "ID": "res-host",
+      "NetworkID": "net-1",
+      "Name": "web-host",
+      "Type": "host",
+      "Prefix": "10.10.0.7/32",
+      "Enabled": true
+    }
+  ],
+  "Routers": {
+    "net-1": {
+      "peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true}
+    }
+  }
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/case.json
new file mode 100644
index 000000000..ca54a4b81
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "A disabled resource with a valid policy and router must leave no trace: no routes and no route firewall rules for either the client or the router. Disabling a resource is the switch that revokes access without deleting the policy.",
+  "peers": ["peer-a", "peer-r"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/golden/peer-a.json
new file mode 100644
index 000000000..a4f5a92bb
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/golden/peer-a.json
@@ -0,0 +1,34 @@
+{
+  "Serial": "25",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/golden/peer-r.json
new file mode 100644
index 000000000..b83cfdff6
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/golden/peer-r.json
@@ -0,0 +1,34 @@
+{
+  "Serial": "25",
+  "peerConfig": {
+    "address": "100.64.0.9/10",
+    "sshConfig": {},
+    "fqdn": "peer-r.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-r.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.9"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/nmdata.json
new file mode 100644
index 000000000..43e00a2db
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-disabled/nmdata.json
@@ -0,0 +1,42 @@
+{
+  "Network": {"Serial": 25},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-off-resource",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["443"],
+          "Sources": ["grp-dev"],
+          "DestinationResource": {"ID": "res-disabled", "Type": "subnet"}
+        }
+      ]
+    }
+  ],
+  "ResourcePolicies": {"res-disabled": [{"ID": "pol-off-resource"}]},
+  "NetworkResources": [
+    {
+      "ID": "res-disabled",
+      "NetworkID": "net-1",
+      "Name": "disabled-subnet",
+      "Type": "subnet",
+      "Prefix": "10.50.0.0/24"
+    }
+  ],
+  "Routers": {
+    "net-1": {
+      "peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true}
+    }
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/case.json
new file mode 100644
index 000000000..494ac0fce
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "An enabled resource with a healthy router but no policy granting access to it must produce nothing anywhere: no route for the client and none for the router either, since access to a resource is only ever created by a policy. The router also gets no route firewall rules despite being a routing peer for the network.",
+  "peers": ["peer-a", "peer-r"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/golden/peer-a.json
new file mode 100644
index 000000000..32f9cf35e
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/golden/peer-a.json
@@ -0,0 +1,34 @@
+{
+  "Serial": "24",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/golden/peer-r.json
new file mode 100644
index 000000000..a97eac9a3
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/golden/peer-r.json
@@ -0,0 +1,34 @@
+{
+  "Serial": "24",
+  "peerConfig": {
+    "address": "100.64.0.9/10",
+    "sshConfig": {},
+    "fqdn": "peer-r.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-r.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.9"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/nmdata.json
new file mode 100644
index 000000000..a3bbf299a
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-no-policy/nmdata.json
@@ -0,0 +1,26 @@
+{
+  "Network": {"Serial": 24},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a"]}
+  },
+  "NetworkResources": [
+    {
+      "ID": "res-orphan",
+      "NetworkID": "net-1",
+      "Name": "orphan-subnet",
+      "Type": "subnet",
+      "Prefix": "10.40.0.0/24",
+      "Enabled": true
+    }
+  ],
+  "Routers": {
+    "net-1": {
+      "peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true}
+    }
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/case.json
new file mode 100644
index 000000000..2922a5deb
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "A DISABLED policy granting access to a network resource must grant nothing: no route to 10.90.0.0/24 for peer-a and none for the router either, exactly as if the policy were absent. THE FULL EXPECTATION CURRENTLY FAILS, and should: resource-policy selection never checks policy.Enabled (networkmapcompute.go and networkmap_components.go both test only nil/len(Rules)/Rules[0]), so the legacy path still hands out the route — access survives disabling the policy. The envelope path happens to be correct because the encoder drops disabled policies from the wire. Fix the compute path, do not weaken this expectation.",
+  "peers": ["peer-a", "peer-r"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/golden/peer-a.json
new file mode 100644
index 000000000..92ba75b1e
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/golden/peer-a.json
@@ -0,0 +1,34 @@
+{
+  "Serial": "39",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/golden/peer-r.json
new file mode 100644
index 000000000..8c27320ee
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/golden/peer-r.json
@@ -0,0 +1,34 @@
+{
+  "Serial": "39",
+  "peerConfig": {
+    "address": "100.64.0.9/10",
+    "sshConfig": {},
+    "fqdn": "peer-r.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-r.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.9"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/nmdata.json
new file mode 100644
index 000000000..be7251712
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-resource-policy-disabled/nmdata.json
@@ -0,0 +1,42 @@
+{
+  "Network": {"Serial": 39},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-revoked",
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["5432"],
+          "Sources": ["grp-dev"],
+          "DestinationResource": {"ID": "res-db", "Type": "subnet"}
+        }
+      ]
+    }
+  ],
+  "ResourcePolicies": {"res-db": [{"ID": "pol-revoked"}]},
+  "NetworkResources": [
+    {
+      "ID": "res-db",
+      "NetworkID": "net-1",
+      "Name": "db-subnet",
+      "Type": "subnet",
+      "Prefix": "10.90.0.0/24",
+      "Enabled": true
+    }
+  ],
+  "Routers": {
+    "net-1": {
+      "peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true}
+    }
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/case.json
new file mode 100644
index 000000000..de1027d48
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "The routing peer for the resource is not in ValidatedPeers — an unapproved peer, which the integrated validator withholds. peer-a must therefore receive no route through it and must not see it as a peer at all: traffic may not be routed through a peer the account has not approved. THE ENVELOPE EXPECTATION CURRENTLY FAILS, and should: component selection puts every routing peer into RouterPeers without checking validation, the encoder indexes them into the envelope's peer table, and the client decoder puts every peer it finds back into its peer map, so the unapproved router reappears client-side with a working route. The full path drops it correctly. Fix the component/encoder path, do not weaken this expectation.",
+  "peers": ["peer-a"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/golden/peer-a.json
new file mode 100644
index 000000000..2554fc08c
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/golden/peer-a.json
@@ -0,0 +1,34 @@
+{
+  "Serial": "40",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/nmdata.json
new file mode 100644
index 000000000..98ba94471
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-router-unvalidated/nmdata.json
@@ -0,0 +1,44 @@
+{
+  "Network": {"Serial": 40},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "ValidatedPeers": {"peer-a": {}},
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-db",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["5432"],
+          "Sources": ["grp-dev"],
+          "DestinationResource": {"ID": "res-db", "Type": "subnet"}
+        }
+      ]
+    }
+  ],
+  "ResourcePolicies": {"res-db": [{"ID": "pol-db"}]},
+  "NetworkResources": [
+    {
+      "ID": "res-db",
+      "NetworkID": "net-1",
+      "Name": "db-subnet",
+      "Type": "subnet",
+      "Prefix": "10.100.0.0/24",
+      "Enabled": true
+    }
+  ],
+  "Routers": {
+    "net-1": {
+      "peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true}
+    }
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/case.json
new file mode 100644
index 000000000..8ec63c816
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "Routing peer group: one router record assigned to a peer group, which the store expands into one entry per member peer sharing the router's settings. peer-a must receive one route per routing peer — same NetID and destination, different route ID and peer — which is what gives the client an HA pair to choose between. Each router must receive only its own route, never its sibling's, plus its own route firewall rule.",
+  "peers": ["peer-a", "peer-r1", "peer-r2"]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-a.json
new file mode 100644
index 000000000..020c34835
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-a.json
@@ -0,0 +1,75 @@
+{
+  "Serial": "23",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "Iu6Lj1HqDXfgDh5RppHCRI6RO3lMlZBCndRNrdS7QUI=",
+      "allowedIps": [
+        "100.64.0.11/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-r1.netbird.test",
+      "agentVersion": "0.60.0"
+    },
+    {
+      "wgPubKey": "YC6sbWtvpB2d6W/XqsNIV9crsrXBoVDdwmhCoszjCps=",
+      "allowedIps": [
+        "100.64.0.12/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-r2.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-ha:peer-r1",
+      "Network": "10.30.0.0/24",
+      "NetworkType": "1",
+      "Peer": "Iu6Lj1HqDXfgDh5RppHCRI6RO3lMlZBCndRNrdS7QUI=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "ha-subnet",
+      "keepRoute": true
+    },
+    {
+      "ID": "res-ha:peer-r2",
+      "Network": "10.30.0.0/24",
+      "NetworkType": "1",
+      "Peer": "YC6sbWtvpB2d6W/XqsNIV9crsrXBoVDdwmhCoszjCps=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "ha-subnet",
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-r1.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-r1.json
new file mode 100644
index 000000000..e42214ca8
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-r1.json
@@ -0,0 +1,69 @@
+{
+  "Serial": "23",
+  "peerConfig": {
+    "address": "100.64.0.11/10",
+    "sshConfig": {},
+    "fqdn": "peer-r1.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-ha:peer-r1",
+      "Network": "10.30.0.0/24",
+      "NetworkType": "1",
+      "Peer": "Iu6Lj1HqDXfgDh5RppHCRI6RO3lMlZBCndRNrdS7QUI=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "ha-subnet",
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-r1.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.11"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRules": [
+    {
+      "sourceRanges": [
+        "100.64.0.1/32"
+      ],
+      "destination": "10.30.0.0/24",
+      "protocol": "TCP",
+      "portInfo": {
+        "port": 5432
+      },
+      "PolicyID": "cG9sLWhh",
+      "RouteID": "res-ha:peer-r1"
+    }
+  ],
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-r2.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-r2.json
new file mode 100644
index 000000000..2560742fb
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/golden/peer-r2.json
@@ -0,0 +1,69 @@
+{
+  "Serial": "23",
+  "peerConfig": {
+    "address": "100.64.0.12/10",
+    "sshConfig": {},
+    "fqdn": "peer-r2.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-ha:peer-r2",
+      "Network": "10.30.0.0/24",
+      "NetworkType": "1",
+      "Peer": "YC6sbWtvpB2d6W/XqsNIV9crsrXBoVDdwmhCoszjCps=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "ha-subnet",
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-r2.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.12"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRules": [
+    {
+      "sourceRanges": [
+        "100.64.0.1/32"
+      ],
+      "destination": "10.30.0.0/24",
+      "protocol": "TCP",
+      "portInfo": {
+        "port": 5432
+      },
+      "PolicyID": "cG9sLWhh",
+      "RouteID": "res-ha:peer-r2"
+    }
+  ],
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/nmdata.json
new file mode 100644
index 000000000..03937cb14
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-routing-peer-group-ha/nmdata.json
@@ -0,0 +1,46 @@
+{
+  "Network": {"Serial": 23},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-r1": {"IP": "100.64.0.11", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-r2": {"IP": "100.64.0.12", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a"]},
+    "grp-routers": {"Peers": ["peer-r1", "peer-r2"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-ha",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["5432"],
+          "Sources": ["grp-dev"],
+          "DestinationResource": {"ID": "res-ha", "Type": "subnet"}
+        }
+      ]
+    }
+  ],
+  "ResourcePolicies": {"res-ha": [{"ID": "pol-ha"}]},
+  "NetworkResources": [
+    {
+      "ID": "res-ha",
+      "NetworkID": "net-ha",
+      "Name": "ha-subnet",
+      "Type": "subnet",
+      "Prefix": "10.30.0.0/24",
+      "Enabled": true
+    }
+  ],
+  "Routers": {
+    "net-ha": {
+      "peer-r1": {"PublicID": "router-ha", "PeerGroups": ["grp-routers"], "Masquerade": true, "Metric": 9999, "Enabled": true},
+      "peer-r2": {"PublicID": "router-ha", "PeerGroups": ["grp-routers"], "Masquerade": true, "Metric": 9999, "Enabled": true}
+    }
+  }
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/case.json
new file mode 100644
index 000000000..16cfedf34
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "Subnet network resource behind one directly-assigned router, with masquerade off and a non-default metric so both reach the wire verbatim, and an all-protocol policy from a two-peer source group. peer-r's route firewall rule must list both source peers; peer-b confirms a second client in the same group gets its own identical route.",
+  "peers": ["peer-a", "peer-b", "peer-r"]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-a.json
new file mode 100644
index 000000000..a5bc8f880
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-a.json
@@ -0,0 +1,55 @@
+{
+  "Serial": "21",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "allowedIps": [
+        "100.64.0.9/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-r.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-subnet:peer-r",
+      "Network": "10.20.0.0/24",
+      "NetworkType": "1",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "500",
+      "NetID": "office-subnet",
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-b.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-b.json
new file mode 100644
index 000000000..01c31edf6
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-b.json
@@ -0,0 +1,55 @@
+{
+  "Serial": "21",
+  "peerConfig": {
+    "address": "100.64.0.2/10",
+    "sshConfig": {},
+    "fqdn": "peer-b.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "allowedIps": [
+        "100.64.0.9/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-r.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-subnet:peer-r",
+      "Network": "10.20.0.0/24",
+      "NetworkType": "1",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "500",
+      "NetID": "office-subnet",
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-b.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-r.json
new file mode 100644
index 000000000..39a29125d
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/golden/peer-r.json
@@ -0,0 +1,76 @@
+{
+  "Serial": "21",
+  "peerConfig": {
+    "address": "100.64.0.9/10",
+    "sshConfig": {},
+    "fqdn": "peer-r.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "AvldyrZ12Pf90jzf3AXmhPwg3UcI+jtJHfbpBlupvko=",
+      "allowedIps": [
+        "100.64.0.2/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-b.netbird.test",
+      "agentVersion": "0.60.0"
+    },
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-subnet:peer-r",
+      "Network": "10.20.0.0/24",
+      "NetworkType": "1",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "500",
+      "NetID": "office-subnet",
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-r.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.9"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRules": [
+    {
+      "sourceRanges": [
+        "100.64.0.1/32",
+        "100.64.0.2/32"
+      ],
+      "destination": "10.20.0.0/24",
+      "protocol": "ALL",
+      "portInfo": {},
+      "PolicyID": "cG9sLXN1Ym5ldA==",
+      "RouteID": "res-subnet:peer-r"
+    }
+  ],
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/nmdata.json
new file mode 100644
index 000000000..ba27f494c
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/net-subnet-resource/nmdata.json
@@ -0,0 +1,43 @@
+{
+  "Network": {"Serial": 21},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a", "peer-b"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-subnet",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "all",
+          "Sources": ["grp-dev"],
+          "DestinationResource": {"ID": "res-subnet", "Type": "subnet"}
+        }
+      ]
+    }
+  ],
+  "ResourcePolicies": {"res-subnet": [{"ID": "pol-subnet"}]},
+  "NetworkResources": [
+    {
+      "ID": "res-subnet",
+      "NetworkID": "net-1",
+      "Name": "office-subnet",
+      "Type": "subnet",
+      "Prefix": "10.20.0.0/24",
+      "Enabled": true
+    }
+  ],
+  "Routers": {
+    "net-1": {
+      "peer-r": {"PublicID": "router-direct", "Metric": 500, "Enabled": true}
+    }
+  }
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-direct-peer-unvalidated/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-direct-peer-unvalidated/case.json
new file mode 100644
index 000000000..cdf31c413
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-direct-peer-unvalidated/case.json
@@ -0,0 +1,5 @@
+{
+  "description": "A peer named directly as a rule source or destination is subject to approval exactly like a group member: unvalidated peer-b is neither a source for peer-c nor a destination for peer-a, while the validated direct source peer-a reaches peer-c. Legacy mode is excluded: the frozen legacynmap copy still carries the direct-peer bypass.",
+  "peers": ["peer-a", "peer-c"],
+  "modes": ["full", "envelope"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-direct-peer-unvalidated/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-direct-peer-unvalidated/golden/peer-a.json
new file mode 100644
index 000000000..e0605525f
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-direct-peer-unvalidated/golden/peer-a.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "22",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "4deEImv8zGvsyBmmfC2G0eQkbyMzyGuz/YK7pcYETwM=",
+      "allowedIps": [
+        "100.64.0.3/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-c.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-c.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.3"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.3",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRpcmVjdC1vaw=="
+    },
+    {
+      "PeerIP": "100.64.0.3",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRpcmVjdC1vaw=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-direct-peer-unvalidated/golden/peer-c.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-direct-peer-unvalidated/golden/peer-c.json
new file mode 100644
index 000000000..f2b3e9357
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-direct-peer-unvalidated/golden/peer-c.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "22",
+  "peerConfig": {
+    "address": "100.64.0.3/10",
+    "sshConfig": {},
+    "fqdn": "peer-c.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-c.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.3"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRpcmVjdC1vaw=="
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRpcmVjdC1vaw=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-direct-peer-unvalidated/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-direct-peer-unvalidated/nmdata.json
new file mode 100644
index 000000000..283df304c
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-direct-peer-unvalidated/nmdata.json
@@ -0,0 +1,63 @@
+{
+  "Network": {"Serial": 22},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-c": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "ValidatedPeers": {"peer-a": {}, "peer-c": {}},
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a"]},
+    "grp-ops": {"Peers": ["peer-c"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-direct-ok",
+      "PublicID": "pol-direct-ok-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["443"],
+          "Bidirectional": true,
+          "SourceResource": {"ID": "peer-a", "Type": "peer"},
+          "Destinations": ["grp-ops"]
+        }
+      ]
+    },
+    {
+      "ID": "pol-src-unval",
+      "PublicID": "pol-src-unval-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["8443"],
+          "Bidirectional": true,
+          "SourceResource": {"ID": "peer-b", "Type": "peer"},
+          "Destinations": ["grp-ops"]
+        }
+      ]
+    },
+    {
+      "ID": "pol-dst-unval",
+      "PublicID": "pol-dst-unval-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["9443"],
+          "Bidirectional": true,
+          "Sources": ["grp-dev"],
+          "DestinationResource": {"ID": "peer-b", "Type": "peer"}
+        }
+      ]
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/case.json
new file mode 100644
index 000000000..39c477b9f
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "Direct peer-to-peer policy via Source/DestinationResource of type peer, no groups involved; peer-a and peer-b see each other, bystander peer-c sees nobody.",
+  "peers": ["peer-a", "peer-b", "peer-c"]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-a.json
new file mode 100644
index 000000000..4d59c33bb
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-a.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "15",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "AvldyrZ12Pf90jzf3AXmhPwg3UcI+jtJHfbpBlupvko=",
+      "allowedIps": [
+        "100.64.0.2/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-b.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-b.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.2",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRpcmVjdA=="
+    },
+    {
+      "PeerIP": "100.64.0.2",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRpcmVjdA=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-b.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-b.json
new file mode 100644
index 000000000..59b4bd24c
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-b.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "15",
+  "peerConfig": {
+    "address": "100.64.0.2/10",
+    "sshConfig": {},
+    "fqdn": "peer-b.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-b.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRpcmVjdA=="
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRpcmVjdA=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-c.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-c.json
new file mode 100644
index 000000000..9ff24ce1a
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/golden/peer-c.json
@@ -0,0 +1,33 @@
+{
+  "Serial": "15",
+  "peerConfig": {
+    "address": "100.64.0.3/10",
+    "sshConfig": {},
+    "fqdn": "peer-c.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-c.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.3"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/nmdata.json
new file mode 100644
index 000000000..f3ee4d163
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-peer-to-peer/nmdata.json
@@ -0,0 +1,26 @@
+{
+  "Network": {"Serial": 15},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-c": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Policies": [
+    {
+      "ID": "pol-direct",
+      "PublicID": "pol-direct-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["443"],
+          "Bidirectional": true,
+          "SourceResource": {"ID": "peer-a", "Type": "peer"},
+          "DestinationResource": {"ID": "peer-b", "Type": "peer"}
+        }
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/case.json
new file mode 100644
index 000000000..a0eccba20
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "One-way udp/514 plus bidirectional tcp port-range 1000-2000 between the same groups; a disabled policy and a policy whose only rule is disabled must leave no trace.",
+  "peers": ["peer-a", "peer-srv"]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/golden/peer-a.json
new file mode 100644
index 000000000..5a2276d96
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/golden/peer-a.json
@@ -0,0 +1,81 @@
+{
+  "Serial": "14",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "jEboa3bOv65XRbq8+8JuagiwWz+mM7Fc5MUfBQbOf6Y=",
+      "allowedIps": [
+        "100.64.0.10/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-srv.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.10",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 1000,
+          "end": 2000
+        }
+      },
+      "PolicyID": "cG9sLXJhbmdl"
+    },
+    {
+      "PeerIP": "100.64.0.10",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 1000,
+          "end": 2000
+        }
+      },
+      "PolicyID": "cG9sLXJhbmdl"
+    },
+    {
+      "PeerIP": "100.64.0.10",
+      "Direction": "OUT",
+      "Protocol": "UDP",
+      "Port": "514",
+      "PolicyID": "cG9sLXN5c2xvZw=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/golden/peer-srv.json
new file mode 100644
index 000000000..6a89148f5
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/golden/peer-srv.json
@@ -0,0 +1,80 @@
+{
+  "Serial": "14",
+  "peerConfig": {
+    "address": "100.64.0.10/10",
+    "sshConfig": {},
+    "fqdn": "peer-srv.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 1000,
+          "end": 2000
+        }
+      },
+      "PolicyID": "cG9sLXJhbmdl"
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 1000,
+          "end": 2000
+        }
+      },
+      "PolicyID": "cG9sLXJhbmdl"
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "UDP",
+      "Port": "514",
+      "PolicyID": "cG9sLXN5c2xvZw=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/nmdata.json
new file mode 100644
index 000000000..f1262a0d7
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/policy-ports-and-ranges/nmdata.json
@@ -0,0 +1,74 @@
+{
+  "Network": {"Serial": 14},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a"]},
+    "grp-svc": {"Peers": ["peer-srv"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-syslog",
+      "PublicID": "pol-syslog-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "udp",
+          "Ports": ["514"],
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-svc"]
+        }
+      ]
+    },
+    {
+      "ID": "pol-range",
+      "PublicID": "pol-range-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "PortRanges": [{"Start": 1000, "End": 2000}],
+          "Bidirectional": true,
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-svc"]
+        }
+      ]
+    },
+    {
+      "ID": "pol-off",
+      "PublicID": "pol-off-pub",
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["9999"],
+          "Bidirectional": true,
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-svc"]
+        }
+      ]
+    },
+    {
+      "ID": "pol-rule-off",
+      "PublicID": "pol-rule-off-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Action": "accept",
+          "Protocol": "udp",
+          "Ports": ["1111"],
+          "Bidirectional": true,
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-svc"]
+        }
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/case.json
new file mode 100644
index 000000000..3307e0afe
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "Posture checks gate a policy's sources only, never its destinations. peer-srv-old would fail the version check, but it sits in the destination group, so peer-client must still receive it alongside peer-srv-new, and peer-srv-old must still receive peer-client. This asymmetry is deliberate in the compute path — destination peers are resolved with no posture checks passed in — and it is worth pinning because it is easy to assume a posture check protects both ends.",
+  "peers": ["peer-client", "peer-srv-old"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/golden/peer-client.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/golden/peer-client.json
new file mode 100644
index 000000000..73182932b
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/golden/peer-client.json
@@ -0,0 +1,93 @@
+{
+  "Serial": "37",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-client.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "MdeD+cDSnurizeZ/Zd7rEdIhs9VZViEnutUwkodqb1s=",
+      "allowedIps": [
+        "100.64.0.12/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-srv-new.netbird.test",
+      "agentVersion": "1.0.0"
+    },
+    {
+      "wgPubKey": "ph1eqUTlSeLQ6V9zLEUpck25m5K5sOQq+AHY879HZME=",
+      "allowedIps": [
+        "100.64.0.11/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-srv-old.netbird.test",
+      "agentVersion": "0.30.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-client.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv-new.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.12"
+          },
+          {
+            "Name": "peer-srv-old.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.11"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.11",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRlc3Q="
+    },
+    {
+      "PeerIP": "100.64.0.11",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRlc3Q="
+    },
+    {
+      "PeerIP": "100.64.0.12",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRlc3Q="
+    },
+    {
+      "PeerIP": "100.64.0.12",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRlc3Q="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/golden/peer-srv-old.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/golden/peer-srv-old.json
new file mode 100644
index 000000000..8d1ad4feb
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/golden/peer-srv-old.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "37",
+  "peerConfig": {
+    "address": "100.64.0.11/10",
+    "sshConfig": {},
+    "fqdn": "peer-srv-old.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "tKxuKEYQFPR8lCpcfVWBKVX0vGFKYXtTtFjXhoiu5zc=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-client.netbird.test",
+      "agentVersion": "1.0.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-client.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv-old.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.11"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRlc3Q="
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRlc3Q="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/nmdata.json
new file mode 100644
index 000000000..ca5ece21a
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-destination-not-gated/nmdata.json
@@ -0,0 +1,33 @@
+{
+  "Network": {"Serial": 37},
+  "Peers": {
+    "peer-client": {"IP": "100.64.0.1", "Meta": {"WtVersion": "1.0.0"}},
+    "peer-srv-old": {"IP": "100.64.0.11", "Meta": {"WtVersion": "0.30.0"}},
+    "peer-srv-new": {"IP": "100.64.0.12", "Meta": {"WtVersion": "1.0.0"}}
+  },
+  "Groups": {
+    "grp-clients": {"Peers": ["peer-client"]},
+    "grp-srv": {"Peers": ["peer-srv-old", "peer-srv-new"]}
+  },
+  "PostureChecks": {
+    "chk-version": {"Checks": {"NBVersionCheck": {"MinVersion": "0.45.0"}}}
+  },
+  "Policies": [
+    {
+      "ID": "pol-dest",
+      "Enabled": true,
+      "SourcePostureChecks": ["chk-version"],
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["443"],
+          "Bidirectional": true,
+          "Sources": ["grp-clients"],
+          "Destinations": ["grp-srv"]
+        }
+      ]
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-direct-source/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-direct-source/case.json
new file mode 100644
index 000000000..8d7460721
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-direct-source/case.json
@@ -0,0 +1,5 @@
+{
+  "description": "A peer named directly as a rule source is gated by the policy's posture checks exactly like a group member: peer-b (0.40.0) fails the 0.45.0 minimum, so it gets no connectivity and peer-c must not see it, while the compliant direct source peer-a reaches peer-c. Legacy mode is excluded: the frozen legacynmap copy still carries the direct-peer bypass.",
+  "peers": ["peer-b", "peer-c"],
+  "modes": ["full", "envelope"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-direct-source/golden/peer-b.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-direct-source/golden/peer-b.json
new file mode 100644
index 000000000..240358e40
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-direct-source/golden/peer-b.json
@@ -0,0 +1,33 @@
+{
+  "Serial": "21",
+  "peerConfig": {
+    "address": "100.64.0.2/10",
+    "sshConfig": {},
+    "fqdn": "peer-b.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-b.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-direct-source/golden/peer-c.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-direct-source/golden/peer-c.json
new file mode 100644
index 000000000..85573ed35
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-direct-source/golden/peer-c.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "21",
+  "peerConfig": {
+    "address": "100.64.0.3/10",
+    "sshConfig": {},
+    "fqdn": "peer-c.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-c.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.3"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRpcmVjdC1vaw=="
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWRpcmVjdC1vaw=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-direct-source/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-direct-source/nmdata.json
new file mode 100644
index 000000000..e6b99bfdd
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-direct-source/nmdata.json
@@ -0,0 +1,51 @@
+{
+  "Network": {"Serial": 21},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.40.0"}},
+    "peer-c": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-ops": {"Peers": ["peer-c"]}
+  },
+  "PostureChecks": {
+    "chk-ver": {"Checks": {"NBVersionCheck": {"MinVersion": "0.45.0"}}}
+  },
+  "PostureCheckXIDToPublicID": {"chk-ver": "chk-ver-pub"},
+  "Policies": [
+    {
+      "ID": "pol-direct-ok",
+      "PublicID": "pol-direct-ok-pub",
+      "Enabled": true,
+      "SourcePostureChecks": ["chk-ver"],
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["443"],
+          "Bidirectional": true,
+          "SourceResource": {"ID": "peer-a", "Type": "peer"},
+          "Destinations": ["grp-ops"]
+        }
+      ]
+    },
+    {
+      "ID": "pol-direct-denied",
+      "PublicID": "pol-direct-denied-pub",
+      "Enabled": true,
+      "SourcePostureChecks": ["chk-ver"],
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["8443"],
+          "Bidirectional": true,
+          "SourceResource": {"ID": "peer-b", "Type": "peer"},
+          "Destinations": ["grp-ops"]
+        }
+      ]
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/case.json
new file mode 100644
index 000000000..a661fa53e
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/case.json
@@ -0,0 +1,7 @@
+{
+  "description": "Source-side NB-version posture check: peer-b (0.40.0) fails the 0.45.0 minimum, so peer-c must not see it and peer-b itself gets no policy connectivity.",
+  "peers": [
+    "peer-b",
+    "peer-c"
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/golden/peer-b.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/golden/peer-b.json
new file mode 100644
index 000000000..201be294f
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/golden/peer-b.json
@@ -0,0 +1,34 @@
+{
+  "Serial": "6",
+  "peerConfig": {
+    "address": "100.64.0.2/10",
+    "sshConfig": {},
+    "fqdn": "peer-b.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-b.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/golden/peer-c.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/golden/peer-c.json
new file mode 100644
index 000000000..009d00490
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/golden/peer-c.json
@@ -0,0 +1,65 @@
+{
+  "Serial": "6",
+  "peerConfig": {
+    "address": "100.64.0.3/10",
+    "sshConfig": {},
+    "fqdn": "peer-c.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-c.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.3"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWdhdGVk"
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWdhdGVk"
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/nmdata.json
new file mode 100644
index 000000000..4962e8b6a
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-gated/nmdata.json
@@ -0,0 +1,36 @@
+{
+  "Network": {"Serial": 6},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.40.0"}},
+    "peer-c": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a", "peer-b"]},
+    "grp-ops": {"Peers": ["peer-c"]}
+  },
+  "PostureChecks": {
+    "chk-ver": {"Checks": {"NBVersionCheck": {"MinVersion": "0.45.0"}}}
+  },
+  "PostureCheckXIDToPublicID": {"chk-ver": "chk-ver-pub"},
+  "Policies": [
+    {
+      "ID": "pol-gated",
+      "PublicID": "pol-gated-pub",
+      "Enabled": true,
+      "SourcePostureChecks": ["chk-ver"],
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Bidirectional": true,
+          "Ports": ["443"],
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-ops"]
+        }
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/case.json
new file mode 100644
index 000000000..9f7f86860
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "Geo location posture check in allow mode. An entry naming only a country matches the whole country, so peer-de passes; an entry naming a city must match that city exactly, so peer-us-ny passes while peer-us-bos does not. peer-fr matches nothing and fails. peer-nowhere has no location at all, which the check reports as an error, and an errored check denies — so it fails too.",
+  "peers": ["peer-srv", "peer-de", "peer-us-bos", "peer-nowhere"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-de.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-de.json
new file mode 100644
index 000000000..9a07139fe
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-de.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "31",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-de.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "jEboa3bOv65XRbq8+8JuagiwWz+mM7Fc5MUfBQbOf6Y=",
+      "allowedIps": [
+        "100.64.0.10/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-srv.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-de.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.10",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWdlbw=="
+    },
+    {
+      "PeerIP": "100.64.0.10",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWdlbw=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-nowhere.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-nowhere.json
new file mode 100644
index 000000000..aed97a69f
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-nowhere.json
@@ -0,0 +1,33 @@
+{
+  "Serial": "31",
+  "peerConfig": {
+    "address": "100.64.0.5/10",
+    "sshConfig": {},
+    "fqdn": "peer-nowhere.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-nowhere.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.5"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-srv.json
new file mode 100644
index 000000000..9f3fcb1ba
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-srv.json
@@ -0,0 +1,93 @@
+{
+  "Serial": "31",
+  "peerConfig": {
+    "address": "100.64.0.10/10",
+    "sshConfig": {},
+    "fqdn": "peer-srv.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "9nwvdE0wik6Fcs8Tw6WBnmOqGZmzdiTR4VZAdRBOJF4=",
+      "allowedIps": [
+        "100.64.0.2/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-us-ny.netbird.test",
+      "agentVersion": "0.60.0"
+    },
+    {
+      "wgPubKey": "s/f5frZqT3DT1o9QCuhA14Pj5GUa4JUsF4M3twrprmk=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-de.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-de.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          },
+          {
+            "Name": "peer-us-ny.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWdlbw=="
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWdlbw=="
+    },
+    {
+      "PeerIP": "100.64.0.2",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWdlbw=="
+    },
+    {
+      "PeerIP": "100.64.0.2",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWdlbw=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-us-bos.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-us-bos.json
new file mode 100644
index 000000000..624666c36
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/golden/peer-us-bos.json
@@ -0,0 +1,33 @@
+{
+  "Serial": "31",
+  "peerConfig": {
+    "address": "100.64.0.3/10",
+    "sshConfig": {},
+    "fqdn": "peer-us-bos.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-us-bos.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.3"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/nmdata.json
new file mode 100644
index 000000000..b92840266
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-allow/nmdata.json
@@ -0,0 +1,46 @@
+{
+  "Network": {"Serial": 31},
+  "Peers": {
+    "peer-de": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}, "Location": {"CountryCode": "DE", "CityName": "Berlin"}},
+    "peer-us-ny": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}, "Location": {"CountryCode": "US", "CityName": "New York"}},
+    "peer-us-bos": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.60.0"}, "Location": {"CountryCode": "US", "CityName": "Boston"}},
+    "peer-fr": {"IP": "100.64.0.4", "Meta": {"WtVersion": "0.60.0"}, "Location": {"CountryCode": "FR", "CityName": "Paris"}},
+    "peer-nowhere": {"IP": "100.64.0.5", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-clients": {"Peers": ["peer-de", "peer-us-ny", "peer-us-bos", "peer-fr", "peer-nowhere"]},
+    "grp-srv": {"Peers": ["peer-srv"]}
+  },
+  "PostureChecks": {
+    "chk-geo": {
+      "Checks": {
+        "GeoLocationCheck": {
+          "Action": "allow",
+          "Locations": [
+            {"CountryCode": "DE"},
+            {"CountryCode": "US", "CityName": "New York"}
+          ]
+        }
+      }
+    }
+  },
+  "Policies": [
+    {
+      "ID": "pol-geo",
+      "Enabled": true,
+      "SourcePostureChecks": ["chk-geo"],
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["443"],
+          "Bidirectional": true,
+          "Sources": ["grp-clients"],
+          "Destinations": ["grp-srv"]
+        }
+      ]
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/case.json
new file mode 100644
index 000000000..f234b5280
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "Geo location posture check in deny mode: matching the list rejects, not matching passes, so peer-ru is excluded and peer-de is admitted. peer-nowhere has no location and fails here as well — a missing location is an error and errors deny in both modes, so deny mode is not a way to admit peers whose location is unknown.",
+  "peers": ["peer-srv", "peer-ru", "peer-nowhere"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-nowhere.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-nowhere.json
new file mode 100644
index 000000000..e73b77d9e
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-nowhere.json
@@ -0,0 +1,33 @@
+{
+  "Serial": "32",
+  "peerConfig": {
+    "address": "100.64.0.3/10",
+    "sshConfig": {},
+    "fqdn": "peer-nowhere.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-nowhere.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.3"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-ru.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-ru.json
new file mode 100644
index 000000000..721267f37
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-ru.json
@@ -0,0 +1,33 @@
+{
+  "Serial": "32",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-ru.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-ru.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-srv.json
new file mode 100644
index 000000000..93883369e
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/golden/peer-srv.json
@@ -0,0 +1,62 @@
+{
+  "Serial": "32",
+  "peerConfig": {
+    "address": "100.64.0.10/10",
+    "sshConfig": {},
+    "fqdn": "peer-srv.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "s/f5frZqT3DT1o9QCuhA14Pj5GUa4JUsF4M3twrprmk=",
+      "allowedIps": [
+        "100.64.0.2/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-de.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-de.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.2",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLWdlby1kZW55"
+    },
+    {
+      "PeerIP": "100.64.0.2",
+      "Direction": "OUT",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLWdlby1kZW55"
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/nmdata.json
new file mode 100644
index 000000000..5edc50a38
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-geo-deny/nmdata.json
@@ -0,0 +1,40 @@
+{
+  "Network": {"Serial": 32},
+  "Peers": {
+    "peer-ru": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}, "Location": {"CountryCode": "RU", "CityName": "Moscow"}},
+    "peer-de": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}, "Location": {"CountryCode": "DE", "CityName": "Berlin"}},
+    "peer-nowhere": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-clients": {"Peers": ["peer-ru", "peer-de", "peer-nowhere"]},
+    "grp-srv": {"Peers": ["peer-srv"]}
+  },
+  "PostureChecks": {
+    "chk-geo-deny": {
+      "Checks": {
+        "GeoLocationCheck": {
+          "Action": "deny",
+          "Locations": [{"CountryCode": "RU"}]
+        }
+      }
+    }
+  },
+  "Policies": [
+    {
+      "ID": "pol-geo-deny",
+      "Enabled": true,
+      "SourcePostureChecks": ["chk-geo-deny"],
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "all",
+          "Bidirectional": true,
+          "Sources": ["grp-clients"],
+          "Destinations": ["grp-srv"]
+        }
+      ]
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/case.json
new file mode 100644
index 000000000..c0eab5408
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "One posture check bundle holding two different checks. All checks in a bundle must pass, so only peer-both is admitted: peer-badgeo satisfies the version rule and peer-badversion satisfies the location rule, and each is still rejected on the other. This pins the AND semantics of a bundle rather than any-of.",
+  "peers": ["peer-srv", "peer-both", "peer-badgeo"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-badgeo.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-badgeo.json
new file mode 100644
index 000000000..6a6272909
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-badgeo.json
@@ -0,0 +1,33 @@
+{
+  "Serial": "35",
+  "peerConfig": {
+    "address": "100.64.0.2/10",
+    "sshConfig": {},
+    "fqdn": "peer-badgeo.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-badgeo.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-both.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-both.json
new file mode 100644
index 000000000..7e18e7cb8
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-both.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "35",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-both.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "jEboa3bOv65XRbq8+8JuagiwWz+mM7Fc5MUfBQbOf6Y=",
+      "allowedIps": [
+        "100.64.0.10/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-srv.netbird.test",
+      "agentVersion": "1.0.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-both.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.10",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWNvbWJv"
+    },
+    {
+      "PeerIP": "100.64.0.10",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWNvbWJv"
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-srv.json
new file mode 100644
index 000000000..e91e8f777
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/golden/peer-srv.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "35",
+  "peerConfig": {
+    "address": "100.64.0.10/10",
+    "sshConfig": {},
+    "fqdn": "peer-srv.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "ilmSCJoVLTTY/Am7or8ES8R0hL/OdfE2FDK197pxc5o=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-both.netbird.test",
+      "agentVersion": "1.0.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-both.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWNvbWJv"
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLWNvbWJv"
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/nmdata.json
new file mode 100644
index 000000000..fbf7a5f1e
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-multiple-checks/nmdata.json
@@ -0,0 +1,42 @@
+{
+  "Network": {"Serial": 35},
+  "Peers": {
+    "peer-both": {"IP": "100.64.0.1", "Meta": {"WtVersion": "1.0.0"}, "Location": {"CountryCode": "DE", "CityName": "Berlin"}},
+    "peer-badgeo": {"IP": "100.64.0.2", "Meta": {"WtVersion": "1.0.0"}, "Location": {"CountryCode": "FR", "CityName": "Paris"}},
+    "peer-badversion": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.30.0"}, "Location": {"CountryCode": "DE", "CityName": "Berlin"}},
+    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "1.0.0"}}
+  },
+  "Groups": {
+    "grp-clients": {"Peers": ["peer-both", "peer-badgeo", "peer-badversion"]},
+    "grp-srv": {"Peers": ["peer-srv"]}
+  },
+  "PostureChecks": {
+    "chk-combo": {
+      "Checks": {
+        "NBVersionCheck": {"MinVersion": "0.45.0"},
+        "GeoLocationCheck": {
+          "Action": "allow",
+          "Locations": [{"CountryCode": "DE"}]
+        }
+      }
+    }
+  },
+  "Policies": [
+    {
+      "ID": "pol-combo",
+      "Enabled": true,
+      "SourcePostureChecks": ["chk-combo"],
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["443"],
+          "Bidirectional": true,
+          "Sources": ["grp-clients"],
+          "Destinations": ["grp-srv"]
+        }
+      ]
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/case.json
new file mode 100644
index 000000000..d10bfc9ae
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "Peer network range posture check in allow mode over 192.168.0.0/16. peer-office passes on its reported interface network, and peer-by-connip passes on the address it connected from, which the check folds in as a single-host prefix — so either source of address information can satisfy it. peer-remote is outside the range and peer-noaddr reports no address at all, which errors and therefore denies. Note this policy is tcp/22 without the peer's SSH flag, so no authorized users appear.",
+  "peers": ["peer-srv", "peer-office", "peer-by-connip", "peer-remote"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-by-connip.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-by-connip.json
new file mode 100644
index 000000000..c9cb3ed0a
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-by-connip.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "33",
+  "peerConfig": {
+    "address": "100.64.0.3/10",
+    "sshConfig": {},
+    "fqdn": "peer-by-connip.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "jEboa3bOv65XRbq8+8JuagiwWz+mM7Fc5MUfBQbOf6Y=",
+      "allowedIps": [
+        "100.64.0.10/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-srv.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-by-connip.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.3"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.10",
+      "Protocol": "TCP",
+      "Port": "22",
+      "PolicyID": "cG9sLXJhbmdl"
+    },
+    {
+      "PeerIP": "100.64.0.10",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "22",
+      "PolicyID": "cG9sLXJhbmdl"
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-office.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-office.json
new file mode 100644
index 000000000..1b9834d12
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-office.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "33",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-office.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "jEboa3bOv65XRbq8+8JuagiwWz+mM7Fc5MUfBQbOf6Y=",
+      "allowedIps": [
+        "100.64.0.10/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-srv.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-office.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.10",
+      "Protocol": "TCP",
+      "Port": "22",
+      "PolicyID": "cG9sLXJhbmdl"
+    },
+    {
+      "PeerIP": "100.64.0.10",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "22",
+      "PolicyID": "cG9sLXJhbmdl"
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-remote.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-remote.json
new file mode 100644
index 000000000..134a0aff2
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-remote.json
@@ -0,0 +1,33 @@
+{
+  "Serial": "33",
+  "peerConfig": {
+    "address": "100.64.0.2/10",
+    "sshConfig": {},
+    "fqdn": "peer-remote.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-remote.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-srv.json
new file mode 100644
index 000000000..a8499e031
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/golden/peer-srv.json
@@ -0,0 +1,93 @@
+{
+  "Serial": "33",
+  "peerConfig": {
+    "address": "100.64.0.10/10",
+    "sshConfig": {},
+    "fqdn": "peer-srv.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "2Za6YHlJPJPv3hG/vDvdT0emXqIQADX9+wE8F1ZsgHU=",
+      "allowedIps": [
+        "100.64.0.3/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-by-connip.netbird.test",
+      "agentVersion": "0.60.0"
+    },
+    {
+      "wgPubKey": "iVuRGJEIX4iqqF3zV01cxAEgyjXW3X4rrMoal6iA4fc=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-office.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-by-connip.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.3"
+          },
+          {
+            "Name": "peer-office.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "Port": "22",
+      "PolicyID": "cG9sLXJhbmdl"
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "22",
+      "PolicyID": "cG9sLXJhbmdl"
+    },
+    {
+      "PeerIP": "100.64.0.3",
+      "Protocol": "TCP",
+      "Port": "22",
+      "PolicyID": "cG9sLXJhbmdl"
+    },
+    {
+      "PeerIP": "100.64.0.3",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "22",
+      "PolicyID": "cG9sLXJhbmdl"
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/nmdata.json
new file mode 100644
index 000000000..a258e855d
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-network-range/nmdata.json
@@ -0,0 +1,42 @@
+{
+  "Network": {"Serial": 33},
+  "Peers": {
+    "peer-office": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0", "NetworkAddresses": [{"NetIP": "192.168.1.10/24"}]}},
+    "peer-remote": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0", "NetworkAddresses": [{"NetIP": "10.0.0.5/8"}]}},
+    "peer-by-connip": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.60.0"}, "Location": {"ConnectionIP": "192.168.5.5"}},
+    "peer-noaddr": {"IP": "100.64.0.4", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-clients": {"Peers": ["peer-office", "peer-remote", "peer-by-connip", "peer-noaddr"]},
+    "grp-srv": {"Peers": ["peer-srv"]}
+  },
+  "PostureChecks": {
+    "chk-range": {
+      "Checks": {
+        "PeerNetworkRangeCheck": {
+          "Action": "allow",
+          "Ranges": ["192.168.0.0/16"]
+        }
+      }
+    }
+  },
+  "Policies": [
+    {
+      "ID": "pol-range",
+      "Enabled": true,
+      "SourcePostureChecks": ["chk-range"],
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["22"],
+          "Bidirectional": true,
+          "Sources": ["grp-clients"],
+          "Destinations": ["grp-srv"]
+        }
+      ]
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/case.json
new file mode 100644
index 000000000..b23a187d4
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "OS version posture check with per-OS minimums. peer-srv must see only the peers that satisfy their own platform's rule: the Linux peer on kernel 6.1 (the check compares the part before the first dash) and the macOS peer on 14.2. The old Linux and macOS peers fail. peer-win fails too even though its version looks modern, because the check defines no Windows minimum and a platform with no rule configured is treated as failing, not as unrestricted — a surprising rule worth freezing. Each rejected peer also loses its own view of peer-srv, since the policy is its only connectivity.",
+  "peers": ["peer-srv", "peer-lin-ok", "peer-lin-old", "peer-win"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-lin-ok.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-lin-ok.json
new file mode 100644
index 000000000..f299b9fcf
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-lin-ok.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "30",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-lin-ok.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "jEboa3bOv65XRbq8+8JuagiwWz+mM7Fc5MUfBQbOf6Y=",
+      "allowedIps": [
+        "100.64.0.10/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-srv.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-lin-ok.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.10",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLW9z"
+    },
+    {
+      "PeerIP": "100.64.0.10",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLW9z"
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-lin-old.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-lin-old.json
new file mode 100644
index 000000000..c0a5412fa
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-lin-old.json
@@ -0,0 +1,33 @@
+{
+  "Serial": "30",
+  "peerConfig": {
+    "address": "100.64.0.2/10",
+    "sshConfig": {},
+    "fqdn": "peer-lin-old.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-lin-old.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-srv.json
new file mode 100644
index 000000000..91883d20b
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-srv.json
@@ -0,0 +1,93 @@
+{
+  "Serial": "30",
+  "peerConfig": {
+    "address": "100.64.0.10/10",
+    "sshConfig": {},
+    "fqdn": "peer-srv.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "XpdB4aptfFjgsQOHfEO65dNozY8R7EIw3/alAnXjl+k=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-lin-ok.netbird.test",
+      "agentVersion": "0.60.0"
+    },
+    {
+      "wgPubKey": "gRO02HiaHUKq2xTbYJhPsmGj06bK0HGU2tgL0pKB2yQ=",
+      "allowedIps": [
+        "100.64.0.3/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-mac-ok.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-lin-ok.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-mac-ok.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.3"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLW9z"
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLW9z"
+    },
+    {
+      "PeerIP": "100.64.0.3",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLW9z"
+    },
+    {
+      "PeerIP": "100.64.0.3",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "443",
+      "PolicyID": "cG9sLW9z"
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-win.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-win.json
new file mode 100644
index 000000000..0b0cafb26
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/golden/peer-win.json
@@ -0,0 +1,33 @@
+{
+  "Serial": "30",
+  "peerConfig": {
+    "address": "100.64.0.5/10",
+    "sshConfig": {},
+    "fqdn": "peer-win.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-win.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.5"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/nmdata.json
new file mode 100644
index 000000000..018c0cc21
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-os-version/nmdata.json
@@ -0,0 +1,43 @@
+{
+  "Network": {"Serial": 30},
+  "Peers": {
+    "peer-lin-ok": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0", "GoOS": "linux", "KernelVersion": "6.1.0-arch1"}},
+    "peer-lin-old": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0", "GoOS": "linux", "KernelVersion": "5.4.0-generic"}},
+    "peer-mac-ok": {"IP": "100.64.0.3", "Meta": {"WtVersion": "0.60.0", "GoOS": "darwin", "OSVersion": "14.2"}},
+    "peer-mac-old": {"IP": "100.64.0.4", "Meta": {"WtVersion": "0.60.0", "GoOS": "darwin", "OSVersion": "12.0"}},
+    "peer-win": {"IP": "100.64.0.5", "Meta": {"WtVersion": "0.60.0", "GoOS": "windows", "KernelVersion": "10.0.19045"}},
+    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0", "GoOS": "linux", "KernelVersion": "6.1.0-arch1"}}
+  },
+  "Groups": {
+    "grp-clients": {"Peers": ["peer-lin-ok", "peer-lin-old", "peer-mac-ok", "peer-mac-old", "peer-win"]},
+    "grp-srv": {"Peers": ["peer-srv"]}
+  },
+  "PostureChecks": {
+    "chk-os": {
+      "Checks": {
+        "OSVersionCheck": {
+          "Linux": {"MinKernelVersion": "6.0.0"},
+          "Darwin": {"MinVersion": "13.0"}
+        }
+      }
+    }
+  },
+  "Policies": [
+    {
+      "ID": "pol-os",
+      "Enabled": true,
+      "SourcePostureChecks": ["chk-os"],
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["443"],
+          "Bidirectional": true,
+          "Sources": ["grp-clients"],
+          "Destinations": ["grp-srv"]
+        }
+      ]
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/case.json
new file mode 100644
index 000000000..b730389a6
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "Process posture check, which picks the path for the peer's own platform. peer-lin-running and peer-mac-running each have their platform's process running and pass. peer-lin-stopped reports the same file but not running, so it fails — presence of the binary is not enough. peer-bsd runs an unsupported operating system, which the check reports as an error, and errors deny.",
+  "peers": ["peer-srv", "peer-lin-running", "peer-lin-stopped", "peer-bsd"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-bsd.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-bsd.json
new file mode 100644
index 000000000..f75459d39
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-bsd.json
@@ -0,0 +1,33 @@
+{
+  "Serial": "34",
+  "peerConfig": {
+    "address": "100.64.0.4/10",
+    "sshConfig": {},
+    "fqdn": "peer-bsd.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-bsd.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.4"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-lin-running.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-lin-running.json
new file mode 100644
index 000000000..ddfa39472
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-lin-running.json
@@ -0,0 +1,62 @@
+{
+  "Serial": "34",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-lin-running.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "jEboa3bOv65XRbq8+8JuagiwWz+mM7Fc5MUfBQbOf6Y=",
+      "allowedIps": [
+        "100.64.0.10/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-srv.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-lin-running.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.10",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLXByb2M="
+    },
+    {
+      "PeerIP": "100.64.0.10",
+      "Direction": "OUT",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLXByb2M="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-lin-stopped.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-lin-stopped.json
new file mode 100644
index 000000000..828a933f7
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-lin-stopped.json
@@ -0,0 +1,33 @@
+{
+  "Serial": "34",
+  "peerConfig": {
+    "address": "100.64.0.2/10",
+    "sshConfig": {},
+    "fqdn": "peer-lin-stopped.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-lin-stopped.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-srv.json
new file mode 100644
index 000000000..6eb409623
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/golden/peer-srv.json
@@ -0,0 +1,89 @@
+{
+  "Serial": "34",
+  "peerConfig": {
+    "address": "100.64.0.10/10",
+    "sshConfig": {},
+    "fqdn": "peer-srv.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "d9LCTf7vwqctprOKyF95j17uPWpRjEeirHfB75RIGlk=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-lin-running.netbird.test",
+      "agentVersion": "0.60.0"
+    },
+    {
+      "wgPubKey": "rgP4xt50GcHp7fFgBoSt8yz5bp5AnOAVHMmOd+rTbR4=",
+      "allowedIps": [
+        "100.64.0.3/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-mac-running.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-lin-running.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-mac-running.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.3"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLXByb2M="
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLXByb2M="
+    },
+    {
+      "PeerIP": "100.64.0.3",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLXByb2M="
+    },
+    {
+      "PeerIP": "100.64.0.3",
+      "Direction": "OUT",
+      "Protocol": "ALL",
+      "PolicyID": "cG9sLXByb2M="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/nmdata.json
new file mode 100644
index 000000000..b112e2235
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-process/nmdata.json
@@ -0,0 +1,70 @@
+{
+  "Network": {"Serial": 34},
+  "Peers": {
+    "peer-lin-running": {
+      "IP": "100.64.0.1",
+      "Meta": {
+        "WtVersion": "0.60.0",
+        "GoOS": "linux",
+        "Files": [{"Path": "/usr/bin/agent", "ProcessIsRunning": true}]
+      }
+    },
+    "peer-lin-stopped": {
+      "IP": "100.64.0.2",
+      "Meta": {
+        "WtVersion": "0.60.0",
+        "GoOS": "linux",
+        "Files": [{"Path": "/usr/bin/agent"}]
+      }
+    },
+    "peer-mac-running": {
+      "IP": "100.64.0.3",
+      "Meta": {
+        "WtVersion": "0.60.0",
+        "GoOS": "darwin",
+        "Files": [{"Path": "/Applications/Agent.app", "ProcessIsRunning": true}]
+      }
+    },
+    "peer-bsd": {
+      "IP": "100.64.0.4",
+      "Meta": {
+        "WtVersion": "0.60.0",
+        "GoOS": "freebsd",
+        "Files": [{"Path": "/usr/bin/agent", "ProcessIsRunning": true}]
+      }
+    },
+    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0", "GoOS": "linux"}}
+  },
+  "Groups": {
+    "grp-clients": {"Peers": ["peer-lin-running", "peer-lin-stopped", "peer-mac-running", "peer-bsd"]},
+    "grp-srv": {"Peers": ["peer-srv"]}
+  },
+  "PostureChecks": {
+    "chk-proc": {
+      "Checks": {
+        "ProcessCheck": {
+          "Processes": [
+            {"LinuxPath": "/usr/bin/agent", "MacPath": "/Applications/Agent.app"}
+          ]
+        }
+      }
+    }
+  },
+  "Policies": [
+    {
+      "ID": "pol-proc",
+      "Enabled": true,
+      "SourcePostureChecks": ["chk-proc"],
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "all",
+          "Bidirectional": true,
+          "Sources": ["grp-clients"],
+          "Destinations": ["grp-srv"]
+        }
+      ]
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/case.json
new file mode 100644
index 000000000..b30b06243
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "Posture check on a policy granting access to a network resource. peer-ok must receive the route to the resource through peer-r, while peer-bad fails the version check and must receive no route at all. The router's route firewall rule must narrow its SourceRanges to peer-ok's address only — a peer rejected by posture must not be permitted through the routing peer either, which is the enforcement that actually matters.",
+  "peers": ["peer-ok", "peer-bad", "peer-r"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-bad.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-bad.json
new file mode 100644
index 000000000..b1aa1a494
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-bad.json
@@ -0,0 +1,34 @@
+{
+  "Serial": "38",
+  "peerConfig": {
+    "address": "100.64.0.2/10",
+    "sshConfig": {},
+    "fqdn": "peer-bad.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-bad.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-ok.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-ok.json
new file mode 100644
index 000000000..6aa688eef
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-ok.json
@@ -0,0 +1,56 @@
+{
+  "Serial": "38",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-ok.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "allowedIps": [
+        "100.64.0.9/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-r.netbird.test",
+      "agentVersion": "1.0.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-db:peer-r",
+      "Network": "10.80.0.0/24",
+      "NetworkType": "1",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "db-subnet",
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-ok.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-r.json
new file mode 100644
index 000000000..192304ca6
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/golden/peer-r.json
@@ -0,0 +1,69 @@
+{
+  "Serial": "38",
+  "peerConfig": {
+    "address": "100.64.0.9/10",
+    "sshConfig": {},
+    "fqdn": "peer-r.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "XPXITqbev8jVtIkumbPt5vpohe2OHWvhNGO3z2mgcxM=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-ok.netbird.test",
+      "agentVersion": "1.0.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-db:peer-r",
+      "Network": "10.80.0.0/24",
+      "NetworkType": "1",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "db-subnet",
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-r.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.9"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRules": [
+    {
+      "sourceRanges": [
+        "100.64.0.1/32"
+      ],
+      "destination": "10.80.0.0/24",
+      "protocol": "TCP",
+      "portInfo": {
+        "port": 5432
+      },
+      "PolicyID": "cG9sLXJlcw==",
+      "RouteID": "res-db:peer-r"
+    }
+  ],
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/nmdata.json
new file mode 100644
index 000000000..840c5a7f8
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-resource-policy/nmdata.json
@@ -0,0 +1,48 @@
+{
+  "Network": {"Serial": 38},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-ok": {"IP": "100.64.0.1", "Meta": {"WtVersion": "1.0.0"}},
+    "peer-bad": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.30.0"}},
+    "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "1.0.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-ok", "peer-bad"]}
+  },
+  "PostureChecks": {
+    "chk-version": {"Checks": {"NBVersionCheck": {"MinVersion": "0.45.0"}}}
+  },
+  "Policies": [
+    {
+      "ID": "pol-res",
+      "Enabled": true,
+      "SourcePostureChecks": ["chk-version"],
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["5432"],
+          "Sources": ["grp-dev"],
+          "DestinationResource": {"ID": "res-db", "Type": "subnet"}
+        }
+      ]
+    }
+  ],
+  "ResourcePolicies": {"res-db": [{"ID": "pol-res"}]},
+  "NetworkResources": [
+    {
+      "ID": "res-db",
+      "NetworkID": "net-1",
+      "Name": "db-subnet",
+      "Type": "subnet",
+      "Prefix": "10.80.0.0/24",
+      "Enabled": true
+    }
+  ],
+  "Routers": {
+    "net-1": {
+      "peer-r": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true}
+    }
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/case.json
new file mode 100644
index 000000000..203b89533
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "The same peer under two policies carrying different posture checks. peer-x is on an old agent version but in an allowed country, so the version-gated policy rejects it while the location-gated one admits it: it must reach peer-srv-b on 8443 and not peer-srv-a at all. Failing one policy's check must not leak into another policy's decision. peer-srv-a correspondingly sees nobody, peer-srv-b sees peer-x.",
+  "peers": ["peer-x", "peer-srv-a", "peer-srv-b"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-srv-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-srv-a.json
new file mode 100644
index 000000000..8008db39c
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-srv-a.json
@@ -0,0 +1,33 @@
+{
+  "Serial": "36",
+  "peerConfig": {
+    "address": "100.64.0.11/10",
+    "sshConfig": {},
+    "fqdn": "peer-srv-a.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeersIsEmpty": true,
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-srv-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.11"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-srv-b.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-srv-b.json
new file mode 100644
index 000000000..c7bc45742
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-srv-b.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "36",
+  "peerConfig": {
+    "address": "100.64.0.12/10",
+    "sshConfig": {},
+    "fqdn": "peer-srv-b.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "N1oKwtIwXdTDF0HdKDss0gPhxkqz+/Z/91734QZVCng=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-x.netbird.test",
+      "agentVersion": "0.40.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-srv-b.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.12"
+          },
+          {
+            "Name": "peer-x.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "Port": "8443",
+      "PolicyID": "cG9sLWxlbmllbnQ="
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "8443",
+      "PolicyID": "cG9sLWxlbmllbnQ="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-x.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-x.json
new file mode 100644
index 000000000..461454233
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/golden/peer-x.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "36",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-x.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "fT/Mb0QBqXGx2q06gXqizvlXp5uz+ErGCaKmCEXVbMk=",
+      "allowedIps": [
+        "100.64.0.12/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-srv-b.netbird.test",
+      "agentVersion": "1.0.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-srv-b.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.12"
+          },
+          {
+            "Name": "peer-x.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "5353"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.12",
+      "Protocol": "TCP",
+      "Port": "8443",
+      "PolicyID": "cG9sLWxlbmllbnQ="
+    },
+    {
+      "PeerIP": "100.64.0.12",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "8443",
+      "PolicyID": "cG9sLWxlbmllbnQ="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/nmdata.json
new file mode 100644
index 000000000..2e0512194
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/posture-two-policies/nmdata.json
@@ -0,0 +1,55 @@
+{
+  "Network": {"Serial": 36},
+  "Peers": {
+    "peer-x": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.40.0"}, "Location": {"CountryCode": "DE", "CityName": "Berlin"}},
+    "peer-srv-a": {"IP": "100.64.0.11", "Meta": {"WtVersion": "1.0.0"}},
+    "peer-srv-b": {"IP": "100.64.0.12", "Meta": {"WtVersion": "1.0.0"}}
+  },
+  "Groups": {
+    "grp-clients": {"Peers": ["peer-x"]},
+    "grp-srv-a": {"Peers": ["peer-srv-a"]},
+    "grp-srv-b": {"Peers": ["peer-srv-b"]}
+  },
+  "PostureChecks": {
+    "chk-version": {"Checks": {"NBVersionCheck": {"MinVersion": "0.45.0"}}},
+    "chk-geo": {
+      "Checks": {
+        "GeoLocationCheck": {"Action": "allow", "Locations": [{"CountryCode": "DE"}]}
+      }
+    }
+  },
+  "Policies": [
+    {
+      "ID": "pol-strict",
+      "Enabled": true,
+      "SourcePostureChecks": ["chk-version"],
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["443"],
+          "Bidirectional": true,
+          "Sources": ["grp-clients"],
+          "Destinations": ["grp-srv-a"]
+        }
+      ]
+    },
+    {
+      "ID": "pol-lenient",
+      "Enabled": true,
+      "SourcePostureChecks": ["chk-geo"],
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["8443"],
+          "Bidirectional": true,
+          "Sources": ["grp-clients"],
+          "Destinations": ["grp-srv-b"]
+        }
+      ]
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/case.json
new file mode 100644
index 000000000..5f9e98ea7
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/case.json
@@ -0,0 +1,7 @@
+{
+  "description": "A reverse-proxy service targeting a domain network resource. The synthesised proxy-access ACL is a resource policy too: on the account path the resource-policy map was built after injection, so the routing peer must carry a route firewall rule sourced from the proxy peer for the resource's domain. The store reads the policies table and ResourcePolicies never holds it, so only the synthesis puts it there.",
+  "peers": [
+    "router-peer",
+    "proxy-peer"
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/golden/proxy-peer.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/golden/proxy-peer.json
new file mode 100644
index 000000000..a9e74061f
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/golden/proxy-peer.json
@@ -0,0 +1,60 @@
+{
+  "Serial": "32",
+  "peerConfig": {
+    "address": "100.64.0.99/10",
+    "sshConfig": {},
+    "fqdn": "proxy-peer.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "4IuEBozN3DjkJXkJtBp/9Ekkr0zkfucwk9gqxWhV1hE=",
+      "allowedIps": [
+        "100.64.0.9/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "router-peer.netbird.test",
+      "lazyState": "LazyStateLazy",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-domain:router-peer",
+      "Network": "192.0.2.0/32",
+      "NetworkType": "3",
+      "Peer": "4IuEBozN3DjkJXkJtBp/9Ekkr0zkfucwk9gqxWhV1hE=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "app-domain",
+      "Domains": [
+        "app.internal"
+      ],
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "proxy-peer.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.99"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/golden/router-peer.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/golden/router-peer.json
new file mode 100644
index 000000000..d0bb48a3b
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/golden/router-peer.json
@@ -0,0 +1,80 @@
+{
+  "Serial": "32",
+  "peerConfig": {
+    "address": "100.64.0.9/10",
+    "sshConfig": {},
+    "fqdn": "router-peer.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "MgrwmZOHFZ+i0SXrfbBOcATxBAQsWKllrGL/32GvlxY=",
+      "allowedIps": [
+        "100.64.0.99/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "proxy-peer.netbird.test",
+      "lazyState": "LazyStateLazy",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-domain:router-peer",
+      "Network": "192.0.2.0/32",
+      "NetworkType": "3",
+      "Peer": "4IuEBozN3DjkJXkJtBp/9Ekkr0zkfucwk9gqxWhV1hE=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "app-domain",
+      "Domains": [
+        "app.internal"
+      ],
+      "keepRoute": true
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "router-peer.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.9"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "firewallRulesIsEmpty": true,
+  "routesFirewallRules": [
+    {
+      "sourceRanges": [
+        "100.64.0.99/32"
+      ],
+      "destination": "192.0.2.0/32",
+      "protocol": "TCP",
+      "portInfo": {
+        "range": {
+          "start": 443,
+          "end": 443
+        }
+      },
+      "isDynamic": true,
+      "domains": [
+        "app.internal"
+      ],
+      "PolicyID": "cHJveHktYWNjZXNzLXN2Yy0xLXByb3h5LXBlZXIt",
+      "RouteID": "res-domain:router-peer"
+    }
+  ],
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/nmdata.json
new file mode 100644
index 000000000..cbf729d9f
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-domain-resource/nmdata.json
@@ -0,0 +1,44 @@
+{
+  "Network": {"Serial": 32},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "router-peer": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}},
+    "proxy-peer": {
+      "IP": "100.64.0.99",
+      "Meta": {"WtVersion": "0.60.0"},
+      "ProxyMeta": {"Embedded": true, "Cluster": "eu.proxy.netbird.io"}
+    }
+  },
+  "NetworkResources": [
+    {
+      "ID": "res-domain",
+      "NetworkID": "net-1",
+      "Name": "app-domain",
+      "Type": "domain",
+      "Domain": "app.internal",
+      "Enabled": true
+    }
+  ],
+  "Routers": {
+    "net-1": {
+      "router-peer": {"PublicID": "router-direct", "Masquerade": true, "Metric": 9999, "Enabled": true}
+    }
+  },
+  "ProxyTargetedDomainResourceIDs": {"res-domain": {}},
+  "Services": [
+    {
+      "ID": "svc-1",
+      "Enabled": true,
+      "Mode": "http",
+      "ProxyCluster": "eu.proxy.netbird.io",
+      "Targets": [
+        {
+          "Enabled": true,
+          "Protocol": "https",
+          "TargetID": "res-domain",
+          "TargetType": "domain"
+        }
+      ]
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/case.json
new file mode 100644
index 000000000..d8450505f
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/case.json
@@ -0,0 +1,7 @@
+{
+  "description": "A reverse-proxy service targeting a peer. The proxy-access ACL is synthesised from Services, never loaded from the policies table, and is what lets the cluster's embedded proxy peer reach the target on the target's port: proxy-peer gets an OUT rule to app-peer on TCP 8080 and app-peer the matching IN rule. Without the synthesis both maps are empty of each other.",
+  "peers": [
+    "proxy-peer",
+    "app-peer"
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/golden/app-peer.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/golden/app-peer.json
new file mode 100644
index 000000000..0e71a62ba
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/golden/app-peer.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "30",
+  "peerConfig": {
+    "address": "100.64.0.10/10",
+    "sshConfig": {},
+    "fqdn": "app-peer.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "MgrwmZOHFZ+i0SXrfbBOcATxBAQsWKllrGL/32GvlxY=",
+      "allowedIps": [
+        "100.64.0.99/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "proxy-peer.netbird.test",
+      "lazyState": "LazyStateLazy",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "app-peer.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          },
+          {
+            "Name": "proxy-peer.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.99"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.99",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 8080,
+          "end": 8080
+        }
+      },
+      "PolicyID": "cHJveHktYWNjZXNzLXN2Yy0xLXByb3h5LXBlZXIt"
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/golden/proxy-peer.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/golden/proxy-peer.json
new file mode 100644
index 000000000..4c6053319
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/golden/proxy-peer.json
@@ -0,0 +1,65 @@
+{
+  "Serial": "30",
+  "peerConfig": {
+    "address": "100.64.0.99/10",
+    "sshConfig": {},
+    "fqdn": "proxy-peer.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "/wFxrqMtMwWNZak/f0UDddUkCZMTmxNuiuk4/RGGNcY=",
+      "allowedIps": [
+        "100.64.0.10/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "app-peer.netbird.test",
+      "lazyState": "LazyStateLazy",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "app-peer.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          },
+          {
+            "Name": "proxy-peer.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.99"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.10",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 8080,
+          "end": 8080
+        }
+      },
+      "PolicyID": "cHJveHktYWNjZXNzLXN2Yy0xLXByb3h5LXBlZXIt"
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/nmdata.json
new file mode 100644
index 000000000..b4645fb90
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-peer-target/nmdata.json
@@ -0,0 +1,29 @@
+{
+  "Network": {"Serial": 30},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "proxy-peer": {
+      "IP": "100.64.0.99",
+      "Meta": {"WtVersion": "0.60.0"},
+      "ProxyMeta": {"Embedded": true, "Cluster": "eu.proxy.netbird.io"}
+    },
+    "app-peer": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Services": [
+    {
+      "ID": "svc-1",
+      "Enabled": true,
+      "Mode": "http",
+      "ProxyCluster": "eu.proxy.netbird.io",
+      "Targets": [
+        {
+          "Enabled": true,
+          "Port": 8080,
+          "Protocol": "http",
+          "TargetID": "app-peer",
+          "TargetType": "peer"
+        }
+      ]
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/case.json
new file mode 100644
index 000000000..8ef26f4cc
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/case.json
@@ -0,0 +1,7 @@
+{
+  "description": "A private reverse-proxy service. The private-access ACL is synthesised from Services, never loaded from the policies table, and is what lets the service's AccessGroups reach the cluster's embedded proxy peer on TCP 80 and 443: user-peer gets OUT rules on both ports and proxy-peer the matching IN rules. Without the synthesis both maps are empty of each other.",
+  "peers": [
+    "user-peer",
+    "proxy-peer"
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/golden/proxy-peer.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/golden/proxy-peer.json
new file mode 100644
index 000000000..7022f0c70
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/golden/proxy-peer.json
@@ -0,0 +1,75 @@
+{
+  "Serial": "31",
+  "peerConfig": {
+    "address": "100.64.0.99/10",
+    "sshConfig": {},
+    "fqdn": "proxy-peer.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "v19TN/CymWAs/WppcLjz3atM+t4ySNdImGtoPu4wnT8=",
+      "allowedIps": [
+        "100.64.0.10/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "user-peer.netbird.test",
+      "lazyState": "LazyStateLazy",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "proxy-peer.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.99"
+          },
+          {
+            "Name": "user-peer.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.10",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 443,
+          "end": 443
+        }
+      },
+      "PolicyID": "cHJpdmF0ZS1hY2Nlc3Mtc3ZjLTEtcHJveHktcGVlcg=="
+    },
+    {
+      "PeerIP": "100.64.0.10",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 80,
+          "end": 80
+        }
+      },
+      "PolicyID": "cHJpdmF0ZS1hY2Nlc3Mtc3ZjLTEtcHJveHktcGVlcg=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/golden/user-peer.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/golden/user-peer.json
new file mode 100644
index 000000000..7197abf22
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/golden/user-peer.json
@@ -0,0 +1,77 @@
+{
+  "Serial": "31",
+  "peerConfig": {
+    "address": "100.64.0.10/10",
+    "sshConfig": {},
+    "fqdn": "user-peer.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "MgrwmZOHFZ+i0SXrfbBOcATxBAQsWKllrGL/32GvlxY=",
+      "allowedIps": [
+        "100.64.0.99/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "proxy-peer.netbird.test",
+      "lazyState": "LazyStateLazy",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "proxy-peer.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.99"
+          },
+          {
+            "Name": "user-peer.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.99",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 443,
+          "end": 443
+        }
+      },
+      "PolicyID": "cHJpdmF0ZS1hY2Nlc3Mtc3ZjLTEtcHJveHktcGVlcg=="
+    },
+    {
+      "PeerIP": "100.64.0.99",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 80,
+          "end": 80
+        }
+      },
+      "PolicyID": "cHJpdmF0ZS1hY2Nlc3Mtc3ZjLTEtcHJveHktcGVlcg=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/nmdata.json
new file mode 100644
index 000000000..066849bd4
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/proxy-service-private-access/nmdata.json
@@ -0,0 +1,26 @@
+{
+  "Network": {"Serial": 31},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "user-peer": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}},
+    "other-peer": {"IP": "100.64.0.11", "Meta": {"WtVersion": "0.60.0"}},
+    "proxy-peer": {
+      "IP": "100.64.0.99",
+      "Meta": {"WtVersion": "0.60.0"},
+      "ProxyMeta": {"Embedded": true, "Cluster": "eu.proxy.netbird.io"}
+    }
+  },
+  "Groups": {
+    "grp-admins": {"Peers": ["user-peer"]}
+  },
+  "Services": [
+    {
+      "ID": "svc-1",
+      "Enabled": true,
+      "Private": true,
+      "Mode": "http",
+      "ProxyCluster": "eu.proxy.netbird.io",
+      "AccessGroups": ["grp-admins", "grp-deleted"]
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/case.json
new file mode 100644
index 000000000..8d99a239c
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "Classic route with access control groups: instead of the wide-open default permit, peer-r's route firewall rule must be narrowed to the policy that targets the ACL group — protocol and port from that rule, SourceRanges limited to the two source peers. peer-a still receives the route itself.",
+  "peers": ["peer-a", "peer-r"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/golden/peer-a.json
new file mode 100644
index 000000000..c52aecce9
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/golden/peer-a.json
@@ -0,0 +1,76 @@
+{
+  "Serial": "27",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "allowedIps": [
+        "100.64.0.9/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-r.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "rt-acl",
+      "Network": "10.70.0.0/24",
+      "NetworkType": "1",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "db-net"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-r.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.9"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.9",
+      "Protocol": "TCP",
+      "Port": "3306",
+      "PolicyID": "cG9sLWFjbA=="
+    },
+    {
+      "PeerIP": "100.64.0.9",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "3306",
+      "PolicyID": "cG9sLWFjbA=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/golden/peer-r.json
new file mode 100644
index 000000000..cc03ef08c
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/golden/peer-r.json
@@ -0,0 +1,119 @@
+{
+  "Serial": "27",
+  "peerConfig": {
+    "address": "100.64.0.9/10",
+    "sshConfig": {},
+    "fqdn": "peer-r.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "AvldyrZ12Pf90jzf3AXmhPwg3UcI+jtJHfbpBlupvko=",
+      "allowedIps": [
+        "100.64.0.2/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-b.netbird.test",
+      "agentVersion": "0.60.0"
+    },
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "rt-acl",
+      "Network": "10.70.0.0/24",
+      "NetworkType": "1",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "db-net"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-b.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          },
+          {
+            "Name": "peer-r.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.9"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "Port": "3306",
+      "PolicyID": "cG9sLWFjbA=="
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "3306",
+      "PolicyID": "cG9sLWFjbA=="
+    },
+    {
+      "PeerIP": "100.64.0.2",
+      "Protocol": "TCP",
+      "Port": "3306",
+      "PolicyID": "cG9sLWFjbA=="
+    },
+    {
+      "PeerIP": "100.64.0.2",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "3306",
+      "PolicyID": "cG9sLWFjbA=="
+    }
+  ],
+  "routesFirewallRules": [
+    {
+      "sourceRanges": [
+        "100.64.0.1/32",
+        "100.64.0.2/32"
+      ],
+      "destination": "10.70.0.0/24",
+      "protocol": "TCP",
+      "portInfo": {
+        "port": 3306
+      },
+      "PolicyID": "cG9sLWFjbA==",
+      "RouteID": "rt-acl"
+    }
+  ],
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/nmdata.json
new file mode 100644
index 000000000..d7bfd0d08
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-access-control-groups/nmdata.json
@@ -0,0 +1,45 @@
+{
+  "Network": {"Serial": 27},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a", "peer-b"]},
+    "grp-acl": {"Peers": ["peer-r"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-acl",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["3306"],
+          "Bidirectional": true,
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-acl"]
+        }
+      ]
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "rt-acl",
+      "NetID": "db-net",
+      "Network": "10.70.0.0/24",
+      "NetworkType": 1,
+      "Peer": "peer-r",
+      "PeerID": "peer-r",
+      "Groups": ["grp-dev"],
+      "AccessControlGroups": ["grp-acl"],
+      "Metric": 9999,
+      "Masquerade": true,
+      "Enabled": true
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/case.json
new file mode 100644
index 000000000..52d45346a
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "Classic route served by a peer group instead of one peer: each routing peer's copy takes the route id with its own peer id appended and drops the PeerGroups field, and the distribution group's peer-a must receive both copies as an HA pair. Each router receives only its own copy plus a default-permit route firewall rule, because the route carries no access control groups. A policy connecting the two groups is required — route distribution follows peers the target may already talk to.",
+  "peers": ["peer-a", "peer-r1"]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/golden/peer-a.json
new file mode 100644
index 000000000..fd9e32274
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/golden/peer-a.json
@@ -0,0 +1,114 @@
+{
+  "Serial": "26",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "Iu6Lj1HqDXfgDh5RppHCRI6RO3lMlZBCndRNrdS7QUI=",
+      "allowedIps": [
+        "100.64.0.11/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-r1.netbird.test",
+      "agentVersion": "0.60.0"
+    },
+    {
+      "wgPubKey": "YC6sbWtvpB2d6W/XqsNIV9crsrXBoVDdwmhCoszjCps=",
+      "allowedIps": [
+        "100.64.0.12/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-r2.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "rt-ha:peer-r1",
+      "Network": "10.60.0.0/24",
+      "NetworkType": "1",
+      "Peer": "Iu6Lj1HqDXfgDh5RppHCRI6RO3lMlZBCndRNrdS7QUI=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "ha-net"
+    },
+    {
+      "ID": "rt-ha:peer-r2",
+      "Network": "10.60.0.0/24",
+      "NetworkType": "1",
+      "Peer": "YC6sbWtvpB2d6W/XqsNIV9crsrXBoVDdwmhCoszjCps=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "ha-net"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-r1.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.11"
+          },
+          {
+            "Name": "peer-r2.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.12"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.11",
+      "Protocol": "TCP",
+      "Port": "8080",
+      "PolicyID": "cG9sLWNvbm4="
+    },
+    {
+      "PeerIP": "100.64.0.11",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "8080",
+      "PolicyID": "cG9sLWNvbm4="
+    },
+    {
+      "PeerIP": "100.64.0.12",
+      "Protocol": "TCP",
+      "Port": "8080",
+      "PolicyID": "cG9sLWNvbm4="
+    },
+    {
+      "PeerIP": "100.64.0.12",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "8080",
+      "PolicyID": "cG9sLWNvbm4="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/golden/peer-r1.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/golden/peer-r1.json
new file mode 100644
index 000000000..bd8bdecd0
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/golden/peer-r1.json
@@ -0,0 +1,93 @@
+{
+  "Serial": "26",
+  "peerConfig": {
+    "address": "100.64.0.11/10",
+    "sshConfig": {},
+    "fqdn": "peer-r1.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "rt-ha:peer-r1",
+      "Network": "10.60.0.0/24",
+      "NetworkType": "1",
+      "Peer": "Iu6Lj1HqDXfgDh5RppHCRI6RO3lMlZBCndRNrdS7QUI=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "ha-net"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-r1.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.11"
+          },
+          {
+            "Name": "peer-r2.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.12"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "Port": "8080",
+      "PolicyID": "cG9sLWNvbm4="
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "8080",
+      "PolicyID": "cG9sLWNvbm4="
+    }
+  ],
+  "routesFirewallRules": [
+    {
+      "sourceRanges": [
+        "0.0.0.0/0"
+      ],
+      "destination": "10.60.0.0/24",
+      "protocol": "ALL",
+      "portInfo": {},
+      "RouteID": "rt-ha:peer-r1"
+    }
+  ],
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/nmdata.json
new file mode 100644
index 000000000..8b81a585f
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/route-peer-groups-ha/nmdata.json
@@ -0,0 +1,43 @@
+{
+  "Network": {"Serial": 26},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-r1": {"IP": "100.64.0.11", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-r2": {"IP": "100.64.0.12", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a"]},
+    "grp-routers": {"Peers": ["peer-r1", "peer-r2"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-conn",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["8080"],
+          "Bidirectional": true,
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-routers"]
+        }
+      ]
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "rt-ha",
+      "NetID": "ha-net",
+      "Network": "10.60.0.0/24",
+      "NetworkType": 1,
+      "PeerGroups": ["grp-routers"],
+      "Groups": ["grp-dev"],
+      "Metric": 9999,
+      "Masquerade": true,
+      "Enabled": true
+    }
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/case.json
new file mode 100644
index 000000000..a94d3653f
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/case.json
@@ -0,0 +1,7 @@
+{
+  "description": "Classic route distributed to grp-dev plus a network resource behind router peer-r with a resource policy; peer-a gets routes and route firewall rules, peer-r gets the routing-peer view.",
+  "peers": [
+    "peer-a",
+    "peer-r"
+  ]
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/golden/peer-a.json
new file mode 100644
index 000000000..9426bd846
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/golden/peer-a.json
@@ -0,0 +1,86 @@
+{
+  "Serial": "7",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "allowedIps": [
+        "100.64.0.9/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-r.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-db:peer-r",
+      "Network": "10.10.0.0/24",
+      "NetworkType": "1",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "db",
+      "keepRoute": true
+    },
+    {
+      "ID": "rt-1",
+      "Network": "10.20.0.0/24",
+      "NetworkType": "1",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "office-net"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-r.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.9"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.9",
+      "Protocol": "TCP",
+      "Port": "8080",
+      "PolicyID": "cG9sLWNvbm4="
+    },
+    {
+      "PeerIP": "100.64.0.9",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "8080",
+      "PolicyID": "cG9sLWNvbm4="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/golden/peer-r.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/golden/peer-r.json
new file mode 100644
index 000000000..e5971796d
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/golden/peer-r.json
@@ -0,0 +1,138 @@
+{
+  "Serial": "7",
+  "peerConfig": {
+    "address": "100.64.0.9/10",
+    "sshConfig": {},
+    "fqdn": "peer-r.netbird.test",
+    "RoutingPeerDnsResolutionEnabled": true,
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "AvldyrZ12Pf90jzf3AXmhPwg3UcI+jtJHfbpBlupvko=",
+      "allowedIps": [
+        "100.64.0.2/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-b.netbird.test",
+      "agentVersion": "0.60.0"
+    },
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "Routes": [
+    {
+      "ID": "res-db:peer-r",
+      "Network": "10.10.0.0/24",
+      "NetworkType": "1",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "db",
+      "keepRoute": true
+    },
+    {
+      "ID": "rt-1",
+      "Network": "10.20.0.0/24",
+      "NetworkType": "1",
+      "Peer": "ImPDKs2PJxHA24/N7umWi8lfEf2B0B5W/7dYZUzNS3s=",
+      "Metric": "9999",
+      "Masquerade": true,
+      "NetID": "office-net"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-b.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          },
+          {
+            "Name": "peer-r.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.9"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "Port": "8080",
+      "PolicyID": "cG9sLWNvbm4="
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "8080",
+      "PolicyID": "cG9sLWNvbm4="
+    },
+    {
+      "PeerIP": "100.64.0.2",
+      "Protocol": "TCP",
+      "Port": "8080",
+      "PolicyID": "cG9sLWNvbm4="
+    },
+    {
+      "PeerIP": "100.64.0.2",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "8080",
+      "PolicyID": "cG9sLWNvbm4="
+    }
+  ],
+  "routesFirewallRules": [
+    {
+      "sourceRanges": [
+        "0.0.0.0/0"
+      ],
+      "destination": "10.20.0.0/24",
+      "protocol": "ALL",
+      "portInfo": {},
+      "RouteID": "rt-1"
+    },
+    {
+      "sourceRanges": [
+        "100.64.0.1/32",
+        "100.64.0.2/32"
+      ],
+      "destination": "10.10.0.0/24",
+      "protocol": "TCP",
+      "portInfo": {
+        "port": 5432
+      },
+      "PolicyID": "cG9sLWRi",
+      "RouteID": "res-db:peer-r"
+    }
+  ],
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/nmdata.json
new file mode 100644
index 000000000..795047f50
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/routes-resources/nmdata.json
@@ -0,0 +1,97 @@
+{
+  "Network": {"Serial": 7},
+  "AccountSettings": {"RoutingPeerDNSResolutionEnabled": true},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-r": {"IP": "100.64.0.9", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a", "peer-b"]},
+    "grp-routers": {"Peers": ["peer-r"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-conn",
+      "PublicID": "pol-conn-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Bidirectional": true,
+          "Ports": ["8080"],
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-routers"]
+        }
+      ]
+    },
+    {
+      "ID": "pol-db",
+      "PublicID": "pol-db-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["5432"],
+          "Sources": ["grp-dev"],
+          "DestinationResource": {"ID": "res-db", "Type": "subnet"}
+        }
+      ]
+    }
+  ],
+  "ResourcePolicies": {
+    "res-db": [
+      {
+        "ID": "pol-db",
+        "PublicID": "pol-db-pub",
+        "Enabled": true,
+        "Rules": [
+          {
+            "Enabled": true,
+            "Action": "accept",
+            "Protocol": "tcp",
+            "Ports": ["5432"],
+            "Sources": ["grp-dev"],
+            "DestinationResource": {"ID": "res-db", "Type": "subnet"}
+          }
+        ]
+      }
+    ]
+  },
+  "Routes": [
+    {
+      "ID": "rt-1",
+      "PublicID": "rt-1-pub",
+      "NetID": "office-net",
+      "Network": "10.20.0.0/24",
+      "NetworkType": 1,
+      "Peer": "peer-r",
+      "PeerID": "peer-r",
+      "Metric": 9999,
+      "Masquerade": true,
+      "Enabled": true,
+      "Groups": ["grp-dev"]
+    }
+  ],
+  "NetworkResources": [
+    {
+      "ID": "res-db",
+      "PublicID": "res-db-pub",
+      "NetworkID": "net-1",
+      "Name": "db",
+      "Type": "subnet",
+      "Prefix": "10.10.0.0/24",
+      "Enabled": true
+    }
+  ],
+  "Routers": {
+    "net-1": {
+      "peer-r": {"Masquerade": true, "Metric": 9999, "Enabled": true}
+    }
+  },
+  "NetworkXIDToPublicID": {"net-1": "net-1-pub"}
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/case.json
new file mode 100644
index 000000000..78ac576d9
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "netbird-ssh with AuthorizedGroups: grp-admins members may log in as root, grp-oncall (empty local-user list) as any machine user; peer-srv must receive both mappings in SshAuth, clients get plain TCP firewall rules. THE ENVELOPE EXPECTATION CURRENTLY FAILS, and should: encodeAuthorizedGroups/encodeGroupIDToUserIDs translate group keys via components.Groups, which never holds user-only groups, so the wire loses every authorized user while PeerConfig still reports sshEnabled — the peer runs sshd and denies every login. Pre-existing on main since PR #6711, not a regression. Fix the encoder, do not weaken this expectation.",
+  "peers": ["peer-srv", "peer-a"]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/golden/peer-a.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/golden/peer-a.json
new file mode 100644
index 000000000..6560bc74d
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/golden/peer-a.json
@@ -0,0 +1,63 @@
+{
+  "Serial": "10",
+  "peerConfig": {
+    "address": "100.64.0.1/10",
+    "sshConfig": {},
+    "fqdn": "peer-a.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "jEboa3bOv65XRbq8+8JuagiwWz+mM7Fc5MUfBQbOf6Y=",
+      "allowedIps": [
+        "100.64.0.10/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-srv.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.10",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 22022,
+          "end": 22022
+        }
+      },
+      "PolicyID": "cG9sLXNzaA=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/golden/peer-srv.json
new file mode 100644
index 000000000..0a66bcbf0
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/golden/peer-srv.json
@@ -0,0 +1,109 @@
+{
+  "Serial": "10",
+  "peerConfig": {
+    "address": "100.64.0.10/10",
+    "sshConfig": {
+      "sshEnabled": true
+    },
+    "fqdn": "peer-srv.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "AvldyrZ12Pf90jzf3AXmhPwg3UcI+jtJHfbpBlupvko=",
+      "allowedIps": [
+        "100.64.0.2/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-b.netbird.test",
+      "agentVersion": "0.60.0"
+    },
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-b.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.2"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 22022,
+          "end": 22022
+        }
+      },
+      "PolicyID": "cG9sLXNzaA=="
+    },
+    {
+      "PeerIP": "100.64.0.2",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 22022,
+          "end": 22022
+        }
+      },
+      "PolicyID": "cG9sLXNzaA=="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub",
+    "AuthorizedUsers": [
+      "CF6q+CJTtcJE8MVIcpPyOw==",
+      "zSsmm7BAxWD/EuunyETFXA==",
+      "0M0MizUGgS6HAaJa0LjGKQ=="
+    ],
+    "machineUsers": {
+      "*": {
+        "indexes": [
+          2
+        ]
+      },
+      "root": {
+        "indexes": [
+          0,
+          1
+        ]
+      }
+    }
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/nmdata.json
new file mode 100644
index 000000000..de0788ac9
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-groups/nmdata.json
@@ -0,0 +1,37 @@
+{
+  "Network": {"Serial": 10},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-b": {"IP": "100.64.0.2", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a", "peer-b"]},
+    "grp-srv": {"Peers": ["peer-srv"]}
+  },
+  "GroupIDToUserIDs": {
+    "grp-admins": ["user-x", "user-y"],
+    "grp-oncall": ["user-z"]
+  },
+  "Policies": [
+    {
+      "ID": "pol-ssh",
+      "PublicID": "pol-ssh-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "netbird-ssh",
+          "PortRanges": [{"Start": 22022, "End": 22022}],
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-srv"],
+          "AuthorizedGroups": {
+            "grp-admins": ["root"],
+            "grp-oncall": []
+          }
+        }
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/case.json
new file mode 100644
index 000000000..e4799638e
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "netbird-ssh with a single AuthorizedUser: peer-srv's SshAuth maps the wildcard machine user to exactly user-solo.",
+  "peers": ["peer-srv"]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/golden/peer-srv.json
new file mode 100644
index 000000000..dde0d4014
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/golden/peer-srv.json
@@ -0,0 +1,74 @@
+{
+  "Serial": "11",
+  "peerConfig": {
+    "address": "100.64.0.10/10",
+    "sshConfig": {
+      "sshEnabled": true
+    },
+    "fqdn": "peer-srv.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 22022,
+          "end": 22022
+        }
+      },
+      "PolicyID": "cG9sLXNzaC11c2Vy"
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub",
+    "AuthorizedUsers": [
+      "/6cwl49UgLozU42NCr0RUA=="
+    ],
+    "machineUsers": {
+      "*": {
+        "indexes": [
+          0
+        ]
+      }
+    }
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/nmdata.json
new file mode 100644
index 000000000..1c92329a8
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-authorized-user/nmdata.json
@@ -0,0 +1,29 @@
+{
+  "Network": {"Serial": 11},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a"]},
+    "grp-srv": {"Peers": ["peer-srv"]}
+  },
+  "Policies": [
+    {
+      "ID": "pol-ssh-user",
+      "PublicID": "pol-ssh-user-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "netbird-ssh",
+          "PortRanges": [{"Start": 22022, "End": 22022}],
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-srv"],
+          "AuthorizedUser": "user-solo"
+        }
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/case.json
new file mode 100644
index 000000000..8d4fb6c1a
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "netbird-ssh with neither AuthorizedGroups nor AuthorizedUser falls back to the account AllowedUserIDs under the wildcard machine user — and works with the peer's own SSHEnabled left off, unlike legacy SSH.",
+  "peers": ["peer-srv"]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/golden/peer-srv.json
new file mode 100644
index 000000000..e63768a95
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/golden/peer-srv.json
@@ -0,0 +1,76 @@
+{
+  "Serial": "12",
+  "peerConfig": {
+    "address": "100.64.0.10/10",
+    "sshConfig": {
+      "sshEnabled": true
+    },
+    "fqdn": "peer-srv.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "PortInfo": {
+        "range": {
+          "start": 22022,
+          "end": 22022
+        }
+      },
+      "PolicyID": "cG9sLXNzaC1hbnk="
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub",
+    "AuthorizedUsers": [
+      "O8fBfcakRSAM4gX+YBNe+w==",
+      "1vwcS03btOdBRX0dhz0NRg=="
+    ],
+    "machineUsers": {
+      "*": {
+        "indexes": [
+          0,
+          1
+        ]
+      }
+    }
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/nmdata.json
new file mode 100644
index 000000000..97bfe3342
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-fallback-allowed-users/nmdata.json
@@ -0,0 +1,29 @@
+{
+  "Network": {"Serial": 12},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a"]},
+    "grp-srv": {"Peers": ["peer-srv"]}
+  },
+  "AllowedUserIDs": {"user-1": {}, "user-2": {}},
+  "Policies": [
+    {
+      "ID": "pol-ssh-any",
+      "PublicID": "pol-ssh-any-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "netbird-ssh",
+          "PortRanges": [{"Start": 22022, "End": 22022}],
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-srv"]
+        }
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/case.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/case.json
new file mode 100644
index 000000000..1427fbd19
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/case.json
@@ -0,0 +1,4 @@
+{
+  "description": "A tcp/22 policy implies legacy SSH only when the destination peer has SSHEnabled; here it does not, so peer-srv gets the firewall rules but no authorized users.",
+  "peers": ["peer-srv"]
+}
\ No newline at end of file
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/golden/peer-srv.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/golden/peer-srv.json
new file mode 100644
index 000000000..1ab6d4697
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/golden/peer-srv.json
@@ -0,0 +1,64 @@
+{
+  "Serial": "13",
+  "peerConfig": {
+    "address": "100.64.0.10/10",
+    "sshConfig": {},
+    "fqdn": "peer-srv.netbird.test",
+    "autoUpdate": {}
+  },
+  "remotePeers": [
+    {
+      "wgPubKey": "vblMc9U8RAI6cVopcKEMTVT6lVC3D9nTTMSwot5d3L4=",
+      "allowedIps": [
+        "100.64.0.1/32"
+      ],
+      "sshConfig": {},
+      "fqdn": "peer-a.netbird.test",
+      "agentVersion": "0.60.0"
+    }
+  ],
+  "DNSConfig": {
+    "ServiceEnable": true,
+    "CustomZones": [
+      {
+        "Domain": "netbird.test.",
+        "Records": [
+          {
+            "Name": "peer-a.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.1"
+          },
+          {
+            "Name": "peer-srv.netbird.test",
+            "Type": "1",
+            "Class": "IN",
+            "TTL": "300",
+            "RData": "100.64.0.10"
+          }
+        ]
+      }
+    ],
+    "ForwarderPort": "22054"
+  },
+  "FirewallRules": [
+    {
+      "PeerIP": "100.64.0.1",
+      "Protocol": "TCP",
+      "Port": "22",
+      "PolicyID": "cG9sLXRjcDIy"
+    },
+    {
+      "PeerIP": "100.64.0.1",
+      "Direction": "OUT",
+      "Protocol": "TCP",
+      "Port": "22",
+      "PolicyID": "cG9sLXRjcDIy"
+    }
+  ],
+  "routesFirewallRulesIsEmpty": true,
+  "sshAuth": {
+    "UserIDClaim": "sub"
+  }
+}
diff --git a/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/nmdata.json b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/nmdata.json
new file mode 100644
index 000000000..f00d0f06e
--- /dev/null
+++ b/management/internals/controllers/network_map/nmaptest/testdata/cases/ssh-legacy-disabled/nmdata.json
@@ -0,0 +1,30 @@
+{
+  "Network": {"Serial": 13},
+  "Peers": {
+    "peer-a": {"IP": "100.64.0.1", "Meta": {"WtVersion": "0.60.0"}},
+    "peer-srv": {"IP": "100.64.0.10", "Meta": {"WtVersion": "0.60.0"}}
+  },
+  "Groups": {
+    "grp-dev": {"Peers": ["peer-a"]},
+    "grp-srv": {"Peers": ["peer-srv"]}
+  },
+  "AllowedUserIDs": {"user-1": {}},
+  "Policies": [
+    {
+      "ID": "pol-tcp22",
+      "PublicID": "pol-tcp22-pub",
+      "Enabled": true,
+      "Rules": [
+        {
+          "Enabled": true,
+          "Action": "accept",
+          "Protocol": "tcp",
+          "Ports": ["22"],
+          "Bidirectional": true,
+          "Sources": ["grp-dev"],
+          "Destinations": ["grp-srv"]
+        }
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/management/internals/modules/agentnetwork/catalog/catalog.go b/management/internals/modules/agentnetwork/catalog/catalog.go
index 2c4efd0b4..3c7b995e5 100644
--- a/management/internals/modules/agentnetwork/catalog/catalog.go
+++ b/management/internals/modules/agentnetwork/catalog/catalog.go
@@ -113,8 +113,61 @@ type Provider struct {
 	// upstream provider + credentials on Portkey's hosted side).
 	ExtraHeaders []ExtraHeader
 	Models       []Model
+	// Discovery, when non-nil, describes how to ask this vendor which
+	// models the operator's own credential can actually reach, so the
+	// provider form can offer a live list instead of only the hand-curated
+	// Models above. Nil for entries with no listing endpoint (gateways
+	// vary too much) — those keep free-text entry.
+	Discovery *Discovery
 }
 
+// ListingShape names the response envelope a vendor returns its model
+// listing in. Every vendor invented its own, and none of them can be
+// guessed from the request, so the catalog states it.
+type ListingShape string
+
+const (
+	// ShapeOpenAIData is {"data":[{"id":…}]} — OpenAI, and Anthropic, which
+	// adopted the same envelope.
+	ShapeOpenAIData ListingShape = "openai_data"
+	// ShapeBedrockInferenceProfiles is
+	// {"inferenceProfileSummaries":[{"inferenceProfileId":…}]}. The ids carry
+	// the region prefix that makes them invocable, which is exactly what an
+	// operator cannot reconstruct by hand.
+	ShapeBedrockInferenceProfiles ListingShape = "bedrock_inference_profiles"
+	// ShapeVertexPublisherModels is {"publisherModels":[{"name":…}]}, where
+	// name is a resource path and the invocable id is its last segment joined
+	// to a separate versionId field.
+	ShapeVertexPublisherModels ListingShape = "vertex_publisher_models"
+)
+
+// Discovery describes one vendor's model-listing endpoint.
+//
+// Host is deliberately separate from the provider record's upstream URL:
+// Bedrock serves listings from the control plane (bedrock.) while
+// inference must go to the runtime host (bedrock-runtime.), so the
+// two cannot be the same value. Empty Host means "use the record's own
+// upstream", which is right for every vendor that serves both from one host.
+//
+// The regionPlaceholder in Host is substituted from the provider record's
+// region. Deriving the discovery host from the catalog rather than accepting
+// one from the caller is also what keeps this from being an open proxy: the
+// only hosts management will dial are the ones written here.
+type Discovery struct {
+	Host  string
+	Path  string
+	Query string
+	Shape ListingShape
+	// Headers are static headers the vendor requires beyond the credential
+	// (Anthropic versions its API through one and rejects a request without
+	// it). The auth header itself comes from AuthHeaderName/Template.
+	Headers map[string]string
+}
+
+// RegionPlaceholder is replaced in Discovery.Host by the provider record's
+// configured region.
+const RegionPlaceholder = ""
+
 // ExtraHeader names a single optional per-provider routing/config
 // header. Catalog declares N of these per provider type; the operator
 // fills any subset on the provider record (see Provider.ExtraValues).
@@ -245,8 +298,12 @@ var providers = []Provider{
 		AuthHeaderTemplate: "Bearer ${API_KEY}",
 		DefaultContentType: "application/json",
 		BrandColor:         "#10A37F",
-		ParserID:           "openai",
-		PricingSurfaces:    []string{"openai"},
+		Discovery: &Discovery{
+			Path:  "/v1/models",
+			Shape: ShapeOpenAIData,
+		},
+		ParserID:        "openai",
+		PricingSurfaces: []string{"openai"},
 		// Pricing + context windows cross-checked against LiteLLM's
 		// model_prices_and_context_window.json. Notable corrections from
 		// earlier values: o4-mini repriced from $4/$16 to $1.10/$4.40
@@ -284,8 +341,18 @@ var providers = []Provider{
 		AuthHeaderTemplate: "${API_KEY}",
 		DefaultContentType: "application/json",
 		BrandColor:         "#D97757",
-		ParserID:           "anthropic",
-		PricingSurfaces:    []string{"anthropic"},
+		Discovery: &Discovery{
+			Path: "/v1/models",
+			// The default page is short and a picker wants the whole
+			// catalogue in one call.
+			Query: "limit=1000",
+			Shape: ShapeOpenAIData,
+			// Anthropic versions its API through a header and refuses a
+			// request that omits it, listing included.
+			Headers: map[string]string{"anthropic-version": "2023-06-01"},
+		},
+		ParserID:        "anthropic",
+		PricingSurfaces: []string{"anthropic"},
 		// Per Anthropic's current model lineup. Pricing in USD per 1k
 		// tokens. Context windows: 4.6+ family is 1M; Haiku 4.5 stays at
 		// 200K. claude-3-7-sonnet and claude-3-5-haiku retired
@@ -296,6 +363,8 @@ var providers = []Provider{
 		// account to be on >= 30-day data retention or all requests
 		// 400.
 		Models: []Model{
+			{ID: "claude-opus-5", Label: "Claude Opus 5", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
+			{ID: "claude-sonnet-5", Label: "Claude Sonnet 5", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 1000000},
 			{ID: "claude-fable-5", Label: "Claude Fable 5", InputPer1k: 0.010, OutputPer1k: 0.050, CacheReadPer1k: 0.001, CacheCreationPer1k: 0.0125, ContextWindow: 1000000},
 			{ID: "claude-opus-4-8", Label: "Claude Opus 4.8", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
 			{ID: "claude-opus-4-7", Label: "Claude Opus 4.7", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
@@ -343,6 +412,22 @@ var providers = []Provider{
 		AuthHeaderTemplate: "Bearer ${API_KEY}",
 		DefaultContentType: "application/json",
 		BrandColor:         "#FF9900",
+		// Listings come from the CONTROL PLANE, not the runtime host in
+		// DefaultHost above: ListInferenceProfiles is not an operation
+		// bedrock-runtime implements, and answers 
+		// there. Inference has to go to the runtime host, so the two hosts
+		// genuinely differ and Discovery.Host carries the difference.
+		//
+		// Inference profiles rather than foundation models because the profile
+		// id is the invocable one: it carries the region prefix (eu., us.,
+		// global.) that AWS requires and that cannot be derived from the
+		// configured region — an eu-central-1 account legitimately holds
+		// global.* profiles.
+		Discovery: &Discovery{
+			Host:  "bedrock." + RegionPlaceholder + ".amazonaws.com",
+			Path:  "/inference-profiles",
+			Shape: ShapeBedrockInferenceProfiles,
+		},
 		// ParserID stays empty (path-style dispatch via IsBedrockPathStyle);
 		// the request parser meters these under the "bedrock" surface.
 		PricingSurfaces: []string{"bedrock"},
@@ -355,6 +440,8 @@ var providers = []Provider{
 		// Llama 3.3 70B entry kept unchanged — LiteLLM tracks only
 		// per-region Llama 3 entries; standalone 3.3 not yet listed.
 		Models: []Model{
+			{ID: "anthropic.claude-opus-5", Label: "Claude Opus 5 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
+			{ID: "anthropic.claude-sonnet-5", Label: "Claude Sonnet 5 (Bedrock)", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 1000000},
 			{ID: "anthropic.claude-opus-4-8", Label: "Claude Opus 4.8 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
 			{ID: "anthropic.claude-opus-4-7", Label: "Claude Opus 4.7 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
 			{ID: "anthropic.claude-opus-4-6", Label: "Claude Opus 4.6 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
@@ -391,6 +478,15 @@ var providers = []Provider{
 		AuthHeaderTemplate: "Bearer ${API_KEY}",
 		DefaultContentType: "application/json",
 		BrandColor:         "#4285F4",
+		// Only the v1beta1 publisher listing answers: the v1 form and the
+		// project-scoped form under BOTH versions return 404. That means the
+		// list is publisher-global — it cannot say which models this project
+		// has enabled — so it is offered as a suggestion beside the catalog
+		// rather than replacing it. See the discovery e2e for the probes.
+		Discovery: &Discovery{
+			Path:  "/v1beta1/publishers/anthropic/models",
+			Shape: ShapeVertexPublisherModels,
+		},
 		// ParserID stays empty (path-style dispatch via IsVertexPathStyle);
 		// Anthropic-on-Vertex requests are metered under the "anthropic"
 		// surface with the bare, unversioned model id.
@@ -406,6 +502,8 @@ var providers = []Provider{
 		// exists — the router denies unmeterable publishers rather than forward
 		// them uncounted.
 		Models: []Model{
+			{ID: "claude-opus-5", Label: "Claude Opus 5 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
+			{ID: "claude-sonnet-5", Label: "Claude Sonnet 5 (Vertex)", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 1000000},
 			{ID: "claude-fable-5", Label: "Claude Fable 5 (Vertex)", InputPer1k: 0.010, OutputPer1k: 0.050, CacheReadPer1k: 0.001, CacheCreationPer1k: 0.0125, ContextWindow: 1000000},
 			{ID: "claude-opus-4-8", Label: "Claude Opus 4.8 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
 			{ID: "claude-opus-4-7", Label: "Claude Opus 4.7 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
diff --git a/management/internals/modules/agentnetwork/catalog/catalog_test.go b/management/internals/modules/agentnetwork/catalog/catalog_test.go
new file mode 100644
index 000000000..e4e887e6f
--- /dev/null
+++ b/management/internals/modules/agentnetwork/catalog/catalog_test.go
@@ -0,0 +1,36 @@
+package catalog
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// TestClaudeLineupSelectable pins the models Claude Code resolves to by
+// default. A model absent from the lineup can't be ticked on a provider
+// record, so llm_router denies it as not-routable and the operator has no
+// way to authorise the client's own default.
+func TestClaudeLineupSelectable(t *testing.T) {
+	for providerID, wanted := range map[string][]string{
+		"anthropic_api": {"claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5"},
+		"bedrock_api":   {"anthropic.claude-opus-5", "anthropic.claude-sonnet-5", "anthropic.claude-haiku-4-5"},
+		"vertex_ai_api": {"claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5"},
+	} {
+		provider, ok := Lookup(providerID)
+		require.True(t, ok, "catalog must define %s", providerID)
+
+		selectable := make(map[string]Model, len(provider.Models))
+		for _, m := range provider.Models {
+			selectable[m.ID] = m
+		}
+		for _, id := range wanted {
+			model, found := selectable[id]
+			require.True(t, found, "%s must offer %s", providerID, id)
+			assert.NotEmpty(t, model.Label, "%s/%s needs a label for the picker", providerID, id)
+			assert.Positive(t, model.InputPer1k, "%s/%s needs an input rate", providerID, id)
+			assert.Positive(t, model.OutputPer1k, "%s/%s needs an output rate", providerID, id)
+			assert.Positive(t, model.ContextWindow, "%s/%s needs a context window", providerID, id)
+		}
+	}
+}
diff --git a/management/internals/modules/agentnetwork/handlers/model_discovery_handler_test.go b/management/internals/modules/agentnetwork/handlers/model_discovery_handler_test.go
new file mode 100644
index 000000000..389c2ae50
--- /dev/null
+++ b/management/internals/modules/agentnetwork/handlers/model_discovery_handler_test.go
@@ -0,0 +1,178 @@
+package handlers
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
+	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/modeldiscovery"
+	nbcontext "github.com/netbirdio/netbird/management/server/context"
+	"github.com/netbirdio/netbird/shared/auth"
+	"github.com/netbirdio/netbird/shared/management/http/api"
+)
+
+// discoveryManagerStub records what the handler asked for and returns a canned
+// answer. The Manager interface is embedded rather than implemented: only the
+// one method is reachable from this handler, and a call to any other should
+// fail loudly rather than silently return a zero value.
+type discoveryManagerStub struct {
+	agentnetwork.Manager
+
+	gotReq      modeldiscovery.Request
+	gotRecordID string
+	models      []modeldiscovery.Model
+	err         error
+}
+
+func (s *discoveryManagerStub) DiscoverProviderModels(
+	_ context.Context, _, _ string, req modeldiscovery.Request, recordID string,
+) ([]modeldiscovery.Model, error) {
+	s.gotReq = req
+	s.gotRecordID = recordID
+	return s.models, s.err
+}
+
+// postDiscovery drives the handler with an authenticated request.
+func postDiscovery(t *testing.T, stub *discoveryManagerStub, body string) *httptest.ResponseRecorder {
+	t.Helper()
+	h := &handler{manager: stub}
+
+	req := httptest.NewRequest(http.MethodPost, "/agent-network/catalog/providers/models", strings.NewReader(body))
+	req = req.WithContext(nbcontext.SetUserAuthInContext(req.Context(), auth.UserAuth{
+		AccountId: "acc-1",
+		UserId:    "user-1",
+	}))
+
+	rec := httptest.NewRecorder()
+	h.discoverProviderModels(rec, req)
+	return rec
+}
+
+func TestDiscoverModelsReturnsTheVendorList(t *testing.T) {
+	stub := &discoveryManagerStub{models: []modeldiscovery.Model{
+		{ID: "eu.anthropic.claude-haiku-4-5-20251001-v1:0", Label: "EU Claude Haiku 4.5", PricingKnown: true},
+		{ID: "global.cohere.embed-v4:0", Label: "Global Cohere Embed v4"},
+		// A vendor that supplies no display name at all. Bedrock does for
+		// every profile, but the OpenAI listing carries none.
+		{ID: "gpt-4o-mini", PricingKnown: true},
+	}}
+
+	rec := postDiscovery(t, stub, `{
+		"catalog_provider_id":"bedrock_api",
+		"upstream_url":"https://bedrock-runtime.eu-central-1.amazonaws.com",
+		"api_key":"aws-bearer"
+	}`)
+	require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+
+	var out api.AgentNetworkModelDiscoveryResponse
+	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out))
+	require.Len(t, out.Models, 3)
+
+	assert.Equal(t, "eu.anthropic.claude-haiku-4-5-20251001-v1:0", out.Models[0].Id)
+	assert.True(t, out.Models[0].PricingKnown)
+	// An unpriced model must say so rather than arriving indistinguishable
+	// from a priced one: registering it silently would meter at zero.
+	assert.False(t, out.Models[1].PricingKnown)
+
+	require.NotNil(t, out.Models[0].Label, "the vendor supplied a display name")
+	assert.Equal(t, "EU Claude Haiku 4.5", *out.Models[0].Label)
+	// A vendor that supplies no name must omit the key rather than send an
+	// empty string: the dashboard falls back to the id on absence, and would
+	// render a blank row for "".
+	assert.Nil(t, out.Models[2].Label, "an absent label must not serialize")
+	assert.NotContains(t, rec.Body.String(), `"label":""`)
+
+	assert.Equal(t, "bedrock_api", stub.gotReq.CatalogID)
+	assert.Equal(t, "aws-bearer", stub.gotReq.APIKey)
+	// The upstream is what the region is read back out of for Bedrock, so
+	// losing it here would break discovery for every regional provider.
+	assert.Equal(t, "https://bedrock-runtime.eu-central-1.amazonaws.com", stub.gotReq.UpstreamURL)
+	assert.Empty(t, stub.gotRecordID)
+}
+
+func TestDiscoverModelsUsesAStoredRecordWithoutAKey(t *testing.T) {
+	stub := &discoveryManagerStub{}
+
+	rec := postDiscovery(t, stub, `{"catalog_provider_id":"openai_api","provider_id":"prov-42"}`)
+	require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+
+	// The dashboard refreshes a saved provider's list without ever holding
+	// the credential, so the record id has to reach the manager.
+	assert.Equal(t, "prov-42", stub.gotRecordID)
+	assert.Empty(t, stub.gotReq.APIKey)
+}
+
+// TestDiscoverModelsRefusesMixedCredentials covers the case where a caller
+// names a saved provider AND supplies a key. Accepting it would run an
+// arbitrary credential under the identity of a record the caller may only be
+// permitted to read.
+func TestDiscoverModelsRefusesMixedCredentials(t *testing.T) {
+	stub := &discoveryManagerStub{}
+
+	rec := postDiscovery(t, stub, `{
+		"catalog_provider_id":"openai_api",
+		"provider_id":"prov-42",
+		"api_key":"sk-attacker"
+	}`)
+	assert.Equal(t, http.StatusBadRequest, rec.Code)
+	assert.Empty(t, stub.gotRecordID, "the request must be refused before it reaches the manager")
+}
+
+// TestDiscoverModelsReportsNoDiscoveryDistinctly matters because the caller
+// falls back to the catalog's own model list on this outcome. Collapsing it
+// into a generic 500 would turn "this provider has no listing endpoint" into
+// "something went wrong", and the form would show an error instead of a list.
+func TestDiscoverModelsReportsNoDiscoveryDistinctly(t *testing.T) {
+	stub := &discoveryManagerStub{err: modeldiscovery.ErrNoDiscovery}
+
+	rec := postDiscovery(t, stub, `{"catalog_provider_id":"litellm_proxy","upstream_url":"https://gw.example.com","api_key":"sk"}`)
+	assert.Equal(t, http.StatusUnprocessableEntity, rec.Code)
+}
+
+// TestDiscoverModelsTrimsTheCatalogID pins that the id the emptiness check
+// accepts is the id the manager receives. A padded value that clears the check
+// but reaches the catalog untrimmed misses the lookup, and the operator is told
+// their provider does not exist.
+func TestDiscoverModelsTrimsTheCatalogID(t *testing.T) {
+	stub := &discoveryManagerStub{}
+
+	rec := postDiscovery(t, stub, `{"catalog_provider_id":"  openai_api  ","api_key":"sk"}`)
+	require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+	assert.Equal(t, "openai_api", stub.gotReq.CatalogID)
+}
+
+// TestDiscoverModelsReportsCallerInputAsBadRequest covers the other half of the
+// error mapping. These failures are all reachable from a well-formed request
+// with a bad field value, so answering 500 both misinforms the operator and
+// puts their typo into the server's error rate.
+func TestDiscoverModelsReportsCallerInputAsBadRequest(t *testing.T) {
+	stub := &discoveryManagerStub{
+		err: fmt.Errorf("%w: unknown catalog provider %q", modeldiscovery.ErrInvalidRequest, "nope"),
+	}
+
+	rec := postDiscovery(t, stub, `{"catalog_provider_id":"nope","api_key":"sk"}`)
+	assert.Equal(t, http.StatusBadRequest, rec.Code)
+	assert.Contains(t, rec.Body.String(), "unknown catalog provider")
+}
+
+func TestDiscoverModelsRejectsMalformedRequests(t *testing.T) {
+	for name, body := range map[string]string{
+		"not json":               `{`,
+		"no catalog provider":    `{"api_key":"sk"}`,
+		"blank catalog provider": `{"catalog_provider_id":"   ","api_key":"sk"}`,
+	} {
+		t.Run(name, func(t *testing.T) {
+			stub := &discoveryManagerStub{}
+			rec := postDiscovery(t, stub, body)
+			assert.Equal(t, http.StatusBadRequest, rec.Code)
+		})
+	}
+}
diff --git a/management/internals/modules/agentnetwork/handlers/providers_handler.go b/management/internals/modules/agentnetwork/handlers/providers_handler.go
index 0d8a44ca3..645d1da61 100644
--- a/management/internals/modules/agentnetwork/handlers/providers_handler.go
+++ b/management/internals/modules/agentnetwork/handlers/providers_handler.go
@@ -7,6 +7,7 @@ package handlers
 
 import (
 	"encoding/json"
+	"errors"
 	"math"
 	"net/http"
 	"net/url"
@@ -16,6 +17,7 @@ import (
 
 	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
 	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
+	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/modeldiscovery"
 	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing"
 	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
 	nbcontext "github.com/netbirdio/netbird/management/server/context"
@@ -32,6 +34,7 @@ type handler struct {
 func RegisterEndpoints(manager agentnetwork.Manager, router *mux.Router) {
 	h := &handler{manager: manager}
 	router.HandleFunc("/agent-network/catalog/providers", h.getCatalogProviders).Methods("GET", "OPTIONS")
+	router.HandleFunc("/agent-network/catalog/providers/models", h.discoverProviderModels).Methods("POST", "OPTIONS")
 	router.HandleFunc("/agent-network/providers", h.getAllProviders).Methods("GET", "OPTIONS")
 	router.HandleFunc("/agent-network/providers", h.createProvider).Methods("POST", "OPTIONS")
 	router.HandleFunc("/agent-network/providers/{providerId}", h.getProvider).Methods("GET", "OPTIONS")
@@ -61,6 +64,98 @@ func (h *handler) getCatalogProviders(w http.ResponseWriter, r *http.Request) {
 	util.WriteJSONObject(r.Context(), w, out)
 }
 
+// discoverProviderModels asks the vendor which models the operator's own
+// credential can reach, so the provider form can offer a live list rather than
+// only the static catalog.
+func (h *handler) discoverProviderModels(w http.ResponseWriter, r *http.Request) {
+	userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
+	if err != nil {
+		util.WriteError(r.Context(), err, w)
+		return
+	}
+
+	var body api.AgentNetworkModelDiscoveryRequest
+	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+		util.WriteErrorResponse("invalid json", http.StatusBadRequest, w)
+		return
+	}
+	// Trimmed once and carried, not trimmed for the emptiness test and then
+	// discarded: a padded " openai_api " would clear the check here and miss
+	// the catalog lookup, reporting the provider as unknown.
+	catalogID := strings.TrimSpace(body.CatalogProviderId)
+	if catalogID == "" {
+		util.WriteErrorResponse("catalog_provider_id is required", http.StatusBadRequest, w)
+		return
+	}
+
+	recordID := strValue(body.ProviderId)
+	req := modeldiscovery.Request{
+		CatalogID:   catalogID,
+		UpstreamURL: strValue(body.UpstreamUrl),
+		APIKey:      strValue(body.ApiKey),
+	}
+	// One source of credential or the other, never a mix: taking a key from
+	// the request while addressing a saved record would let a caller run an
+	// arbitrary credential against a provider they can only read.
+	if recordID != "" && req.APIKey != "" {
+		util.WriteErrorResponse("provide either provider_id or api_key, not both", http.StatusBadRequest, w)
+		return
+	}
+
+	models, err := h.manager.DiscoverProviderModels(r.Context(), userAuth.AccountId, userAuth.UserId, req, recordID)
+	if err != nil {
+		// A provider with no listing endpoint is a fact about the catalog
+		// entry, not a failure: the caller falls back to the catalog's own
+		// models, so it must be able to tell the two apart.
+		if errors.Is(err, modeldiscovery.ErrNoDiscovery) {
+			util.WriteErrorResponse(err.Error(), http.StatusUnprocessableEntity, w)
+			return
+		}
+		// An unknown provider, an unusable upstream, a missing region or a
+		// missing key are all things the caller sent, reachable from a
+		// well-formed request. Reporting them as 500 tells the operator the
+		// server broke and buries genuine faults in the error rate.
+		if errors.Is(err, modeldiscovery.ErrInvalidRequest) {
+			util.WriteErrorResponse(err.Error(), http.StatusBadRequest, w)
+			return
+		}
+		util.WriteError(r.Context(), err, w)
+		return
+	}
+
+	out := api.AgentNetworkModelDiscoveryResponse{Models: make([]api.AgentNetworkDiscoveredModel, 0, len(models))}
+	for _, m := range models {
+		entry := api.AgentNetworkDiscoveredModel{
+			Id:           m.ID,
+			PricingKnown: m.PricingKnown,
+			// Sent even when zero: the form prefills every discovered model as
+			// an editable row, and an unpriced one is shown at zero and flagged
+			// rather than left out.
+			InputPer1k:  m.InputPer1k,
+			OutputPer1k: m.OutputPer1k,
+			// Cache rates stay absent when unset, matching the catalog
+			// response — a zero would read as "free", not "not applicable".
+			CachedInputPer1k:   positiveRatePtr(m.CachedInputPer1k),
+			CacheReadPer1k:     positiveRatePtr(m.CacheReadPer1k),
+			CacheCreationPer1k: positiveRatePtr(m.CacheCreationPer1k),
+		}
+		if m.Label != "" {
+			label := m.Label
+			entry.Label = &label
+		}
+		out.Models = append(out.Models, entry)
+	}
+	util.WriteJSONObject(r.Context(), w, out)
+}
+
+// strValue reads an optional string field, treating absent as empty.
+func strValue(v *string) string {
+	if v == nil {
+		return ""
+	}
+	return strings.TrimSpace(*v)
+}
+
 // applyDefaultPricing overwrites the catalog response's model rates with
 // the LIVE default pricing table, which may differ from the compiled-in
 // catalog rates when the operator provides a defaults_llm_pricing.yaml.
diff --git a/management/internals/modules/agentnetwork/manager.go b/management/internals/modules/agentnetwork/manager.go
index 379672989..41789195e 100644
--- a/management/internals/modules/agentnetwork/manager.go
+++ b/management/internals/modules/agentnetwork/manager.go
@@ -13,6 +13,7 @@ import (
 	log "github.com/sirupsen/logrus"
 
 	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/labelgen"
+	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/modeldiscovery"
 	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
 	"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
 	"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey"
@@ -50,6 +51,7 @@ type Manager interface {
 	CreateProvider(ctx context.Context, userID string, provider *types.Provider) (*types.Provider, error)
 	UpdateProvider(ctx context.Context, userID string, provider *types.Provider) (*types.Provider, error)
 	DeleteProvider(ctx context.Context, accountID, userID, providerID string) error
+	DiscoverProviderModels(ctx context.Context, accountID, userID string, req modeldiscovery.Request, recordID string) ([]modeldiscovery.Model, error)
 
 	GetAllPolicies(ctx context.Context, accountID, userID string) ([]*types.Policy, error)
 	GetPolicy(ctx context.Context, accountID, userID, policyID string) (*types.Policy, error)
@@ -123,6 +125,15 @@ type managerImpl struct {
 	permissionsManager permissions.Manager
 	proxyController    proxy.Controller
 
+	// modelDiscovery queries vendors for the models a credential can reach.
+	// A field rather than a package call so tests can drive it without
+	// reaching the network.
+	//
+	// One instance serves every request for the process's lifetime, so its
+	// fields must stay read-only after construction: lazy initialisation
+	// inside Fetch or httpClient would race across request goroutines.
+	modelDiscovery *modeldiscovery.Client
+
 	// reconcileCache holds the last set of synthesised proxy mappings
 	// per account, each paired with the proxy that served it, so a change
 	// of serving proxy can be diffed without re-deriving it.
@@ -151,6 +162,7 @@ func NewManager(
 		accountManager:     accountManager,
 		permissionsManager: permissionsManager,
 		proxyController:    proxyController,
+		modelDiscovery:     &modeldiscovery.Client{},
 		reconcileCache:     make(map[string]map[string]syntheticMapping),
 		labelRng:           rand.New(rand.NewSource(time.Now().UnixNano())),
 	}
@@ -170,6 +182,38 @@ func (m *managerImpl) GetProvider(ctx context.Context, accountID, userID, provid
 	return m.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, accountID, providerID)
 }
 
+// DiscoverProviderModels asks the vendor which models a credential can reach.
+//
+// recordID, when set, names an existing provider whose stored credential and
+// upstream are used instead of the ones in req — so the dashboard can refresh
+// the list without ever holding the key.
+//
+// Gated on Create rather than Read: this spends the operator's credential
+// against a third party, which is not something a read-only role should be
+// able to make the server do. That one check also covers reading the stored
+// record — Create is strictly stronger than Read here, and the lookup is
+// scoped to accountID, so another account's record is never reachable.
+func (m *managerImpl) DiscoverProviderModels(ctx context.Context, accountID, userID string, req modeldiscovery.Request, recordID string) ([]modeldiscovery.Model, error) {
+	if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Create); err != nil {
+		return nil, err
+	}
+
+	if recordID != "" {
+		record, err := m.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, accountID, recordID)
+		if err != nil {
+			return nil, err
+		}
+		// The catalog id comes from the stored record too: letting the caller
+		// name a different one would run a provider's credential against
+		// whichever vendor endpoint they picked.
+		req.CatalogID = record.ProviderID
+		req.UpstreamURL = record.UpstreamURL
+		req.APIKey = record.APIKey
+	}
+
+	return m.modelDiscovery.Fetch(ctx, req)
+}
+
 // CreateProvider persists a new provider for the account. Providers have no
 // settings side effects: the account's endpoint is bootstrapped separately and
 // explicitly via CreateSettings, and every provider in the account routes
@@ -1017,6 +1061,10 @@ func (*mockManager) GetAllProviders(_ context.Context, _, _ string) ([]*types.Pr
 	return []*types.Provider{}, nil
 }
 
+func (*mockManager) DiscoverProviderModels(_ context.Context, _, _ string, _ modeldiscovery.Request, _ string) ([]modeldiscovery.Model, error) {
+	return nil, nil
+}
+
 func (*mockManager) GetProvider(_ context.Context, _, _, _ string) (*types.Provider, error) {
 	return &types.Provider{}, nil
 }
diff --git a/management/internals/modules/agentnetwork/modeldiscovery/discovery.go b/management/internals/modules/agentnetwork/modeldiscovery/discovery.go
new file mode 100644
index 000000000..253cc63b3
--- /dev/null
+++ b/management/internals/modules/agentnetwork/modeldiscovery/discovery.go
@@ -0,0 +1,469 @@
+// Package modeldiscovery asks a vendor which models an operator's own
+// credential can reach, so the provider form can offer a live list instead of
+// only the catalog's hand-curated one.
+//
+// The catalog cannot know two things that matter. It goes stale — its entries
+// carry comments tracking which models a vendor retired on which date — and it
+// cannot see an account: which OpenAI models an org is entitled to, which
+// Bedrock inference profiles a given account and region hold, which Vertex
+// models a project has enabled. Those are exactly the facts an operator needs
+// when filling in a provider record, and only the vendor has them.
+//
+// The vendor is authoritative for the model ID. The catalog remains
+// authoritative for pricing, and a discovered model the catalog cannot price
+// is reported as such rather than silently registered at a rate of zero.
+package modeldiscovery
+
+import (
+	"context"
+	"encoding/base64"
+	"errors"
+	"fmt"
+	"io"
+	"net"
+	"net/http"
+	"net/netip"
+	"net/url"
+	"strings"
+	"syscall"
+	"time"
+
+	"golang.org/x/oauth2/google"
+
+	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
+	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing"
+)
+
+const (
+	// fetchTimeout bounds one vendor call end to end. A listing is a single
+	// small GET; anything slower is a vendor problem and the operator is
+	// waiting on a form.
+	fetchTimeout = 8 * time.Second
+	// maxListingBytes bounds the response we will buffer. The largest real
+	// listing observed is Bedrock's foundation-model catalogue at ~70KB, so
+	// this is a wide margin over anything legitimate.
+	maxListingBytes = 2 << 20
+	// gcpScope matches the scope llm_router mints Vertex tokens under, so a
+	// credential that works for discovery works for inference too.
+	gcpScope = "https://www.googleapis.com/auth/cloud-platform"
+	// vertexKeyfilePrefix marks an api_key that is a base64 service-account
+	// JSON key rather than a bearer token.
+	vertexKeyfilePrefix = "keyfile::"
+)
+
+// ErrNoDiscovery is returned for a catalog entry that declares no listing
+// endpoint. Gateways vary too much to have one, and the caller should fall
+// back to the catalog list plus free-text entry rather than treating this as
+// a failure.
+var ErrNoDiscovery = errors.New("provider has no model-discovery endpoint")
+
+// ErrInvalidRequest marks a discovery failure caused by the caller's own input
+// rather than by the vendor or by this server. Every one of these is reachable
+// from a well-formed request carrying a bad field value, so the handler owes
+// the caller a 400 — a 500 would both misinform them and bury real server
+// faults in the error rate.
+var ErrInvalidRequest = errors.New("invalid discovery request")
+
+// Model is one discovered model.
+type Model struct {
+	// ID is the identifier to register on the provider record, in the form the
+	// vendor issues it. For Bedrock that is the region-prefixed inference
+	// profile id, which is the only form AWS accepts at invoke time.
+	ID string
+	// Label is the vendor's display name where it supplies one.
+	Label string
+	// PricingKnown reports whether the shipped pricing table can price this
+	// model. False means the operator must set rates, or the request would
+	// meter at zero.
+	PricingKnown bool
+	// The rates below are the defaults for this model, taken from the same
+	// table the proxy bills with, so the form prefills exactly what a request
+	// would cost. All zero when PricingKnown is false — an unpriced model is
+	// offered at zero and flagged, rather than withheld: the vendor says the
+	// credential can reach it, and refusing to show it would hide a model the
+	// operator genuinely has.
+	InputPer1k         float64
+	OutputPer1k        float64
+	CachedInputPer1k   float64
+	CacheReadPer1k     float64
+	CacheCreationPer1k float64
+}
+
+// Request identifies which vendor to ask and with what credential.
+type Request struct {
+	// CatalogID selects the catalog entry, which supplies the endpoint, the
+	// auth header and the response shape. The caller never supplies those.
+	CatalogID string
+	// UpstreamURL is the provider record's configured upstream. It is used
+	// only when the catalog entry declares no discovery host of its own.
+	UpstreamURL string
+	// Region substitutes the catalog host's  placeholder.
+	Region string
+	// APIKey is the operator's credential, exactly as stored on the record.
+	APIKey string
+}
+
+// Client fetches model listings. The zero value is usable; Resolver and
+// HTTPClient exist so tests can drive it against a local server.
+type Client struct {
+	HTTPClient *http.Client
+	// Resolver looks up the host for the SSRF check. Nil uses the default.
+	Resolver *net.Resolver
+	// AllowPrivateHosts disables the private-address guard. Only tests set it:
+	// their server is on loopback, which is precisely what the guard blocks.
+	AllowPrivateHosts bool
+}
+
+// Fetch returns the models the credential can reach.
+func (c *Client) Fetch(ctx context.Context, req Request) ([]Model, error) {
+	entry, ok := catalog.Lookup(req.CatalogID)
+	if !ok {
+		return nil, fmt.Errorf("%w: unknown catalog provider %q", ErrInvalidRequest, req.CatalogID)
+	}
+	if entry.Discovery == nil {
+		return nil, ErrNoDiscovery
+	}
+
+	endpoint, err := c.discoveryURL(entry, req)
+	if err != nil {
+		return nil, err
+	}
+
+	ctx, cancel := context.WithTimeout(ctx, fetchTimeout)
+	defer cancel()
+
+	httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+	if err != nil {
+		return nil, fmt.Errorf("build discovery request: %w", err)
+	}
+	if err := applyAuth(httpReq, entry, req.APIKey); err != nil {
+		return nil, err
+	}
+	for name, value := range entry.Discovery.Headers {
+		httpReq.Header.Set(name, value)
+	}
+	httpReq.Header.Set("Accept", "application/json")
+
+	resp, err := c.httpClient().Do(httpReq)
+	if err != nil {
+		return nil, fmt.Errorf("reach %s: %w", entry.Name, err)
+	}
+	defer func() { _ = resp.Body.Close() }()
+
+	body, err := io.ReadAll(io.LimitReader(resp.Body, maxListingBytes))
+	if err != nil {
+		return nil, fmt.Errorf("read %s listing: %w", entry.Name, err)
+	}
+	if resp.StatusCode != http.StatusOK {
+		// Surface the vendor's own status. An operator whose key lacks a scope
+		// needs to see 403 rather than a generic failure.
+		return nil, fmt.Errorf("%s returned %d for its model listing", entry.Name, resp.StatusCode)
+	}
+
+	ids, err := parseListing(entry.Discovery.Shape, body)
+	if err != nil {
+		return nil, err
+	}
+	return decorate(entry, ids), nil
+}
+
+// discoveryURL builds the listing URL and refuses one that does not point at a
+// public host.
+//
+// The path, query and (for Bedrock) the host all come from the catalog rather
+// than from the caller, so the only operator-controlled part is the host of an
+// entry whose listing lives on its own upstream. That still has to be checked:
+// management holds credentials for every provider, and an upstream pointed at
+// an internal address would turn this endpoint into a probe of the management
+// server's own network.
+func (c *Client) discoveryURL(entry catalog.Provider, req Request) (string, error) {
+	host := entry.Discovery.Host
+	if host == "" {
+		parsed, err := url.Parse(strings.TrimSpace(req.UpstreamURL))
+		if err != nil || parsed.Host == "" {
+			return "", fmt.Errorf("%w: provider upstream %q is not a usable URL", ErrInvalidRequest, req.UpstreamURL)
+		}
+		host = parsed.Host
+	}
+	if strings.Contains(host, catalog.RegionPlaceholder) {
+		region := strings.TrimSpace(req.Region)
+		if region == "" {
+			// A provider record carries no region field: the region lives
+			// inside the upstream host the operator already configured, so
+			// read it back out rather than asking them for it twice.
+			region = RegionFromUpstream(entry, req.UpstreamURL)
+		}
+		if region == "" {
+			return "", fmt.Errorf("%w: %s discovery needs a region, and none could be read from the provider upstream",
+				ErrInvalidRequest, entry.Name)
+		}
+		host = strings.ReplaceAll(host, catalog.RegionPlaceholder, region)
+	}
+
+	target := &url.URL{Scheme: "https", Host: host, Path: entry.Discovery.Path, RawQuery: entry.Discovery.Query}
+	if err := c.checkPublicHost(target.Hostname()); err != nil {
+		return "", err
+	}
+	return target.String(), nil
+}
+
+// RegionFromUpstream recovers the region an operator embedded in the provider
+// upstream, by matching it against the catalog's own host template. Bedrock's
+// template is "bedrock-runtime..amazonaws.com" and Vertex's is
+// "-aiplatform.googleapis.com", so the region is whatever sits between
+// the fixed halves. Returns empty when the upstream does not match the
+// template, which is the case for a custom or proxied endpoint.
+func RegionFromUpstream(entry catalog.Provider, upstreamURL string) string {
+	prefix, suffix, found := strings.Cut(entry.DefaultHost, catalog.RegionPlaceholder)
+	if !found {
+		return ""
+	}
+	parsed, err := url.Parse(strings.TrimSpace(upstreamURL))
+	if err != nil {
+		return ""
+	}
+	host := parsed.Hostname()
+	if host == "" {
+		// A bare host with no scheme parses as a path, not a host.
+		host = strings.TrimSpace(upstreamURL)
+	}
+	// The two halves must not overlap. "bedrock-runtime.amazonaws.com" carries
+	// both of Bedrock's — it is the regionless endpoint — and satisfies both
+	// checks above while leaving nothing between them, so slicing it would
+	// panic on an inverted range rather than report "no region here".
+	if !strings.HasPrefix(host, prefix) || !strings.HasSuffix(host, suffix) ||
+		len(host) < len(prefix)+len(suffix) {
+		return ""
+	}
+	region := host[len(prefix) : len(host)-len(suffix)]
+	if region == "" || strings.Contains(region, ".") {
+		return ""
+	}
+	return region
+}
+
+// checkPublicHost refuses hosts that resolve to an address the management
+// server should never be asked to reach on an operator's behalf.
+func (c *Client) checkPublicHost(host string) error {
+	if c.AllowPrivateHosts {
+		return nil
+	}
+	if host == "" {
+		return errors.New("discovery host is empty")
+	}
+	resolver := c.Resolver
+	if resolver == nil {
+		resolver = net.DefaultResolver
+	}
+	ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout)
+	defer cancel()
+
+	addrs, err := resolver.LookupNetIP(ctx, "ip", host)
+	if err != nil {
+		return fmt.Errorf("resolve discovery host %q: %w", host, err)
+	}
+	// Every address must be public: a name that resolves to one public and one
+	// loopback address is still a way to reach loopback.
+	for _, addr := range addrs {
+		if !isPublic(addr) {
+			return fmt.Errorf("%w: discovery host %q resolves to a non-public address", ErrInvalidRequest, host)
+		}
+	}
+	return nil
+}
+
+// isPublic reports whether an address is one we are willing to dial.
+func isPublic(addr netip.Addr) bool {
+	addr = addr.Unmap()
+	switch {
+	case !addr.IsValid(),
+		addr.IsLoopback(),
+		addr.IsPrivate(),
+		addr.IsLinkLocalUnicast(),
+		addr.IsLinkLocalMulticast(),
+		addr.IsInterfaceLocalMulticast(),
+		addr.IsMulticast(),
+		addr.IsUnspecified():
+		return false
+	}
+	// 100.64.0.0/10 (carrier NAT) is where NetBird's own overlay addresses
+	// live, so it is emphatically not somewhere to send a provider credential.
+	if addr.Is4() {
+		b := addr.As4()
+		if b[0] == 100 && b[1] >= 64 && b[1] <= 127 {
+			return false
+		}
+	}
+	return true
+}
+
+// applyAuth sets the credential header the catalog entry declares. A Vertex
+// service-account key is exchanged for an OAuth token first, the same way the
+// proxy does at request time.
+func applyAuth(req *http.Request, entry catalog.Provider, apiKey string) error {
+	key := strings.TrimSpace(apiKey)
+	if key == "" {
+		return fmt.Errorf("%w: %s discovery needs an API key", ErrInvalidRequest, entry.Name)
+	}
+	if rest, ok := strings.CutPrefix(key, vertexKeyfilePrefix); ok {
+		token, err := mintGCPToken(req.Context(), rest)
+		if err != nil {
+			return err
+		}
+		key = token
+	}
+	name := entry.AuthHeaderName
+	if name == "" {
+		name = "Authorization"
+	}
+	template := entry.AuthHeaderTemplate
+	if template == "" {
+		template = "${API_KEY}"
+	}
+	req.Header.Set(name, strings.ReplaceAll(template, "${API_KEY}", key))
+	return nil
+}
+
+// mintGCPToken exchanges a base64 service-account key for an access token.
+func mintGCPToken(ctx context.Context, saKeyB64 string) (string, error) {
+	jsonKey, err := base64.StdEncoding.DecodeString(strings.TrimSpace(saKeyB64))
+	if err != nil {
+		return "", fmt.Errorf("decode service-account key: %w", err)
+	}
+	conf, err := google.JWTConfigFromJSON(jsonKey, gcpScope)
+	if err != nil {
+		return "", fmt.Errorf("parse service-account key: %w", err)
+	}
+	tok, err := conf.TokenSource(ctx).Token()
+	if err != nil {
+		return "", fmt.Errorf("mint gcp token: %w", err)
+	}
+	return tok.AccessToken, nil
+}
+
+// decorate turns raw vendor ids into the models the caller renders, attaching
+// the rates the request would actually be billed at.
+//
+// Rates come from the live default pricing table rather than the compiled-in
+// catalog, because that is the table the synthesiser ships to the proxy: an
+// operator running a defaults_llm_pricing.yaml would otherwise be shown one
+// price in the form and charged another. It is also the same lookup the catalog
+// endpoint prefills from, so a model reached by either route prices identically.
+func decorate(entry catalog.Provider, ids []listedModel) []Model {
+	out := make([]Model, 0, len(ids))
+	seen := make(map[string]struct{}, len(ids))
+	for _, listed := range ids {
+		if listed.id == "" {
+			continue
+		}
+		if _, dup := seen[listed.id]; dup {
+			continue
+		}
+		seen[listed.id] = struct{}{}
+
+		// The table keys pricing by the normalised id while the vendor issues
+		// the wire form, so normalise before looking it up — otherwise every
+		// Bedrock profile would report unpriced.
+		model := Model{ID: listed.id, Label: listed.label}
+		if rate, known := pricing.LookupDefault(entry.PricingSurfaces, normalizeForPricing(entry.ID, listed.id)); known {
+			model.PricingKnown = true
+			model.InputPer1k = rate.InputPer1k
+			model.OutputPer1k = rate.OutputPer1k
+			model.CachedInputPer1k = rate.CachedInputPer1k
+			model.CacheReadPer1k = rate.CacheReadPer1k
+			model.CacheCreationPer1k = rate.CacheCreationPer1k
+		}
+		out = append(out, model)
+	}
+	return out
+}
+
+// refuseRedirect is the redirect policy every discovery request runs under. A
+// redirect is a way to move the request to a host checkPublicHost never saw,
+// so none are followed.
+func refuseRedirect(*http.Request, []*http.Request) error {
+	return http.ErrUseLastResponse
+}
+
+func (c *Client) httpClient() *http.Client {
+	if c.HTTPClient != nil {
+		if c.HTTPClient.CheckRedirect != nil {
+			return c.HTTPClient
+		}
+		// An injected client that states no policy still gets ours: the
+		// no-redirect guarantee should not depend on the caller remembering it.
+		//
+		// Copied rather than assigned into: one Client is shared by every
+		// request for the process's lifetime, so writing to its fields here
+		// would race across request goroutines. The copy shares the Transport,
+		// which is safe for concurrent use by design.
+		clone := *c.HTTPClient
+		clone.CheckRedirect = refuseRedirect
+		return &clone
+	}
+	transport := guardedTransport
+	if c.AllowPrivateHosts {
+		transport = http.DefaultTransport
+	}
+	return &http.Client{
+		Timeout:       fetchTimeout,
+		Transport:     transport,
+		CheckRedirect: refuseRedirect,
+	}
+}
+
+// guardedTransport dials only addresses isPublic accepts.
+//
+// checkPublicHost resolves the host itself, and the transport then resolves it
+// again when it dials — two lookups of a name whose owner chooses the answers.
+// A record that returns a public address to the first and 127.0.0.1 to the
+// second passes the guard and reaches loopback anyway, which is the whole of
+// DNS rebinding. Re-checking at the socket closes that window: whatever the
+// second lookup returned is what Control is handed, and an address the guard
+// refuses never gets connected.
+//
+// Shared package-wide rather than built per Fetch so connections and their
+// pool survive between calls; the guard holds no state.
+var guardedTransport = newGuardedTransport()
+
+func newGuardedTransport() http.RoundTripper {
+	base, ok := http.DefaultTransport.(*http.Transport)
+	if !ok {
+		// Something replaced the default transport. Fall back to it rather
+		// than dropping its behaviour, and rely on checkPublicHost alone.
+		return http.DefaultTransport
+	}
+	// Cloned so proxy settings, TLS defaults and timeouts come from the
+	// standard transport rather than being restated here.
+	transport := base.Clone()
+	dialer := &net.Dialer{
+		Timeout:   fetchTimeout,
+		KeepAlive: 30 * time.Second,
+		Control: func(_, address string, _ syscall.RawConn) error {
+			return guardDialAddress(address)
+		},
+	}
+	transport.DialContext = dialer.DialContext
+	return transport
+}
+
+// guardDialAddress refuses a resolved socket address the discovery client has
+// no business connecting to. Control hands it over post-resolution and
+// pre-connect, once per address the dialer tries, so a name with several A
+// records is checked at each one.
+func guardDialAddress(address string) error {
+	host, _, err := net.SplitHostPort(address)
+	if err != nil {
+		return fmt.Errorf("discovery dial address %q is unreadable", address)
+	}
+	addr, err := netip.ParseAddr(host)
+	if err != nil {
+		// Control is documented to receive a resolved address; anything else
+		// is a state we cannot vet, so it does not get dialled.
+		return fmt.Errorf("discovery dial address %q is not an IP", host)
+	}
+	if !isPublic(addr) {
+		return fmt.Errorf("discovery refused to dial non-public address %s", addr)
+	}
+	return nil
+}
diff --git a/management/internals/modules/agentnetwork/modeldiscovery/discovery_test.go b/management/internals/modules/agentnetwork/modeldiscovery/discovery_test.go
new file mode 100644
index 000000000..133bd5148
--- /dev/null
+++ b/management/internals/modules/agentnetwork/modeldiscovery/discovery_test.go
@@ -0,0 +1,532 @@
+package modeldiscovery
+
+import (
+	"context"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"net/netip"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
+	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing"
+)
+
+// stubTransport answers every request with one canned response and records the
+// request it was given, so a test can assert on the URL and headers the client
+// built without a network round trip.
+type stubTransport struct {
+	status int
+	body   string
+	got    *http.Request
+}
+
+func (s *stubTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+	s.got = req
+	status := s.status
+	if status == 0 {
+		status = http.StatusOK
+	}
+	return &http.Response{
+		StatusCode: status,
+		Body:       io.NopCloser(strings.NewReader(s.body)),
+		Header:     http.Header{"Content-Type": []string{"application/json"}},
+		Request:    req,
+	}, nil
+}
+
+// newStubClient returns a client that never leaves the process. The host guard
+// is disabled because it would otherwise resolve the vendor's real name, which
+// would make these tests depend on DNS.
+func newStubClient(status int, body string) (*Client, *stubTransport) {
+	tr := &stubTransport{status: status, body: body}
+	return &Client{
+		HTTPClient:        &http.Client{Transport: tr},
+		AllowPrivateHosts: true,
+	}, tr
+}
+
+// The payloads below are trimmed from what the vendors actually returned in
+// the discovery e2e, rather than invented, so a parser that only works against
+// an idealised shape fails here.
+
+const openAIListing = `{"object":"list","data":[
+  {"id":"gpt-4o-mini","object":"model","created":1721172741,"owned_by":"system"},
+  {"id":"gpt-4o","object":"model","created":1715367049,"owned_by":"system"}
+]}`
+
+const anthropicListing = `{"data":[
+  {"type":"model","id":"claude-haiku-4-5-20251001","display_name":"Claude Haiku 4.5"},
+  {"type":"model","id":"claude-sonnet-4-6","display_name":"Claude Sonnet 4.6"}
+],"has_more":false}`
+
+const bedrockListing = `{"inferenceProfileSummaries":[
+  {"inferenceProfileId":"eu.anthropic.claude-haiku-4-5-20251001-v1:0",
+   "inferenceProfileName":"EU Anthropic Claude Haiku 4.5","status":"ACTIVE","type":"SYSTEM_DEFINED"},
+  {"inferenceProfileId":"global.cohere.embed-v4:0",
+   "inferenceProfileName":"Global Cohere Embed v4","status":"ACTIVE","type":"SYSTEM_DEFINED"},
+  {"inferenceProfileId":"eu.meta.llama3-2-1b-instruct-v1:0",
+   "inferenceProfileName":"EU Meta Llama 3.2 1B","status":"INACTIVE","type":"SYSTEM_DEFINED"}
+]}`
+
+const vertexListing = `{"publisherModels":[
+  {"name":"publishers/anthropic/models/claude-3-opus","versionId":"20240229","launchStage":"GA"},
+  {"name":"publishers/anthropic/models/claude-sonnet-4-5","versionId":"20250929","launchStage":"GA"}
+]}`
+
+func TestFetchOpenAIListing(t *testing.T) {
+	cl, tr := newStubClient(http.StatusOK, openAIListing)
+
+	models, err := cl.Fetch(context.Background(), Request{
+		CatalogID:   "openai_api",
+		UpstreamURL: "https://api.openai.com",
+		APIKey:      "sk-test",
+	})
+	require.NoError(t, err)
+
+	assert.Equal(t, "https://api.openai.com/v1/models", tr.got.URL.String())
+	assert.Equal(t, "Bearer sk-test", tr.got.Header.Get("Authorization"),
+		"the credential must be injected through the catalog's auth template")
+	assert.Equal(t, []string{"gpt-4o-mini", "gpt-4o"}, ids(models))
+	for _, m := range models {
+		assert.True(t, m.PricingKnown, "both models are in the shipped catalog: %s", m.ID)
+	}
+}
+
+func TestFetchAnthropicSendsTheVersionHeader(t *testing.T) {
+	cl, tr := newStubClient(http.StatusOK, anthropicListing)
+
+	models, err := cl.Fetch(context.Background(), Request{
+		CatalogID:   "anthropic_api",
+		UpstreamURL: "https://api.anthropic.com",
+		APIKey:      "sk-ant-test",
+	})
+	require.NoError(t, err)
+
+	// Anthropic rejects a request without the version header, so a listing
+	// that reached us at all proves it was sent — but assert it, because the
+	// failure mode otherwise only shows up against the live API.
+	assert.Equal(t, "2023-06-01", tr.got.Header.Get("anthropic-version"))
+	assert.Equal(t, "sk-ant-test", tr.got.Header.Get("x-api-key"),
+		"Anthropic takes a bare key under its own header, not a Bearer token")
+	assert.Equal(t, "limit=1000", tr.got.URL.RawQuery)
+
+	assert.Equal(t, []string{"claude-haiku-4-5-20251001", "claude-sonnet-4-6"}, ids(models))
+	assert.Equal(t, "Claude Haiku 4.5", models[0].Label)
+}
+
+func TestFetchBedrockUsesTheControlPlaneAndKeepsWireIDs(t *testing.T) {
+	cl, tr := newStubClient(http.StatusOK, bedrockListing)
+
+	models, err := cl.Fetch(context.Background(), Request{
+		CatalogID: "bedrock_api",
+		// The record's upstream is the RUNTIME host, which does not serve
+		// listings. The catalog's own discovery host must win over it.
+		UpstreamURL: "https://bedrock-runtime.eu-central-1.amazonaws.com",
+		Region:      "eu-central-1",
+		APIKey:      "aws-bearer",
+	})
+	require.NoError(t, err)
+
+	assert.Equal(t, "https://bedrock.eu-central-1.amazonaws.com/inference-profiles",
+		tr.got.URL.String(), "listings come from the control plane, not the runtime host")
+
+	// Region-prefixed ids verbatim: the prefix is what makes them invocable
+	// and it cannot be reconstructed — global.* alongside eu.* is exactly the
+	// case that defeats deriving it from the configured region.
+	assert.Equal(t, []string{
+		"eu.anthropic.claude-haiku-4-5-20251001-v1:0",
+		"global.cohere.embed-v4:0",
+	}, ids(models), "an INACTIVE profile must not be offered")
+
+	assert.True(t, models[0].PricingKnown,
+		"the catalog prices anthropic.claude-haiku-4-5, which this id normalises to")
+	assert.False(t, models[1].PricingKnown,
+		"cohere embed is not in the shipped Bedrock catalog, so the operator must price it")
+
+	// The rates travel with the model, so the form can prefill an editable row
+	// rather than making the operator look every price up by hand.
+	assert.Positive(t, models[0].InputPer1k, "a priced model must carry its input rate")
+	assert.Positive(t, models[0].OutputPer1k, "a priced model must carry its output rate")
+	// An unpriced model is offered at zero and flagged, not withheld: the
+	// vendor says the credential can reach it.
+	assert.Zero(t, models[1].InputPer1k)
+	assert.Zero(t, models[1].OutputPer1k)
+}
+
+// TestDiscoveredRatesMatchTheCatalogEndpoint pins the two prefill paths to one
+// table. The provider form fills a model row either from the catalog response
+// or from a discovery response, and an operator who switches between them must
+// not see the price change — both must equal what the proxy will bill.
+func TestDiscoveredRatesMatchTheCatalogEndpoint(t *testing.T) {
+	cl, _ := newStubClient(http.StatusOK, openAIListing)
+
+	models, err := cl.Fetch(context.Background(), Request{
+		CatalogID:   "openai_api",
+		UpstreamURL: "https://api.openai.com",
+		APIKey:      "sk-test",
+	})
+	require.NoError(t, err)
+	require.NotEmpty(t, models)
+
+	entry, ok := catalog.Lookup("openai_api")
+	require.True(t, ok)
+
+	for _, m := range models {
+		want, known := pricing.LookupDefault(entry.PricingSurfaces, m.ID)
+		require.True(t, known, "%s should be priced by the default table", m.ID)
+		assert.Equal(t, want.InputPer1k, m.InputPer1k, "input rate for %s", m.ID)
+		assert.Equal(t, want.OutputPer1k, m.OutputPer1k, "output rate for %s", m.ID)
+		assert.Equal(t, want.CachedInputPer1k, m.CachedInputPer1k, "cached-input rate for %s", m.ID)
+	}
+}
+
+func TestFetchVertexJoinsNameAndVersion(t *testing.T) {
+	cl, _ := newStubClient(http.StatusOK, vertexListing)
+
+	models, err := cl.Fetch(context.Background(), Request{
+		CatalogID:   "vertex_ai_api",
+		UpstreamURL: "https://us-east5-aiplatform.googleapis.com",
+		Region:      "us-east5",
+		APIKey:      "ya29.test-token",
+	})
+	require.NoError(t, err)
+
+	// Vertex addresses a model as "@" on rawPredict, and splits
+	// those across two fields in the listing.
+	assert.Equal(t, []string{"claude-3-opus@20240229", "claude-sonnet-4-5@20250929"}, ids(models))
+	assert.Equal(t, "claude-3-opus", models[0].Label)
+}
+
+func TestFetchSurfacesTheVendorStatus(t *testing.T) {
+	cl, _ := newStubClient(http.StatusForbidden, `{"error":{"message":"no access"}}`)
+
+	_, err := cl.Fetch(context.Background(), Request{
+		CatalogID:   "openai_api",
+		UpstreamURL: "https://api.openai.com",
+		APIKey:      "sk-test",
+	})
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "403",
+		"an operator whose key lacks access needs to see which status the vendor returned")
+}
+
+func TestFetchRejectsAProviderWithoutDiscovery(t *testing.T) {
+	cl, _ := newStubClient(http.StatusOK, openAIListing)
+
+	_, err := cl.Fetch(context.Background(), Request{
+		CatalogID:   "litellm_proxy",
+		UpstreamURL: "https://gateway.example.com",
+		APIKey:      "sk-test",
+	})
+	assert.ErrorIs(t, err, ErrNoDiscovery,
+		"a gateway with no listing endpoint must be distinguishable from a failure, so the caller can fall back")
+}
+
+func TestFetchRequiresACredential(t *testing.T) {
+	cl, _ := newStubClient(http.StatusOK, openAIListing)
+
+	_, err := cl.Fetch(context.Background(), Request{
+		CatalogID:   "openai_api",
+		UpstreamURL: "https://api.openai.com",
+	})
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "API key")
+}
+
+func TestDiscoveryURLNeedsARegionWhenTheHostTemplatesOne(t *testing.T) {
+	cl, _ := newStubClient(http.StatusOK, bedrockListing)
+
+	// An upstream that matches no catalog template — a proxy in front of
+	// Bedrock, say — leaves nothing to read the region from. Refusing beats
+	// guessing: an unsubstituted placeholder would dial a host that does not
+	// exist, and a guessed region would dial the wrong account's endpoint.
+	_, err := cl.Fetch(context.Background(), Request{
+		CatalogID:   "bedrock_api",
+		UpstreamURL: "https://bedrock.internal-proxy.example.com",
+		APIKey:      "aws-bearer",
+	})
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "region")
+}
+
+// TestHostGuardRejectsNonPublicAddresses is the SSRF guard. Management holds a
+// credential for every provider, so an upstream pointed at an internal address
+// would turn discovery into a way to probe — and hand a token to — the
+// management server's own network.
+func TestHostGuardRejectsNonPublicAddresses(t *testing.T) {
+	for _, tc := range []struct {
+		name string
+		addr string
+		want bool
+	}{
+		{"loopback v4", "127.0.0.1", false},
+		{"loopback v6", "::1", false},
+		{"private 10/8", "10.0.0.5", false},
+		{"private 172.16/12", "172.16.4.1", false},
+		{"private 192.168/16", "192.168.1.1", false},
+		{"link-local", "169.254.169.254", false}, // cloud metadata
+		{"unspecified", "0.0.0.0", false},
+		{"multicast", "224.0.0.1", false},
+		{"netbird overlay 100.64/10", "100.90.1.2", false},
+		{"v4-mapped loopback", "::ffff:127.0.0.1", false},
+		{"public v4", "1.1.1.1", true},
+		{"public v6", "2606:4700:4700::1111", true},
+		{"just outside CGNAT", "100.128.0.1", true},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			addr, err := netip.ParseAddr(tc.addr)
+			require.NoError(t, err)
+			assert.Equal(t, tc.want, isPublic(addr))
+		})
+	}
+}
+
+func TestHostGuardResolvesAndRejectsLocalhost(t *testing.T) {
+	cl := &Client{}
+	err := cl.checkPublicHost("localhost")
+	require.Error(t, err, "a name resolving to loopback must be refused, not just a literal address")
+	assert.Contains(t, err.Error(), "non-public")
+}
+
+// TestRedirectsAreNotFollowed covers a gap the other tests leave open: they all
+// inject an HTTPClient, which bypasses httpClient() and therefore the redirect
+// policy entirely. The policy is a security control — a 302 moves the request
+// to a host checkPublicHost never resolved — so it needs a test that goes
+// through the constructor the manager actually uses.
+func TestRedirectsAreNotFollowed(t *testing.T) {
+	var hits int
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		hits++
+		http.Redirect(w, r, "http://169.254.169.254/latest/meta-data/", http.StatusFound)
+	}))
+	t.Cleanup(srv.Close)
+
+	for name, cl := range map[string]*Client{
+		// The production shape: no injected client at all.
+		"default client": {AllowPrivateHosts: true},
+		// An injected client that states no policy must inherit ours rather
+		// than silently chasing the redirect.
+		"injected client with no policy": {
+			AllowPrivateHosts: true,
+			HTTPClient:        &http.Client{},
+		},
+	} {
+		t.Run(name, func(t *testing.T) {
+			hits = 0
+			req, err := http.NewRequest(http.MethodGet, srv.URL, nil)
+			require.NoError(t, err)
+
+			resp, err := cl.httpClient().Do(req)
+			require.NoError(t, err)
+			t.Cleanup(func() { _ = resp.Body.Close() })
+
+			assert.Equal(t, http.StatusFound, resp.StatusCode,
+				"the redirect must be surfaced, not followed to an unchecked host")
+			assert.Equal(t, 1, hits, "exactly one request must leave the client")
+		})
+	}
+}
+
+// TestInjectedClientKeepsItsOwnRedirectPolicy pins that the default above is a
+// default, not an override, and that supplying it does not mutate the caller's
+// client — one Client is shared across every request, so a write here would
+// race.
+func TestInjectedClientKeepsItsOwnRedirectPolicy(t *testing.T) {
+	own := func(*http.Request, []*http.Request) error { return nil }
+	injected := &http.Client{CheckRedirect: own}
+	cl := &Client{HTTPClient: injected}
+
+	assert.Same(t, injected, cl.httpClient(),
+		"a client that states a policy must be handed back untouched")
+
+	bare := &http.Client{}
+	cl = &Client{HTTPClient: bare}
+	require.NotSame(t, bare, cl.httpClient(), "the policy must be applied to a copy")
+	assert.Nil(t, bare.CheckRedirect, "the caller's client must not be written to")
+}
+
+// TestDialGuardRejectsRebindingToANonPublicAddress covers the window between
+// the two DNS lookups. checkPublicHost resolves the host, then the transport
+// resolves it again to dial; a name whose owner answers the first with a public
+// address and the second with 127.0.0.1 would otherwise pass the guard and
+// still reach loopback. The dial-time check sees whatever the second lookup
+// actually returned.
+func TestDialGuardRejectsRebindingToANonPublicAddress(t *testing.T) {
+	for _, tc := range []struct {
+		name    string
+		address string
+		wantErr string
+	}{
+		{"loopback", "127.0.0.1:443", "non-public"},
+		{"cloud metadata", "169.254.169.254:80", "non-public"},
+		{"rfc1918", "10.1.2.3:443", "non-public"},
+		{"netbird overlay", "100.90.1.2:443", "non-public"},
+		{"loopback v6", "[::1]:443", "non-public"},
+		{"unresolved name", "evil.example.com:443", "not an IP"},
+		{"no port", "1.1.1.1", "unreadable"},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			err := guardDialAddress(tc.address)
+			require.Error(t, err)
+			assert.Contains(t, err.Error(), tc.wantErr)
+		})
+	}
+
+	assert.NoError(t, guardDialAddress("1.1.1.1:443"), "a public address must still be dialled")
+	assert.NoError(t, guardDialAddress("[2606:4700:4700::1111]:443"))
+}
+
+// TestDialGuardIsInstalledOnTheDefaultClient pins the wiring rather than the
+// guard: a correct guard nothing calls protects nothing.
+func TestDialGuardIsInstalledOnTheDefaultClient(t *testing.T) {
+	cl := &Client{}
+	transport, ok := cl.httpClient().Transport.(*http.Transport)
+	require.True(t, ok, "the default discovery client must carry the guarded transport")
+	require.NotNil(t, transport.DialContext, "the guarded transport must dial through the guard")
+
+	_, err := transport.DialContext(context.Background(), "tcp", "127.0.0.1:9")
+	require.Error(t, err, "the guard must refuse loopback even when the caller dials it directly")
+	assert.Contains(t, err.Error(), "non-public")
+
+	// Tests point the client at a loopback server on purpose, so the opt-out
+	// has to reach the dialer too.
+	relaxed := &Client{AllowPrivateHosts: true}
+	assert.Equal(t, http.DefaultTransport, relaxed.httpClient().Transport)
+}
+
+// TestCallerInputFailuresAreMarkedInvalid keeps the handler's 400 mapping
+// honest: it branches on this sentinel, so an unmarked caller-input failure
+// silently becomes a 500.
+func TestCallerInputFailuresAreMarkedInvalid(t *testing.T) {
+	for _, tc := range []struct {
+		name string
+		req  Request
+	}{
+		{"unknown provider", Request{CatalogID: "not_a_provider", APIKey: "k"}},
+		{"unusable upstream", Request{CatalogID: "openai_api", UpstreamURL: "://", APIKey: "k"}},
+		{"missing api key", Request{CatalogID: "openai_api", UpstreamURL: "https://api.openai.com"}},
+		{"no region to read", Request{
+			CatalogID:   "bedrock_api",
+			UpstreamURL: "https://bedrock-runtime.amazonaws.com",
+			APIKey:      "aws-bearer",
+		}},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			cl, _ := newStubClient(http.StatusOK, openAIListing)
+			_, err := cl.Fetch(context.Background(), tc.req)
+			require.Error(t, err)
+			assert.ErrorIs(t, err, ErrInvalidRequest)
+		})
+	}
+}
+
+// TestEveryDiscoveryEntryHasAParser keeps the catalog and the parser table from
+// drifting: adding a Discovery block with a shape nothing parses would fail
+// only at runtime, in front of an operator.
+func TestEveryDiscoveryEntryHasAParser(t *testing.T) {
+	for _, entry := range catalog.All() {
+		if entry.Discovery == nil {
+			continue
+		}
+		t.Run(entry.ID, func(t *testing.T) {
+			assert.NotEmpty(t, entry.Discovery.Path, "a discovery entry needs a path")
+			_, err := parseListing(entry.Discovery.Shape, []byte(`{}`))
+			assert.NoError(t, err, "shape %q has no parser", entry.Discovery.Shape)
+		})
+	}
+}
+
+func ids(models []Model) []string {
+	out := make([]string, 0, len(models))
+	for _, m := range models {
+		out = append(out, m.ID)
+	}
+	return out
+}
+
+// TestRegionIsReadBackFromTheUpstream covers the reason the API takes no
+// region field: a provider record has none, and the operator already encoded
+// it in the upstream host when they configured inference.
+func TestRegionIsReadBackFromTheUpstream(t *testing.T) {
+	cl, tr := newStubClient(http.StatusOK, bedrockListing)
+
+	_, err := cl.Fetch(context.Background(), Request{
+		CatalogID:   "bedrock_api",
+		UpstreamURL: "https://bedrock-runtime.us-west-2.amazonaws.com",
+		APIKey:      "aws-bearer",
+	})
+	require.NoError(t, err)
+	assert.Equal(t, "bedrock.us-west-2.amazonaws.com", tr.got.URL.Host)
+}
+
+func TestRegionFromUpstream(t *testing.T) {
+	bedrock, ok := catalog.Lookup("bedrock_api")
+	require.True(t, ok)
+	vertex, ok := catalog.Lookup("vertex_ai_api")
+	require.True(t, ok)
+
+	for _, tc := range []struct {
+		name     string
+		entry    catalog.Provider
+		upstream string
+		want     string
+	}{
+		{"bedrock runtime host", bedrock, "https://bedrock-runtime.eu-central-1.amazonaws.com", "eu-central-1"},
+		{"bedrock without scheme", bedrock, "bedrock-runtime.ap-south-1.amazonaws.com", "ap-south-1"},
+		{"vertex regional host", vertex, "https://us-east5-aiplatform.googleapis.com", "us-east5"},
+		// A proxied or self-hosted upstream matches no template, and guessing
+		// a region from it would build a URL pointing somewhere arbitrary.
+		{"unrelated upstream", bedrock, "https://llm.internal.example.com", ""},
+		{"vertex global host has no region segment", vertex, "https://aiplatform.googleapis.com", ""},
+		// Bedrock's regionless endpoint carries both halves of the template at
+		// once, with nothing between them. It has to read as "no region here"
+		// rather than as an inverted slice range.
+		{"bedrock regionless endpoint", bedrock, "https://bedrock-runtime.amazonaws.com", ""},
+		{"bedrock regionless without scheme", bedrock, "bedrock-runtime.amazonaws.com", ""},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			assert.Equal(t, tc.want, RegionFromUpstream(tc.entry, tc.upstream))
+		})
+	}
+}
+
+// bedrockGeoListing carries profiles from geographies the original prefix list
+// did not name. Every one reduces to a catalog key, so every one must arrive
+// priced — an unstripped geography is what made a real account's listing come
+// back almost entirely at zero.
+const bedrockGeoListing = `{"inferenceProfileSummaries":[
+  {"inferenceProfileId":"jp.anthropic.claude-sonnet-5-20260514-v1:0",
+   "inferenceProfileName":"JP Anthropic Claude Sonnet 5","status":"ACTIVE","type":"SYSTEM_DEFINED"},
+  {"inferenceProfileId":"au.anthropic.claude-haiku-4-5-20251001-v1:0",
+   "inferenceProfileName":"AU Anthropic Claude Haiku 4.5","status":"ACTIVE","type":"SYSTEM_DEFINED"},
+  {"inferenceProfileId":"us-gov.anthropic.claude-sonnet-5-20260514-v1:0",
+   "inferenceProfileName":"GovCloud Anthropic Claude Sonnet 5","status":"ACTIVE","type":"SYSTEM_DEFINED"}
+]}`
+
+func TestBedrockProfilesFromAnyGeographyArrivePriced(t *testing.T) {
+	cl, _ := newStubClient(http.StatusOK, bedrockGeoListing)
+
+	models, err := cl.Fetch(context.Background(), Request{
+		CatalogID:   "bedrock_api",
+		UpstreamURL: "https://bedrock-runtime.eu-central-1.amazonaws.com",
+		APIKey:      "aws-token",
+	})
+	require.NoError(t, err)
+	require.Len(t, models, 3)
+
+	for _, m := range models {
+		assert.True(t, m.PricingKnown, "%s must resolve to a catalog rate", m.ID)
+		assert.Greater(t, m.InputPer1k, 0.0, "input rate for %s", m.ID)
+		assert.Greater(t, m.OutputPer1k, 0.0, "output rate for %s", m.ID)
+		assert.Greater(t, m.CacheReadPer1k, 0.0, "cache-read rate for %s", m.ID)
+	}
+
+	// The wire id is preserved whatever the pricing key reduced to: it is the
+	// only form that works at invoke time.
+	assert.Equal(t, "jp.anthropic.claude-sonnet-5-20260514-v1:0", models[0].ID)
+}
diff --git a/management/internals/modules/agentnetwork/modeldiscovery/parse.go b/management/internals/modules/agentnetwork/modeldiscovery/parse.go
new file mode 100644
index 000000000..83048cb8a
--- /dev/null
+++ b/management/internals/modules/agentnetwork/modeldiscovery/parse.go
@@ -0,0 +1,134 @@
+package modeldiscovery
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+
+	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
+	sharedllm "github.com/netbirdio/netbird/shared/llm"
+)
+
+// listedModel is one entry lifted out of a vendor listing before the catalog
+// is consulted about it.
+type listedModel struct {
+	id    string
+	label string
+}
+
+// parseListing extracts model ids from a vendor listing. Each vendor invented
+// its own envelope, and the shape is declared by the catalog rather than
+// sniffed, so a vendor that changes shape fails loudly instead of silently
+// returning nothing.
+func parseListing(shape catalog.ListingShape, body []byte) ([]listedModel, error) {
+	switch shape {
+	case catalog.ShapeOpenAIData:
+		return parseOpenAIData(body)
+	case catalog.ShapeBedrockInferenceProfiles:
+		return parseBedrockInferenceProfiles(body)
+	case catalog.ShapeVertexPublisherModels:
+		return parseVertexPublisherModels(body)
+	default:
+		return nil, fmt.Errorf("no parser for listing shape %q", shape)
+	}
+}
+
+// parseOpenAIData reads {"data":[{"id":…}]}, which OpenAI defined and
+// Anthropic adopted. Anthropic additionally supplies display_name.
+func parseOpenAIData(body []byte) ([]listedModel, error) {
+	var doc struct {
+		Data []struct {
+			ID          string `json:"id"`
+			DisplayName string `json:"display_name"`
+		} `json:"data"`
+	}
+	if err := json.Unmarshal(body, &doc); err != nil {
+		return nil, fmt.Errorf("decode model listing: %w", err)
+	}
+	out := make([]listedModel, 0, len(doc.Data))
+	for _, entry := range doc.Data {
+		out = append(out, listedModel{id: entry.ID, label: entry.DisplayName})
+	}
+	return out, nil
+}
+
+// parseBedrockInferenceProfiles reads
+// {"inferenceProfileSummaries":[{"inferenceProfileId":…}]}.
+//
+// The profile id is taken verbatim because its region prefix (eu., us.,
+// global.) is what makes it invocable, and it is not derivable from the
+// configured region — an account in one region legitimately holds global.*
+// profiles alongside its regional ones.
+//
+// Only ACTIVE profiles are offered: AWS reports others, and registering one
+// would produce a model that routes inside NetBird and fails at AWS.
+func parseBedrockInferenceProfiles(body []byte) ([]listedModel, error) {
+	var doc struct {
+		Summaries []struct {
+			ID     string `json:"inferenceProfileId"`
+			Name   string `json:"inferenceProfileName"`
+			Status string `json:"status"`
+		} `json:"inferenceProfileSummaries"`
+	}
+	if err := json.Unmarshal(body, &doc); err != nil {
+		return nil, fmt.Errorf("decode inference-profile listing: %w", err)
+	}
+	out := make([]listedModel, 0, len(doc.Summaries))
+	for _, entry := range doc.Summaries {
+		if entry.Status != "" && !strings.EqualFold(entry.Status, "ACTIVE") {
+			continue
+		}
+		out = append(out, listedModel{id: entry.ID, label: entry.Name})
+	}
+	return out, nil
+}
+
+// parseVertexPublisherModels reads {"publisherModels":[{"name":…}]}, where
+// name is a resource path ("publishers/anthropic/models/claude-3-opus") and
+// the version lives in a separate field.
+//
+// Vertex addresses a model as "@" on the rawPredict path, so the
+// two are joined here: reporting the bare name would hand the operator an id
+// that looks usable and is not.
+func parseVertexPublisherModels(body []byte) ([]listedModel, error) {
+	var doc struct {
+		Models []struct {
+			Name      string `json:"name"`
+			VersionID string `json:"versionId"`
+		} `json:"publisherModels"`
+	}
+	if err := json.Unmarshal(body, &doc); err != nil {
+		return nil, fmt.Errorf("decode publisher-model listing: %w", err)
+	}
+	out := make([]listedModel, 0, len(doc.Models))
+	for _, entry := range doc.Models {
+		id := entry.Name
+		if slash := strings.LastIndex(id, "/"); slash >= 0 {
+			id = id[slash+1:]
+		}
+		if id == "" {
+			continue
+		}
+		label := id
+		if entry.VersionID != "" {
+			id += "@" + entry.VersionID
+		}
+		out = append(out, listedModel{id: id, label: label})
+	}
+	return out, nil
+}
+
+// normalizeForPricing maps a vendor's wire id onto the key the catalog prices
+// it under. It mirrors the synthesiser's normalizePricingModelID: the two must
+// agree, or a model reported here as priced would meter at the default rate
+// instead of the operator's.
+func normalizeForPricing(catalogProviderID, modelID string) string {
+	switch {
+	case catalog.IsBedrockPathStyle(catalogProviderID):
+		return sharedllm.NormalizeBedrockModel(modelID)
+	case catalog.IsVertexPathStyle(catalogProviderID):
+		return sharedllm.NormalizeVertexModel(modelID)
+	default:
+		return modelID
+	}
+}
diff --git a/management/internals/modules/agentnetwork/pricing/defaults.go b/management/internals/modules/agentnetwork/pricing/defaults.go
index c690313bc..315cfe208 100644
--- a/management/internals/modules/agentnetwork/pricing/defaults.go
+++ b/management/internals/modules/agentnetwork/pricing/defaults.go
@@ -47,17 +47,11 @@ var supplementalDefaults = map[string]map[string]Entry{
 		"gpt-5-nano": {InputPer1k: 0.00005, OutputPer1k: 0.0004, CachedInputPer1k: 0.000005},
 	},
 	"anthropic": {
-		// claude-opus-5 is not yet in the catalog lineup but gateway /
-		// grandfathered traffic uses it; priced so it isn't skipped.
-		"claude-opus-5": {InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625},
 		// "kimi-k3[1m]" is the 1M-context alias some Claude Code guides
 		// configure against Moonshot's Anthropic-compatible endpoint;
 		// priced identically to kimi-k3 so those requests aren't skipped.
 		"kimi-k3[1m]": {InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003},
 	},
-	"bedrock": {
-		"anthropic.claude-opus-5": {InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625},
-	},
 }
 
 var (
diff --git a/management/internals/modules/agentnetwork/pricing/defaults_llm_pricing.example.yaml b/management/internals/modules/agentnetwork/pricing/defaults_llm_pricing.example.yaml
index bb1cb09a8..78830ae3c 100644
--- a/management/internals/modules/agentnetwork/pricing/defaults_llm_pricing.example.yaml
+++ b/management/internals/modules/agentnetwork/pricing/defaults_llm_pricing.example.yaml
@@ -82,6 +82,11 @@ anthropic:
     output_per_1k: 0.015
     cache_read_per_1k: 0.0003
     cache_creation_per_1k: 0.00375
+  claude-sonnet-5:
+    input_per_1k: 0.003
+    output_per_1k: 0.015
+    cache_read_per_1k: 0.0003
+    cache_creation_per_1k: 0.00375
   kimi-k3:
     input_per_1k: 0.003
     output_per_1k: 0.015
@@ -145,6 +150,11 @@ bedrock:
     output_per_1k: 0.015
     cache_read_per_1k: 0.0003
     cache_creation_per_1k: 0.00375
+  anthropic.claude-sonnet-5:
+    input_per_1k: 0.003
+    output_per_1k: 0.015
+    cache_read_per_1k: 0.0003
+    cache_creation_per_1k: 0.00375
   meta.llama3-3-70b-instruct:
     input_per_1k: 0.00072
     output_per_1k: 0.00072
diff --git a/management/internals/modules/agentnetwork/pricing/defaults_test.go b/management/internals/modules/agentnetwork/pricing/defaults_test.go
index 99c965687..04b6de550 100644
--- a/management/internals/modules/agentnetwork/pricing/defaults_test.go
+++ b/management/internals/modules/agentnetwork/pricing/defaults_test.go
@@ -116,11 +116,13 @@ func TestDefaultTable_PinnedRates(t *testing.T) {
 	assert.InDelta(t, 0.010, fable.InputPer1k, 1e-9, "claude-fable-5 input")
 	assert.InDelta(t, 0.0125, fable.CacheCreationPer1k, 1e-9, "claude-fable-5 cache creation")
 
-	// Supplementals present on their surfaces.
+	// Every id below must stay priced whichever source provides it: the
+	// catalog lineup for the current Claude 5 family, supplementalDefaults
+	// for the ids the dashboard deliberately doesn't offer.
 	for surface, ids := range map[string][]string{
 		"openai":    {"gpt-5", "gpt-5-mini", "gpt-5-nano"},
-		"anthropic": {"claude-opus-5", "kimi-k3[1m]", "kimi-k3"},
-		"bedrock":   {"anthropic.claude-opus-5"},
+		"anthropic": {"claude-opus-5", "claude-sonnet-5", "kimi-k3[1m]", "kimi-k3"},
+		"bedrock":   {"anthropic.claude-opus-5", "anthropic.claude-sonnet-5"},
 	} {
 		for _, id := range ids {
 			_, ok := table[surface][id]
diff --git a/management/internals/modules/agentnetwork/synthesizer.go b/management/internals/modules/agentnetwork/synthesizer.go
index 3fd92be96..66a19acd9 100644
--- a/management/internals/modules/agentnetwork/synthesizer.go
+++ b/management/internals/modules/agentnetwork/synthesizer.go
@@ -10,6 +10,7 @@ import (
 	"strings"
 
 	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
+	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/modeldiscovery"
 	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
 	rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
 	"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey"
@@ -211,7 +212,19 @@ func SynthesizeServices(ctx context.Context, s store.Store, accountID string) ([
 
 	groupIndex := indexProviderGroups(enabledPolicies)
 
-	routerCfgJSON, err := buildRouterConfigJSON(enabledProviders, groupIndex)
+	// The proxy guardrail is a per-provider fail-closed backstop; the
+	// authoritative per-policy/group decision is management's
+	// SelectPolicyForRequest. A provider lands in that map only when every
+	// authorising policy restricts models.
+	providerAllowlists := buildProviderAllowlists(enabledPolicies, guardrailsByID)
+
+	// Discovery gets the finer view: per policy rather than flattened per
+	// provider, so a listing can be bounded to what the calling groups may
+	// actually use instead of the union across everyone who reaches the
+	// provider.
+	modelPolicies := buildModelPolicies(enabledPolicies, guardrailsByID)
+
+	routerCfgJSON, err := buildRouterConfigJSON(enabledProviders, groupIndex, modelPolicies)
 	if err != nil {
 		return nil, err
 	}
@@ -228,11 +241,6 @@ func SynthesizeServices(ctx context.Context, s store.Store, accountID string) ([
 
 	mergedGuardrails := mergeGuardrails(enabledPolicies, guardrailsByID)
 	applyAccountCollectionControls(&mergedGuardrails, settings)
-	// The proxy guardrail is a per-provider fail-closed backstop; the
-	// authoritative per-policy/group decision is management's
-	// SelectPolicyForRequest. A provider lands in this map only when every
-	// authorising policy restricts models.
-	providerAllowlists := buildProviderAllowlists(enabledPolicies, guardrailsByID)
 	guardrailJSON, err := marshalGuardrailConfig(providerAllowlists, mergedGuardrails.PromptCapture)
 	if err != nil {
 		return nil, err
@@ -351,6 +359,11 @@ type routerProviderRoute struct {
 	AuthHeaderName  string   `json:"auth_header_name"`
 	AuthHeaderValue string   `json:"auth_header_value"`
 	AllowedGroupIDs []string `json:"allowed_group_ids,omitempty"`
+	// ModelPolicies is one entry per enabled policy authorising this provider,
+	// carrying that policy's source groups and the models it permits. The
+	// router bounds a model listing with it, so a provider two groups reach
+	// under different allowlists offers each only its own.
+	ModelPolicies []routerModelPolicy `json:"model_policies,omitempty"`
 	// Vertex marks a Google Vertex AI provider, whose requests carry the
 	// model in the URL path. The router selects it by path, bypassing the
 	// model/vendor table.
@@ -368,6 +381,9 @@ type routerProviderRoute struct {
 	// proxy dials this provider's upstream. For self-hosted / internal gateways
 	// behind a private or self-signed certificate.
 	SkipTLSVerify bool `json:"skip_tls_verify,omitempty"`
+	// DiscoveryHost, when set, is the host serving this provider's model
+	// listing, for a vendor that does not serve it from the inference host.
+	DiscoveryHost string `json:"discovery_host,omitempty"`
 }
 
 // indexProviderGroups walks the enabled policies and returns, per
@@ -422,7 +438,7 @@ func indexProviderGroups(policies []*types.Policy) map[string][]string {
 // path-prefix tiebreak. Providers no enabled policy authorises
 // (orphans) are intentionally OMITTED so the router never observes a
 // route with an empty ACL.
-func buildRouterConfigJSON(providers []*types.Provider, groupIndex map[string][]string) ([]byte, error) {
+func buildRouterConfigJSON(providers []*types.Provider, groupIndex map[string][]string, modelPolicies map[string][]routerModelPolicy) ([]byte, error) {
 	cfg := routerConfig{Providers: make([]routerProviderRoute, 0, len(providers))}
 	for _, p := range providers {
 		groups, hasPolicy := groupIndex[p.ID]
@@ -435,6 +451,9 @@ func buildRouterConfigJSON(providers []*types.Provider, groupIndex map[string][]
 		if err != nil {
 			return nil, fmt.Errorf("router config for provider %s: %w", p.ID, err)
 		}
+		// Lookup rather than assume: an unknown provider id yields the zero
+		// entry, which declares no discovery and so contributes nothing.
+		catalogEntry, _ := catalog.Lookup(p.ProviderID)
 		headerName, headerValue, gcpSAKeyB64, err := providerAuthHeader(p)
 		if err != nil {
 			return nil, err
@@ -449,10 +468,12 @@ func buildRouterConfigJSON(providers []*types.Provider, groupIndex map[string][]
 			AuthHeaderName:          headerName,
 			AuthHeaderValue:         headerValue,
 			AllowedGroupIDs:         groups,
+			ModelPolicies:           modelPolicies[p.ID],
 			Vertex:                  catalog.IsVertexPathStyle(p.ProviderID),
 			Bedrock:                 catalog.IsBedrockPathStyle(p.ProviderID),
 			GCPServiceAccountKeyB64: gcpSAKeyB64,
 			SkipTLSVerify:           p.SkipTLSVerification,
+			DiscoveryHost:           discoveryHost(catalogEntry, p.UpstreamURL),
 		})
 	}
 	out, err := json.Marshal(cfg)
@@ -462,6 +483,33 @@ func buildRouterConfigJSON(providers []*types.Provider, groupIndex map[string][]
 	return out, nil
 }
 
+// discoveryHost returns the host serving this provider's model listing when it
+// differs from the inference host, and empty when the two are the same — which
+// is true of every vendor but Bedrock, whose ListInferenceProfiles is a control
+// plane operation on bedrock. while InvokeModel must go to
+// bedrock-runtime.. One provider record therefore needs two hosts.
+//
+// The catalog declares the listing host; the region is recovered from the
+// upstream the operator configured, since a provider record carries no region
+// field. An upstream matching no catalog template yields empty rather than a
+// guess: a proxied or self-hosted Bedrock endpoint may serve both from one
+// place, and inventing a host would send the credential somewhere the operator
+// never configured.
+func discoveryHost(entry catalog.Provider, upstreamURL string) string {
+	if entry.Discovery == nil || entry.Discovery.Host == "" {
+		return ""
+	}
+	host := entry.Discovery.Host
+	if !strings.Contains(host, catalog.RegionPlaceholder) {
+		return host
+	}
+	region := modeldiscovery.RegionFromUpstream(entry, upstreamURL)
+	if region == "" {
+		return ""
+	}
+	return strings.ReplaceAll(host, catalog.RegionPlaceholder, region)
+}
+
 // providerVendor returns the parser surface ("openai", "anthropic", …)
 // the provider speaks, sourced from its catalog entry's ParserID. The
 // router uses it to keep a request the parser tagged with a vendor on a
@@ -1098,3 +1146,46 @@ func mergeGuardrail(g *types.Guardrail, merged *MergedGuardrails) {
 		}
 	}
 }
+
+// routerModelPolicy mirrors the router's ModelPolicyRule: one authorising
+// policy's source groups plus the models it permits. Models is nil for a
+// policy that sets no model allowlist, which lifts the restriction for the
+// groups it binds — so nil and empty must survive the round trip distinctly.
+type routerModelPolicy struct {
+	GroupIDs []string `json:"group_ids"`
+	Models   []string `json:"models"`
+}
+
+// buildModelPolicies indexes, per provider, one rule for each enabled policy
+// authorising it: the policy's source groups and the models its guardrail
+// permits.
+//
+// This is deliberately finer than buildProviderAllowlists, which flattens the
+// same inputs into one list per provider for the proxy's fail-closed guardrail.
+// A flattened list cannot answer "what may THIS caller see", so a provider two
+// teams reach under different allowlists would offer each team the other's
+// models — a picker full of entries the next request refuses. Keeping the
+// source groups alongside the models lets the router answer it at request time,
+// where it knows the caller's groups.
+func buildModelPolicies(policies []*types.Policy, byID map[string]*types.Guardrail) map[string][]routerModelPolicy {
+	out := make(map[string][]routerModelPolicy)
+	for _, p := range policies {
+		if p == nil || len(p.SourceGroups) == 0 {
+			continue
+		}
+		restricted, models := policyModelAllowlist(p, byID)
+		rule := routerModelPolicy{GroupIDs: append([]string(nil), p.SourceGroups...)}
+		if restricted {
+			// Never nil when restricted: an allowlist permitting nothing must
+			// stay distinguishable from no allowlist at all.
+			rule.Models = append([]string{}, models...)
+		}
+		for _, providerID := range p.DestinationProviderIDs {
+			if providerID == "" {
+				continue
+			}
+			out[providerID] = append(out[providerID], rule)
+		}
+	}
+	return out
+}
diff --git a/management/internals/modules/agentnetwork/synthesizer_pricing_test.go b/management/internals/modules/agentnetwork/synthesizer_pricing_test.go
index 83961878a..e82f2ef05 100644
--- a/management/internals/modules/agentnetwork/synthesizer_pricing_test.go
+++ b/management/internals/modules/agentnetwork/synthesizer_pricing_test.go
@@ -103,3 +103,37 @@ func TestBuildCostMeterConfig_OrphanAndGatewayProviders(t *testing.T) {
 	assert.NotContains(t, cfg.Pricing.Providers, "prov-litellm", "empty-models gateway needs no per-record entry")
 	assert.NotEmpty(t, cfg.Pricing.Defaults["openai"], "defaults still ship so the gateway's catalog-model traffic is priced")
 }
+
+// TestBuildCostMeterConfig_BedrockGeographyOutsideTheOriginalFour is the
+// accounting half of the geography bug. The docs tell operators to register a
+// Bedrock id exactly as AWS issues it, region prefix included, and the cost
+// meter keys its table by the normalized form. While the geography was matched
+// against a list of four, a profile issued anywhere else kept its prefix,
+// missed the catalog entry it was meant to inherit from, and billed with a
+// zero entry underneath the operator's own rates — so every cache bucket
+// metered free and a model priced only by catalog defaults metered at nothing
+// at all.
+func TestBuildCostMeterConfig_BedrockGeographyOutsideTheOriginalFour(t *testing.T) {
+	for _, geo := range []string{"jp", "au", "ca", "sa", "us-gov"} {
+		t.Run(geo, func(t *testing.T) {
+			bedrock := &types.Provider{
+				ID:         "prov-bedrock",
+				ProviderID: "bedrock_api",
+				Enabled:    true,
+				Models: []types.ProviderModel{
+					{ID: geo + ".anthropic.claude-sonnet-5-20260514-v1:0", InputPer1k: 0.003, OutputPer1k: 0.015},
+				},
+			}
+			raw, err := buildCostMeterConfigJSON([]*types.Provider{bedrock}, map[string][]string{"prov-bedrock": {"grp"}})
+			require.NoError(t, err)
+			cfg := decodeCostMeterConfig(t, raw)
+
+			e, ok := cfg.Pricing.Providers["prov-bedrock"]["anthropic.claude-sonnet-5"]
+			require.True(t, ok, "a %s profile must key by the same normalized id the parser emits", geo)
+			assert.InDelta(t, 0.0003, e.CacheReadPer1k, 1e-9,
+				"cache read must be inherited from the bedrock default entry, not left at zero")
+			assert.InDelta(t, 0.00375, e.CacheCreationPer1k, 1e-9,
+				"cache creation must be inherited from the bedrock default entry, not left at zero")
+		})
+	}
+}
diff --git a/management/internals/modules/agentnetwork/synthesizer_provider_allowlist_test.go b/management/internals/modules/agentnetwork/synthesizer_provider_allowlist_test.go
index 2cfc0db8c..a27cd2ae4 100644
--- a/management/internals/modules/agentnetwork/synthesizer_provider_allowlist_test.go
+++ b/management/internals/modules/agentnetwork/synthesizer_provider_allowlist_test.go
@@ -4,6 +4,7 @@ import (
 	"testing"
 
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 
 	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
 )
@@ -93,3 +94,75 @@ func TestBuildProviderAllowlists(t *testing.T) {
 			"an enabled-but-empty allowlist is restricted with an empty set, not unrestricted")
 	})
 }
+
+// policyForGroups builds an enabled policy binding the given source groups to
+// the given providers under an optional guardrail.
+func policyForGroups(id string, groups []string, guardrailIDs []string, providerIDs ...string) *types.Policy {
+	return &types.Policy{
+		ID:                     id,
+		Enabled:                true,
+		SourceGroups:           groups,
+		DestinationProviderIDs: providerIDs,
+		GuardrailIDs:           guardrailIDs,
+	}
+}
+
+// TestBuildModelPolicies covers the finer index discovery needs. Where
+// buildProviderAllowlists flattens every authorising policy into one list per
+// provider — enough for a fail-closed backstop, but blind to who is asking —
+// this keeps each policy's source groups beside its models so the router can
+// bound a listing to the calling groups.
+func TestBuildModelPolicies(t *testing.T) {
+	byID := map[string]*types.Guardrail{
+		"g-4o":       allowlistGuardrail("g-4o", "acc-1", "gpt-4o"),
+		"g-opus":     allowlistGuardrail("g-opus", "acc-1", "claude-opus-4"),
+		"g-disabled": {ID: "g-disabled", Checks: types.GuardrailChecks{ModelAllowlist: types.GuardrailModelAllowlist{Enabled: false, Models: []string{"gpt-4o"}}}},
+	}
+
+	t.Run("each policy keeps its own groups and models", func(t *testing.T) {
+		policies := []*types.Policy{
+			policyForGroups("p1", []string{"grp-eng"}, []string{"g-4o"}, "prov-x"),
+			policyForGroups("p2", []string{"grp-sales"}, []string{"g-opus"}, "prov-x"),
+		}
+		got := buildModelPolicies(policies, byID)
+		assert.Equal(t, []routerModelPolicy{
+			{GroupIDs: []string{"grp-eng"}, Models: []string{"gpt-4o"}},
+			{GroupIDs: []string{"grp-sales"}, Models: []string{"claude-opus-4"}},
+		}, got["prov-x"],
+			"the two policies must stay separable so neither group is offered the other's models")
+	})
+
+	t.Run("an unrestricted policy carries nil models", func(t *testing.T) {
+		policies := []*types.Policy{
+			policyForGroups("p1", []string{"grp-eng"}, []string{"g-4o"}, "prov-x"),
+			policyForGroups("p2", []string{"grp-admin"}, nil, "prov-x"),
+		}
+		got := buildModelPolicies(policies, byID)
+		assert.Nil(t, got["prov-x"][1].Models,
+			"no allowlist must reach the router as nil, which lifts the restriction for its groups")
+	})
+
+	t.Run("a disabled allowlist is not a restriction", func(t *testing.T) {
+		policies := []*types.Policy{policyForGroups("p1", []string{"grp-eng"}, []string{"g-disabled"}, "prov-x")}
+		got := buildModelPolicies(policies, byID)
+		assert.Nil(t, got["prov-x"][0].Models,
+			"a guardrail with the allowlist check off restricts nothing")
+	})
+
+	t.Run("an enabled allowlist with no models permits nothing", func(t *testing.T) {
+		byIDEmpty := map[string]*types.Guardrail{
+			"g-empty": {ID: "g-empty", Checks: types.GuardrailChecks{ModelAllowlist: types.GuardrailModelAllowlist{Enabled: true}}},
+		}
+		policies := []*types.Policy{policyForGroups("p1", []string{"grp-eng"}, []string{"g-empty"}, "prov-x")}
+		got := buildModelPolicies(policies, byIDEmpty)
+		require.NotNil(t, got["prov-x"][0].Models,
+			"an empty allowlist must not arrive as nil — that would read as unrestricted")
+		assert.Empty(t, got["prov-x"][0].Models)
+	})
+
+	t.Run("a policy binding no groups is skipped", func(t *testing.T) {
+		policies := []*types.Policy{policyForGroups("p1", nil, []string{"g-4o"}, "prov-x")}
+		assert.Empty(t, buildModelPolicies(policies, byID),
+			"a policy with no source groups authorises nobody, so it bounds nobody's listing")
+	})
+}
diff --git a/management/internals/modules/agentnetwork/synthesizer_test.go b/management/internals/modules/agentnetwork/synthesizer_test.go
index 817129571..352d36646 100644
--- a/management/internals/modules/agentnetwork/synthesizer_test.go
+++ b/management/internals/modules/agentnetwork/synthesizer_test.go
@@ -10,6 +10,7 @@ import (
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 
+	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
 	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
 	rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
 	"github.com/netbirdio/netbird/management/server/store"
@@ -1245,3 +1246,57 @@ func TestSynthesizeServices_EmptyAPIKey_FailsClosed(t *testing.T) {
 	require.Error(t, err, "synthesis must refuse a provider with no api key")
 	assert.Contains(t, err.Error(), "no api key", "error must surface the missing credential")
 }
+
+// TestDiscoveryHost pins which providers get a separate listing host. Getting
+// this wrong in either direction is costly: a missing host leaves Bedrock
+// discovery 404ing at AWS, and a host on the wrong provider would send that
+// provider's listing — and its credential — somewhere the operator never
+// configured.
+func TestDiscoveryHost(t *testing.T) {
+	entry := func(id string) catalog.Provider {
+		p, ok := catalog.Lookup(id)
+		require.True(t, ok, "catalog entry %s must exist", id)
+		return p
+	}
+
+	for _, tc := range []struct {
+		name     string
+		entry    catalog.Provider
+		upstream string
+		want     string
+	}{
+		{
+			// ListInferenceProfiles is a control-plane operation; the runtime
+			// host answers  for it.
+			name:  "bedrock splits the listing off the runtime host",
+			entry: entry("bedrock_api"), upstream: "https://bedrock-runtime.eu-central-1.amazonaws.com",
+			want: "bedrock.eu-central-1.amazonaws.com",
+		},
+		{
+			name:  "bedrock in another region",
+			entry: entry("bedrock_api"), upstream: "https://bedrock-runtime.us-west-2.amazonaws.com",
+			want: "bedrock.us-west-2.amazonaws.com",
+		},
+		{
+			// A proxied Bedrock endpoint may well serve both from one place,
+			// and there is no region to read back out of it.
+			name:  "proxied bedrock upstream yields no discovery host",
+			entry: entry("bedrock_api"), upstream: "https://bedrock.internal.example.com",
+			want: "",
+		},
+		{
+			name:  "openai serves its listing from the same host",
+			entry: entry("openai_api"), upstream: "https://api.openai.com",
+			want: "",
+		},
+		{
+			name:  "vertex serves its listing from the same host",
+			entry: entry("vertex_ai_api"), upstream: "https://us-east5-aiplatform.googleapis.com",
+			want: "",
+		},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			assert.Equal(t, tc.want, discoveryHost(tc.entry, tc.upstream))
+		})
+	}
+}
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 e1a5cae48..a2aad19b6 100644
--- a/management/internals/shared/grpc/components_encoder.go
+++ b/management/internals/shared/grpc/components_encoder.go
@@ -4,10 +4,9 @@ import (
 	"encoding/base64"
 	"strconv"
 
-	nbdns "github.com/netbirdio/netbird/dns"
 	"github.com/netbirdio/netbird/management/server/types"
-	nbroute "github.com/netbirdio/netbird/route"
 	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	"github.com/netbirdio/netbird/shared/management/proto"
 )
 
@@ -84,6 +83,7 @@ func EncodeNetworkMapEnvelope(in ComponentsEnvelopeInput) *proto.NetworkMapEnvel
 	enc := newComponentEncoder(c)
 	enc.indexAllPeers()
 	routerIdxs := enc.indexRouterPeers(c.RouterPeers)
+	enc.indexAllNetworkResources()
 
 	// Phase 2: gather every policy that any consumer references (peer-pair
 	// policies + resource-only policies) so encodeResourcePoliciesMap can
@@ -105,7 +105,6 @@ func EncodeNetworkMapEnvelope(in ComponentsEnvelopeInput) *proto.NetworkMapEnvel
 		DnsSettings:         enc.encodeDNSSettings(c.DNSSettings),
 		DnsDomain:           in.DNSDomain,
 		CustomZoneDomain:    c.CustomZoneDomain,
-		AgentVersions:       enc.agentVersions,
 		Peers:               enc.peers,
 		RouterPeerIndexes:   routerIdxs,
 		Policies:            policies,
@@ -130,7 +129,7 @@ func EncodeNetworkMapEnvelope(in ComponentsEnvelopeInput) *proto.NetworkMapEnvel
 // networkSerial returns c.Network.CurrentSerial() with a nil guard. The
 // production path always populates c.Network, but the encoder is exported
 // and a hand-built components struct may omit it.
-func networkSerial(n *types.Network) uint64 {
+func networkSerial(n *nmdata.Network) uint64 {
 	if n == nil {
 		return 0
 	}
@@ -143,16 +142,15 @@ type componentEncoder struct {
 	peerOrder map[string]uint32
 	peers     []*proto.PeerCompact
 
-	agentVersionOrder map[string]uint32
-	agentVersions     []string
+	networkIdToPublicId map[string]string
 }
 
 func newComponentEncoder(c *types.NetworkMapComponents) *componentEncoder {
 	return &componentEncoder{
-		components:        c,
-		peerOrder:         make(map[string]uint32, len(c.Peers)),
-		peers:             make([]*proto.PeerCompact, 0, len(c.Peers)),
-		agentVersionOrder: make(map[string]uint32),
+		components:          c,
+		peerOrder:           make(map[string]uint32, len(c.Peers)),
+		peers:               make([]*proto.PeerCompact, 0, len(c.Peers)),
+		networkIdToPublicId: make(map[string]string),
 	}
 }
 
@@ -165,7 +163,7 @@ func (e *componentEncoder) indexAllPeers() {
 	}
 }
 
-func (e *componentEncoder) appendPeer(p *types.ComponentPeer) uint32 {
+func (e *componentEncoder) appendPeer(p *nmdata.Peer) uint32 {
 	if idx, ok := e.peerOrder[p.ID]; ok {
 		return idx
 	}
@@ -175,11 +173,10 @@ func (e *componentEncoder) appendPeer(p *types.ComponentPeer) uint32 {
 	return idx
 }
 
-// indexRouterPeers ensures every router peer is in the peer dedup table
-// (c.RouterPeers may contain peers not in c.Peers when validation rules drop
-// them) and returns their wire indexes for the RouterPeerIndexes field. Must
-// run before any encoder that resolves peer ids via e.peerOrder.
-func (e *componentEncoder) indexRouterPeers(routers map[string]*types.ComponentPeer) []uint32 {
+// indexRouterPeers ensures every router peer is in the peer dedup table and
+// returns their wire indexes for the RouterPeerIndexes field. Must run before
+// any encoder that resolves peer ids via e.peerOrder.
+func (e *componentEncoder) indexRouterPeers(routers map[string]*nmdata.Peer) []uint32 {
 	if len(routers) == 0 {
 		return nil
 	}
@@ -193,6 +190,15 @@ func (e *componentEncoder) indexRouterPeers(routers map[string]*types.ComponentP
 	return out
 }
 
+func (e *componentEncoder) indexAllNetworkResources() {
+	for _, r := range e.components.NetworkResources {
+		if !r.Enabled {
+			continue
+		}
+		e.networkIdToPublicId[r.ID] = r.PublicID
+	}
+}
+
 func (e *componentEncoder) encodeGroups() []*proto.GroupCompact {
 	if len(e.components.Groups) == 0 {
 		return nil
@@ -206,10 +212,22 @@ func (e *componentEncoder) encodeGroups() []*proto.GroupCompact {
 				peerIdxs = append(peerIdxs, idx)
 			}
 		}
+
+		groupCompactResources := func() []*proto.ResourceCompact {
+			var toret []*proto.ResourceCompact
+			for _, r := range g.Resources {
+				if pr := e.resourceToProto(r); pr != nil {
+					toret = append(toret, pr)
+				}
+			}
+			return toret
+		}
+
 		out = append(out, &proto.GroupCompact{
 			Id:          g.PublicID,
 			PeerIndexes: peerIdxs,
 			IsAll:       g.IsGroupAll(),
+			Resources:   groupCompactResources(),
 		})
 	}
 	return out
@@ -219,7 +237,7 @@ func (e *componentEncoder) encodeGroups() []*proto.GroupCompact {
 // list and a map from policy pointer to the indexes of its emitted rules in
 // that list — used by encodeResourcePoliciesMap to translate
 // ResourcePoliciesMap[resourceID][]*Policy into wire-side indexes.
-func (e *componentEncoder) encodePolicies(policies []*types.Policy) []*proto.PolicyCompact {
+func (e *componentEncoder) encodePolicies(policies []*nmdata.Policy) []*proto.PolicyCompact {
 	if len(policies) == 0 {
 		return nil
 	}
@@ -241,7 +259,7 @@ func (e *componentEncoder) encodePolicies(policies []*types.Policy) []*proto.Pol
 }
 
 // encodePolicyRule maps a single PolicyRule under pol to a PolicyCompact entry.
-func (e *componentEncoder) encodePolicyRule(pol *types.Policy, r *types.PolicyRule) *proto.PolicyCompact {
+func (e *componentEncoder) encodePolicyRule(pol *nmdata.Policy, r *nmdata.PolicyRule) *proto.PolicyCompact {
 	return &proto.PolicyCompact{
 		Id:                    pol.PublicID,
 		Action:                networkmap.GetProtoAction(string(r.Action)),
@@ -280,14 +298,14 @@ func (e *componentEncoder) groupPublicXids(src []string) []string {
 // only live in ResourcePoliciesMap; without this union step they'd be lost
 // from the wire and the client's resource-policy lookup would come back
 // empty.
-func unionPolicies(policies []*types.Policy, resourcePolicies map[string][]*types.Policy) []*types.Policy {
+func unionPolicies(policies []*nmdata.Policy, resourcePolicies map[string][]*nmdata.Policy) []*nmdata.Policy {
 	// Fast path: non-router peers have no resource-only policies, so the
 	// "union" is identical to `policies`. Skip the dedup map allocation.
 	if len(resourcePolicies) == 0 {
 		return policies
 	}
 	seen := make(map[string]struct{}, len(policies))
-	out := make([]*types.Policy, 0, len(policies))
+	out := make([]*nmdata.Policy, 0, len(policies))
 	for _, p := range policies {
 		if p == nil {
 			continue
@@ -314,16 +332,15 @@ func unionPolicies(policies []*types.Policy, resourcePolicies map[string][]*type
 }
 
 // encodeAuthorizedGroups translates rule.AuthorizedGroups (map keyed by
-// group xid → local-user names) to the wire form (map keyed by group
-// account_seq_id → UserNameList). Groups without a seq id are dropped —
-// matches how source/destination group references handle the same case.
+// group xid → local-user names) to the wire form (map keyed by
+// authorizedGroupKey → UserNameList).
 func (e *componentEncoder) encodeAuthorizedGroups(m map[string][]string) map[string]*proto.UserNameList {
 	if len(m) == 0 {
 		return nil
 	}
 	out := make(map[string]*proto.UserNameList, len(m))
 	for groupID, names := range m {
-		id, ok := e.groupPublicXid(groupID)
+		id, ok := e.authorizedGroupKey(groupID)
 		if !ok {
 			continue
 		}
@@ -332,6 +349,24 @@ func (e *componentEncoder) encodeAuthorizedGroups(m map[string][]string) map[str
 	return out
 }
 
+// authorizedGroupKey resolves the wire key for a group that grants SSH access.
+// These are user groups: they hold no peers, so nothing puts them in
+// components.Groups and groupPublicXid cannot see them. Dropping them the way a
+// missing source/destination group is dropped would strip every authorized user
+// from the envelope while PeerConfig still reports SSH enabled, leaving the peer
+// running sshd with nobody able to log in — so the id is passed through instead.
+// AuthorizedGroups and GroupIDToUserIDs are only ever used against each other,
+// on both sides of the wire, so they just have to agree.
+func (e *componentEncoder) authorizedGroupKey(groupID string) (string, bool) {
+	if groupID == "" {
+		return "", false
+	}
+	if id, ok := e.groupPublicXid(groupID); ok {
+		return id, true
+	}
+	return groupID, true
+}
+
 func (e *componentEncoder) groupPublicXid(groupID string) (string, bool) {
 	g, ok := e.components.Groups[groupID]
 	if !ok {
@@ -345,17 +380,29 @@ func (e *componentEncoder) groupPublicXid(groupID string) (string, bool) {
 // peers array. For other resource types only the type string is shipped
 // today (Calculate's resource-typed rule path consults SourceResource only
 // for "peer" — other types fall through to group-based lookup).
-func (e *componentEncoder) resourceToProto(r types.Resource) *proto.ResourceCompact {
-	if r.ID == "" && r.Type == "" {
+func (e *componentEncoder) resourceToProto(r nmdata.Resource) *proto.ResourceCompact {
+	if !types.ResourceType(r.Type).Valid() || r.ID == "" {
 		return nil
 	}
-	out := &proto.ResourceCompact{Type: string(r.Type)}
-	if r.Type == types.ResourceTypePeer && r.ID != "" {
-		if idx, ok := e.peerOrder[r.ID]; ok {
-			out.PeerIndexSet = true
-			out.PeerIndex = idx
+
+	out := &proto.ResourceCompact{Type: r.Type}
+
+	if r.Type == string(types.ResourceTypePeer) {
+		idx, ok := e.peerOrder[r.ID]
+		if !ok {
+			return nil
 		}
+		out.PeerIndexSet = true
+		out.PeerIndex = idx
+		return out
 	}
+
+	publicID, ok := e.networkIdToPublicId[r.ID]
+	if !ok {
+		return nil
+	}
+	out.Id = publicID
+
 	return out
 }
 
@@ -389,7 +436,7 @@ func (e *componentEncoder) networkPublicId(xid string) (string, bool) {
 	return id, true
 }
 
-func (e *componentEncoder) encodeDNSSettings(s *types.DNSSettings) *proto.DNSSettingsCompact {
+func (e *componentEncoder) encodeDNSSettings(s *nmdata.DNSSettings) *proto.DNSSettingsCompact {
 	if s == nil || len(s.DisabledManagementGroups) == 0 {
 		return nil
 	}
@@ -404,7 +451,7 @@ func (e *componentEncoder) encodeDNSSettings(s *types.DNSSettings) *proto.DNSSet
 	return out
 }
 
-func (e *componentEncoder) encodeRoutes(routes []*nbroute.Route) []*proto.RouteRaw {
+func (e *componentEncoder) encodeRoutes(routes []*nmdata.Route) []*proto.RouteRaw {
 	if len(routes) == 0 {
 		return nil
 	}
@@ -442,7 +489,7 @@ func (e *componentEncoder) encodeRoutes(routes []*nbroute.Route) []*proto.RouteR
 	return out
 }
 
-func (e *componentEncoder) encodeNameServerGroups(nsgs []*nbdns.NameServerGroup) []*proto.NameServerGroupRaw {
+func (e *componentEncoder) encodeNameServerGroups(nsgs []*nmdata.NameServerGroup) []*proto.NameServerGroupRaw {
 	if len(nsgs) == 0 {
 		return nil
 	}
@@ -465,7 +512,7 @@ func (e *componentEncoder) encodeNameServerGroups(nsgs []*nbdns.NameServerGroup)
 	return out
 }
 
-func encodeNameServers(servers []nbdns.NameServer) []*proto.NameServer {
+func encodeNameServers(servers []nmdata.NameServer) []*proto.NameServer {
 	if len(servers) == 0 {
 		return nil
 	}
@@ -480,7 +527,7 @@ func encodeNameServers(servers []nbdns.NameServer) []*proto.NameServer {
 	return out
 }
 
-func encodeSimpleRecords(records []nbdns.SimpleRecord) []*proto.SimpleRecord {
+func encodeSimpleRecords(records []nmdata.SimpleRecord) []*proto.SimpleRecord {
 	if len(records) == 0 {
 		return nil
 	}
@@ -497,7 +544,7 @@ func encodeSimpleRecords(records []nbdns.SimpleRecord) []*proto.SimpleRecord {
 	return out
 }
 
-func encodeCustomZones(zones []nbdns.CustomZone) []*proto.CustomZone {
+func encodeCustomZones(zones []nmdata.CustomZone) []*proto.CustomZone {
 	if len(zones) == 0 {
 		return nil
 	}
@@ -513,7 +560,7 @@ func encodeCustomZones(zones []nbdns.CustomZone) []*proto.CustomZone {
 	return out
 }
 
-func (e *componentEncoder) encodeNetworkResources(resources []*types.ComponentResource) []*proto.NetworkResourceRaw {
+func (e *componentEncoder) encodeNetworkResources(resources []*nmdata.NetworkResource) []*proto.NetworkResourceRaw {
 	if len(resources) == 0 {
 		return nil
 	}
@@ -542,7 +589,7 @@ func (e *componentEncoder) encodeNetworkResources(resources []*types.ComponentRe
 	return out
 }
 
-func (e *componentEncoder) encodeRoutersMap(routersMap map[string]map[string]*types.ComponentRouter) map[string]*proto.NetworkRouterList {
+func (e *componentEncoder) encodeRoutersMap(routersMap map[string]map[string]*nmdata.NetworkRouter) map[string]*proto.NetworkRouterList {
 	if len(routersMap) == 0 {
 		return nil
 	}
@@ -578,7 +625,7 @@ func (e *componentEncoder) encodeRoutersMap(routersMap map[string]map[string]*ty
 	return out
 }
 
-func (e *componentEncoder) encodeResourcePoliciesMap(rpm map[string][]*types.Policy) map[string]*proto.PolicyIds {
+func (e *componentEncoder) encodeResourcePoliciesMap(rpm map[string][]*nmdata.Policy) map[string]*proto.PolicyIds {
 	if len(rpm) == 0 {
 		return nil
 	}
@@ -599,6 +646,9 @@ func (e *componentEncoder) encodeResourcePoliciesMap(rpm map[string][]*types.Pol
 		}
 		ids := make([]string, 0, len(policies))
 		for _, pol := range policies {
+			if pol == nil {
+				continue
+			}
 			ids = append(ids, pol.PublicID)
 		}
 		if len(ids) == 0 {
@@ -615,7 +665,7 @@ func (e *componentEncoder) encodeGroupIDToUserIDs(m map[string][]string) map[str
 	}
 	out := make(map[string]*proto.UserIDList, len(m))
 	for groupID, userIDs := range m {
-		id, ok := e.groupPublicXid(groupID)
+		id, ok := e.authorizedGroupKey(groupID)
 		if !ok || len(userIDs) == 0 {
 			continue
 		}
@@ -665,7 +715,7 @@ func (e *componentEncoder) encodePostureFailedPeers(m map[string]map[string]stru
 // (which shouldn't happen in production but the encoder is exported)
 // degrades to login_expiration_enabled = false, which makes
 // LoginExpired() return false for every peer.
-func toAccountSettingsCompact(s *types.AccountSettingsInfo) *proto.AccountSettingsCompact {
+func toAccountSettingsCompact(s *nmdata.AccountSettingsInfo) *proto.AccountSettingsCompact {
 	if s == nil {
 		return &proto.AccountSettingsCompact{}
 	}
@@ -675,7 +725,7 @@ func toAccountSettingsCompact(s *types.AccountSettingsInfo) *proto.AccountSettin
 	}
 }
 
-func toAccountNetwork(n *types.Network) *proto.AccountNetwork {
+func toAccountNetwork(n *nmdata.Network) *proto.AccountNetwork {
 	if n == nil {
 		return nil
 	}
@@ -691,20 +741,21 @@ func toAccountNetwork(n *types.Network) *proto.AccountNetwork {
 	return out
 }
 
-func toPeerCompact(p *types.ComponentPeer) *proto.PeerCompact {
+func toPeerCompact(p *nmdata.Peer) *proto.PeerCompact {
 	pc := &proto.PeerCompact{
 		WgPubKey:               decodeWgKey(p.Key),
 		SshPubKey:              []byte(p.SSHKey),
 		DnsLabel:               p.DNSLabel,
-		AgentVersion:           p.AgentVersion,
-		AddedWithSsoLogin:      p.AddedWithSSOLogin,
+		AgentVersion:           p.Meta.WtVersion,
+		AddedWithSsoLogin:      p.UserID != "",
 		LoginExpirationEnabled: p.LoginExpirationEnabled,
 		SshEnabled:             p.SSHEnabled,
-		SupportsIpv6:           p.SupportsIPv6,
-		SupportsSourcePrefixes: p.SupportsSourcePrefixes,
-		ServerSshAllowed:       p.ServerSSHAllowed,
+		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 {
@@ -753,7 +804,7 @@ func portsToUint32(ports []string) []uint32 {
 	return out
 }
 
-func portRangesToProto(ranges []types.RulePortRange) []*proto.PortInfo_Range {
+func portRangesToProto(ranges []nmdata.RulePortRange) []*proto.PortInfo_Range {
 	if len(ranges) == 0 {
 		return nil
 	}
diff --git a/management/internals/shared/grpc/components_encoder_test.go b/management/internals/shared/grpc/components_encoder_test.go
index 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 820708c98..cdd2a7f37 100644
--- a/management/internals/shared/grpc/components_envelope_response.go
+++ b/management/internals/shared/grpc/components_envelope_response.go
@@ -7,11 +7,10 @@ import (
 
 	"github.com/netbirdio/netbird/client/ssh/auth"
 	nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
-	nbpeer "github.com/netbirdio/netbird/management/server/peer"
-	"github.com/netbirdio/netbird/management/server/posture"
 	"github.com/netbirdio/netbird/management/server/types"
 	sharedgrpc "github.com/netbirdio/netbird/shared/management/grpc"
 	"github.com/netbirdio/netbird/shared/management/networkmap"
+	nmdata "github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	"github.com/netbirdio/netbird/shared/management/proto"
 )
 
@@ -31,14 +30,14 @@ func ToComponentSyncResponse(
 	config *nbconfig.Config,
 	httpConfig *nbconfig.HttpServerConfig,
 	deviceFlowConfig *nbconfig.DeviceAuthorizationFlow,
-	peer *nbpeer.Peer,
+	peer *nmdata.Peer,
 	turnCredentials *Token,
 	relayCredentials *Token,
 	components *types.NetworkMapComponents,
 	proxyPatch *types.NetworkMap,
 	dnsName string,
-	checks []*posture.Checks,
-	settings *types.Settings,
+	checks []*nmdata.PostureChecks,
+	settings *nmdata.AccountSettingsInfo,
 	extraSettings *types.ExtraSettings,
 	peerGroups []string,
 	dnsFwdPort int64,
@@ -66,7 +65,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 +103,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 +113,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 +144,7 @@ func toProxyPatch(nm *types.NetworkMap, dnsName string, includeIPv6, useSourcePr
 //
 // The full SSH AuthorizedUsers map is still produced by the client when it
 // runs Calculate() over the envelope.
-func computeSSHEnabledForPeer(c *types.NetworkMapComponents, peer *nbpeer.Peer) bool {
+func computeSSHEnabledForPeer(c *types.NetworkMapComponents, peer *nmdata.Peer) bool {
 	if c == nil || peer == nil {
 		return false
 	}
@@ -170,25 +169,25 @@ func computeSSHEnabledForPeer(c *types.NetworkMapComponents, peer *nbpeer.Peer)
 // ruleEnablesSSHForPeer returns true when rule is active, targets peer, and
 // either explicitly authorises SSH or covers the legacy TCP/22 path while the
 // peer itself has SSH enabled locally.
-func ruleEnablesSSHForPeer(c *types.NetworkMapComponents, rule *types.PolicyRule, peer *nbpeer.Peer) bool {
+func ruleEnablesSSHForPeer(c *types.NetworkMapComponents, rule *nmdata.PolicyRule, peer *nmdata.Peer) bool {
 	if rule == nil || !rule.Enabled {
 		return false
 	}
 	if !peerInDestinations(c, rule, peer.ID) {
 		return false
 	}
-	if rule.Protocol == types.PolicyRuleProtocolNetbirdSSH {
+	if rule.Protocol == string(types.PolicyRuleProtocolNetbirdSSH) {
 		return true
 	}
-	return peer.SSHEnabled && types.PolicyRuleImpliesLegacySSH(rule)
+	return peer.SSHEnabled && nmdata.PolicyRuleImpliesLegacySSH(rule)
 }
 
 // peerInDestinations reports whether peerID is in any of rule.Destinations'
 // groups (or matches DestinationResource if it's a peer-typed resource —
 // for non-peer types Calculate falls through to group lookup, so we mirror
 // that exactly to avoid silent divergence).
-func peerInDestinations(c *types.NetworkMapComponents, rule *types.PolicyRule, peerID string) bool {
-	if rule.DestinationResource.Type == types.ResourceTypePeer && rule.DestinationResource.ID != "" {
+func peerInDestinations(c *types.NetworkMapComponents, rule *nmdata.PolicyRule, peerID string) bool {
+	if rule.DestinationResource.Type == string(types.ResourceTypePeer) && rule.DestinationResource.ID != "" {
 		return rule.DestinationResource.ID == peerID
 	}
 	for _, groupID := range rule.Destinations {
diff --git a/management/internals/shared/grpc/components_envelope_response_test.go b/management/internals/shared/grpc/components_envelope_response_test.go
index 20f4e6824..039cb73f4 100644
--- a/management/internals/shared/grpc/components_envelope_response_test.go
+++ b/management/internals/shared/grpc/components_envelope_response_test.go
@@ -5,8 +5,8 @@ import (
 
 	"github.com/stretchr/testify/assert"
 
-	nbpeer "github.com/netbirdio/netbird/management/server/peer"
 	"github.com/netbirdio/netbird/management/server/types"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 )
 
 // TestComputeSSHEnabledForPeer covers both Calculate-mirroring branches:
@@ -17,16 +17,15 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
 	const targetPeerID = "target"
 	const targetGroupID = "g_dst"
 
-	mkComponents := func(rule *types.PolicyRule, sshEnabled bool) (*types.NetworkMapComponents, *nbpeer.Peer) {
-		peer := &nbpeer.Peer{ID: targetPeerID, SSHEnabled: sshEnabled}
-		group := &types.ComponentGroup{ID: targetGroupID, Name: "dst", Peers: []string{targetPeerID}}
+	mkComponents := func(rule *nmdata.PolicyRule, sshEnabled bool) (*types.NetworkMapComponents, *nmdata.Peer) {
+		peer := &nmdata.Peer{ID: targetPeerID, SSHEnabled: sshEnabled}
 		return &types.NetworkMapComponents{
-			Peers:  map[string]*types.ComponentPeer{targetPeerID: peer.ToComponent()},
-			Groups: map[string]*types.ComponentGroup{targetGroupID: group},
-			Policies: []*types.Policy{{
+			Peers:  map[string]*nmdata.Peer{targetPeerID: peer},
+			Groups: map[string]*nmdata.Group{targetGroupID: {Name: "dst", Peers: []string{targetPeerID}}},
+			Policies: []*nmdata.Policy{{
 				ID:      "p",
 				Enabled: true,
-				Rules:   []*types.PolicyRule{rule},
+				Rules:   []*nmdata.PolicyRule{rule},
 			}},
 		}, peer
 	}
@@ -34,14 +33,14 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
 	cases := []struct {
 		name        string
 		peerSSH     bool
-		rule        types.PolicyRule
+		rule        nmdata.PolicyRule
 		wantEnabled bool
 	}{
 		{
 			name:    "explicit-netbird-ssh-activates-regardless-of-peer-ssh",
 			peerSSH: false,
-			rule: types.PolicyRule{
-				Enabled: true, Protocol: types.PolicyRuleProtocolNetbirdSSH,
+			rule: nmdata.PolicyRule{
+				Enabled: true, Protocol: string(types.PolicyRuleProtocolNetbirdSSH),
 				Destinations: []string{targetGroupID},
 			},
 			wantEnabled: true,
@@ -49,8 +48,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
 		{
 			name:    "implicit-tcp-22-with-peer-ssh",
 			peerSSH: true,
-			rule: types.PolicyRule{
-				Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"22"},
+			rule: nmdata.PolicyRule{
+				Enabled: true, Protocol: string(types.PolicyRuleProtocolTCP), Ports: []string{"22"},
 				Destinations: []string{targetGroupID},
 			},
 			wantEnabled: true,
@@ -58,8 +57,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
 		{
 			name:    "implicit-tcp-22-without-peer-ssh-disabled",
 			peerSSH: false,
-			rule: types.PolicyRule{
-				Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"22"},
+			rule: nmdata.PolicyRule{
+				Enabled: true, Protocol: string(types.PolicyRuleProtocolTCP), Ports: []string{"22"},
 				Destinations: []string{targetGroupID},
 			},
 			wantEnabled: false,
@@ -67,8 +66,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
 		{
 			name:    "implicit-tcp-22022-with-peer-ssh",
 			peerSSH: true,
-			rule: types.PolicyRule{
-				Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"22022"},
+			rule: nmdata.PolicyRule{
+				Enabled: true, Protocol: string(types.PolicyRuleProtocolTCP), Ports: []string{"22022"},
 				Destinations: []string{targetGroupID},
 			},
 			wantEnabled: true,
@@ -76,8 +75,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
 		{
 			name:    "implicit-all-protocol-with-peer-ssh",
 			peerSSH: true,
-			rule: types.PolicyRule{
-				Enabled: true, Protocol: types.PolicyRuleProtocolALL,
+			rule: nmdata.PolicyRule{
+				Enabled: true, Protocol: string(types.PolicyRuleProtocolALL),
 				Destinations: []string{targetGroupID},
 			},
 			wantEnabled: true,
@@ -85,10 +84,10 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
 		{
 			name:    "implicit-port-range-covers-22",
 			peerSSH: true,
-			rule: types.PolicyRule{
+			rule: nmdata.PolicyRule{
 				Enabled:      true,
-				Protocol:     types.PolicyRuleProtocolTCP,
-				PortRanges:   []types.RulePortRange{{Start: 20, End: 30}},
+				Protocol:     string(types.PolicyRuleProtocolTCP),
+				PortRanges:   []nmdata.RulePortRange{{Start: 20, End: 30}},
 				Destinations: []string{targetGroupID},
 			},
 			wantEnabled: true,
@@ -96,8 +95,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
 		{
 			name:    "tcp-80-no-ssh",
 			peerSSH: true,
-			rule: types.PolicyRule{
-				Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"80"},
+			rule: nmdata.PolicyRule{
+				Enabled: true, Protocol: string(types.PolicyRuleProtocolTCP), Ports: []string{"80"},
 				Destinations: []string{targetGroupID},
 			},
 			wantEnabled: false,
@@ -105,8 +104,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
 		{
 			name:    "disabled-rule-skipped",
 			peerSSH: true,
-			rule: types.PolicyRule{
-				Enabled: false, Protocol: types.PolicyRuleProtocolNetbirdSSH,
+			rule: nmdata.PolicyRule{
+				Enabled: false, Protocol: string(types.PolicyRuleProtocolNetbirdSSH),
 				Destinations: []string{targetGroupID},
 			},
 			wantEnabled: false,
@@ -114,8 +113,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
 		{
 			name:    "peer-not-in-destinations",
 			peerSSH: true,
-			rule: types.PolicyRule{
-				Enabled: true, Protocol: types.PolicyRuleProtocolNetbirdSSH,
+			rule: nmdata.PolicyRule{
+				Enabled: true, Protocol: string(types.PolicyRuleProtocolNetbirdSSH),
 				Destinations: []string{"g_other"}, // target not in this group
 			},
 			wantEnabled: false,
@@ -123,21 +122,21 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
 		{
 			name:    "peer-typed-destination-resource-matches",
 			peerSSH: false,
-			rule: types.PolicyRule{
+			rule: nmdata.PolicyRule{
 				Enabled:             true,
-				Protocol:            types.PolicyRuleProtocolNetbirdSSH,
-				DestinationResource: types.Resource{ID: targetPeerID, Type: types.ResourceTypePeer},
+				Protocol:            string(types.PolicyRuleProtocolNetbirdSSH),
+				DestinationResource: nmdata.Resource{ID: targetPeerID, Type: string(types.ResourceTypePeer)},
 			},
 			wantEnabled: true,
 		},
 		{
 			name:    "non-peer-destination-resource-falls-through-to-groups",
 			peerSSH: false,
-			rule: types.PolicyRule{
+			rule: nmdata.PolicyRule{
 				Enabled:             true,
-				Protocol:            types.PolicyRuleProtocolNetbirdSSH,
-				DestinationResource: types.Resource{ID: targetPeerID, Type: "host"}, // wrong type
-				Destinations:        []string{targetGroupID},                        // saved by group fallback
+				Protocol:            string(types.PolicyRuleProtocolNetbirdSSH),
+				DestinationResource: nmdata.Resource{ID: targetPeerID, Type: "host"}, // wrong type
+				Destinations:        []string{targetGroupID},                         // saved by group fallback
 			},
 			wantEnabled: true,
 		},
@@ -156,16 +155,16 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
 // belt-and-suspenders presence guard mirroring Calculate's
 // getAllPeersFromGroups invariant.
 func TestComputeSSHEnabledForPeer_TargetMissingFromComponents(t *testing.T) {
-	peer := &nbpeer.Peer{ID: "missing", SSHEnabled: true}
+	peer := &nmdata.Peer{ID: "missing", SSHEnabled: true}
 	c := &types.NetworkMapComponents{
-		Peers: map[string]*types.ComponentPeer{}, // target peer NOT present
-		Groups: map[string]*types.ComponentGroup{
-			"g": {ID: "g", Peers: []string{"missing"}},
+		Peers: map[string]*nmdata.Peer{}, // target peer NOT present
+		Groups: map[string]*nmdata.Group{
+			"g": {Peers: []string{"missing"}},
 		},
-		Policies: []*types.Policy{{
+		Policies: []*nmdata.Policy{{
 			ID: "p", Enabled: true,
-			Rules: []*types.PolicyRule{{
-				Enabled: true, Protocol: types.PolicyRuleProtocolNetbirdSSH,
+			Rules: []*nmdata.PolicyRule{{
+				Enabled: true, Protocol: string(types.PolicyRuleProtocolNetbirdSSH),
 				Destinations: []string{"g"},
 			}},
 		}},
@@ -179,6 +178,6 @@ func TestComputeSSHEnabledForPeer_TargetMissingFromComponents(t *testing.T) {
 // exported indirectly via ToComponentSyncResponse and may receive nil
 // components on graceful-degrade paths.
 func TestComputeSSHEnabledForPeer_NilInputs(t *testing.T) {
-	assert.False(t, computeSSHEnabledForPeer(nil, &nbpeer.Peer{ID: "x"}))
+	assert.False(t, computeSSHEnabledForPeer(nil, &nmdata.Peer{ID: "x"}))
 	assert.False(t, computeSSHEnabledForPeer(&types.NetworkMapComponents{}, nil))
 }
diff --git a/management/internals/shared/grpc/conversion.go b/management/internals/shared/grpc/conversion.go
index 2b923836c..96bd9f1f4 100644
--- a/management/internals/shared/grpc/conversion.go
+++ b/management/internals/shared/grpc/conversion.go
@@ -18,10 +18,9 @@ import (
 
 	"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache"
 	nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
-	nbpeer "github.com/netbirdio/netbird/management/server/peer"
-	"github.com/netbirdio/netbird/management/server/posture"
 	"github.com/netbirdio/netbird/management/server/types"
 	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	"github.com/netbirdio/netbird/shared/management/proto"
 	"github.com/netbirdio/netbird/shared/netiputil"
 )
@@ -47,7 +46,7 @@ func init() {
 // nil when no server config is set (the fan-out network-map path) because clients treat any
 // non-nil config as authoritative: a config without a relay section is interpreted as relay
 // disabled and wipes the clients' relay URLs.
-func toNetbirdConfig(config *nbconfig.Config, turnCredentials *Token, relayToken *Token, extraSettings *types.ExtraSettings, settings *types.Settings) *proto.NetbirdConfig {
+func toNetbirdConfig(config *nbconfig.Config, turnCredentials *Token, relayToken *Token, extraSettings *types.ExtraSettings, settings *nmdata.AccountSettingsInfo) *proto.NetbirdConfig {
 	if config == nil {
 		return nil
 	}
@@ -119,7 +118,7 @@ func toNetbirdConfig(config *nbconfig.Config, turnCredentials *Token, relayToken
 	return nbConfig
 }
 
-func toPeerConfig(peer *nbpeer.Peer, network *types.Network, dnsName string, settings *types.Settings, httpConfig *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, enableSSH bool, forceRoutingPeerDNS bool) *proto.PeerConfig {
+func toPeerConfig(peer *nmdata.Peer, network *nmdata.Network, dnsName string, settings *nmdata.AccountSettingsInfo, httpConfig *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, enableSSH bool, forceRoutingPeerDNS bool) *proto.PeerConfig {
 	netmask, _ := network.Net.Mask.Size()
 	fqdn := peer.FQDN(dnsName)
 
@@ -154,12 +153,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 []*nmdata.PostureChecks, dnsCache *cache.DNSConfigCache, settings *nmdata.AccountSettingsInfo, extraSettings *types.ExtraSettings, peerGroups []string, dnsFwdPort int64) *proto.SyncResponse {
 	// IPv6 data in AllowedIPs and SourcePrefixes wildcard expansion depends on
 	// whether the target peer supports IPv6. Routes and firewall rules are already
 	// filtered at the source (network map builder).
 	includeIPv6 := peer.SupportsIPv6() && peer.IPv6.IsValid()
 	useSourcePrefixes := peer.SupportsSourcePrefixes()
+	localIsProxy := peer.ProxyMeta.Embedded
 
 	response := &proto.SyncResponse{
 		PeerConfig: toPeerConfig(peer, networkMap.Network, dnsName, settings, httpConfig, deviceFlowConfig, networkMap.EnableSSH, networkMap.ForceRoutingPeerDNSResolution),
@@ -179,7 +179,7 @@ func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nb
 	response.NetworkMap.PeerConfig = response.PeerConfig
 
 	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
@@ -189,7 +189,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/proxy.go b/management/internals/shared/grpc/proxy.go
index 40b0914ef..cee50b270 100644
--- a/management/internals/shared/grpc/proxy.go
+++ b/management/internals/shared/grpc/proxy.go
@@ -1311,7 +1311,7 @@ func (s *ProxyServiceServer) authenticateHeader(ctx context.Context, serviceID s
 			lastErr = err
 			continue
 		}
-		return true, "header-user", proxyauth.MethodHeader
+		return true, proxyauth.HeaderUserID, proxyauth.MethodHeader
 	}
 
 	if lastErr != nil {
diff --git a/management/internals/shared/grpc/server.go b/management/internals/shared/grpc/server.go
index 3d5f0a1b7..240243497 100644
--- a/management/internals/shared/grpc/server.go
+++ b/management/internals/shared/grpc/server.go
@@ -42,10 +42,10 @@ import (
 	"github.com/netbirdio/netbird/management/server/auth"
 	nbContext "github.com/netbirdio/netbird/management/server/context"
 	nbpeer "github.com/netbirdio/netbird/management/server/peer"
-	"github.com/netbirdio/netbird/management/server/posture"
 	"github.com/netbirdio/netbird/management/server/settings"
 	"github.com/netbirdio/netbird/management/server/telemetry"
 	"github.com/netbirdio/netbird/management/server/types"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	"github.com/netbirdio/netbird/shared/management/proto"
 	internalStatus "github.com/netbirdio/netbird/shared/management/status"
 )
@@ -902,7 +902,7 @@ func (s *Server) ExtendAuthSession(ctx context.Context, req *proto.EncryptedMess
 	}, nil
 }
 
-func (s *Server) prepareLoginResponse(ctx context.Context, peer *nbpeer.Peer, network *types.Network, postureChecks []*posture.Checks, enableSSH bool) (*proto.LoginResponse, error) {
+func (s *Server) prepareLoginResponse(ctx context.Context, peer *nbpeer.Peer, network *types.Network, postureChecks []*nmdata.PostureChecks, enableSSH bool) (*proto.LoginResponse, error) {
 	var relayToken *Token
 	var err error
 	if s.config.Relay != nil && len(s.config.Relay.Addresses) > 0 {
@@ -920,8 +920,8 @@ func (s *Server) prepareLoginResponse(ctx context.Context, peer *nbpeer.Peer, ne
 
 	// if peer has reached this point then it has logged in
 	loginResp := &proto.LoginResponse{
-		NetbirdConfig: toNetbirdConfig(s.config, nil, relayToken, nil, settings),
-		PeerConfig:    toPeerConfig(peer, network, s.networkMapController.GetDNSDomain(settings), settings, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, enableSSH, false),
+		NetbirdConfig: toNetbirdConfig(s.config, nil, relayToken, nil, types.TwinAccountSettings(settings)),
+		PeerConfig:    toPeerConfig(types.TwinPeer(peer), types.TwinNetwork(network), s.networkMapController.GetDNSDomain(settings), types.TwinAccountSettings(settings), s.config.HttpConfig, s.config.DeviceAuthorizationFlow, enableSSH, false),
 		Checks:        toProtocolChecks(ctx, postureChecks),
 	}
 
@@ -990,7 +990,7 @@ func (s *Server) IsHealthy(ctx context.Context, req *proto.Empty) (*proto.Empty,
 }
 
 // sendInitialSync sends initial proto.SyncResponse to the peer requesting synchronization
-func (s *Server) sendInitialSync(ctx context.Context, peerKey wgtypes.Key, peer *nbpeer.Peer, networkMap *types.NetworkMap, postureChecks []*posture.Checks, srv proto.ManagementService_SyncServer, dnsFwdPort int64) error {
+func (s *Server) sendInitialSync(ctx context.Context, peerKey wgtypes.Key, peer *nbpeer.Peer, networkMap *types.NetworkMap, postureChecks []*nmdata.PostureChecks, srv proto.ManagementService_SyncServer, dnsFwdPort int64) error {
 	var err error
 	var turnToken *Token
 
@@ -1052,9 +1052,9 @@ func (s *Server) sendInitialSync(ctx context.Context, peerKey wgtypes.Key, peer
 			log.WithContext(ctx).Errorf("failed to build components for peer %s on initial sync: %v", peer.ID, err)
 			return status.Errorf(codes.Internal, "failed to build initial sync envelope")
 		}
-		plainResp = ToComponentSyncResponse(ctx, s.config, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, freshPeer, turnToken, relayToken, components, proxyPatch, dnsName, freshPostureChecks, settings, settings.Extra, peerGroups, freshDnsFwdPort)
+		plainResp = ToComponentSyncResponse(ctx, s.config, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, types.TwinPeer(freshPeer), turnToken, relayToken, components, proxyPatch, dnsName, freshPostureChecks, types.TwinAccountSettings(settings), settings.Extra, peerGroups, freshDnsFwdPort)
 	} else {
-		plainResp = ToSyncResponse(ctx, s.config, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, peer, turnToken, relayToken, networkMap, dnsName, postureChecks, nil, settings, settings.Extra, peerGroups, dnsFwdPort)
+		plainResp = ToSyncResponse(ctx, s.config, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, types.TwinPeer(peer), turnToken, relayToken, networkMap, dnsName, postureChecks, nil, types.TwinAccountSettings(settings), settings.Extra, peerGroups, dnsFwdPort)
 	}
 
 	key, err := s.secretsManager.GetWGKey()
@@ -1301,7 +1301,7 @@ func (s *Server) Logout(ctx context.Context, req *proto.EncryptedMessage) (*prot
 }
 
 // toProtocolChecks converts posture checks to protocol checks.
-func toProtocolChecks(ctx context.Context, postureChecks []*posture.Checks) []*proto.Checks {
+func toProtocolChecks(ctx context.Context, postureChecks []*nmdata.PostureChecks) []*proto.Checks {
 	protoChecks := make([]*proto.Checks, 0, len(postureChecks))
 	for _, postureCheck := range postureChecks {
 		check := toProtocolCheck(postureCheck)
@@ -1313,8 +1313,8 @@ func toProtocolChecks(ctx context.Context, postureChecks []*posture.Checks) []*p
 	return protoChecks
 }
 
-// toProtocolCheck converts a posture.Checks to a proto.Checks.
-func toProtocolCheck(postureCheck *posture.Checks) *proto.Checks {
+// toProtocolCheck converts posture checks to a proto.Checks.
+func toProtocolCheck(postureCheck *nmdata.PostureChecks) *proto.Checks {
 	protoCheck := &proto.Checks{}
 
 	if check := postureCheck.Checks.ProcessCheck; check != nil {
diff --git a/management/internals/shared/requestbuffer/buffer.go b/management/internals/shared/requestbuffer/buffer.go
new file mode 100644
index 000000000..c3823776c
--- /dev/null
+++ b/management/internals/shared/requestbuffer/buffer.go
@@ -0,0 +1,102 @@
+// Package requestbuffer coalesces concurrent reads of the same expensive
+// resource into a single fetch.
+package requestbuffer
+
+import (
+	"context"
+	"os"
+	"sync"
+	"time"
+
+	log "github.com/sirupsen/logrus"
+)
+
+// FetchFunc reads the resource identified by key.
+type FetchFunc[T any] func(ctx context.Context, key string) (T, error)
+
+// Buffer batches requests per key: the first request opens a window, every
+// request arriving within it joins the batch, and a single fetch serves them
+// all. The fetch starts only after the window closed, so a caller never
+// observes data read before its own request.
+type Buffer[T any] struct {
+	ctx      context.Context
+	name     string
+	fetch    FetchFunc[T]
+	interval time.Duration
+
+	mu      sync.Mutex
+	waiting map[string][]chan result[T]
+}
+
+type result[T any] struct {
+	value T
+	err   error
+}
+
+// New returns a Buffer serving batched requests through fetch. ctx bounds the
+// fetches, not the callers, and must outlive them.
+func New[T any](ctx context.Context, name string, interval time.Duration, fetch FetchFunc[T]) *Buffer[T] {
+	return &Buffer[T]{
+		ctx:      ctx,
+		name:     name,
+		fetch:    fetch,
+		interval: interval,
+		waiting:  make(map[string][]chan result[T]),
+	}
+}
+
+// Get returns the value for key, sharing one fetch with the other callers of
+// the current batch. The value is shared as is, so callers must treat it as
+// read-only unless the fetch hands out copies.
+func (b *Buffer[T]) Get(ctx context.Context, key string) (T, error) {
+	ch := make(chan result[T], 1)
+
+	b.mu.Lock()
+	b.waiting[key] = append(b.waiting[key], ch)
+	first := len(b.waiting[key]) == 1
+	b.mu.Unlock()
+
+	if first {
+		time.AfterFunc(b.interval, func() { b.flush(key) })
+	}
+
+	select {
+	case res := <-ch:
+		return res.value, res.err
+	case <-ctx.Done():
+		var zero T
+		return zero, ctx.Err()
+	}
+}
+
+func (b *Buffer[T]) flush(key string) {
+	b.mu.Lock()
+	waiting := b.waiting[key]
+	delete(b.waiting, key)
+	b.mu.Unlock()
+
+	if len(waiting) == 0 {
+		return
+	}
+
+	start := time.Now()
+	value, err := b.fetch(b.ctx, key)
+	log.WithContext(b.ctx).Tracef("%s: fetched %s for %d waiters in %s", b.name, key, len(waiting), time.Since(start))
+
+	for _, ch := range waiting {
+		ch <- result[T]{value: value, err: err}
+	}
+}
+
+// Interval reads a buffer interval from envVar, falling back to def.
+func Interval(ctx context.Context, envVar string, def time.Duration) time.Duration {
+	value := os.Getenv(envVar)
+	interval, err := time.ParseDuration(value)
+	if err != nil {
+		if value != "" {
+			log.WithContext(ctx).Warnf("failed to parse %s: %s", envVar, err)
+		}
+		return def
+	}
+	return interval
+}
diff --git a/management/internals/shared/requestbuffer/buffer_test.go b/management/internals/shared/requestbuffer/buffer_test.go
new file mode 100644
index 000000000..9e356145e
--- /dev/null
+++ b/management/internals/shared/requestbuffer/buffer_test.go
@@ -0,0 +1,106 @@
+package requestbuffer
+
+import (
+	"context"
+	"errors"
+	"sync"
+	"sync/atomic"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestBufferCoalescesConcurrentRequests(t *testing.T) {
+	var fetches atomic.Int32
+	buffer := New(context.Background(), "test", 50*time.Millisecond,
+		func(ctx context.Context, key string) (string, error) {
+			fetches.Add(1)
+			return key, nil
+		})
+
+	var wg sync.WaitGroup
+	for range 10 {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			value, err := buffer.Get(context.Background(), "account")
+			assert.NoError(t, err)
+			assert.Equal(t, "account", value)
+		}()
+	}
+	wg.Wait()
+
+	assert.Equal(t, int32(1), fetches.Load())
+}
+
+func TestBufferSeparatesKeys(t *testing.T) {
+	keys := make(chan string, 2)
+	buffer := New(context.Background(), "test", 10*time.Millisecond,
+		func(ctx context.Context, key string) (string, error) {
+			keys <- key
+			return key, nil
+		})
+
+	var wg sync.WaitGroup
+	for _, key := range []string{"a", "b"} {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			_, err := buffer.Get(context.Background(), key)
+			assert.NoError(t, err)
+		}()
+	}
+	wg.Wait()
+	close(keys)
+
+	var fetched []string
+	for key := range keys {
+		fetched = append(fetched, key)
+	}
+	assert.ElementsMatch(t, []string{"a", "b"}, fetched)
+}
+
+func TestBufferFetchesAfterRequest(t *testing.T) {
+	var version atomic.Int32
+	buffer := New(context.Background(), "test", 10*time.Millisecond,
+		func(ctx context.Context, key string) (int32, error) {
+			return version.Load(), nil
+		})
+
+	first, err := buffer.Get(context.Background(), "account")
+	require.NoError(t, err)
+	assert.Equal(t, int32(0), first)
+
+	version.Store(1)
+
+	second, err := buffer.Get(context.Background(), "account")
+	require.NoError(t, err)
+	assert.Equal(t, int32(1), second)
+}
+
+func TestBufferPropagatesError(t *testing.T) {
+	fetchErr := errors.New("fetch failed")
+	buffer := New(context.Background(), "test", 10*time.Millisecond,
+		func(ctx context.Context, key string) (*int, error) {
+			return nil, fetchErr
+		})
+
+	value, err := buffer.Get(context.Background(), "account")
+	assert.ErrorIs(t, err, fetchErr)
+	assert.Nil(t, value)
+}
+
+func TestBufferHonorsCallerContext(t *testing.T) {
+	buffer := New(context.Background(), "test", time.Minute,
+		func(ctx context.Context, key string) (string, error) {
+			return key, nil
+		})
+
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
+	defer cancel()
+
+	_, err := buffer.Get(ctx, "account")
+	assert.ErrorIs(t, err, context.DeadlineExceeded)
+}
diff --git a/management/server/account.go b/management/server/account.go
index 700dfa04d..4fe0e5338 100644
--- a/management/server/account.go
+++ b/management/server/account.go
@@ -52,6 +52,7 @@ import (
 	"github.com/netbirdio/netbird/management/server/util"
 	"github.com/netbirdio/netbird/route"
 	nbdomain "github.com/netbirdio/netbird/shared/management/domain"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	"github.com/netbirdio/netbird/shared/management/status"
 )
 
@@ -1920,7 +1921,7 @@ func domainIsUpToDate(domain string, domainCategory string, userAuth auth.UserAu
 // derived from syncTime (the moment the gRPC stream opened). Any
 // concurrent stream that started earlier loses the optimistic-lock race
 // in MarkPeerConnected and bails without writing.
-func (am *DefaultAccountManager) SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) {
+func (am *DefaultAccountManager) SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) {
 	peer, netMap, postureChecks, dnsfwdPort, err := am.SyncPeer(ctx, types.PeerSync{WireGuardPubKey: peerPubKey, Meta: meta, RealIP: realIP}, accountID)
 	if err != nil {
 		return nil, nil, nil, 0, fmt.Errorf("error syncing peer: %w", err)
diff --git a/management/server/account/manager.go b/management/server/account/manager.go
index f4b0408cf..154c9ab18 100644
--- a/management/server/account/manager.go
+++ b/management/server/account/manager.go
@@ -23,6 +23,7 @@ import (
 	"github.com/netbirdio/netbird/management/server/users"
 	"github.com/netbirdio/netbird/route"
 	"github.com/netbirdio/netbird/shared/management/domain"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 )
 
 type ExternalCacheManager nbcache.UserDataCache
@@ -70,7 +71,7 @@ type Manager interface {
 	UpdatePeerIPv6(ctx context.Context, accountID, userID, peerID string, newIPv6 netip.Addr) error
 	GetNetworkMap(ctx context.Context, peerID string) (*types.NetworkMap, error)
 	GetPeerNetwork(ctx context.Context, peerID string) (*types.Network, error)
-	AddPeer(ctx context.Context, accountID, setupKey, userID string, p *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error)
+	AddPeer(ctx context.Context, accountID, setupKey, userID string, p *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.Network, []*nmdata.PostureChecks, bool, error)
 	CreatePAT(ctx context.Context, accountID string, initiatorUserID string, targetUserID string, tokenName string, expiresIn int) (*types.PersonalAccessTokenGenerated, error)
 	DeletePAT(ctx context.Context, accountID string, initiatorUserID string, targetUserID string, tokenID string) error
 	GetPAT(ctx context.Context, accountID string, initiatorUserID string, targetUserID string, tokenID string) (*types.PersonalAccessToken, error)
@@ -109,9 +110,9 @@ type Manager interface {
 	GetPeer(ctx context.Context, accountID, peerID, userID string) (*nbpeer.Peer, error)
 	UpdateAccountSettings(ctx context.Context, accountID, userID string, newSettings *types.Settings) (*types.Settings, error)
 	UpdateAccountOnboarding(ctx context.Context, accountID, userID string, newOnboarding *types.AccountOnboarding) (*types.AccountOnboarding, error)
-	LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error)                    // used by peer gRPC API
-	ExtendPeerSession(ctx context.Context, peerPubKey, userID string) (time.Time, error)                                                    // used by peer gRPC API for ExtendAuthSession
-	SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) // used by peer gRPC API
+	LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*nmdata.PostureChecks, bool, error)                    // used by peer gRPC API
+	ExtendPeerSession(ctx context.Context, peerPubKey, userID string) (time.Time, error)                                                          // used by peer gRPC API for ExtendAuthSession
+	SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) // used by peer gRPC API
 	GetExternalCacheManager() ExternalCacheManager
 	GetPostureChecks(ctx context.Context, accountID, postureChecksID, userID string) (*posture.Checks, error)
 	SavePostureChecks(ctx context.Context, accountID, userID string, postureChecks *posture.Checks, create bool) (*posture.Checks, error)
@@ -121,7 +122,7 @@ type Manager interface {
 	UpdateIntegratedValidator(ctx context.Context, accountID, userID, validator string, groups []string) error
 	GroupValidation(ctx context.Context, accountId string, groups []string) (bool, error)
 	GetValidatedPeers(ctx context.Context, accountID string) (map[string]struct{}, map[string]string, error)
-	SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error)
+	SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*nmdata.PostureChecks, int64, error)
 	OnPeerDisconnected(ctx context.Context, accountID string, peerPubKey string, streamStartTime time.Time) error
 	SyncPeerMeta(ctx context.Context, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP) error
 	FindExistingPostureCheck(accountID string, checks *posture.ChecksDefinition) (*posture.Checks, error)
diff --git a/management/server/account/manager_mock.go b/management/server/account/manager_mock.go
index 9ac10cba0..f31f63d0e 100644
--- a/management/server/account/manager_mock.go
+++ b/management/server/account/manager_mock.go
@@ -29,6 +29,7 @@ import (
 	route "github.com/netbirdio/netbird/route"
 	auth "github.com/netbirdio/netbird/shared/auth"
 	domain "github.com/netbirdio/netbird/shared/management/domain"
+	nmdata "github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	gomock "go.uber.org/mock/gomock"
 )
 
@@ -86,12 +87,12 @@ func (mr *MockManagerMockRecorder) AccountExists(ctx, accountID any) *gomock.Cal
 }
 
 // AddPeer mocks base method.
-func (m *MockManager) AddPeer(ctx context.Context, accountID, setupKey, userID string, p *peer.Peer, temporary bool) (*peer.Peer, *types.Network, []*posture.Checks, bool, error) {
+func (m *MockManager) AddPeer(ctx context.Context, accountID, setupKey, userID string, p *peer.Peer, temporary bool) (*peer.Peer, *types.Network, []*nmdata.PostureChecks, bool, error) {
 	m.ctrl.T.Helper()
 	ret := m.ctrl.Call(m, "AddPeer", ctx, accountID, setupKey, userID, p, temporary)
 	ret0, _ := ret[0].(*peer.Peer)
 	ret1, _ := ret[1].(*types.Network)
-	ret2, _ := ret[2].([]*posture.Checks)
+	ret2, _ := ret[2].([]*nmdata.PostureChecks)
 	ret3, _ := ret[3].(bool)
 	ret4, _ := ret[4].(error)
 	return ret0, ret1, ret2, ret3, ret4
@@ -1323,12 +1324,12 @@ func (mr *MockManagerMockRecorder) ListUsers(ctx, accountID any) *gomock.Call {
 }
 
 // LoginPeer mocks base method.
-func (m *MockManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*peer.Peer, *types.Network, []*posture.Checks, bool, error) {
+func (m *MockManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*peer.Peer, *types.Network, []*nmdata.PostureChecks, bool, error) {
 	m.ctrl.T.Helper()
 	ret := m.ctrl.Call(m, "LoginPeer", ctx, login)
 	ret0, _ := ret[0].(*peer.Peer)
 	ret1, _ := ret[1].(*types.Network)
-	ret2, _ := ret[2].([]*posture.Checks)
+	ret2, _ := ret[2].([]*nmdata.PostureChecks)
 	ret3, _ := ret[3].(bool)
 	ret4, _ := ret[4].(error)
 	return ret0, ret1, ret2, ret3, ret4
@@ -1568,12 +1569,12 @@ func (mr *MockManagerMockRecorder) StoreEvent(ctx, initiatorID, targetID, accoun
 }
 
 // SyncAndMarkPeer mocks base method.
-func (m *MockManager) SyncAndMarkPeer(ctx context.Context, accountID, peerPubKey string, meta peer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*peer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) {
+func (m *MockManager) SyncAndMarkPeer(ctx context.Context, accountID, peerPubKey string, meta peer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*peer.Peer, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) {
 	m.ctrl.T.Helper()
 	ret := m.ctrl.Call(m, "SyncAndMarkPeer", ctx, accountID, peerPubKey, meta, realIP, syncTime)
 	ret0, _ := ret[0].(*peer.Peer)
 	ret1, _ := ret[1].(*types.NetworkMap)
-	ret2, _ := ret[2].([]*posture.Checks)
+	ret2, _ := ret[2].([]*nmdata.PostureChecks)
 	ret3, _ := ret[3].(int64)
 	ret4, _ := ret[4].(error)
 	return ret0, ret1, ret2, ret3, ret4
@@ -1586,12 +1587,12 @@ func (mr *MockManagerMockRecorder) SyncAndMarkPeer(ctx, accountID, peerPubKey, m
 }
 
 // SyncPeer mocks base method.
-func (m *MockManager) SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*peer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) {
+func (m *MockManager) SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*peer.Peer, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) {
 	m.ctrl.T.Helper()
 	ret := m.ctrl.Call(m, "SyncPeer", ctx, sync, accountID)
 	ret0, _ := ret[0].(*peer.Peer)
 	ret1, _ := ret[1].(*types.NetworkMap)
-	ret2, _ := ret[2].([]*posture.Checks)
+	ret2, _ := ret[2].([]*nmdata.PostureChecks)
 	ret3, _ := ret[3].(int64)
 	ret4, _ := ret[4].(error)
 	return ret0, ret1, ret2, ret3, ret4
diff --git a/management/server/account/request_buffer.go b/management/server/account/request_buffer.go
index eced1929f..3f91996eb 100644
--- a/management/server/account/request_buffer.go
+++ b/management/server/account/request_buffer.go
@@ -6,6 +6,8 @@ import (
 	"github.com/netbirdio/netbird/management/server/types"
 )
 
+//go:generate go tool mockgen -package=account -source=./request_buffer.go -destination=request_buffer_mock.go
+
 type RequestBuffer interface {
 	GetAccountWithBackpressure(ctx context.Context, accountID string) (*types.Account, error)
 }
diff --git a/management/server/account/request_buffer_mock.go b/management/server/account/request_buffer_mock.go
new file mode 100644
index 000000000..b48ef2700
--- /dev/null
+++ b/management/server/account/request_buffer_mock.go
@@ -0,0 +1,57 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: ./request_buffer.go
+//
+// Generated by this command:
+//
+//	mockgen -package=account -source=./request_buffer.go -destination=request_buffer_mock.go
+//
+
+// Package account is a generated GoMock package.
+package account
+
+import (
+	context "context"
+	reflect "reflect"
+
+	types "github.com/netbirdio/netbird/management/server/types"
+	gomock "go.uber.org/mock/gomock"
+)
+
+// MockRequestBuffer is a mock of RequestBuffer interface.
+type MockRequestBuffer struct {
+	ctrl     *gomock.Controller
+	recorder *MockRequestBufferMockRecorder
+	isgomock struct{}
+}
+
+// MockRequestBufferMockRecorder is the mock recorder for MockRequestBuffer.
+type MockRequestBufferMockRecorder struct {
+	mock *MockRequestBuffer
+}
+
+// NewMockRequestBuffer creates a new mock instance.
+func NewMockRequestBuffer(ctrl *gomock.Controller) *MockRequestBuffer {
+	mock := &MockRequestBuffer{ctrl: ctrl}
+	mock.recorder = &MockRequestBufferMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockRequestBuffer) EXPECT() *MockRequestBufferMockRecorder {
+	return m.recorder
+}
+
+// GetAccountWithBackpressure mocks base method.
+func (m *MockRequestBuffer) GetAccountWithBackpressure(ctx context.Context, accountID string) (*types.Account, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetAccountWithBackpressure", ctx, accountID)
+	ret0, _ := ret[0].(*types.Account)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// GetAccountWithBackpressure indicates an expected call of GetAccountWithBackpressure.
+func (mr *MockRequestBufferMockRecorder) GetAccountWithBackpressure(ctx, accountID any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountWithBackpressure", reflect.TypeOf((*MockRequestBuffer)(nil).GetAccountWithBackpressure), ctx, accountID)
+}
diff --git a/management/server/account_request_buffer.go b/management/server/account_request_buffer.go
index e1672c2d0..792099431 100644
--- a/management/server/account_request_buffer.go
+++ b/management/server/account_request_buffer.go
@@ -2,117 +2,38 @@ package server
 
 import (
 	"context"
-	"os"
-	"sync"
 	"time"
 
 	log "github.com/sirupsen/logrus"
 
+	"github.com/netbirdio/netbird/management/internals/shared/requestbuffer"
 	"github.com/netbirdio/netbird/management/server/store"
 	"github.com/netbirdio/netbird/management/server/types"
 )
 
-// AccountRequest holds the result channel to return the requested account.
-type AccountRequest struct {
-	AccountID  string
-	ResultChan chan *AccountResult
-}
-
-// AccountResult holds the account data or an error.
-type AccountResult struct {
-	Account *types.Account
-	Err     error
-}
+const defaultAccountBufferInterval = 100 * time.Millisecond
 
 type AccountRequestBuffer struct {
-	store               store.Store
-	getAccountRequests  map[string][]*AccountRequest
-	mu                  sync.Mutex
-	getAccountRequestCh chan *AccountRequest
-	bufferInterval      time.Duration
+	buffer *requestbuffer.Buffer[*types.Account]
 }
 
 func NewAccountRequestBuffer(ctx context.Context, store store.Store) *AccountRequestBuffer {
-	bufferIntervalStr := os.Getenv("NB_GET_ACCOUNT_BUFFER_INTERVAL")
-	bufferInterval, err := time.ParseDuration(bufferIntervalStr)
-	if err != nil {
-		if bufferIntervalStr != "" {
-			log.WithContext(ctx).Warnf("failed to parse account request buffer interval: %s", err)
-		}
-		bufferInterval = 100 * time.Millisecond
+	interval := requestbuffer.Interval(ctx, "NB_GET_ACCOUNT_BUFFER_INTERVAL", defaultAccountBufferInterval)
+	log.WithContext(ctx).Infof("set account request buffer interval to %s", interval)
+
+	return &AccountRequestBuffer{
+		buffer: requestbuffer.New(ctx, "account request buffer", interval, store.GetAccount),
 	}
-
-	log.WithContext(ctx).Infof("set account request buffer interval to %s", bufferInterval)
-
-	ac := AccountRequestBuffer{
-		store:               store,
-		getAccountRequests:  make(map[string][]*AccountRequest),
-		getAccountRequestCh: make(chan *AccountRequest),
-		bufferInterval:      bufferInterval,
-	}
-
-	go ac.processGetAccountRequests(ctx)
-
-	return &ac
 }
+
 func (ac *AccountRequestBuffer) GetAccountWithBackpressure(ctx context.Context, accountID string) (*types.Account, error) {
-	req := &AccountRequest{
-		AccountID:  accountID,
-		ResultChan: make(chan *AccountResult, 1),
+	account, err := ac.buffer.Get(ctx, accountID)
+	if err != nil || account == nil {
+		return account, err
 	}
 
-	log.WithContext(ctx).Tracef("requesting account %s with backpressure", accountID)
-	startTime := time.Now()
-	ac.getAccountRequestCh <- req
-
-	result := <-req.ResultChan
-	log.WithContext(ctx).Tracef("got account with backpressure after %s", time.Since(startTime))
-	return result.Account, result.Err
-}
-
-func (ac *AccountRequestBuffer) processGetAccountBatch(ctx context.Context, accountID string) {
-	ac.mu.Lock()
-	requests := ac.getAccountRequests[accountID]
-	delete(ac.getAccountRequests, accountID)
-	ac.mu.Unlock()
-
-	if len(requests) == 0 {
-		return
-	}
-
-	startTime := time.Now()
-	account, err := ac.store.GetAccount(ctx, accountID)
-	log.WithContext(ctx).Tracef("getting account %s in batch took %s", accountID, time.Since(startTime))
-	result := &AccountResult{Account: account, Err: err}
-
-	for _, req := range requests {
-		if account != nil {
-			// Shallow copy the account so each goroutine gets its own struct value.
-			// This prevents data races when callers mutate fields like Policies.
-			accountCopy := *account
-			req.ResultChan <- &AccountResult{Account: &accountCopy, Err: err}
-		} else {
-			req.ResultChan <- result
-		}
-		close(req.ResultChan)
-	}
-}
-
-func (ac *AccountRequestBuffer) processGetAccountRequests(ctx context.Context) {
-	for {
-		select {
-		case req := <-ac.getAccountRequestCh:
-			ac.mu.Lock()
-			ac.getAccountRequests[req.AccountID] = append(ac.getAccountRequests[req.AccountID], req)
-			if len(ac.getAccountRequests[req.AccountID]) == 1 {
-				go func(ctx context.Context, accountID string) {
-					time.Sleep(ac.bufferInterval)
-					ac.processGetAccountBatch(ctx, accountID)
-				}(ctx, req.AccountID)
-			}
-			ac.mu.Unlock()
-		case <-ctx.Done():
-			return
-		}
-	}
+	// Shallow copy the account so each caller gets its own struct value.
+	// This prevents data races when callers mutate fields like Policies.
+	accountCopy := *account
+	return &accountCopy, nil
 }
diff --git a/management/server/account_test.go b/management/server/account_test.go
index 5a826e103..b462cc2a6 100644
--- a/management/server/account_test.go
+++ b/management/server/account_test.go
@@ -10,16 +10,17 @@ import (
 	"os"
 	"reflect"
 	"strconv"
+	"strings"
 	"sync"
 	"testing"
 	"time"
 
-	"go.uber.org/mock/gomock"
 	"github.com/prometheus/client_golang/prometheus/push"
 	log "github.com/sirupsen/logrus"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 	"go.opentelemetry.io/otel/metric/noop"
+	"go.uber.org/mock/gomock"
 	"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
 
 	"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
@@ -37,6 +38,8 @@ import (
 	"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
 	reverseproxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service/manager"
 	"github.com/netbirdio/netbird/management/internals/modules/zones"
+	networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
+	networkmapdbfactory "github.com/netbirdio/netbird/management/internals/network_map_db/factory"
 	"github.com/netbirdio/netbird/management/internals/server/config"
 	nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
 	nbAccount "github.com/netbirdio/netbird/management/server/account"
@@ -3293,13 +3296,33 @@ func createManager(t testing.TB) (*DefaultAccountManager, *update_channel.PeersU
 	if err != nil {
 		return nil, nil, err
 	}
-	eventStore := &activity.InMemoryEventStore{}
+	return buildTestManager(t, store, nil)
+}
 
-	metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
-	if err != nil {
-		return nil, nil, err
+// createManagerWithNetworkMapStore builds a manager whose network map controller
+// reads the twin (nmdata) store, the production path on sqlite and postgres.
+func createManagerWithNetworkMapStore(t testing.TB) (*DefaultAccountManager, *update_channel.PeersUpdateManager) {
+	t.Helper()
+
+	if engine := os.Getenv("NETBIRD_STORE_ENGINE"); engine != "" && !strings.EqualFold(engine, string(types.SqliteStoreEngine)) {
+		t.Skipf("network map store test needs the sqlite engine, got %s", engine)
 	}
 
+	dataDir := t.TempDir()
+	store, err := createStoreAt(t, dataDir)
+	require.NoError(t, err)
+
+	nmdataStore, err := networkmapdbfactory.NewNetworkMapDBStore(context.Background(), types.SqliteStoreEngine, dataDir, MockIntegratedValidator{}, newSettingsMockManager(t))
+	require.NoError(t, err)
+
+	manager, updateManager, err := buildTestManager(t, store, nmdataStore)
+	require.NoError(t, err)
+	return manager, updateManager
+}
+
+func newSettingsMockManager(t testing.TB) *settings.MockManager {
+	t.Helper()
+
 	ctrl := gomock.NewController(t)
 	t.Cleanup(ctrl.Finish)
 
@@ -3312,6 +3335,23 @@ func createManager(t testing.TB) (*DefaultAccountManager, *update_channel.PeersU
 		UpdateExtraSettings(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
 		Return(false, nil).
 		AnyTimes()
+	return settingsMockManager
+}
+
+func buildTestManager(t testing.TB, store store.Store, nmdataStore *networkmapdb.NetworkMapDBStoreImpl) (*DefaultAccountManager, *update_channel.PeersUpdateManager, error) {
+	t.Helper()
+
+	eventStore := &activity.InMemoryEventStore{}
+
+	metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
+	if err != nil {
+		return nil, nil, err
+	}
+
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	settingsMockManager := newSettingsMockManager(t)
 
 	permissionsManager := permissions.NewManager(store)
 	peersManager := peers.NewManager(store, permissionsManager)
@@ -3331,7 +3371,7 @@ func createManager(t testing.TB) (*DefaultAccountManager, *update_channel.PeersU
 
 	updateManager := update_channel.NewPeersUpdateManager(metrics)
 	requestBuffer := NewAccountRequestBuffer(ctx, store)
-	networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{})
+	networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nmdataStore)
 	manager, err := BuildManager(ctx, &config.Config{}, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
 	if err != nil {
 		return nil, nil, err
@@ -3349,7 +3389,11 @@ func createManager(t testing.TB) (*DefaultAccountManager, *update_channel.PeersU
 
 func createStore(t testing.TB) (store.Store, error) {
 	t.Helper()
-	dataDir := t.TempDir()
+	return createStoreAt(t, t.TempDir())
+}
+
+func createStoreAt(t testing.TB, dataDir string) (store.Store, error) {
+	t.Helper()
 	store, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "", dataDir)
 	if err != nil {
 		return nil, err
diff --git a/management/server/affected_peers_property_test.go b/management/server/affected_peers_property_test.go
index f393465bc..b64aeb813 100644
--- a/management/server/affected_peers_property_test.go
+++ b/management/server/affected_peers_property_test.go
@@ -27,8 +27,6 @@ func allPeerMaps(t *testing.T, manager *DefaultAccountManager, accountID string)
 	account, err := manager.Store.GetAccount(ctx, accountID)
 	require.NoError(t, err)
 
-	account.InjectProxyPolicies(ctx)
-
 	validated := make(map[string]struct{}, len(account.Peers))
 	for id := range account.Peers {
 		validated[id] = struct{}{}
diff --git a/management/server/affected_peers_router_paths_test.go b/management/server/affected_peers_router_paths_test.go
index 5d83367fd..d5868a5c1 100644
--- a/management/server/affected_peers_router_paths_test.go
+++ b/management/server/affected_peers_router_paths_test.go
@@ -12,6 +12,7 @@ import (
 	resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
 	routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
 	"github.com/netbirdio/netbird/management/server/posture"
+	"github.com/netbirdio/netbird/management/server/store"
 	"github.com/netbirdio/netbird/management/server/types"
 )
 
@@ -145,7 +146,7 @@ func TestAffectedPeers_GroupAddResource_RefreshesRoutingPeer(t *testing.T) {
 	assert.NotContains(t, affected, s.unrelatedPeerID, "unrelated peer must not be affected")
 }
 
-func (s *routerScenario) createPostureCheckGatedPolicy(t *testing.T, ctx context.Context) string {
+func (s *routerScenario) createPostureCheckGatedPolicy(t *testing.T, ctx context.Context, policy *types.Policy) string {
 	t.Helper()
 
 	check, err := s.manager.SavePostureChecks(ctx, s.accountID, userID, &posture.Checks{
@@ -156,7 +157,6 @@ func (s *routerScenario) createPostureCheckGatedPolicy(t *testing.T, ctx context
 	}, true)
 	require.NoError(t, err)
 
-	policy := peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID)
 	policy.SourcePostureChecks = []string{check.ID}
 	_, err = s.manager.SavePolicy(ctx, s.accountID, userID, policy, true)
 	require.NoError(t, err)
@@ -168,7 +168,7 @@ func TestAffectedPeers_E2E_SavePostureCheck_RefreshesRoutingPeer(t *testing.T) {
 	s := setupRouterScenario(t, true)
 	ctx := context.Background()
 
-	checkID := s.createPostureCheckGatedPolicy(t, ctx)
+	checkID := s.createPostureCheckGatedPolicy(t, ctx, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID))
 
 	srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID)
 	routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID)
@@ -338,3 +338,61 @@ func TestAffectedPeers_PeerChange_RouterInOtherNetworkNotAffected(t *testing.T)
 	assert.NotContains(t, affected, second.routerPeerID,
 		"a router in an unrelated network must not be affected by a source-peer change for another resource")
 }
+
+// TestAffectedPeers_E2E_PostureFlip_RefreshesRoutingPeer drives the customer path
+// on the twin store: the source peer's metadata flips a posture verdict on sync,
+// and the routing peer serving the gated resource must be refreshed in both
+// directions. Without the flip detection the deny direction takes the nmap
+// shortcut (the denied peer's map holds no router) and the allow direction
+// depends on which meta field moved, leaving the routers with a stale map.
+func TestAffectedPeers_E2E_PostureFlip_RefreshesRoutingPeer(t *testing.T) {
+	runPostureFlipRefreshesRoutingPeer(t, func(s *routerScenario) *types.Policy {
+		return peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID)
+	})
+}
+
+// TestAffectedPeers_E2E_PostureFlip_DirectSourcePeer_RefreshesRoutingPeer is the same
+// scenario with the source peer named directly in the rule: it must receive its posture
+// checks and have its flips detected exactly like a group member.
+func TestAffectedPeers_E2E_PostureFlip_DirectSourcePeer_RefreshesRoutingPeer(t *testing.T) {
+	runPostureFlipRefreshesRoutingPeer(t, func(s *routerScenario) *types.Policy {
+		return peerToResourcePolicyByPeer(s.sourcePeerID, s.resourceGroupID)
+	})
+}
+
+func runPostureFlipRefreshesRoutingPeer(t *testing.T, policyFor func(s *routerScenario) *types.Policy) {
+	t.Helper()
+
+	manager, updateManager := createManagerWithNetworkMapStore(t)
+	s := buildRouterScenario(t, manager, updateManager, true)
+	ctx := context.Background()
+
+	s.createPostureCheckGatedPolicy(t, ctx, policyFor(s))
+
+	source, err := s.manager.Store.GetPeerByID(ctx, store.LockingStrengthNone, s.accountID, s.sourcePeerID)
+	require.NoError(t, err)
+
+	syncWithVersion := func(version string) {
+		meta := source.Meta
+		meta.WtVersion = version
+		_, _, _, _, err := s.manager.SyncPeer(ctx, types.PeerSync{WireGuardPubKey: source.Key, Meta: meta}, s.accountID)
+		require.NoError(t, err)
+	}
+	syncWithVersion("0.31.0")
+
+	routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID)
+	unrelatedCh := s.updateManager.CreateChannel(ctx, s.unrelatedPeerID)
+	t.Cleanup(func() {
+		s.updateManager.CloseChannel(ctx, s.routerPeerID)
+		s.updateManager.CloseChannel(ctx, s.unrelatedPeerID)
+	})
+	settleAffectedUpdates(routerCh, unrelatedCh)
+
+	syncWithVersion("0.29.0")
+	peerShouldReceiveUpdate(t, routerCh)
+	peerShouldNotReceiveUpdate(t, unrelatedCh)
+
+	syncWithVersion("0.31.0")
+	peerShouldReceiveUpdate(t, routerCh)
+	peerShouldNotReceiveUpdate(t, unrelatedCh)
+}
diff --git a/management/server/affected_peers_router_test.go b/management/server/affected_peers_router_test.go
index cc9df0a6a..7e3f02b27 100644
--- a/management/server/affected_peers_router_test.go
+++ b/management/server/affected_peers_router_test.go
@@ -60,6 +60,12 @@ func setupRouterScenario(t *testing.T, directRouterPeer bool) *routerScenario {
 	manager, updateManager, err := createManager(t)
 	require.NoError(t, err)
 
+	return buildRouterScenario(t, manager, updateManager, directRouterPeer)
+}
+
+func buildRouterScenario(t *testing.T, manager *DefaultAccountManager, updateManager *update_channel.PeersUpdateManager, directRouterPeer bool) *routerScenario {
+	t.Helper()
+
 	ctx := context.Background()
 
 	account, err := createAccount(manager, "router_scenario", userID, "")
@@ -167,6 +173,23 @@ func peerToResourcePolicyByGroup(sourceGroupID, resourceGroupID string) *types.P
 	}
 }
 
+// peerToResourcePolicyByPeer builds a policy naming the source peer directly via
+// SourceResource rather than through a group.
+func peerToResourcePolicyByPeer(sourcePeerID, resourceGroupID string) *types.Policy {
+	return &types.Policy{
+		Enabled: true,
+		Name:    "peer-to-resource-by-peer",
+		Rules: []*types.PolicyRule{
+			{
+				Enabled:        true,
+				SourceResource: types.Resource{ID: sourcePeerID, Type: types.ResourceTypePeer},
+				Destinations:   []string{resourceGroupID},
+				Action:         types.PolicyTrafficActionAccept,
+			},
+		},
+	}
+}
+
 // peerToResourcePolicyByResource builds a policy referencing the resource
 // directly via DestinationResource rather than its group.
 func peerToResourcePolicyByResource(sourceGroupID, resourceID string) *types.Policy {
diff --git a/management/server/dns_test.go b/management/server/dns_test.go
index d7667a304..25bef664c 100644
--- a/management/server/dns_test.go
+++ b/management/server/dns_test.go
@@ -234,7 +234,7 @@ func createDNSManager(t *testing.T) (*DefaultAccountManager, error) {
 
 	updateManager := update_channel.NewPeersUpdateManager(metrics)
 	requestBuffer := NewAccountRequestBuffer(ctx, store)
-	networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.test", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{})
+	networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.test", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil)
 
 	return BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
 }
diff --git a/management/server/groups/manager.go b/management/server/groups/manager.go
index 6d19b1c35..893be1e5a 100644
--- a/management/server/groups/manager.go
+++ b/management/server/groups/manager.go
@@ -6,7 +6,6 @@ import (
 
 	"github.com/netbirdio/netbird/management/server/account"
 	"github.com/netbirdio/netbird/management/server/activity"
-	resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
 	"github.com/netbirdio/netbird/management/server/permissions"
 	"github.com/netbirdio/netbird/management/server/permissions/modules"
 	"github.com/netbirdio/netbird/management/server/permissions/operations"
@@ -31,10 +30,6 @@ type managerImpl struct {
 	accountManager     account.Manager
 }
 
-func eventMetaResource(group *types.Group, resource *resourceTypes.NetworkResource) map[string]any {
-	return map[string]any{"name": group.Name, "id": group.ID, "resource_name": resource.Name, "resource_id": resource.ID, "resource_type": resource.Type}
-}
-
 type mockManager struct {
 }
 
@@ -114,7 +109,7 @@ func (m *managerImpl) AddResourceToGroupInTransaction(ctx context.Context, trans
 	}
 
 	event := func() {
-		m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceAddedToGroup, eventMetaResource(group, networkResource))
+		m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceAddedToGroup, group.EventMetaResource(types.TwinNetworkResource(networkResource)))
 	}
 
 	return event, nil
@@ -138,7 +133,7 @@ func (m *managerImpl) RemoveResourceFromGroupInTransaction(ctx context.Context,
 	}
 
 	event := func() {
-		m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceRemovedFromGroup, eventMetaResource(group, networkResource))
+		m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceRemovedFromGroup, group.EventMetaResource(types.TwinNetworkResource(networkResource)))
 	}
 
 	return event, nil
diff --git a/management/server/http/handlers/peers/peers_handler.go b/management/server/http/handlers/peers/peers_handler.go
index 03a37c3ec..1d7dd69f5 100644
--- a/management/server/http/handlers/peers/peers_handler.go
+++ b/management/server/http/handlers/peers/peers_handler.go
@@ -446,7 +446,7 @@ func (h *Handler) GetAccessiblePeers(w http.ResponseWriter, r *http.Request) {
 
 	netMap := account.GetPeerNetworkMapFromComponents(ctx, peerID, dns.CustomZone{}, nil, validPeers, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), nil, account.GetActiveGroupUsers())
 
-	util.WriteJSONObject(ctx, w, toAccessiblePeers(netMap, account.Peers, dnsDomain))
+	util.WriteJSONObject(ctx, w, toAccessiblePeers(account.Peers, netMap, dnsDomain))
 }
 
 func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) {
@@ -534,20 +534,22 @@ func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request)
 	util.WriteJSONObject(r.Context(), w, resp)
 }
 
-// toAccessiblePeers rehydrates the calculated map's component peers into the
-// account's full peer objects, which carry the location/status/meta fields
-// the API response needs.
-func toAccessiblePeers(netMap *types.NetworkMap, accountPeers map[string]*nbpeer.Peer, dnsDomain string) []api.AccessiblePeer {
+// toAccessiblePeers resolves the twin peers in netMap back to the full account
+// peers (by ID) so the API response keeps Status/Name/OS/GeoNameID, which the
+// slim netmap twins intentionally don't carry.
+func toAccessiblePeers(accountPeers map[string]*nbpeer.Peer, netMap *types.NetworkMap, dnsDomain string) []api.AccessiblePeer {
 	accessiblePeers := make([]api.AccessiblePeer, 0, len(netMap.Peers)+len(netMap.OfflinePeers))
-	add := func(peers []*types.ComponentPeer) {
-		for _, p := range peers {
-			if peer := accountPeers[p.ID]; peer != nil {
-				accessiblePeers = append(accessiblePeers, peerToAccessiblePeer(peer, dnsDomain))
-			}
+	appendByID := func(id string) {
+		if p, ok := accountPeers[id]; ok && p != nil {
+			accessiblePeers = append(accessiblePeers, peerToAccessiblePeer(p, dnsDomain))
 		}
 	}
-	add(netMap.Peers)
-	add(netMap.OfflinePeers)
+	for _, p := range netMap.Peers {
+		appendByID(p.ID)
+	}
+	for _, p := range netMap.OfflinePeers {
+		appendByID(p.ID)
+	}
 
 	return accessiblePeers
 }
diff --git a/management/server/http/testing/testing_tools/channel/channel.go b/management/server/http/testing/testing_tools/channel/channel.go
index 8b05b2ddf..44408d751 100644
--- a/management/server/http/testing/testing_tools/channel/channel.go
+++ b/management/server/http/testing/testing_tools/channel/channel.go
@@ -96,7 +96,7 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee
 	}
 
 	requestBuffer := server.NewAccountRequestBuffer(ctx, store)
-	networkMapController := controller.NewController(ctx, store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsManager, "", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peersManager), &config.Config{})
+	networkMapController := controller.NewController(ctx, store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsManager, "", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peersManager), &config.Config{}, nil)
 	am, err := server.BuildManager(ctx, nil, store, networkMapController, jobManager, nil, "", &activity.InMemoryEventStore{}, geoMock, false, validatorMock, metrics, proxyController, settingsManager, permissionsManager, false, cacheStore)
 	if err != nil {
 		t.Fatalf("Failed to create manager: %v", err)
@@ -226,7 +226,7 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin
 	}
 
 	requestBuffer := server.NewAccountRequestBuffer(ctx, store)
-	networkMapController := controller.NewController(ctx, store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsManager, "", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peersManager), &config.Config{})
+	networkMapController := controller.NewController(ctx, store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsManager, "", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peersManager), &config.Config{}, nil)
 	am, err := server.BuildManager(ctx, nil, store, networkMapController, jobManager, nil, "", &activity.InMemoryEventStore{}, geoMock, false, validatorMock, metrics, proxyController, settingsManager, permissionsManager, false, cacheStore)
 	if err != nil {
 		t.Fatalf("Failed to create manager: %v", err)
diff --git a/management/server/identity_provider_test.go b/management/server/identity_provider_test.go
index b55d4f24c..eef69dc14 100644
--- a/management/server/identity_provider_test.go
+++ b/management/server/identity_provider_test.go
@@ -92,7 +92,7 @@ func createManagerWithEmbeddedIdP(t testing.TB) (*DefaultAccountManager, *update
 
 	updateManager := update_channel.NewPeersUpdateManager(metrics)
 	requestBuffer := NewAccountRequestBuffer(ctx, testStore)
-	networkMapController := controller.NewController(ctx, testStore, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(testStore, peersManager), &config.Config{})
+	networkMapController := controller.NewController(ctx, testStore, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(testStore, peersManager), &config.Config{}, nil)
 	manager, err := BuildManager(ctx, &config.Config{}, testStore, networkMapController, job.NewJobManager(nil, testStore, peersManager), idpManager, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
 	if err != nil {
 		return nil, nil, err
diff --git a/management/server/integrated_validator.go b/management/server/integrated_validator.go
index 69ea668ad..9ec1f491e 100644
--- a/management/server/integrated_validator.go
+++ b/management/server/integrated_validator.go
@@ -11,6 +11,7 @@ import (
 	nbpeer "github.com/netbirdio/netbird/management/server/peer"
 	"github.com/netbirdio/netbird/management/server/store"
 	"github.com/netbirdio/netbird/management/server/types"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 )
 
 // UpdateIntegratedValidator updates the integrated validator groups for a specified account.
@@ -109,7 +110,7 @@ func (am *DefaultAccountManager) GetValidatedPeers(ctx context.Context, accountI
 		return nil, nil, err
 	}
 
-	validPeers, err := am.integratedPeerValidator.GetValidatedPeers(ctx, accountID, groups, peers, settings.Extra)
+	validPeers, err := am.integratedPeerValidator.GetValidatedPeers(ctx, accountID, types.TwinGroups(groups), types.TwinPeers(peers), settings.Extra)
 	if err != nil {
 		return nil, nil, err
 	}
@@ -138,7 +139,7 @@ func (a MockIntegratedValidator) ValidatePeer(_ context.Context, update *nbpeer.
 	return update, false, nil
 }
 
-func (a MockIntegratedValidator) GetValidatedPeers(_ context.Context, accountID string, groups []*types.Group, peers []*nbpeer.Peer, extraSettings *types.ExtraSettings) (map[string]struct{}, error) {
+func (a MockIntegratedValidator) GetValidatedPeers(_ context.Context, accountID string, groups []*nmdata.Group, peers []*nmdata.Peer, extraSettings *types.ExtraSettings) (map[string]struct{}, error) {
 	validatedPeers := make(map[string]struct{})
 	for _, peer := range peers {
 		validatedPeers[peer.ID] = struct{}{}
diff --git a/management/server/integrations/integrated_validator/integrated_validator_mock.go b/management/server/integrations/integrated_validator/integrated_validator_mock.go
new file mode 100644
index 000000000..73178a869
--- /dev/null
+++ b/management/server/integrations/integrated_validator/integrated_validator_mock.go
@@ -0,0 +1,187 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: ./interface.go
+//
+// Generated by this command:
+//
+//	mockgen -package integrated_validator -destination=integrated_validator_mock.go -source=./interface.go -build_flags=-mod=mod
+//
+
+// Package integrated_validator is a generated GoMock package.
+package integrated_validator
+
+import (
+	context "context"
+	reflect "reflect"
+
+	peer "github.com/netbirdio/netbird/management/server/peer"
+	types "github.com/netbirdio/netbird/management/server/types"
+	nmdata "github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+	proto "github.com/netbirdio/netbird/shared/management/proto"
+	gomock "go.uber.org/mock/gomock"
+)
+
+// MockIntegratedValidator is a mock of IntegratedValidator interface.
+type MockIntegratedValidator struct {
+	ctrl     *gomock.Controller
+	recorder *MockIntegratedValidatorMockRecorder
+	isgomock struct{}
+}
+
+// MockIntegratedValidatorMockRecorder is the mock recorder for MockIntegratedValidator.
+type MockIntegratedValidatorMockRecorder struct {
+	mock *MockIntegratedValidator
+}
+
+// NewMockIntegratedValidator creates a new mock instance.
+func NewMockIntegratedValidator(ctrl *gomock.Controller) *MockIntegratedValidator {
+	mock := &MockIntegratedValidator{ctrl: ctrl}
+	mock.recorder = &MockIntegratedValidatorMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockIntegratedValidator) EXPECT() *MockIntegratedValidatorMockRecorder {
+	return m.recorder
+}
+
+// GetInvalidPeers mocks base method.
+func (m *MockIntegratedValidator) GetInvalidPeers(ctx context.Context, accountID string, extraSettings *types.ExtraSettings) (map[string]string, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetInvalidPeers", ctx, accountID, extraSettings)
+	ret0, _ := ret[0].(map[string]string)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// GetInvalidPeers indicates an expected call of GetInvalidPeers.
+func (mr *MockIntegratedValidatorMockRecorder) GetInvalidPeers(ctx, accountID, extraSettings any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInvalidPeers", reflect.TypeOf((*MockIntegratedValidator)(nil).GetInvalidPeers), ctx, accountID, extraSettings)
+}
+
+// GetValidatedPeers mocks base method.
+func (m *MockIntegratedValidator) GetValidatedPeers(ctx context.Context, accountID string, groups []*nmdata.Group, peers []*nmdata.Peer, extraSettings *types.ExtraSettings) (map[string]struct{}, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetValidatedPeers", ctx, accountID, groups, peers, extraSettings)
+	ret0, _ := ret[0].(map[string]struct{})
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// GetValidatedPeers indicates an expected call of GetValidatedPeers.
+func (mr *MockIntegratedValidatorMockRecorder) GetValidatedPeers(ctx, accountID, groups, peers, extraSettings any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetValidatedPeers", reflect.TypeOf((*MockIntegratedValidator)(nil).GetValidatedPeers), ctx, accountID, groups, peers, extraSettings)
+}
+
+// IsNotValidPeer mocks base method.
+func (m *MockIntegratedValidator) IsNotValidPeer(ctx context.Context, accountID string, arg2 *peer.Peer, peersGroup []string, extraSettings *types.ExtraSettings) (bool, bool, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "IsNotValidPeer", ctx, accountID, arg2, peersGroup, extraSettings)
+	ret0, _ := ret[0].(bool)
+	ret1, _ := ret[1].(bool)
+	ret2, _ := ret[2].(error)
+	return ret0, ret1, ret2
+}
+
+// IsNotValidPeer indicates an expected call of IsNotValidPeer.
+func (mr *MockIntegratedValidatorMockRecorder) IsNotValidPeer(ctx, accountID, arg2, peersGroup, extraSettings any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsNotValidPeer", reflect.TypeOf((*MockIntegratedValidator)(nil).IsNotValidPeer), ctx, accountID, arg2, peersGroup, extraSettings)
+}
+
+// PeerDeleted mocks base method.
+func (m *MockIntegratedValidator) PeerDeleted(ctx context.Context, accountID, peerID string, extraSettings *types.ExtraSettings) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "PeerDeleted", ctx, accountID, peerID, extraSettings)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// PeerDeleted indicates an expected call of PeerDeleted.
+func (mr *MockIntegratedValidatorMockRecorder) PeerDeleted(ctx, accountID, peerID, extraSettings any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PeerDeleted", reflect.TypeOf((*MockIntegratedValidator)(nil).PeerDeleted), ctx, accountID, peerID, extraSettings)
+}
+
+// PreparePeer mocks base method.
+func (m *MockIntegratedValidator) PreparePeer(ctx context.Context, accountID string, p *peer.Peer, peersGroup []string, extraSettings *types.ExtraSettings, temporary bool) *peer.Peer {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "PreparePeer", ctx, accountID, p, peersGroup, extraSettings, temporary)
+	ret0, _ := ret[0].(*peer.Peer)
+	return ret0
+}
+
+// PreparePeer indicates an expected call of PreparePeer.
+func (mr *MockIntegratedValidatorMockRecorder) PreparePeer(ctx, accountID, p, peersGroup, extraSettings, temporary any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PreparePeer", reflect.TypeOf((*MockIntegratedValidator)(nil).PreparePeer), ctx, accountID, p, peersGroup, extraSettings, temporary)
+}
+
+// SetPeerInvalidationListener mocks base method.
+func (m *MockIntegratedValidator) SetPeerInvalidationListener(fn func(string, []string)) {
+	m.ctrl.T.Helper()
+	m.ctrl.Call(m, "SetPeerInvalidationListener", fn)
+}
+
+// SetPeerInvalidationListener indicates an expected call of SetPeerInvalidationListener.
+func (mr *MockIntegratedValidatorMockRecorder) SetPeerInvalidationListener(fn any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPeerInvalidationListener", reflect.TypeOf((*MockIntegratedValidator)(nil).SetPeerInvalidationListener), fn)
+}
+
+// Stop mocks base method.
+func (m *MockIntegratedValidator) Stop(ctx context.Context) {
+	m.ctrl.T.Helper()
+	m.ctrl.Call(m, "Stop", ctx)
+}
+
+// Stop indicates an expected call of Stop.
+func (mr *MockIntegratedValidatorMockRecorder) Stop(ctx any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stop", reflect.TypeOf((*MockIntegratedValidator)(nil).Stop), ctx)
+}
+
+// ValidateExtraSettings mocks base method.
+func (m *MockIntegratedValidator) ValidateExtraSettings(ctx context.Context, newExtraSettings, oldExtraSettings *types.ExtraSettings, userID, accountID string) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "ValidateExtraSettings", ctx, newExtraSettings, oldExtraSettings, userID, accountID)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// ValidateExtraSettings indicates an expected call of ValidateExtraSettings.
+func (mr *MockIntegratedValidatorMockRecorder) ValidateExtraSettings(ctx, newExtraSettings, oldExtraSettings, userID, accountID any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateExtraSettings", reflect.TypeOf((*MockIntegratedValidator)(nil).ValidateExtraSettings), ctx, newExtraSettings, oldExtraSettings, userID, accountID)
+}
+
+// ValidateFlowResponse mocks base method.
+func (m *MockIntegratedValidator) ValidateFlowResponse(ctx context.Context, peerKey string, flowResponse *proto.PKCEAuthorizationFlow) *proto.PKCEAuthorizationFlow {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "ValidateFlowResponse", ctx, peerKey, flowResponse)
+	ret0, _ := ret[0].(*proto.PKCEAuthorizationFlow)
+	return ret0
+}
+
+// ValidateFlowResponse indicates an expected call of ValidateFlowResponse.
+func (mr *MockIntegratedValidatorMockRecorder) ValidateFlowResponse(ctx, peerKey, flowResponse any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateFlowResponse", reflect.TypeOf((*MockIntegratedValidator)(nil).ValidateFlowResponse), ctx, peerKey, flowResponse)
+}
+
+// ValidatePeer mocks base method.
+func (m *MockIntegratedValidator) ValidatePeer(ctx context.Context, update, p *peer.Peer, userID, accountID, dnsDomain string, peersGroup []string, extraSettings *types.ExtraSettings) (*peer.Peer, bool, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "ValidatePeer", ctx, update, p, userID, accountID, dnsDomain, peersGroup, extraSettings)
+	ret0, _ := ret[0].(*peer.Peer)
+	ret1, _ := ret[1].(bool)
+	ret2, _ := ret[2].(error)
+	return ret0, ret1, ret2
+}
+
+// ValidatePeer indicates an expected call of ValidatePeer.
+func (mr *MockIntegratedValidatorMockRecorder) ValidatePeer(ctx, update, p, userID, accountID, dnsDomain, peersGroup, extraSettings any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidatePeer", reflect.TypeOf((*MockIntegratedValidator)(nil).ValidatePeer), ctx, update, p, userID, accountID, dnsDomain, peersGroup, extraSettings)
+}
diff --git a/management/server/integrations/integrated_validator/interface.go b/management/server/integrations/integrated_validator/interface.go
index 326fbfaf0..dc3332177 100644
--- a/management/server/integrations/integrated_validator/interface.go
+++ b/management/server/integrations/integrated_validator/interface.go
@@ -5,16 +5,19 @@ import (
 
 	nbpeer "github.com/netbirdio/netbird/management/server/peer"
 	"github.com/netbirdio/netbird/management/server/types"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	"github.com/netbirdio/netbird/shared/management/proto"
 )
 
+//go:generate go tool mockgen -package integrated_validator -destination=integrated_validator_mock.go -source=./interface.go -build_flags=-mod=mod
+
 // IntegratedValidator interface exists to avoid the circle dependencies
 type IntegratedValidator interface {
 	ValidateExtraSettings(ctx context.Context, newExtraSettings *types.ExtraSettings, oldExtraSettings *types.ExtraSettings, userID string, accountID string) error
-	ValidatePeer(ctx context.Context, update *nbpeer.Peer, peer *nbpeer.Peer, userID string, accountID string, dnsDomain string, peersGroup []string, extraSettings *types.ExtraSettings) (*nbpeer.Peer, bool, error)
-	PreparePeer(ctx context.Context, accountID string, peer *nbpeer.Peer, peersGroup []string, extraSettings *types.ExtraSettings, temporary bool) *nbpeer.Peer
+	ValidatePeer(ctx context.Context, update *nbpeer.Peer, p *nbpeer.Peer, userID string, accountID string, dnsDomain string, peersGroup []string, extraSettings *types.ExtraSettings) (*nbpeer.Peer, bool, error)
+	PreparePeer(ctx context.Context, accountID string, p *nbpeer.Peer, peersGroup []string, extraSettings *types.ExtraSettings, temporary bool) *nbpeer.Peer
 	IsNotValidPeer(ctx context.Context, accountID string, peer *nbpeer.Peer, peersGroup []string, extraSettings *types.ExtraSettings) (bool, bool, error)
-	GetValidatedPeers(ctx context.Context, accountID string, groups []*types.Group, peers []*nbpeer.Peer, extraSettings *types.ExtraSettings) (map[string]struct{}, error)
+	GetValidatedPeers(ctx context.Context, accountID string, groups []*nmdata.Group, peers []*nmdata.Peer, extraSettings *types.ExtraSettings) (map[string]struct{}, error)
 	GetInvalidPeers(ctx context.Context, accountID string, extraSettings *types.ExtraSettings) (map[string]string, error)
 	PeerDeleted(ctx context.Context, accountID, peerID string, extraSettings *types.ExtraSettings) error
 	SetPeerInvalidationListener(fn func(accountID string, peerIDs []string))
diff --git a/management/server/integrations/integrated_validator/validator/validator.go b/management/server/integrations/integrated_validator/validator/validator.go
index db1d34373..33199c065 100644
--- a/management/server/integrations/integrated_validator/validator/validator.go
+++ b/management/server/integrations/integrated_validator/validator/validator.go
@@ -10,6 +10,7 @@ import (
 	nbpeer "github.com/netbirdio/netbird/management/server/peer"
 	"github.com/netbirdio/netbird/management/server/settings"
 	"github.com/netbirdio/netbird/management/server/types"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	"github.com/netbirdio/netbird/shared/management/proto"
 )
 
@@ -35,7 +36,7 @@ func (v *IntegratedValidatorImpl) IsNotValidPeer(_ context.Context, _ string, _
 	return false, false, nil
 }
 
-func (v *IntegratedValidatorImpl) GetValidatedPeers(_ context.Context, _ string, _ []*types.Group, peers []*nbpeer.Peer, _ *types.ExtraSettings) (map[string]struct{}, error) {
+func (v *IntegratedValidatorImpl) GetValidatedPeers(_ context.Context, _ string, _ []*nmdata.Group, peers []*nmdata.Peer, _ *types.ExtraSettings) (map[string]struct{}, error) {
 	validatedPeers := make(map[string]struct{})
 	for _, p := range peers {
 		validatedPeers[p.ID] = struct{}{}
diff --git a/management/server/management_proto_test.go b/management/server/management_proto_test.go
index c23ca6237..4f8aa8265 100644
--- a/management/server/management_proto_test.go
+++ b/management/server/management_proto_test.go
@@ -376,7 +376,7 @@ func startManagementForTest(t *testing.T, testFile string, config *config.Config
 		return nil, nil, "", cleanup, err
 	}
 
-	networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeralMgr, config)
+	networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeralMgr, config, nil)
 	accountManager, err := BuildManager(ctx, nil, store, networkMapController, jobManager, nil, "",
 		eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
 
diff --git a/management/server/management_test.go b/management/server/management_test.go
index 80c76f0de..3a8d6ecc2 100644
--- a/management/server/management_test.go
+++ b/management/server/management_test.go
@@ -216,7 +216,7 @@ func startServer(
 
 	updateManager := update_channel.NewPeersUpdateManager(metrics)
 	requestBuffer := server.NewAccountRequestBuffer(ctx, str)
-	networkMapController := controller.NewController(ctx, str, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(str, peers.NewManager(str, permissionsManager)), config)
+	networkMapController := controller.NewController(ctx, str, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(str, peers.NewManager(str, permissionsManager)), config, nil)
 
 	accountManager, err := server.BuildManager(
 		context.Background(),
diff --git a/management/server/mock_server/account_mock.go b/management/server/mock_server/account_mock.go
index 071e3771b..2f871c3e2 100644
--- a/management/server/mock_server/account_mock.go
+++ b/management/server/mock_server/account_mock.go
@@ -24,6 +24,7 @@ import (
 	"github.com/netbirdio/netbird/management/server/users"
 	"github.com/netbirdio/netbird/route"
 	"github.com/netbirdio/netbird/shared/management/domain"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 )
 
 var _ account.Manager = (*MockAccountManager)(nil)
@@ -41,11 +42,11 @@ type MockAccountManager struct {
 	GetPeersFunc                          func(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error)
 	MarkPeerConnectedFunc                 func(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error
 	MarkPeerDisconnectedFunc              func(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64) error
-	SyncAndMarkPeerFunc                   func(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error)
+	SyncAndMarkPeerFunc                   func(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*nmdata.PostureChecks, int64, error)
 	DeletePeerFunc                        func(ctx context.Context, accountID, peerKey, userID string) error
 	GetNetworkMapFunc                     func(ctx context.Context, peerKey string) (*types.NetworkMap, error)
 	GetPeerNetworkFunc                    func(ctx context.Context, peerKey string) (*types.Network, error)
-	AddPeerFunc                           func(ctx context.Context, accountID string, setupKey string, userId string, peer *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error)
+	AddPeerFunc                           func(ctx context.Context, accountID string, setupKey string, userId string, peer *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.Network, []*nmdata.PostureChecks, bool, error)
 	GetGroupFunc                          func(ctx context.Context, accountID, groupID, userID string) (*types.Group, error)
 	GetAllGroupsFunc                      func(ctx context.Context, accountID, userID string) ([]*types.Group, error)
 	GetGroupByNameFunc                    func(ctx context.Context, groupName, accountID, userID string) (*types.Group, error)
@@ -98,9 +99,9 @@ type MockAccountManager struct {
 	SaveDNSSettingsFunc                   func(ctx context.Context, accountID, userID string, dnsSettingsToSave *types.DNSSettings) error
 	GetPeerFunc                           func(ctx context.Context, accountID, peerID, userID string) (*nbpeer.Peer, error)
 	UpdateAccountSettingsFunc             func(ctx context.Context, accountID, userID string, newSettings *types.Settings) (*types.Settings, error)
-	LoginPeerFunc                         func(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error)
+	LoginPeerFunc                         func(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*nmdata.PostureChecks, bool, error)
 	ExtendPeerSessionFunc                 func(ctx context.Context, peerPubKey, userID string) (time.Time, error)
-	SyncPeerFunc                          func(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error)
+	SyncPeerFunc                          func(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*nmdata.PostureChecks, int64, error)
 	InviteUserFunc                        func(ctx context.Context, accountID string, initiatorUserID string, targetUserEmail string) error
 	ApproveUserFunc                       func(ctx context.Context, accountID, initiatorUserID, targetUserID string) (*types.UserInfo, error)
 	RejectUserFunc                        func(ctx context.Context, accountID, initiatorUserID, targetUserID string) error
@@ -230,7 +231,7 @@ func (am *MockAccountManager) DeleteSetupKey(ctx context.Context, accountID, use
 	return status.Errorf(codes.Unimplemented, "method DeleteSetupKey is not implemented")
 }
 
-func (am *MockAccountManager) SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) {
+func (am *MockAccountManager) SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) {
 	if am.SyncAndMarkPeerFunc != nil {
 		return am.SyncAndMarkPeerFunc(ctx, accountID, peerPubKey, meta, realIP, syncTime)
 	}
@@ -424,7 +425,7 @@ func (am *MockAccountManager) AddPeer(
 	userId string,
 	peer *nbpeer.Peer,
 	temporary bool,
-) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) {
+) (*nbpeer.Peer, *types.Network, []*nmdata.PostureChecks, bool, error) {
 	if am.AddPeerFunc != nil {
 		return am.AddPeerFunc(ctx, accountID, setupKey, userId, peer, temporary)
 	}
@@ -862,7 +863,7 @@ func (am *MockAccountManager) UpdateAccountSettings(ctx context.Context, account
 }
 
 // LoginPeer mocks LoginPeer of the AccountManager interface
-func (am *MockAccountManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) {
+func (am *MockAccountManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*nmdata.PostureChecks, bool, error) {
 	if am.LoginPeerFunc != nil {
 		return am.LoginPeerFunc(ctx, login)
 	}
@@ -878,7 +879,7 @@ func (am *MockAccountManager) ExtendPeerSession(ctx context.Context, peerPubKey,
 }
 
 // SyncPeer mocks SyncPeer of the AccountManager interface
-func (am *MockAccountManager) SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) {
+func (am *MockAccountManager) SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) {
 	if am.SyncPeerFunc != nil {
 		return am.SyncPeerFunc(ctx, sync, accountID)
 	}
diff --git a/management/server/nameserver_test.go b/management/server/nameserver_test.go
index ce5d5d57b..deed9c34f 100644
--- a/management/server/nameserver_test.go
+++ b/management/server/nameserver_test.go
@@ -803,7 +803,7 @@ func createNSManager(t *testing.T) (*DefaultAccountManager, error) {
 
 	updateManager := update_channel.NewPeersUpdateManager(metrics)
 	requestBuffer := NewAccountRequestBuffer(ctx, store)
-	networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{})
+	networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil)
 
 	return BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
 }
diff --git a/management/server/networks/resources/types/resource.go b/management/server/networks/resources/types/resource.go
index 643f9cdd6..4cf7f7ea3 100644
--- a/management/server/networks/resources/types/resource.go
+++ b/management/server/networks/resources/types/resource.go
@@ -14,7 +14,6 @@ import (
 	nbDomain "github.com/netbirdio/netbird/shared/management/domain"
 
 	"github.com/netbirdio/netbird/shared/management/http/api"
-	sharedTypes "github.com/netbirdio/netbird/shared/management/types"
 )
 
 type NetworkResourceType string
@@ -65,27 +64,6 @@ func NewNetworkResource(accountID, networkID, name, description, address string,
 	}, nil
 }
 
-// ToComponent converts the resource to its self-contained components
-// representation. Returns nil for a nil resource.
-func (n *NetworkResource) ToComponent() *sharedTypes.ComponentResource {
-	if n == nil {
-		return nil
-	}
-	return &sharedTypes.ComponentResource{
-		ID:          n.ID,
-		PublicID:    n.PublicID,
-		NetworkID:   n.NetworkID,
-		AccountID:   n.AccountID,
-		Name:        n.Name,
-		Description: n.Description,
-		Type:        sharedTypes.ComponentResourceType(n.Type),
-		Address:     n.Address,
-		Domain:      n.Domain,
-		Prefix:      n.Prefix,
-		Enabled:     n.Enabled,
-	}
-}
-
 func (n *NetworkResource) ToAPIResponse(groups []api.GroupMinimum) *api.NetworkResource {
 	addr := n.Prefix.String()
 	if n.Type == Domain {
diff --git a/management/server/networks/routers/types/router.go b/management/server/networks/routers/types/router.go
index b8097cdbb..189d7f792 100644
--- a/management/server/networks/routers/types/router.go
+++ b/management/server/networks/routers/types/router.go
@@ -7,7 +7,6 @@ import (
 
 	"github.com/netbirdio/netbird/management/server/networks/types"
 	"github.com/netbirdio/netbird/shared/management/http/api"
-	sharedTypes "github.com/netbirdio/netbird/shared/management/types"
 )
 
 type NetworkRouter struct {
@@ -22,36 +21,6 @@ type NetworkRouter struct {
 	Enabled    bool
 }
 
-// ToComponent converts the router to its self-contained components
-// representation. Returns nil for a nil router.
-func (n *NetworkRouter) ToComponent() *sharedTypes.ComponentRouter {
-	if n == nil {
-		return nil
-	}
-	return &sharedTypes.ComponentRouter{
-		NetworkID:  n.NetworkID,
-		PublicID:   n.PublicID,
-		Peer:       n.Peer,
-		PeerGroups: n.PeerGroups,
-		Masquerade: n.Masquerade,
-		Metric:     n.Metric,
-		Enabled:    n.Enabled,
-	}
-}
-
-// ToComponentMap converts a peer-keyed router map to its components
-// representation.
-func ToComponentMap(routers map[string]*NetworkRouter) map[string]*sharedTypes.ComponentRouter {
-	if routers == nil {
-		return nil
-	}
-	out := make(map[string]*sharedTypes.ComponentRouter, len(routers))
-	for id, r := range routers {
-		out[id] = r.ToComponent()
-	}
-	return out
-}
-
 func NewNetworkRouter(accountID string, networkID string, peer string, peerGroups []string, masquerade bool, metric int, enabled bool) (*NetworkRouter, error) {
 	r := &NetworkRouter{
 		ID:         xid.New().String(),
diff --git a/management/server/peer.go b/management/server/peer.go
index 589cf9abf..07619f51e 100644
--- a/management/server/peer.go
+++ b/management/server/peer.go
@@ -21,8 +21,8 @@ import (
 	"github.com/netbirdio/netbird/management/server/permissions/modules"
 	"github.com/netbirdio/netbird/management/server/permissions/operations"
 	"github.com/netbirdio/netbird/shared/management/domain"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 
-	"github.com/netbirdio/netbird/management/server/posture"
 	"github.com/netbirdio/netbird/management/server/store"
 	"github.com/netbirdio/netbird/management/server/types"
 
@@ -740,7 +740,7 @@ func (am *DefaultAccountManager) handleSetupKeyAddedPeer(ctx context.Context, en
 // to it. We also add the User ID to the peer metadata to identify registrant. If no userID provided, then fail with status.PermissionDenied
 // Each new Peer will be assigned a new next net.IP from the Account.Network and Account.Network.LastIP will be updated (IP's are not reused).
 // The peer property is just a placeholder for the Peer properties to pass further
-func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKey, userID string, peer *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) {
+func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKey, userID string, peer *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.Network, []*nmdata.PostureChecks, bool, error) {
 	if setupKey == "" && userID == "" && !peer.ProxyMeta.Embedded {
 		// no auth method provided => reject access
 		return nil, nil, nil, false, status.ErrNoAuthMethodProvided
@@ -1000,7 +1000,7 @@ func getPeerIPDNSLabel(ip netip.Addr, peerHostName string) (string, error) {
 }
 
 // SyncPeer checks whether peer is eligible for receiving NetworkMap (authenticated) and returns its NetworkMap if eligible
-func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) {
+func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) {
 	var peer *nbpeer.Peer
 	var ipv6CapabilityChanged bool
 	var metaDiff nbpeer.MetaDiff
@@ -1064,7 +1064,7 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy
 		return nil, nil, nil, 0, err
 	}
 
-	metaDiffAffectsPosture := posture.AffectsPosture(ctx, &metaDiff, resPostureChecks)
+	metaDiffAffectsPosture := metaDiffAffectsPosture(&metaDiff, resPostureChecks)
 	if requiresPeerUpdate(ctx, isStatusChanged, sync.UpdateAccountPeers, ipv6CapabilityChanged, metaDiffAffectsPosture, metaDiff.VersionChanged(), metaDiff.HostnameChanged()) {
 		changedPeerIDs := []string{peer.ID}
 		affectedPeerIDs := am.syncPeerAffectedPeers(ctx, accountID, peer.ID, nmap, peerNotValid, metaDiffAffectsPosture)
@@ -1076,6 +1076,14 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy
 	return peer, nmap, resPostureChecks, dnsFwdPort, nil
 }
 
+// metaDiffAffectsPosture reports whether the meta change flips the verdict of any of
+// the peer's posture checks, replaying them against the old and new state.
+func metaDiffAffectsPosture(diff *nbpeer.MetaDiff, checks []*nmdata.PostureChecks) bool {
+	oldPeer := types.TwinPeer(&nbpeer.Peer{Meta: diff.OldMeta, Location: diff.OldLocation})
+	newPeer := types.TwinPeer(&nbpeer.Peer{Meta: diff.NewMeta, Location: diff.NewLocation})
+	return nmdata.PostureVerdictChanged(checks, oldPeer, newPeer)
+}
+
 func requiresPeerUpdate(ctx context.Context, isStatusChanged, updateAccountPeers, ipv6CapabilityChanged, metaDiffAffectsPosture, versionChanged, hostname bool) bool {
 	var reason string
 	switch {
@@ -1127,7 +1135,7 @@ func (am *DefaultAccountManager) markConnectedAffectedPeers(ctx context.Context,
 	return affectedPeerIDsFromNetworkMap(nmap, peerID)
 }
 
-func (am *DefaultAccountManager) handlePeerLoginNotFound(ctx context.Context, login types.PeerLogin, err error) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) {
+func (am *DefaultAccountManager) handlePeerLoginNotFound(ctx context.Context, login types.PeerLogin, err error) (*nbpeer.Peer, *types.Network, []*nmdata.PostureChecks, bool, error) {
 	if errStatus, ok := status.FromError(err); ok && errStatus.Type() == status.NotFound {
 		// we couldn't find this peer by its public key which can mean that peer hasn't been registered yet.
 		// Try registering it.
@@ -1148,7 +1156,7 @@ func (am *DefaultAccountManager) handlePeerLoginNotFound(ctx context.Context, lo
 
 // LoginPeer logs in or registers a peer.
 // If peer doesn't exist the function checks whether a setup key or a user is present and registers a new peer if so.
-func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) {
+func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*nmdata.PostureChecks, bool, error) {
 	accountID, err := am.Store.GetAccountIDByPeerPubKey(ctx, login.WireGuardPubKey)
 	if err != nil {
 		return am.handlePeerLoginNotFound(ctx, login, err)
@@ -1321,7 +1329,7 @@ func (am *DefaultAccountManager) ExtendPeerSession(ctx context.Context, peerPubK
 
 // getPeerLoginInfo computes the login/register response data (network, posture
 // checks, SSH) from the store without building the peer's full network map.
-func getPeerLoginInfo(ctx context.Context, transaction store.Store, accountID string, peer *nbpeer.Peer, isValid bool) (*types.Network, []*posture.Checks, bool, error) {
+func getPeerLoginInfo(ctx context.Context, transaction store.Store, accountID string, peer *nbpeer.Peer, isValid bool) (*types.Network, []*nmdata.PostureChecks, bool, error) {
 	network, err := transaction.GetAccountNetwork(ctx, store.LockingStrengthNone, accountID)
 	if err != nil {
 		return nil, nil, false, fmt.Errorf("get account network: %w", err)
@@ -1341,7 +1349,7 @@ func getPeerLoginInfo(ctx context.Context, transaction store.Store, accountID st
 		return nil, nil, false, err
 	}
 
-	postureChecks, err := getPeerPostureChecks(ctx, transaction, accountID, peerGroupIDs, policies)
+	postureChecks, err := getPeerPostureChecks(ctx, transaction, accountID, peer.ID, peerGroupIDs, policies)
 	if err != nil {
 		return nil, nil, false, err
 	}
@@ -1363,7 +1371,7 @@ func isPeerSSHEnabled(ctx context.Context, peer *nbpeer.Peer, policies []*types.
 }
 
 // getPeerPostureChecks returns the posture checks for the peer.
-func getPeerPostureChecks(ctx context.Context, transaction store.Store, accountID string, peerGroupIDs []string, policies []*types.Policy) ([]*posture.Checks, error) {
+func getPeerPostureChecks(ctx context.Context, transaction store.Store, accountID, peerID string, peerGroupIDs []string, policies []*types.Policy) ([]*nmdata.PostureChecks, error) {
 	if len(policies) == 0 {
 		return nil, nil
 	}
@@ -1375,7 +1383,7 @@ func getPeerPostureChecks(ctx context.Context, transaction store.Store, accountI
 			continue
 		}
 
-		postureChecksIDs := processPeerPostureChecks(policy, peerGroupIDs)
+		postureChecksIDs := processPeerPostureChecks(policy, peerID, peerGroupIDs)
 		peerPostureChecksIDs = append(peerPostureChecksIDs, postureChecksIDs...)
 	}
 
@@ -1384,16 +1392,20 @@ func getPeerPostureChecks(ctx context.Context, transaction store.Store, accountI
 		return nil, err
 	}
 
-	return maps.Values(peerPostureChecks), nil
+	return types.TwinPostureChecksList(maps.Values(peerPostureChecks)), nil
 }
 
-// processPeerPostureChecks checks if the peer is in the source group of the policy and returns the posture checks.
-func processPeerPostureChecks(policy *types.Policy, peerGroupIDs []string) []string {
+// processPeerPostureChecks returns the policy's posture checks when the peer is a source of the policy, directly or through a source group.
+func processPeerPostureChecks(policy *types.Policy, peerID string, peerGroupIDs []string) []string {
 	for _, rule := range policy.Rules {
 		if !rule.Enabled {
 			continue
 		}
 
+		if rule.SourceResource.Type == types.ResourceTypePeer && rule.SourceResource.ID == peerID {
+			return policy.SourcePostureChecks
+		}
+
 		for _, sourceGroup := range rule.Sources {
 			if slices.Contains(peerGroupIDs, sourceGroup) {
 				return policy.SourcePostureChecks
@@ -1588,7 +1600,7 @@ func affectedPeerIDsFromNetworkMap(nmap *types.NetworkMap, selfPeerID string) []
 	}
 	seen := make(map[string]struct{}, len(nmap.Peers)+len(nmap.OfflinePeers))
 	ids := make([]string, 0, len(nmap.Peers)+len(nmap.OfflinePeers))
-	add := func(peers []*types.ComponentPeer) {
+	add := func(peers []*nmdata.Peer) {
 		for _, p := range peers {
 			if p == nil || p.ID == "" || p.ID == selfPeerID {
 				continue
diff --git a/management/server/peer/peer.go b/management/server/peer/peer.go
index 7c4971285..80c77592c 100644
--- a/management/server/peer/peer.go
+++ b/management/server/peer/peer.go
@@ -13,14 +13,14 @@ import (
 
 	"github.com/netbirdio/netbird/management/server/util"
 	"github.com/netbirdio/netbird/shared/management/http/api"
-	sharedTypes "github.com/netbirdio/netbird/shared/management/types"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 )
 
 // Peer capability constants mirror the proto enum values.
 const (
-	PeerCapabilitySourcePrefixes      int32 = 1
-	PeerCapabilityIPv6Overlay         int32 = 2
-	PeerCapabilityComponentNetworkMap int32 = 3
+	PeerCapabilitySourcePrefixes      = nmdata.PeerCapabilitySourcePrefixes
+	PeerCapabilityIPv6Overlay         = nmdata.PeerCapabilityIPv6Overlay
+	PeerCapabilityComponentNetworkMap = nmdata.PeerCapabilityComponentNetworkMap
 )
 
 // Peer represents a machine connected to the network.
@@ -206,35 +206,6 @@ func (p *Peer) AddedWithSSOLogin() bool {
 	return p.UserID != ""
 }
 
-// ToComponent converts the peer to its self-contained components
-// representation, carrying exactly the subset of peer data that crosses the
-// components wire format. Returns nil for a nil peer so callers can convert
-// possibly-missing peers without guarding.
-func (p *Peer) ToComponent() *sharedTypes.ComponentPeer {
-	if p == nil {
-		return nil
-	}
-	cp := &sharedTypes.ComponentPeer{
-		ID:                     p.ID,
-		Key:                    p.Key,
-		IP:                     p.IP,
-		IPv6:                   p.IPv6,
-		DNSLabel:               p.DNSLabel,
-		SSHKey:                 p.SSHKey,
-		SSHEnabled:             p.SSHEnabled,
-		ServerSSHAllowed:       p.Meta.Flags.ServerSSHAllowed,
-		AgentVersion:           p.Meta.WtVersion,
-		SupportsSourcePrefixes: p.SupportsSourcePrefixes(),
-		SupportsIPv6:           p.SupportsIPv6(),
-		LoginExpirationEnabled: p.LoginExpirationEnabled,
-		AddedWithSSOLogin:      p.AddedWithSSOLogin(),
-	}
-	if p.LastLogin != nil {
-		cp.LastLogin = *p.LastLogin
-	}
-	return cp
-}
-
 // HasCapability reports whether the peer has the given capability.
 func (p *Peer) HasCapability(capability int32) bool {
 	return slices.Contains(p.Meta.Capabilities, capability)
diff --git a/management/server/peer_posture_test.go b/management/server/peer_posture_test.go
new file mode 100644
index 000000000..88662e2fa
--- /dev/null
+++ b/management/server/peer_posture_test.go
@@ -0,0 +1,203 @@
+package server
+
+import (
+	"net"
+	"net/netip"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	nbpeer "github.com/netbirdio/netbird/management/server/peer"
+	"github.com/netbirdio/netbird/management/server/posture"
+	"github.com/netbirdio/netbird/management/server/types"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+func diffFrom(oldMeta, newMeta nbpeer.PeerSystemMeta, oldLoc, newLoc nbpeer.Location) *nbpeer.MetaDiff {
+	return &nbpeer.MetaDiff{
+		OldMeta:     oldMeta,
+		NewMeta:     newMeta,
+		OldLocation: oldLoc,
+		NewLocation: newLoc,
+	}
+}
+
+func postureBundle(def nmdata.ChecksDefinition) []*nmdata.PostureChecks {
+	return []*nmdata.PostureChecks{{Checks: def}}
+}
+
+func TestMetaDiffAffectsPosture_NBVersion(t *testing.T) {
+	c := postureBundle(nmdata.ChecksDefinition{NBVersionCheck: &nmdata.NBVersionCheck{MinVersion: "1.2.0"}})
+
+	tests := []struct {
+		name           string
+		oldVer, newVer string
+		want           bool
+	}{
+		{"both above min, no flip", "1.3.0", "1.4.0", false},
+		{"both below min, no flip", "1.0.0", "1.1.0", false},
+		{"crosses up below->above", "1.1.0", "1.3.0", true},
+		{"crosses down above->below", "1.3.0", "1.1.0", true},
+		{"unparsable old only -> flip", "garbage", "1.3.0", true},
+		{"unparsable both -> no flip", "garbage", "junk", false},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			diff := diffFrom(
+				nbpeer.PeerSystemMeta{WtVersion: tt.oldVer},
+				nbpeer.PeerSystemMeta{WtVersion: tt.newVer},
+				nbpeer.Location{}, nbpeer.Location{},
+			)
+			assert.Equal(t, tt.want, metaDiffAffectsPosture(diff, c))
+		})
+	}
+}
+
+func TestMetaDiffAffectsPosture_OSVersion_KernelBumpWithinMin(t *testing.T) {
+	c := postureBundle(nmdata.ChecksDefinition{OSVersionCheck: &nmdata.OSVersionCheck{
+		Linux: &nmdata.MinKernelVersionCheck{MinKernelVersion: "5.0.0"},
+	}})
+
+	withinMin := diffFrom(
+		nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "5.10.0-arch1"},
+		nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "5.15.0-arch2"},
+		nbpeer.Location{}, nbpeer.Location{},
+	)
+	assert.False(t, metaDiffAffectsPosture(withinMin, c))
+
+	crossesDown := diffFrom(
+		nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "5.10.0-arch1"},
+		nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "4.19.0-arch1"},
+		nbpeer.Location{}, nbpeer.Location{},
+	)
+	assert.True(t, metaDiffAffectsPosture(crossesDown, c))
+}
+
+func TestMetaDiffAffectsPosture_OSVersion_GoOSSwitchFlipsVerdict(t *testing.T) {
+	c := postureBundle(nmdata.ChecksDefinition{OSVersionCheck: &nmdata.OSVersionCheck{
+		Linux: &nmdata.MinKernelVersionCheck{MinKernelVersion: "6.0.0"},
+	}})
+
+	diff := diffFrom(
+		nbpeer.PeerSystemMeta{GoOS: "freebsd"},
+		nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "4.19.0"},
+		nbpeer.Location{}, nbpeer.Location{},
+	)
+	assert.True(t, metaDiffAffectsPosture(diff, c))
+}
+
+func TestMetaDiffAffectsPosture_Process_GoOSSwitchFlipsVerdict(t *testing.T) {
+	c := postureBundle(nmdata.ChecksDefinition{ProcessCheck: &nmdata.ProcessCheck{
+		Processes: []nmdata.Process{{LinuxPath: "/usr/bin/foo"}},
+	}})
+
+	files := []nbpeer.File{{Path: "/usr/bin/foo", ProcessIsRunning: true}}
+	diff := diffFrom(
+		nbpeer.PeerSystemMeta{GoOS: "linux", Files: files},
+		nbpeer.PeerSystemMeta{GoOS: "windows", Files: files},
+		nbpeer.Location{}, nbpeer.Location{},
+	)
+	assert.True(t, metaDiffAffectsPosture(diff, c))
+}
+
+func TestMetaDiffAffectsPosture_Process_UnrelatedFileChange(t *testing.T) {
+	c := postureBundle(nmdata.ChecksDefinition{ProcessCheck: &nmdata.ProcessCheck{
+		Processes: []nmdata.Process{{LinuxPath: "/usr/bin/foo"}},
+	}})
+
+	diff := diffFrom(
+		nbpeer.PeerSystemMeta{GoOS: "linux", Files: []nbpeer.File{
+			{Path: "/usr/bin/foo", ProcessIsRunning: true},
+		}},
+		nbpeer.PeerSystemMeta{GoOS: "linux", Files: []nbpeer.File{
+			{Path: "/usr/bin/foo", ProcessIsRunning: true},
+			{Path: "/usr/bin/bar", ProcessIsRunning: true},
+		}},
+		nbpeer.Location{}, nbpeer.Location{},
+	)
+	assert.False(t, metaDiffAffectsPosture(diff, c))
+}
+
+func TestMetaDiffAffectsPosture_GeoLocation(t *testing.T) {
+	c := postureBundle(nmdata.ChecksDefinition{GeoLocationCheck: &nmdata.GeoLocationCheck{
+		Action:    posture.CheckActionAllow,
+		Locations: []nmdata.GeoLocation{{CountryCode: "DE"}},
+	}})
+
+	stayAllowed := diffFrom(
+		nbpeer.PeerSystemMeta{}, nbpeer.PeerSystemMeta{},
+		nbpeer.Location{CountryCode: "DE", CityName: "Berlin"},
+		nbpeer.Location{CountryCode: "DE", CityName: "Munich"},
+	)
+	assert.False(t, metaDiffAffectsPosture(stayAllowed, c))
+
+	moveOut := diffFrom(
+		nbpeer.PeerSystemMeta{}, nbpeer.PeerSystemMeta{},
+		nbpeer.Location{CountryCode: "DE"},
+		nbpeer.Location{CountryCode: "FR"},
+	)
+	assert.True(t, metaDiffAffectsPosture(moveOut, c))
+}
+
+func TestMetaDiffAffectsPosture_PeerNetworkRange_ConnectionIP(t *testing.T) {
+	c := postureBundle(nmdata.ChecksDefinition{PeerNetworkRangeCheck: &nmdata.PeerNetworkRangeCheck{
+		Action: posture.CheckActionAllow,
+		Ranges: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
+	}})
+
+	movesOutOfRange := diffFrom(
+		nbpeer.PeerSystemMeta{}, nbpeer.PeerSystemMeta{},
+		nbpeer.Location{ConnectionIP: net.ParseIP("10.1.2.3")},
+		nbpeer.Location{ConnectionIP: net.ParseIP("8.8.8.8")},
+	)
+	assert.True(t, metaDiffAffectsPosture(movesOutOfRange, c))
+
+	staysInRange := diffFrom(
+		nbpeer.PeerSystemMeta{}, nbpeer.PeerSystemMeta{},
+		nbpeer.Location{ConnectionIP: net.ParseIP("10.1.2.3")},
+		nbpeer.Location{ConnectionIP: net.ParseIP("10.9.9.9")},
+	)
+	assert.False(t, metaDiffAffectsPosture(staysInRange, c))
+}
+
+func TestMetaDiffAffectsPosture_IrrelevantFieldChange(t *testing.T) {
+	c := postureBundle(nmdata.ChecksDefinition{
+		NBVersionCheck:   &nmdata.NBVersionCheck{MinVersion: "1.0.0"},
+		GeoLocationCheck: &nmdata.GeoLocationCheck{Action: posture.CheckActionAllow, Locations: []nmdata.GeoLocation{{CountryCode: "DE"}}},
+	})
+
+	diff := diffFrom(
+		nbpeer.PeerSystemMeta{Hostname: "old", WtVersion: "1.5.0"},
+		nbpeer.PeerSystemMeta{Hostname: "new", WtVersion: "1.5.0"},
+		nbpeer.Location{CountryCode: "DE"}, nbpeer.Location{CountryCode: "DE"},
+	)
+	assert.False(t, metaDiffAffectsPosture(diff, c))
+}
+
+func TestMetaDiffAffectsPosture_NoChecks(t *testing.T) {
+	diff := diffFrom(
+		nbpeer.PeerSystemMeta{WtVersion: "1.0.0"},
+		nbpeer.PeerSystemMeta{WtVersion: "2.0.0"},
+		nbpeer.Location{}, nbpeer.Location{},
+	)
+	assert.False(t, metaDiffAffectsPosture(diff, nil))
+}
+
+func TestProcessPeerPostureChecks(t *testing.T) {
+	policy := &types.Policy{
+		Enabled:             true,
+		SourcePostureChecks: []string{"pc1"},
+		Rules: []*types.PolicyRule{
+			{Enabled: false, Sources: []string{"g-disabled"}, SourceResource: types.Resource{ID: "peer-disabled", Type: types.ResourceTypePeer}},
+			{Enabled: true, Sources: []string{"g-src"}, Destinations: []string{"g-dst"}},
+			{Enabled: true, SourceResource: types.Resource{ID: "peer-direct", Type: types.ResourceTypePeer}, Destinations: []string{"g-dst"}},
+			{Enabled: true, SourceResource: types.Resource{ID: "peer-as-host", Type: types.ResourceTypeHost}, Destinations: []string{"g-dst"}},
+		},
+	}
+
+	assert.Equal(t, []string{"pc1"}, processPeerPostureChecks(policy, "peer-in-group", []string{"g-src"}), "source group member")
+	assert.Equal(t, []string{"pc1"}, processPeerPostureChecks(policy, "peer-direct", nil), "direct source peer")
+	assert.Empty(t, processPeerPostureChecks(policy, "peer-elsewhere", []string{"g-dst"}), "destination-only peer")
+	assert.Empty(t, processPeerPostureChecks(policy, "peer-disabled", []string{"g-disabled"}), "disabled rule")
+	assert.Empty(t, processPeerPostureChecks(policy, "peer-as-host", nil), "source resource of a non-peer type")
+}
diff --git a/management/server/peer_test.go b/management/server/peer_test.go
index 80d270e98..22f2b9b6f 100644
--- a/management/server/peer_test.go
+++ b/management/server/peer_test.go
@@ -16,11 +16,11 @@ import (
 	"testing"
 	"time"
 
-	"go.uber.org/mock/gomock"
 	"github.com/rs/xid"
 	log "github.com/sirupsen/logrus"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+	"go.uber.org/mock/gomock"
 	"golang.org/x/exp/maps"
 	"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
 
@@ -57,6 +57,7 @@ import (
 	"github.com/netbirdio/netbird/management/server/types"
 	nbroute "github.com/netbirdio/netbird/route"
 	"github.com/netbirdio/netbird/shared/management/domain"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	"github.com/netbirdio/netbird/shared/management/proto"
 )
 
@@ -1091,22 +1092,22 @@ func TestToSyncResponse(t *testing.T) {
 		Signature: "turn-pass",
 	}
 	networkMap := &types.NetworkMap{
-		Network: &types.Network{Net: *ipnet, Serial: 1000},
-		Peers: []*types.ComponentPeer{{
+		Network: &nmdata.Network{Net: *ipnet, Serial: 1000},
+		Peers: []*nmdata.Peer{{
 			IP:         netip.MustParseAddr("192.168.1.2"),
 			IPv6:       netip.MustParseAddr("fd00::2"),
 			Key:        "peer2-key",
 			DNSLabel:   "peer2",
 			SSHEnabled: true,
 			SSHKey:     "peer2-ssh-key"}},
-		OfflinePeers: []*types.ComponentPeer{{
+		OfflinePeers: []*nmdata.Peer{{
 			IP:         netip.MustParseAddr("192.168.1.3"),
 			IPv6:       netip.MustParseAddr("fd00::3"),
 			Key:        "peer3-key",
 			DNSLabel:   "peer3",
 			SSHEnabled: true,
 			SSHKey:     "peer3-ssh-key"}},
-		Routes: []*nbroute.Route{
+		Routes: []*nmdata.Route{
 			{
 				ID:          "route1",
 				Network:     netip.MustParsePrefix("10.0.0.0/24"),
@@ -1169,18 +1170,18 @@ func TestToSyncResponse(t *testing.T) {
 		},
 	}
 	dnsName := "example.com"
-	checks := []*posture.Checks{
+	checks := []*nmdata.PostureChecks{
 		{
-			Checks: posture.ChecksDefinition{
-				ProcessCheck: &posture.ProcessCheck{
-					Processes: []posture.Process{{LinuxPath: "/usr/bin/netbird"}},
+			Checks: nmdata.ChecksDefinition{
+				ProcessCheck: &nmdata.ProcessCheck{
+					Processes: []nmdata.Process{{LinuxPath: "/usr/bin/netbird"}},
 				},
 			},
 		},
 	}
 	dnsCache := &cache.DNSConfigCache{}
 	accountSettings := &types.Settings{RoutingPeerDNSResolutionEnabled: true}
-	response := grpc.ToSyncResponse(context.Background(), config, config.HttpConfig, config.DeviceAuthorizationFlow, peer, turnRelayToken, turnRelayToken, networkMap, dnsName, checks, dnsCache, accountSettings, nil, []string{}, int64(dnsForwarderPort))
+	response := grpc.ToSyncResponse(context.Background(), config, config.HttpConfig, config.DeviceAuthorizationFlow, types.TwinPeer(peer), turnRelayToken, turnRelayToken, networkMap, dnsName, checks, dnsCache, types.TwinAccountSettings(accountSettings), nil, []string{}, int64(dnsForwarderPort))
 
 	assert.NotNil(t, response)
 	// assert peer config
@@ -1300,7 +1301,7 @@ func Test_RegisterPeerByUser(t *testing.T) {
 
 	updateManager := update_channel.NewPeersUpdateManager(metrics)
 	requestBuffer := NewAccountRequestBuffer(ctx, s)
-	networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{})
+	networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil)
 
 	am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
 	assert.NoError(t, err)
@@ -1391,7 +1392,7 @@ func Test_RegisterPeerBySetupKey(t *testing.T) {
 
 	updateManager := update_channel.NewPeersUpdateManager(metrics)
 	requestBuffer := NewAccountRequestBuffer(ctx, s)
-	networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{})
+	networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil)
 
 	am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
 	assert.NoError(t, err)
@@ -1550,7 +1551,7 @@ func Test_RegisterPeerRollbackOnFailure(t *testing.T) {
 
 	updateManager := update_channel.NewPeersUpdateManager(metrics)
 	requestBuffer := NewAccountRequestBuffer(ctx, s)
-	networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{})
+	networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil)
 
 	am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
 	assert.NoError(t, err)
@@ -1635,7 +1636,7 @@ func Test_LoginPeer(t *testing.T) {
 
 	updateManager := update_channel.NewPeersUpdateManager(metrics)
 	requestBuffer := NewAccountRequestBuffer(ctx, s)
-	networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{})
+	networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil)
 
 	am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
 	assert.NoError(t, err)
diff --git a/management/server/posture/affects_posture_test.go b/management/server/posture/affects_posture_test.go
deleted file mode 100644
index 6aa54d892..000000000
--- a/management/server/posture/affects_posture_test.go
+++ /dev/null
@@ -1,202 +0,0 @@
-package posture
-
-import (
-	"context"
-	"net"
-	"net/netip"
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-
-	nbpeer "github.com/netbirdio/netbird/management/server/peer"
-)
-
-// diffFrom builds a MetaDiff from the old/new snapshots AffectsPosture replays against.
-func diffFrom(oldMeta, newMeta nbpeer.PeerSystemMeta, oldLoc, newLoc nbpeer.Location) *nbpeer.MetaDiff {
-	return &nbpeer.MetaDiff{
-		OldMeta:     oldMeta,
-		NewMeta:     newMeta,
-		OldLocation: oldLoc,
-		NewLocation: newLoc,
-	}
-}
-
-func checks(def ChecksDefinition) []*Checks {
-	return []*Checks{{Checks: def}}
-}
-
-func TestAffectsPosture_NilDiff(t *testing.T) {
-	assert.False(t, AffectsPosture(context.Background(), nil, checks(ChecksDefinition{
-		NBVersionCheck: &NBVersionCheck{MinVersion: "1.0.0"},
-	})))
-}
-
-func TestAffectsPosture_NBVersion(t *testing.T) {
-	c := checks(ChecksDefinition{NBVersionCheck: &NBVersionCheck{MinVersion: "1.2.0"}})
-
-	tests := []struct {
-		name           string
-		oldVer, newVer string
-		want           bool
-	}{
-		{"both above min, no flip", "1.3.0", "1.4.0", false},
-		{"both below min, no flip", "1.0.0", "1.1.0", false},
-		{"crosses up below->above", "1.1.0", "1.3.0", true},
-		{"crosses down above->below", "1.3.0", "1.1.0", true},
-		{"unparsable old only -> flip", "garbage", "1.3.0", true},
-		{"unparsable both -> no flip", "garbage", "junk", false},
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			diff := diffFrom(
-				nbpeer.PeerSystemMeta{WtVersion: tt.oldVer},
-				nbpeer.PeerSystemMeta{WtVersion: tt.newVer},
-				nbpeer.Location{}, nbpeer.Location{},
-			)
-			assert.Equal(t, tt.want, AffectsPosture(context.Background(), diff, c))
-		})
-	}
-}
-
-func TestAffectsPosture_OSVersion_KernelBumpWithinMin(t *testing.T) {
-	c := checks(ChecksDefinition{OSVersionCheck: &OSVersionCheck{
-		Linux: &MinKernelVersionCheck{MinKernelVersion: "5.0.0"},
-	}})
-
-	// Kernel moves but stays above the minimum: verdict stays pass -> not affected.
-	withinMin := diffFrom(
-		nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "5.10.0-arch1"},
-		nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "5.15.0-arch2"},
-		nbpeer.Location{}, nbpeer.Location{},
-	)
-	assert.False(t, AffectsPosture(context.Background(), withinMin, c))
-
-	// Kernel drops below the minimum: verdict flips pass -> fail -> affected.
-	crossesDown := diffFrom(
-		nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "5.10.0-arch1"},
-		nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "4.19.0-arch1"},
-		nbpeer.Location{}, nbpeer.Location{},
-	)
-	assert.True(t, AffectsPosture(context.Background(), crossesDown, c))
-}
-
-func TestAffectsPosture_OSVersion_GoOSSwitchFlipsVerdict(t *testing.T) {
-	// Only Linux is constrained. An OS outside the switch (freebsd) passes; switching to a
-	// failing linux kernel flips the verdict pass -> fail.
-	c := checks(ChecksDefinition{OSVersionCheck: &OSVersionCheck{
-		Linux: &MinKernelVersionCheck{MinKernelVersion: "6.0.0"},
-	}})
-
-	diff := diffFrom(
-		nbpeer.PeerSystemMeta{GoOS: "freebsd"},
-		nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "4.19.0"},
-		nbpeer.Location{}, nbpeer.Location{},
-	)
-	assert.True(t, AffectsPosture(context.Background(), diff, c))
-}
-
-func TestAffectsPosture_Process_GoOSSwitchFlipsVerdict(t *testing.T) {
-	// Process runs at a linux path. Switching GoOS to windows (no WindowsPath configured)
-	// flips the verdict.
-	c := checks(ChecksDefinition{ProcessCheck: &ProcessCheck{
-		Processes: []Process{{LinuxPath: "/usr/bin/foo"}},
-	}})
-
-	files := []nbpeer.File{{Path: "/usr/bin/foo", ProcessIsRunning: true}}
-	diff := diffFrom(
-		nbpeer.PeerSystemMeta{GoOS: "linux", Files: files},
-		nbpeer.PeerSystemMeta{GoOS: "windows", Files: files},
-		nbpeer.Location{}, nbpeer.Location{},
-	)
-	assert.True(t, AffectsPosture(context.Background(), diff, c))
-}
-
-func TestAffectsPosture_Process_UnrelatedFileChange(t *testing.T) {
-	// A tracked process stays running while an unrelated file is added: the verdict does
-	// not move, so posture is not affected.
-	c := checks(ChecksDefinition{ProcessCheck: &ProcessCheck{
-		Processes: []Process{{LinuxPath: "/usr/bin/foo"}},
-	}})
-
-	diff := diffFrom(
-		nbpeer.PeerSystemMeta{GoOS: "linux", Files: []nbpeer.File{
-			{Path: "/usr/bin/foo", ProcessIsRunning: true},
-		}},
-		nbpeer.PeerSystemMeta{GoOS: "linux", Files: []nbpeer.File{
-			{Path: "/usr/bin/foo", ProcessIsRunning: true},
-			{Path: "/usr/bin/bar", ProcessIsRunning: true},
-		}},
-		nbpeer.Location{}, nbpeer.Location{},
-	)
-	assert.False(t, AffectsPosture(context.Background(), diff, c))
-}
-
-func TestAffectsPosture_GeoLocation(t *testing.T) {
-	c := checks(ChecksDefinition{GeoLocationCheck: &GeoLocationCheck{
-		Action:    CheckActionAllow,
-		Locations: []Location{{CountryCode: "DE"}},
-	}})
-
-	// Moving within allowed countries keeps the verdict; moving out flips it.
-	stayAllowed := diffFrom(
-		nbpeer.PeerSystemMeta{}, nbpeer.PeerSystemMeta{},
-		nbpeer.Location{CountryCode: "DE", CityName: "Berlin"},
-		nbpeer.Location{CountryCode: "DE", CityName: "Munich"},
-	)
-	assert.False(t, AffectsPosture(context.Background(), stayAllowed, c))
-
-	moveOut := diffFrom(
-		nbpeer.PeerSystemMeta{}, nbpeer.PeerSystemMeta{},
-		nbpeer.Location{CountryCode: "DE"},
-		nbpeer.Location{CountryCode: "FR"},
-	)
-	assert.True(t, AffectsPosture(context.Background(), moveOut, c))
-}
-
-func TestAffectsPosture_PeerNetworkRange_ConnectionIP(t *testing.T) {
-	// The check reads the connection IP. Moving out of the allowed range flips the verdict;
-	// moving within it does not.
-	_, allowed, _ := net.ParseCIDR("10.0.0.0/8")
-	c := checks(ChecksDefinition{PeerNetworkRangeCheck: &PeerNetworkRangeCheck{
-		Action: CheckActionAllow,
-		Ranges: []netip.Prefix{netip.MustParsePrefix(allowed.String())},
-	}})
-
-	movesOutOfRange := diffFrom(
-		nbpeer.PeerSystemMeta{}, nbpeer.PeerSystemMeta{},
-		nbpeer.Location{ConnectionIP: net.ParseIP("10.1.2.3")},
-		nbpeer.Location{ConnectionIP: net.ParseIP("8.8.8.8")},
-	)
-	assert.True(t, AffectsPosture(context.Background(), movesOutOfRange, c))
-
-	staysInRange := diffFrom(
-		nbpeer.PeerSystemMeta{}, nbpeer.PeerSystemMeta{},
-		nbpeer.Location{ConnectionIP: net.ParseIP("10.1.2.3")},
-		nbpeer.Location{ConnectionIP: net.ParseIP("10.9.9.9")},
-	)
-	assert.False(t, AffectsPosture(context.Background(), staysInRange, c))
-}
-
-func TestAffectsPosture_IrrelevantFieldChange(t *testing.T) {
-	// Hostname changes but no check reads it: not affected even with checks present.
-	c := checks(ChecksDefinition{
-		NBVersionCheck:   &NBVersionCheck{MinVersion: "1.0.0"},
-		GeoLocationCheck: &GeoLocationCheck{Action: CheckActionAllow, Locations: []Location{{CountryCode: "DE"}}},
-	})
-
-	diff := diffFrom(
-		nbpeer.PeerSystemMeta{Hostname: "old", WtVersion: "1.5.0"},
-		nbpeer.PeerSystemMeta{Hostname: "new", WtVersion: "1.5.0"},
-		nbpeer.Location{CountryCode: "DE"}, nbpeer.Location{CountryCode: "DE"},
-	)
-	assert.False(t, AffectsPosture(context.Background(), diff, c))
-}
-
-func TestAffectsPosture_NoChecks(t *testing.T) {
-	diff := diffFrom(
-		nbpeer.PeerSystemMeta{WtVersion: "1.0.0"},
-		nbpeer.PeerSystemMeta{WtVersion: "2.0.0"},
-		nbpeer.Location{}, nbpeer.Location{},
-	)
-	assert.False(t, AffectsPosture(context.Background(), diff, nil))
-}
diff --git a/management/server/posture/checks.go b/management/server/posture/checks.go
index 72b719252..c38136d1c 100644
--- a/management/server/posture/checks.go
+++ b/management/server/posture/checks.go
@@ -7,7 +7,6 @@ import (
 	"regexp"
 
 	"github.com/hashicorp/go-version"
-	log "github.com/sirupsen/logrus"
 
 	nbpeer "github.com/netbirdio/netbird/management/server/peer"
 	"github.com/netbirdio/netbird/shared/management/http/api"
@@ -55,46 +54,6 @@ type Checks struct {
 	Checks ChecksDefinition `gorm:"serializer:json"`
 }
 
-// AffectsPosture reports whether the change in diff flips the verdict of any check. It
-// replays each check against the peer's old and new state and compares verdicts, so a
-// change that moves a field but stays the right side of a threshold (e.g. a kernel bump
-// still above the minimum) does not force a re-evaluation. See verdictChanged for how an
-// evaluation error counts.
-func AffectsPosture(ctx context.Context, diff *nbpeer.MetaDiff, checks []*Checks) bool {
-	if diff == nil {
-		return false
-	}
-
-	oldPeer := nbpeer.Peer{Meta: diff.OldMeta, Location: diff.OldLocation}
-	newPeer := nbpeer.Peer{Meta: diff.NewMeta, Location: diff.NewLocation}
-
-	for _, c := range checks {
-		for _, check := range c.GetChecks() {
-			if verdictChanged(ctx, check, oldPeer, newPeer) {
-				return true
-			}
-		}
-	}
-	return false
-}
-
-// verdictChanged replays check against old and new state and reports whether the verdict
-// differs. Like callers, it treats an evaluation error as deny: two errors are the same
-// verdict (no change), an error on one side only is a flip.
-func verdictChanged(ctx context.Context, check Check, oldPeer, newPeer nbpeer.Peer) bool {
-	oldPass, oldErr := check.Check(ctx, oldPeer)
-	newPass, newErr := check.Check(ctx, newPeer)
-
-	oldVerdict := oldPass && (oldErr == nil)
-	newVerdict := newPass && (newErr == nil)
-	changed := oldVerdict != newVerdict
-
-	log.WithContext(ctx).Tracef("posture check %s replay: verdict %t -> %t (changed=%t), errs: %v -> %v",
-		check.Name(), oldVerdict, newVerdict, changed, oldErr, newErr)
-
-	return changed
-}
-
 // ChecksDefinition contains definition of actual check
 type ChecksDefinition struct {
 	NBVersionCheck        *NBVersionCheck        `json:",omitempty"`
diff --git a/management/server/route_test.go b/management/server/route_test.go
index 53dbb29d9..4ca9ee48f 100644
--- a/management/server/route_test.go
+++ b/management/server/route_test.go
@@ -1201,7 +1201,7 @@ func TestGetNetworkMap_RouteSync(t *testing.T) {
 	peer1Routes, err := am.GetNetworkMap(context.Background(), peer1ID)
 	require.NoError(t, err)
 	require.Len(t, peer1Routes.Routes, 1, "we should receive one route for peer1")
-	require.True(t, expectedRoute.Equal(peer1Routes.Routes[0]), "received route should be equal")
+	require.True(t, types.TwinRoute(expectedRoute).Equal(peer1Routes.Routes[0]), "received route should be equal")
 
 	peer2Routes, err := am.GetNetworkMap(context.Background(), peer2ID)
 	require.NoError(t, err)
@@ -1299,7 +1299,7 @@ func createRouterManager(t *testing.T) (*DefaultAccountManager, *update_channel.
 
 	updateManager := update_channel.NewPeersUpdateManager(metrics)
 	requestBuffer := NewAccountRequestBuffer(ctx, store)
-	networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{})
+	networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil)
 
 	am, err := BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
 	if err != nil {
diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go
index 99bb2c2c1..6337ebf1a 100644
--- a/management/server/store/sql_store.go
+++ b/management/server/store/sql_store.go
@@ -3166,9 +3166,9 @@ func getGormConfig() *gorm.Config {
 
 // newPostgresStore initializes a new Postgres store.
 func newPostgresStore(ctx context.Context, metrics telemetry.AppMetrics, skipMigration bool) (Store, error) {
-	dsn, ok := lookupDSNEnv(postgresDsnEnv, postgresDsnEnvLegacy)
+	dsn, ok := lookupDSNEnv(PostgresDsnEnv, PostgresDsnEnvLegacy)
 	if !ok {
-		return nil, fmt.Errorf("%s is not set", postgresDsnEnv)
+		return nil, fmt.Errorf("%s is not set", PostgresDsnEnv)
 	}
 	return NewPostgresqlStore(ctx, dsn, metrics, skipMigration)
 }
diff --git a/management/server/store/sql_store_get_account_test.go b/management/server/store/sql_store_get_account_test.go
index 56f2a6c41..686839b1f 100644
--- a/management/server/store/sql_store_get_account_test.go
+++ b/management/server/store/sql_store_get_account_test.go
@@ -13,7 +13,6 @@ import (
 	"github.com/stretchr/testify/require"
 
 	nbdns "github.com/netbirdio/netbird/dns"
-	"github.com/netbirdio/netbird/management/server/integration_reference"
 	resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
 	routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
 	networkTypes "github.com/netbirdio/netbird/management/server/networks/types"
@@ -21,6 +20,7 @@ import (
 	"github.com/netbirdio/netbird/management/server/posture"
 	"github.com/netbirdio/netbird/management/server/types"
 	"github.com/netbirdio/netbird/route"
+	"github.com/netbirdio/netbird/shared/management/integration_reference"
 )
 
 // TestGetAccount_LoadsCustomDomains verifies GetAccount populates account.Domains.
diff --git a/management/server/store/store.go b/management/server/store/store.go
index ca911092b..7daeb28a9 100644
--- a/management/server/store/store.go
+++ b/management/server/store/store.go
@@ -436,8 +436,8 @@ type AgentNetworkMetrics struct {
 }
 
 const (
-	postgresDsnEnv       = "NB_STORE_ENGINE_POSTGRES_DSN"
-	postgresDsnEnvLegacy = "NETBIRD_STORE_ENGINE_POSTGRES_DSN"
+	PostgresDsnEnv       = "NB_STORE_ENGINE_POSTGRES_DSN"
+	PostgresDsnEnvLegacy = "NETBIRD_STORE_ENGINE_POSTGRES_DSN"
 	mysqlDsnEnv          = "NB_STORE_ENGINE_MYSQL_DSN"
 	mysqlDsnEnvLegacy    = "NETBIRD_STORE_ENGINE_MYSQL_DSN"
 )
@@ -781,7 +781,7 @@ func getSqlStoreEngine(ctx context.Context, store *SqlStore, kind types.Engine)
 }
 
 func newReusedPostgresStore(ctx context.Context, store *SqlStore, kind types.Engine) (*SqlStore, func(), error) {
-	dsn, ok := lookupDSNEnv(postgresDsnEnv, postgresDsnEnvLegacy)
+	dsn, ok := lookupDSNEnv(PostgresDsnEnv, PostgresDsnEnvLegacy)
 	if !ok || dsn == "" {
 		var err error
 		_, dsn, err = testutil.CreatePostgresTestContainer()
@@ -791,7 +791,7 @@ func newReusedPostgresStore(ctx context.Context, store *SqlStore, kind types.Eng
 	}
 
 	if dsn == "" {
-		return nil, nil, fmt.Errorf("%s is not set", postgresDsnEnv)
+		return nil, nil, fmt.Errorf("%s is not set", PostgresDsnEnv)
 	}
 
 	db, err := openDBWithRetry(dsn, kind, 5)
diff --git a/management/server/types/account.go b/management/server/types/account.go
index 4616fe26b..d689b0175 100644
--- a/management/server/types/account.go
+++ b/management/server/types/account.go
@@ -9,7 +9,6 @@ import (
 	"strings"
 	"time"
 
-	"github.com/hashicorp/go-multierror"
 	"github.com/miekg/dns"
 	"github.com/rs/xid"
 	log "github.com/sirupsen/logrus"
@@ -18,8 +17,6 @@ import (
 	nbdns "github.com/netbirdio/netbird/dns"
 	proxydomain "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
 	"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
-	"github.com/netbirdio/netbird/management/internals/modules/zones"
-	"github.com/netbirdio/netbird/management/internals/modules/zones/records"
 	resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
 	routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
 	networkTypes "github.com/netbirdio/netbird/management/server/networks/types"
@@ -28,11 +25,12 @@ import (
 	"github.com/netbirdio/netbird/management/server/util"
 	"github.com/netbirdio/netbird/route"
 	"github.com/netbirdio/netbird/shared/management/domain"
+	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	"github.com/netbirdio/netbird/shared/management/status"
 )
 
 const (
-	defaultTTL = 300
 	// privateServiceDNSRecordTTL is short so proxy-peer changes propagate quickly to clients.
 	privateServiceDNSRecordTTL      = 5
 	DefaultPeerLoginExpiration      = 24 * time.Hour
@@ -384,94 +382,11 @@ func peerInDistributionGroups(peerGroups LookupMap, distributionGroups []string)
 }
 
 func (a *Account) GetPeersCustomZone(ctx context.Context, dnsDomain string) nbdns.CustomZone {
-	var merr *multierror.Error
-
-	if dnsDomain == "" {
-		log.WithContext(ctx).Error("no dns domain is set, returning empty zone")
-		return nbdns.CustomZone{}
+	twins := make(map[string]*nmdata.Peer, len(a.Peers))
+	for id, p := range a.Peers {
+		twins[id] = twinPeer(p)
 	}
-
-	customZone := nbdns.CustomZone{
-		Domain:  dns.Fqdn(dnsDomain),
-		Records: make([]nbdns.SimpleRecord, 0, len(a.Peers)),
-	}
-
-	domainSuffix := "." + dnsDomain
-
-	ipv6AllowedPeers := a.peerIPv6AllowedSet()
-
-	var sb strings.Builder
-	for _, peer := range a.Peers {
-		if peer.DNSLabel == "" {
-			merr = multierror.Append(merr, fmt.Errorf("peer %s has an empty DNS label", peer.Name))
-			continue
-		}
-
-		sb.Grow(len(peer.DNSLabel) + len(domainSuffix))
-		sb.WriteString(peer.DNSLabel)
-		sb.WriteString(domainSuffix)
-
-		fqdn := sb.String()
-		customZone.Records = append(customZone.Records, nbdns.SimpleRecord{
-			Name:  fqdn,
-			Type:  int(dns.TypeA),
-			Class: nbdns.DefaultClass,
-			TTL:   defaultTTL,
-			RData: peer.IP.String(),
-		})
-		// Only advertise AAAA for peers that have a valid IPv6, whose client supports it,
-		// and that belong to an IPv6-enabled group. Old clients don't configure v6 on their
-		// WireGuard interface, so resolving their AAAA causes connections to hang.
-		// Capability changes (client upgrade/downgrade, --disable-ipv6 toggle) propagate
-		// to other peers via SyncPeer/LoginPeer regardless of version change, so AAAA
-		// records refresh when a peer first reports the IPv6 overlay capability.
-		_, peerAllowed := ipv6AllowedPeers[peer.ID]
-		hasIPv6 := peer.IPv6.IsValid() && peer.SupportsIPv6() && peerAllowed
-		if hasIPv6 {
-			customZone.Records = append(customZone.Records, nbdns.SimpleRecord{
-				Name:  fqdn,
-				Type:  int(dns.TypeAAAA),
-				Class: nbdns.DefaultClass,
-				TTL:   defaultTTL,
-				RData: peer.IPv6.String(),
-			})
-		}
-		sb.Reset()
-
-		for _, extraLabel := range peer.ExtraDNSLabels {
-			sb.Grow(len(extraLabel) + len(domainSuffix))
-			sb.WriteString(extraLabel)
-			sb.WriteString(domainSuffix)
-
-			extraFqdn := sb.String()
-			customZone.Records = append(customZone.Records, nbdns.SimpleRecord{
-				Name:  extraFqdn,
-				Type:  int(dns.TypeA),
-				Class: nbdns.DefaultClass,
-				TTL:   defaultTTL,
-				RData: peer.IP.String(),
-			})
-			if hasIPv6 {
-				customZone.Records = append(customZone.Records, nbdns.SimpleRecord{
-					Name:  extraFqdn,
-					Type:  int(dns.TypeAAAA),
-					Class: nbdns.DefaultClass,
-					TTL:   defaultTTL,
-					RData: peer.IPv6.String(),
-				})
-			}
-			sb.Reset()
-		}
-
-	}
-
-	go func() {
-		if merr != nil {
-			log.WithContext(ctx).Errorf("error generating custom zone for account %s: %v", a.Id, merr)
-		}
-	}()
-
-	return customZone
+	return fromTwinCustomZone(networkmap.PeersCustomZone(ctx, a.Id, dnsDomain, twins, a.peerIPv6AllowedSet()))
 }
 
 // GetExpiredPeers returns peers that have been expired
@@ -994,13 +909,13 @@ func (a *Account) GetPeerConnectionResources(ctx context.Context, peer *nbpeer.P
 			var peerInSources, peerInDestinations bool
 
 			if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" {
-				sourcePeers, peerInSources = a.getPeerFromResource(rule.SourceResource, peer.ID)
+				sourcePeers, peerInSources = a.getPeerFromResource(ctx, rule.SourceResource, peer.ID, policy.SourcePostureChecks, validatedPeersMap)
 			} else {
 				sourcePeers, peerInSources = a.getAllPeersFromGroups(ctx, rule.Sources, peer.ID, policy.SourcePostureChecks, validatedPeersMap)
 			}
 
 			if rule.DestinationResource.Type == ResourceTypePeer && rule.DestinationResource.ID != "" {
-				destinationPeers, peerInDestinations = a.getPeerFromResource(rule.DestinationResource, peer.ID)
+				destinationPeers, peerInDestinations = a.getPeerFromResource(ctx, rule.DestinationResource, peer.ID, nil, validatedPeersMap)
 			} else {
 				destinationPeers, peerInDestinations = a.getAllPeersFromGroups(ctx, rule.Destinations, peer.ID, nil, validatedPeersMap)
 			}
@@ -1065,6 +980,26 @@ func (a *Account) GetPeerConnectionResources(ctx context.Context, peer *nbpeer.P
 	return peers, fwRules, authorizedUsers, sshEnabled
 }
 
+// proxyTargetedDomainResourceIDs returns the set of domain network resource IDs
+// targeted by an enabled, non-terminated reverse-proxy service.
+func (a *Account) proxyTargetedDomainResourceIDs() map[string]struct{} {
+	ids := make(map[string]struct{})
+	for _, svc := range a.Services {
+		if svc == nil || !svc.Enabled || svc.Terminated {
+			continue
+		}
+		for _, target := range svc.Targets {
+			if target == nil || !target.Enabled {
+				continue
+			}
+			if target.TargetType == service.TargetTypeDomain {
+				ids[target.TargetId] = struct{}{}
+			}
+		}
+	}
+	return ids
+}
+
 func (a *Account) getAllowedUserIDs() map[string]struct{} {
 	users := make(map[string]struct{})
 	for _, nbUser := range a.Users {
@@ -1085,7 +1020,6 @@ func (a *Account) connResourcesGenerator(ctx context.Context, targetPeer *nbpeer
 	peersExists := make(map[string]struct{})
 	rules := make([]*FirewallRule, 0)
 	peers := make([]*nbpeer.Peer, 0)
-	targetComponent := targetPeer.ToComponent()
 
 	return func(rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int) {
 			for _, peer := range groupPeers {
@@ -1121,10 +1055,10 @@ func (a *Account) connResourcesGenerator(ctx context.Context, targetPeer *nbpeer
 				if len(rule.Ports) == 0 && len(rule.PortRanges) == 0 {
 					rules = append(rules, &fr)
 				} else {
-					rules = append(rules, ExpandPortsAndRanges(fr, rule, targetComponent)...)
+					rules = append(rules, ExpandPortsAndRanges(fr, rule, targetPeer)...)
 				}
 
-				rules = AppendIPv6FirewallRule(rules, rulesExists, peer.ToComponent(), targetComponent, rule, FirewallRuleContext{
+				rules = AppendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, rule, FirewallRuleContext{
 					Direction:   direction,
 					DirStr:      strconv.Itoa(direction),
 					ProtocolStr: string(protocol),
@@ -1186,8 +1120,17 @@ func ruleHasDestination(rule *PolicyRule, peerID string, peerGroupIDs map[string
 // Important: Posture checks are applicable only to source group peers,
 // for destination group peers, call this method with an empty list of sourcePostureChecksIDs
 func (a *Account) getAllPeersFromGroups(ctx context.Context, groups []string, peerID string, sourcePostureChecksIDs []string, validatedPeersMap map[string]struct{}) ([]*nbpeer.Peer, bool) {
+	return a.filterPolicyPeers(ctx, a.getUniquePeerIDsFromGroupsIDs(ctx, groups), peerID, sourcePostureChecksIDs, validatedPeersMap)
+}
+
+// getPeerFromResource resolves a rule side that names a peer directly, admitting it
+// like a member of a group holding only that peer.
+func (a *Account) getPeerFromResource(ctx context.Context, resource Resource, peerID string, sourcePostureChecksIDs []string, validatedPeersMap map[string]struct{}) ([]*nbpeer.Peer, bool) {
+	return a.filterPolicyPeers(ctx, []string{resource.ID}, peerID, sourcePostureChecksIDs, validatedPeersMap)
+}
+
+func (a *Account) filterPolicyPeers(ctx context.Context, uniquePeerIDs []string, peerID string, sourcePostureChecksIDs []string, validatedPeersMap map[string]struct{}) ([]*nbpeer.Peer, bool) {
 	peerInGroups := false
-	uniquePeerIDs := a.getUniquePeerIDsFromGroupsIDs(ctx, groups)
 	filteredPeers := make([]*nbpeer.Peer, 0, len(uniquePeerIDs))
 	for _, p := range uniquePeerIDs {
 		peer, ok := a.Peers[p]
@@ -1216,19 +1159,6 @@ func (a *Account) getAllPeersFromGroups(ctx context.Context, groups []string, pe
 	return filteredPeers, peerInGroups
 }
 
-func (a *Account) getPeerFromResource(resource Resource, peerID string) ([]*nbpeer.Peer, bool) {
-	peer := a.GetPeer(resource.ID)
-	if peer == nil {
-		return []*nbpeer.Peer{}, false
-	}
-
-	if peer.ID == peerID {
-		return []*nbpeer.Peer{}, true
-	}
-
-	return []*nbpeer.Peer{peer}, false
-}
-
 // validatePostureChecksOnPeer validates the posture checks on a peer
 func (a *Account) validatePostureChecksOnPeer(ctx context.Context, sourcePostureChecksID []string, peerID string) bool {
 	peer, ok := a.Peers[peerID]
@@ -1284,7 +1214,7 @@ func (a *Account) getRouteFirewallRules(ctx context.Context, peerID string, poli
 	return fwRules
 }
 
-func (a *Account) getRulePeers(rule *PolicyRule, postureChecks []string, peerID string, distributionPeers map[string]struct{}, validatedPeersMap map[string]struct{}) []*ComponentPeer {
+func (a *Account) getRulePeers(rule *PolicyRule, postureChecks []string, peerID string, distributionPeers map[string]struct{}, validatedPeersMap map[string]struct{}) []*nbpeer.Peer {
 	distPeersWithPolicy := make(map[string]struct{})
 	for _, id := range rule.Sources {
 		group := a.Groups[id]
@@ -1311,13 +1241,13 @@ func (a *Account) getRulePeers(rule *PolicyRule, postureChecks []string, peerID
 		}
 	}
 
-	distributionGroupPeers := make([]*ComponentPeer, 0, len(distPeersWithPolicy))
+	distributionGroupPeers := make([]*nbpeer.Peer, 0, len(distPeersWithPolicy))
 	for pID := range distPeersWithPolicy {
 		peer := a.Peers[pID]
 		if peer == nil {
 			continue
 		}
-		distributionGroupPeers = append(distributionGroupPeers, peer.ToComponent())
+		distributionGroupPeers = append(distributionGroupPeers, peer)
 	}
 	return distributionGroupPeers
 }
@@ -1520,54 +1450,6 @@ func (a *Account) GetResourceRoutersMap() map[string]map[string]*routerTypes.Net
 	return routers
 }
 
-// forcesRoutingPeerDNSResolution reports whether the given peer must run
-// routing-peer DNS resolution regardless of the account-global
-// RoutingPeerDNSResolutionEnabled setting. It returns true when the peer is a
-// router for a domain network resource that is targeted by an enabled
-// reverse-proxy service, so the peer's DNS forwarder starts and can resolve
-// the target for the embedded proxy peers. Embedded proxy peers themselves are
-// handled at PeerConfig build time.
-func (a *Account) forcesRoutingPeerDNSResolution(peerID string, routers map[string]map[string]*routerTypes.NetworkRouter) bool {
-	targeted := a.proxyTargetedDomainResourceIDs()
-	if len(targeted) == 0 {
-		return false
-	}
-
-	for _, resource := range a.NetworkResources {
-		if resource == nil || !resource.Enabled || resource.Type != resourceTypes.Domain {
-			continue
-		}
-		if _, ok := targeted[resource.ID]; !ok {
-			continue
-		}
-		if _, isRouter := routers[resource.NetworkID][peerID]; isRouter {
-			return true
-		}
-	}
-
-	return false
-}
-
-// proxyTargetedDomainResourceIDs returns the set of domain network resource IDs
-// targeted by an enabled, non-terminated reverse-proxy service.
-func (a *Account) proxyTargetedDomainResourceIDs() map[string]struct{} {
-	ids := make(map[string]struct{})
-	for _, svc := range a.Services {
-		if svc == nil || !svc.Enabled || svc.Terminated {
-			continue
-		}
-		for _, target := range svc.Targets {
-			if target == nil || !target.Enabled {
-				continue
-			}
-			if target.TargetType == service.TargetTypeDomain {
-				ids[target.TargetId] = struct{}{}
-			}
-		}
-	}
-	return ids
-}
-
 // getPoliciesSourcePeers collects all unique peers from the source groups defined in the given policies.
 func getPoliciesSourcePeers(policies []*Policy, groups map[string]*Group) map[string]struct{} {
 	sourcePeers := make(map[string]struct{})
@@ -1668,176 +1550,6 @@ func (a *Account) GetProxyPeers() map[string][]*nbpeer.Peer {
 	return proxyPeers
 }
 
-func (a *Account) InjectProxyPolicies(ctx context.Context) {
-	if len(a.Services) == 0 {
-		return
-	}
-
-	proxyPeersByCluster := a.GetProxyPeers()
-	if len(proxyPeersByCluster) == 0 {
-		return
-	}
-
-	for _, service := range a.Services {
-		if !service.Enabled {
-			continue
-		}
-		a.injectServiceProxyPolicies(ctx, service, proxyPeersByCluster)
-	}
-
-}
-
-func (a *Account) injectServiceProxyPolicies(ctx context.Context, service *service.Service, proxyPeersByCluster map[string][]*nbpeer.Peer) {
-	proxyPeers := proxyPeersByCluster[service.ProxyCluster]
-	for _, target := range service.Targets {
-		if !target.Enabled {
-			continue
-		}
-		a.injectTargetProxyPolicies(ctx, service, target, proxyPeers)
-	}
-
-	a.injectPrivateServicePolicies(service, proxyPeers)
-}
-
-// injectPrivateServicePolicies synthesises an in-memory ACL: AccessGroups → cluster proxy peers on TCP 80/443.
-func (a *Account) injectPrivateServicePolicies(svc *service.Service, proxyPeers []*nbpeer.Peer) {
-	if !svc.Private {
-		return
-	}
-	if len(svc.AccessGroups) == 0 {
-		return
-	}
-	if len(proxyPeers) == 0 {
-		return
-	}
-	// A service's AccessGroups can name groups that no longer exist — persisted
-	// services and the agent-network synthesiser both carry the ids verbatim from
-	// their own state. An unresolvable source authorises nothing, so drop it here
-	// rather than let the network-map assembly resolve it to a nil group.
-	sources := a.existingGroupIDs(svc.AccessGroups)
-	if len(sources) == 0 {
-		return
-	}
-	for _, proxyPeer := range proxyPeers {
-		a.Policies = append(a.Policies, a.createPrivateServicePolicy(svc, proxyPeer, sources))
-	}
-}
-
-// existingGroupIDs returns the subset of groupIDs that resolve to a group in the account,
-// preserving the input order.
-func (a *Account) existingGroupIDs(groupIDs []string) []string {
-	out := make([]string, 0, len(groupIDs))
-	for _, groupID := range groupIDs {
-		if _, ok := a.Groups[groupID]; ok {
-			out = append(out, groupID)
-		}
-	}
-	return out
-}
-
-func (a *Account) createPrivateServicePolicy(svc *service.Service, proxyPeer *nbpeer.Peer, accessGroups []string) *Policy {
-	policyID := fmt.Sprintf("private-access-%s-%s", svc.ID, proxyPeer.ID)
-	sources := append([]string(nil), accessGroups...)
-	return &Policy{
-		ID:      policyID,
-		Name:    fmt.Sprintf("Private Access to %s", svc.Name),
-		Enabled: true,
-		Rules: []*PolicyRule{
-			{
-				ID:       policyID,
-				PolicyID: policyID,
-				Name:     fmt.Sprintf("Allow access groups to reach %s", svc.Name),
-				Enabled:  true,
-				Sources:  sources,
-				DestinationResource: Resource{
-					ID:   proxyPeer.ID,
-					Type: ResourceTypePeer,
-				},
-				Bidirectional: false,
-				Protocol:      PolicyRuleProtocolTCP,
-				Action:        PolicyTrafficActionAccept,
-				PortRanges: []RulePortRange{
-					{Start: 80, End: 80},
-					{Start: 443, End: 443},
-				},
-			},
-		},
-	}
-}
-
-func (a *Account) injectTargetProxyPolicies(ctx context.Context, service *service.Service, target *service.Target, proxyPeers []*nbpeer.Peer) {
-	port, ok := a.resolveTargetPort(ctx, target)
-	if !ok {
-		return
-	}
-
-	path := ""
-	if target.Path != nil {
-		path = *target.Path
-	}
-
-	for _, proxyPeer := range proxyPeers {
-		policy := a.createProxyPolicy(service, target, proxyPeer, port, path)
-		a.Policies = append(a.Policies, policy)
-	}
-}
-
-func (a *Account) resolveTargetPort(ctx context.Context, target *service.Target) (uint16, bool) {
-	if target.Port != 0 {
-		return target.Port, true
-	}
-
-	switch target.Protocol {
-	case "https", "tls":
-		return 443, true
-	case "http":
-		return 80, true
-	default:
-		log.WithContext(ctx).Warnf("unsupported protocol %s for proxy target %s, skipping policy injection", target.Protocol, target.TargetId)
-		return 0, false
-	}
-}
-
-func (a *Account) createProxyPolicy(svc *service.Service, target *service.Target, proxyPeer *nbpeer.Peer, port uint16, path string) *Policy {
-	policyID := fmt.Sprintf("proxy-access-%s-%s-%s", svc.ID, proxyPeer.ID, path)
-
-	protocol := PolicyRuleProtocolTCP
-	if svc.Mode == service.ModeUDP {
-		protocol = PolicyRuleProtocolUDP
-	}
-
-	return &Policy{
-		ID:      policyID,
-		Name:    fmt.Sprintf("Proxy Access to %s", svc.Name),
-		Enabled: true,
-		Rules: []*PolicyRule{
-			{
-				ID:       policyID,
-				PolicyID: policyID,
-				Name:     fmt.Sprintf("Allow access to %s", svc.Name),
-				Enabled:  true,
-				SourceResource: Resource{
-					ID:   proxyPeer.ID,
-					Type: ResourceTypePeer,
-				},
-				DestinationResource: Resource{
-					ID:   target.TargetId,
-					Type: ResourceType(target.TargetType),
-				},
-				Bidirectional: false,
-				Protocol:      protocol,
-				Action:        PolicyTrafficActionAccept,
-				PortRanges: []RulePortRange{
-					{
-						Start: port,
-						End:   port,
-					},
-				},
-			},
-		},
-	}
-}
-
 // filterZoneRecordsForPeers filters DNS records to only include peers to connect.
 // AAAA records are excluded when the requesting peer lacks IPv6 capability.
 func filterZoneRecordsForPeers(peer *nbpeer.Peer, customZone nbdns.CustomZone, peersToConnect, expiredPeers []*nbpeer.Peer) []nbdns.SimpleRecord {
@@ -1870,66 +1582,3 @@ func filterZoneRecordsForPeers(peer *nbpeer.Peer, customZone nbdns.CustomZone, p
 
 	return filteredRecords
 }
-
-// filterPeerAppliedZones filters account zones based on the peer's group membership
-func filterPeerAppliedZones(ctx context.Context, accountZones []*zones.Zone, peerGroups LookupMap) []nbdns.CustomZone {
-	var customZones []nbdns.CustomZone
-
-	if len(peerGroups) == 0 {
-		return customZones
-	}
-
-	for _, zone := range accountZones {
-		if !zone.Enabled || len(zone.Records) == 0 {
-			continue
-		}
-
-		hasAccess := false
-		for _, distGroupID := range zone.DistributionGroups {
-			if _, found := peerGroups[distGroupID]; found {
-				hasAccess = true
-				break
-			}
-		}
-
-		if !hasAccess {
-			continue
-		}
-
-		simpleRecords := make([]nbdns.SimpleRecord, 0, len(zone.Records))
-		for _, record := range zone.Records {
-			var recordType int
-			rData := record.Content
-
-			switch record.Type {
-			case records.RecordTypeA:
-				recordType = int(dns.TypeA)
-			case records.RecordTypeAAAA:
-				recordType = int(dns.TypeAAAA)
-			case records.RecordTypeCNAME:
-				recordType = int(dns.TypeCNAME)
-				rData = dns.Fqdn(record.Content)
-			default:
-				log.WithContext(ctx).Warnf("unknown DNS record type %s for record %s", record.Type, record.ID)
-				continue
-			}
-
-			simpleRecords = append(simpleRecords, nbdns.SimpleRecord{
-				Name:  dns.Fqdn(record.Name),
-				Type:  recordType,
-				Class: nbdns.DefaultClass,
-				TTL:   record.TTL,
-				RData: rData,
-			})
-		}
-
-		customZones = append(customZones, nbdns.CustomZone{
-			Domain:               dns.Fqdn(zone.Domain),
-			Records:              simpleRecords,
-			SearchDomainDisabled: !zone.EnableSearchDomain,
-			NonAuthoritative:     true,
-		})
-	}
-
-	return customZones
-}
diff --git a/management/server/types/account_components.go b/management/server/types/account_components.go
index 624a778fe..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,724 +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(),
-			// must include the target peer as it's required on the client
-			Peers: map[string]*ComponentPeer{peerID: peer.ToComponent()},
-		})
-	}
 
-	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
new file mode 100644
index 000000000..99f5f9b72
--- /dev/null
+++ b/management/server/types/account_components_test.go
@@ -0,0 +1,23 @@
+package types
+
+import (
+	"context"
+	"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"
+)
+
+func TestGetPeerNetworkMapComponents_PeerMissingFromAcount(t *testing.T) {
+	account := Account{Network: NewNetwork()}
+	nmapcomponets := account.GetPeerNetworkMapComponents(context.TODO(), "missing-peer", dns.CustomZone{}, nil, nil, nil, nil, nil)
+
+	assert.Equal(t, EmptyNetworkMapComponents(&types.NetworkMapComponents{
+		PeerID:                        "missing-peer",
+		Network:                       TwinNetwork(account.Network),
+		Peers:                         map[string]*nmdata.Peer{"missing-peer": nil},
+		ForceRoutingPeerDNSResolution: false,
+	}), nmapcomponets)
+}
diff --git a/management/server/types/account_networkmapdata.go b/management/server/types/account_networkmapdata.go
new file mode 100644
index 000000000..d554bfe80
--- /dev/null
+++ b/management/server/types/account_networkmapdata.go
@@ -0,0 +1,623 @@
+package types
+
+import (
+	"github.com/miekg/dns"
+
+	nbdns "github.com/netbirdio/netbird/dns"
+	"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
+	"github.com/netbirdio/netbird/management/internals/modules/zones"
+	"github.com/netbirdio/netbird/management/internals/modules/zones/records"
+	resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
+	routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
+	nbpeer "github.com/netbirdio/netbird/management/server/peer"
+	"github.com/netbirdio/netbird/management/server/posture"
+	nbroute "github.com/netbirdio/netbird/route"
+	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+// toNetworkMapData builds the slim twin store from the account once per
+// account. The per-peer components calculation then runs on the twin.
+func (a *Account) toNetworkMapData(
+	accountZones []*zones.Zone,
+	validatedPeersMap map[string]struct{},
+	resourcePolicies map[string][]*Policy,
+	routers map[string]map[string]*routerTypes.NetworkRouter,
+	groupIDToUserIDs map[string][]string,
+) *networkmap.NetworkMapData {
+	nmd := &networkmap.NetworkMapData{
+		Peers:                     make(map[string]*nmdata.Peer, len(a.Peers)),
+		Groups:                    make(map[string]*nmdata.Group, len(a.Groups)),
+		Policies:                  make([]*nmdata.Policy, 0, len(a.Policies)),
+		Routes:                    make([]*nmdata.Route, 0, len(a.Routes)),
+		NameServerGroups:          make([]*nmdata.NameServerGroup, 0, len(a.NameServerGroups)),
+		NetworkResources:          make([]*nmdata.NetworkResource, 0, len(a.NetworkResources)),
+		PostureChecks:             make(map[string]*nmdata.PostureChecks, len(a.PostureChecks)),
+		ResourcePolicies:          make(map[string][]*nmdata.Policy, len(resourcePolicies)),
+		Routers:                   make(map[string]map[string]*nmdata.NetworkRouter, len(routers)),
+		ValidatedPeers:            validatedPeersMap,
+		GroupIDToUserIDs:          groupIDToUserIDs,
+		PostureValidation:         a.PostureValidation,
+		AllowedUserIDs:            a.getAllowedUserIDs(),
+		NetworkXIDToPublicID:      make(map[string]string, len(a.Networks)),
+		PostureCheckXIDToPublicID: make(map[string]string, len(a.PostureChecks)),
+	}
+
+	if a.Network != nil {
+		nmd.Network = TwinNetwork(a.Network)
+	}
+	nmd.DNSSettings = &nmdata.DNSSettings{DisabledManagementGroups: a.DNSSettings.DisabledManagementGroups}
+	nmd.AccountSettings = TwinAccountSettings(a.Settings)
+
+	for id, p := range a.Peers {
+		nmd.Peers[id] = twinPeer(p)
+	}
+	for id, g := range a.Groups {
+		nmd.Groups[id] = twinGroup(g)
+	}
+
+	policyCache := make(map[string]*nmdata.Policy, len(a.Policies))
+	twinPol := func(p *Policy) *nmdata.Policy {
+		if p == nil {
+			return nil
+		}
+		if tp, ok := policyCache[p.ID]; ok {
+			return tp
+		}
+		tp := twinPolicy(p)
+		policyCache[p.ID] = tp
+		return tp
+	}
+	for _, p := range a.Policies {
+		nmd.Policies = append(nmd.Policies, twinPol(p))
+	}
+	for resID, pols := range resourcePolicies {
+		twinPols := make([]*nmdata.Policy, 0, len(pols))
+		for _, p := range pols {
+			twinPols = append(twinPols, twinPol(p))
+		}
+		nmd.ResourcePolicies[resID] = twinPols
+	}
+
+	for _, r := range a.Routes {
+		if r == nil {
+			continue
+		}
+		nmd.Routes = append(nmd.Routes, twinRoute(r))
+	}
+	for _, nsg := range a.NameServerGroups {
+		nmd.NameServerGroups = append(nmd.NameServerGroups, twinNSG(nsg))
+	}
+	for _, res := range a.NetworkResources {
+		nmd.NetworkResources = append(nmd.NetworkResources, TwinNetworkResource(res))
+	}
+	for _, pc := range a.PostureChecks {
+		if pc != nil {
+			nmd.PostureChecks[pc.ID] = TwinPostureChecks(pc)
+			nmd.PostureCheckXIDToPublicID[pc.ID] = pc.PublicID
+		}
+	}
+	for _, n := range a.Networks {
+		if n != nil {
+			nmd.NetworkXIDToPublicID[n.ID] = n.PublicID
+		}
+	}
+	for networkID, inner := range routers {
+		twinInner := make(map[string]*nmdata.NetworkRouter, len(inner))
+		for peerID, router := range inner {
+			twinInner[peerID] = twinRouter(router)
+		}
+		nmd.Routers[networkID] = twinInner
+	}
+
+	nmd.ProxyTargetedDomainResourceIDs = a.proxyTargetedDomainResourceIDs()
+	nmd.AppliedZoneCandidates = buildAppliedZoneCandidates(accountZones)
+	nmd.PrivateServiceCandidates = a.buildPrivateServiceCandidates()
+	nmd.Services = TwinServices(a.Services)
+
+	return nmd
+}
+
+// TwinServices converts reverse-proxy services to their slim nmdata twins.
+// Exported for the network-map controller, which hands the store-backed twin
+// the same services the account carries.
+func TwinServices(services []*service.Service) []*nmdata.Service {
+	if len(services) == 0 {
+		return nil
+	}
+	out := make([]*nmdata.Service, 0, len(services))
+	for _, svc := range services {
+		if svc == nil {
+			continue
+		}
+		targets := make([]*nmdata.ServiceTarget, 0, len(svc.Targets))
+		for _, t := range svc.Targets {
+			if t == nil {
+				continue
+			}
+			path := ""
+			if t.Path != nil {
+				path = *t.Path
+			}
+			targets = append(targets, &nmdata.ServiceTarget{
+				Enabled:    t.Enabled,
+				Path:       path,
+				Port:       t.Port,
+				Protocol:   t.Protocol,
+				TargetID:   t.TargetId,
+				TargetType: string(t.TargetType),
+			})
+		}
+		out = append(out, &nmdata.Service{
+			ID:           svc.ID,
+			Enabled:      svc.Enabled,
+			Private:      svc.Private,
+			Mode:         svc.Mode,
+			ProxyCluster: svc.ProxyCluster,
+			AccessGroups: svc.AccessGroups,
+			Targets:      targets,
+		})
+	}
+	return out
+}
+
+func twinPeer(p *nbpeer.Peer) *nmdata.Peer {
+	if p == nil {
+		return nil
+	}
+	networkAddresses := make([]nmdata.NetworkAddress, 0, len(p.Meta.NetworkAddresses))
+	for _, na := range p.Meta.NetworkAddresses {
+		networkAddresses = append(networkAddresses, nmdata.NetworkAddress{NetIP: na.NetIP})
+	}
+	files := make([]nmdata.File, 0, len(p.Meta.Files))
+	for _, f := range p.Meta.Files {
+		files = append(files, nmdata.File{Path: f.Path, ProcessIsRunning: f.ProcessIsRunning})
+	}
+	return &nmdata.Peer{
+		ID:                     p.ID,
+		Key:                    p.Key,
+		SSHKey:                 p.SSHKey,
+		DNSLabel:               p.DNSLabel,
+		UserID:                 p.UserID,
+		SSHEnabled:             p.SSHEnabled,
+		LoginExpirationEnabled: p.LoginExpirationEnabled,
+		LastLogin:              p.LastLogin,
+		IP:                     p.IP,
+		IPv6:                   p.IPv6,
+		RequiresApproval:       p.Status != nil && p.Status.RequiresApproval,
+		ExtraDNSLabels:         p.ExtraDNSLabels,
+		ProxyMeta:              nmdata.ProxyMeta{Embedded: p.ProxyMeta.Embedded, Cluster: p.ProxyMeta.Cluster},
+		Meta: nmdata.PeerSystemMeta{
+			WtVersion:          p.Meta.WtVersion,
+			GoOS:               p.Meta.GoOS,
+			OSVersion:          p.Meta.OSVersion,
+			KernelVersion:      p.Meta.KernelVersion,
+			NetworkAddresses:   networkAddresses,
+			Files:              files,
+			Capabilities:       p.Meta.Capabilities,
+			SyncMessageVersion: p.Meta.SyncMessageVersion,
+			Flags: nmdata.Flags{
+				ServerSSHAllowed: p.Meta.Flags.ServerSSHAllowed,
+				DisableIPv6:      p.Meta.Flags.DisableIPv6,
+			},
+		},
+		Location: nmdata.PeerLocation{
+			CountryCode:  p.Location.CountryCode,
+			CityName:     p.Location.CityName,
+			ConnectionIP: p.Location.ConnectionIP,
+		},
+	}
+}
+
+// TwinPeer converts a real peer to its slim nmdata twin. Exported for the
+// port-forwarding integration, which builds proxy NetworkMaps holding twins.
+func TwinPeer(p *nbpeer.Peer) *nmdata.Peer {
+	return twinPeer(p)
+}
+
+// TwinPeers converts real peers to their slim nmdata twins.
+func TwinPeers(peers []*nbpeer.Peer) []*nmdata.Peer {
+	out := make([]*nmdata.Peer, len(peers))
+	for i, p := range peers {
+		out[i] = twinPeer(p)
+	}
+	return out
+}
+
+// TwinGroups converts real groups to their slim nmdata twins.
+func TwinGroups(groups []*Group) []*nmdata.Group {
+	out := make([]*nmdata.Group, len(groups))
+	for i, g := range groups {
+		out[i] = twinGroup(g)
+	}
+	return out
+}
+
+func twinGroup(g *Group) *nmdata.Group {
+	if g == nil {
+		return nil
+	}
+	return &nmdata.Group{
+		ID:       g.ID,
+		Name:     g.Name,
+		PublicID: g.PublicID,
+		Peers:    g.Peers,
+	}
+}
+
+func twinPolicy(p *Policy) *nmdata.Policy {
+	if p == nil {
+		return nil
+	}
+	rules := make([]*nmdata.PolicyRule, 0, len(p.Rules))
+	for _, r := range p.Rules {
+		rules = append(rules, twinRule(r))
+	}
+	return &nmdata.Policy{
+		ID:                  p.ID,
+		PublicID:            p.PublicID,
+		Enabled:             p.Enabled,
+		SourcePostureChecks: p.SourcePostureChecks,
+		Rules:               rules,
+	}
+}
+
+func twinRule(r *PolicyRule) *nmdata.PolicyRule {
+	if r == nil {
+		return nil
+	}
+	var portRanges []nmdata.RulePortRange
+	if r.PortRanges != nil {
+		portRanges = make([]nmdata.RulePortRange, len(r.PortRanges))
+		for i, pr := range r.PortRanges {
+			portRanges[i] = nmdata.RulePortRange{Start: pr.Start, End: pr.End}
+		}
+	}
+	return &nmdata.PolicyRule{
+		ID:                  r.ID,
+		PolicyID:            r.PolicyID,
+		Enabled:             r.Enabled,
+		Action:              string(r.Action),
+		Protocol:            string(r.Protocol),
+		Bidirectional:       r.Bidirectional,
+		Sources:             r.Sources,
+		Destinations:        r.Destinations,
+		SourceResource:      nmdata.Resource{ID: r.SourceResource.ID, Type: string(r.SourceResource.Type)},
+		DestinationResource: nmdata.Resource{ID: r.DestinationResource.ID, Type: string(r.DestinationResource.Type)},
+		Ports:               r.Ports,
+		PortRanges:          portRanges,
+		AuthorizedGroups:    r.AuthorizedGroups,
+		AuthorizedUser:      r.AuthorizedUser,
+	}
+}
+
+func twinRoute(r *nbroute.Route) *nmdata.Route {
+	return &nmdata.Route{
+		ID:                  string(r.ID),
+		AccountID:           r.AccountID,
+		PublicID:            r.PublicID,
+		Network:             r.Network,
+		Domains:             r.Domains,
+		KeepRoute:           r.KeepRoute,
+		NetID:               string(r.NetID),
+		Description:         r.Description,
+		Peer:                r.Peer,
+		PeerID:              r.PeerID,
+		PeerGroups:          r.PeerGroups,
+		NetworkType:         int(r.NetworkType),
+		Masquerade:          r.Masquerade,
+		Metric:              r.Metric,
+		Enabled:             r.Enabled,
+		Groups:              r.Groups,
+		AccessControlGroups: r.AccessControlGroups,
+		SkipAutoApply:       r.SkipAutoApply,
+	}
+}
+
+// TwinRoute converts a real *route.Route to its slim nmdata twin. Exported for
+// tests that assert against twin routes returned in a NetworkMap.
+func TwinRoute(r *nbroute.Route) *nmdata.Route {
+	return twinRoute(r)
+}
+
+func TwinNetworkResource(r *resourceTypes.NetworkResource) *nmdata.NetworkResource {
+	if r == nil {
+		return nil
+	}
+	return &nmdata.NetworkResource{
+		ID:          r.ID,
+		NetworkID:   r.NetworkID,
+		AccountID:   r.AccountID,
+		PublicID:    r.PublicID,
+		Name:        r.Name,
+		Description: r.Description,
+		Type:        string(r.Type),
+		Address:     r.Address,
+		Domain:      r.Domain,
+		Prefix:      r.Prefix,
+		Enabled:     r.Enabled,
+	}
+}
+
+func twinRouter(r *routerTypes.NetworkRouter) *nmdata.NetworkRouter {
+	if r == nil {
+		return nil
+	}
+	return &nmdata.NetworkRouter{
+		PublicID:   r.PublicID,
+		PeerGroups: r.PeerGroups,
+		Masquerade: r.Masquerade,
+		Metric:     r.Metric,
+		Enabled:    r.Enabled,
+	}
+}
+
+func twinNSG(n *nbdns.NameServerGroup) *nmdata.NameServerGroup {
+	if n == nil {
+		return nil
+	}
+	nameServers := make([]nmdata.NameServer, 0, len(n.NameServers))
+	for _, ns := range n.NameServers {
+		nameServers = append(nameServers, nmdata.NameServer{
+			IP:     ns.IP,
+			NSType: int(ns.NSType),
+			Port:   ns.Port,
+		})
+	}
+	return &nmdata.NameServerGroup{
+		ID:                   n.ID,
+		PublicID:             n.PublicID,
+		Name:                 n.Name,
+		Description:          n.Description,
+		NameServers:          nameServers,
+		Groups:               n.Groups,
+		Primary:              n.Primary,
+		Domains:              n.Domains,
+		Enabled:              n.Enabled,
+		SearchDomainsEnabled: n.SearchDomainsEnabled,
+	}
+}
+
+// TwinNetwork converts a real *Network to its slim twin. Exported for the
+// graceful-degrade path that builds a minimal NetworkMapComponents directly.
+func TwinNetwork(n *Network) *nmdata.Network {
+	nc := n.Copy()
+	return &nmdata.Network{
+		Identifier: nc.Identifier,
+		Net:        nc.Net,
+		NetV6:      nc.NetV6,
+		Dns:        nc.Dns,
+		Serial:     int64(nc.Serial),
+	}
+}
+
+// TwinPostureChecksList converts posture checks to their slim nmdata twins.
+func TwinPostureChecksList(checks []*posture.Checks) []*nmdata.PostureChecks {
+	out := make([]*nmdata.PostureChecks, 0, len(checks))
+	for _, pc := range checks {
+		out = append(out, TwinPostureChecks(pc))
+	}
+	return out
+}
+
+// TwinPostureChecks converts posture checks to their slim nmdata twin.
+func TwinPostureChecks(pc *posture.Checks) *nmdata.PostureChecks {
+	if pc == nil {
+		return nil
+	}
+	out := &nmdata.PostureChecks{ID: pc.ID}
+	def := pc.Checks
+	if def.NBVersionCheck != nil {
+		out.Checks.NBVersionCheck = &nmdata.NBVersionCheck{MinVersion: def.NBVersionCheck.MinVersion}
+	}
+	if def.OSVersionCheck != nil {
+		oc := &nmdata.OSVersionCheck{}
+		if def.OSVersionCheck.Android != nil {
+			oc.Android = &nmdata.MinVersionCheck{MinVersion: def.OSVersionCheck.Android.MinVersion}
+		}
+		if def.OSVersionCheck.Darwin != nil {
+			oc.Darwin = &nmdata.MinVersionCheck{MinVersion: def.OSVersionCheck.Darwin.MinVersion}
+		}
+		if def.OSVersionCheck.Ios != nil {
+			oc.Ios = &nmdata.MinVersionCheck{MinVersion: def.OSVersionCheck.Ios.MinVersion}
+		}
+		if def.OSVersionCheck.Linux != nil {
+			oc.Linux = &nmdata.MinKernelVersionCheck{MinKernelVersion: def.OSVersionCheck.Linux.MinKernelVersion}
+		}
+		if def.OSVersionCheck.Windows != nil {
+			oc.Windows = &nmdata.MinKernelVersionCheck{MinKernelVersion: def.OSVersionCheck.Windows.MinKernelVersion}
+		}
+		out.Checks.OSVersionCheck = oc
+	}
+	if def.GeoLocationCheck != nil {
+		gc := &nmdata.GeoLocationCheck{Action: def.GeoLocationCheck.Action}
+		for _, loc := range def.GeoLocationCheck.Locations {
+			gc.Locations = append(gc.Locations, nmdata.GeoLocation{CountryCode: loc.CountryCode, CityName: loc.CityName})
+		}
+		out.Checks.GeoLocationCheck = gc
+	}
+	if def.PeerNetworkRangeCheck != nil {
+		out.Checks.PeerNetworkRangeCheck = &nmdata.PeerNetworkRangeCheck{
+			Action: def.PeerNetworkRangeCheck.Action,
+			Ranges: def.PeerNetworkRangeCheck.Ranges,
+		}
+	}
+	if def.ProcessCheck != nil {
+		procs := make([]nmdata.Process, 0, len(def.ProcessCheck.Processes))
+		for _, p := range def.ProcessCheck.Processes {
+			procs = append(procs, nmdata.Process{LinuxPath: p.LinuxPath, MacPath: p.MacPath, WindowsPath: p.WindowsPath})
+		}
+		out.Checks.ProcessCheck = &nmdata.ProcessCheck{Processes: procs}
+	}
+	return out
+}
+
+// buildAppliedZoneCandidates precomputes the account-level custom DNS zones
+// (record conversion) once; the per-peer distribution-group gate runs in the
+// components calc. Mirrors the account-level half of filterPeerAppliedZones.
+func buildAppliedZoneCandidates(accountZones []*zones.Zone) []networkmap.AppliedZoneCandidate {
+	var out []networkmap.AppliedZoneCandidate
+	for _, zone := range accountZones {
+		if !zone.Enabled || len(zone.Records) == 0 {
+			continue
+		}
+		simpleRecords := make([]nmdata.SimpleRecord, 0, len(zone.Records))
+		for _, record := range zone.Records {
+			var recordType int
+			rData := record.Content
+			switch record.Type {
+			case records.RecordTypeA:
+				recordType = int(dns.TypeA)
+			case records.RecordTypeAAAA:
+				recordType = int(dns.TypeAAAA)
+			case records.RecordTypeCNAME:
+				recordType = int(dns.TypeCNAME)
+				rData = dns.Fqdn(record.Content)
+			default:
+				continue
+			}
+			simpleRecords = append(simpleRecords, nmdata.SimpleRecord{
+				Name:  dns.Fqdn(record.Name),
+				Type:  recordType,
+				Class: nbdns.DefaultClass,
+				TTL:   record.TTL,
+				RData: rData,
+			})
+		}
+		out = append(out, networkmap.AppliedZoneCandidate{
+			DistributionGroups: zone.DistributionGroups,
+			Zone: nmdata.CustomZone{
+				Domain:               dns.Fqdn(zone.Domain),
+				Records:              simpleRecords,
+				SearchDomainDisabled: !zone.EnableSearchDomain,
+				NonAuthoritative:     true,
+			},
+		})
+	}
+	return out
+}
+
+// buildPrivateServiceCandidates precomputes the connected-proxy A records per
+// private service (account-level); the per-peer access-group gate + apex merge
+// run in the components calc. Mirrors the account-level half of
+// SynthesizePrivateServiceZones.
+func (a *Account) buildPrivateServiceCandidates() []networkmap.PrivateServiceCandidate {
+	if len(a.Services) == 0 {
+		return nil
+	}
+	proxyPeersByCluster := a.GetProxyPeers()
+	if len(proxyPeersByCluster) == 0 {
+		return nil
+	}
+
+	var out []networkmap.PrivateServiceCandidate
+	for _, svc := range a.Services {
+		if svc == nil || !svc.Enabled || !svc.Private {
+			continue
+		}
+		if len(svc.AccessGroups) == 0 {
+			continue
+		}
+		proxyPeers := proxyPeersByCluster[svc.ProxyCluster]
+		if len(proxyPeers) == 0 {
+			continue
+		}
+		apex := a.privateServiceDomainZone(svc)
+		if apex == "" {
+			continue
+		}
+
+		var recs []nmdata.SimpleRecord
+		for _, p := range proxyPeers {
+			if p == nil || !p.IP.IsValid() {
+				continue
+			}
+			if p.Status == nil || !p.Status.Connected {
+				continue
+			}
+			recs = append(recs, nmdata.SimpleRecord{
+				Name:  dns.Fqdn(svc.Domain),
+				Type:  int(dns.TypeA),
+				Class: nbdns.DefaultClass,
+				TTL:   privateServiceDNSRecordTTL,
+				RData: p.IP.String(),
+			})
+		}
+		if len(recs) == 0 {
+			continue
+		}
+
+		out = append(out, networkmap.PrivateServiceCandidate{
+			AccessGroups: svc.AccessGroups,
+			Zone: nmdata.CustomZone{
+				Domain:               dns.Fqdn(apex),
+				Records:              recs,
+				NonAuthoritative:     true,
+				SearchDomainDisabled: true,
+			},
+		})
+	}
+	return out
+}
+
+// TwinAccountSettings converts real account settings to the slim nmdata twin.
+// Exported for callers of the twin-based sync response builders.
+func TwinAccountSettings(s *Settings) *nmdata.AccountSettingsInfo {
+	if s == nil {
+		return nil
+	}
+	return &nmdata.AccountSettingsInfo{
+		PeerLoginExpirationEnabled:      s.PeerLoginExpirationEnabled,
+		PeerLoginExpiration:             s.PeerLoginExpiration,
+		PeerInactivityExpirationEnabled: s.PeerInactivityExpirationEnabled,
+		PeerInactivityExpiration:        s.PeerInactivityExpiration,
+		DNSDomain:                       s.DNSDomain,
+		IPv6EnabledGroups:               s.IPv6EnabledGroups,
+		RoutingPeerDNSResolutionEnabled: s.RoutingPeerDNSResolutionEnabled,
+		LazyConnectionEnabled:           s.LazyConnectionEnabled,
+		AutoUpdateVersion:               s.AutoUpdateVersion,
+		AutoUpdateAlways:                s.AutoUpdateAlways,
+		MetricsPushEnabled:              s.MetricsPushEnabled,
+	}
+}
+
+func fromTwinCustomZone(z nmdata.CustomZone) nbdns.CustomZone {
+	records := make([]nbdns.SimpleRecord, 0, len(z.Records))
+	for _, r := range z.Records {
+		records = append(records, nbdns.SimpleRecord{
+			Name:  r.Name,
+			Type:  r.Type,
+			Class: r.Class,
+			TTL:   r.TTL,
+			RData: r.RData,
+		})
+	}
+	return nbdns.CustomZone{
+		Domain:               z.Domain,
+		Records:              records,
+		SearchDomainDisabled: z.SearchDomainDisabled,
+		NonAuthoritative:     z.NonAuthoritative,
+	}
+}
+
+// TwinCustomZone converts a real DNS custom zone to its slim nmdata twin.
+// Exported for the network-map controller's DB-store path, which feeds real
+// zones into the twin-based components calculation.
+func TwinCustomZone(z nbdns.CustomZone) nmdata.CustomZone {
+	records := make([]nmdata.SimpleRecord, 0, len(z.Records))
+	for _, r := range z.Records {
+		records = append(records, nmdata.SimpleRecord{
+			Name:  r.Name,
+			Type:  r.Type,
+			Class: r.Class,
+			TTL:   r.TTL,
+			RData: r.RData,
+		})
+	}
+	return nmdata.CustomZone{
+		Domain:               z.Domain,
+		Records:              records,
+		SearchDomainDisabled: z.SearchDomainDisabled,
+		NonAuthoritative:     z.NonAuthoritative,
+	}
+}
diff --git a/management/server/types/account_private_netmap_test.go b/management/server/types/account_private_netmap_test.go
index 11b3d985a..5dccfbf30 100644
--- a/management/server/types/account_private_netmap_test.go
+++ b/management/server/types/account_private_netmap_test.go
@@ -9,6 +9,7 @@ import (
 	"github.com/stretchr/testify/require"
 
 	nbdns "github.com/netbirdio/netbird/dns"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 )
 
 func TestPrivateService_NetworkMap_UserPeer_AndProxyPeer(t *testing.T) {
@@ -17,7 +18,6 @@ func TestPrivateService_NetworkMap_UserPeer_AndProxyPeer(t *testing.T) {
 	account.Peers["proxy-peer"].Meta.WtVersion = "0.50.0"
 
 	ctx := context.Background()
-	account.InjectProxyPolicies(ctx)
 
 	validated := map[string]struct{}{
 		"user-peer":  {},
@@ -48,7 +48,7 @@ func TestPrivateService_NetworkMap_UserPeer_AndProxyPeer(t *testing.T) {
 	})
 }
 
-func netmapPeerIDs(peers []*ComponentPeer) []string {
+func netmapPeerIDs(peers []*nmdata.Peer) []string {
 	ids := make([]string, 0, len(peers))
 	for _, p := range peers {
 		if p == nil {
diff --git a/management/server/types/account_test.go b/management/server/types/account_test.go
index 80f2a950a..063b2d7e7 100644
--- a/management/server/types/account_test.go
+++ b/management/server/types/account_test.go
@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"net"
 	"net/netip"
+	"strings"
 	"testing"
 
 	"github.com/miekg/dns"
@@ -13,13 +14,12 @@ import (
 
 	nbdns "github.com/netbirdio/netbird/dns"
 	"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
-	"github.com/netbirdio/netbird/management/internals/modules/zones"
-	"github.com/netbirdio/netbird/management/internals/modules/zones/records"
 	resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
 	routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
 	networkTypes "github.com/netbirdio/netbird/management/server/networks/types"
 	nbpeer "github.com/netbirdio/netbird/management/server/peer"
 	"github.com/netbirdio/netbird/route"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 )
 
 func setupTestAccount() *Account {
@@ -666,7 +666,7 @@ func Test_ExpandPortsAndRanges_SSHRuleExpansion(t *testing.T) {
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			result := ExpandPortsAndRanges(tt.base, tt.rule, tt.peer.ToComponent())
+			result := ExpandPortsAndRanges(tt.base, tt.rule, tt.peer)
 
 			var ports []string
 			for _, fr := range result {
@@ -1040,518 +1040,6 @@ func Test_FilterZoneRecordsForPeers(t *testing.T) {
 	}
 }
 
-func Test_filterPeerAppliedZones(t *testing.T) {
-	ctx := context.Background()
-
-	tests := []struct {
-		name         string
-		accountZones []*zones.Zone
-		peerGroups   LookupMap
-		expected     []nbdns.CustomZone
-	}{
-		{
-			name:         "empty peer groups returns empty custom zones",
-			accountZones: []*zones.Zone{},
-			peerGroups:   LookupMap{},
-			expected:     []nbdns.CustomZone{},
-		},
-		{
-			name: "peer has access to zone with A record",
-			accountZones: []*zones.Zone{
-				{
-					ID:                 "zone1",
-					Domain:             "example.com",
-					Enabled:            true,
-					EnableSearchDomain: false,
-					DistributionGroups: []string{"group1"},
-					Records: []*records.Record{
-						{
-							ID:      "record1",
-							Name:    "www.example.com",
-							Type:    records.RecordTypeA,
-							Content: "192.168.1.1",
-							TTL:     300,
-						},
-					},
-				},
-			},
-			peerGroups: LookupMap{"group1": struct{}{}},
-			expected: []nbdns.CustomZone{
-				{
-					Domain: "example.com.",
-					Records: []nbdns.SimpleRecord{
-						{
-							Name:  "www.example.com.",
-							Type:  int(dns.TypeA),
-							Class: nbdns.DefaultClass,
-							TTL:   300,
-							RData: "192.168.1.1",
-						},
-					},
-					SearchDomainDisabled: true,
-				},
-			},
-		},
-		{
-			name: "peer has access to zone with search domain enabled",
-			accountZones: []*zones.Zone{
-				{
-					ID:                 "zone1",
-					Domain:             "internal.local",
-					Enabled:            true,
-					EnableSearchDomain: true,
-					DistributionGroups: []string{"group1"},
-					Records: []*records.Record{
-						{
-							ID:      "record1",
-							Name:    "api.internal.local",
-							Type:    records.RecordTypeA,
-							Content: "10.0.0.1",
-							TTL:     600,
-						},
-					},
-				},
-			},
-			peerGroups: LookupMap{"group1": struct{}{}},
-			expected: []nbdns.CustomZone{
-				{
-					Domain: "internal.local.",
-					Records: []nbdns.SimpleRecord{
-						{
-							Name:  "api.internal.local.",
-							Type:  int(dns.TypeA),
-							Class: nbdns.DefaultClass,
-							TTL:   600,
-							RData: "10.0.0.1",
-						},
-					},
-					SearchDomainDisabled: false,
-				},
-			},
-		},
-		{
-			name: "peer has no access to zone",
-			accountZones: []*zones.Zone{
-				{
-					ID:                 "zone1",
-					Domain:             "private.com",
-					Enabled:            true,
-					EnableSearchDomain: false,
-					DistributionGroups: []string{"group2"},
-					Records: []*records.Record{
-						{
-							ID:      "record1",
-							Name:    "secret.private.com",
-							Type:    records.RecordTypeA,
-							Content: "192.168.1.1",
-							TTL:     300,
-						},
-					},
-				},
-			},
-			peerGroups: LookupMap{"group1": struct{}{}},
-			expected:   []nbdns.CustomZone{},
-		},
-		{
-			name: "disabled zone is filtered out",
-			accountZones: []*zones.Zone{
-				{
-					ID:                 "zone1",
-					Domain:             "disabled.com",
-					Enabled:            false,
-					EnableSearchDomain: false,
-					DistributionGroups: []string{"group1"},
-					Records: []*records.Record{
-						{
-							ID:      "record1",
-							Name:    "www.disabled.com",
-							Type:    records.RecordTypeA,
-							Content: "192.168.1.1",
-							TTL:     300,
-						},
-					},
-				},
-			},
-			peerGroups: LookupMap{"group1": struct{}{}},
-			expected:   []nbdns.CustomZone{},
-		},
-		{
-			name: "zone with no records is filtered out",
-			accountZones: []*zones.Zone{
-				{
-					ID:                 "zone1",
-					Domain:             "empty.com",
-					Enabled:            true,
-					EnableSearchDomain: false,
-					DistributionGroups: []string{"group1"},
-					Records:            []*records.Record{},
-				},
-			},
-			peerGroups: LookupMap{"group1": struct{}{}},
-			expected:   []nbdns.CustomZone{},
-		},
-		{
-			name: "peer has access via multiple groups",
-			accountZones: []*zones.Zone{
-				{
-					ID:                 "zone1",
-					Domain:             "multi.com",
-					Enabled:            true,
-					EnableSearchDomain: false,
-					DistributionGroups: []string{"group1", "group2", "group3"},
-					Records: []*records.Record{
-						{
-							ID:      "record1",
-							Name:    "www.multi.com",
-							Type:    records.RecordTypeA,
-							Content: "192.168.1.1",
-							TTL:     300,
-						},
-					},
-				},
-			},
-			peerGroups: LookupMap{"group2": struct{}{}},
-			expected: []nbdns.CustomZone{
-				{
-					Domain: "multi.com.",
-					Records: []nbdns.SimpleRecord{
-						{
-							Name:  "www.multi.com.",
-							Type:  int(dns.TypeA),
-							Class: nbdns.DefaultClass,
-							TTL:   300,
-							RData: "192.168.1.1",
-						},
-					},
-					SearchDomainDisabled: true,
-				},
-			},
-		},
-		{
-			name: "multiple zones with mixed access",
-			accountZones: []*zones.Zone{
-				{
-					ID:                 "zone1",
-					Domain:             "allowed.com",
-					Enabled:            true,
-					EnableSearchDomain: false,
-					DistributionGroups: []string{"group1"},
-					Records: []*records.Record{
-						{
-							ID:      "record1",
-							Name:    "www.allowed.com",
-							Type:    records.RecordTypeA,
-							Content: "192.168.1.1",
-							TTL:     300,
-						},
-					},
-				},
-				{
-					ID:                 "zone2",
-					Domain:             "denied.com",
-					Enabled:            true,
-					EnableSearchDomain: false,
-					DistributionGroups: []string{"group2"},
-					Records: []*records.Record{
-						{
-							ID:      "record2",
-							Name:    "www.denied.com",
-							Type:    records.RecordTypeA,
-							Content: "192.168.1.2",
-							TTL:     300,
-						},
-					},
-				},
-			},
-			peerGroups: LookupMap{"group1": struct{}{}},
-			expected: []nbdns.CustomZone{
-				{
-					Domain: "allowed.com.",
-					Records: []nbdns.SimpleRecord{
-						{
-							Name:  "www.allowed.com.",
-							Type:  int(dns.TypeA),
-							Class: nbdns.DefaultClass,
-							TTL:   300,
-							RData: "192.168.1.1",
-						},
-					},
-					SearchDomainDisabled: true,
-				},
-			},
-		},
-		{
-			name: "zone with multiple record types",
-			accountZones: []*zones.Zone{
-				{
-					ID:                 "zone1",
-					Domain:             "mixed.com",
-					Enabled:            true,
-					EnableSearchDomain: false,
-					DistributionGroups: []string{"group1"},
-					Records: []*records.Record{
-						{
-							ID:      "record1",
-							Name:    "www.mixed.com",
-							Type:    records.RecordTypeA,
-							Content: "192.168.1.1",
-							TTL:     300,
-						},
-						{
-							ID:      "record2",
-							Name:    "ipv6.mixed.com",
-							Type:    records.RecordTypeAAAA,
-							Content: "2001:db8::1",
-							TTL:     600,
-						},
-						{
-							ID:      "record3",
-							Name:    "alias.mixed.com",
-							Type:    records.RecordTypeCNAME,
-							Content: "www.mixed.com",
-							TTL:     900,
-						},
-					},
-				},
-			},
-			peerGroups: LookupMap{"group1": struct{}{}},
-			expected: []nbdns.CustomZone{
-				{
-					Domain: "mixed.com.",
-					Records: []nbdns.SimpleRecord{
-						{
-							Name:  "www.mixed.com.",
-							Type:  int(dns.TypeA),
-							Class: nbdns.DefaultClass,
-							TTL:   300,
-							RData: "192.168.1.1",
-						},
-						{
-							Name:  "ipv6.mixed.com.",
-							Type:  int(dns.TypeAAAA),
-							Class: nbdns.DefaultClass,
-							TTL:   600,
-							RData: "2001:db8::1",
-						},
-						{
-							Name:  "alias.mixed.com.",
-							Type:  int(dns.TypeCNAME),
-							Class: nbdns.DefaultClass,
-							TTL:   900,
-							RData: "www.mixed.com.",
-						},
-					},
-					SearchDomainDisabled: true,
-				},
-			},
-		},
-		{
-			name: "multiple zones both accessible",
-			accountZones: []*zones.Zone{
-				{
-					ID:                 "zone1",
-					Domain:             "first.com",
-					Enabled:            true,
-					EnableSearchDomain: true,
-					DistributionGroups: []string{"group1"},
-					Records: []*records.Record{
-						{
-							ID:      "record1",
-							Name:    "www.first.com",
-							Type:    records.RecordTypeA,
-							Content: "192.168.1.1",
-							TTL:     300,
-						},
-					},
-				},
-				{
-					ID:                 "zone2",
-					Domain:             "second.com",
-					Enabled:            true,
-					EnableSearchDomain: false,
-					DistributionGroups: []string{"group1"},
-					Records: []*records.Record{
-						{
-							ID:      "record2",
-							Name:    "www.second.com",
-							Type:    records.RecordTypeA,
-							Content: "192.168.1.2",
-							TTL:     600,
-						},
-					},
-				},
-			},
-			peerGroups: LookupMap{"group1": struct{}{}},
-			expected: []nbdns.CustomZone{
-				{
-					Domain: "first.com.",
-					Records: []nbdns.SimpleRecord{
-						{
-							Name:  "www.first.com.",
-							Type:  int(dns.TypeA),
-							Class: nbdns.DefaultClass,
-							TTL:   300,
-							RData: "192.168.1.1",
-						},
-					},
-					SearchDomainDisabled: false,
-				},
-				{
-					Domain: "second.com.",
-					Records: []nbdns.SimpleRecord{
-						{
-							Name:  "www.second.com.",
-							Type:  int(dns.TypeA),
-							Class: nbdns.DefaultClass,
-							TTL:   600,
-							RData: "192.168.1.2",
-						},
-					},
-					SearchDomainDisabled: true,
-				},
-			},
-		},
-		{
-			name: "zone with multiple records of same type",
-			accountZones: []*zones.Zone{
-				{
-					ID:                 "zone1",
-					Domain:             "multi-a.com",
-					Enabled:            true,
-					EnableSearchDomain: false,
-					DistributionGroups: []string{"group1"},
-					Records: []*records.Record{
-						{
-							ID:      "record1",
-							Name:    "www.multi-a.com",
-							Type:    records.RecordTypeA,
-							Content: "192.168.1.1",
-							TTL:     300,
-						},
-						{
-							ID:      "record2",
-							Name:    "www.multi-a.com",
-							Type:    records.RecordTypeA,
-							Content: "192.168.1.2",
-							TTL:     300,
-						},
-					},
-				},
-			},
-			peerGroups: LookupMap{"group1": struct{}{}},
-			expected: []nbdns.CustomZone{
-				{
-					Domain: "multi-a.com.",
-					Records: []nbdns.SimpleRecord{
-						{
-							Name:  "www.multi-a.com.",
-							Type:  int(dns.TypeA),
-							Class: nbdns.DefaultClass,
-							TTL:   300,
-							RData: "192.168.1.1",
-						},
-						{
-							Name:  "www.multi-a.com.",
-							Type:  int(dns.TypeA),
-							Class: nbdns.DefaultClass,
-							TTL:   300,
-							RData: "192.168.1.2",
-						},
-					},
-					SearchDomainDisabled: true,
-				},
-			},
-		},
-		{
-			name: "peer in multiple groups accessing different zones",
-			accountZones: []*zones.Zone{
-				{
-					ID:                 "zone1",
-					Domain:             "zone1.com",
-					Enabled:            true,
-					EnableSearchDomain: false,
-					DistributionGroups: []string{"group1"},
-					Records: []*records.Record{
-						{
-							ID:      "record1",
-							Name:    "www.zone1.com",
-							Type:    records.RecordTypeA,
-							Content: "192.168.1.1",
-							TTL:     300,
-						},
-					},
-				},
-				{
-					ID:                 "zone2",
-					Domain:             "zone2.com",
-					Enabled:            true,
-					EnableSearchDomain: false,
-					DistributionGroups: []string{"group2"},
-					Records: []*records.Record{
-						{
-							ID:      "record2",
-							Name:    "www.zone2.com",
-							Type:    records.RecordTypeA,
-							Content: "192.168.1.2",
-							TTL:     300,
-						},
-					},
-				},
-			},
-			peerGroups: LookupMap{"group1": struct{}{}, "group2": struct{}{}},
-			expected: []nbdns.CustomZone{
-				{
-					Domain: "zone1.com.",
-					Records: []nbdns.SimpleRecord{
-						{
-							Name:  "www.zone1.com.",
-							Type:  int(dns.TypeA),
-							Class: nbdns.DefaultClass,
-							TTL:   300,
-							RData: "192.168.1.1",
-						},
-					},
-					SearchDomainDisabled: true,
-				},
-				{
-					Domain: "zone2.com.",
-					Records: []nbdns.SimpleRecord{
-						{
-							Name:  "www.zone2.com.",
-							Type:  int(dns.TypeA),
-							Class: nbdns.DefaultClass,
-							TTL:   300,
-							RData: "192.168.1.2",
-						},
-					},
-					SearchDomainDisabled: true,
-				},
-			},
-		},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			result := filterPeerAppliedZones(ctx, tt.accountZones, tt.peerGroups)
-			require.Equal(t, len(tt.expected), len(result), "number of custom zones should match")
-
-			for i, expectedZone := range tt.expected {
-				assert.Equal(t, expectedZone.Domain, result[i].Domain, "domain should match")
-				assert.Equal(t, expectedZone.SearchDomainDisabled, result[i].SearchDomainDisabled, "search domain disabled flag should match")
-				assert.Equal(t, len(expectedZone.Records), len(result[i].Records), "number of records should match")
-
-				for j, expectedRecord := range expectedZone.Records {
-					assert.Equal(t, expectedRecord.Name, result[i].Records[j].Name, "record name should match")
-					assert.Equal(t, expectedRecord.Type, result[i].Records[j].Type, "record type should match")
-					assert.Equal(t, expectedRecord.Class, result[i].Records[j].Class, "record class should match")
-					assert.Equal(t, expectedRecord.TTL, result[i].Records[j].TTL, "record TTL should match")
-					assert.Equal(t, expectedRecord.RData, result[i].Records[j].RData, "record RData should match")
-				}
-			}
-		})
-	}
-}
-
 func TestInjectPrivateServicePolicies_ProxyPeerGetsInboundRule(t *testing.T) {
 	ctx := context.Background()
 
@@ -1564,6 +1052,7 @@ func TestInjectPrivateServicePolicies_ProxyPeerGetsInboundRule(t *testing.T) {
 			Identifier: "net-1",
 			Net:        net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.CIDRMask(10, 32)},
 		},
+		Settings: &Settings{},
 		Peers: map[string]*nbpeer.Peer{
 			"user-peer": {
 				ID:        "user-peer",
@@ -1614,41 +1103,25 @@ func TestInjectPrivateServicePolicies_ProxyPeerGetsInboundRule(t *testing.T) {
 		},
 	}
 
-	account.InjectProxyPolicies(ctx)
-
-	var found *Policy
-	for _, p := range account.Policies {
-		if p != nil && p.ID == "private-access-svc-1-proxy-peer" {
-			found = p
-			break
-		}
-	}
-	require.NotNil(t, found, "expected synthesised private-access policy in account.Policies")
+	found := findPolicy(injectedPolicies(account), "private-access-svc-1-proxy-peer")
+	require.NotNil(t, found, "expected synthesised private-access policy in the twin store")
 	require.Len(t, found.Rules, 1, "policy should have exactly one rule")
 	rule := found.Rules[0]
 	assert.Equal(t, []string{"grp-admins"}, rule.Sources, "sources should be group IDs verbatim")
 	assert.Equal(t, "proxy-peer", rule.DestinationResource.ID, "destination resource should be the proxy peer ID")
-	assert.Equal(t, ResourceTypePeer, rule.DestinationResource.Type, "destination resource type should be peer")
+	assert.Equal(t, string(ResourceTypePeer), rule.DestinationResource.Type, "destination resource type should be peer")
 
 	validatedPeersMap := map[string]struct{}{
 		"user-peer":  {},
 		"proxy-peer": {},
 	}
 
-	proxyPeer := account.Peers["proxy-peer"]
-	aclPeers, firewallRules, _, _ := account.GetPeerConnectionResources(ctx, proxyPeer, validatedPeersMap, nil)
+	nm := account.GetPeerNetworkMapFromComponents(ctx, "proxy-peer", nbdns.CustomZone{}, nil, validatedPeersMap, nil, nil, nil, nil)
 
-	var sawUserAsAclPeer bool
-	for _, p := range aclPeers {
-		if p.ID == "user-peer" {
-			sawUserAsAclPeer = true
-			break
-		}
-	}
-	assert.True(t, sawUserAsAclPeer, "proxy peer should see the user peer as an ACL peer")
+	assert.Contains(t, netmapPeerIDs(nm.Peers), "user-peer", "proxy peer should see the user peer as an ACL peer")
 
 	var inboundRules []*FirewallRule
-	for _, r := range firewallRules {
+	for _, r := range nm.FirewallRules {
 		if r.Direction == FirewallRuleDirectionIN && r.PeerIP == userPeerIP.String() {
 			inboundRules = append(inboundRules, r)
 		}
@@ -1657,29 +1130,23 @@ func TestInjectPrivateServicePolicies_ProxyPeerGetsInboundRule(t *testing.T) {
 }
 
 func TestInjectPrivateServicePolicies_NotPrivate_NoPolicy(t *testing.T) {
-	ctx := context.Background()
 	account := privateServiceTestAccount(t)
 	account.Services[0].Private = false
 
-	account.InjectProxyPolicies(ctx)
 	assert.False(t, hasPrivateAccessPolicy(account, "svc-1"), "non-private service must not synthesise an access policy")
 }
 
 func TestInjectPrivateServicePolicies_EmptyAccessGroups_NoPolicy(t *testing.T) {
-	ctx := context.Background()
 	account := privateServiceTestAccount(t)
 	account.Services[0].AccessGroups = nil
 
-	account.InjectProxyPolicies(ctx)
 	assert.False(t, hasPrivateAccessPolicy(account, "svc-1"), "private service with no access groups must not synthesise a policy")
 }
 
 func TestInjectPrivateServicePolicies_NoProxyPeers_NoPolicy(t *testing.T) {
-	ctx := context.Background()
 	account := privateServiceTestAccount(t)
 	delete(account.Peers, "proxy-peer")
 
-	account.InjectProxyPolicies(ctx)
 	assert.False(t, hasPrivateAccessPolicy(account, "svc-1"), "policy must not synthesise when the cluster has no proxy peers")
 }
 
@@ -1742,10 +1209,27 @@ func privateServiceTestAccount(t *testing.T) *Account {
 	}
 }
 
+// injectedPolicies returns the twin's policies with the synthesised proxy ACLs
+// already in place, the way the per-peer computation sees them.
+func injectedPolicies(account *Account) []*nmdata.Policy {
+	nmd := account.toNetworkMapData(nil, nil, nil, nil, nil)
+	nmd.InjectProxyPolicies()
+	return nmd.Policies
+}
+
+func findPolicy(policies []*nmdata.Policy, id string) *nmdata.Policy {
+	for _, p := range policies {
+		if p != nil && p.ID == id {
+			return p
+		}
+	}
+	return nil
+}
+
 func hasPrivateAccessPolicy(account *Account, serviceID string) bool {
 	prefix := "private-access-" + serviceID + "-"
-	for _, p := range account.Policies {
-		if p != nil && len(p.ID) > len(prefix) && p.ID[:len(prefix)] == prefix {
+	for _, p := range injectedPolicies(account) {
+		if p != nil && strings.HasPrefix(p.ID, prefix) {
 			return true
 		}
 	}
@@ -1781,41 +1265,45 @@ func TestForcesRoutingPeerDNSResolution(t *testing.T) {
 		return buildAccountRes(serviceEnabled, targetEnabled, resourceEnabled, targetType, resourceTypes.Domain)
 	}
 
+	forced := func(account *Account, peerID string) bool {
+		nmd := account.toNetworkMapData(nil, nil, nil, account.GetResourceRoutersMap(), nil)
+		return nmd.GetPeerNetworkMapComponents(peerID, nmdata.CustomZone{}).ForceRoutingPeerDNSResolution
+	}
+
 	t.Run("router peer for RP-targeted domain resource is forced", func(t *testing.T) {
 		account := buildAccount(true, true, true, service.TargetTypeDomain)
-		routers := account.GetResourceRoutersMap()
-		assert.True(t, account.forcesRoutingPeerDNSResolution("router-peer", routers), "direct router peer should be forced")
-		assert.True(t, account.forcesRoutingPeerDNSResolution("router-peer-grp", routers), "group-member router peer should be forced")
+		assert.True(t, forced(account, "router-peer"), "direct router peer should be forced")
+		assert.True(t, forced(account, "router-peer-grp"), "group-member router peer should be forced")
 	})
 
 	t.Run("non-router peer is not forced", func(t *testing.T) {
 		account := buildAccount(true, true, true, service.TargetTypeDomain)
-		assert.False(t, account.forcesRoutingPeerDNSResolution("other-peer", account.GetResourceRoutersMap()))
+		assert.False(t, forced(account, "other-peer"))
 	})
 
 	t.Run("not forced when service disabled", func(t *testing.T) {
 		account := buildAccount(false, true, true, service.TargetTypeDomain)
-		assert.False(t, account.forcesRoutingPeerDNSResolution("router-peer", account.GetResourceRoutersMap()))
+		assert.False(t, forced(account, "router-peer"))
 	})
 
 	t.Run("not forced when target disabled", func(t *testing.T) {
 		account := buildAccount(true, false, true, service.TargetTypeDomain)
-		assert.False(t, account.forcesRoutingPeerDNSResolution("router-peer", account.GetResourceRoutersMap()))
+		assert.False(t, forced(account, "router-peer"))
 	})
 
 	t.Run("not forced when resource disabled", func(t *testing.T) {
 		account := buildAccount(true, true, false, service.TargetTypeDomain)
-		assert.False(t, account.forcesRoutingPeerDNSResolution("router-peer", account.GetResourceRoutersMap()))
+		assert.False(t, forced(account, "router-peer"))
 	})
 
 	t.Run("not forced for non-domain target type", func(t *testing.T) {
 		account := buildAccount(true, true, true, service.TargetTypePeer)
-		assert.False(t, account.forcesRoutingPeerDNSResolution("router-peer", account.GetResourceRoutersMap()))
+		assert.False(t, forced(account, "router-peer"))
 	})
 
 	t.Run("not forced when targeted resource is not a domain", func(t *testing.T) {
 		account := buildAccountRes(true, true, true, service.TargetTypeDomain, resourceTypes.Host)
-		assert.False(t, account.forcesRoutingPeerDNSResolution("router-peer", account.GetResourceRoutersMap()),
+		assert.False(t, forced(account, "router-peer"),
 			"a domain target pointing at a non-domain resource must not force resolution")
 	})
 }
diff --git a/management/server/types/aliases.go b/management/server/types/aliases.go
index 9324cfa1e..452a2746d 100644
--- a/management/server/types/aliases.go
+++ b/management/server/types/aliases.go
@@ -2,54 +2,31 @@ package types
 
 import (
 	"context"
-	"math/rand"
-	"net"
-	"net/netip"
 
+	nbpeer "github.com/netbirdio/netbird/management/server/peer"
 	nbroute "github.com/netbirdio/netbird/route"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	sharedtypes "github.com/netbirdio/netbird/shared/management/types"
 )
 
 // Type aliases for types relocated to shared/management/types so that the
 // client-side compute path can depend on them
 
-type DNSSettings = sharedtypes.DNSSettings
-
 type FirewallRule = sharedtypes.FirewallRule
 
-type Network = sharedtypes.Network
 type NetworkMap = sharedtypes.NetworkMap
 type ForwardingRule = sharedtypes.ForwardingRule
 
-type Policy = sharedtypes.Policy
-type PolicyUpdateOperation = sharedtypes.PolicyUpdateOperation
-
-type PolicyRule = sharedtypes.PolicyRule
-type PolicyUpdateOperationType = sharedtypes.PolicyUpdateOperationType
 type PolicyTrafficActionType = sharedtypes.PolicyTrafficActionType
 type PolicyRuleProtocolType = sharedtypes.PolicyRuleProtocolType
-type PolicyRuleDirection = sharedtypes.PolicyRuleDirection
 type RulePortRange = sharedtypes.RulePortRange
 
-type Resource = sharedtypes.Resource
 type ResourceType = sharedtypes.ResourceType
 
 type RouteFirewallRule = sharedtypes.RouteFirewallRule
 
 type NetworkMapComponents = sharedtypes.NetworkMapComponents
 
-type ComponentPeer = sharedtypes.ComponentPeer
-type ComponentGroup = sharedtypes.ComponentGroup
-type ComponentRouter = sharedtypes.ComponentRouter
-type ComponentResource = sharedtypes.ComponentResource
-type ComponentResourceType = sharedtypes.ComponentResourceType
-
-const (
-	ComponentResourceHost   = sharedtypes.ComponentResourceHost
-	ComponentResourceSubnet = sharedtypes.ComponentResourceSubnet
-	ComponentResourceDomain = sharedtypes.ComponentResourceDomain
-)
-
 var EmptyNetworkMapComponents = sharedtypes.EmptyNetworkMapComponents
 
 type AccountSettingsInfo = sharedtypes.AccountSettingsInfo
@@ -60,54 +37,36 @@ type NetworkMapComponentsCompact = sharedtypes.NetworkMapComponentsCompact
 type LookupMap = sharedtypes.LookupMap
 type FirewallRuleContext = sharedtypes.FirewallRuleContext
 
-const GroupAllName = sharedtypes.GroupAllName
-
 // Function forwarders preserve types.X(...) call sites that previously
 // resolved to package-local funcs. Plain forwarders (not var aliases) keep
 // the symbol immutable and allow the inliner to flatten the call.
 
+func ParseRuleString(rule string) (PolicyRuleProtocolType, RulePortRange, error) {
+	return sharedtypes.ParseRuleString(rule)
+}
+
 func PolicyRuleImpliesLegacySSH(rule *PolicyRule) bool {
-	return sharedtypes.PolicyRuleImpliesLegacySSH(rule)
+	return nmdata.PolicyRuleImpliesLegacySSH(twinRule(rule))
 }
 
-func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *ComponentPeer) []*FirewallRule {
-	return sharedtypes.ExpandPortsAndRanges(base, rule, peer)
+// ExpandPortsAndRanges / AppendIPv6FirewallRule / GenerateRouteFirewallRules
+// forward to the shared twin-typed helpers, converting the real types the
+// legacy Account calc still uses to nmdata twins at this boundary.
+
+func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *nbpeer.Peer) []*FirewallRule {
+	return sharedtypes.ExpandPortsAndRanges(base, twinRule(rule), twinPeer(peer))
 }
 
-func AppendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *ComponentPeer, rule *PolicyRule, rc FirewallRuleContext) []*FirewallRule {
-	return sharedtypes.AppendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, rule, rc)
+func AppendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *nbpeer.Peer, rule *PolicyRule, rc FirewallRuleContext) []*FirewallRule {
+	return sharedtypes.AppendIPv6FirewallRule(rules, rulesExists, twinPeer(peer), twinPeer(targetPeer), twinRule(rule), rc)
 }
 
 func CalculateNetworkMapFromComponents(ctx context.Context, components *NetworkMapComponents) *NetworkMap {
 	return sharedtypes.CalculateNetworkMapFromComponents(ctx, components)
 }
 
-func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*ComponentPeer, direction int, includeIPv6 bool) []*RouteFirewallRule {
-	return sharedtypes.GenerateRouteFirewallRules(ctx, route, rule, groupPeers, direction, includeIPv6)
-}
-
-func AllocateIPv6Subnet(r *rand.Rand) net.IPNet {
-	return sharedtypes.AllocateIPv6Subnet(r)
-}
-
-func NewNetwork() *Network {
-	return sharedtypes.NewNetwork()
-}
-
-func AllocatePeerIP(prefix netip.Prefix, takenIps []netip.Addr) (netip.Addr, error) {
-	return sharedtypes.AllocatePeerIP(prefix, takenIps)
-}
-
-func AllocateRandomPeerIP(prefix netip.Prefix) (netip.Addr, error) {
-	return sharedtypes.AllocateRandomPeerIP(prefix)
-}
-
-func AllocateRandomPeerIPv6(prefix netip.Prefix) (netip.Addr, error) {
-	return sharedtypes.AllocateRandomPeerIPv6(prefix)
-}
-
-func ParseRuleString(rule string) (PolicyRuleProtocolType, RulePortRange, error) {
-	return sharedtypes.ParseRuleString(rule)
+func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int, includeIPv6 bool) []*RouteFirewallRule {
+	return sharedtypes.GenerateRouteFirewallRules(ctx, twinRoute(route), twinRule(rule), TwinPeers(groupPeers), direction, includeIPv6)
 }
 
 const (
@@ -115,6 +74,11 @@ const (
 	FirewallRuleDirectionOUT = sharedtypes.FirewallRuleDirectionOUT
 )
 
+const (
+	AllowedIPsFormat   = sharedtypes.AllowedIPsFormat
+	AllowedIPsV6Format = sharedtypes.AllowedIPsV6Format
+)
+
 const (
 	ResourceTypePeer   = sharedtypes.ResourceTypePeer
 	ResourceTypeDomain = sharedtypes.ResourceTypeDomain
@@ -134,15 +98,3 @@ const (
 	PolicyRuleProtocolICMP       = sharedtypes.PolicyRuleProtocolICMP
 	PolicyRuleProtocolNetbirdSSH = sharedtypes.PolicyRuleProtocolNetbirdSSH
 )
-
-const (
-	PolicyRuleFlowDirect   = sharedtypes.PolicyRuleFlowDirect
-	PolicyRuleFlowBidirect = sharedtypes.PolicyRuleFlowBidirect
-)
-
-const (
-	DefaultRuleName          = sharedtypes.DefaultRuleName
-	DefaultRuleDescription   = sharedtypes.DefaultRuleDescription
-	DefaultPolicyName        = sharedtypes.DefaultPolicyName
-	DefaultPolicyDescription = sharedtypes.DefaultPolicyDescription
-)
diff --git a/shared/management/types/dns_settings.go b/management/server/types/dns_settings.go
similarity index 100%
rename from shared/management/types/dns_settings.go
rename to management/server/types/dns_settings.go
diff --git a/management/server/types/group.go b/management/server/types/group.go
index a5e196997..ac0a2a7f2 100644
--- a/management/server/types/group.go
+++ b/management/server/types/group.go
@@ -1,7 +1,8 @@
 package types
 
 import (
-	"github.com/netbirdio/netbird/management/server/integration_reference"
+	"github.com/netbirdio/netbird/shared/management/integration_reference"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 )
 
 const (
@@ -67,6 +68,10 @@ func (g *Group) EventMeta() map[string]any {
 	return map[string]any{"name": g.Name}
 }
 
+func (g *Group) EventMetaResource(resource *nmdata.NetworkResource) map[string]any {
+	return map[string]any{"name": g.Name, "id": g.ID, "resource_name": resource.Name, "resource_id": resource.ID, "resource_type": resource.Type}
+}
+
 func (g *Group) Copy() *Group {
 	group := &Group{
 		ID:                   g.ID,
@@ -90,39 +95,14 @@ func (g *Group) HasPeers() bool {
 	return len(g.Peers) > 0
 }
 
+// GroupAllName is the reserved name of the default group that contains every peer in an account.
+const GroupAllName = "All"
+
 // IsGroupAll checks if the group is a default "All" group.
 func (g *Group) IsGroupAll() bool {
 	return g.Name == GroupAllName
 }
 
-// ToComponent converts the group to its self-contained components
-// representation. The Peers slice is shared, not copied — components are
-// treated as immutable snapshots. Returns nil for a nil group.
-func (g *Group) ToComponent() *ComponentGroup {
-	if g == nil {
-		return nil
-	}
-	return &ComponentGroup{
-		ID:       g.ID,
-		PublicID: g.PublicID,
-		Name:     g.Name,
-		Peers:    g.Peers,
-	}
-}
-
-// GroupsToComponent converts an id-keyed group map to its components
-// representation, preserving nil entries.
-func GroupsToComponent(groups map[string]*Group) map[string]*ComponentGroup {
-	if groups == nil {
-		return nil
-	}
-	out := make(map[string]*ComponentGroup, len(groups))
-	for id, g := range groups {
-		out[id] = g.ToComponent()
-	}
-	return out
-}
-
 // AddPeer adds peerID to Peers if not present, returning true if added.
 func (g *Group) AddPeer(peerID string) bool {
 	if peerID == "" {
diff --git a/management/server/types/ipv6_endtoend_test.go b/management/server/types/ipv6_endtoend_test.go
index d83603abe..76c61369e 100644
--- a/management/server/types/ipv6_endtoend_test.go
+++ b/management/server/types/ipv6_endtoend_test.go
@@ -9,7 +9,7 @@ import (
 	"github.com/stretchr/testify/require"
 
 	nbpeer "github.com/netbirdio/netbird/management/server/peer"
-	"github.com/netbirdio/netbird/management/server/types"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 )
 
 func TestNetworkMapComponents_IPv6EndToEnd(t *testing.T) {
@@ -105,7 +105,7 @@ func TestNetworkMapComponents_RemotePeerWithoutCapability(t *testing.T) {
 	require.NotNil(t, nm)
 
 	t.Run("AllowedIPs include remote v6", func(t *testing.T) {
-		var dst *types.ComponentPeer
+		var dst *nmdata.Peer
 		for _, p := range nm.Peers {
 			if p.ID == "peer-dst-1" {
 				dst = p
diff --git a/management/server/types/legacynmap/account_components.go b/management/server/types/legacynmap/account_components.go
new file mode 100644
index 000000000..5d5b4a9cf
--- /dev/null
+++ b/management/server/types/legacynmap/account_components.go
@@ -0,0 +1,701 @@
+package legacynmap
+
+import (
+	"context"
+	"slices"
+	"time"
+
+	log "github.com/sirupsen/logrus"
+
+	nbdns "github.com/netbirdio/netbird/dns"
+	"github.com/netbirdio/netbird/management/internals/modules/zones"
+	routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
+	"github.com/netbirdio/netbird/management/server/telemetry"
+	"github.com/netbirdio/netbird/route"
+)
+
+// GetPeerNetworkMapResult dispatches to either the legacy-NetworkMap path or
+// the components path based on the peer's capability and the kill switch.
+// Capable peers (PeerCapabilityComponentNetworkMap) get the raw components
+// shape — the server skips Calculate() entirely for them, saving CPU
+// proportional to the number of capable peers in the account. Legacy peers
+// (or any peer when componentsDisabled is true) get the fully-expanded
+// NetworkMap as before.
+
+func GetPeerNetworkMapFromComponents(a *Account,
+	ctx context.Context,
+	peerID string,
+	peersCustomZone nbdns.CustomZone,
+	accountZones []*zones.Zone,
+	validatedPeersMap map[string]struct{},
+	resourcePolicies map[string][]*Policy,
+	routers map[string]map[string]*routerTypes.NetworkRouter,
+	metrics *telemetry.AccountManagerMetrics,
+	groupIDToUserIDs map[string][]string,
+) *NetworkMap {
+	start := time.Now()
+
+	components := GetPeerNetworkMapComponents(a,
+		ctx,
+		peerID,
+		peersCustomZone,
+		accountZones,
+		validatedPeersMap,
+		resourcePolicies,
+		routers,
+		groupIDToUserIDs,
+	)
+
+	if components.IsEmpty() {
+		return &NetworkMap{Network: components.Network}
+	}
+
+	nm := CalculateNetworkMapFromComponents(ctx, components)
+
+	if metrics != nil {
+		objectCount := int64(len(nm.Peers) + len(nm.OfflinePeers) + len(nm.Routes) + len(nm.FirewallRules) + len(nm.RoutesFirewallRules))
+		metrics.CountNetworkMapObjects(objectCount)
+		metrics.CountGetPeerNetworkMapDuration(time.Since(start))
+
+		if objectCount > 5000 {
+			log.WithContext(ctx).Tracef("account: %s has a total resource count of %d objects from components, "+
+				"peers: %d, offline peers: %d, routes: %d, firewall rules: %d, route firewall rules: %d",
+				a.Id, objectCount, len(nm.Peers), len(nm.OfflinePeers), len(nm.Routes), len(nm.FirewallRules), len(nm.RoutesFirewallRules))
+		}
+	}
+
+	return nm
+}
+
+func GetPeerNetworkMapComponents(a *Account,
+	ctx context.Context,
+	peerID string,
+	peersCustomZone nbdns.CustomZone,
+	accountZones []*zones.Zone,
+	validatedPeersMap map[string]struct{},
+	resourcePolicies map[string][]*Policy,
+	routers map[string]map[string]*routerTypes.NetworkRouter,
+	groupIDToUserIDs map[string][]string,
+) *NetworkMapComponents {
+	peer := a.Peers[peerID]
+	// this can never happen, things are very wrong if it did
+	// TODO (dmitri) maybe consider using invariants?
+	if peer == nil {
+		log.WithField("peer id", peerID).Error("NetworkMapComponents are computed for a peer missing from the account")
+		return EmptyNetworkMapComponents(&NetworkMapComponents{
+			PeerID:  peerID,
+			Network: a.Network.Copy(),
+			// must include the target peer as it's required on the client
+			Peers: map[string]*ComponentPeer{peerID: peerToComponent(peer)},
+		})
+	}
+
+	if _, ok := validatedPeersMap[peerID]; !ok {
+		// Mirror legacy graceful-degrade: GetPeerNetworkMapFromComponents
+		// returns &NetworkMap{Network: a.Network.Copy()} when components is
+		// nil. Match that floor so the receiving client always sees the
+		// account Network identifier, not a fully-empty envelope.
+		return EmptyNetworkMapComponents(&NetworkMapComponents{
+			PeerID:  peerID,
+			Network: a.Network.Copy(),
+			// must include the target peer as it's required on the client
+			Peers: map[string]*ComponentPeer{peerID: peerToComponent(peer)},
+		})
+	}
+
+	components := &NetworkMapComponents{
+		PeerID:                    peerID,
+		Network:                   a.Network.Copy(),
+		NameServerGroups:          make([]*nbdns.NameServerGroup, 0),
+		CustomZoneDomain:          peersCustomZone.Domain,
+		ResourcePoliciesMap:       make(map[string][]*Policy),
+		RoutersMap:                make(map[string]map[string]*ComponentRouter),
+		NetworkResources:          make([]*ComponentResource, 0),
+		PostureFailedPeers:        make(map[string]map[string]struct{}, len(a.PostureChecks)),
+		RouterPeers:               make(map[string]*ComponentPeer),
+		NetworkXIDToPublicID:      make(map[string]string, len(a.Networks)),
+		PostureCheckXIDToPublicID: make(map[string]string, len(a.PostureChecks)),
+
+		ForceRoutingPeerDNSResolution: forcesRoutingPeerDNSResolution(a, peerID, routers),
+	}
+	for _, n := range a.Networks {
+		if n != nil {
+			components.NetworkXIDToPublicID[n.ID] = n.PublicID
+		}
+	}
+	for _, pc := range a.PostureChecks {
+		if pc != nil {
+			components.PostureCheckXIDToPublicID[pc.ID] = pc.PublicID
+		}
+	}
+
+	components.AccountSettings = &AccountSettingsInfo{
+		PeerLoginExpirationEnabled:      a.Settings.PeerLoginExpirationEnabled,
+		PeerLoginExpiration:             a.Settings.PeerLoginExpiration,
+		PeerInactivityExpirationEnabled: a.Settings.PeerInactivityExpirationEnabled,
+		PeerInactivityExpiration:        a.Settings.PeerInactivityExpiration,
+	}
+
+	components.DNSSettings = &a.DNSSettings
+
+	// relevantPeers always contains the target peer (peerID)
+	relevantPeers, relevantGroups, relevantPolicies, relevantRoutes, sshReqs := getPeersGroupsPoliciesRoutes(a, ctx, peerID, peer.SSHEnabled, validatedPeersMap, &components.PostureFailedPeers)
+
+	if len(sshReqs.neededGroupIDs) > 0 {
+		components.GroupIDToUserIDs = filterGroupIDToUserIDs(groupIDToUserIDs, sshReqs.neededGroupIDs)
+	}
+	if sshReqs.needAllowedUserIDs {
+		components.AllowedUserIDs = getAllowedUserIDs(a)
+	}
+
+	components.Peers = relevantPeers
+	components.Groups = groupsToComponent(relevantGroups)
+	components.Policies = relevantPolicies
+	components.Routes = relevantRoutes
+	components.AllDNSRecords = filterDNSRecordsByPeers(peersCustomZone.Records, relevantPeers, peer.SupportsIPv6() && peer.IPv6.IsValid())
+
+	peerGroups := a.GetPeerGroups(peerID)
+	components.AccountZones = filterPeerAppliedZones(ctx, accountZones, LookupMap(peerGroups))
+	components.AccountZones = append(components.AccountZones, a.SynthesizePrivateServiceZones(peerID)...)
+
+	for _, nsGroup := range a.NameServerGroups {
+		if nsGroup.Enabled {
+			for _, gID := range nsGroup.Groups {
+				if _, found := relevantGroups[gID]; found {
+					components.NameServerGroups = append(components.NameServerGroups, nsGroup)
+					break
+				}
+			}
+		}
+	}
+
+	for _, resource := range a.NetworkResources {
+		if !resource.Enabled {
+			continue
+		}
+
+		policies, exists := resourcePolicies[resource.ID]
+		if !exists {
+			continue
+		}
+
+		addSourcePeers := false
+
+		networkRoutingPeers, routerExists := routers[resource.NetworkID]
+		if routerExists {
+			if _, ok := networkRoutingPeers[peerID]; ok {
+				addSourcePeers = true
+			}
+		}
+
+		for _, policy := range policies {
+			if addSourcePeers {
+				var peers []string
+				if policy.Rules[0].SourceResource.Type == ResourceTypePeer && policy.Rules[0].SourceResource.ID != "" {
+					peers = []string{policy.Rules[0].SourceResource.ID}
+				} else {
+					peers = getUniquePeerIDsFromGroupsIDs(a, ctx, policy.SourceGroups())
+				}
+				for _, pID := range getPostureValidPeersSaveFailed(a, peers, policy.SourcePostureChecks, validatedPeersMap, &components.PostureFailedPeers) {
+					if _, exists := components.Peers[pID]; !exists {
+						components.Peers[pID] = peerToComponent(a.GetPeer(pID))
+					}
+				}
+			} else {
+				peerInSources := false
+				if policy.Rules[0].SourceResource.Type == ResourceTypePeer && policy.Rules[0].SourceResource.ID != "" {
+					peerInSources = policy.Rules[0].SourceResource.ID == peerID
+				} else {
+					for _, groupID := range policy.SourceGroups() {
+						if group := a.GetGroup(groupID); group != nil && slices.Contains(group.Peers, peerID) {
+							peerInSources = true
+							break
+						}
+					}
+				}
+				if !peerInSources {
+					continue
+				}
+				isValid, pname := validatePostureChecksOnPeerGetFailed(a, ctx, policy.SourcePostureChecks, peerID)
+				if !isValid && len(pname) > 0 {
+					if _, ok := components.PostureFailedPeers[pname]; !ok {
+						components.PostureFailedPeers[pname] = make(map[string]struct{})
+					}
+					components.PostureFailedPeers[pname][peer.ID] = struct{}{}
+					continue
+				}
+				addSourcePeers = true
+			}
+
+			for _, rule := range policy.Rules {
+				for _, srcGroupID := range rule.Sources {
+					if g := a.Groups[srcGroupID]; g != nil {
+						if _, exists := components.Groups[srcGroupID]; !exists {
+							components.Groups[srcGroupID] = groupToComponent(g)
+						}
+					}
+				}
+				for _, dstGroupID := range rule.Destinations {
+					if g := a.Groups[dstGroupID]; g != nil {
+						if _, exists := components.Groups[dstGroupID]; !exists {
+							components.Groups[dstGroupID] = groupToComponent(g)
+						}
+					}
+				}
+			}
+			components.ResourcePoliciesMap[resource.ID] = policies
+		}
+
+		// Only expose router peers and the per-network routers_map when this
+		// target peer actually has access to the resource (either as a router
+		// itself or via a policy that includes it as a source). Without this
+		// gate, every peer's envelope was leaking router peers of every
+		// network in the account — accounts with many tenants/networks
+		// shipped tens of unrelated peers in `peers[]` and `routers_map`.
+		if addSourcePeers {
+			components.RoutersMap[resource.NetworkID] = routersToComponentMap(networkRoutingPeers)
+			for peerIDKey := range networkRoutingPeers {
+				if p := a.Peers[peerIDKey]; p != nil {
+					cp := components.RouterPeers[peerIDKey]
+					if cp == nil {
+						cp = peerToComponent(p)
+						components.RouterPeers[peerIDKey] = cp
+					}
+					if _, exists := components.Peers[peerIDKey]; !exists {
+						if _, validated := validatedPeersMap[peerIDKey]; validated {
+							components.Peers[peerIDKey] = cp
+						}
+					}
+				}
+			}
+			components.NetworkResources = append(components.NetworkResources, resourceToComponent(resource))
+		}
+	}
+
+	filterGroupPeers(&components.Groups, components.Peers)
+	filterPostureFailedPeers(&components.PostureFailedPeers, components.Policies, components.ResourcePoliciesMap, components.Peers)
+
+	return components
+}
+
+type sshRequirements struct {
+	neededGroupIDs     map[string]struct{}
+	needAllowedUserIDs bool
+}
+
+func getPeersGroupsPoliciesRoutes(a *Account,
+	ctx context.Context,
+	peerID string,
+	peerSSHEnabled bool,
+	validatedPeersMap map[string]struct{},
+	postureFailedPeers *map[string]map[string]struct{},
+) (map[string]*ComponentPeer, map[string]*Group, []*Policy, []*route.Route, sshRequirements) {
+	relevantPeerIDs := make(map[string]*ComponentPeer, len(a.Peers)/4)
+	relevantGroupIDs := make(map[string]*Group, len(a.Groups)/4)
+	relevantPolicies := make([]*Policy, 0, len(a.Policies))
+	relevantRoutes := make([]*route.Route, 0, len(a.Routes))
+	sshReqs := sshRequirements{neededGroupIDs: make(map[string]struct{})}
+
+	relevantPeerIDs[peerID] = peerToComponent(a.GetPeer(peerID))
+
+	peerGroupSet := make(map[string]struct{}, 8)
+	for groupID, group := range a.Groups {
+		if slices.Contains(group.Peers, peerID) {
+			relevantGroupIDs[groupID] = a.GetGroup(groupID)
+			peerGroupSet[groupID] = struct{}{}
+		}
+	}
+
+	routeAccessControlGroups := make(map[string]struct{})
+	for _, r := range a.Routes {
+		if r == nil {
+			continue
+		}
+		relevant := r.Peer == peerID
+		if !relevant {
+			for _, groupID := range r.PeerGroups {
+				if _, ok := peerGroupSet[groupID]; ok {
+					relevant = true
+					break
+				}
+			}
+		}
+		if !relevant && r.Enabled {
+			for _, groupID := range r.Groups {
+				if _, ok := peerGroupSet[groupID]; ok {
+					relevant = true
+					break
+				}
+			}
+		}
+		if !relevant {
+			continue
+		}
+
+		for _, groupID := range r.PeerGroups {
+			relevantGroupIDs[groupID] = a.GetGroup(groupID)
+		}
+		for _, groupID := range r.Groups {
+			relevantGroupIDs[groupID] = a.GetGroup(groupID)
+		}
+		if r.Enabled {
+			for _, groupID := range r.AccessControlGroups {
+				relevantGroupIDs[groupID] = a.GetGroup(groupID)
+				routeAccessControlGroups[groupID] = struct{}{}
+			}
+		}
+
+		// Include route advertisers in relevantPeerIDs. The envelope
+		// encoder writes route.peer_index by looking up r.Peer in the
+		// shipped peers list; if the advertiser is policy-isolated from
+		// the target peer (no rule edge between them), it would otherwise
+		// be omitted and the decoder would fail to resolve r.Peer, leaving
+		// the client without a WG tunnel target for this route. Legacy
+		// NetworkMap.Routes shipped the WG public key inline, so the
+		// equivalence path doesn't surface this — but the dependency is
+		// real once a client actually tries to use the route.
+		// Gate by validatedPeersMap so non-validated advertisers stay out
+		// (matches the network-resource router behaviour at the bottom of
+		// this loop, and the legacy invariant that only validated peers
+		// reach a client's view).
+		if r.Peer != "" {
+			if _, ok := validatedPeersMap[r.Peer]; ok {
+				if p := a.GetPeer(r.Peer); p != nil {
+					relevantPeerIDs[r.Peer] = peerToComponent(p)
+				}
+			}
+		}
+		for _, groupID := range r.PeerGroups {
+			g := a.GetGroup(groupID)
+			if g == nil {
+				continue
+			}
+			for _, pid := range g.Peers {
+				if _, exists := relevantPeerIDs[pid]; exists {
+					continue
+				}
+				if _, ok := validatedPeersMap[pid]; !ok {
+					continue
+				}
+				if p := a.GetPeer(pid); p != nil {
+					relevantPeerIDs[pid] = peerToComponent(p)
+				}
+			}
+		}
+		relevantRoutes = append(relevantRoutes, r)
+	}
+
+	for _, policy := range a.Policies {
+		if !policy.Enabled {
+			continue
+		}
+
+		policyRelevant := false
+		for _, rule := range policy.Rules {
+			if !rule.Enabled {
+				continue
+			}
+
+			if len(routeAccessControlGroups) > 0 {
+				for _, destGroupID := range rule.Destinations {
+					if _, needed := routeAccessControlGroups[destGroupID]; needed {
+						policyRelevant = true
+						for _, srcGroupID := range rule.Sources {
+							relevantGroupIDs[srcGroupID] = a.GetGroup(srcGroupID)
+						}
+						for _, dstGroupID := range rule.Destinations {
+							relevantGroupIDs[dstGroupID] = a.GetGroup(dstGroupID)
+						}
+						break
+					}
+				}
+			}
+
+			var sourcePeers, destinationPeers []string
+			var peerInSources, peerInDestinations bool
+
+			if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" {
+				sourcePeers = []string{rule.SourceResource.ID}
+				if rule.SourceResource.ID == peerID {
+					peerInSources = true
+				}
+			} else {
+				sourcePeers, peerInSources = getPeersFromGroups(a, ctx, rule.Sources, peerID, policy.SourcePostureChecks, validatedPeersMap, postureFailedPeers)
+			}
+
+			if rule.DestinationResource.Type == ResourceTypePeer && rule.DestinationResource.ID != "" {
+				destinationPeers = []string{rule.DestinationResource.ID}
+				if rule.DestinationResource.ID == peerID {
+					peerInDestinations = true
+				}
+			} else {
+				destinationPeers, peerInDestinations = getPeersFromGroups(a, ctx, rule.Destinations, peerID, nil, validatedPeersMap, postureFailedPeers)
+			}
+
+			if peerInSources {
+				policyRelevant = true
+				for _, pid := range destinationPeers {
+					if _, exists := relevantPeerIDs[pid]; !exists {
+						relevantPeerIDs[pid] = peerToComponent(a.GetPeer(pid))
+					}
+				}
+				for _, dstGroupID := range rule.Destinations {
+					relevantGroupIDs[dstGroupID] = a.GetGroup(dstGroupID)
+				}
+			}
+
+			if peerInDestinations {
+				policyRelevant = true
+				for _, pid := range sourcePeers {
+					if _, exists := relevantPeerIDs[pid]; !exists {
+						relevantPeerIDs[pid] = peerToComponent(a.GetPeer(pid))
+					}
+				}
+				for _, srcGroupID := range rule.Sources {
+					relevantGroupIDs[srcGroupID] = a.GetGroup(srcGroupID)
+				}
+
+				if rule.Protocol == PolicyRuleProtocolNetbirdSSH {
+					switch {
+					case len(rule.AuthorizedGroups) > 0:
+						for groupID := range rule.AuthorizedGroups {
+							sshReqs.neededGroupIDs[groupID] = struct{}{}
+						}
+					case rule.AuthorizedUser != "":
+					default:
+						sshReqs.needAllowedUserIDs = true
+					}
+				} else if PolicyRuleImpliesLegacySSH(rule) && peerSSHEnabled {
+					sshReqs.needAllowedUserIDs = true
+				}
+			}
+		}
+		if policyRelevant {
+			relevantPolicies = append(relevantPolicies, policy)
+		}
+	}
+
+	return relevantPeerIDs, relevantGroupIDs, relevantPolicies, relevantRoutes, sshReqs
+}
+
+func getPeersFromGroups(a *Account, ctx context.Context, groups []string, peerID string, sourcePostureChecksIDs []string,
+	validatedPeersMap map[string]struct{}, postureFailedPeers *map[string]map[string]struct{}) ([]string, bool) {
+	peerInGroups := false
+	filteredPeerIDs := make([]string, 0, len(groups))
+	seenPeerIds := make(map[string]struct{}, len(groups))
+
+	for _, gid := range groups {
+		group := a.GetGroup(gid)
+		if group == nil {
+			continue
+		}
+
+		if group.IsGroupAll() || len(groups) == 1 {
+			filteredPeerIDs = make([]string, 0, len(group.Peers))
+			peerInGroups = false
+			for _, pid := range group.Peers {
+				peer, ok := a.Peers[pid]
+				if !ok || peer == nil {
+					continue
+				}
+
+				if _, ok := validatedPeersMap[peer.ID]; !ok {
+					continue
+				}
+
+				isValid, pname := validatePostureChecksOnPeerGetFailed(a, ctx, sourcePostureChecksIDs, peer.ID)
+				if !isValid && len(pname) > 0 {
+					if _, ok := (*postureFailedPeers)[pname]; !ok {
+						(*postureFailedPeers)[pname] = make(map[string]struct{})
+					}
+					(*postureFailedPeers)[pname][peer.ID] = struct{}{}
+					continue
+				}
+
+				if peer.ID == peerID {
+					peerInGroups = true
+					continue
+				}
+
+				filteredPeerIDs = append(filteredPeerIDs, peer.ID)
+			}
+			return filteredPeerIDs, peerInGroups
+		}
+
+		for _, pid := range group.Peers {
+			if _, seen := seenPeerIds[pid]; seen {
+				continue
+			}
+			seenPeerIds[pid] = struct{}{}
+			peer, ok := a.Peers[pid]
+			if !ok || peer == nil {
+				continue
+			}
+
+			if _, ok := validatedPeersMap[peer.ID]; !ok {
+				continue
+			}
+
+			isValid, pname := validatePostureChecksOnPeerGetFailed(a, ctx, sourcePostureChecksIDs, peer.ID)
+			if !isValid && len(pname) > 0 {
+				if _, ok := (*postureFailedPeers)[pname]; !ok {
+					(*postureFailedPeers)[pname] = make(map[string]struct{})
+				}
+				(*postureFailedPeers)[pname][peer.ID] = struct{}{}
+				continue
+			}
+
+			if peer.ID == peerID {
+				peerInGroups = true
+				continue
+			}
+
+			filteredPeerIDs = append(filteredPeerIDs, peer.ID)
+		}
+	}
+
+	return filteredPeerIDs, peerInGroups
+}
+
+func validatePostureChecksOnPeerGetFailed(a *Account, ctx context.Context, sourcePostureChecksID []string, peerID string) (bool, string) {
+	peer, ok := a.Peers[peerID]
+	if !ok || peer == nil {
+		return false, ""
+	}
+
+	for _, postureChecksID := range sourcePostureChecksID {
+		postureChecks := a.GetPostureChecks(postureChecksID)
+		if postureChecks == nil {
+			continue
+		}
+
+		for _, check := range postureChecks.GetChecks() {
+			isValid, _ := check.Check(ctx, *peer)
+			if !isValid {
+				return false, postureChecksID
+			}
+		}
+	}
+	return true, ""
+}
+
+func getPostureValidPeersSaveFailed(a *Account, inputPeers []string, postureChecksIDs []string, validatedPeersMap map[string]struct{}, postureFailedPeers *map[string]map[string]struct{}) []string {
+	var dest []string
+	for _, peerID := range inputPeers {
+		if _, validated := validatedPeersMap[peerID]; !validated {
+			continue
+		}
+		valid, pname := validatePostureChecksOnPeerGetFailed(a, context.Background(), postureChecksIDs, peerID)
+		if valid {
+			dest = append(dest, peerID)
+			continue
+		}
+		if _, ok := (*postureFailedPeers)[pname]; !ok {
+			(*postureFailedPeers)[pname] = make(map[string]struct{})
+		}
+		(*postureFailedPeers)[pname][peerID] = struct{}{}
+	}
+	return dest
+}
+
+// filterGroupPeers trims each group's Peers slice to only those peers that
+// also appear in `peers`. Groups whose filtered list is empty are NOT
+// deleted from the map — they're kept so the components wire encoder can
+// still resolve seq references from routes/policies/access-control groups
+// that name them. Calculate() tolerates groups with empty Peers (the inner
+// loops simply iterate zero times), so retaining them is behaviourally a
+// no-op for the legacy path that consumes the same NetworkMapComponents.
+func filterGroupPeers(groups *map[string]*ComponentGroup, peers map[string]*ComponentPeer) {
+	for groupID, groupInfo := range *groups {
+		filteredPeers := make([]string, 0, len(groupInfo.Peers))
+		for _, pid := range groupInfo.Peers {
+			if _, exists := peers[pid]; exists {
+				filteredPeers = append(filteredPeers, pid)
+			}
+		}
+
+		if len(filteredPeers) != len(groupInfo.Peers) {
+			ng := *groupInfo
+			ng.Peers = filteredPeers
+			(*groups)[groupID] = &ng
+		}
+	}
+}
+
+func filterPostureFailedPeers(postureFailedPeers *map[string]map[string]struct{}, policies []*Policy, resourcePoliciesMap map[string][]*Policy, peers map[string]*ComponentPeer) {
+	if len(*postureFailedPeers) == 0 {
+		return
+	}
+
+	referencedPostureChecks := make(map[string]struct{})
+	for _, policy := range policies {
+		for _, checkID := range policy.SourcePostureChecks {
+			referencedPostureChecks[checkID] = struct{}{}
+		}
+	}
+	for _, resPolicies := range resourcePoliciesMap {
+		for _, policy := range resPolicies {
+			for _, checkID := range policy.SourcePostureChecks {
+				referencedPostureChecks[checkID] = struct{}{}
+			}
+		}
+	}
+
+	for checkID, failedPeers := range *postureFailedPeers {
+		if _, referenced := referencedPostureChecks[checkID]; !referenced {
+			delete(*postureFailedPeers, checkID)
+			continue
+		}
+		for peerID := range failedPeers {
+			if _, exists := peers[peerID]; !exists {
+				delete(failedPeers, peerID)
+			}
+		}
+		if len(failedPeers) == 0 {
+			delete(*postureFailedPeers, checkID)
+		}
+	}
+}
+
+func filterDNSRecordsByPeers(records []nbdns.SimpleRecord, peers map[string]*ComponentPeer, includeIPv6 bool) []nbdns.SimpleRecord {
+	if len(records) == 0 || len(peers) == 0 {
+		return nil
+	}
+
+	// Include both v4 and v6 addresses so AAAA records (whose RData is an IPv6
+	// address) are not filtered out when peers have IPv6 assigned. When the
+	// requesting peer doesn't have IPv6, omit v6 IPs so AAAA records get dropped.
+	peerIPs := make(map[string]struct{}, len(peers)*2)
+	for _, peer := range peers {
+		if peer == nil {
+			continue
+		}
+		peerIPs[peer.IP.String()] = struct{}{}
+		if includeIPv6 && peer.IPv6.IsValid() {
+			peerIPs[peer.IPv6.String()] = struct{}{}
+		}
+	}
+
+	filteredRecords := make([]nbdns.SimpleRecord, 0, len(records))
+	for _, record := range records {
+		if _, exists := peerIPs[record.RData]; exists {
+			filteredRecords = append(filteredRecords, record)
+		}
+	}
+
+	return filteredRecords
+}
+
+func filterGroupIDToUserIDs(fullMap map[string][]string, neededGroupIDs map[string]struct{}) map[string][]string {
+	if len(neededGroupIDs) == 0 {
+		return nil
+	}
+
+	filtered := make(map[string][]string, len(neededGroupIDs))
+	for groupID := range neededGroupIDs {
+		if users, ok := fullMap[groupID]; ok {
+			filtered[groupID] = users
+		}
+	}
+	return filtered
+}
diff --git a/management/server/types/legacynmap/aliases.go b/management/server/types/legacynmap/aliases.go
new file mode 100644
index 000000000..82a18192b
--- /dev/null
+++ b/management/server/types/legacynmap/aliases.go
@@ -0,0 +1,35 @@
+package legacynmap
+
+import (
+	types "github.com/netbirdio/netbird/management/server/types"
+	sharedtypes "github.com/netbirdio/netbird/shared/management/types"
+)
+
+type (
+	Account = types.Account
+
+	DNSSettings       = types.DNSSettings
+	FirewallRule      = sharedtypes.FirewallRule
+	ForwardingRule    = sharedtypes.ForwardingRule
+	Group             = types.Group
+	Network           = types.Network
+	Policy            = types.Policy
+	PolicyRule        = types.PolicyRule
+	Resource          = types.Resource
+	RulePortRange     = sharedtypes.RulePortRange
+	RouteFirewallRule = sharedtypes.RouteFirewallRule
+)
+
+const (
+	FirewallRuleDirectionIN  = sharedtypes.FirewallRuleDirectionIN
+	FirewallRuleDirectionOUT = sharedtypes.FirewallRuleDirectionOUT
+
+	PolicyRuleProtocolALL        = sharedtypes.PolicyRuleProtocolALL
+	PolicyRuleProtocolTCP        = sharedtypes.PolicyRuleProtocolTCP
+	PolicyRuleProtocolNetbirdSSH = sharedtypes.PolicyRuleProtocolNetbirdSSH
+	PolicyTrafficActionAccept    = sharedtypes.PolicyTrafficActionAccept
+	ResourceTypePeer             = sharedtypes.ResourceTypePeer
+
+	AllowedIPsFormat   = sharedtypes.AllowedIPsFormat
+	AllowedIPsV6Format = sharedtypes.AllowedIPsV6Format
+)
diff --git a/management/server/types/legacynmap/benchmark_test.go b/management/server/types/legacynmap/benchmark_test.go
new file mode 100644
index 000000000..22e291e00
--- /dev/null
+++ b/management/server/types/legacynmap/benchmark_test.go
@@ -0,0 +1,350 @@
+//go:build nmapequiv
+
+// Account-load benchmark: the legacy store.GetAccount hydration (pgx fast
+// path, as in production) vs the nmdata store's GetNetworkMapData, against the
+// same Postgres copy as the equivalence test.
+//
+//	NETBIRD_STORE_ENGINE_POSTGRES_DSN='...' go test -tags nmapequiv \
+//	  -run '^$' -bench . -benchtime 5x -timeout 60m \
+//	  ./management/server/types/legacynmap/
+//
+// NETMAP_ACCOUNTS selects the accounts (comma-separated); by default the ten
+// accounts with the most peers are used. Each account is a sub-benchmark, so
+// the two paths can be compared per account. One warmup call runs untimed
+// before each measurement so Postgres buffer-cache state is comparable.
+//
+// Reported metrics beyond ns/op and allocs:
+//
+//   - queries/op    round trips, counted client-side via a pgx tracer
+//     (GetNetworkMapData only — the legacy store's pool is internal)
+//   - xact/op       committed transactions from pg_stat_database; the legacy
+//     pgx path runs autocommit statements, so this approximates its round
+//     trips, while GetNetworkMapData runs a single transaction
+//   - tup_returned/op, tup_fetched/op   rows scanned/fetched server-side
+//   - blks_read/op, blks_hit/op         buffer cache misses/hits
+//
+// The pg_stat_database numbers are database-global: run without concurrent
+// load. The two stat snapshots per sub-benchmark add a small constant
+// overhead to the server-side deltas.
+package legacynmap_test
+
+import (
+	"context"
+	"os"
+	"strings"
+	"sync/atomic"
+	"testing"
+	"time"
+
+	"github.com/jackc/pgx/v5"
+	"github.com/jackc/pgx/v5/pgxpool"
+	"github.com/stretchr/testify/require"
+
+	nbdns "github.com/netbirdio/netbird/dns"
+	"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache"
+	networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql"
+	mgmtgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
+	"github.com/netbirdio/netbird/management/server/store"
+	"github.com/netbirdio/netbird/management/server/types"
+	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+func BenchmarkGetAccount(b *testing.B) {
+	dsn := equivDSN()
+	if dsn == "" {
+		b.Skip("NETBIRD_STORE_ENGINE_POSTGRES_DSN not set")
+	}
+	ctx := context.Background()
+
+	statsConn, err := pgx.Connect(ctx, dsn)
+	require.NoError(b, err, "connect stats connection")
+	b.Cleanup(func() { statsConn.Close(ctx) })
+
+	testStore, err := store.NewPostgresqlStore(ctx, dsn, nil, true)
+	require.NoError(b, err, "connect to postgres")
+	b.Cleanup(func() { testStore.Close(ctx) })
+
+	for _, accountID := range benchAccountIDs(b, ctx, statsConn) {
+		b.Run(accountID, func(b *testing.B) {
+			logAccountShape(b, ctx, statsConn, accountID)
+			benchDBLoad(b, ctx, statsConn, nil, func() error {
+				_, err := testStore.GetAccount(ctx, accountID)
+				return err
+			})
+		})
+	}
+}
+
+func BenchmarkGetNetworkMapData(b *testing.B) {
+	dsn := equivDSN()
+	if dsn == "" {
+		b.Skip("NETBIRD_STORE_ENGINE_POSTGRES_DSN not set")
+	}
+	ctx := context.Background()
+
+	statsConn, err := pgx.Connect(ctx, dsn)
+	require.NoError(b, err, "connect stats connection")
+	b.Cleanup(func() { statsConn.Close(ctx) })
+
+	tracer := &queryCountTracer{}
+	cfg, err := pgxpool.ParseConfig(dsn)
+	require.NoError(b, err, "parse dsn")
+	cfg.ConnConfig.Tracer = tracer
+	pool, err := pgxpool.NewWithConfig(ctx, cfg)
+	require.NoError(b, err, "connect nmdata store")
+	b.Cleanup(pool.Close)
+	nmStore := nmDataStore(b, &networkmap_pgsql.PgStore{Pool: pool})
+
+	for _, accountID := range benchAccountIDs(b, ctx, statsConn) {
+		b.Run(accountID, func(b *testing.B) {
+			logAccountShape(b, ctx, statsConn, accountID)
+			benchDBLoad(b, ctx, statsConn, tracer, func() error {
+				_, err := nmStore.GetNetworkMapData(ctx, accountID)
+				return err
+			})
+		})
+	}
+}
+
+// BenchmarkAccountFullRound measures store load plus the full per-peer fan-out
+// to *proto.SyncResponse for every peer of the account, the way the production
+// account path runs it: index maps and per-peer twin building happen after
+// GetAccount and are part of the measured op. BenchmarkNetworkMapDataFullRound
+// is the equivalent for the nmdata path, whose index building happens inside
+// GetNetworkMapData. Select both with -bench FullRound.
+func BenchmarkAccountFullRound(b *testing.B) {
+	dsn := equivDSN()
+	if dsn == "" {
+		b.Skip("NETBIRD_STORE_ENGINE_POSTGRES_DSN not set")
+	}
+	ctx := context.Background()
+
+	statsConn, err := pgx.Connect(ctx, dsn)
+	require.NoError(b, err, "connect stats connection")
+	b.Cleanup(func() { statsConn.Close(ctx) })
+
+	testStore, err := store.NewPostgresqlStore(ctx, dsn, nil, true)
+	require.NoError(b, err, "connect to postgres")
+	b.Cleanup(func() { testStore.Close(ctx) })
+
+	for _, accountID := range benchAccountIDs(b, ctx, statsConn) {
+		b.Run(accountID, func(b *testing.B) {
+			logAccountShape(b, ctx, statsConn, accountID)
+			benchDBLoad(b, ctx, statsConn, nil, func() error {
+				account, err := testStore.GetAccount(ctx, accountID)
+				if err != nil {
+					return err
+				}
+				buildAccountSyncResponses(ctx, account)
+				return nil
+			})
+		})
+	}
+}
+
+func BenchmarkNetworkMapDataFullRound(b *testing.B) {
+	dsn := equivDSN()
+	if dsn == "" {
+		b.Skip("NETBIRD_STORE_ENGINE_POSTGRES_DSN not set")
+	}
+	ctx := context.Background()
+
+	statsConn, err := pgx.Connect(ctx, dsn)
+	require.NoError(b, err, "connect stats connection")
+	b.Cleanup(func() { statsConn.Close(ctx) })
+
+	tracer := &queryCountTracer{}
+	cfg, err := pgxpool.ParseConfig(dsn)
+	require.NoError(b, err, "parse dsn")
+	cfg.ConnConfig.Tracer = tracer
+	pool, err := pgxpool.NewWithConfig(ctx, cfg)
+	require.NoError(b, err, "connect nmdata store")
+	b.Cleanup(pool.Close)
+	nmStore := nmDataStore(b, &networkmap_pgsql.PgStore{Pool: pool})
+
+	for _, accountID := range benchAccountIDs(b, ctx, statsConn) {
+		b.Run(accountID, func(b *testing.B) {
+			logAccountShape(b, ctx, statsConn, accountID)
+			benchDBLoad(b, ctx, statsConn, tracer, func() error {
+				nmData, err := nmStore.GetNetworkMapData(ctx, accountID)
+				if err != nil {
+					return err
+				}
+				buildDataSyncResponses(ctx, nmData)
+				return nil
+			})
+		})
+	}
+}
+
+// buildAccountSyncResponses fans out to every peer like the controller's
+// account path: index maps once, twin conversion and network-map computation
+// per peer.
+func buildAccountSyncResponses(ctx context.Context, account *types.Account) {
+	validated := make(map[string]struct{}, len(account.Peers))
+	for peerID := range account.Peers {
+		validated[peerID] = struct{}{}
+	}
+	resourcePolicies := account.GetResourcePoliciesMap()
+	routers := account.GetResourceRoutersMap()
+	groupUsers := account.GetActiveGroupUsers()
+	settings := account.Settings
+	if settings == nil {
+		settings = &types.Settings{}
+	}
+	dnsCache := &cache.DNSConfigCache{}
+
+	for peerID, peer := range account.Peers {
+		nm := account.GetPeerNetworkMapFromComponents(
+			ctx, peerID, nbdns.CustomZone{}, nil, validated, resourcePolicies, routers, nil, groupUsers,
+		)
+		mgmtgrpc.ToSyncResponse(
+			ctx, nil, nil, nil, types.TwinPeer(peer), nil, nil, nm, equivDNSName, nil,
+			dnsCache, types.TwinAccountSettings(settings), settings.Extra, nil, 0,
+		)
+	}
+}
+
+// buildDataSyncResponses is the nmdata-path equivalent of
+// buildAccountSyncResponses.
+func buildDataSyncResponses(ctx context.Context, nmData *networkmap.NetworkMapData) {
+	validated := make(map[string]struct{}, len(nmData.Peers))
+	for peerID := range nmData.Peers {
+		validated[peerID] = struct{}{}
+	}
+	nmData.ValidatedPeers = validated
+	dnsCache := &cache.DNSConfigCache{}
+
+	for peerID, peer := range nmData.Peers {
+		components := nmData.GetPeerNetworkMapComponents(peerID, nmdata.CustomZone{})
+		nm := &types.NetworkMap{Network: components.Network}
+		if !components.IsEmpty() {
+			nm = types.CalculateNetworkMapFromComponents(ctx, components)
+		}
+		mgmtgrpc.ToSyncResponse(
+			ctx, nil, nil, nil, peer, nil, nil, nm, equivDNSName, nil,
+			dnsCache, nmData.AccountSettings, nil, nil, 0,
+		)
+	}
+}
+
+// benchDBLoad runs op b.N times and reports server-side pg_stat_database
+// deltas per op. A non-nil tracer additionally reports exact client round
+// trips per op.
+//
+// Backends flush cumulative stats at most once per second and only while
+// processing commands, so around each snapshot the load settles: sleep past
+// the flush interval, then run one extra untimed op whose command end flushes
+// everything pending. The trailing extra op lands inside the measured window,
+// hence the b.N+1 denominator for the server-side metrics.
+func benchDBLoad(b *testing.B, ctx context.Context, statsConn *pgx.Conn, tracer *queryCountTracer, op func() error) {
+	b.Helper()
+
+	require.NoError(b, op(), "warmup")
+	settleDBStats(b, op)
+
+	before, err := snapshotDBStats(ctx, statsConn)
+	require.NoError(b, err, "stats snapshot")
+	var queriesBefore int64
+	if tracer != nil {
+		queriesBefore = tracer.queries.Load()
+	}
+
+	b.ReportAllocs()
+	b.ResetTimer()
+	for i := 0; i < b.N; i++ {
+		if err := op(); err != nil {
+			b.Fatal(err)
+		}
+	}
+	b.StopTimer()
+
+	settleDBStats(b, op)
+	after, err := snapshotDBStats(ctx, statsConn)
+	require.NoError(b, err, "stats snapshot")
+
+	ops := float64(b.N + 1)
+	if tracer != nil {
+		b.ReportMetric(float64(tracer.queries.Load()-queriesBefore)/ops, "queries/op")
+	}
+	b.ReportMetric(float64(after.xactCommit-before.xactCommit)/ops, "xact/op")
+	b.ReportMetric(float64(after.tupReturned-before.tupReturned)/ops, "tup_returned/op")
+	b.ReportMetric(float64(after.tupFetched-before.tupFetched)/ops, "tup_fetched/op")
+	b.ReportMetric(float64(after.blksRead-before.blksRead)/ops, "blks_read/op")
+	b.ReportMetric(float64(after.blksHit-before.blksHit)/ops, "blks_hit/op")
+}
+
+func settleDBStats(b *testing.B, op func() error) {
+	b.Helper()
+	time.Sleep(1100 * time.Millisecond)
+	require.NoError(b, op(), "stats flush op")
+	time.Sleep(100 * time.Millisecond)
+}
+
+func benchAccountIDs(b *testing.B, ctx context.Context, conn *pgx.Conn) []string {
+	b.Helper()
+
+	if ids := strings.TrimSpace(os.Getenv("NETMAP_ACCOUNTS")); ids != "" {
+		var out []string
+		for _, id := range strings.Split(ids, ",") {
+			if id = strings.TrimSpace(id); id != "" {
+				out = append(out, id)
+			}
+		}
+		return out
+	}
+
+	rows, err := conn.Query(ctx,
+		"select account_id from peers group by account_id order by count(*) desc, account_id limit 10")
+	require.NoError(b, err, "list benchmark accounts")
+	ids, err := pgx.CollectRows(rows, pgx.RowTo[string])
+	require.NoError(b, err, "collect benchmark accounts")
+	require.NotEmpty(b, ids, "no accounts found")
+	return ids
+}
+
+func logAccountShape(b *testing.B, ctx context.Context, conn *pgx.Conn, accountID string) {
+	b.Helper()
+
+	var peers, groups, users, policies, routes, resources, nsGroups int
+	err := conn.QueryRow(ctx, `select
+		(select count(*) from peers where account_id=$1),
+		(select count(*) from groups where account_id=$1),
+		(select count(*) from users where account_id=$1),
+		(select count(*) from policies where account_id=$1),
+		(select count(*) from routes where account_id=$1),
+		(select count(*) from network_resources where account_id=$1),
+		(select count(*) from name_server_groups where account_id=$1)`, accountID).
+		Scan(&peers, &groups, &users, &policies, &routes, &resources, &nsGroups)
+	require.NoError(b, err, "account shape")
+	b.Logf("account=%s peers=%d groups=%d users=%d policies=%d routes=%d resources=%d nsgroups=%d",
+		accountID, peers, groups, users, policies, routes, resources, nsGroups)
+}
+
+type dbStats struct {
+	xactCommit  int64
+	tupReturned int64
+	tupFetched  int64
+	blksRead    int64
+	blksHit     int64
+}
+
+func snapshotDBStats(ctx context.Context, conn *pgx.Conn) (dbStats, error) {
+	var s dbStats
+	err := conn.QueryRow(ctx, `select xact_commit, tup_returned, tup_fetched, blks_read, blks_hit
+		from pg_stat_database where datname = current_database()`).
+		Scan(&s.xactCommit, &s.tupReturned, &s.tupFetched, &s.blksRead, &s.blksHit)
+	return s, err
+}
+
+type queryCountTracer struct {
+	queries atomic.Int64
+}
+
+func (t *queryCountTracer) TraceQueryStart(ctx context.Context, _ *pgx.Conn, _ pgx.TraceQueryStartData) context.Context {
+	t.queries.Add(1)
+	return ctx
+}
+
+func (t *queryCountTracer) TraceQueryEnd(context.Context, *pgx.Conn, pgx.TraceQueryEndData) {}
diff --git a/shared/management/types/component_types.go b/management/server/types/legacynmap/component_types.go
similarity index 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..3f71dafa5
--- /dev/null
+++ b/management/server/types/legacynmap/networkmap_components.go
@@ -0,0 +1,1032 @@
+package legacynmap
+
+import (
+	"context"
+	"maps"
+	"net/netip"
+	"slices"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/netbirdio/netbird/client/ssh/auth"
+	nbdns "github.com/netbirdio/netbird/dns"
+	"github.com/netbirdio/netbird/route"
+	"github.com/netbirdio/netbird/shared/management/domain"
+)
+
+type NetworkMapComponents struct {
+	PeerID string
+
+	Network          *Network
+	AccountSettings  *AccountSettingsInfo
+	DNSSettings      *DNSSettings
+	CustomZoneDomain string
+
+	Peers               map[string]*ComponentPeer
+	Groups              map[string]*ComponentGroup
+	Policies            []*Policy
+	Routes              []*route.Route
+	NameServerGroups    []*nbdns.NameServerGroup
+	AllDNSRecords       []nbdns.SimpleRecord
+	AccountZones        []nbdns.CustomZone
+	ResourcePoliciesMap map[string][]*Policy
+	RoutersMap          map[string]map[string]*ComponentRouter
+	NetworkResources    []*ComponentResource
+
+	GroupIDToUserIDs   map[string][]string
+	AllowedUserIDs     map[string]struct{}
+	PostureFailedPeers map[string]map[string]struct{}
+
+	RouterPeers map[string]*ComponentPeer
+
+	// NetworkXIDToPublicID maps Network.ID (xid) → PublicID.
+	// Consumed by the envelope encoder to
+	// translate RoutersMap keys and NetworkResource.NetworkID references
+	// to compact uint32 ids. Legacy Calculate() doesn't consult it.
+	NetworkXIDToPublicID map[string]string
+
+	// PostureCheckXIDToPublicID maps posture.Checks.ID (xid) → PublicID.
+	// Same role as NetworkXIDToPublicID, used for PostureFailedPeers keys and
+	// policy SourcePostureChecks references.
+	PostureCheckXIDToPublicID map[string]string
+	routesByPeerOnce          sync.Once
+	routesByPeerIdx           map[string][]routeIndexEntry
+
+	// true when returning an empty-like map (returned instead of nil)
+	empty bool
+
+	// ForceRoutingPeerDNSResolution forces the peer to run/use routing-peer DNS
+	// resolution regardless of the account-global setting, for reverse-proxy
+	// domain targets.
+	ForceRoutingPeerDNSResolution bool
+}
+
+type routeIndexEntry struct {
+	route    *route.Route
+	viaGroup bool
+}
+
+type AccountSettingsInfo struct {
+	PeerLoginExpirationEnabled      bool
+	PeerLoginExpiration             time.Duration
+	PeerInactivityExpirationEnabled bool
+	PeerInactivityExpiration        time.Duration
+}
+
+func EmptyNetworkMapComponents(nm *NetworkMapComponents) *NetworkMapComponents {
+	nm.empty = true
+	return nm
+}
+
+func (c *NetworkMapComponents) GetPeerInfo(peerID string) *ComponentPeer {
+	return c.Peers[peerID]
+}
+
+func (c *NetworkMapComponents) GetRouterPeerInfo(peerID string) *ComponentPeer {
+	return c.RouterPeers[peerID]
+}
+
+func (c *NetworkMapComponents) GetGroupInfo(groupID string) *ComponentGroup {
+	return c.Groups[groupID]
+}
+
+func (c *NetworkMapComponents) IsPeerInGroup(peerID, groupID string) bool {
+	group := c.GetGroupInfo(groupID)
+	if group == nil {
+		return false
+	}
+
+	return slices.Contains(group.Peers, peerID)
+}
+
+func (c *NetworkMapComponents) GetPeerGroups(peerID string) map[string]struct{} {
+	groups := make(map[string]struct{})
+	for groupID, group := range c.Groups {
+		if slices.Contains(group.Peers, peerID) {
+			groups[groupID] = struct{}{}
+		}
+	}
+	return groups
+}
+
+func (c *NetworkMapComponents) ValidatePostureChecksOnPeer(peerID string, postureCheckIDs []string) bool {
+	_, exists := c.Peers[peerID]
+	if !exists {
+		return false
+	}
+	if len(postureCheckIDs) == 0 {
+		return true
+	}
+	for _, checkID := range postureCheckIDs {
+		if failedPeers, exists := c.PostureFailedPeers[checkID]; exists {
+			if _, failed := failedPeers[peerID]; failed {
+				return false
+			}
+		}
+	}
+	return true
+}
+
+func CalculateNetworkMapFromComponents(ctx context.Context, components *NetworkMapComponents) *NetworkMap {
+	return components.Calculate(ctx)
+}
+
+func (c *NetworkMapComponents) Calculate(ctx context.Context) *NetworkMap {
+	targetPeerID := c.PeerID
+
+	peerGroups := c.GetPeerGroups(targetPeerID)
+
+	aclPeers, firewallRules, authorizedUsers, sshEnabled := c.getPeerConnectionResources(targetPeerID)
+
+	peersToConnect, expiredPeers := c.filterPeersByLoginExpiration(aclPeers)
+
+	includeIPv6 := false
+	if p := c.Peers[targetPeerID]; p != nil {
+		includeIPv6 = p.SupportsIPv6 && p.IPv6.IsValid()
+	}
+	routesUpdate := filterAndExpandRoutes(c.getRoutesToSync(targetPeerID, peersToConnect, peerGroups), includeIPv6)
+	routesFirewallRules := c.getPeerRoutesFirewallRules(ctx, targetPeerID, includeIPv6)
+
+	isRouter, networkResourcesRoutes, sourcePeers := c.getNetworkResourcesRoutesToSync(targetPeerID)
+	var networkResourcesFirewallRules []*RouteFirewallRule
+	if isRouter {
+		networkResourcesFirewallRules = c.getPeerNetworkResourceFirewallRules(ctx, targetPeerID, networkResourcesRoutes, includeIPv6)
+	}
+
+	peersToConnectIncludingRouters := c.addNetworksRoutingPeers(
+		networkResourcesRoutes,
+		targetPeerID,
+		peersToConnect,
+		expiredPeers,
+		isRouter,
+		sourcePeers,
+	)
+
+	dnsManagementStatus := c.getPeerDNSManagementStatusFromGroups(peerGroups)
+	dnsUpdate := nbdns.Config{
+		ServiceEnable: dnsManagementStatus,
+	}
+
+	if dnsManagementStatus {
+		var customZones []nbdns.CustomZone
+
+		if c.CustomZoneDomain != "" && len(c.AllDNSRecords) > 0 {
+			customZones = append(customZones, nbdns.CustomZone{
+				Domain:  c.CustomZoneDomain,
+				Records: c.AllDNSRecords,
+			})
+		}
+
+		customZones = append(customZones, c.AccountZones...)
+
+		dnsUpdate.CustomZones = customZones
+		dnsUpdate.NameServerGroups = c.getPeerNSGroupsFromGroups(targetPeerID, peerGroups)
+	}
+
+	return &NetworkMap{
+		Peers:               peersToConnectIncludingRouters,
+		Network:             c.Network.Copy(),
+		Routes:              append(filterAndExpandRoutes(networkResourcesRoutes, includeIPv6), routesUpdate...),
+		DNSConfig:           dnsUpdate,
+		OfflinePeers:        expiredPeers,
+		FirewallRules:       firewallRules,
+		RoutesFirewallRules: append(networkResourcesFirewallRules, routesFirewallRules...),
+		AuthorizedUsers:     authorizedUsers,
+		EnableSSH:           sshEnabled,
+
+		ForceRoutingPeerDNSResolution: c.ForceRoutingPeerDNSResolution,
+	}
+}
+
+func (c *NetworkMapComponents) IsEmpty() bool {
+	return c.empty
+}
+
+func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) ([]*ComponentPeer, []*FirewallRule, map[string]map[string]struct{}, bool) {
+	targetPeer := c.GetPeerInfo(targetPeerID)
+	if targetPeer == nil {
+		return nil, nil, nil, false
+	}
+
+	generateResources, getAccumulatedResources := c.connResourcesGenerator(targetPeer)
+	authorizedUsers := make(map[string]map[string]struct{})
+	sshEnabled := false
+
+	for _, policy := range c.Policies {
+		if !policy.Enabled {
+			continue
+		}
+
+		for _, rule := range policy.Rules {
+			if !rule.Enabled {
+				continue
+			}
+
+			var sourcePeers, destinationPeers []*ComponentPeer
+			var peerInSources, peerInDestinations bool
+
+			if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" {
+				sourcePeers, peerInSources = c.getPeerFromResource(rule.SourceResource, targetPeerID)
+			} else {
+				sourcePeers, peerInSources = c.getAllPeersFromGroups(rule.Sources, targetPeerID, policy.SourcePostureChecks)
+			}
+
+			if rule.DestinationResource.Type == ResourceTypePeer && rule.DestinationResource.ID != "" {
+				destinationPeers, peerInDestinations = c.getPeerFromResource(rule.DestinationResource, targetPeerID)
+			} else {
+				destinationPeers, peerInDestinations = c.getAllPeersFromGroups(rule.Destinations, targetPeerID, nil)
+			}
+
+			if rule.Bidirectional {
+				if peerInSources {
+					generateResources(rule, destinationPeers, FirewallRuleDirectionIN)
+				}
+				if peerInDestinations {
+					generateResources(rule, sourcePeers, FirewallRuleDirectionOUT)
+				}
+			}
+
+			if peerInSources {
+				generateResources(rule, destinationPeers, FirewallRuleDirectionOUT)
+			}
+
+			if peerInDestinations {
+				generateResources(rule, sourcePeers, FirewallRuleDirectionIN)
+			}
+
+			if peerInDestinations && rule.Protocol == PolicyRuleProtocolNetbirdSSH {
+				sshEnabled = true
+				switch {
+				case len(rule.AuthorizedGroups) > 0:
+					for groupID, localUsers := range rule.AuthorizedGroups {
+						userIDs, ok := c.GroupIDToUserIDs[groupID]
+						if !ok {
+							continue
+						}
+
+						if len(localUsers) == 0 {
+							localUsers = []string{auth.Wildcard}
+						}
+
+						for _, localUser := range localUsers {
+							if authorizedUsers[localUser] == nil {
+								authorizedUsers[localUser] = make(map[string]struct{})
+							}
+							for _, userID := range userIDs {
+								authorizedUsers[localUser][userID] = struct{}{}
+							}
+						}
+					}
+				case rule.AuthorizedUser != "":
+					if authorizedUsers[auth.Wildcard] == nil {
+						authorizedUsers[auth.Wildcard] = make(map[string]struct{})
+					}
+					authorizedUsers[auth.Wildcard][rule.AuthorizedUser] = struct{}{}
+				default:
+					authorizedUsers[auth.Wildcard] = c.getAllowedUserIDs()
+				}
+			} else if peerInDestinations && PolicyRuleImpliesLegacySSH(rule) && targetPeer.SSHEnabled {
+				sshEnabled = true
+				authorizedUsers[auth.Wildcard] = c.getAllowedUserIDs()
+			}
+		}
+	}
+
+	peers, fwRules := getAccumulatedResources()
+	return peers, fwRules, authorizedUsers, sshEnabled
+}
+
+func (c *NetworkMapComponents) getAllowedUserIDs() map[string]struct{} {
+	if c.AllowedUserIDs != nil {
+		result := make(map[string]struct{}, len(c.AllowedUserIDs))
+		maps.Copy(result, c.AllowedUserIDs)
+		return result
+	}
+	return make(map[string]struct{})
+}
+
+func (c *NetworkMapComponents) connResourcesGenerator(targetPeer *ComponentPeer) (func(*PolicyRule, []*ComponentPeer, int), func() ([]*ComponentPeer, []*FirewallRule)) {
+	rulesExists := make(map[string]struct{})
+	peersExists := make(map[string]struct{})
+	rules := make([]*FirewallRule, 0)
+	peers := make([]*ComponentPeer, 0)
+
+	return func(rule *PolicyRule, groupPeers []*ComponentPeer, direction int) {
+			protocol := rule.Protocol
+			if protocol == PolicyRuleProtocolNetbirdSSH {
+				protocol = PolicyRuleProtocolTCP
+			}
+
+			protocolStr := string(protocol)
+			actionStr := string(rule.Action)
+			dirStr := strconv.Itoa(direction)
+			portsJoined := strings.Join(rule.Ports, ",")
+
+			for _, peer := range groupPeers {
+				if peer == nil {
+					continue
+				}
+
+				if _, ok := peersExists[peer.ID]; !ok {
+					peers = append(peers, peer)
+					peersExists[peer.ID] = struct{}{}
+				}
+
+				peerIP := peer.IP.String()
+
+				fr := FirewallRule{
+					PolicyID:  rule.ID,
+					PeerIP:    peerIP,
+					Direction: direction,
+					Action:    actionStr,
+					Protocol:  protocolStr,
+				}
+
+				ruleID := rule.ID + peerIP + dirStr +
+					protocolStr + actionStr + portsJoined
+				if _, ok := rulesExists[ruleID]; ok {
+					continue
+				}
+				rulesExists[ruleID] = struct{}{}
+
+				if len(rule.Ports) == 0 && len(rule.PortRanges) == 0 {
+					rules = append(rules, &fr)
+				} else {
+					rules = append(rules, ExpandPortsAndRanges(fr, rule, targetPeer)...)
+				}
+
+				rules = AppendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, rule, FirewallRuleContext{
+					Direction:   direction,
+					DirStr:      dirStr,
+					ProtocolStr: protocolStr,
+					ActionStr:   actionStr,
+					PortsJoined: portsJoined,
+				})
+			}
+		}, func() ([]*ComponentPeer, []*FirewallRule) {
+			return peers, rules
+		}
+}
+
+func (c *NetworkMapComponents) getAllPeersFromGroups(groups []string, peerID string, sourcePostureChecksIDs []string) ([]*ComponentPeer, bool) {
+	peerInGroups := false
+	uniquePeerIDs := c.getUniquePeerIDsFromGroupsIDs(groups)
+	filteredPeers := make([]*ComponentPeer, 0, len(uniquePeerIDs))
+
+	for _, p := range uniquePeerIDs {
+		peerInfo := c.GetPeerInfo(p)
+		if peerInfo == nil {
+			continue
+		}
+
+		if _, ok := c.Peers[p]; !ok {
+			continue
+		}
+
+		if !c.ValidatePostureChecksOnPeer(p, sourcePostureChecksIDs) {
+			continue
+		}
+
+		if p == peerID {
+			peerInGroups = true
+			continue
+		}
+
+		filteredPeers = append(filteredPeers, peerInfo)
+	}
+
+	return filteredPeers, peerInGroups
+}
+
+func (c *NetworkMapComponents) getUniquePeerIDsFromGroupsIDs(groups []string) []string {
+	peerIDs := make(map[string]struct{}, len(groups))
+	for _, groupID := range groups {
+		group := c.GetGroupInfo(groupID)
+		if group == nil {
+			continue
+		}
+
+		if group.IsGroupAll() || len(groups) == 1 {
+			return group.Peers
+		}
+
+		for _, peerID := range group.Peers {
+			peerIDs[peerID] = struct{}{}
+		}
+	}
+
+	ids := make([]string, 0, len(peerIDs))
+	for peerID := range peerIDs {
+		ids = append(ids, peerID)
+	}
+
+	return ids
+}
+
+func (c *NetworkMapComponents) getPeerFromResource(resource Resource, peerID string) ([]*ComponentPeer, bool) {
+	if resource.ID == peerID {
+		return []*ComponentPeer{}, true
+	}
+
+	peerInfo := c.GetPeerInfo(resource.ID)
+	if peerInfo == nil {
+		return []*ComponentPeer{}, false
+	}
+
+	return []*ComponentPeer{peerInfo}, false
+}
+
+func (c *NetworkMapComponents) filterPeersByLoginExpiration(aclPeers []*ComponentPeer) ([]*ComponentPeer, []*ComponentPeer) {
+	peersToConnect := make([]*ComponentPeer, 0, len(aclPeers))
+	var expiredPeers []*ComponentPeer
+
+	for _, p := range aclPeers {
+		expired, _ := p.LoginExpired(c.AccountSettings.PeerLoginExpiration)
+		if c.AccountSettings.PeerLoginExpirationEnabled && expired {
+			expiredPeers = append(expiredPeers, p)
+			continue
+		}
+		peersToConnect = append(peersToConnect, p)
+	}
+
+	return peersToConnect, expiredPeers
+}
+
+func (c *NetworkMapComponents) getPeerDNSManagementStatusFromGroups(peerGroups map[string]struct{}) bool {
+	for _, groupID := range c.DNSSettings.DisabledManagementGroups {
+		if _, found := peerGroups[groupID]; found {
+			return false
+		}
+	}
+	return true
+}
+
+func (c *NetworkMapComponents) getPeerNSGroupsFromGroups(peerID string, groupList map[string]struct{}) []*nbdns.NameServerGroup {
+	var peerNSGroups []*nbdns.NameServerGroup
+
+	targetPeerInfo := c.GetPeerInfo(peerID)
+	if targetPeerInfo == nil {
+		return peerNSGroups
+	}
+
+	peerIPStr := targetPeerInfo.IP.String()
+
+	for _, nsGroup := range c.NameServerGroups {
+		if !nsGroup.Enabled {
+			continue
+		}
+		for _, gID := range nsGroup.Groups {
+			if _, found := groupList[gID]; found {
+				if !c.peerIsNameserver(peerIPStr, nsGroup) {
+					peerNSGroups = append(peerNSGroups, nsGroup.Copy())
+				}
+				break
+			}
+		}
+	}
+
+	return peerNSGroups
+}
+
+func (c *NetworkMapComponents) peerIsNameserver(peerIPStr string, nsGroup *nbdns.NameServerGroup) bool {
+	for _, ns := range nsGroup.NameServers {
+		if peerIPStr == ns.IP.String() {
+			return true
+		}
+	}
+	return false
+}
+
+// filterAndExpandRoutes drops v6 routes for non-capable peers and duplicates
+// the default v4 route (0.0.0.0/0) as ::/0 for v6-capable peers.
+// TODO: the "-v6" suffix on IDs could collide with user-supplied route IDs.
+func filterAndExpandRoutes(routes []*route.Route, includeIPv6 bool) []*route.Route {
+	filtered := make([]*route.Route, 0, len(routes))
+	for _, r := range routes {
+		if !includeIPv6 && r.Network.Addr().Is6() {
+			continue
+		}
+		filtered = append(filtered, r)
+
+		if includeIPv6 && r.Network.Bits() == 0 && r.Network.Addr().Is4() {
+			v6 := r.Copy()
+			v6.ID = r.ID + "-v6-default"
+			v6.NetID = r.NetID + "-v6"
+			v6.Network = netip.MustParsePrefix("::/0")
+			v6.NetworkType = route.IPv6Network
+			filtered = append(filtered, v6)
+		}
+	}
+	return filtered
+}
+
+func (c *NetworkMapComponents) getRoutesToSync(peerID string, aclPeers []*ComponentPeer, peerGroups LookupMap) []*route.Route {
+	routes, peerDisabledRoutes := c.getRoutingPeerRoutes(peerID)
+	peerRoutesMembership := make(LookupMap)
+	for _, r := range append(routes, peerDisabledRoutes...) {
+		peerRoutesMembership[string(r.GetHAUniqueID())] = struct{}{}
+	}
+
+	for _, peer := range aclPeers {
+		activeRoutes, _ := c.getRoutingPeerRoutes(peer.ID)
+		groupFilteredRoutes := c.filterRoutesByGroups(activeRoutes, peerGroups)
+		filteredRoutes := c.filterRoutesFromPeersOfSameHAGroup(groupFilteredRoutes, peerRoutesMembership)
+		routes = append(routes, filteredRoutes...)
+	}
+
+	return routes
+}
+
+func (c *NetworkMapComponents) getRoutingPeerRoutes(peerID string) (enabledRoutes []*route.Route, disabledRoutes []*route.Route) {
+	peerInfo := c.GetPeerInfo(peerID)
+	if peerInfo == nil {
+		peerInfo = c.GetRouterPeerInfo(peerID)
+	}
+	if peerInfo == nil {
+		return enabledRoutes, disabledRoutes
+	}
+
+	seenRoute := make(map[route.ID]struct{})
+
+	takeRoute := func(r *route.Route) {
+		if _, ok := seenRoute[r.ID]; ok {
+			return
+		}
+		seenRoute[r.ID] = struct{}{}
+
+		r.Peer = peerInfo.Key
+
+		if r.Enabled {
+			enabledRoutes = append(enabledRoutes, r)
+			return
+		}
+		disabledRoutes = append(disabledRoutes, r)
+	}
+
+	for _, entry := range c.routesByPeer()[peerID] {
+		if entry.viaGroup {
+			newPeerRoute := entry.route.Copy()
+			newPeerRoute.PeerGroups = nil
+			newPeerRoute.ID = route.ID(string(entry.route.ID) + ":" + peerID)
+			takeRoute(newPeerRoute)
+			continue
+		}
+		takeRoute(entry.route.Copy())
+	}
+
+	return enabledRoutes, disabledRoutes
+}
+
+func (c *NetworkMapComponents) routesByPeer() map[string][]routeIndexEntry {
+	c.routesByPeerOnce.Do(func() {
+		idx := make(map[string][]routeIndexEntry)
+		for _, r := range c.Routes {
+			for _, groupID := range r.PeerGroups {
+				group := c.GetGroupInfo(groupID)
+				if group == nil {
+					continue
+				}
+				for _, id := range group.Peers {
+					idx[id] = append(idx[id], routeIndexEntry{route: r, viaGroup: true})
+				}
+			}
+			if r.Peer != "" {
+				idx[r.Peer] = append(idx[r.Peer], routeIndexEntry{route: r})
+			}
+		}
+		c.routesByPeerIdx = idx
+	})
+
+	return c.routesByPeerIdx
+}
+
+func (c *NetworkMapComponents) filterRoutesByGroups(routes []*route.Route, groupListMap LookupMap) []*route.Route {
+	var filteredRoutes []*route.Route
+	for _, r := range routes {
+		for _, groupID := range r.Groups {
+			_, found := groupListMap[groupID]
+			if found {
+				filteredRoutes = append(filteredRoutes, r)
+				break
+			}
+		}
+	}
+	return filteredRoutes
+}
+
+func (c *NetworkMapComponents) filterRoutesFromPeersOfSameHAGroup(routes []*route.Route, peerMemberships LookupMap) []*route.Route {
+	var filteredRoutes []*route.Route
+	for _, r := range routes {
+		_, found := peerMemberships[string(r.GetHAUniqueID())]
+		if !found {
+			filteredRoutes = append(filteredRoutes, r)
+		}
+	}
+	return filteredRoutes
+}
+
+func (c *NetworkMapComponents) getPeerRoutesFirewallRules(ctx context.Context, peerID string, includeIPv6 bool) []*RouteFirewallRule {
+	routesFirewallRules := make([]*RouteFirewallRule, 0)
+
+	enabledRoutes, _ := c.getRoutingPeerRoutes(peerID)
+	for _, r := range enabledRoutes {
+		if len(r.AccessControlGroups) == 0 {
+			defaultPermit := c.getDefaultPermit(r, includeIPv6)
+			routesFirewallRules = append(routesFirewallRules, defaultPermit...)
+			continue
+		}
+
+		distributionPeers := c.getDistributionGroupsPeers(r)
+
+		for _, accessGroup := range r.AccessControlGroups {
+			policies := c.getAllRoutePoliciesFromGroups([]string{accessGroup})
+			rules := c.getRouteFirewallRules(ctx, peerID, policies, r, distributionPeers, includeIPv6)
+			routesFirewallRules = append(routesFirewallRules, rules...)
+		}
+	}
+
+	return routesFirewallRules
+}
+
+func (c *NetworkMapComponents) getDefaultPermit(r *route.Route, includeIPv6 bool) []*RouteFirewallRule {
+	if r.Network.Addr().Is6() && !includeIPv6 {
+		return nil
+	}
+
+	sources := []string{"0.0.0.0/0"}
+	if r.Network.Addr().Is6() {
+		sources = []string{"::/0"}
+	}
+
+	rule := RouteFirewallRule{
+		SourceRanges: sources,
+		Action:       string(PolicyTrafficActionAccept),
+		Destination:  r.Network.String(),
+		Protocol:     string(PolicyRuleProtocolALL),
+		Domains:      r.Domains,
+		IsDynamic:    r.IsDynamic(),
+		RouteID:      r.ID,
+	}
+
+	rules := []*RouteFirewallRule{&rule}
+
+	isDefaultV4 := r.Network.Addr().Is4() && r.Network.Bits() == 0
+	if includeIPv6 && (r.IsDynamic() || isDefaultV4) {
+		ruleV6 := rule
+		ruleV6.SourceRanges = []string{"::/0"}
+		if isDefaultV4 {
+			ruleV6.Destination = "::/0"
+			ruleV6.RouteID = r.ID + "-v6-default"
+		}
+		rules = append(rules, &ruleV6)
+	}
+
+	return rules
+}
+
+func (c *NetworkMapComponents) getDistributionGroupsPeers(r *route.Route) map[string]struct{} {
+	distPeers := make(map[string]struct{})
+	for _, id := range r.Groups {
+		group := c.GetGroupInfo(id)
+		if group == nil {
+			continue
+		}
+
+		for _, pID := range group.Peers {
+			distPeers[pID] = struct{}{}
+		}
+	}
+	return distPeers
+}
+
+func (c *NetworkMapComponents) getAllRoutePoliciesFromGroups(accessControlGroups []string) []*Policy {
+	routePolicies := make([]*Policy, 0)
+	for _, groupID := range accessControlGroups {
+		for _, policy := range c.Policies {
+			for _, rule := range policy.Rules {
+				if slices.Contains(rule.Destinations, groupID) {
+					routePolicies = append(routePolicies, policy)
+				}
+			}
+		}
+	}
+
+	return routePolicies
+}
+
+func (c *NetworkMapComponents) getRouteFirewallRules(ctx context.Context, peerID string, policies []*Policy, route *route.Route, distributionPeers map[string]struct{}, includeIPv6 bool) []*RouteFirewallRule {
+	var fwRules []*RouteFirewallRule
+	for _, policy := range policies {
+		if !policy.Enabled {
+			continue
+		}
+
+		for _, rule := range policy.Rules {
+			if !rule.Enabled {
+				continue
+			}
+
+			rulePeers := c.getRulePeers(rule, policy.SourcePostureChecks, peerID, distributionPeers)
+			rules := GenerateRouteFirewallRules(ctx, route, rule, rulePeers, FirewallRuleDirectionIN, includeIPv6)
+			fwRules = append(fwRules, rules...)
+		}
+	}
+	return fwRules
+}
+
+func (c *NetworkMapComponents) getRulePeers(rule *PolicyRule, postureChecks []string, peerID string, distributionPeers map[string]struct{}) []*ComponentPeer {
+	distPeersWithPolicy := make(map[string]struct{})
+	for _, id := range rule.Sources {
+		group := c.GetGroupInfo(id)
+		if group == nil {
+			continue
+		}
+
+		for _, pID := range group.Peers {
+			if pID == peerID {
+				continue
+			}
+			_, distPeer := distributionPeers[pID]
+			_, valid := c.Peers[pID]
+			if distPeer && valid && c.ValidatePostureChecksOnPeer(pID, postureChecks) {
+				distPeersWithPolicy[pID] = struct{}{}
+			}
+		}
+	}
+	if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" {
+		_, distPeer := distributionPeers[rule.SourceResource.ID]
+		_, valid := c.Peers[rule.SourceResource.ID]
+		if distPeer && valid && c.ValidatePostureChecksOnPeer(rule.SourceResource.ID, postureChecks) {
+			distPeersWithPolicy[rule.SourceResource.ID] = struct{}{}
+		}
+	}
+
+	distributionGroupPeers := make([]*ComponentPeer, 0, len(distPeersWithPolicy))
+	for pID := range distPeersWithPolicy {
+		peerInfo := c.GetPeerInfo(pID)
+		if peerInfo == nil {
+			continue
+		}
+		distributionGroupPeers = append(distributionGroupPeers, peerInfo)
+	}
+	return distributionGroupPeers
+}
+
+func (c *NetworkMapComponents) getNetworkResourcesRoutesToSync(peerID string) (bool, []*route.Route, map[string]struct{}) {
+	var isRoutingPeer bool
+	var routes []*route.Route
+	allSourcePeers := make(map[string]struct{})
+
+	for _, resource := range c.NetworkResources {
+		if !resource.Enabled {
+			continue
+		}
+
+		var addSourcePeers bool
+
+		networkRoutingPeers, exists := c.RoutersMap[resource.NetworkID]
+		if exists {
+			if router, ok := networkRoutingPeers[peerID]; ok {
+				isRoutingPeer, addSourcePeers = true, true
+				routes = append(routes, c.getNetworkResourcesRoutes(resource, peerID, router)...)
+			}
+		}
+
+		newRoutes := c.processResourcePolicies(peerID, resource, networkRoutingPeers, addSourcePeers, allSourcePeers)
+		routes = append(routes, newRoutes...)
+	}
+
+	return isRoutingPeer, routes, allSourcePeers
+}
+
+func (c *NetworkMapComponents) processResourcePolicies(
+	peerID string,
+	resource *ComponentResource,
+	networkRoutingPeers map[string]*ComponentRouter,
+	addSourcePeers bool,
+	allSourcePeers map[string]struct{},
+) []*route.Route {
+	var routes []*route.Route
+
+	for _, policy := range c.ResourcePoliciesMap[resource.ID] {
+		peers := c.getResourcePolicyPeers(policy)
+		if addSourcePeers {
+			for _, pID := range c.getPostureValidPeers(peers, policy.SourcePostureChecks) {
+				allSourcePeers[pID] = struct{}{}
+			}
+			continue
+		}
+
+		if slices.Contains(peers, peerID) && c.ValidatePostureChecksOnPeer(peerID, policy.SourcePostureChecks) {
+			for peerId, router := range networkRoutingPeers {
+				routes = append(routes, c.getNetworkResourcesRoutes(resource, peerId, router)...)
+			}
+			break
+		}
+	}
+
+	return routes
+}
+
+func (c *NetworkMapComponents) getResourcePolicyPeers(policy *Policy) []string {
+	if policy.Rules[0].SourceResource.Type == ResourceTypePeer && policy.Rules[0].SourceResource.ID != "" {
+		return []string{policy.Rules[0].SourceResource.ID}
+	}
+	return c.getUniquePeerIDsFromGroupsIDs(policy.SourceGroups())
+}
+
+func (c *NetworkMapComponents) getNetworkResourcesRoutes(resource *ComponentResource, peerID string, router *ComponentRouter) []*route.Route {
+	resourceAppliedPolicies := c.ResourcePoliciesMap[resource.ID]
+
+	var routes []*route.Route
+	if len(resourceAppliedPolicies) > 0 {
+		peerInfo := c.GetPeerInfo(peerID)
+		if peerInfo != nil {
+			routes = append(routes, c.networkResourceToRoute(resource, peerInfo, router))
+		}
+	}
+
+	return routes
+}
+
+func (c *NetworkMapComponents) networkResourceToRoute(resource *ComponentResource, peer *ComponentPeer, router *ComponentRouter) *route.Route {
+	r := &route.Route{
+		ID:          route.ID(resource.ID + ":" + peer.ID),
+		AccountID:   resource.AccountID,
+		Peer:        peer.Key,
+		PeerID:      peer.ID,
+		Metric:      router.Metric,
+		Masquerade:  router.Masquerade,
+		Enabled:     resource.Enabled,
+		KeepRoute:   true,
+		NetID:       route.NetID(resource.Name),
+		Description: resource.Description,
+	}
+
+	if resource.Type == ComponentResourceHost || resource.Type == ComponentResourceSubnet {
+		r.Network = resource.Prefix
+
+		r.NetworkType = route.IPv4Network
+		if resource.Prefix.Addr().Is6() {
+			r.NetworkType = route.IPv6Network
+		}
+	}
+
+	if resource.Type == ComponentResourceDomain {
+		domainList, err := domain.FromStringList([]string{resource.Domain})
+		if err == nil {
+			r.Domains = domainList
+			r.NetworkType = route.DomainNetwork
+			r.Network = netip.PrefixFrom(netip.AddrFrom4([4]byte{192, 0, 2, 0}), 32)
+		}
+	}
+
+	return r
+}
+
+func (c *NetworkMapComponents) getPostureValidPeers(inputPeers []string, postureChecksIDs []string) []string {
+	var dest []string
+	for _, peerID := range inputPeers {
+		if c.ValidatePostureChecksOnPeer(peerID, postureChecksIDs) {
+			dest = append(dest, peerID)
+		}
+	}
+	return dest
+}
+
+func (c *NetworkMapComponents) getPeerNetworkResourceFirewallRules(ctx context.Context, peerID string, routes []*route.Route, includeIPv6 bool) []*RouteFirewallRule {
+	routesFirewallRules := make([]*RouteFirewallRule, 0)
+
+	peerInfo := c.GetPeerInfo(peerID)
+	if peerInfo == nil {
+		return routesFirewallRules
+	}
+
+	for _, r := range routes {
+		if r.Peer != peerInfo.Key {
+			continue
+		}
+
+		resourceID := string(r.GetResourceID())
+		resourcePolicies := c.ResourcePoliciesMap[resourceID]
+		distributionPeers := c.getPoliciesSourcePeers(resourcePolicies)
+
+		rules := c.getRouteFirewallRules(ctx, peerID, resourcePolicies, r, distributionPeers, includeIPv6)
+		for _, rule := range rules {
+			if len(rule.SourceRanges) > 0 {
+				routesFirewallRules = append(routesFirewallRules, rule)
+			}
+		}
+	}
+
+	return routesFirewallRules
+}
+
+func (c *NetworkMapComponents) getPoliciesSourcePeers(policies []*Policy) map[string]struct{} {
+	sourcePeers := make(map[string]struct{})
+
+	for _, policy := range policies {
+		for _, rule := range policy.Rules {
+			for _, sourceGroup := range rule.Sources {
+				group := c.GetGroupInfo(sourceGroup)
+				if group == nil {
+					continue
+				}
+
+				for _, peer := range group.Peers {
+					sourcePeers[peer] = struct{}{}
+				}
+			}
+
+			if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" {
+				sourcePeers[rule.SourceResource.ID] = struct{}{}
+			}
+		}
+	}
+
+	return sourcePeers
+}
+
+func (c *NetworkMapComponents) addNetworksRoutingPeers(
+	networkResourcesRoutes []*route.Route,
+	peerID string,
+	peersToConnect []*ComponentPeer,
+	expiredPeers []*ComponentPeer,
+	isRouter bool,
+	sourcePeers map[string]struct{},
+) []*ComponentPeer {
+
+	networkRoutesPeers := make(map[string]struct{}, len(networkResourcesRoutes))
+	for _, r := range networkResourcesRoutes {
+		networkRoutesPeers[r.PeerID] = struct{}{}
+	}
+
+	delete(sourcePeers, peerID)
+	delete(networkRoutesPeers, peerID)
+
+	for _, existingPeer := range peersToConnect {
+		delete(sourcePeers, existingPeer.ID)
+		delete(networkRoutesPeers, existingPeer.ID)
+	}
+	for _, expPeer := range expiredPeers {
+		delete(sourcePeers, expPeer.ID)
+		delete(networkRoutesPeers, expPeer.ID)
+	}
+
+	missingPeers := make(map[string]struct{}, len(sourcePeers)+len(networkRoutesPeers))
+	if isRouter {
+		for p := range sourcePeers {
+			missingPeers[p] = struct{}{}
+		}
+	}
+	for p := range networkRoutesPeers {
+		missingPeers[p] = struct{}{}
+	}
+
+	for p := range missingPeers {
+		peerInfo := c.GetPeerInfo(p)
+		if peerInfo == nil {
+			peerInfo = c.GetRouterPeerInfo(p)
+		}
+		if peerInfo != nil {
+			peersToConnect = append(peersToConnect, peerInfo)
+		}
+	}
+
+	return peersToConnect
+}
+
+type FirewallRuleContext struct {
+	Direction   int
+	DirStr      string
+	ProtocolStr string
+	ActionStr   string
+	PortsJoined string
+}
+
+func AppendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *ComponentPeer, rule *PolicyRule, rc FirewallRuleContext) []*FirewallRule {
+	if !peer.IPv6.IsValid() || !targetPeer.SupportsIPv6 || !targetPeer.IPv6.IsValid() {
+		return rules
+	}
+
+	v6IP := peer.IPv6.String()
+	v6RuleID := rule.ID + v6IP + rc.DirStr + rc.ProtocolStr + rc.ActionStr + rc.PortsJoined
+	if _, ok := rulesExists[v6RuleID]; ok {
+		return rules
+	}
+	rulesExists[v6RuleID] = struct{}{}
+
+	v6fr := FirewallRule{
+		PolicyID:  rule.ID,
+		PeerIP:    v6IP,
+		Direction: rc.Direction,
+		Action:    rc.ActionStr,
+		Protocol:  rc.ProtocolStr,
+	}
+	if len(rule.Ports) == 0 && len(rule.PortRanges) == 0 {
+		return append(rules, &v6fr)
+	}
+	return append(rules, ExpandPortsAndRanges(v6fr, rule, targetPeer)...)
+}
diff --git a/management/server/types/legacynmap/proto_legacy.go b/management/server/types/legacynmap/proto_legacy.go
new file mode 100644
index 000000000..74451b268
--- /dev/null
+++ b/management/server/types/legacynmap/proto_legacy.go
@@ -0,0 +1,220 @@
+package legacynmap
+
+import (
+	"context"
+	"fmt"
+	"net/netip"
+	"net/url"
+	"strings"
+
+	"github.com/netbirdio/netbird/client/ssh/auth"
+	nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
+	nbpeer "github.com/netbirdio/netbird/management/server/peer"
+	"github.com/netbirdio/netbird/management/server/types"
+	nbroute "github.com/netbirdio/netbird/route"
+	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/proto"
+	"github.com/netbirdio/netbird/shared/netiputil"
+)
+
+func ToProtocolRoutes(routes []*nbroute.Route) []*proto.Route {
+	protoRoutes := make([]*proto.Route, 0, len(routes))
+	for _, r := range routes {
+		protoRoutes = append(protoRoutes, ToProtocolRoute(r))
+	}
+	return protoRoutes
+}
+
+func ToProtocolRoute(route *nbroute.Route) *proto.Route {
+	return &proto.Route{
+		ID:            string(route.ID),
+		NetID:         string(route.NetID),
+		Network:       route.Network.String(),
+		Domains:       route.Domains.ToPunycodeList(),
+		NetworkType:   int64(route.NetworkType),
+		Peer:          route.Peer,
+		Metric:        int64(route.Metric),
+		Masquerade:    route.Masquerade,
+		KeepRoute:     route.KeepRoute,
+		SkipAutoApply: route.SkipAutoApply,
+	}
+}
+
+func AppendRemotePeerConfig(dst []*proto.RemotePeerConfig, peers []*ComponentPeer, dnsName string, includeIPv6 bool, localIsProxy bool) []*proto.RemotePeerConfig {
+	for _, rPeer := range peers {
+		allowedIPs := []string{rPeer.IP.String() + "/32"}
+		if includeIPv6 && rPeer.IPv6.IsValid() {
+			allowedIPs = append(allowedIPs, rPeer.IPv6.String()+"/128")
+		}
+		dst = append(dst, &proto.RemotePeerConfig{
+			WgPubKey:     rPeer.Key,
+			AllowedIps:   allowedIPs,
+			SshConfig:    &proto.SSHConfig{SshPubKey: []byte(rPeer.SSHKey)},
+			Fqdn:         rPeer.FQDN(dnsName),
+			AgentVersion: rPeer.AgentVersion,
+			LazyState:    lazyStateFor(localIsProxy, rPeer),
+		})
+	}
+	return dst
+}
+
+// lazyStateFor returns the per-peer lazy override for a remote peer. Connections
+// involving an ephemeral proxy peer on either endpoint default to lazy so shared
+// proxy infrastructure is not kept permanently connected to every peer. All
+// other peers follow the account-wide flag. A future admin-facing per-peer
+// setting can return LazyStateEager here to force a peer always-active.
+func lazyStateFor(localIsProxy bool, rPeer *ComponentPeer) proto.LazyState {
+	if localIsProxy || rPeer.ProxyEmbedded {
+		return proto.LazyState_LazyStateLazy
+	}
+	return proto.LazyState_LazyStateDefault
+}
+
+func buildJWTConfig(config *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow) *proto.JWTConfig {
+	if config == nil || config.AuthAudience == "" {
+		return nil
+	}
+
+	issuer := strings.TrimSpace(config.AuthIssuer)
+	if issuer == "" && deviceFlowConfig != nil {
+		if d := deriveIssuerFromTokenEndpoint(deviceFlowConfig.ProviderConfig.TokenEndpoint); d != "" {
+			issuer = d
+		}
+	}
+	if issuer == "" {
+		return nil
+	}
+
+	keysLocation := strings.TrimSpace(config.AuthKeysLocation)
+	if keysLocation == "" {
+		keysLocation = strings.TrimSuffix(issuer, "/") + "/.well-known/jwks.json"
+	}
+
+	audience := config.AuthAudience
+	if config.CLIAuthAudience != "" {
+		audience = config.CLIAuthAudience
+	}
+
+	audiences := []string{config.AuthAudience}
+	if config.CLIAuthAudience != "" && config.CLIAuthAudience != config.AuthAudience {
+		audiences = append(audiences, config.CLIAuthAudience)
+	}
+
+	return &proto.JWTConfig{
+		Issuer:       issuer,
+		Audience:     audience, //nolint:staticcheck
+		Audiences:    audiences,
+		KeysLocation: keysLocation,
+	}
+}
+
+func toPeerConfig(peer *nbpeer.Peer, network *Network, dnsName string, settings *types.Settings, httpConfig *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, enableSSH bool, forceRoutingPeerDNS bool) *proto.PeerConfig {
+	netmask, _ := network.Net.Mask.Size()
+	fqdn := peer.FQDN(dnsName)
+
+	sshConfig := &proto.SSHConfig{
+		SshEnabled: peer.SSHEnabled || enableSSH,
+	}
+
+	if sshConfig.SshEnabled {
+		sshConfig.JwtConfig = buildJWTConfig(httpConfig, deviceFlowConfig)
+	}
+
+	peerConfig := &proto.PeerConfig{
+		Address:                         fmt.Sprintf("%s/%d", peer.IP.String(), netmask),
+		SshConfig:                       sshConfig,
+		Fqdn:                            fqdn,
+		RoutingPeerDnsResolutionEnabled: settings.RoutingPeerDNSResolutionEnabled || peer.ProxyMeta.Embedded || forceRoutingPeerDNS,
+		LazyConnectionEnabled:           settings.LazyConnectionEnabled,
+		AutoUpdate: &proto.AutoUpdateSettings{
+			Version:      settings.AutoUpdateVersion,
+			AlwaysUpdate: settings.AutoUpdateAlways,
+		},
+	}
+
+	if peer.SupportsIPv6() && peer.IPv6.IsValid() && network.NetV6.IP != nil {
+		ones, _ := network.NetV6.Mask.Size()
+		v6Prefix := netip.PrefixFrom(peer.IPv6.Unmap(), ones)
+		if b, err := netiputil.EncodePrefix(v6Prefix); err == nil {
+			peerConfig.AddressV6 = b
+		}
+	}
+
+	return peerConfig
+}
+
+// ToProtoNetworkMap mirrors main's ToSyncResponse, restricted to the
+// proto.NetworkMap it produces. SyncResponse-level fields (NetbirdConfig,
+// Checks, the deprecated top-level RemotePeers) are omitted — they are not part
+// of the equivalence surface. PeerConfig is included because proto.NetworkMap
+// carries it, and it is where main's ForceRoutingPeerDNSResolution surfaces.
+func ToProtoNetworkMap(
+	ctx context.Context,
+	peer *nbpeer.Peer,
+	nm *NetworkMap,
+	dnsName string,
+	settings *types.Settings,
+	httpConfig *nbconfig.HttpServerConfig,
+	dnsCache networkmap.DNSConfigCache,
+	dnsFwdPort int64,
+) *proto.NetworkMap {
+	includeIPv6 := peer.SupportsIPv6() && peer.IPv6.IsValid()
+	useSourcePrefixes := peer.SupportsSourcePrefixes()
+	localIsProxy := peer.ProxyMeta.Embedded
+
+	peerConfig := toPeerConfig(peer, nm.Network, dnsName, settings, httpConfig, nil, nm.EnableSSH, nm.ForceRoutingPeerDNSResolution)
+
+	pm := &proto.NetworkMap{
+		Serial:     nm.Network.CurrentSerial(),
+		Routes:     ToProtocolRoutes(nm.Routes),
+		DNSConfig:  networkmap.ToProtocolDNSConfig(nm.DNSConfig, dnsCache, dnsFwdPort),
+		PeerConfig: peerConfig,
+	}
+
+	remotePeers := make([]*proto.RemotePeerConfig, 0, len(nm.Peers)+len(nm.OfflinePeers))
+	remotePeers = AppendRemotePeerConfig(remotePeers, nm.Peers, dnsName, includeIPv6, localIsProxy)
+	pm.RemotePeers = remotePeers
+	pm.RemotePeersIsEmpty = len(remotePeers) == 0
+
+	pm.OfflinePeers = AppendRemotePeerConfig(nil, nm.OfflinePeers, dnsName, includeIPv6, localIsProxy)
+
+	firewallRules := networkmap.ToProtocolFirewallRules(nm.FirewallRules, includeIPv6, useSourcePrefixes)
+	pm.FirewallRules = firewallRules
+	pm.FirewallRulesIsEmpty = len(firewallRules) == 0
+
+	routesFirewallRules := networkmap.ToProtocolRoutesFirewallRules(nm.RoutesFirewallRules)
+	pm.RoutesFirewallRules = routesFirewallRules
+	pm.RoutesFirewallRulesIsEmpty = len(routesFirewallRules) == 0
+
+	if nm.ForwardingRules != nil {
+		forwardingRules := make([]*proto.ForwardingRule, 0, len(nm.ForwardingRules))
+		for _, rule := range nm.ForwardingRules {
+			forwardingRules = append(forwardingRules, rule.ToProto())
+		}
+		pm.ForwardingRules = forwardingRules
+	}
+
+	if nm.AuthorizedUsers != nil {
+		hashedUsers, machineUsers := networkmap.BuildAuthorizedUsersProto(ctx, nm.AuthorizedUsers)
+		userIDClaim := auth.DefaultUserIDClaim
+		if httpConfig != nil && httpConfig.AuthUserIDClaim != "" {
+			userIDClaim = httpConfig.AuthUserIDClaim
+		}
+		pm.SshAuth = &proto.SSHAuth{AuthorizedUsers: hashedUsers, MachineUsers: machineUsers, UserIDClaim: userIDClaim}
+	}
+
+	return pm
+}
+
+func deriveIssuerFromTokenEndpoint(tokenEndpoint string) string {
+	if tokenEndpoint == "" {
+		return ""
+	}
+
+	u, err := url.Parse(tokenEndpoint)
+	if err != nil {
+		return ""
+	}
+
+	return fmt.Sprintf("%s://%s/", u.Scheme, u.Host)
+}
diff --git a/management/server/types/legacynmap/proxy_policies.go b/management/server/types/legacynmap/proxy_policies.go
new file mode 100644
index 000000000..e8f8c3969
--- /dev/null
+++ b/management/server/types/legacynmap/proxy_policies.go
@@ -0,0 +1,150 @@
+package legacynmap
+
+import (
+	"fmt"
+
+	"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
+	nbpeer "github.com/netbirdio/netbird/management/server/peer"
+	sharedtypes "github.com/netbirdio/netbird/shared/management/types"
+)
+
+// SynthesizeProxyPolicies is main's Account.InjectProxyPolicies, frozen. On
+// main the network-map controller called it on the account before computing,
+// so a comparison that starts from the account has to apply it too. It returns
+// the policies instead of appending them, so the caller can measure the legacy
+// path without mutating the account the other paths share.
+func SynthesizeProxyPolicies(a *Account) []*Policy {
+	if len(a.Services) == 0 {
+		return nil
+	}
+
+	proxyPeersByCluster := a.GetProxyPeers()
+	if len(proxyPeersByCluster) == 0 {
+		return nil
+	}
+
+	var out []*Policy
+	for _, svc := range a.Services {
+		if svc == nil || !svc.Enabled {
+			continue
+		}
+
+		proxyPeers := proxyPeersByCluster[svc.ProxyCluster]
+		for _, target := range svc.Targets {
+			if target == nil || !target.Enabled {
+				continue
+			}
+			port, ok := legacyTargetPort(target)
+			if !ok {
+				continue
+			}
+			path := ""
+			if target.Path != nil {
+				path = *target.Path
+			}
+			for _, proxyPeer := range proxyPeers {
+				out = append(out, legacyProxyPolicy(svc, target, proxyPeer, port, path))
+			}
+		}
+
+		out = append(out, legacyPrivateServicePolicies(a, svc, proxyPeers)...)
+	}
+	return out
+}
+
+func legacyPrivateServicePolicies(a *Account, svc *service.Service, proxyPeers []*nbpeer.Peer) []*Policy {
+	if !svc.Private || len(svc.AccessGroups) == 0 || len(proxyPeers) == 0 {
+		return nil
+	}
+
+	sources := make([]string, 0, len(svc.AccessGroups))
+	for _, groupID := range svc.AccessGroups {
+		if _, ok := a.Groups[groupID]; ok {
+			sources = append(sources, groupID)
+		}
+	}
+	if len(sources) == 0 {
+		return nil
+	}
+
+	out := make([]*Policy, 0, len(proxyPeers))
+	for _, proxyPeer := range proxyPeers {
+		policyID := fmt.Sprintf("private-access-%s-%s", svc.ID, proxyPeer.ID)
+		out = append(out, &Policy{
+			ID:      policyID,
+			Name:    fmt.Sprintf("Private Access to %s", svc.Name),
+			Enabled: true,
+			Rules: []*PolicyRule{
+				{
+					ID:       policyID,
+					PolicyID: policyID,
+					Name:     fmt.Sprintf("Allow access groups to reach %s", svc.Name),
+					Enabled:  true,
+					Sources:  append([]string(nil), sources...),
+					DestinationResource: Resource{
+						ID:   proxyPeer.ID,
+						Type: ResourceTypePeer,
+					},
+					Bidirectional: false,
+					Protocol:      PolicyRuleProtocolTCP,
+					Action:        PolicyTrafficActionAccept,
+					PortRanges: []RulePortRange{
+						{Start: 80, End: 80},
+						{Start: 443, End: 443},
+					},
+				},
+			},
+		})
+	}
+	return out
+}
+
+func legacyProxyPolicy(svc *service.Service, target *service.Target, proxyPeer *nbpeer.Peer, port uint16, path string) *Policy {
+	policyID := fmt.Sprintf("proxy-access-%s-%s-%s", svc.ID, proxyPeer.ID, path)
+
+	protocol := PolicyRuleProtocolTCP
+	if svc.Mode == service.ModeUDP {
+		protocol = sharedtypes.PolicyRuleProtocolUDP
+	}
+
+	return &Policy{
+		ID:      policyID,
+		Name:    fmt.Sprintf("Proxy Access to %s", svc.Name),
+		Enabled: true,
+		Rules: []*PolicyRule{
+			{
+				ID:       policyID,
+				PolicyID: policyID,
+				Name:     fmt.Sprintf("Allow access to %s", svc.Name),
+				Enabled:  true,
+				SourceResource: Resource{
+					ID:   proxyPeer.ID,
+					Type: ResourceTypePeer,
+				},
+				DestinationResource: Resource{
+					ID:   target.TargetId,
+					Type: sharedtypes.ResourceType(target.TargetType),
+				},
+				Bidirectional: false,
+				Protocol:      protocol,
+				Action:        PolicyTrafficActionAccept,
+				PortRanges:    []RulePortRange{{Start: port, End: port}},
+			},
+		},
+	}
+}
+
+func legacyTargetPort(target *service.Target) (uint16, bool) {
+	if target.Port != 0 {
+		return target.Port, true
+	}
+
+	switch target.Protocol {
+	case "https", "tls":
+		return 443, true
+	case "http":
+		return 80, true
+	default:
+		return 0, false
+	}
+}
diff --git a/management/server/types/network.go b/management/server/types/network.go
new file mode 100644
index 000000000..72ca1af85
--- /dev/null
+++ b/management/server/types/network.go
@@ -0,0 +1,271 @@
+package types
+
+import (
+	"encoding/binary"
+	"fmt"
+	"math/rand"
+	"net"
+	"net/netip"
+	"slices"
+	"sync"
+	"time"
+
+	"github.com/c-robinson/iplib"
+	"github.com/rs/xid"
+
+	"github.com/netbirdio/netbird/shared/management/status"
+)
+
+const (
+	// SubnetSize is a size of the subnet of the global network, e.g.  100.77.0.0/16
+	SubnetSize = 16
+	// NetSize is a global network size 100.64.0.0/10
+	NetSize = 10
+
+	// IPv6SubnetSize is the prefix length of per-account IPv6 subnets.
+	// Each account gets a /64 from its unique /48 ULA prefix.
+	IPv6SubnetSize = 64
+)
+
+type Network struct {
+	Identifier string    `json:"id"`
+	Net        net.IPNet `gorm:"serializer:json"`
+	// NetV6 is the IPv6 ULA subnet for this account's overlay. Empty if not yet allocated.
+	NetV6 net.IPNet `gorm:"serializer:json"`
+	Dns   string
+	// Serial is an ID that increments by 1 when any change to the network happened (e.g. new peer has been added).
+	// Used to synchronize state to the client apps.
+	Serial uint64
+
+	Mu sync.Mutex `json:"-" gorm:"-"`
+}
+
+// NewNetwork creates a new Network initializing it with a Serial=0
+// It takes a random /16 subnet from 100.64.0.0/10 (64 different subnets)
+// and a random /64 subnet from fd00:4e42::/32 for IPv6.
+func NewNetwork() *Network {
+	n := iplib.NewNet4(net.ParseIP("100.64.0.0"), NetSize)
+	sub, _ := n.Subnet(SubnetSize)
+
+	s := rand.NewSource(time.Now().UnixNano())
+	r := rand.New(s)
+	intn := r.Intn(len(sub))
+
+	return &Network{
+		Identifier: xid.New().String(),
+		Net:        sub[intn].IPNet,
+		NetV6:      AllocateIPv6Subnet(r),
+		Dns:        "",
+		Serial:     0,
+	}
+}
+
+// AllocateIPv6Subnet generates a random RFC 4193 ULA /64 prefix.
+// The format follows RFC 4193 section 3.1: fd + 40-bit Global ID + 16-bit Subnet ID.
+// The Global ID and Subnet ID are randomized (simplified from the SHA-1 algorithm
+// in section 3.2.2), giving 2^56 possible /64 subnets across all accounts.
+func AllocateIPv6Subnet(r *rand.Rand) net.IPNet {
+	ip := make(net.IP, 16)
+	ip[0] = 0xfd
+	// Bytes 1-5: 40-bit random Global ID
+	ip[1] = byte(r.Intn(256))
+	ip[2] = byte(r.Intn(256))
+	ip[3] = byte(r.Intn(256))
+	ip[4] = byte(r.Intn(256))
+	ip[5] = byte(r.Intn(256))
+	// Bytes 6-7: 16-bit random Subnet ID
+	ip[6] = byte(r.Intn(256))
+	ip[7] = byte(r.Intn(256))
+
+	return net.IPNet{
+		IP:   ip,
+		Mask: net.CIDRMask(IPv6SubnetSize, 128),
+	}
+}
+
+// IncSerial increments Serial by 1 reflecting that the network state has been changed
+func (n *Network) IncSerial() {
+	n.Mu.Lock()
+	defer n.Mu.Unlock()
+	n.Serial++
+}
+
+// CurrentSerial returns the Network.Serial of the network (latest state id)
+func (n *Network) CurrentSerial() uint64 {
+	n.Mu.Lock()
+	defer n.Mu.Unlock()
+	return n.Serial
+}
+
+func (n *Network) Copy() *Network {
+	n.Mu.Lock()
+	defer n.Mu.Unlock()
+	return &Network{
+		Identifier: n.Identifier,
+		Net:        n.Net,
+		NetV6:      n.NetV6,
+		Dns:        n.Dns,
+		Serial:     n.Serial,
+	}
+}
+
+// AllocatePeerIP picks an available IP from a netip.Prefix.
+// This method considers already taken IPs and reuses IPs if there are gaps in takenIps.
+// E.g. if prefix=100.30.0.0/16 and takenIps=[100.30.0.1, 100.30.0.4] then the result would be 100.30.0.2 or 100.30.0.3.
+func AllocatePeerIP(prefix netip.Prefix, takenIps []netip.Addr) (netip.Addr, error) {
+	b := prefix.Masked().Addr().As4()
+	baseIP := binary.BigEndian.Uint32(b[:])
+	hostBits := 32 - prefix.Bits()
+	totalIPs := uint32(1 << hostBits)
+
+	taken := make(map[uint32]struct{}, len(takenIps)+1)
+	taken[baseIP] = struct{}{}            // reserve network IP
+	taken[baseIP+totalIPs-1] = struct{}{} // reserve broadcast IP
+
+	for _, ip := range takenIps {
+		ab := ip.As4()
+		taken[binary.BigEndian.Uint32(ab[:])] = struct{}{}
+	}
+
+	rng := rand.New(rand.NewSource(time.Now().UnixNano()))
+	maxAttempts := (int(totalIPs) - len(taken)) / 100
+
+	for i := 0; i < maxAttempts; i++ {
+		offset := uint32(rng.Intn(int(totalIPs-2))) + 1
+		candidate := baseIP + offset
+		if _, exists := taken[candidate]; !exists {
+			return uint32ToIP(candidate), nil
+		}
+	}
+
+	for offset := uint32(1); offset < totalIPs-1; offset++ {
+		candidate := baseIP + offset
+		if _, exists := taken[candidate]; !exists {
+			return uint32ToIP(candidate), nil
+		}
+	}
+
+	return netip.Addr{}, status.Errorf(status.PreconditionFailed, "network %s is out of IPs", prefix.String())
+}
+
+// AllocateRandomPeerIP picks a random available IP from a netip.Prefix.
+func AllocateRandomPeerIP(prefix netip.Prefix) (netip.Addr, error) {
+	b := prefix.Masked().Addr().As4()
+	baseIP := binary.BigEndian.Uint32(b[:])
+	hostBits := 32 - prefix.Bits()
+	totalIPs := uint32(1 << hostBits)
+
+	rng := rand.New(rand.NewSource(time.Now().UnixNano()))
+	offset := uint32(rng.Intn(int(totalIPs-2))) + 1
+
+	candidate := baseIP + offset
+	return uint32ToIP(candidate), nil
+}
+
+// AllocateRandomPeerIPv6 picks a random host address within the given IPv6 prefix.
+// Only the host bits (after the prefix length) are randomized.
+func AllocateRandomPeerIPv6(prefix netip.Prefix) (netip.Addr, error) {
+	ones := prefix.Bits()
+	if ones == 0 || ones > 126 || !prefix.Addr().Is6() {
+		return netip.Addr{}, fmt.Errorf("invalid IPv6 subnet: %s", prefix.String())
+	}
+
+	ip := prefix.Addr().As16()
+
+	rng := rand.New(rand.NewSource(time.Now().UnixNano()))
+
+	// Determine which byte the host bits start in
+	firstHostByte := ones / 8
+	// If the prefix doesn't end on a byte boundary, handle the partial byte
+	partialBits := ones % 8
+
+	if partialBits > 0 {
+		// Keep the network bits in the partial byte, randomize the rest
+		hostMask := byte(0xff >> partialBits)
+		ip[firstHostByte] = (ip[firstHostByte] & ^hostMask) | (byte(rng.Intn(256)) & hostMask)
+		firstHostByte++
+	}
+
+	// Randomize remaining full host bytes
+	for i := firstHostByte; i < 16; i++ {
+		ip[i] = byte(rng.Intn(256))
+	}
+
+	// Avoid all-zeros and all-ones host parts by checking only host bits.
+	if isHostAllZeroOrOnes(ip[:], ones) {
+		ip = prefix.Masked().Addr().As16()
+		ip[15] |= 0x01
+	}
+
+	return netip.AddrFrom16(ip).Unmap(), nil
+}
+
+// isHostAllZeroOrOnes checks whether all host bits (after prefixLen) are zero or all ones.
+func isHostAllZeroOrOnes(ip []byte, prefixLen int) bool {
+	hostStart := prefixLen / 8
+	partialBits := prefixLen % 8
+
+	hostSlice := slices.Clone(ip[hostStart:])
+	if partialBits > 0 {
+		hostSlice[0] &= 0xff >> partialBits
+	}
+
+	allZero := !slices.ContainsFunc(hostSlice, func(v byte) bool { return v != 0 })
+	if allZero {
+		return true
+	}
+
+	// Build the all-ones mask for host bits
+	onesMask := make([]byte, len(hostSlice))
+	for i := range onesMask {
+		onesMask[i] = 0xff
+	}
+	if partialBits > 0 {
+		onesMask[0] = 0xff >> partialBits
+	}
+
+	return slices.Equal(hostSlice, onesMask)
+}
+
+func uint32ToIP(n uint32) netip.Addr {
+	var b [4]byte
+	binary.BigEndian.PutUint32(b[:], n)
+	return netip.AddrFrom4(b)
+}
+
+// generateIPs generates a list of all possible IPs of the given network excluding IPs specified in the exclusion list
+func generateIPs(ipNet *net.IPNet, exclusions map[string]struct{}) ([]net.IP, int) {
+
+	var ips []net.IP
+	for ip := ipNet.IP.Mask(ipNet.Mask); ipNet.Contains(ip); incIP(ip) {
+		if _, ok := exclusions[ip.String()]; !ok && ip[3] != 0 {
+			ips = append(ips, copyIP(ip))
+		}
+	}
+
+	// remove network address, broadcast and Fake DNS resolver address
+	lenIPs := len(ips)
+	switch {
+	case lenIPs < 2:
+		return ips, lenIPs
+	case lenIPs < 3:
+		return ips[1 : len(ips)-1], lenIPs - 2
+	default:
+		return ips[1 : len(ips)-2], lenIPs - 3
+	}
+}
+
+func copyIP(ip net.IP) net.IP {
+	dup := make(net.IP, len(ip))
+	copy(dup, ip)
+	return dup
+}
+
+func incIP(ip net.IP) {
+	for j := len(ip) - 1; j >= 0; j-- {
+		ip[j]++
+		if ip[j] > 0 {
+			break
+		}
+	}
+}
diff --git a/management/server/types/network_test.go b/management/server/types/network_test.go
new file mode 100644
index 000000000..d8a06dbbc
--- /dev/null
+++ b/management/server/types/network_test.go
@@ -0,0 +1,264 @@
+package types
+
+import (
+	"encoding/binary"
+	"net"
+	"net/netip"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestNewNetwork(t *testing.T) {
+	network := NewNetwork()
+
+	// generated net should be a subnet of a larger 100.64.0.0/10 net
+	ipNet := net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.IPMask{255, 192, 0, 0}}
+	assert.Equal(t, ipNet.Contains(network.Net.IP), true)
+}
+
+func TestAllocatePeerIP(t *testing.T) {
+	prefix := netip.MustParsePrefix("100.64.0.0/24")
+	var ips []netip.Addr
+	for i := 0; i < 252; i++ {
+		ip, err := AllocatePeerIP(prefix, ips)
+		if err != nil {
+			t.Fatal(err)
+		}
+		ips = append(ips, ip)
+	}
+
+	assert.Len(t, ips, 252)
+
+	uniq := make(map[string]struct{})
+	for _, ip := range ips {
+		if _, ok := uniq[ip.String()]; !ok {
+			uniq[ip.String()] = struct{}{}
+		} else {
+			t.Errorf("found duplicate IP %s", ip.String())
+		}
+	}
+}
+
+func TestAllocatePeerIPSmallSubnet(t *testing.T) {
+	// Test /27 network (10.0.0.0/27) - should only have 30 usable IPs (10.0.0.1 to 10.0.0.30)
+	prefix := netip.MustParsePrefix("10.0.0.0/27")
+	var ips []netip.Addr
+
+	// Allocate all available IPs in the /27 network
+	for i := 0; i < 30; i++ {
+		ip, err := AllocatePeerIP(prefix, ips)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		// Verify IP is within the correct range
+		if !prefix.Contains(ip) {
+			t.Errorf("allocated IP %s is not within network %s", ip.String(), prefix.String())
+		}
+
+		ips = append(ips, ip)
+	}
+
+	assert.Len(t, ips, 30)
+
+	// Verify all IPs are unique
+	uniq := make(map[string]struct{})
+	for _, ip := range ips {
+		if _, ok := uniq[ip.String()]; !ok {
+			uniq[ip.String()] = struct{}{}
+		} else {
+			t.Errorf("found duplicate IP %s", ip.String())
+		}
+	}
+
+	// Try to allocate one more IP - should fail as network is full
+	_, err := AllocatePeerIP(prefix, ips)
+	if err == nil {
+		t.Error("expected error when network is full, but got none")
+	}
+}
+
+func TestAllocatePeerIPVariousCIDRs(t *testing.T) {
+	testCases := []struct {
+		name           string
+		cidr           string
+		expectedUsable int
+	}{
+		{"/30 network", "192.168.1.0/30", 2},   // 4 total - 2 reserved = 2 usable
+		{"/29 network", "192.168.1.0/29", 6},   // 8 total - 2 reserved = 6 usable
+		{"/28 network", "192.168.1.0/28", 14},  // 16 total - 2 reserved = 14 usable
+		{"/27 network", "192.168.1.0/27", 30},  // 32 total - 2 reserved = 30 usable
+		{"/26 network", "192.168.1.0/26", 62},  // 64 total - 2 reserved = 62 usable
+		{"/25 network", "192.168.1.0/25", 126}, // 128 total - 2 reserved = 126 usable
+		{"/16 network", "10.0.0.0/16", 65534},  // 65536 total - 2 reserved = 65534 usable
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			prefix, err := netip.ParsePrefix(tc.cidr)
+			require.NoError(t, err)
+			prefix = prefix.Masked()
+
+			var ips []netip.Addr
+
+			// For larger networks, test only a subset to avoid long test runs
+			testCount := tc.expectedUsable
+			if testCount > 1000 {
+				testCount = 1000
+			}
+
+			// Allocate IPs and verify they're within the correct range
+			for i := 0; i < testCount; i++ {
+				ip, err := AllocatePeerIP(prefix, ips)
+				require.NoError(t, err, "failed to allocate IP %d", i)
+
+				// Verify IP is within the correct range
+				assert.True(t, prefix.Contains(ip), "allocated IP %s is not within network %s", ip.String(), prefix.String())
+
+				// Verify IP is not network or broadcast address
+				networkAddr := prefix.Masked().Addr()
+				hostBits := 32 - prefix.Bits()
+				b := networkAddr.As4()
+				baseIP := binary.BigEndian.Uint32(b[:])
+				broadcastIP := uint32ToIP(baseIP + (1 << hostBits) - 1)
+
+				assert.NotEqual(t, networkAddr, ip, "allocated network address %s", ip.String())
+				assert.NotEqual(t, broadcastIP, ip, "allocated broadcast address %s", ip.String())
+
+				ips = append(ips, ip)
+			}
+
+			assert.Len(t, ips, testCount)
+
+			// Verify all IPs are unique
+			uniq := make(map[string]struct{})
+			for _, ip := range ips {
+				ipStr := ip.String()
+				assert.NotContains(t, uniq, ipStr, "found duplicate IP %s", ipStr)
+				uniq[ipStr] = struct{}{}
+			}
+		})
+	}
+}
+
+func TestGenerateIPs(t *testing.T) {
+	ipNet := net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.IPMask{255, 255, 255, 0}}
+	ips, ipsLen := generateIPs(&ipNet, map[string]struct{}{"100.64.0.0": {}})
+	if ipsLen != 252 {
+		t.Errorf("expected 252 ips, got %d", len(ips))
+		return
+	}
+	if ips[len(ips)-1].String() != "100.64.0.253" {
+		t.Errorf("expected last ip to be: 100.64.0.253, got %s", ips[len(ips)-1].String())
+	}
+}
+
+func TestNewNetworkHasIPv6(t *testing.T) {
+	network := NewNetwork()
+
+	assert.NotNil(t, network.NetV6.IP, "v6 subnet should be allocated")
+	assert.True(t, network.NetV6.IP.To4() == nil, "v6 subnet should be IPv6")
+	assert.Equal(t, byte(0xfd), network.NetV6.IP[0], "v6 subnet should be ULA (fd prefix)")
+
+	ones, bits := network.NetV6.Mask.Size()
+	assert.Equal(t, 64, ones, "v6 subnet should be /64")
+	assert.Equal(t, 128, bits)
+}
+
+func TestAllocateIPv6SubnetUniqueness(t *testing.T) {
+	seen := make(map[string]struct{})
+	for i := 0; i < 100; i++ {
+		network := NewNetwork()
+		key := network.NetV6.IP.String()
+		_, duplicate := seen[key]
+		assert.False(t, duplicate, "duplicate v6 subnet: %s", key)
+		seen[key] = struct{}{}
+	}
+}
+
+func TestAllocateRandomPeerIPv6(t *testing.T) {
+	prefix := netip.MustParsePrefix("fd12:3456:7890:abcd::/64")
+
+	ip, err := AllocateRandomPeerIPv6(prefix)
+	require.NoError(t, err)
+
+	assert.True(t, ip.Is6(), "should be IPv6")
+	assert.True(t, prefix.Contains(ip), "should be within subnet")
+	// First 8 bytes (network prefix) should match
+	b := ip.As16()
+	prefixBytes := prefix.Addr().As16()
+	assert.Equal(t, prefixBytes[:8], b[:8], "prefix should match")
+	// Interface ID should not be all zeros
+	allZero := true
+	for _, v := range b[8:] {
+		if v != 0 {
+			allZero = false
+			break
+		}
+	}
+	assert.False(t, allZero, "interface ID should not be all zeros")
+}
+
+func TestAllocateRandomPeerIPv6_VariousPrefixes(t *testing.T) {
+	tests := []struct {
+		name   string
+		cidr   string
+		prefix int
+	}{
+		{"standard /64", "fd00:1234:5678:abcd::/64", 64},
+		{"small /112", "fd00:1234:5678:abcd::/112", 112},
+		{"large /48", "fd00:1234::/48", 48},
+		{"non-boundary /60", "fd00:1234:5670::/60", 60},
+		{"non-boundary /52", "fd00:1230::/52", 52},
+		{"minimum /120", "fd00:1234:5678:abcd::100/120", 120},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			prefix, err := netip.ParsePrefix(tt.cidr)
+			require.NoError(t, err)
+			prefix = prefix.Masked()
+
+			assert.Equal(t, tt.prefix, prefix.Bits())
+
+			for i := 0; i < 50; i++ {
+				ip, err := AllocateRandomPeerIPv6(prefix)
+				require.NoError(t, err)
+				assert.True(t, prefix.Contains(ip), "IP %s should be within %s", ip, prefix)
+			}
+		})
+	}
+}
+
+func TestAllocateRandomPeerIPv6_PreservesNetworkBits(t *testing.T) {
+	// For a /112, bytes 0-13 should be preserved, only bytes 14-15 should vary
+	prefix := netip.MustParsePrefix("fd00:1234:5678:abcd:ef01:2345:6789:0/112")
+
+	prefixBytes := prefix.Addr().As16()
+	for i := 0; i < 20; i++ {
+		ip, err := AllocateRandomPeerIPv6(prefix)
+		require.NoError(t, err)
+		// First 14 bytes (112 bits = 14 bytes) must match the network
+		b := ip.As16()
+		assert.Equal(t, prefixBytes[:14], b[:14], "network bytes should be preserved for /112")
+	}
+}
+
+func TestAllocateRandomPeerIPv6_NonByteBoundary(t *testing.T) {
+	// For a /60, the first 7.5 bytes are network, so byte 7 is partial
+	prefix := netip.MustParsePrefix("fd00:1234:5678:abc0::/60")
+
+	prefixBytes := prefix.Addr().As16()
+	for i := 0; i < 50; i++ {
+		ip, err := AllocateRandomPeerIPv6(prefix)
+		require.NoError(t, err)
+		b := ip.As16()
+		assert.True(t, prefix.Contains(ip), "IP %s should be within %s", ip, prefix)
+		// First 7 bytes must match exactly
+		assert.Equal(t, prefixBytes[:7], b[:7], "full network bytes should match for /60")
+		// Byte 7: top 4 bits (0xc = 1100) must be preserved
+		assert.Equal(t, prefixBytes[7]&0xf0, b[7]&0xf0, "partial byte network bits should be preserved for /60")
+	}
+}
diff --git a/management/server/types/networkmap_components_correctness_test.go b/management/server/types/networkmap_components_correctness_test.go
index 825d51d4e..35b5f7149 100644
--- a/management/server/types/networkmap_components_correctness_test.go
+++ b/management/server/types/networkmap_components_correctness_test.go
@@ -388,7 +388,7 @@ func TestComponents_NetworkSerial(t *testing.T) {
 	account.Network.Serial = 42
 	nm := componentsNetworkMap(account, "peer-0", validatedPeers)
 	require.NotNil(t, nm)
-	assert.Equal(t, uint64(42), nm.Network.Serial, "network serial should match")
+	assert.Equal(t, uint64(42), nm.Network.CurrentSerial(), "network serial should match")
 }
 
 // ──────────────────────────────────────────────────────────────────────────────
@@ -812,7 +812,7 @@ func TestComponents_AllPeersGetValidMaps(t *testing.T) {
 		}
 		nm := componentsNetworkMap(account, peerID, validatedPeers)
 		require.NotNil(t, nm, "network map should not be nil for %s", peerID)
-		assert.Equal(t, account.Network.Serial, nm.Network.Serial, "serial mismatch for %s", peerID)
+		assert.Equal(t, account.Network.Serial, nm.Network.CurrentSerial(), "serial mismatch for %s", peerID)
 		assert.NotEmpty(t, nm.Peers, "validated peer %s should see other peers", peerID)
 	}
 }
@@ -833,7 +833,7 @@ func TestComponents_LargeScaleMapGeneration(t *testing.T) {
 				require.NotNil(t, nm, "network map should not be nil for %s", peerID)
 				assert.NotEmpty(t, nm.Peers, "peer %s should see other peers at scale", peerID)
 				assert.NotEmpty(t, nm.Routes, "peer %s should have routes at scale", peerID)
-				assert.Equal(t, account.Network.Serial, nm.Network.Serial, "serial mismatch for %s", peerID)
+				assert.Equal(t, account.Network.Serial, nm.Network.CurrentSerial(), "serial mismatch for %s", peerID)
 			}
 		})
 	}
@@ -875,6 +875,89 @@ func TestComponents_PeerAsSourceResource(t *testing.T) {
 	assert.True(t, has443, "peer-0 as source resource should have port 443 rule")
 }
 
+func hasFirewallRuleTo(nm *types.NetworkMap, peerIP, port string) bool {
+	for _, rule := range nm.FirewallRules {
+		if rule.PeerIP == peerIP && rule.Port == port {
+			return true
+		}
+	}
+	return false
+}
+
+// TestComponents_PeerAsSourceResource_PostureChecks verifies that a directly referenced
+// source peer is gated by the policy's posture checks like a member of a group holding only
+// that peer: peer-1 (0.25.0) fails the 0.26.0 minimum, peer-2 (0.40.0) passes.
+func TestComponents_PeerAsSourceResource_PostureChecks(t *testing.T) {
+	account, validatedPeers := scalableTestAccountWithoutDefaultPolicy(20, 2)
+
+	for _, sourcePeerID := range []string{"peer-1", "peer-2"} {
+		account.Policies = append(account.Policies, &types.Policy{
+			ID: "policy-peer-src-" + sourcePeerID, Name: "Peer Source " + sourcePeerID, Enabled: true, AccountID: "test-account",
+			SourcePostureChecks: []string{"posture-check-ver"},
+			Rules: []*types.PolicyRule{{
+				ID: "rule-peer-src-" + sourcePeerID, Enabled: true,
+				Action:         types.PolicyTrafficActionAccept,
+				Protocol:       types.PolicyRuleProtocolTCP,
+				Bidirectional:  true,
+				Ports:          []string{"9443"},
+				SourceResource: types.Resource{ID: sourcePeerID, Type: types.ResourceTypePeer},
+				Destinations:   []string{"group-0"},
+			}},
+		})
+	}
+
+	nm0 := componentsNetworkMap(account, "peer-0", validatedPeers)
+	require.NotNil(t, nm0)
+	assert.False(t, hasFirewallRuleTo(nm0, "100.64.0.1", "9443"), "destination must not see the direct source peer failing the posture check")
+	assert.True(t, hasFirewallRuleTo(nm0, "100.64.0.2", "9443"), "destination must see the direct source peer passing the posture check")
+
+	nm1 := componentsNetworkMap(account, "peer-1", validatedPeers)
+	require.NotNil(t, nm1)
+	assert.False(t, hasFirewallRuleTo(nm1, "100.64.0.0", "9443"), "a direct source peer failing the posture check gets no policy connectivity")
+
+	nm2 := componentsNetworkMap(account, "peer-2", validatedPeers)
+	require.NotNil(t, nm2)
+	assert.True(t, hasFirewallRuleTo(nm2, "100.64.0.0", "9443"), "a direct source peer passing the posture check gets policy connectivity")
+}
+
+// TestComponents_PeerAsResource_Unvalidated verifies that a directly referenced peer is
+// subject to approval like a group member, whether it is the rule's source or destination.
+func TestComponents_PeerAsResource_Unvalidated(t *testing.T) {
+	account, validatedPeers := scalableTestAccountWithoutDefaultPolicy(20, 2)
+	delete(validatedPeers, "peer-2")
+
+	account.Policies = append(account.Policies,
+		&types.Policy{
+			ID: "policy-unval-src", Name: "Unvalidated Source", Enabled: true, AccountID: "test-account",
+			Rules: []*types.PolicyRule{{
+				ID: "rule-unval-src", Enabled: true,
+				Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolTCP, Bidirectional: true,
+				Ports:          []string{"9443"},
+				SourceResource: types.Resource{ID: "peer-2", Type: types.ResourceTypePeer},
+				Destinations:   []string{"group-0"},
+			}},
+		},
+		&types.Policy{
+			ID: "policy-unval-dst", Name: "Unvalidated Destination", Enabled: true, AccountID: "test-account",
+			Rules: []*types.PolicyRule{{
+				ID: "rule-unval-dst", Enabled: true,
+				Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolTCP, Bidirectional: true,
+				Ports:               []string{"9444"},
+				Sources:             []string{"group-0"},
+				DestinationResource: types.Resource{ID: "peer-2", Type: types.ResourceTypePeer},
+			}},
+		},
+	)
+
+	nm0 := componentsNetworkMap(account, "peer-0", validatedPeers)
+	require.NotNil(t, nm0)
+	assert.False(t, hasFirewallRuleTo(nm0, "100.64.0.2", "9443"), "an unvalidated direct source peer must not be admitted")
+	assert.False(t, hasFirewallRuleTo(nm0, "100.64.0.2", "9444"), "an unvalidated direct destination peer must not be admitted")
+	for _, p := range nm0.Peers {
+		assert.NotEqual(t, "peer-2", p.ID, "an unvalidated direct peer must not be shipped as a remote peer")
+	}
+}
+
 // TestComponents_PeerAsDestinationResource verifies that a policy with DestinationResource.Type=Peer
 // targets only that specific peer as the destination.
 func TestComponents_PeerAsDestinationResource(t *testing.T) {
diff --git a/management/server/types/networkmap_components_test.go b/management/server/types/networkmap_components_test.go
index 3f2288f88..f6d542609 100644
--- a/management/server/types/networkmap_components_test.go
+++ b/management/server/types/networkmap_components_test.go
@@ -18,6 +18,7 @@ import (
 	"github.com/netbirdio/netbird/management/server/posture"
 	"github.com/netbirdio/netbird/management/server/types"
 	"github.com/netbirdio/netbird/route"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 )
 
 func networkMapFromComponents(t *testing.T, account *types.Account, peerID string, validatedPeers map[string]struct{}) *types.NetworkMap {
@@ -49,7 +50,7 @@ func allPeersValidated(account *types.Account, excludePeerIDs ...string) map[str
 	return validated
 }
 
-func peerIDs(peers []*types.ComponentPeer) []string {
+func peerIDs(peers []*nmdata.Peer) []string {
 	ids := make([]string, len(peers))
 	for i, p := range peers {
 		ids[i] = p.ID
@@ -625,7 +626,7 @@ func TestNetworkMapComponents_DomainNetworkResource(t *testing.T) {
 
 	var hasDomainRoute bool
 	for _, r := range nm.Routes {
-		if r.NetworkType == route.DomainNetwork && len(r.Domains) > 0 && r.Domains[0].SafeString() == "api.example.com" {
+		if r.NetworkType == int(route.DomainNetwork) && len(r.Domains) > 0 && r.Domains[0].SafeString() == "api.example.com" {
 			hasDomainRoute = true
 		}
 	}
diff --git a/management/server/types/networkmap_wire_benchmark_test.go b/management/server/types/networkmap_wire_benchmark_test.go
index ee9839a3f..ccec054cd 100644
--- a/management/server/types/networkmap_wire_benchmark_test.go
+++ b/management/server/types/networkmap_wire_benchmark_test.go
@@ -66,7 +66,7 @@ func BenchmarkNetworkMapWireEncode(b *testing.B) {
 
 		// Pre-encode once so the size metric is identical for every run inside
 		// the same scale; the b.Loop call only re-runs encode + Marshal.
-		legacyResp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, peer, nil, nil, networkMap, "netbird.cloud", nil, dnsCache, settings, nil, nil, 0)
+		legacyResp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, types.TwinPeer(peer), nil, nil, networkMap, "netbird.cloud", nil, dnsCache, types.TwinAccountSettings(settings), nil, nil, 0)
 		legacyBytes, err := goproto.Marshal(legacyResp.NetworkMap)
 		if err != nil {
 			b.Fatalf("marshal legacy networkmap: %v", err)
@@ -88,7 +88,7 @@ func BenchmarkNetworkMapWireEncode(b *testing.B) {
 			b.ReportMetric(float64(len(legacyBytes)), "bytes/msg")
 			b.ResetTimer()
 			for range b.N {
-				resp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, peer, nil, nil, networkMap, "netbird.cloud", nil, dnsCache, settings, nil, nil, 0)
+				resp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, types.TwinPeer(peer), nil, nil, networkMap, "netbird.cloud", nil, dnsCache, types.TwinAccountSettings(settings), nil, nil, 0)
 				if _, err := goproto.Marshal(resp.NetworkMap); err != nil {
 					b.Fatal(err)
 				}
@@ -135,7 +135,7 @@ func BenchmarkNetworkMapWireSize(b *testing.B) {
 		dnsCache := &cache.DNSConfigCache{}
 		settings := &types.Settings{}
 
-		legacyResp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, peer, nil, nil, networkMap, "netbird.cloud", nil, dnsCache, settings, nil, nil, 0)
+		legacyResp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, types.TwinPeer(peer), nil, nil, networkMap, "netbird.cloud", nil, dnsCache, types.TwinAccountSettings(settings), nil, nil, 0)
 		legacyBytes, err := goproto.Marshal(legacyResp.NetworkMap)
 		if err != nil {
 			b.Fatalf("marshal legacy networkmap: %v", err)
diff --git a/management/server/types/networkmap_wire_breakdown_test.go b/management/server/types/networkmap_wire_breakdown_test.go
index ac2855fa3..adf66b386 100644
--- a/management/server/types/networkmap_wire_breakdown_test.go
+++ b/management/server/types/networkmap_wire_breakdown_test.go
@@ -45,7 +45,7 @@ func TestNetworkMapWireBreakdown(t *testing.T) {
 	dnsCache := &cache.DNSConfigCache{}
 	settings := &types.Settings{}
 
-	legacyResp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, peer, nil, nil, networkMap, "netbird.cloud", nil, dnsCache, settings, nil, nil, 0)
+	legacyResp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, types.TwinPeer(peer), nil, nil, networkMap, "netbird.cloud", nil, dnsCache, types.TwinAccountSettings(settings), nil, nil, 0)
 	legacyTotal := mustMarshalSize(t, legacyResp.NetworkMap)
 
 	envelope := mgmtgrpc.EncodeNetworkMapEnvelope(mgmtgrpc.ComponentsEnvelopeInput{
diff --git a/shared/management/types/policy.go b/management/server/types/policy.go
similarity index 58%
rename from shared/management/types/policy.go
rename to management/server/types/policy.go
index b8f605b94..0f7298d18 100644
--- a/shared/management/types/policy.go
+++ b/management/server/types/policy.go
@@ -1,32 +1,5 @@
 package types
 
-import (
-	"errors"
-	"fmt"
-	"strconv"
-	"strings"
-)
-
-const (
-	// PolicyTrafficActionAccept indicates that the traffic is accepted
-	PolicyTrafficActionAccept = PolicyTrafficActionType("accept")
-	// PolicyTrafficActionDrop indicates that the traffic is dropped
-	PolicyTrafficActionDrop = PolicyTrafficActionType("drop")
-)
-
-const (
-	// PolicyRuleProtocolALL type of traffic
-	PolicyRuleProtocolALL = PolicyRuleProtocolType("all")
-	// PolicyRuleProtocolTCP type of traffic
-	PolicyRuleProtocolTCP = PolicyRuleProtocolType("tcp")
-	// PolicyRuleProtocolUDP type of traffic
-	PolicyRuleProtocolUDP = PolicyRuleProtocolType("udp")
-	// PolicyRuleProtocolICMP type of traffic
-	PolicyRuleProtocolICMP = PolicyRuleProtocolType("icmp")
-	// PolicyRuleProtocolNetbirdSSH type of traffic
-	PolicyRuleProtocolNetbirdSSH = PolicyRuleProtocolType("netbird-ssh")
-)
-
 const (
 	// PolicyRuleFlowDirect allows traffic from source to destination
 	PolicyRuleFlowDirect = PolicyRuleDirection("direct")
@@ -184,85 +157,3 @@ func (p *Policy) SourceGroups() []string {
 
 	return groupIDs
 }
-
-func ParseRuleString(rule string) (PolicyRuleProtocolType, RulePortRange, error) {
-	rule = strings.TrimSpace(strings.ToLower(rule))
-	if rule == "all" {
-		return PolicyRuleProtocolALL, RulePortRange{}, nil
-	}
-	if rule == "icmp" {
-		return PolicyRuleProtocolICMP, RulePortRange{}, nil
-	}
-
-	split := strings.Split(rule, "/")
-	if len(split) != 2 {
-		return "", RulePortRange{}, errors.New("invalid rule format: expected protocol/port or protocol/port-range")
-	}
-
-	protoStr := strings.TrimSpace(split[0])
-	portStr := strings.TrimSpace(split[1])
-
-	var protocol PolicyRuleProtocolType
-	switch protoStr {
-	case "tcp":
-		protocol = PolicyRuleProtocolTCP
-	case "udp":
-		protocol = PolicyRuleProtocolUDP
-	case "icmp":
-		return "", RulePortRange{}, errors.New("icmp does not accept ports; use 'icmp' without '/…'")
-	case "netbird-ssh":
-		return PolicyRuleProtocolNetbirdSSH, RulePortRange{Start: nativeSSHPortNumber, End: nativeSSHPortNumber}, nil
-	default:
-		return "", RulePortRange{}, fmt.Errorf("invalid protocol: %q", protoStr)
-	}
-
-	portRange, err := parsePortRange(portStr)
-	if err != nil {
-		return "", RulePortRange{}, err
-	}
-
-	return protocol, portRange, nil
-}
-
-func parsePortRange(portStr string) (RulePortRange, error) {
-	if strings.Contains(portStr, "-") {
-		rangeParts := strings.Split(portStr, "-")
-		if len(rangeParts) != 2 {
-			return RulePortRange{}, fmt.Errorf("invalid port range %q", portStr)
-		}
-		start, err := parsePort(strings.TrimSpace(rangeParts[0]))
-		if err != nil {
-			return RulePortRange{}, err
-		}
-		end, err := parsePort(strings.TrimSpace(rangeParts[1]))
-		if err != nil {
-			return RulePortRange{}, err
-		}
-		if start > end {
-			return RulePortRange{}, fmt.Errorf("invalid port range: start %d > end %d", start, end)
-		}
-		return RulePortRange{Start: uint16(start), End: uint16(end)}, nil
-	}
-
-	p, err := parsePort(portStr)
-	if err != nil {
-		return RulePortRange{}, err
-	}
-
-	return RulePortRange{Start: uint16(p), End: uint16(p)}, nil
-}
-
-func parsePort(portStr string) (int, error) {
-
-	if portStr == "" {
-		return 0, errors.New("empty port")
-	}
-	p, err := strconv.Atoi(portStr)
-	if err != nil {
-		return 0, fmt.Errorf("invalid port %q: %w", portStr, err)
-	}
-	if p < 1 || p > 65535 {
-		return 0, fmt.Errorf("port out of range (1–65535): %d", p)
-	}
-	return p, nil
-}
diff --git a/management/server/types/policyrule.go b/management/server/types/policyrule.go
new file mode 100644
index 000000000..87905f005
--- /dev/null
+++ b/management/server/types/policyrule.go
@@ -0,0 +1,196 @@
+package types
+
+import (
+	"slices"
+)
+
+// PolicyUpdateOperationType operation type
+type PolicyUpdateOperationType int
+
+// PolicyRuleDirection direction of traffic
+type PolicyRuleDirection string
+
+// PolicyRule is the metadata of the policy
+type PolicyRule struct {
+	// ID of the policy rule
+	ID string `gorm:"primaryKey"`
+
+	// PolicyID is a reference to Policy that this object belongs
+	PolicyID string `json:"-" gorm:"index"`
+
+	// Name of the rule visible in the UI
+	Name string
+
+	// Description of the rule visible in the UI
+	Description string
+
+	// Enabled status of rule in the system
+	Enabled bool
+
+	// Action policy accept or drops packets
+	Action PolicyTrafficActionType
+
+	// Destinations policy destination groups
+	Destinations []string `gorm:"serializer:json"`
+
+	// DestinationResource policy destination resource that the rule is applied to
+	DestinationResource Resource `gorm:"serializer:json"`
+
+	// Sources policy source groups
+	Sources []string `gorm:"serializer:json"`
+
+	// SourceResource policy source resource that the rule is applied to
+	SourceResource Resource `gorm:"serializer:json"`
+
+	// Bidirectional define if the rule is applicable in both directions, sources, and destinations
+	Bidirectional bool
+
+	// Protocol type of the traffic
+	Protocol PolicyRuleProtocolType
+
+	// Ports or it ranges list
+	Ports []string `gorm:"serializer:json"`
+
+	// PortRanges a list of port ranges.
+	PortRanges []RulePortRange `gorm:"serializer:json"`
+
+	// AuthorizedGroups is a map of groupIDs and their respective access to local users via ssh
+	AuthorizedGroups map[string][]string `gorm:"serializer:json"`
+
+	// AuthorizedUser is a list of userIDs that are authorized to access local resources via ssh
+	AuthorizedUser string
+}
+
+// Copy returns a copy of a policy rule
+func (pm *PolicyRule) Copy() *PolicyRule {
+	rule := &PolicyRule{
+		ID:                  pm.ID,
+		PolicyID:            pm.PolicyID,
+		Name:                pm.Name,
+		Description:         pm.Description,
+		Enabled:             pm.Enabled,
+		Action:              pm.Action,
+		Destinations:        make([]string, len(pm.Destinations)),
+		DestinationResource: pm.DestinationResource,
+		Sources:             make([]string, len(pm.Sources)),
+		SourceResource:      pm.SourceResource,
+		Bidirectional:       pm.Bidirectional,
+		Protocol:            pm.Protocol,
+		Ports:               make([]string, len(pm.Ports)),
+		PortRanges:          make([]RulePortRange, len(pm.PortRanges)),
+		AuthorizedGroups:    make(map[string][]string, len(pm.AuthorizedGroups)),
+		AuthorizedUser:      pm.AuthorizedUser,
+	}
+	copy(rule.Destinations, pm.Destinations)
+	copy(rule.Sources, pm.Sources)
+	copy(rule.Ports, pm.Ports)
+	copy(rule.PortRanges, pm.PortRanges)
+	for k, v := range pm.AuthorizedGroups {
+		rule.AuthorizedGroups[k] = make([]string, len(v))
+		copy(rule.AuthorizedGroups[k], v)
+	}
+	return rule
+}
+
+func (pm *PolicyRule) Equal(other *PolicyRule) bool {
+	if pm == nil || other == nil {
+		return pm == other
+	}
+
+	if pm.ID != other.ID ||
+		pm.PolicyID != other.PolicyID ||
+		pm.Name != other.Name ||
+		pm.Description != other.Description ||
+		pm.Enabled != other.Enabled ||
+		pm.Action != other.Action ||
+		pm.Bidirectional != other.Bidirectional ||
+		pm.Protocol != other.Protocol ||
+		pm.SourceResource != other.SourceResource ||
+		pm.DestinationResource != other.DestinationResource ||
+		pm.AuthorizedUser != other.AuthorizedUser {
+		return false
+	}
+
+	if !stringSlicesEqualUnordered(pm.Sources, other.Sources) {
+		return false
+	}
+	if !stringSlicesEqualUnordered(pm.Destinations, other.Destinations) {
+		return false
+	}
+	if !stringSlicesEqualUnordered(pm.Ports, other.Ports) {
+		return false
+	}
+	if !portRangeSlicesEqualUnordered(pm.PortRanges, other.PortRanges) {
+		return false
+	}
+	if !authorizedGroupsEqual(pm.AuthorizedGroups, other.AuthorizedGroups) {
+		return false
+	}
+
+	return true
+}
+
+func stringSlicesEqualUnordered(a, b []string) bool {
+	if len(a) != len(b) {
+		return false
+	}
+	if len(a) == 0 {
+		return true
+	}
+	sorted1 := make([]string, len(a))
+	sorted2 := make([]string, len(b))
+	copy(sorted1, a)
+	copy(sorted2, b)
+	slices.Sort(sorted1)
+	slices.Sort(sorted2)
+	return slices.Equal(sorted1, sorted2)
+}
+
+func portRangeSlicesEqualUnordered(a, b []RulePortRange) bool {
+	if len(a) != len(b) {
+		return false
+	}
+	if len(a) == 0 {
+		return true
+	}
+	cmp := func(x, y RulePortRange) int {
+		if x.Start != y.Start {
+			if x.Start < y.Start {
+				return -1
+			}
+			return 1
+		}
+		if x.End != y.End {
+			if x.End < y.End {
+				return -1
+			}
+			return 1
+		}
+		return 0
+	}
+	sorted1 := make([]RulePortRange, len(a))
+	sorted2 := make([]RulePortRange, len(b))
+	copy(sorted1, a)
+	copy(sorted2, b)
+	slices.SortFunc(sorted1, cmp)
+	slices.SortFunc(sorted2, cmp)
+	return slices.EqualFunc(sorted1, sorted2, func(x, y RulePortRange) bool {
+		return x.Start == y.Start && x.End == y.End
+	})
+}
+
+func authorizedGroupsEqual(a, b map[string][]string) bool {
+	if len(a) != len(b) {
+		return false
+	}
+	for k, va := range a {
+		vb, ok := b[k]
+		if !ok {
+			return false
+		}
+		if !stringSlicesEqualUnordered(va, vb) {
+			return false
+		}
+	}
+	return true
+}
diff --git a/management/server/types/resource.go b/management/server/types/resource.go
new file mode 100644
index 000000000..0f065c850
--- /dev/null
+++ b/management/server/types/resource.go
@@ -0,0 +1,30 @@
+package types
+
+import (
+	"github.com/netbirdio/netbird/shared/management/http/api"
+)
+
+type Resource struct {
+	ID   string
+	Type ResourceType
+}
+
+func (r *Resource) ToAPIResponse() *api.Resource {
+	if r.ID == "" && r.Type == "" {
+		return nil
+	}
+
+	return &api.Resource{
+		Id:   r.ID,
+		Type: api.ResourceType(r.Type),
+	}
+}
+
+func (r *Resource) FromAPIRequest(req *api.Resource) {
+	if req == nil {
+		return
+	}
+
+	r.ID = req.Id
+	r.Type = ResourceType(req.Type)
+}
diff --git a/management/server/types/user.go b/management/server/types/user.go
index 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/auth/auth.go b/proxy/auth/auth.go
index 78f0097d5..5512bf003 100644
--- a/proxy/auth/auth.go
+++ b/proxy/auth/auth.go
@@ -30,6 +30,12 @@ const (
 	SessionJWTIssuer     = "netbird-management"
 )
 
+// HeaderUserID is the synthetic user id recorded for header-authenticated
+// requests. Header auth validates a per-service secret and resolves no user
+// record, so proxy access logs and management-minted session tokens both
+// attribute the request to this id.
+const HeaderUserID = "header-user"
+
 // ResolveProto determines the protocol scheme based on the forwarded proto
 // configuration. When set to "http" or "https" the value is used directly.
 // Otherwise TLS state is used: if conn is non-nil "https" is returned, else "http".
diff --git a/proxy/internal/auth/header.go b/proxy/internal/auth/header.go
index 194800a49..64d5da8f1 100644
--- a/proxy/internal/auth/header.go
+++ b/proxy/internal/auth/header.go
@@ -1,36 +1,33 @@
 package auth
 
 import (
+	"crypto/sha256"
 	"errors"
-	"fmt"
 	"net/http"
+	"sync"
 
 	"github.com/netbirdio/netbird/proxy/auth"
-	"github.com/netbirdio/netbird/proxy/internal/types"
-	"github.com/netbirdio/netbird/shared/management/proto"
+	"github.com/netbirdio/netbird/shared/hash/argon2id"
 )
 
-// ErrHeaderAuthFailed indicates that the header was present but the
-// credential did not validate. Callers should return 401 instead of
-// falling through to other auth schemes.
-var ErrHeaderAuthFailed = errors.New("header authentication failed")
-
-// Header implements header-based authentication. The proxy checks for the
-// configured header in each request and validates its value via gRPC.
+// Header implements header-based authentication. The service mapping carries
+// the argon2id hash of every value accepted for the header, so the proxy
+// verifies the credential locally rather than round-tripping to management.
 type Header struct {
-	id         types.ServiceID
-	accountId  types.AccountID
 	headerName string
-	client     authenticator
+	hashes     []string
+	verified   *verifiedValues
 }
 
-// NewHeader creates a Header authentication scheme for the given header name.
-func NewHeader(client authenticator, id types.ServiceID, accountId types.AccountID, headerName string) Header {
+// NewHeader creates a Header authentication scheme accepting any value whose
+// argon2id hash appears in hashes. An empty hashes slice rejects every request
+// carrying the header, so a mapping that arrived without its hashes fails
+// closed instead of leaving the service unprotected.
+func NewHeader(headerName string, hashes []string) Header {
 	return Header{
-		id:         id,
-		accountId:  accountId,
-		headerName: headerName,
-		client:     client,
+		headerName: http.CanonicalHeaderKey(headerName),
+		hashes:     hashes,
+		verified:   &verifiedValues{seen: make(map[[32]byte]struct{}, len(hashes))},
 	}
 }
 
@@ -39,31 +36,64 @@ func (Header) Type() auth.Method {
 	return auth.MethodHeader
 }
 
-// Authenticate checks for the configured header in the request. If absent,
-// returns empty (unauthenticated). If present, validates via gRPC.
-func (h Header) Authenticate(r *http.Request) (string, string, error) {
+// Authenticate satisfies Scheme. Header credentials are resolved by Verify
+// before the scheme loop runs, so a request that reaches here never carries
+// the header and there is no credential to prompt for.
+func (Header) Authenticate(*http.Request) (string, string, error) {
+	return "", "", nil
+}
+
+// Verify reports whether the request carries the configured header and, when
+// it does, whether the value matches one of the service's hashes.
+//
+// A non-nil unusable is a diagnostic rather than a request error: a stored hash
+// could not be decoded, so no credential can ever match it and the header stays
+// unauthenticatable until the service is saved again. Folding that into an
+// ordinary mismatch would hide the misconfiguration behind a permanent 401.
+func (h Header) Verify(r *http.Request) (present, matched bool, unusable error) {
 	value := r.Header.Get(h.headerName)
 	if value == "" {
-		return "", "", nil
+		return false, false, nil
 	}
 
-	res, err := h.client.Authenticate(r.Context(), &proto.AuthenticateRequest{
-		Id:        string(h.id),
-		AccountId: string(h.accountId),
-		Request: &proto.AuthenticateRequest_HeaderAuth{
-			HeaderAuth: &proto.HeaderAuthRequest{
-				HeaderValue: value,
-				HeaderName:  h.headerName,
-			},
-		},
-	})
-	if err != nil {
-		return "", "", fmt.Errorf("authenticate header: %w", err)
+	digest := sha256.Sum256([]byte(value))
+	if h.verified.has(digest) {
+		return true, true, nil
 	}
 
-	if res.GetSuccess() {
-		return res.GetSessionToken(), "", nil
+	for _, hash := range h.hashes {
+		err := argon2id.Verify(value, hash)
+		if err == nil {
+			h.verified.add(digest)
+			return true, true, nil
+		}
+		if !errors.Is(err, argon2id.ErrMismatchedHashAndPassword) {
+			unusable = err
+		}
 	}
-
-	return "", "", ErrHeaderAuthFailed
+	return true, false, unusable
+}
+
+// verifiedValues remembers which header values already passed argon2id
+// verification. argon2id is deliberately expensive (19 MiB, two passes) and
+// header credentials repeat on every request, so re-deriving per request would
+// dominate the hot path. The set cannot outgrow the number of configured
+// hashes, and a mapping update builds a fresh scheme with an empty set.
+// Values are keyed by digest so the plaintext credential is not retained.
+type verifiedValues struct {
+	mu   sync.Mutex
+	seen map[[32]byte]struct{}
+}
+
+func (v *verifiedValues) has(digest [32]byte) bool {
+	v.mu.Lock()
+	defer v.mu.Unlock()
+	_, ok := v.seen[digest]
+	return ok
+}
+
+func (v *verifiedValues) add(digest [32]byte) {
+	v.mu.Lock()
+	defer v.mu.Unlock()
+	v.seen[digest] = struct{}{}
 }
diff --git a/proxy/internal/auth/middleware.go b/proxy/internal/auth/middleware.go
index 72630b085..8abdf2923 100644
--- a/proxy/internal/auth/middleware.go
+++ b/proxy/internal/auth/middleware.go
@@ -146,7 +146,7 @@ func (mw *Middleware) Protect(next http.Handler) http.Handler {
 			return
 		}
 
-		if mw.forwardWithHeaderAuth(w, r, host, config, next) {
+		if mw.forwardWithHeaderAuth(w, r, config, next) {
 			return
 		}
 
@@ -325,6 +325,16 @@ func (mw *Middleware) forwardWithSessionCookie(w http.ResponseWriter, r *http.Re
 	if err != nil {
 		return false
 	}
+
+	// Header auth is checked per request against the mapping's hashes and mints
+	// no session, so a header-method token can only predate that. Honouring it
+	// would keep a rotated credential working until the token expired.
+	if method == auth.MethodHeader.String() {
+		mw.logger.WithField("host", host).
+			Debug("ignoring header-auth session cookie; the header is required on every request")
+		return false
+	}
+
 	if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
 		cd.SetUserID(userID)
 		cd.SetUserEmail(email)
@@ -436,73 +446,44 @@ func isTunnelSourceIP(ip netip.Addr) bool {
 
 // forwardWithHeaderAuth checks for a Header auth scheme. If the header validates,
 // the request is forwarded directly (no redirect), which is important for API clients.
-func (mw *Middleware) forwardWithHeaderAuth(w http.ResponseWriter, r *http.Request, host string, config DomainConfig, next http.Handler) bool {
+func (mw *Middleware) forwardWithHeaderAuth(w http.ResponseWriter, r *http.Request, config DomainConfig, next http.Handler) bool {
+	var presented []string
 	for _, scheme := range config.Schemes {
 		hdr, ok := scheme.(Header)
 		if !ok {
 			continue
 		}
 
-		handled := mw.tryHeaderScheme(w, r, host, config, hdr, next)
-		if handled {
+		present, matched, unusable := hdr.Verify(r)
+		if matched {
+			if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
+				cd.SetUserID(auth.HeaderUserID)
+				cd.SetAuthMethod(auth.MethodHeader.String())
+			}
+			next.ServeHTTP(w, r)
 			return true
 		}
+		if unusable != nil {
+			mw.logger.WithFields(log.Fields{
+				"host":   r.Host,
+				"header": hdr.headerName,
+			}).WithError(unusable).Error("header auth: a configured hash cannot be decoded, so this header can never authenticate; re-save the service")
+		}
+		if present {
+			presented = append(presented, hdr.headerName)
+		}
 	}
-	return false
-}
 
-func (mw *Middleware) tryHeaderScheme(w http.ResponseWriter, r *http.Request, host string, config DomainConfig, hdr Header, next http.Handler) bool {
-	token, _, err := hdr.Authenticate(r)
-	if err != nil {
-		return mw.handleHeaderAuthError(w, r, err)
-	}
-	if token == "" {
+	if len(presented) == 0 {
 		return false
 	}
 
-	result, err := mw.validateSessionToken(r.Context(), host, token, config.SessionPublicKey, auth.MethodHeader)
-	if err != nil {
-		setHeaderCapturedData(r.Context(), "", "", nil, nil)
-		status := http.StatusBadRequest
-		msg := "invalid session token"
-		if errors.Is(err, errValidationUnavailable) {
-			status = http.StatusBadGateway
-			msg = "authentication service unavailable"
-		}
-		http.Error(w, msg, status)
-		return true
-	}
-
-	if !result.Valid {
-		setHeaderCapturedData(r.Context(), result.UserID, result.UserEmail, result.Groups, result.GroupNames)
-		http.Error(w, "Unauthorized", http.StatusUnauthorized)
-		return true
-	}
-
-	setSessionCookie(w, token, config.SessionExpiration)
-	if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
-		cd.SetUserID(result.UserID)
-		cd.SetUserEmail(result.UserEmail)
-		cd.SetUserGroups(result.Groups)
-		cd.SetUserGroupNames(result.GroupNames)
-		cd.SetAuthMethod(auth.MethodHeader.String())
-	}
-
-	next.ServeHTTP(w, r)
-	return true
-}
-
-func (mw *Middleware) handleHeaderAuthError(w http.ResponseWriter, r *http.Request, err error) bool {
-	if errors.Is(err, ErrHeaderAuthFailed) {
-		setHeaderCapturedData(r.Context(), "", "", nil, nil)
-		http.Error(w, "Unauthorized", http.StatusUnauthorized)
-		return true
-	}
-	mw.logger.WithField("scheme", "header").Warnf("header auth infrastructure error: %v", err)
-	if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
-		cd.SetOrigin(proxy.OriginAuth)
-	}
-	http.Error(w, "authentication service unavailable", http.StatusBadGateway)
+	mw.logger.WithFields(log.Fields{
+		"host":    r.Host,
+		"headers": presented,
+	}).Debug("header auth rejected: no presented header matched a configured hash")
+	setHeaderCapturedData(r.Context(), "", "", nil, nil)
+	http.Error(w, "Unauthorized", http.StatusUnauthorized)
 	return true
 }
 
diff --git a/proxy/internal/auth/middleware_test.go b/proxy/internal/auth/middleware_test.go
index 6608c2b22..9220ce790 100644
--- a/proxy/internal/auth/middleware_test.go
+++ b/proxy/internal/auth/middleware_test.go
@@ -16,6 +16,7 @@ import (
 	"time"
 
 	log "github.com/sirupsen/logrus"
+	logtest "github.com/sirupsen/logrus/hooks/test"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 	"google.golang.org/grpc"
@@ -25,6 +26,7 @@ import (
 	"github.com/netbirdio/netbird/proxy/internal/proxy"
 	"github.com/netbirdio/netbird/proxy/internal/restrict"
 	"github.com/netbirdio/netbird/proxy/internal/types"
+	"github.com/netbirdio/netbird/shared/hash/argon2id"
 	"github.com/netbirdio/netbird/shared/management/proto"
 )
 
@@ -1023,38 +1025,24 @@ func TestProtect_OIDCWithOtherMethodShowsLoginPage(t *testing.T) {
 	assert.Equal(t, http.StatusUnauthorized, rec.Code, "should show login page when multiple methods exist")
 }
 
-// mockAuthenticator is a minimal mock for the authenticator gRPC interface
-// used by the Header scheme.
-type mockAuthenticator struct {
-	fn func(ctx context.Context, req *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error)
-}
-
-func (m *mockAuthenticator) Authenticate(ctx context.Context, in *proto.AuthenticateRequest, _ ...grpc.CallOption) (*proto.AuthenticateResponse, error) {
-	return m.fn(ctx, in)
-}
-
-// newHeaderSchemeWithToken creates a Header scheme backed by a mock that
-// returns a signed session token when the expected header value is provided.
-func newHeaderSchemeWithToken(t *testing.T, kp *sessionkey.KeyPair, headerName, expectedValue string) Header {
+// newHeaderScheme creates a Header scheme accepting each of the given values,
+// hashed the way management hashes them before putting them on the mapping.
+func newHeaderScheme(t *testing.T, headerName string, acceptedValues ...string) Header {
 	t.Helper()
-	token, err := sessionkey.SignToken(kp.PrivateKey, "header-user", "", "example.com", auth.MethodHeader, nil, nil, time.Hour)
-	require.NoError(t, err)
-
-	mock := &mockAuthenticator{fn: func(_ context.Context, req *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error) {
-		ha := req.GetHeaderAuth()
-		if ha != nil && ha.GetHeaderValue() == expectedValue {
-			return &proto.AuthenticateResponse{Success: true, SessionToken: token}, nil
-		}
-		return &proto.AuthenticateResponse{Success: false}, nil
-	}}
-	return NewHeader(mock, "svc1", "acc1", headerName)
+	hashes := make([]string, 0, len(acceptedValues))
+	for _, v := range acceptedValues {
+		hash, err := argon2id.Hash(v)
+		require.NoError(t, err, "hashing an accepted header value must succeed")
+		hashes = append(hashes, hash)
+	}
+	return NewHeader(headerName, hashes)
 }
 
 func TestProtect_HeaderAuth_ForwardsOnSuccess(t *testing.T) {
 	mw := NewMiddleware(log.StandardLogger(), nil, nil)
 	kp := generateTestKeyPair(t)
 
-	hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key")
+	hdr := newHeaderScheme(t, "X-API-Key", "secret-key")
 	require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
 
 	var backendCalled bool
@@ -1075,19 +1063,12 @@ func TestProtect_HeaderAuth_ForwardsOnSuccess(t *testing.T) {
 	assert.Equal(t, http.StatusOK, rec.Code)
 	assert.Equal(t, "ok", rec.Body.String())
 
-	// Session cookie should be set.
-	var sessionCookie *http.Cookie
+	// The credential rides on every request, so no session cookie is issued.
 	for _, c := range rec.Result().Cookies() {
-		if c.Name == auth.SessionCookieName {
-			sessionCookie = c
-			break
-		}
+		assert.NotEqual(t, auth.SessionCookieName, c.Name, "header auth must not issue a session cookie")
 	}
-	require.NotNil(t, sessionCookie, "session cookie should be set after successful header auth")
-	assert.True(t, sessionCookie.HttpOnly)
-	assert.True(t, sessionCookie.Secure)
 
-	assert.Equal(t, "header-user", capturedData.GetUserID())
+	assert.Equal(t, auth.HeaderUserID, capturedData.GetUserID())
 	assert.Equal(t, "header", capturedData.GetAuthMethod())
 }
 
@@ -1095,7 +1076,7 @@ func TestProtect_HeaderAuth_MissingHeaderFallsThrough(t *testing.T) {
 	mw := NewMiddleware(log.StandardLogger(), nil, nil)
 	kp := generateTestKeyPair(t)
 
-	hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key")
+	hdr := newHeaderScheme(t, "X-API-Key", "secret-key")
 	// Also add a PIN scheme so we can verify fallthrough behavior.
 	pinScheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
 	require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr, pinScheme}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
@@ -1114,10 +1095,7 @@ func TestProtect_HeaderAuth_WrongValueReturns401(t *testing.T) {
 	mw := NewMiddleware(log.StandardLogger(), nil, nil)
 	kp := generateTestKeyPair(t)
 
-	mock := &mockAuthenticator{fn: func(_ context.Context, _ *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error) {
-		return &proto.AuthenticateResponse{Success: false}, nil
-	}}
-	hdr := NewHeader(mock, "svc1", "acc1", "X-API-Key")
+	hdr := newHeaderScheme(t, "X-API-Key", "secret-key")
 	require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
 
 	capturedData := proxy.NewCapturedData("")
@@ -1131,93 +1109,282 @@ func TestProtect_HeaderAuth_WrongValueReturns401(t *testing.T) {
 
 	assert.Equal(t, http.StatusUnauthorized, rec.Code)
 	assert.Equal(t, "header", capturedData.GetAuthMethod())
+	assert.Empty(t, hdr.verified.seen, "a rejected value must not be memoized")
 }
 
-func TestProtect_HeaderAuth_InfraErrorReturns502(t *testing.T) {
+// TestProtect_HeaderAuth_MatchesAnyConfiguredHeader covers a client that carries
+// a valid credential on one configured header while also sending an unrelated
+// value on another — an app-level Authorization alongside an API key, say.
+// Schemes OR across header names, so the valid credential admits the request no
+// matter which order the mapping happened to list the headers in.
+func TestProtect_HeaderAuth_MatchesAnyConfiguredHeader(t *testing.T) {
+	tests := []struct {
+		name        string
+		matchedLast bool
+	}{
+		{name: "unmatched header listed first", matchedLast: true},
+		{name: "matched header listed first", matchedLast: false},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			mw := NewMiddleware(log.StandardLogger(), nil, nil)
+			kp := generateTestKeyPair(t)
+
+			authz := newHeaderScheme(t, "Authorization", "Bearer proxy-secret")
+			apiKey := newHeaderScheme(t, "X-Api-Key", "secret-key")
+			schemes := []Scheme{apiKey, authz}
+			if tt.matchedLast {
+				schemes = []Scheme{authz, apiKey}
+			}
+			require.NoError(t, mw.AddDomain("example.com", schemes, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
+
+			var backendCalled bool
+			handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+				backendCalled = true
+				w.WriteHeader(http.StatusOK)
+			}))
+
+			req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
+			req.Header.Set("X-Api-Key", "secret-key")
+			req.Header.Set("Authorization", "Bearer app-level-token")
+			rec := httptest.NewRecorder()
+			handler.ServeHTTP(rec, req)
+
+			assert.True(t, backendCalled, "a valid credential on one header must admit the request")
+			assert.Equal(t, http.StatusOK, rec.Code)
+		})
+	}
+}
+
+// TestProtect_HeaderAuth_RejectsWhenEveryPresentedHeaderFails is the other half
+// of the OR: trying all schemes before rejecting must not turn into admitting a
+// request that satisfied none of them.
+func TestProtect_HeaderAuth_RejectsWhenEveryPresentedHeaderFails(t *testing.T) {
 	mw := NewMiddleware(log.StandardLogger(), nil, nil)
 	kp := generateTestKeyPair(t)
 
-	mock := &mockAuthenticator{fn: func(_ context.Context, _ *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error) {
-		return nil, errors.New("gRPC unavailable")
-	}}
-	hdr := NewHeader(mock, "svc1", "acc1", "X-API-Key")
-	require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
-
-	handler := mw.Protect(newPassthroughHandler())
-
-	req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
-	req.Header.Set("X-API-Key", "some-key")
-	rec := httptest.NewRecorder()
-	handler.ServeHTTP(rec, req)
-
-	assert.Equal(t, http.StatusBadGateway, rec.Code)
-}
-
-func TestProtect_HeaderAuth_SubsequentRequestUsesSessionCookie(t *testing.T) {
-	mw := NewMiddleware(log.StandardLogger(), nil, nil)
-	kp := generateTestKeyPair(t)
-
-	hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key")
-	require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
+	authz := newHeaderScheme(t, "Authorization", "Bearer proxy-secret")
+	apiKey := newHeaderScheme(t, "X-Api-Key", "secret-key")
+	require.NoError(t, mw.AddDomain("example.com", []Scheme{authz, apiKey}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
 
+	var backendCalled bool
 	handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		backendCalled = true
+		w.WriteHeader(http.StatusOK)
+	}))
+
+	req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
+	req.Header.Set("X-Api-Key", "wrong-key")
+	req.Header.Set("Authorization", "Bearer wrong-token")
+	rec := httptest.NewRecorder()
+	handler.ServeHTTP(rec, req)
+
+	assert.False(t, backendCalled)
+	assert.Equal(t, http.StatusUnauthorized, rec.Code)
+}
+
+// TestProtect_HeaderAuth_ReportsUndecodableHash covers a stored hash the proxy
+// cannot decode. No credential can ever match it, so the header is permanently
+// unauthenticatable — an operator fault that has to surface loudly instead of
+// hiding behind the same quiet 401 a wrong credential earns.
+func TestProtect_HeaderAuth_ReportsUndecodableHash(t *testing.T) {
+	validHash, err := argon2id.Hash("secret-key")
+	require.NoError(t, err)
+
+	tests := []struct {
+		name       string
+		hashes     []string
+		wantErrLog bool
+	}{
+		{name: "stored hash cannot be decoded", hashes: []string{"$argon2id$v=19$garbage"}, wantErrLog: true},
+		{name: "wrong credential against a good hash", hashes: []string{validHash}, wantErrLog: false},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			logger, hook := logtest.NewNullLogger()
+			logger.SetLevel(log.DebugLevel)
+			mw := NewMiddleware(logger, nil, nil)
+			kp := generateTestKeyPair(t)
+
+			require.NoError(t, mw.AddDomain("example.com", []Scheme{NewHeader("X-Api-Key", tt.hashes)},
+				kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
+
+			handler := mw.Protect(newPassthroughHandler())
+
+			req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
+			req.Header.Set("X-Api-Key", "wrong-key")
+			rec := httptest.NewRecorder()
+			handler.ServeHTTP(rec, req)
+
+			require.Equal(t, http.StatusUnauthorized, rec.Code, "either way the request is denied")
+
+			var errored []string
+			for _, entry := range hook.AllEntries() {
+				if entry.Level == log.ErrorLevel {
+					errored = append(errored, entry.Message)
+				}
+			}
+
+			if !tt.wantErrLog {
+				assert.Empty(t, errored, "a wrong credential is not an operator fault")
+				return
+			}
+			require.Len(t, errored, 1, "an undecodable hash must be reported once")
+			assert.Contains(t, errored[0], "cannot be decoded")
+		})
+	}
+}
+
+// TestProtect_HeaderAuth_NoHashesFailsClosed covers a mapping that names a
+// header but carries no hash for it: the check cannot be evaluated, so the
+// request must be denied rather than let through unauthenticated.
+func TestProtect_HeaderAuth_NoHashesFailsClosed(t *testing.T) {
+	mw := NewMiddleware(log.StandardLogger(), nil, nil)
+	kp := generateTestKeyPair(t)
+
+	hdr := NewHeader("X-API-Key", nil)
+	require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
+
+	var backendCalled bool
+	handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		backendCalled = true
+		w.WriteHeader(http.StatusOK)
+	}))
+
+	req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
+	req.Header.Set("X-API-Key", "any-key")
+	rec := httptest.NewRecorder()
+	handler.ServeHTTP(rec, req)
+
+	assert.Equal(t, http.StatusUnauthorized, rec.Code)
+	assert.False(t, backendCalled, "a header auth with no hashes must not admit the request")
+}
+
+// TestProtect_HeaderAuth_SubsequentRequestRequiresHeader verifies that header
+// auth grants no ambient session: a follow-up request that drops the header is
+// treated as unauthenticated.
+func TestProtect_HeaderAuth_SubsequentRequestRequiresHeader(t *testing.T) {
+	mw := NewMiddleware(log.StandardLogger(), nil, nil)
+	kp := generateTestKeyPair(t)
+
+	hdr := newHeaderScheme(t, "X-API-Key", "secret-key")
+	require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
+
+	var backendCalls int
+	handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		backendCalls++
 		w.WriteHeader(http.StatusOK)
 	}))
 
-	// First request with header auth.
 	req1 := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
 	req1.Header.Set("X-API-Key", "secret-key")
 	req1 = req1.WithContext(proxy.WithCapturedData(req1.Context(), proxy.NewCapturedData("")))
 	rec1 := httptest.NewRecorder()
 	handler.ServeHTTP(rec1, req1)
 	require.Equal(t, http.StatusOK, rec1.Code)
+	require.Equal(t, 1, backendCalls)
 
-	// Extract session cookie.
-	var sessionCookie *http.Cookie
-	for _, c := range rec1.Result().Cookies() {
-		if c.Name == auth.SessionCookieName {
-			sessionCookie = c
-			break
-		}
-	}
-	require.NotNil(t, sessionCookie)
-
-	// Second request with only the session cookie (no header).
-	capturedData2 := proxy.NewCapturedData("")
+	// Same client, second request, header omitted: no cookie was handed out, so
+	// there is nothing to carry the earlier success forward.
 	req2 := httptest.NewRequest(http.MethodGet, "http://example.com/other", nil)
-	req2.AddCookie(sessionCookie)
-	req2 = req2.WithContext(proxy.WithCapturedData(req2.Context(), capturedData2))
+	for _, c := range rec1.Result().Cookies() {
+		req2.AddCookie(c)
+	}
 	rec2 := httptest.NewRecorder()
 	handler.ServeHTTP(rec2, req2)
 
-	assert.Equal(t, http.StatusOK, rec2.Code)
-	assert.Equal(t, "header-user", capturedData2.GetUserID())
-	assert.Equal(t, "header", capturedData2.GetAuthMethod())
+	assert.Equal(t, http.StatusUnauthorized, rec2.Code, "dropping the header must revoke access")
+	assert.Equal(t, 1, backendCalls, "backend must not be reached without the header")
 }
 
-// TestProtect_HeaderAuth_MultipleValuesSameHeader verifies that the proxy
-// correctly handles multiple valid credentials for the same header name.
-// In production, the mgmt gRPC authenticateHeader iterates all configured
-// header auths and accepts if any hash matches (OR semantics). The proxy
-// creates one Header scheme per entry, but a single gRPC call checks all.
+// TestProtect_HeaderAuth_LegacySessionCookieIsIgnored covers the upgrade
+// window. Header auth used to mint a session token, so cookies with
+// method=header survive a proxy upgrade and stay signature-valid for their full
+// lifetime. They must not stand in for the header, or a credential rotated
+// right after the upgrade would keep working until every such token expired.
+func TestProtect_HeaderAuth_LegacySessionCookieIsIgnored(t *testing.T) {
+	mw := NewMiddleware(log.StandardLogger(), nil, nil)
+	kp := generateTestKeyPair(t)
+
+	hdr := newHeaderScheme(t, "X-API-Key", "secret-key")
+	require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
+
+	// A token management would have minted for header auth before the upgrade.
+	legacyToken, err := sessionkey.SignToken(kp.PrivateKey, auth.HeaderUserID, "", "example.com", auth.MethodHeader, nil, nil, time.Hour)
+	require.NoError(t, err)
+
+	var backendCalls int
+	handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		backendCalls++
+		w.WriteHeader(http.StatusOK)
+	}))
+
+	t.Run("cookie alone is rejected", func(t *testing.T) {
+		req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
+		req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: legacyToken})
+		rec := httptest.NewRecorder()
+		handler.ServeHTTP(rec, req)
+
+		assert.Equal(t, http.StatusUnauthorized, rec.Code, "a header-auth cookie must not authenticate on its own")
+		assert.Equal(t, 0, backendCalls, "backend must not be reached without the header")
+	})
+
+	t.Run("cookie does not block the header path", func(t *testing.T) {
+		req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
+		req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: legacyToken})
+		req.Header.Set("X-API-Key", "secret-key")
+		rec := httptest.NewRecorder()
+		handler.ServeHTTP(rec, req)
+
+		assert.Equal(t, http.StatusOK, rec.Code, "a client sending both must still be admitted by the header")
+		assert.Equal(t, 1, backendCalls)
+	})
+}
+
+// TestProtect_HeaderAuth_RepeatedValueIsMemoized verifies the KDF is run once
+// per distinct accepted value. argon2id is deliberately expensive, so a
+// credential that repeats on every request must not be re-derived each time.
+func TestProtect_HeaderAuth_RepeatedValueIsMemoized(t *testing.T) {
+	mw := NewMiddleware(log.StandardLogger(), nil, nil)
+	kp := generateTestKeyPair(t)
+
+	hdr := newHeaderScheme(t, "X-API-Key", "key-a", "key-b")
+	require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
+
+	handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		w.WriteHeader(http.StatusOK)
+	}))
+
+	get := func(value string) int {
+		req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
+		req.Header.Set("X-API-Key", value)
+		rec := httptest.NewRecorder()
+		handler.ServeHTTP(rec, req)
+		return rec.Code
+	}
+
+	require.Equal(t, http.StatusOK, get("key-a"))
+	require.Equal(t, http.StatusOK, get("key-a"))
+	assert.Len(t, hdr.verified.seen, 1, "the same value must be memoized once")
+
+	require.Equal(t, http.StatusOK, get("key-b"))
+	assert.Len(t, hdr.verified.seen, 2, "each accepted value gets its own entry")
+
+	require.Equal(t, http.StatusUnauthorized, get("key-c"))
+	assert.Len(t, hdr.verified.seen, 2, "rejected values must not grow the set")
+}
+
+// TestProtect_HeaderAuth_MultipleValuesSameHeader verifies that a service with
+// several accepted credentials for one header name accepts any of them.
+// Management applied these OR semantics while it still validated the value; the
+// proxy preserves them by carrying every hash for a name on one scheme.
 func TestProtect_HeaderAuth_MultipleValuesSameHeader(t *testing.T) {
 	mw := NewMiddleware(log.StandardLogger(), nil, nil)
 	kp := generateTestKeyPair(t)
 
-	// Mock simulates mgmt behavior: accepts either token-a or token-b.
-	accepted := map[string]bool{"Bearer token-a": true, "Bearer token-b": true}
-	mock := &mockAuthenticator{fn: func(_ context.Context, req *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error) {
-		ha := req.GetHeaderAuth()
-		if ha != nil && accepted[ha.GetHeaderValue()] {
-			token, err := sessionkey.SignToken(kp.PrivateKey, "header-user", "", "example.com", auth.MethodHeader, nil, nil, time.Hour)
-			require.NoError(t, err)
-			return &proto.AuthenticateResponse{Success: true, SessionToken: token}, nil
-		}
-		return &proto.AuthenticateResponse{Success: false}, nil
-	}}
-
-	// Single Header scheme (as if one entry existed), but the mock checks both values.
-	hdr := NewHeader(mock, "svc1", "acc1", "Authorization")
+	hdr := newHeaderScheme(t, "Authorization", "Bearer token-a", "Bearer token-b")
 	require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
 
 	var backendCalled bool
diff --git a/proxy/internal/llm/model.go b/proxy/internal/llm/model.go
index 76ccfeccf..2e056a57a 100644
--- a/proxy/internal/llm/model.go
+++ b/proxy/internal/llm/model.go
@@ -13,6 +13,14 @@ func NormalizeBedrockModel(modelID string) string {
 	return sharedllm.NormalizeBedrockModel(modelID)
 }
 
+// NormalizeAnthropicModel strips the trailing "-YYYYMMDD" release-date suffix
+// from an Anthropic model id so a dated id a client pins matches the undated
+// one the operator registered. Thin delegate to shared/llm for the same
+// contract reason as the two below.
+func NormalizeAnthropicModel(modelID string) string {
+	return sharedllm.NormalizeAnthropicModel(modelID)
+}
+
 // NormalizeVertexModel strips the "@version" suffix from a Vertex AI model id
 // so it matches the catalog/pricing key. Thin delegate to shared/llm, kept
 // beside NormalizeBedrockModel for the same contract reason.
diff --git a/proxy/internal/llm/pricing/pricing.go b/proxy/internal/llm/pricing/pricing.go
index ce6e636cf..52cedb60e 100644
--- a/proxy/internal/llm/pricing/pricing.go
+++ b/proxy/internal/llm/pricing/pricing.go
@@ -10,6 +10,8 @@ package pricing
 import (
 	"fmt"
 	"math"
+
+	sharedllm "github.com/netbirdio/netbird/shared/llm"
 )
 
 // Entry is a single model's input and output pricing, expressed in USD per
@@ -92,7 +94,10 @@ func NewTable(raw map[string]map[string]EntryJSON) (*Table, error) {
 	return &Table{entries: entries}, nil
 }
 
-// Lookup returns the entry for the given provider surface and model.
+// Lookup returns the entry for the given provider surface and model. A
+// dated Anthropic id falls back to its undated form, so a client pinning
+// "claude-sonnet-4-5-20250929" bills at the registered "claude-sonnet-4-5"
+// rate instead of recording no cost at all.
 func (t *Table) Lookup(provider, model string) (Entry, bool) {
 	if t == nil {
 		return Entry{}, false
@@ -101,7 +106,14 @@ func (t *Table) Lookup(provider, model string) (Entry, bool) {
 	if !ok {
 		return Entry{}, false
 	}
-	e, ok := byModel[model]
+	if e, found := byModel[model]; found {
+		return e, true
+	}
+	undated := sharedllm.NormalizeAnthropicModel(model)
+	if undated == model {
+		return Entry{}, false
+	}
+	e, ok := byModel[undated]
 	return e, ok
 }
 
diff --git a/proxy/internal/llm/pricing/pricing_test.go b/proxy/internal/llm/pricing/pricing_test.go
index b946faa7f..e7d339f06 100644
--- a/proxy/internal/llm/pricing/pricing_test.go
+++ b/proxy/internal/llm/pricing/pricing_test.go
@@ -175,3 +175,22 @@ func TestNewTable_NilAndEmpty(t *testing.T) {
 	require.NoError(t, err)
 	assert.Empty(t, entries, "nil in, empty (never-matching) map out for the per-record map")
 }
+
+// TestLookup_DatedAnthropicIDFallsBackToUndated covers a client pinning a
+// release date on a model priced under its undated id. Without the
+// fallback the request records no cost at all.
+func TestLookup_DatedAnthropicIDFallsBackToUndated(t *testing.T) {
+	table, err := NewTable(map[string]map[string]EntryJSON{
+		"anthropic": {
+			"claude-sonnet-4-5": {InputPer1K: 0.003, OutputPer1K: 0.015},
+		},
+	})
+	require.NoError(t, err, "table must build from a valid defaults map")
+
+	entry, ok := table.Lookup("anthropic", "claude-sonnet-4-5-20250929")
+	require.True(t, ok, "a dated id must resolve to the undated entry")
+	assert.InDelta(t, 0.003, entry.InputPer1K, 1e-9, "dated id must bill at the registered rate")
+
+	_, ok = table.Lookup("anthropic", "claude-sonnet-9-9-20250929")
+	assert.False(t, ok, "an unknown family must stay unpriced")
+}
diff --git a/proxy/internal/middleware/builtin/cost_meter/middleware.go b/proxy/internal/middleware/builtin/cost_meter/middleware.go
index 2ce706cda..8e2e0590c 100644
--- a/proxy/internal/middleware/builtin/cost_meter/middleware.go
+++ b/proxy/internal/middleware/builtin/cost_meter/middleware.go
@@ -11,6 +11,7 @@ import (
 	"fmt"
 	"strconv"
 
+	"github.com/netbirdio/netbird/proxy/internal/llm"
 	"github.com/netbirdio/netbird/proxy/internal/llm/pricing"
 	"github.com/netbirdio/netbird/proxy/internal/middleware"
 )
@@ -175,13 +176,28 @@ func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middlewar
 // Anthropic route still bills its cache buckets additively.
 func (m *Middleware) lookupCosts(md []middleware.KV, surface, model string, inTokens, outTokens, cachedTokens, cacheCreationTokens int64) (pricing.Costs, bool) {
 	if recordID := lookupKV(md, middleware.KeyLLMResolvedProviderID); recordID != "" {
-		if entry, ok := m.perRecord[recordID][model]; ok {
+		if entry, ok := perRecordEntry(m.perRecord[recordID], model); ok {
 			return pricing.EntryCosts(entry, surface, inTokens, outTokens, cachedTokens, cacheCreationTokens), true
 		}
 	}
 	return m.defaults.Costs(surface, model, inTokens, outTokens, cachedTokens, cacheCreationTokens)
 }
 
+// perRecordEntry resolves the operator's stored price for a model on one
+// provider record, falling back to the undated form of a dated Anthropic id
+// so a client that pins a release date still bills at the registered rate.
+func perRecordEntry(byModel map[string]pricing.Entry, model string) (pricing.Entry, bool) {
+	if entry, ok := byModel[model]; ok {
+		return entry, true
+	}
+	undated := llm.NormalizeAnthropicModel(model)
+	if undated == model {
+		return pricing.Entry{}, false
+	}
+	entry, ok := byModel[undated]
+	return entry, ok
+}
+
 // usd renders a cost as the fixed-precision string every cost.usd_* key
 // carries, so the per-bucket values and the aggregates round identically.
 //
diff --git a/proxy/internal/middleware/builtin/llm_guardrail/middleware.go b/proxy/internal/middleware/builtin/llm_guardrail/middleware.go
index 1863aff20..d2b14f265 100644
--- a/proxy/internal/middleware/builtin/llm_guardrail/middleware.go
+++ b/proxy/internal/middleware/builtin/llm_guardrail/middleware.go
@@ -84,8 +84,10 @@ func (m *Middleware) MutationsSupported() bool { return false }
 func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middleware.Output, error) {
 	model, modelPresent := lookupMetadata(in.Metadata, middleware.KeyLLMModel)
 	providerID, _ := lookupMetadata(in.Metadata, middleware.KeyLLMResolvedProviderID)
+	surface, _ := lookupMetadata(in.Metadata, middleware.KeyLLMProvider)
+	nonInference, _ := lookupMetadata(in.Metadata, middleware.KeyLLMNonInference)
 
-	if denial := m.evaluateAllowlist(providerID, model, modelPresent); denial != nil {
+	if denial := m.evaluateAllowlist(providerID, surface, model, modelPresent, nonInference == "true"); denial != nil {
 		return denial, nil
 	}
 
@@ -114,7 +116,7 @@ func (m *Middleware) Close() error { return nil }
 // evaluateAllowlist denies when the resolved provider's allowlist rejects the
 // model; nil means proceed. Scoped to the provider llm_router resolved, so an
 // unrestricted provider (absent from config) is never caught by another's list.
-func (m *Middleware) evaluateAllowlist(providerID, model string, modelPresent bool) *middleware.Output {
+func (m *Middleware) evaluateAllowlist(providerID, surface, model string, modelPresent, nonInference bool) *middleware.Output {
 	if len(m.cfg.ProviderAllowlists) == 0 {
 		return nil
 	}
@@ -122,7 +124,7 @@ func (m *Middleware) evaluateAllowlist(providerID, model string, modelPresent bo
 	// if this request targets a restricted provider — fail closed. llm_router
 	// normally stamps the provider first, so this is a defensive guard.
 	if providerID == "" {
-		return denyModel("", denyCodeModelUnknown, denyMessageModelUnknown, denyReasonModelUnknown)
+		return denyModel(surface, "", denyCodeModelUnknown, denyMessageModelUnknown, denyReasonModelUnknown)
 	}
 	allowlist, restricted := m.cfg.ProviderAllowlists[providerID]
 	if !restricted {
@@ -133,18 +135,29 @@ func (m *Middleware) evaluateAllowlist(providerID, model string, modelPresent bo
 	// Fail closed: with an allowlist in effect for this provider, a request whose
 	// model the parser couldn't extract (absent/empty) is denied. This enforces
 	// the allowlist for path-routed providers (Bedrock, Vertex) with no body model.
+	//
+	// The exception is a non-inference endpoint the router already authorised.
+	// The model listing and the connection-warming probe name no model
+	// anywhere — not in a body, not in the path — so failing closed here
+	// rejected model discovery for exactly the accounts that configured an
+	// allowlist, which is the outage this endpoint is meant to avoid. The
+	// per-model lookup does name one (the router stamps it from the path), so
+	// it still falls through to the allowlist check below.
 	if !modelPresent || normaliseModel(model) == "" {
-		return denyModel("", denyCodeModelUnknown, denyMessageModelUnknown, denyReasonModelUnknown)
+		if nonInference {
+			return nil
+		}
+		return denyModel(surface, "", denyCodeModelUnknown, denyMessageModelUnknown, denyReasonModelUnknown)
 	}
 	if modelInAllowlist(allowlist, model) {
 		return nil
 	}
-	return denyModel(model, denyCodeModel, denyMessageModel, denyReasonModel)
+	return denyModel(surface, model, denyCodeModel, denyMessageModel, denyReasonModel)
 }
 
 // denyModel builds a 403 deny Output for a model-allowlist rejection. model is
 // included in the details only when non-empty.
-func denyModel(model, code, message, reason string) *middleware.Output {
+func denyModel(surface, model, code, message, reason string) *middleware.Output {
 	details := map[string]string{}
 	if model != "" {
 		details["model"] = model
@@ -156,6 +169,7 @@ func denyModel(model, code, message, reason string) *middleware.Output {
 			Code:    code,
 			Message: message,
 			Details: details,
+			Surface: surface,
 		},
 		Metadata: []middleware.KV{
 			{Key: middleware.KeyLLMPolicyDecision, Value: "deny"},
diff --git a/proxy/internal/middleware/builtin/llm_guardrail/middleware_test.go b/proxy/internal/middleware/builtin/llm_guardrail/middleware_test.go
index 5f35fefd3..19d8473fe 100644
--- a/proxy/internal/middleware/builtin/llm_guardrail/middleware_test.go
+++ b/proxy/internal/middleware/builtin/llm_guardrail/middleware_test.go
@@ -343,3 +343,52 @@ func TestFactoryNormalisesAllowlist(t *testing.T) {
 	require.NoError(t, err)
 	assert.Equal(t, middleware.DecisionAllow, out2.Decision, "trimmed entry must still match")
 }
+
+// TestAllowlistSkipsNonInferenceWithoutModel covers the reported regression:
+// GET /v1/models carries no model anywhere, so the fail-closed rule above
+// denied model discovery for exactly the accounts that configured a provider
+// allowlist — the clients that read a 403 here render an empty model picker.
+// The router authorises those endpoints by path before the guardrail sees
+// them, so an absent model there is expected rather than undeterminable.
+func TestAllowlistSkipsNonInferenceWithoutModel(t *testing.T) {
+	mw := New(providerCfg("gpt-4o"))
+	out, err := mw.Invoke(context.Background(), newInputProvider(testProvider,
+		middleware.KV{Key: middleware.KeyLLMNonInference, Value: "true"},
+	))
+	require.NoError(t, err)
+	require.NotNil(t, out)
+	assert.Equal(t, middleware.DecisionAllow, out.Decision,
+		"model discovery must not be refused because it names no model")
+}
+
+// TestAllowlistStillAppliesToNonInferenceWithModel pins that the exemption is
+// scoped to requests that genuinely name nothing. The per-model lookup
+// (GET /v1/models/{id}) is non-inference too, but the router stamps the model
+// from its path, so the allowlist must still decide it — otherwise the
+// exemption becomes a way to confirm a model the policy blocks.
+func TestAllowlistStillAppliesToNonInferenceWithModel(t *testing.T) {
+	mw := New(providerCfg("gpt-4o"))
+
+	t.Run("model in the allowlist", func(t *testing.T) {
+		out, err := mw.Invoke(context.Background(), newInputProvider(testProvider,
+			middleware.KV{Key: middleware.KeyLLMNonInference, Value: "true"},
+			middleware.KV{Key: middleware.KeyLLMModel, Value: "gpt-4o"},
+		))
+		require.NoError(t, err)
+		assert.Equal(t, middleware.DecisionAllow, out.Decision,
+			"an allowlisted model must stay reachable")
+	})
+
+	t.Run("model outside the allowlist", func(t *testing.T) {
+		out, err := mw.Invoke(context.Background(), newInputProvider(testProvider,
+			middleware.KV{Key: middleware.KeyLLMNonInference, Value: "true"},
+			middleware.KV{Key: middleware.KeyLLMModel, Value: "claude-opus-5"},
+		))
+		require.NoError(t, err)
+		assert.Equal(t, middleware.DecisionDeny, out.Decision,
+			"non-inference must not become a way past the allowlist")
+		require.NotNil(t, out.DenyReason)
+		assert.Equal(t, "llm_policy.model_blocked", out.DenyReason.Code,
+			"a named but blocked model is blocked, not unknown")
+	})
+}
diff --git a/proxy/internal/middleware/builtin/llm_identity_inject/middleware.go b/proxy/internal/middleware/builtin/llm_identity_inject/middleware.go
index 722588a15..60b99e194 100644
--- a/proxy/internal/middleware/builtin/llm_identity_inject/middleware.go
+++ b/proxy/internal/middleware/builtin/llm_identity_inject/middleware.go
@@ -217,6 +217,32 @@ func applyHeaderPair(rule *HeaderPairRule, in *middleware.Input) *middleware.Mut
 	return mutations
 }
 
+// bodyInjectableSurfaces are the request-body dialects that accept the
+// OpenAI-standard identity fields this middleware writes. A surface
+// outside this set gets header-only stamping: "user" and "metadata.tags"
+// are not part of the Anthropic Messages schema, which rejects unknown
+// top-level fields and permits only "user_id" under metadata, so writing
+// them into an Anthropic-shaped body turns a working request into a 400.
+// Claude Code speaks that shape through gateway records pinned to the
+// OpenAI parser, so the check keys on the detected surface rather than
+// on the provider record.
+var bodyInjectableSurfaces = map[string]struct{}{
+	"openai": {},
+	// An empty surface means no parser claimed the path (a custom gateway
+	// base). Those upstreams are OpenAI-compatible by convention, so keep
+	// the long-standing behaviour rather than silently dropping identity.
+	"": {},
+}
+
+// bodyAcceptsOpenAIIdentity reports whether the request body may carry the
+// OpenAI-standard identity fields, read from the surface llm_request_parser
+// resolved from the request path.
+func bodyAcceptsOpenAIIdentity(in *middleware.Input) bool {
+	surface, _ := lookupMetadata(in.Metadata, middleware.KeyLLMProvider)
+	_, ok := bodyInjectableSurfaces[surface]
+	return ok
+}
+
 // injectIntoBody parses the request body and writes the supplied
 // identity dimensions into it. Tags land at metadata.tags (creating
 // the metadata object when absent); the user identity lands at the
@@ -225,6 +251,8 @@ func applyHeaderPair(rule *HeaderPairRule, in *middleware.Input) *middleware.Mut
 // was written. Returns ok=false (no mutation) when:
 //
 //   - both inputs are empty (nothing to write);
+//   - the body speaks a dialect without these fields (see
+//     bodyInjectableSurfaces);
 //   - the body is empty or truncated (we don't have the full document
 //     to safely round-trip);
 //   - the body isn't a JSON object (skip silently — this middleware
@@ -245,6 +273,9 @@ func injectIntoBody(in *middleware.Input, tags []string, userID string) ([]byte,
 	if in == nil || len(in.Body) == 0 || in.BodyTruncated {
 		return nil, false
 	}
+	if !bodyAcceptsOpenAIIdentity(in) {
+		return nil, false
+	}
 	var doc map[string]any
 	if err := json.Unmarshal(in.Body, &doc); err != nil {
 		return nil, false
diff --git a/proxy/internal/middleware/builtin/llm_identity_inject/middleware_test.go b/proxy/internal/middleware/builtin/llm_identity_inject/middleware_test.go
index 8ec0930b5..f602f5c33 100644
--- a/proxy/internal/middleware/builtin/llm_identity_inject/middleware_test.go
+++ b/proxy/internal/middleware/builtin/llm_identity_inject/middleware_test.go
@@ -704,3 +704,57 @@ func TestInject_ExtraHeaders_EmptyValueSkipped(t *testing.T) {
 			"empty extra value must not be stamped")
 	}
 }
+
+// TestInject_AnthropicBodyIsNotRewritten pins the shape gate. Claude Code
+// reaches a LiteLLM record on /v1/messages, where "user" is not a
+// permitted top-level field and metadata accepts only "user_id", so
+// writing the OpenAI-standard fields would turn a working request into a
+// 400 naming a field the client never sent. Header stamping still runs, so
+// spend tracking and per-end-user budgets keep working.
+func TestInject_AnthropicBodyIsNotRewritten(t *testing.T) {
+	rule := liteLLMRuleWithBody()
+	rule.HeaderPair.EndUserIDInBody = true
+	mw := New(Config{Providers: []ProviderInjection{rule}})
+
+	in := newInput(litellmProvider, "alice", []string{"grp-eng"})
+	in.UserEmail = "alice@example.com"
+	in.Metadata = append(in.Metadata, middleware.KV{Key: middleware.KeyLLMProvider, Value: "anthropic"})
+	in.Body = []byte(`{"model":"claude-sonnet-5","messages":[]}`)
+
+	out, err := mw.Invoke(context.Background(), in)
+	require.NoError(t, err)
+	require.NotNil(t, out.Mutations)
+	assert.Empty(t, out.Mutations.BodyReplace,
+		"an Anthropic-shaped body must reach the upstream unmodified")
+
+	var endUser string
+	for _, kv := range out.Mutations.HeadersAdd {
+		if kv.Key == "x-litellm-end-user-id" {
+			endUser = kv.Value
+		}
+	}
+	assert.Equal(t, "alice@example.com", endUser,
+		"header stamping must still carry identity when body inject is skipped")
+}
+
+// TestInject_OpenAIBodyStillRewritten guards the gate against
+// over-reaching: the OpenAI surface must keep its body-level identity,
+// which is the only path LiteLLM's tag-budget check reads.
+func TestInject_OpenAIBodyStillRewritten(t *testing.T) {
+	mw := New(Config{Providers: []ProviderInjection{liteLLMRuleWithBody()}})
+
+	in := newInput(litellmProvider, "alice", []string{"grp-eng"})
+	in.Metadata = append(in.Metadata, middleware.KV{Key: middleware.KeyLLMProvider, Value: "openai"})
+	in.Body = []byte(`{"model":"gpt-4o-mini","messages":[]}`)
+
+	out, err := mw.Invoke(context.Background(), in)
+	require.NoError(t, err)
+	require.NotNil(t, out.Mutations)
+	require.NotEmpty(t, out.Mutations.BodyReplace, "the OpenAI surface still gets body tags")
+
+	var doc map[string]any
+	require.NoError(t, json.Unmarshal(out.Mutations.BodyReplace, &doc))
+	meta, ok := doc["metadata"].(map[string]any)
+	require.True(t, ok, "metadata must be an object")
+	assert.NotEmpty(t, meta["tags"], "metadata.tags must still be written")
+}
diff --git a/proxy/internal/middleware/builtin/llm_limit_check/middleware.go b/proxy/internal/middleware/builtin/llm_limit_check/middleware.go
index 42ac56b9b..1e7edcf42 100644
--- a/proxy/internal/middleware/builtin/llm_limit_check/middleware.go
+++ b/proxy/internal/middleware/builtin/llm_limit_check/middleware.go
@@ -84,6 +84,15 @@ func (m *Middleware) Invoke(ctx context.Context, in *middleware.Input) (*middlew
 		return allowNoAttribution(), nil
 	}
 
+	// Model-listing and other non-inference endpoints carry no model, and
+	// management's per-model allowlist fails closed on an empty one. The
+	// router has already authorised the route against the caller's groups
+	// and the request consumes no tokens, so gating it on a model that
+	// cannot exist would only break gateway model discovery.
+	if lookupKV(in.Metadata, middleware.KeyLLMNonInference) == "true" {
+		return allowNoAttribution(), nil
+	}
+
 	providerID := lookupKV(in.Metadata, middleware.KeyLLMResolvedProviderID)
 	if providerID == "" {
 		// llm_router didn't emit a resolved provider id — usually
@@ -117,7 +126,7 @@ func (m *Middleware) Invoke(ctx context.Context, in *middleware.Input) (*middlew
 	}
 
 	if resp.GetDecision() == "deny" {
-		return denyFromManagement(resp), nil
+		return denyFromManagement(resp, lookupKV(in.Metadata, middleware.KeyLLMProvider)), nil
 	}
 	return allowFromManagement(resp), nil
 }
@@ -161,7 +170,7 @@ func allowFromManagement(resp *proto.CheckLLMPolicyLimitsResponse) *middleware.O
 // envelope. The deny code surfaces verbatim through the framework's
 // fixed JSON template; arbitrary middleware bytes can't reach the
 // wire.
-func denyFromManagement(resp *proto.CheckLLMPolicyLimitsResponse) *middleware.Output {
+func denyFromManagement(resp *proto.CheckLLMPolicyLimitsResponse, surface string) *middleware.Output {
 	code := resp.GetDenyCode()
 	if code == "" {
 		code = "llm_policy.cap_exceeded"
@@ -176,6 +185,7 @@ func denyFromManagement(resp *proto.CheckLLMPolicyLimitsResponse) *middleware.Ou
 		DenyReason: &middleware.DenyReason{
 			Code:    code,
 			Message: denyMessageForCode(code),
+			Surface: surface,
 		},
 		Metadata: []middleware.KV{
 			{Key: middleware.KeyLLMPolicyDecision, Value: "deny"},
diff --git a/proxy/internal/middleware/builtin/llm_limit_check/middleware_test.go b/proxy/internal/middleware/builtin/llm_limit_check/middleware_test.go
index 87aa8e9e9..7754998ee 100644
--- a/proxy/internal/middleware/builtin/llm_limit_check/middleware_test.go
+++ b/proxy/internal/middleware/builtin/llm_limit_check/middleware_test.go
@@ -224,3 +224,35 @@ func TestMetadataKeys_Allowlist(t *testing.T) {
 	}
 	assert.ElementsMatch(t, want, keys)
 }
+
+// TestInvoke_NonInferenceSkipsPreflight covers gateway model discovery:
+// GET /v1/models carries no model, and management's per-model allowlist
+// fails closed on an empty one, so a pre-flight would deny discovery for
+// exactly the accounts that use the model allowlist. The router marks the
+// request non-inference after authorising the route, and the gate must
+// then allow without calling management at all.
+func TestInvoke_NonInferenceSkipsPreflight(t *testing.T) {
+	mgmt := &fakeMgmt{
+		checkResp: &proto.CheckLLMPolicyLimitsResponse{
+			Decision: "deny",
+			DenyCode: "llm_policy.model_blocked",
+		},
+	}
+	m := New(mgmt, nil)
+
+	out := runInvoke(t, m, &middleware.Input{
+		AccountID:  "acc-1",
+		UserID:     "user-bob",
+		UserGroups: []string{"grp-engineers"},
+		Metadata: []middleware.KV{
+			{Key: middleware.KeyLLMResolvedProviderID, Value: "prov-1"},
+			{Key: middleware.KeyLLMNonInference, Value: "true"},
+		},
+	})
+
+	assert.Equal(t, middleware.DecisionAllow, out.Decision, "model-less endpoints must not be gated on a model")
+	assert.Nil(t, mgmt.checkReq, "no pre-flight may be sent for a non-inference request")
+
+	assert.Empty(t, lookupKV(out.Metadata, middleware.KeyLLMSelectedPolicyID),
+		"no policy is attributed when nothing was metered")
+}
diff --git a/proxy/internal/middleware/builtin/llm_request_parser/bedrock_test.go b/proxy/internal/middleware/builtin/llm_request_parser/bedrock_test.go
index d8cd81437..82f44cb50 100644
--- a/proxy/internal/middleware/builtin/llm_request_parser/bedrock_test.go
+++ b/proxy/internal/middleware/builtin/llm_request_parser/bedrock_test.go
@@ -1,9 +1,13 @@
 package llm_request_parser
 
 import (
+	"context"
 	"testing"
 
+	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+
+	"github.com/netbirdio/netbird/proxy/internal/middleware"
 )
 
 func TestParseBedrockPath(t *testing.T) {
@@ -36,3 +40,25 @@ func TestParseBedrockPath(t *testing.T) {
 		}
 	}
 }
+
+// TestInvoke_BedrockCountTokens covers the dedicated token-counting
+// endpoint. Denying it does not break the client, it just pushes context
+// counting back onto the inference endpoint, which is billable.
+func TestInvoke_BedrockCountTokens(t *testing.T) {
+	mw := newMiddleware(t)
+
+	out, err := mw.Invoke(context.Background(), &middleware.Input{
+		URL:  "/model/us.anthropic.claude-sonnet-4-5-20250929-v1:0/count-tokens",
+		Body: []byte(`{"input":{"converse":{"messages":[]}}}`),
+	})
+	require.NoError(t, err)
+	require.NotNil(t, out)
+	assert.Equal(t, middleware.DecisionAllow, out.Decision)
+
+	model, ok := metaValue(t, out.Metadata, middleware.KeyLLMModel)
+	require.True(t, ok, "count-tokens carries a model in the path and must emit it")
+	assert.Equal(t, "anthropic.claude-sonnet-4-5", model, "model must be normalized like any other action")
+
+	stream, _ := metaValue(t, out.Metadata, middleware.KeyLLMStream)
+	assert.Equal(t, "false", stream, "count-tokens never streams")
+}
diff --git a/proxy/internal/middleware/builtin/llm_request_parser/middleware.go b/proxy/internal/middleware/builtin/llm_request_parser/middleware.go
index b4d1e16d4..7129c2298 100644
--- a/proxy/internal/middleware/builtin/llm_request_parser/middleware.go
+++ b/proxy/internal/middleware/builtin/llm_request_parser/middleware.go
@@ -61,6 +61,8 @@ func (middlewareImpl) MetadataKeys() []string {
 		middleware.KeyLLMRequestPromptRaw,
 		middleware.KeyLLMCaptureTruncated,
 		middleware.KeyLLMSessionID,
+		middleware.KeyLLMAgentID,
+		middleware.KeyLLMParentAgentID,
 	}
 }
 
@@ -72,9 +74,9 @@ func (middlewareImpl) Close() error { return nil }
 
 // Invoke detects the LLM provider, parses request facts, and emits
 // metadata. Always returns DecisionAllow; never errors. Provider
-// selection prefers the configured providerID (synthesiser-stamped on
-// agent-network targets) so requests routed to a custom upstream URL
-// still resolve. Falls back to URL sniffing when no providerID is set.
+// selection prefers the request path, falling back to the configured
+// providerID (synthesiser-stamped on agent-network targets) so requests
+// routed to a custom upstream URL still resolve.
 func (m middlewareImpl) Invoke(_ context.Context, in *middleware.Input) (*middleware.Output, error) {
 	out := &middleware.Output{Decision: middleware.DecisionAllow}
 	if in == nil {
@@ -92,9 +94,14 @@ func (m middlewareImpl) Invoke(_ context.Context, in *middleware.Input) (*middle
 		return m.invokeBedrock(in, br), nil
 	}
 
-	parser, ok := llm.ParserByName(m.providerID)
+	// A path that names an API surface wins over the configured providerID:
+	// a gateway record pinned to "openai" still serves Claude Code on
+	// /v1/messages, and reading that body with the OpenAI parser loses the
+	// Anthropic usage block and prices the request on the wrong surface.
+	// providerID stays the fallback for upstreams whose path says nothing.
+	parser, ok := llm.DetectParser(extractPath(in.URL))
 	if !ok {
-		parser, ok = llm.DetectParser(extractPath(in.URL))
+		parser, ok = llm.ParserByName(m.providerID)
 	}
 	if !ok {
 		return out, nil
@@ -116,9 +123,9 @@ func (m middlewareImpl) Invoke(_ context.Context, in *middleware.Input) (*middle
 	}
 	appendSessionID := func(md []middleware.KV) []middleware.KV {
 		if sessionID != "" {
-			return append(md, middleware.KV{Key: middleware.KeyLLMSessionID, Value: sessionID})
+			md = append(md, middleware.KV{Key: middleware.KeyLLMSessionID, Value: sessionID})
 		}
-		return md
+		return appendAgentIDs(md, in.Headers)
 	}
 
 	facts, err := parser.ParseRequest(in.Body)
@@ -160,6 +167,41 @@ func (m middlewareImpl) Invoke(_ context.Context, in *middleware.Input) (*middle
 	return out, nil
 }
 
+// agentIDHeader and parentAgentIDHeader carry sub-agent attribution: a
+// coding agent that spawns helpers stamps the spawned agent's id, plus the
+// spawning agent's when that helper is itself nested. Both are opaque
+// identifiers rather than content, so they're emitted regardless of the
+// prompt-collection toggle, the same way the session id is.
+const (
+	agentIDHeader       = "x-claude-code-agent-id"
+	parentAgentIDHeader = "x-claude-code-parent-agent-id"
+)
+
+// appendAgentIDs stamps the sub-agent attribution headers onto the metadata
+// bag, skipping either one the request doesn't carry.
+func appendAgentIDs(md []middleware.KV, headers []middleware.KV) []middleware.KV {
+	for _, pair := range []struct{ key, header string }{
+		{middleware.KeyLLMAgentID, agentIDHeader},
+		{middleware.KeyLLMParentAgentID, parentAgentIDHeader},
+	} {
+		if v := headerValue(headers, pair.header); v != "" {
+			md = append(md, middleware.KV{Key: pair.key, Value: v})
+		}
+	}
+	return md
+}
+
+// headerValue returns the first non-empty value for the named header.
+// Headers arrive in canonical form, so the match is case-insensitive.
+func headerValue(headers []middleware.KV, want string) string {
+	for _, kv := range headers {
+		if strings.EqualFold(kv.Key, want) && kv.Value != "" {
+			return kv.Value
+		}
+	}
+	return ""
+}
+
 // sessionIDHeaders are request header names that may carry a client
 // session identifier, checked in order, case-insensitively. Matching is
 // against Go's canonical header form, so use the hyphenated names the
@@ -173,10 +215,8 @@ var sessionIDHeaders = []string{"x-claude-code-session-id", "session-id", "x-ses
 // canonical form, so the match is case-insensitive.
 func sessionIDFromHeaders(headers []middleware.KV) string {
 	for _, want := range sessionIDHeaders {
-		for _, kv := range headers {
-			if strings.EqualFold(kv.Key, want) && kv.Value != "" {
-				return kv.Value
-			}
+		if v := headerValue(headers, want); v != "" {
+			return v
 		}
 	}
 	return ""
@@ -252,6 +292,12 @@ func parseVertexPath(reqPath string) (vertexRequest, bool) {
 	if c := strings.LastIndex(rest, ":"); c >= 0 {
 		model, action = rest[:c], rest[c+1:]
 	}
+	// Token counting hangs off the model as its own path segment
+	// (".../models/{model}/count-tokens:rawPredict"), so anything past the
+	// first "/" belongs to the method rather than the model id.
+	if slash := strings.Index(model, "/"); slash >= 0 {
+		model = model[:slash]
+	}
 	model = llm.NormalizeVertexModel(model)
 	if model == "" {
 		return vertexRequest{}, false
@@ -298,6 +344,7 @@ func (m middlewareImpl) invokeVertex(in *middleware.Input, vx vertexRequest) *mi
 	if sessionID != "" {
 		md = append(md, middleware.KV{Key: middleware.KeyLLMSessionID, Value: sessionID})
 	}
+	md = appendAgentIDs(md, in.Headers)
 
 	promptTruncated := false
 	if parser != nil && m.capturePrompt {
@@ -345,7 +392,9 @@ func trimBedrockNamespace(reqPath string) string {
 //
 //	/model/{modelId}/{action}
 //
-// action ∈ {invoke, invoke-with-response-stream, converse, converse-stream}.
+// action ∈ {invoke, invoke-with-response-stream, converse, converse-stream,
+// count-tokens}. Token counting carries a model and no usage, so it routes
+// like any other action and meters to zero.
 // The modelId may be URL-encoded and may carry a cross-region inference-profile
 // prefix and a version suffix; normalizeBedrockModel strips both so the model
 // matches catalog pricing.
@@ -369,7 +418,7 @@ func parseBedrockPath(reqPath string) (bedrockRequest, bool) {
 		return bedrockRequest{}, false
 	}
 	switch action {
-	case "invoke", "converse":
+	case "invoke", "converse", "count-tokens":
 		return bedrockRequest{model: model}, true
 	case "invoke-with-response-stream", "converse-stream":
 		return bedrockRequest{model: model, stream: true}, true
@@ -397,6 +446,7 @@ func (m middlewareImpl) invokeBedrock(in *middleware.Input, br bedrockRequest) *
 	if sessionID != "" {
 		md = append(md, middleware.KV{Key: middleware.KeyLLMSessionID, Value: sessionID})
 	}
+	md = appendAgentIDs(md, in.Headers)
 
 	promptTruncated := false
 	if parser != nil && m.capturePrompt {
diff --git a/proxy/internal/middleware/builtin/llm_request_parser/middleware_test.go b/proxy/internal/middleware/builtin/llm_request_parser/middleware_test.go
index bc185b295..8d8517860 100644
--- a/proxy/internal/middleware/builtin/llm_request_parser/middleware_test.go
+++ b/proxy/internal/middleware/builtin/llm_request_parser/middleware_test.go
@@ -45,6 +45,8 @@ func TestMiddleware_StaticSurface(t *testing.T) {
 		middleware.KeyLLMRequestPromptRaw,
 		middleware.KeyLLMCaptureTruncated,
 		middleware.KeyLLMSessionID,
+		middleware.KeyLLMAgentID,
+		middleware.KeyLLMParentAgentID,
 	}
 	assert.Equal(t, expected, keys, "metadata key allowlist must match the spec")
 }
@@ -230,6 +232,31 @@ func TestInvoke_ProviderIDConfigBypassesURLSniff(t *testing.T) {
 	assert.Equal(t, "gpt-4o-mini", model)
 }
 
+func TestInvoke_PathSurfaceBeatsProviderIDConfig(t *testing.T) {
+	// Gateway records (LiteLLM, Portkey, OpenRouter) pin provider_id
+	// "openai", but the same record serves Claude Code on /v1/messages.
+	// Parsing that body as OpenAI reads no usage off the Anthropic
+	// response and prices the request on a surface where no claude-*
+	// model exists, so the path has to win.
+	mw, err := Factory{}.New([]byte(`{"provider_id":"openai"}`))
+	require.NoError(t, err, "factory must accept provider_id config")
+
+	out, err := mw.Invoke(context.Background(), &middleware.Input{
+		URL:  "/v1/messages",
+		Body: []byte(`{"model":"claude-sonnet-5","stream":true,"messages":[{"role":"user","content":"Hi"}]}`),
+	})
+	require.NoError(t, err)
+	require.NotNil(t, out)
+
+	provider, ok := metaValue(t, out.Metadata, middleware.KeyLLMProvider)
+	require.True(t, ok, "provider must be emitted")
+	assert.Equal(t, "anthropic", provider, "the /v1/messages path selects the Anthropic surface")
+
+	model, ok := metaValue(t, out.Metadata, middleware.KeyLLMModel)
+	require.True(t, ok, "model must be extracted")
+	assert.Equal(t, "claude-sonnet-5", model)
+}
+
 func TestInvoke_UnknownProviderIDFallsBackToURL(t *testing.T) {
 	mw, err := Factory{}.New([]byte(`{"provider_id":"not-a-real-parser"}`))
 	require.NoError(t, err, "factory must accept any provider_id string")
@@ -416,3 +443,81 @@ func TestInvoke_NilInputAllows(t *testing.T) {
 	assert.Equal(t, middleware.DecisionAllow, out.Decision, "nil input still allows")
 	assert.Empty(t, out.Metadata, "nil input emits no metadata")
 }
+
+// TestParseVertexPath_CountTokensKeepsModel covers Vertex token counting,
+// where the method hangs off the model as its own path segment. Splitting
+// only on the final colon swallowed "/count-tokens" into the model id, so
+// the router saw a model no route could claim.
+func TestParseVertexPath_CountTokensKeepsModel(t *testing.T) {
+	cases := map[string]struct {
+		model  string
+		stream bool
+	}{
+		"/v1/projects/p/locations/global/publishers/anthropic/models/claude-sonnet-5:rawPredict":                       {model: "claude-sonnet-5"},
+		"/v1/projects/p/locations/global/publishers/anthropic/models/claude-sonnet-5:streamRawPredict":                 {model: "claude-sonnet-5", stream: true},
+		"/v1/projects/p/locations/global/publishers/anthropic/models/claude-sonnet-5/count-tokens:rawPredict":          {model: "claude-sonnet-5"},
+		"/v1/projects/p/locations/global/publishers/anthropic/models/claude-sonnet-5@20250929/count-tokens:rawPredict": {model: "claude-sonnet-5"},
+	}
+	for path, want := range cases {
+		vx, ok := parseVertexPath(path)
+		require.True(t, ok, "must parse %q", path)
+		assert.Equal(t, want.model, vx.model, "model for %q", path)
+		assert.Equal(t, want.stream, vx.stream, "stream flag for %q", path)
+		assert.Equal(t, "anthropic", vx.publisher, "publisher for %q", path)
+	}
+}
+
+// TestInvoke_EmitsAgentIDs covers sub-agent attribution: several agents run
+// in parallel inside one session, and without their ids every request in
+// the session attributes to the session alone.
+func TestInvoke_EmitsAgentIDs(t *testing.T) {
+	mw := newMiddleware(t)
+
+	t.Run("spawned agent", func(t *testing.T) {
+		out, err := mw.Invoke(context.Background(), &middleware.Input{
+			URL:  "/v1/messages",
+			Body: []byte(`{"model":"claude-sonnet-5","messages":[]}`),
+			Headers: []middleware.KV{
+				{Key: "X-Claude-Code-Session-Id", Value: "sess-1"},
+				{Key: "X-Claude-Code-Agent-Id", Value: "agent-7"},
+			},
+		})
+		require.NoError(t, err)
+
+		agent, ok := metaValue(t, out.Metadata, middleware.KeyLLMAgentID)
+		require.True(t, ok, "the spawned agent's id must be emitted")
+		assert.Equal(t, "agent-7", agent)
+
+		_, ok = metaValue(t, out.Metadata, middleware.KeyLLMParentAgentID)
+		assert.False(t, ok, "a top-level agent has no parent to emit")
+	})
+
+	t.Run("nested agent", func(t *testing.T) {
+		out, err := mw.Invoke(context.Background(), &middleware.Input{
+			URL:  "/v1/messages",
+			Body: []byte(`{"model":"claude-sonnet-5","messages":[]}`),
+			Headers: []middleware.KV{
+				{Key: "X-Claude-Code-Agent-Id", Value: "agent-9"},
+				{Key: "X-Claude-Code-Parent-Agent-Id", Value: "agent-7"},
+			},
+		})
+		require.NoError(t, err)
+
+		agent, _ := metaValue(t, out.Metadata, middleware.KeyLLMAgentID)
+		assert.Equal(t, "agent-9", agent)
+		parent, ok := metaValue(t, out.Metadata, middleware.KeyLLMParentAgentID)
+		require.True(t, ok, "a nested agent must carry the spawning agent's id")
+		assert.Equal(t, "agent-7", parent)
+	})
+
+	t.Run("absent on a plain request", func(t *testing.T) {
+		out, err := mw.Invoke(context.Background(), &middleware.Input{
+			URL:  "/v1/messages",
+			Body: []byte(`{"model":"claude-sonnet-5","messages":[]}`),
+		})
+		require.NoError(t, err)
+
+		_, ok := metaValue(t, out.Metadata, middleware.KeyLLMAgentID)
+		assert.False(t, ok, "no key is emitted when the client sends no agent id")
+	})
+}
diff --git a/proxy/internal/middleware/builtin/llm_router/bedrock_discovery_test.go b/proxy/internal/middleware/builtin/llm_router/bedrock_discovery_test.go
new file mode 100644
index 000000000..d21e33c21
--- /dev/null
+++ b/proxy/internal/middleware/builtin/llm_router/bedrock_discovery_test.go
@@ -0,0 +1,175 @@
+package llm_router
+
+import (
+	"context"
+	"net/http"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/netbirdio/netbird/proxy/internal/middleware"
+)
+
+// bedrockRoute is a Bedrock provider whose listing lives on the control plane
+// while inference goes to the runtime host — the split this file is about.
+func bedrockRoute(models []string, policies []ModelPolicyRule) ProviderRoute {
+	return ProviderRoute{
+		ID:              "prov-bedrock",
+		Bedrock:         true,
+		Models:          models,
+		ModelPolicies:   policies,
+		UpstreamScheme:  "https",
+		UpstreamHost:    "bedrock-runtime.eu-central-1.amazonaws.com",
+		DiscoveryHost:   "bedrock.eu-central-1.amazonaws.com",
+		AuthHeaderName:  "Authorization",
+		AuthHeaderValue: "Bearer aws-token",
+		AllowedGroupIDs: []string{defaultTestGroup},
+	}
+}
+
+func getInput(path string) *middleware.Input {
+	return &middleware.Input{
+		Slot:       middleware.SlotOnRequest,
+		Method:     http.MethodGet,
+		URL:        "https://endpoint.netbird.local" + path,
+		UserGroups: []string{defaultTestGroup},
+	}
+}
+
+// TestBedrockListingGoesToTheControlPlane is the whole point of DiscoveryHost.
+// ListInferenceProfiles is not an operation bedrock-runtime implements — it
+// answers  — so a listing forwarded to the
+// inference upstream can only 404, however well it is routed.
+func TestBedrockListingGoesToTheControlPlane(t *testing.T) {
+	mw := New(Config{Providers: []ProviderRoute{bedrockRoute(nil, nil)}})
+
+	out, err := mw.Invoke(context.Background(), getInput("/inference-profiles"))
+	require.NoError(t, err)
+	require.Equal(t, middleware.DecisionAllow, out.Decision)
+	require.NotNil(t, out.Mutations)
+	require.NotNil(t, out.Mutations.RewriteUpstream)
+
+	assert.Equal(t, "bedrock.eu-central-1.amazonaws.com", out.Mutations.RewriteUpstream.Host)
+}
+
+// TestBedrockInferenceStillGoesToTheRuntimeHost is the other half: the
+// redirect must apply to the listing alone. Sending an InvokeModel call to the
+// control plane would break every Bedrock request in the account.
+func TestBedrockInferenceStillGoesToTheRuntimeHost(t *testing.T) {
+	mw := New(Config{Providers: []ProviderRoute{bedrockRoute(nil, nil)}})
+
+	in := newInputWithModelAndURL("anthropic.claude-haiku-4-5",
+		"https://endpoint.netbird.local/model/eu.anthropic.claude-haiku-4-5-20251001-v1:0/invoke")
+	out, err := mw.Invoke(context.Background(), in)
+	require.NoError(t, err)
+	require.Equal(t, middleware.DecisionAllow, out.Decision)
+	require.NotNil(t, out.Mutations.RewriteUpstream)
+
+	assert.Equal(t, "bedrock-runtime.eu-central-1.amazonaws.com", out.Mutations.RewriteUpstream.Host)
+}
+
+// TestIsListingPath guards the narrower reading of "model-less". Both the
+// upstream redirect and the policy bound key on this, and the warming probe
+// must be excluded from both: it carries no listing to filter, and pointing it
+// at the control plane would warm a pool the inference requests never use.
+func TestIsListingPath(t *testing.T) {
+	for path, want := range map[string]bool{
+		"/v1/models":                  true,
+		"/inference-profiles":         true,
+		"/bedrock/inference-profiles": true,
+		"/api/hello":                  false,
+		"/v1/models/gpt-4o":           false, // the per-model lookup, routed elsewhere
+		"/v1/chat/completions":        false,
+	} {
+		t.Run(path, func(t *testing.T) {
+			assert.Equal(t, want, isListingPath(path))
+		})
+	}
+}
+
+// TestBedrockListingIsBoundByPolicy covers the case that was previously
+// unreachable: filtering keyed on /v1/models alone, so a Bedrock listing was
+// routed but never narrowed to what the caller may use.
+func TestBedrockListingIsBoundByPolicy(t *testing.T) {
+	route := bedrockRoute(
+		[]string{"eu.anthropic.claude-haiku-4-5-20251001-v1:0", "eu.anthropic.claude-sonnet-4-6"},
+		[]ModelPolicyRule{{
+			GroupIDs: []string{defaultTestGroup},
+			// A guardrail allowlist names the catalog key, which is the form an
+			// operator picks in the UI — not the region-prefixed wire id the
+			// record registers.
+			Models: []string{"anthropic.claude-haiku-4-5"},
+		}},
+	)
+	mw := New(Config{Providers: []ProviderRoute{route}})
+
+	out, err := mw.Invoke(context.Background(), getInput("/inference-profiles"))
+	require.NoError(t, err)
+	require.NotNil(t, out.Mutations.RewriteUpstream)
+
+	// Exact-string intersection would find nothing here and bound the listing
+	// to empty, handing the caller a picker with no models on a provider that
+	// works perfectly well.
+	assert.Equal(t, []string{"eu.anthropic.claude-haiku-4-5-20251001-v1:0"},
+		out.Mutations.RewriteUpstream.DiscoveryModels)
+}
+
+// TestBedrockListingWithoutADiscoveryHostFallsThrough keeps a proxied or
+// self-hosted Bedrock endpoint working: the synthesiser emits no discovery
+// host for one, and the listing must then go to the configured upstream rather
+// than nowhere.
+func TestBedrockListingWithoutADiscoveryHostFallsThrough(t *testing.T) {
+	route := bedrockRoute(nil, nil)
+	route.UpstreamHost = "bedrock.internal.example.com"
+	route.DiscoveryHost = ""
+	mw := New(Config{Providers: []ProviderRoute{route}})
+
+	out, err := mw.Invoke(context.Background(), getInput("/inference-profiles"))
+	require.NoError(t, err)
+	require.NotNil(t, out.Mutations.RewriteUpstream)
+
+	assert.Equal(t, "bedrock.internal.example.com", out.Mutations.RewriteUpstream.Host)
+}
+
+// TestBedrockProfileDetailHonoursTheModelTable covers GetInferenceProfile,
+// which the listing filter cannot help with: it answers for one profile with a
+// single object, not a set, so nothing narrows it on the way back. Authorising
+// it by provider type alone would let any caller with a Bedrock route read the
+// full configuration of every profile in the account.
+//
+// Both registration spellings are exercised, because a record may carry the
+// raw profile id AWS issues or the catalog key it reduces to.
+func TestBedrockProfileDetailHonoursTheModelTable(t *testing.T) {
+	const permitted = "eu.anthropic.claude-sonnet-5-20260514-v1:0"
+
+	for _, registered := range []string{permitted, "anthropic.claude-sonnet-5"} {
+		t.Run(registered, func(t *testing.T) {
+			mw := New(Config{Providers: []ProviderRoute{bedrockRoute([]string{registered}, nil)}})
+
+			out, err := mw.Invoke(context.Background(), getInput("/inference-profiles/"+permitted))
+			require.NoError(t, err)
+			assert.Equal(t, middleware.DecisionAllow, out.Decision,
+				"a profile the record registers must still resolve")
+
+			denied, err := mw.Invoke(context.Background(),
+				getInput("/inference-profiles/eu.anthropic.claude-opus-5-20260514-v1:0"))
+			require.NoError(t, err)
+			assert.Equal(t, middleware.DecisionDeny, denied.Decision,
+				"a profile outside the record's models must not be readable")
+		})
+	}
+}
+
+// TestBedrockProfileListingStaysModelLess pins the other half: the listing
+// names no profile, so it must not be judged against the model table. It is
+// bounded by DiscoveryModels in the response instead, and denying it here
+// would take model discovery away from exactly the records that enumerate
+// their models.
+func TestBedrockProfileListingStaysModelLess(t *testing.T) {
+	mw := New(Config{Providers: []ProviderRoute{bedrockRoute([]string{"anthropic.claude-sonnet-5"}, nil)}})
+
+	out, err := mw.Invoke(context.Background(), getInput("/inference-profiles"))
+	require.NoError(t, err)
+	assert.Equal(t, middleware.DecisionAllow, out.Decision)
+}
diff --git a/proxy/internal/middleware/builtin/llm_router/bedrock_route_test.go b/proxy/internal/middleware/builtin/llm_router/bedrock_route_test.go
index 40cbcb6bd..badd358c5 100644
--- a/proxy/internal/middleware/builtin/llm_router/bedrock_route_test.go
+++ b/proxy/internal/middleware/builtin/llm_router/bedrock_route_test.go
@@ -1,9 +1,13 @@
 package llm_router
 
 import (
+	"context"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/netbirdio/netbird/proxy/internal/middleware"
 )
 
 // TestRouteClaimsModel_BedrockNormalizesCandidate guards the fix for the native
@@ -28,3 +32,86 @@ func TestRouteClaimsModel_BedrockNormalizesCandidate(t *testing.T) {
 	assert.False(t, routeClaimsModel(openai, "us.gpt-4o"),
 		"non-Bedrock routes must not strip a us. prefix")
 }
+
+// TestRouter_BedrockCountTokensRoutes pins that the token-counting action
+// reaches the Bedrock route instead of denying as not-routable.
+func TestRouter_BedrockCountTokensRoutes(t *testing.T) {
+	mw := New(Config{Providers: []ProviderRoute{{
+		ID:              "bedrock-prod",
+		Bedrock:         true,
+		Models:          []string{"anthropic.claude-sonnet-4-5"},
+		AllowedGroupIDs: []string{defaultTestGroup},
+		UpstreamScheme:  "https",
+		UpstreamHost:    "bedrock-runtime.eu-central-1.amazonaws.com",
+	}}})
+
+	in := newInputWithModelAndURL("anthropic.claude-sonnet-4-5",
+		"/model/anthropic.claude-sonnet-4-5-20250929-v1:0/count-tokens")
+	in.Metadata = append(in.Metadata, middleware.KV{Key: middleware.KeyLLMProvider, Value: "bedrock"})
+
+	out, err := mw.Invoke(context.Background(), in)
+	require.NoError(t, err)
+	require.NotNil(t, out)
+	assert.Equal(t, middleware.DecisionAllow, out.Decision, "count-tokens must route, not deny")
+	require.NotNil(t, out.Mutations)
+	require.NotNil(t, out.Mutations.RewriteUpstream)
+	assert.Equal(t, "bedrock-runtime.eu-central-1.amazonaws.com", out.Mutations.RewriteUpstream.Host)
+}
+
+// TestRouter_BedrockInferenceProfilesRoutes covers the startup lookups a
+// client makes to resolve a configured inference profile. They carry no
+// model, so before they were recognised they denied and wrote a policy
+// rejection into the access log on every session start.
+func TestRouter_BedrockInferenceProfilesRoutes(t *testing.T) {
+	bedrock := ProviderRoute{
+		ID:              "bedrock-prod",
+		Bedrock:         true,
+		AllowedGroupIDs: []string{defaultTestGroup},
+		UpstreamScheme:  "https",
+		UpstreamHost:    "bedrock-runtime.eu-central-1.amazonaws.com",
+	}
+	openai := ProviderRoute{
+		ID:              "openai-prod",
+		Models:          []string{"gpt-4o"},
+		AllowedGroupIDs: []string{defaultTestGroup},
+		UpstreamScheme:  "https",
+		UpstreamHost:    "api.openai.com",
+	}
+	mw := New(Config{Providers: []ProviderRoute{openai, bedrock}})
+
+	for _, path := range []string{
+		"/inference-profiles?type=SYSTEM_DEFINED",
+		"/inference-profiles/us.anthropic.claude-sonnet-5",
+	} {
+		out, err := mw.Invoke(context.Background(), newModellessInput(path))
+		require.NoError(t, err)
+		require.NotNil(t, out)
+		assert.Equal(t, middleware.DecisionAllow, out.Decision, "%s must route", path)
+		require.NotNil(t, out.Mutations)
+		require.NotNil(t, out.Mutations.RewriteUpstream)
+		assert.Equal(t, "bedrock-runtime.eu-central-1.amazonaws.com", out.Mutations.RewriteUpstream.Host,
+			"%s must reach the Bedrock provider, not the first authorised one", path)
+
+		nonInference, _ := metaValue(t, out.Metadata, middleware.KeyLLMNonInference)
+		assert.Equal(t, "true", nonInference, "%s carries no model to gate on", path)
+	}
+}
+
+// TestRouter_BedrockNamespacedInferenceProfilesStripsPrefix pins that the
+// optional gateway namespace is removed before the request goes upstream.
+func TestRouter_BedrockNamespacedInferenceProfilesStripsPrefix(t *testing.T) {
+	mw := New(Config{Providers: []ProviderRoute{{
+		ID:              "bedrock-prod",
+		Bedrock:         true,
+		AllowedGroupIDs: []string{defaultTestGroup},
+		UpstreamScheme:  "https",
+		UpstreamHost:    "bedrock-runtime.eu-central-1.amazonaws.com",
+	}}})
+
+	out, err := mw.Invoke(context.Background(), newModellessInput("/bedrock/inference-profiles"))
+	require.NoError(t, err)
+	require.NotNil(t, out.Mutations)
+	require.NotNil(t, out.Mutations.RewriteUpstream)
+	assert.Equal(t, "/bedrock", out.Mutations.RewriteUpstream.StripPathPrefix,
+		"the namespace prefix must not reach the real Bedrock endpoint")
+}
diff --git a/proxy/internal/middleware/builtin/llm_router/factory.go b/proxy/internal/middleware/builtin/llm_router/factory.go
index 938a23ebe..81b8727f1 100644
--- a/proxy/internal/middleware/builtin/llm_router/factory.go
+++ b/proxy/internal/middleware/builtin/llm_router/factory.go
@@ -44,6 +44,19 @@ type ProviderRoute struct {
 	AuthHeaderName  string   `json:"auth_header_name"`
 	AuthHeaderValue string   `json:"auth_header_value"`
 	AllowedGroupIDs []string `json:"allowed_group_ids"`
+	// ModelPolicies carries, per authorising policy, the source groups it
+	// binds and the models it permits. The router uses it to bound a model
+	// listing to what THIS caller may use: a provider reachable by two groups
+	// under different allowlists must not offer either group the other's
+	// models. Empty means no policy restricts models on this route.
+	ModelPolicies []ModelPolicyRule `json:"model_policies,omitempty"`
+	// DiscoveryHost, when set, is the host that serves this provider's model
+	// listing, for a vendor that does not serve it from the same host as
+	// inference. Bedrock is why it exists: ListInferenceProfiles is a control
+	// plane operation on bedrock., while InvokeModel must go to
+	// bedrock-runtime., so one record genuinely needs two hosts.
+	// Empty means the listing is served from UpstreamHost like everything else.
+	DiscoveryHost string `json:"discovery_host,omitempty"`
 	// Vertex marks a Google Vertex AI provider. Vertex requests carry the
 	// model in the URL path, so the router selects this route by path
 	// (isVertexPath) and bypasses the model/vendor table entirely.
@@ -65,6 +78,18 @@ type ProviderRoute struct {
 	SkipTLSVerify bool `json:"skip_tls_verify,omitempty"`
 }
 
+// ModelPolicyRule is one authorising policy's contribution to what a caller
+// may use on a route: the source groups it binds, and the models it permits.
+//
+// Models is nil when the policy sets no model allowlist — an unrestricted
+// policy, which lifts the restriction for the groups it binds. That is why
+// nil and empty must stay distinct: an empty list is a guardrail that permits
+// nothing, and collapsing the two would let a listing fail open.
+type ModelPolicyRule struct {
+	GroupIDs []string `json:"group_ids"`
+	Models   []string `json:"models"`
+}
+
 // Config is the on-wire configuration accepted by the factory. An
 // empty Providers slice yields a router that denies every request as
 // not-routable; the synthesiser is responsible for stamping the
diff --git a/proxy/internal/middleware/builtin/llm_router/middleware.go b/proxy/internal/middleware/builtin/llm_router/middleware.go
index 2d987eef6..b8d4b001b 100644
--- a/proxy/internal/middleware/builtin/llm_router/middleware.go
+++ b/proxy/internal/middleware/builtin/llm_router/middleware.go
@@ -109,6 +109,10 @@ func (m *Middleware) MetadataKeys() []string {
 		middleware.KeyLLMAuthorisingGroups,
 		middleware.KeyLLMPolicyDecision,
 		middleware.KeyLLMPolicyReason,
+		middleware.KeyLLMNonInference,
+		// Emitted only for the per-model lookup, whose model lives in the path
+		// rather than a body the parser could read.
+		middleware.KeyLLMModel,
 	}
 }
 
@@ -137,29 +141,26 @@ const (
 // known to a provider that no policy authorises for the caller deny
 // with no_authorised_provider.
 func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middleware.Output, error) {
+	reqPath := requestPath(in.URL)
+	// The caller's API dialect, used to mirror a denial in the vendor's own
+	// error shape so the client can explain it to the user.
+	surface, _ := lookupMetadata(in.Metadata, middleware.KeyLLMProvider)
+	model, _ := lookupMetadata(in.Metadata, middleware.KeyLLMModel)
+
 	// Vertex AI carries the model in the URL path, not the body, and is
 	// selected by path rather than by the model/vendor table. Route it before
 	// the model lookup so a model the parser extracted from the path can't be
 	// claimed by a same-vendor direct provider (e.g. claude-* on api.anthropic.com).
-	reqPath := requestPath(in.URL)
 	if isVertexPath(reqPath) {
-		model, _ := lookupMetadata(in.Metadata, middleware.KeyLLMModel)
 		// The request parser emits no llm.provider for a Vertex publisher it
 		// can't parse (e.g. google/gemini). Forwarding such a request would
 		// bypass token/budget metering, so deny it rather than serve it
 		// unmetered.
-		if vendor, _ := lookupMetadata(in.Metadata, middleware.KeyLLMProvider); vendor == "" {
-			return denyUnmeterable(), nil
+		if surface == "" {
+			return denyUnmeterable(surface), nil
 		}
 		route, outcome := m.matchVertex(reqPath, model, in.UserGroups)
-		switch outcome {
-		case matchOutcomeFound:
-			return m.allowWithRoute(route, in.UserGroups), nil
-		case matchOutcomeUnauthorised:
-			return denyNoAuthorisedRoute(model), nil
-		default:
-			return denyUnknownModel(model), nil
-		}
+		return m.decide(route, outcome, surface, model, in.UserGroups, nil), nil
 	}
 
 	// Bedrock likewise carries the model in the URL path (/model/{id}/{action}),
@@ -167,52 +168,231 @@ func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middlewar
 	// before the model lookup; when the prefix is present, strip it from the
 	// forwarded path so the real Bedrock endpoint receives its native path.
 	if isBedrockPath(reqPath) {
-		model, _ := lookupMetadata(in.Metadata, middleware.KeyLLMModel)
 		native, hadPrefix := splitBedrockNamespace(reqPath)
 		route, outcome := m.matchBedrock(native, model, in.UserGroups)
-		switch outcome {
-		case matchOutcomeFound:
-			out := m.allowWithRoute(route, in.UserGroups)
-			if hadPrefix && out.Mutations != nil && out.Mutations.RewriteUpstream != nil {
-				out.Mutations.RewriteUpstream.StripPathPrefix = bedrockNamespacePrefix
+		return m.decide(route, outcome, surface, model, in.UserGroups, func(out *middleware.Output) {
+			if hadPrefix {
+				stripBedrockNamespace(out)
 			}
-			return out, nil
-		case matchOutcomeUnauthorised:
-			return denyNoAuthorisedRoute(model), nil
-		default:
-			return denyUnknownModel(model), nil
-		}
+		}), nil
 	}
 
-	model, ok := lookupMetadata(in.Metadata, middleware.KeyLLMModel)
-	if !ok || model == "" {
-		// Non-inference endpoints (model listing) carry no model but still
-		// need rewriting from the synth placeholder to a real upstream;
-		// clients such as Codex call GET /v1/models at startup to enumerate
-		// availability and read a 403 as "model unavailable".
-		route, outcome := m.matchModelless(requestPath(in.URL), in.UserGroups)
-		switch outcome {
-		case matchOutcomeFound:
-			return m.allowWithRoute(route, in.UserGroups), nil
-		case matchOutcomeUnauthorised:
-			// A recognised model-less endpoint exists but no provider
-			// authorises the caller — deny as an authorisation failure
-			// rather than masking it as a missing model.
-			return denyNoAuthorisedRoute(model), nil
-		default:
-			return denyMissingModel(), nil
-		}
+	// GET /v1/models/{id} carries no body, so no model reaches the router in
+	// metadata — but the path names one, and answering it confirms a model
+	// exists and is reachable. Authorise it against the model table like any
+	// other per-model request, then mark it non-inference so it still skips
+	// the token pre-flight it would otherwise charge nothing against.
+	if detail, isDetail := modelDetailID(reqPath); isDetail && isNonInferenceMethod(in.Method) {
+		route, outcome := m.matchRoute(detail, surface, reqPath, in.UserGroups)
+		return m.decide(route, outcome, surface, detail, in.UserGroups, func(out *middleware.Output) {
+			markNonInference(out)
+			// The parser reads models from JSON bodies only, and this request
+			// has none, so stamp the one the path names. Without it the
+			// guardrail's own allowlist — a separate, possibly narrower list
+			// than the route's — never sees a model to check.
+			out.Metadata = append(out.Metadata, middleware.KV{Key: middleware.KeyLLMModel, Value: detail})
+		}), nil
 	}
 
-	vendor, _ := lookupMetadata(in.Metadata, middleware.KeyLLMProvider)
-	route, outcome := m.matchRoute(model, vendor, requestPath(in.URL), in.UserGroups)
+	if model == "" {
+		return m.routeModelless(reqPath, surface, in.Method, in.UserGroups), nil
+	}
+
+	route, outcome := m.matchRoute(model, surface, reqPath, in.UserGroups)
+	return m.decide(route, outcome, surface, model, in.UserGroups, nil), nil
+}
+
+// decide turns a per-model match result into the middleware's decision. Every
+// surface that routes by model shares the same two denial arms — a model no
+// route claims is not routable, one that some route claims but none authorises
+// for this caller is an authorisation failure — so they live here once.
+// decorate, when non-nil, adjusts the allow with whatever that surface needs.
+func (m *Middleware) decide(
+	route ProviderRoute,
+	outcome matchOutcome,
+	surface, model string,
+	userGroups []string,
+	decorate func(*middleware.Output),
+) *middleware.Output {
 	switch outcome {
 	case matchOutcomeFound:
-		return m.allowWithRoute(route, in.UserGroups), nil
+		out := m.allowWithRoute(route, surface, userGroups)
+		if decorate != nil {
+			decorate(out)
+		}
+		return out
 	case matchOutcomeUnauthorised:
-		return denyNoAuthorisedRoute(model), nil
+		return denyNoAuthorisedRoute(surface, model)
 	default:
-		return denyUnknownModel(model), nil
+		return denyUnknownModel(surface, model)
+	}
+}
+
+// routeModelless serves the endpoints that name no model at all: the model
+// listing, the connection-warming probe, and the Bedrock inference-profile
+// lookup. They still need rewriting from the synth placeholder to a real
+// upstream — clients such as Codex call GET /v1/models at startup to enumerate
+// availability and read a 403 as "model unavailable".
+func (m *Middleware) routeModelless(reqPath, surface, method string, userGroups []string) *middleware.Output {
+	route, outcome := m.matchModelless(reqPath, method, userGroups)
+	switch outcome {
+	case matchOutcomeFound:
+		out := m.allowWithRoute(route, surface, userGroups)
+		markNonInference(out)
+		if _, hadPrefix := splitBedrockNamespace(reqPath); hadPrefix {
+			stripBedrockNamespace(out)
+		}
+		if isListingPath(reqPath) && out.Mutations != nil && out.Mutations.RewriteUpstream != nil {
+			// A vendor that serves its listing from somewhere other than its
+			// inference upstream is redirected here, and only for the listing
+			// — every other request still goes to the configured upstream.
+			if route.DiscoveryHost != "" {
+				out.Mutations.RewriteUpstream.Host = route.DiscoveryHost
+			}
+			// What the caller may actually use bounds what the picker may
+			// offer: every entry outside it is a request the chain will deny a
+			// moment later.
+			if models, bounded := discoverableModels(route, userGroups); bounded {
+				out.Mutations.RewriteUpstream.DiscoveryModels = models
+			}
+		}
+		return out
+	case matchOutcomeUnauthorised:
+		// A recognised model-less endpoint exists but no provider authorises
+		// the caller — deny as an authorisation failure rather than masking it
+		// as a missing model.
+		return denyNoAuthorisedRoute(surface, "")
+	default:
+		return denyMissingModel(surface)
+	}
+}
+
+// isNonInferenceMethod reports whether a request method is one the
+// non-inference endpoints actually use: the listing and the per-model lookup
+// are GET, the connection-warming probe is HEAD or GET. The method is the only
+// thing separating "GET /v1/models/{id}" from a POST to the same path carrying
+// an inference body, and the non-inference mark exempts a request from the
+// token pre-flight — so anything else falls through to normal per-model
+// routing, which denies when the request names no model.
+func isNonInferenceMethod(method string) bool {
+	return method == http.MethodGet || method == http.MethodHead
+}
+
+// discoverableModels returns the model ids a caller in userGroups may actually
+// use on this route, and whether the listing should be bounded to them at all.
+//
+// Two things narrow a listing, and both must apply or the picker offers models
+// the very next request refuses:
+//
+//   - the provider's own enumerated models, when it lists any (a gateway record
+//     enumerates nothing and claims everything);
+//   - the model allowlists of the policies that authorise THIS caller. A
+//     provider reachable by two groups under different allowlists must not
+//     offer either group the other's models, which is why the rules carry their
+//     source groups rather than arriving pre-flattened.
+//
+// A policy that sets no allowlist lifts the restriction for the groups it
+// binds, so a caller holding one unrestricted policy sees the provider's full
+// list. bounded is false when nothing narrows the listing — an unrestricted
+// caller on a route that enumerates nothing — in which case the upstream's own
+// answer passes through untouched.
+func discoverableModels(route ProviderRoute, userGroups []string) ([]string, bool) {
+	permitted, restricted := policyPermittedModels(route, userGroups)
+
+	switch {
+	case !restricted && len(route.Models) == 0:
+		return nil, false
+	case !restricted:
+		return append([]string(nil), route.Models...), true
+	case len(route.Models) == 0:
+		// A gateway record enumerates nothing, so the allowlist is the whole
+		// bound — previously such a record offered the upstream's entire
+		// catalogue however narrow the policy was.
+		return sortedModels(permitted), true
+	}
+
+	// Both bound: only what the provider serves and the policy permits.
+	intersection := make(map[string]struct{}, len(route.Models))
+	for _, m := range route.Models {
+		if _, ok := permitted[m]; ok {
+			intersection[m] = struct{}{}
+			continue
+		}
+		// The two sides are not always written the same way. A Bedrock record
+		// may register the raw inference-profile id an operator copied from
+		// AWS while a guardrail allowlist names the catalog key, and comparing
+		// those verbatim finds nothing — which would bound a correctly
+		// configured provider's listing down to empty. routeClaimsModel
+		// already normalises the candidate for exactly this reason, and the
+		// listing bound has to agree with it or the picker disagrees with what
+		// the guardrail will actually allow.
+		if route.Bedrock {
+			if _, ok := permitted[llm.NormalizeBedrockModel(m)]; ok {
+				intersection[m] = struct{}{}
+			}
+		}
+	}
+	return sortedModels(intersection), true
+}
+
+// policyPermittedModels folds the rules whose groups intersect the caller's
+// into the set of models they permit. restricted is false when the caller
+// holds at least one authorising policy that sets no allowlist, or when no
+// rule binds them at all.
+func policyPermittedModels(route ProviderRoute, userGroups []string) (map[string]struct{}, bool) {
+	permitted := make(map[string]struct{})
+	restricted := false
+	for _, rule := range route.ModelPolicies {
+		if !groupsIntersect(rule.GroupIDs, userGroups) {
+			continue
+		}
+		if rule.Models == nil {
+			// An unrestricted policy the caller holds lifts the restriction
+			// entirely, whatever the others say.
+			return nil, false
+		}
+		restricted = true
+		for _, m := range rule.Models {
+			permitted[m] = struct{}{}
+		}
+	}
+	return permitted, restricted
+}
+
+// groupsIntersect reports whether the two group-id sets share a member.
+func groupsIntersect(a, b []string) bool {
+	for _, x := range a {
+		for _, y := range b {
+			if x == y {
+				return true
+			}
+		}
+	}
+	return false
+}
+
+// sortedModels flattens a model set into a stable slice so the bound the proxy
+// applies — and any test asserting on it — does not depend on map order.
+func sortedModels(set map[string]struct{}) []string {
+	out := make([]string, 0, len(set))
+	for m := range set {
+		out = append(out, m)
+	}
+	sort.Strings(out)
+	return out
+}
+
+// markNonInference tags an allow as a request that spends no tokens, so the
+// limit check skips the management pre-flight it would charge nothing against.
+func markNonInference(out *middleware.Output) {
+	out.Metadata = append(out.Metadata, middleware.KV{Key: middleware.KeyLLMNonInference, Value: "true"})
+}
+
+// stripBedrockNamespace tells the rewrite to drop the optional "/bedrock"
+// gateway namespace so the upstream receives its native Bedrock path.
+func stripBedrockNamespace(out *middleware.Output) {
+	if out.Mutations != nil && out.Mutations.RewriteUpstream != nil {
+		out.Mutations.RewriteUpstream.StripPathPrefix = bedrockNamespacePrefix
 	}
 }
 
@@ -300,12 +480,91 @@ func (m *Middleware) matchRoute(model, vendor, reqPath string, userGroups []stri
 	return best, matchOutcomeFound
 }
 
-// isModelLessPath reports whether reqPath is a known OpenAI-shaped
-// non-inference endpoint that legitimately carries no model in its
-// request (the model-listing endpoints). These must route to an upstream
-// rather than deny, so model enumeration works end to end.
+// connectionWarmPath is the probe Anthropic clients send before their first
+// inference request to open the upstream connection early. Forwarding it
+// warms the connection the request will actually use; denying it only fills
+// the access log with rejections at every session start.
+const connectionWarmPath = "/api/hello"
+
+// modelListingPath is the endpoint clients read at startup to populate
+// their model picker. Its response is a list the proxy can bound; the
+// per-model "/v1/models/{id}" lookup returns a single object and is left
+// alone.
+const modelListingPath = "/v1/models"
+
+// isListingPath reports whether reqPath asks for a MODEL LISTING, as opposed
+// to the other model-less endpoints. Only a listing gets an upstream redirect
+// and a policy bound: the connection-warming probe carries no model list to
+// filter, and rewriting its host would send the warm-up to the wrong pool.
+func isListingPath(reqPath string) bool {
+	return reqPath == modelListingPath || isBedrockModelLessPath(reqPath)
+}
+
+// isModelLessPath reports whether reqPath is a known non-inference endpoint
+// that legitimately carries no model at all: the model listing and the
+// connection-warming probe. These must route to an upstream rather than
+// deny, so model enumeration works end to end. The per-model
+// "/v1/models/{id}" lookup is deliberately excluded — it names a model, so
+// it is authorised against the model table instead (see modelDetailID).
 func isModelLessPath(reqPath string) bool {
-	return reqPath == "/v1/models" || strings.HasPrefix(reqPath, "/v1/models/")
+	return reqPath == modelListingPath || reqPath == connectionWarmPath
+}
+
+// modelDetailID returns the model id named by a "/v1/models/{id}" lookup.
+// reqPath comes from url.URL.Path, which is already percent-decoded, so an
+// id carrying a "/" (a self-hosted "Qwen/Qwen2.5-0.5B-Instruct" sent as
+// "Qwen%2FQwen2.5-...") arrives whole and everything after the prefix is the
+// id, separators included.
+func modelDetailID(reqPath string) (string, bool) {
+	if !strings.HasPrefix(reqPath, modelListingPath+"/") {
+		return "", false
+	}
+	id := strings.TrimPrefix(reqPath, modelListingPath+"/")
+	if id == "" {
+		return "", false
+	}
+	return id, true
+}
+
+// isBedrockModelLessPath reports whether reqPath is a Bedrock
+// inference-profile lookup, optionally behind the "/bedrock" gateway
+// namespace. Clients read these at startup to resolve a configured profile
+// to its underlying model. They carry no model of their own, so they route
+// by path to a Bedrock provider rather than through the model table.
+//
+// On native AWS these live on the control plane ("bedrock.") while a
+// provider's upstream is normally the runtime host ("bedrock-runtime."),
+// so forwarding yields a 404 there. That is deliberate: a client has one base
+// URL, so pointing it straight at the runtime host 404s identically, and
+// forwarding keeps the proxy transparent instead of inventing a policy denial
+// the client would never otherwise see. Operators whose Bedrock upstream is a
+// gateway that does serve the lookup get a working answer.
+func isBedrockModelLessPath(reqPath string) bool {
+	native, _ := splitBedrockNamespace(reqPath)
+	return native == "/inference-profiles" || strings.HasPrefix(native, bedrockProfileDetailPrefix)
+}
+
+// bedrockProfileDetailPrefix precedes the identifier in a GetInferenceProfile
+// lookup, once any gateway namespace is off the front.
+const bedrockProfileDetailPrefix = "/inference-profiles/"
+
+// bedrockProfileID returns the inference profile a "/inference-profiles/{id}"
+// lookup names. The listing beside it names none, which is what separates the
+// two: a listing is a set the response filter can bound, while this answers
+// for one profile with a single object no filter inspects.
+//
+// The id arrives as AWS issues it — region prefix and version suffix included
+// — because that is the only form that works at invoke time.
+func bedrockProfileID(reqPath string) (string, bool) {
+	native, _ := splitBedrockNamespace(reqPath)
+	if !strings.HasPrefix(native, bedrockProfileDetailPrefix) {
+		return "", false
+	}
+	id := strings.TrimPrefix(native, bedrockProfileDetailPrefix)
+	if id == "" {
+		return "", false
+	}
+	return id, true
 }
 
 // isVertexPath reports whether reqPath is a Google Vertex AI publisher
@@ -332,20 +591,33 @@ func splitBedrockNamespace(reqPath string) (string, bool) {
 	return reqPath, false
 }
 
+// bedrockActions are the runtime actions that follow the model id in a
+// Bedrock path. count-tokens is here so a client can price its context
+// against the dedicated endpoint; denying it pushes that work back onto
+// the inference endpoint, which bills for it.
+var bedrockActions = []string{
+	"/invoke",
+	"/invoke-with-response-stream",
+	"/converse",
+	"/converse-stream",
+	"/count-tokens",
+}
+
 // isBedrockPath reports whether reqPath is an AWS Bedrock runtime model
-// endpoint: /model/{modelId}/{action} where action is invoke,
-// invoke-with-response-stream, converse, or converse-stream — optionally behind
-// a "/bedrock" gateway-namespace prefix. The model lives in the path, so these
-// requests are routed by path to the Bedrock provider.
+// endpoint: /model/{modelId}/{action} — optionally behind a "/bedrock"
+// gateway-namespace prefix. The model lives in the path, so these requests
+// are routed by path to the Bedrock provider.
 func isBedrockPath(reqPath string) bool {
 	native, _ := splitBedrockNamespace(reqPath)
 	if !strings.HasPrefix(native, "/model/") {
 		return false
 	}
-	return strings.HasSuffix(native, "/invoke") ||
-		strings.HasSuffix(native, "/invoke-with-response-stream") ||
-		strings.HasSuffix(native, "/converse") ||
-		strings.HasSuffix(native, "/converse-stream")
+	for _, action := range bedrockActions {
+		if strings.HasSuffix(native, action) {
+			return true
+		}
+	}
+	return false
 }
 
 // matchVertex selects the Vertex provider authorised for the caller's groups
@@ -425,19 +697,42 @@ func (m *Middleware) matchPathRoute(reqPath, model string, userGroups []string,
 // declaration order), matchOutcomeUnauthorised when no provider authorises
 // the caller, or matchOutcomeUnknownModel when the path isn't a recognised
 // model-less endpoint.
-func (m *Middleware) matchModelless(reqPath string, userGroups []string) (ProviderRoute, matchOutcome) {
-	if !isModelLessPath(reqPath) {
+func (m *Middleware) matchModelless(reqPath, method string, userGroups []string) (ProviderRoute, matchOutcome) {
+	if !isNonInferenceMethod(method) {
 		return ProviderRoute{}, matchOutcomeUnknownModel
 	}
-	var candidates []ProviderRoute
-	for _, route := range m.cfg.Providers {
+	var eligible func(ProviderRoute) bool
+	switch {
+	case isBedrockModelLessPath(reqPath):
+		if profile, isDetail := bedrockProfileID(reqPath); isDetail {
+			// A detail lookup names one profile, so it is authorised like any
+			// other per-model request rather than by provider type alone. The
+			// listing beside it is bounded by DiscoveryModels on the way back,
+			// but this answers with a single object no filter inspects — so
+			// without the check here, a caller reads the full configuration of
+			// every profile in the account, including the ones its policy
+			// never named.
+			//
+			// The id is normalised first: a record may register the raw
+			// profile id or the catalog key it reduces to, and routeClaimsModel
+			// expects the normalised form an inference request would carry.
+			wanted := llm.NormalizeBedrockModel(profile)
+			eligible = func(r ProviderRoute) bool { return r.Bedrock && routeClaimsModel(r, wanted) }
+		} else {
+			eligible = func(r ProviderRoute) bool { return r.Bedrock }
+		}
+	case isModelLessPath(reqPath):
 		// Vertex/Bedrock are path-routed and don't serve OpenAI-style
 		// model-listing endpoints; including them here could rewrite a
 		// GET /v1/models to an upstream that 404s it.
-		if route.Vertex || route.Bedrock {
-			continue
-		}
-		if routeAuthorisesGroups(route, userGroups) {
+		eligible = func(r ProviderRoute) bool { return !r.Vertex && !r.Bedrock }
+	default:
+		return ProviderRoute{}, matchOutcomeUnknownModel
+	}
+
+	var candidates []ProviderRoute
+	for _, route := range m.cfg.Providers {
+		if eligible(route) && routeAuthorisesGroups(route, userGroups) {
 			candidates = append(candidates, route)
 		}
 	}
@@ -564,6 +859,16 @@ func routeClaimsModel(route ProviderRoute, model string) bool {
 		if route.Bedrock && llm.NormalizeBedrockModel(candidate) == model {
 			return true
 		}
+		// A client may pin a dated Anthropic id ("claude-sonnet-4-5-20250929")
+		// where the operator registered the undated one. Only an undated
+		// registration absorbs a dated request: normalising both sides would
+		// let a route pinned to one dated release claim a different one, so an
+		// operator who deliberately pinned a build would silently serve
+		// another — and with several such routes, ordering would decide which.
+		if candidate == llm.NormalizeAnthropicModel(candidate) &&
+			candidate == llm.NormalizeAnthropicModel(model) {
+			return true
+		}
 	}
 	return false
 }
@@ -612,7 +917,7 @@ func requestPath(raw string) string {
 // provider id so identity-stamping middlewares (llm_identity_inject)
 // tag the request with ONLY the groups that authorised this specific
 // route — not every group the peer happens to be in.
-func (m *Middleware) allowWithRoute(route ProviderRoute, userGroups []string) *middleware.Output {
+func (m *Middleware) allowWithRoute(route ProviderRoute, surface string, userGroups []string) *middleware.Output {
 	rewrite := &middleware.UpstreamRewrite{
 		Scheme: route.UpstreamScheme,
 		Host:   route.UpstreamHost,
@@ -634,7 +939,7 @@ func (m *Middleware) allowWithRoute(route ProviderRoute, userGroups []string) *m
 		// request time (cached + auto-refreshed) instead of a static value.
 		bearer, err := m.gcpBearer(route.GCPServiceAccountKeyB64)
 		if err != nil {
-			return denyUpstreamAuth()
+			return denyUpstreamAuth(surface)
 		}
 		authValue = bearer
 	}
@@ -704,11 +1009,12 @@ func (m *Middleware) gcpTokenSource(saKeyB64 string) (oauth2.TokenSource, error)
 // denyUpstreamAuth is returned when the router cannot obtain the upstream
 // credential (e.g. a malformed service-account key or an unreachable token
 // endpoint). It surfaces as a 502 — an upstream problem, not a policy denial.
-func denyUpstreamAuth() *middleware.Output {
+func denyUpstreamAuth(surface string) *middleware.Output {
 	return &middleware.Output{
 		Decision:   middleware.DecisionDeny,
 		DenyStatus: 502,
 		DenyReason: &middleware.DenyReason{
+			Surface: surface,
 			Code:    denyCodeUpstreamAuth,
 			Message: "could not obtain upstream credential",
 		},
@@ -722,11 +1028,12 @@ func denyUpstreamAuth() *middleware.Output {
 // denyUnmeterable returns the deny envelope for a path-routed request whose
 // publisher has no parser surface, so its usage can't be metered. Serving it
 // would bypass token/budget caps, so it is rejected with a 403.
-func denyUnmeterable() *middleware.Output {
+func denyUnmeterable(surface string) *middleware.Output {
 	return &middleware.Output{
 		Decision:   middleware.DecisionDeny,
 		DenyStatus: 403,
 		DenyReason: &middleware.DenyReason{
+			Surface: surface,
 			Code:    denyCodeUnmeterable,
 			Message: "request publisher is not supported for metering",
 		},
@@ -739,11 +1046,12 @@ func denyUnmeterable() *middleware.Output {
 
 // denyMissingModel returns the deny envelope for a request whose
 // envelope has no llm.model metadata.
-func denyMissingModel() *middleware.Output {
+func denyMissingModel(surface string) *middleware.Output {
 	return &middleware.Output{
 		Decision:   middleware.DecisionDeny,
 		DenyStatus: 403,
 		DenyReason: &middleware.DenyReason{
+			Surface: surface,
 			Code:    denyCodeNotRoutable,
 			Message: "missing llm.model on request envelope",
 		},
@@ -756,11 +1064,12 @@ func denyMissingModel() *middleware.Output {
 
 // denyUnknownModel returns the deny envelope for a model that no
 // configured provider claims.
-func denyUnknownModel(model string) *middleware.Output {
+func denyUnknownModel(surface, model string) *middleware.Output {
 	return &middleware.Output{
 		Decision:   middleware.DecisionDeny,
 		DenyStatus: 403,
 		DenyReason: &middleware.DenyReason{
+			Surface: surface,
 			Code:    denyCodeNotRoutable,
 			Message: fmt.Sprintf("no provider configured for model %s", model),
 			Details: map[string]string{"model": model},
@@ -775,11 +1084,12 @@ func denyUnknownModel(model string) *middleware.Output {
 // denyNoAuthorisedRoute returns the deny envelope for a model that one
 // or more providers claim, but where no policy authorises the caller's
 // groups for any of those providers.
-func denyNoAuthorisedRoute(model string) *middleware.Output {
+func denyNoAuthorisedRoute(surface, model string) *middleware.Output {
 	return &middleware.Output{
 		Decision:   middleware.DecisionDeny,
 		DenyStatus: 403,
 		DenyReason: &middleware.DenyReason{
+			Surface: surface,
 			Code:    denyCodeNoAuthorisedRoute,
 			Message: fmt.Sprintf("no policy authorises model %s for the caller's groups", model),
 			Details: map[string]string{"model": model},
diff --git a/proxy/internal/middleware/builtin/llm_router/middleware_test.go b/proxy/internal/middleware/builtin/llm_router/middleware_test.go
index 425c383c1..5a1d32480 100644
--- a/proxy/internal/middleware/builtin/llm_router/middleware_test.go
+++ b/proxy/internal/middleware/builtin/llm_router/middleware_test.go
@@ -2,6 +2,7 @@ package llm_router
 
 import (
 	"context"
+	"net/http"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -60,6 +61,8 @@ func TestMiddlewareIdentity(t *testing.T) {
 		[]string{
 			middleware.KeyLLMResolvedProviderID,
 			middleware.KeyLLMAuthorisingGroups,
+			middleware.KeyLLMNonInference,
+			middleware.KeyLLMModel,
 			middleware.KeyLLMPolicyDecision,
 			middleware.KeyLLMPolicyReason,
 		},
@@ -171,8 +174,12 @@ func TestRouter_MissingModel(t *testing.T) {
 // from which a model could be parsed). UserGroups matches defaultTestGroup.
 func newModellessInput(reqURL string) *middleware.Input {
 	return &middleware.Input{
-		Slot:       middleware.SlotOnRequest,
-		URL:        reqURL,
+		Slot: middleware.SlotOnRequest,
+		URL:  reqURL,
+		// The non-inference endpoints are read requests; the method is what
+		// separates them from an inference body posted to the same path, so
+		// state it rather than leaning on the zero value.
+		Method:     http.MethodGet,
 		UserGroups: []string{defaultTestGroup},
 	}
 }
@@ -197,6 +204,12 @@ func TestRouter_ModelLessPath_RoutesToAuthorisedProvider(t *testing.T) {
 
 	provider, _ := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID)
 	assert.Equal(t, "openai-prod", provider, "resolved provider must be the authorised route")
+
+	// The limits gate reads this to tell "no model applies here" from
+	// "the model could not be determined", which fails closed.
+	nonInference, ok := metaValue(t, out.Metadata, middleware.KeyLLMNonInference)
+	require.True(t, ok, "model-less allow must mark the request non-inference")
+	assert.Equal(t, "true", nonInference)
 }
 
 func TestRouter_ModelLessPath_MultiProviderDeclarationOrder(t *testing.T) {
@@ -873,3 +886,403 @@ func TestRouter_EmptyModelsClaimsAnyModel(t *testing.T) {
 	resolved, _ := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID)
 	assert.Equal(t, "litellm", resolved)
 }
+
+// TestRouter_DatedAnthropicModelRoutes covers a client pinning a release
+// date on a model the operator registered undated. Exact matches still win,
+// so an operator who registers both dated releases keeps them distinct.
+func TestRouter_DatedAnthropicModelRoutes(t *testing.T) {
+	mw := New(Config{Providers: []ProviderRoute{{
+		ID:              "anthropic-prod",
+		Vendor:          "anthropic",
+		Models:          []string{"claude-sonnet-4-5"},
+		AllowedGroupIDs: []string{defaultTestGroup},
+		UpstreamScheme:  "https",
+		UpstreamHost:    "api.anthropic.com",
+	}}})
+
+	in := newInputWithModelAndURL("claude-sonnet-4-5-20250929", "/v1/messages")
+	in.Metadata = append(in.Metadata, middleware.KV{Key: middleware.KeyLLMProvider, Value: "anthropic"})
+
+	out, err := mw.Invoke(context.Background(), in)
+	require.NoError(t, err)
+	require.NotNil(t, out)
+	assert.Equal(t, middleware.DecisionAllow, out.Decision, "a dated id must route to the undated registration")
+	require.NotNil(t, out.Mutations)
+	require.NotNil(t, out.Mutations.RewriteUpstream)
+	assert.Equal(t, "api.anthropic.com", out.Mutations.RewriteUpstream.Host)
+}
+
+// TestRouter_ConnectionWarmProbeRoutes covers the HEAD /api/hello probe an
+// Anthropic client sends before its first request. Forwarding it warms the
+// connection that request will use; denying it only wrote a rejection into
+// the access log at every session start.
+func TestRouter_ConnectionWarmProbeRoutes(t *testing.T) {
+	mw := New(Config{Providers: []ProviderRoute{{
+		ID:              "anthropic-prod",
+		Vendor:          "anthropic",
+		Models:          []string{"claude-sonnet-5"},
+		AllowedGroupIDs: []string{defaultTestGroup},
+		UpstreamScheme:  "https",
+		UpstreamHost:    "api.anthropic.com",
+	}}})
+
+	in := newModellessInput("/api/hello")
+	in.Method = http.MethodHead
+
+	out, err := mw.Invoke(context.Background(), in)
+	require.NoError(t, err)
+	require.NotNil(t, out)
+	assert.Equal(t, middleware.DecisionAllow, out.Decision, "the warm-up probe must reach the upstream")
+	require.NotNil(t, out.Mutations)
+	require.NotNil(t, out.Mutations.RewriteUpstream)
+	assert.Equal(t, "api.anthropic.com", out.Mutations.RewriteUpstream.Host)
+
+	nonInference, _ := metaValue(t, out.Metadata, middleware.KeyLLMNonInference)
+	assert.Equal(t, "true", nonInference, "the probe carries no model to gate on")
+}
+
+// TestRouter_ModelListingCarriesAuthorisedModels pins the list the proxy
+// bounds the discovery response with. A catch-all route enumerates nothing,
+// so it must not bound the upstream's list at all.
+func TestRouter_ModelListingCarriesAuthorisedModels(t *testing.T) {
+	enumerated := ProviderRoute{
+		ID:              "anthropic-prod",
+		Models:          []string{"claude-sonnet-5", "claude-haiku-4-5"},
+		AllowedGroupIDs: []string{defaultTestGroup},
+		UpstreamScheme:  "https",
+		UpstreamHost:    "api.anthropic.com",
+	}
+
+	t.Run("enumerated route bounds the listing", func(t *testing.T) {
+		mw := New(Config{Providers: []ProviderRoute{enumerated}})
+		out, err := mw.Invoke(context.Background(), newModellessInput("/v1/models"))
+		require.NoError(t, err)
+		require.NotNil(t, out.Mutations)
+		require.NotNil(t, out.Mutations.RewriteUpstream)
+		assert.Equal(t, []string{"claude-sonnet-5", "claude-haiku-4-5"},
+			out.Mutations.RewriteUpstream.DiscoveryModels,
+			"the picker must be bounded by what the route authorises")
+	})
+
+	t.Run("catch-all route leaves the listing alone", func(t *testing.T) {
+		catchAll := enumerated
+		catchAll.Models = nil
+		mw := New(Config{Providers: []ProviderRoute{catchAll}})
+
+		out, err := mw.Invoke(context.Background(), newModellessInput("/v1/models"))
+		require.NoError(t, err)
+		require.NotNil(t, out.Mutations.RewriteUpstream)
+		assert.Empty(t, out.Mutations.RewriteUpstream.DiscoveryModels,
+			"a route that claims every model cannot bound the upstream's list")
+	})
+
+	t.Run("per-model lookup is not a listing", func(t *testing.T) {
+		mw := New(Config{Providers: []ProviderRoute{enumerated}})
+		out, err := mw.Invoke(context.Background(), newModellessInput("/v1/models/claude-sonnet-5"))
+		require.NoError(t, err)
+		require.NotNil(t, out.Mutations.RewriteUpstream)
+		assert.Empty(t, out.Mutations.RewriteUpstream.DiscoveryModels,
+			"the single-object lookup has no data array to filter")
+	})
+}
+
+// TestRouter_ModelDetailHonoursAllowlist pins that GET /v1/models/{id} is
+// authorised against the model table. It carries no body model, so treating
+// it as a model-less endpoint would let a caller confirm a model the route
+// does not list — the listing itself is bounded to the allowlist, so the
+// detail lookup must be too.
+func TestRouter_ModelDetailHonoursAllowlist(t *testing.T) {
+	enumerated := ProviderRoute{
+		ID:              "anthropic-prod",
+		Models:          []string{"claude-sonnet-5"},
+		AllowedGroupIDs: []string{defaultTestGroup},
+		UpstreamScheme:  "https",
+		UpstreamHost:    "api.anthropic.com",
+	}
+
+	t.Run("allowlisted model routes and skips metering", func(t *testing.T) {
+		mw := New(Config{Providers: []ProviderRoute{enumerated}})
+		out, err := mw.Invoke(context.Background(), newModellessInput("/v1/models/claude-sonnet-5"))
+		require.NoError(t, err)
+		assert.Equal(t, middleware.DecisionAllow, out.Decision)
+		require.NotNil(t, out.Mutations)
+		require.NotNil(t, out.Mutations.RewriteUpstream)
+		assert.Equal(t, "api.anthropic.com", out.Mutations.RewriteUpstream.Host)
+
+		nonInference, _ := metaValue(t, out.Metadata, middleware.KeyLLMNonInference)
+		assert.Equal(t, "true", nonInference, "a detail lookup spends no tokens")
+	})
+
+	t.Run("model outside the allowlist denies", func(t *testing.T) {
+		mw := New(Config{Providers: []ProviderRoute{enumerated}})
+		out, err := mw.Invoke(context.Background(), newModellessInput("/v1/models/claude-opus-5"))
+		require.NoError(t, err)
+		assert.Equal(t, middleware.DecisionDeny, out.Decision,
+			"a model no route lists must not be confirmed by the detail lookup")
+	})
+
+	t.Run("dated id matches its undated registration", func(t *testing.T) {
+		mw := New(Config{Providers: []ProviderRoute{enumerated}})
+		out, err := mw.Invoke(context.Background(), newModellessInput("/v1/models/claude-sonnet-5-20250929"))
+		require.NoError(t, err)
+		assert.Equal(t, middleware.DecisionAllow, out.Decision,
+			"a pinned release of an allowlisted family stays reachable")
+	})
+
+	t.Run("catch-all route still answers every lookup", func(t *testing.T) {
+		catchAll := enumerated
+		catchAll.Models = nil
+		mw := New(Config{Providers: []ProviderRoute{catchAll}})
+
+		out, err := mw.Invoke(context.Background(), newModellessInput("/v1/models/anything-at-all"))
+		require.NoError(t, err)
+		assert.Equal(t, middleware.DecisionAllow, out.Decision,
+			"a gateway that enumerates nothing cannot refuse a lookup")
+	})
+}
+
+// TestRouter_NonInferenceRequiresReadMethod pins that the non-inference mark —
+// which exempts a request from the token pre-flight — is reachable only by the
+// read methods these endpoints actually use. A POST to the same path could
+// carry an inference body, so it must not buy the exemption; it falls through
+// to normal per-model routing instead, which denies when no model is named.
+func TestRouter_NonInferenceRequiresReadMethod(t *testing.T) {
+	route := ProviderRoute{
+		ID:              "gateway",
+		AllowedGroupIDs: []string{defaultTestGroup},
+		UpstreamScheme:  "https",
+		UpstreamHost:    "gateway.example.com",
+	}
+
+	for _, path := range []string{"/v1/models", "/v1/models/claude-sonnet-5", "/api/hello"} {
+		t.Run("POST "+path, func(t *testing.T) {
+			mw := New(Config{Providers: []ProviderRoute{route}})
+
+			in := newModellessInput(path)
+			in.Method = http.MethodPost
+
+			out, err := mw.Invoke(context.Background(), in)
+			require.NoError(t, err)
+			assert.Equal(t, middleware.DecisionDeny, out.Decision,
+				"a write to a non-inference path must not route unmetered")
+
+			nonInference, _ := metaValue(t, out.Metadata, middleware.KeyLLMNonInference)
+			assert.NotEqual(t, "true", nonInference,
+				"only a read method may skip the token pre-flight")
+		})
+	}
+
+	t.Run("HEAD keeps the warm probe working", func(t *testing.T) {
+		mw := New(Config{Providers: []ProviderRoute{route}})
+
+		in := newModellessInput(connectionWarmPath)
+		in.Method = http.MethodHead
+
+		out, err := mw.Invoke(context.Background(), in)
+		require.NoError(t, err)
+		assert.Equal(t, middleware.DecisionAllow, out.Decision,
+			"the HEAD warm probe must still reach the upstream")
+
+		nonInference, _ := metaValue(t, out.Metadata, middleware.KeyLLMNonInference)
+		assert.Equal(t, "true", nonInference,
+			"the HEAD warm probe carries no model to meter")
+	})
+}
+
+// TestRouter_PinnedDatedModelStaysDistinct pins that a route registered
+// against one dated Anthropic release does not claim another. Normalising
+// both sides of the comparison made every dated build of a family
+// interchangeable, so an operator who deliberately pinned a build would have
+// served a different one — and with several such routes, declaration or path
+// order would have decided which.
+func TestRouter_PinnedDatedModelStaysDistinct(t *testing.T) {
+	pinned := ProviderRoute{
+		ID:              "anthropic-pinned",
+		Vendor:          "anthropic",
+		Models:          []string{"claude-sonnet-4-5-20250101"},
+		AllowedGroupIDs: []string{defaultTestGroup},
+		UpstreamScheme:  "https",
+		UpstreamHost:    "pinned.example.com",
+	}
+
+	t.Run("a different dated release is not claimed", func(t *testing.T) {
+		mw := New(Config{Providers: []ProviderRoute{pinned}})
+		in := newInputWithModelAndURL("claude-sonnet-4-5-20250202", "/v1/messages")
+		in.Metadata = append(in.Metadata, middleware.KV{Key: middleware.KeyLLMProvider, Value: "anthropic"})
+
+		out, err := mw.Invoke(context.Background(), in)
+		require.NoError(t, err)
+		assert.Equal(t, middleware.DecisionDeny, out.Decision,
+			"a route pinned to one dated build must not serve another")
+	})
+
+	t.Run("its own dated release still routes", func(t *testing.T) {
+		mw := New(Config{Providers: []ProviderRoute{pinned}})
+		in := newInputWithModelAndURL("claude-sonnet-4-5-20250101", "/v1/messages")
+		in.Metadata = append(in.Metadata, middleware.KV{Key: middleware.KeyLLMProvider, Value: "anthropic"})
+
+		out, err := mw.Invoke(context.Background(), in)
+		require.NoError(t, err)
+		assert.Equal(t, middleware.DecisionAllow, out.Decision, "the exact match must still route")
+	})
+
+	t.Run("two pinned builds each route to their own provider", func(t *testing.T) {
+		other := pinned
+		other.ID = "anthropic-pinned-newer"
+		other.Models = []string{"claude-sonnet-4-5-20250202"}
+		other.UpstreamHost = "newer.example.com"
+		mw := New(Config{Providers: []ProviderRoute{pinned, other}})
+
+		in := newInputWithModelAndURL("claude-sonnet-4-5-20250202", "/v1/messages")
+		in.Metadata = append(in.Metadata, middleware.KV{Key: middleware.KeyLLMProvider, Value: "anthropic"})
+
+		out, err := mw.Invoke(context.Background(), in)
+		require.NoError(t, err)
+		require.Equal(t, middleware.DecisionAllow, out.Decision)
+		require.NotNil(t, out.Mutations)
+		require.NotNil(t, out.Mutations.RewriteUpstream)
+		assert.Equal(t, "newer.example.com", out.Mutations.RewriteUpstream.Host,
+			"declaration order must not decide between two deliberately pinned builds")
+	})
+}
+
+// TestRouter_DiscoveryBoundToCallersPolicies pins that a model listing is
+// bounded by the policies that authorise the caller, not by the union across
+// everyone who can reach the provider. Two teams sharing one provider record
+// under different allowlists is the case that makes the difference visible: a
+// flattened per-provider list would offer each team the other's models, and
+// every one of those entries is a request the guardrail then refuses.
+func TestRouter_DiscoveryBoundToCallersPolicies(t *testing.T) {
+	const (
+		eng   = "grp-eng"
+		sales = "grp-sales"
+	)
+	route := ProviderRoute{
+		ID:              "shared-gateway",
+		Models:          []string{"claude-sonnet-5", "claude-haiku-4-5", "gpt-4o"},
+		AllowedGroupIDs: []string{eng, sales},
+		UpstreamScheme:  "https",
+		UpstreamHost:    "gateway.example.com",
+		ModelPolicies: []ModelPolicyRule{
+			{GroupIDs: []string{eng}, Models: []string{"claude-sonnet-5"}},
+			{GroupIDs: []string{sales}, Models: []string{"gpt-4o"}},
+		},
+	}
+
+	listingFor := func(t *testing.T, group string) []string {
+		t.Helper()
+		mw := New(Config{Providers: []ProviderRoute{route}})
+		in := newModellessInput(modelListingPath)
+		in.UserGroups = []string{group}
+
+		out, err := mw.Invoke(context.Background(), in)
+		require.NoError(t, err)
+		require.Equal(t, middleware.DecisionAllow, out.Decision)
+		require.NotNil(t, out.Mutations)
+		require.NotNil(t, out.Mutations.RewriteUpstream)
+		return out.Mutations.RewriteUpstream.DiscoveryModels
+	}
+
+	t.Run("each group sees only its own policy's models", func(t *testing.T) {
+		assert.Equal(t, []string{"claude-sonnet-5"}, listingFor(t, eng),
+			"engineering must not be offered the model only sales may use")
+		assert.Equal(t, []string{"gpt-4o"}, listingFor(t, sales),
+			"sales must not be offered the model only engineering may use")
+	})
+
+	t.Run("a model no policy allows is offered to nobody", func(t *testing.T) {
+		for _, group := range []string{eng, sales} {
+			assert.NotContains(t, listingFor(t, group), "claude-haiku-4-5",
+				"the provider serves it, but no policy permits it")
+		}
+	})
+}
+
+// TestRouter_DiscoveryUnrestrictedPolicy covers the lifting rule: a caller
+// holding one policy without a model allowlist sees everything the provider
+// enumerates, whatever the other policies say.
+func TestRouter_DiscoveryUnrestrictedPolicy(t *testing.T) {
+	const (
+		eng   = "grp-eng"
+		admin = "grp-admin"
+	)
+	route := ProviderRoute{
+		ID:              "shared-gateway",
+		Models:          []string{"claude-sonnet-5", "gpt-4o"},
+		AllowedGroupIDs: []string{eng, admin},
+		UpstreamScheme:  "https",
+		UpstreamHost:    "gateway.example.com",
+		ModelPolicies: []ModelPolicyRule{
+			{GroupIDs: []string{eng}, Models: []string{"claude-sonnet-5"}},
+			// nil Models: a policy that sets no allowlist at all.
+			{GroupIDs: []string{admin}},
+		},
+	}
+	mw := New(Config{Providers: []ProviderRoute{route}})
+
+	in := newModellessInput(modelListingPath)
+	in.UserGroups = []string{eng, admin}
+
+	out, err := mw.Invoke(context.Background(), in)
+	require.NoError(t, err)
+	require.NotNil(t, out.Mutations.RewriteUpstream)
+	assert.ElementsMatch(t, []string{"claude-sonnet-5", "gpt-4o"},
+		out.Mutations.RewriteUpstream.DiscoveryModels,
+		"an unrestricted policy the caller holds lifts the restriction")
+}
+
+// TestRouter_DiscoveryOnGatewayRecord covers a record that enumerates no
+// models. It previously offered the upstream's whole catalogue however narrow
+// the policy was, because there was nothing to intersect against; the policy
+// allowlist is now the bound on its own.
+func TestRouter_DiscoveryOnGatewayRecord(t *testing.T) {
+	const eng = "grp-eng"
+	base := ProviderRoute{
+		ID:              "litellm",
+		AllowedGroupIDs: []string{eng},
+		UpstreamScheme:  "https",
+		UpstreamHost:    "litellm.internal",
+	}
+
+	t.Run("a policy allowlist bounds it", func(t *testing.T) {
+		route := base
+		route.ModelPolicies = []ModelPolicyRule{{GroupIDs: []string{eng}, Models: []string{"gpt-4o"}}}
+		mw := New(Config{Providers: []ProviderRoute{route}})
+
+		in := newModellessInput(modelListingPath)
+		in.UserGroups = []string{eng}
+
+		out, err := mw.Invoke(context.Background(), in)
+		require.NoError(t, err)
+		assert.Equal(t, []string{"gpt-4o"}, out.Mutations.RewriteUpstream.DiscoveryModels,
+			"a catch-all record must still be bounded by what policy permits")
+	})
+
+	t.Run("an allowlist permitting nothing offers nothing", func(t *testing.T) {
+		route := base
+		route.ModelPolicies = []ModelPolicyRule{{GroupIDs: []string{eng}, Models: []string{}}}
+		mw := New(Config{Providers: []ProviderRoute{route}})
+
+		in := newModellessInput(modelListingPath)
+		in.UserGroups = []string{eng}
+
+		out, err := mw.Invoke(context.Background(), in)
+		require.NoError(t, err)
+		require.NotNil(t, out.Mutations.RewriteUpstream)
+		assert.Empty(t, out.Mutations.RewriteUpstream.DiscoveryModels,
+			"an empty allowlist permits nothing, and must not be read as unrestricted")
+	})
+
+	t.Run("no policy restriction leaves the listing alone", func(t *testing.T) {
+		mw := New(Config{Providers: []ProviderRoute{base}})
+
+		in := newModellessInput(modelListingPath)
+		in.UserGroups = []string{eng}
+
+		out, err := mw.Invoke(context.Background(), in)
+		require.NoError(t, err)
+		require.NotNil(t, out.Mutations.RewriteUpstream)
+		assert.Nil(t, out.Mutations.RewriteUpstream.DiscoveryModels,
+			"nothing narrows the listing, so the upstream's own answer passes through")
+	})
+}
diff --git a/proxy/internal/middleware/decision.go b/proxy/internal/middleware/decision.go
index 0970bdea4..97dca4af5 100644
--- a/proxy/internal/middleware/decision.go
+++ b/proxy/internal/middleware/decision.go
@@ -11,11 +11,78 @@ var codeRegex = regexp.MustCompile(`^[a-z][a-z0-9._-]{0,63}$`)
 // denyResponse is the on-wire shape rendered by RenderDenyResponse.
 // Keeping this as a typed struct ensures we never leak
 // middleware-supplied bytes outside known fields.
+//
+// Type and Error mirror the denial in the vendor's own error shape when
+// the request reached a known LLM surface. LLM clients only parse their
+// provider's envelope, so without the mirror a budget stop reaches the
+// user as an unexplained API error. The NetBird fields stay where they
+// were, so the body is a superset and existing consumers are unaffected.
 type denyResponse struct {
 	Code       string            `json:"code"`
 	Message    string            `json:"message,omitempty"`
 	Details    map[string]string `json:"details,omitempty"`
 	Middleware string            `json:"middleware,omitempty"`
+	Type       string            `json:"type,omitempty"`
+	Error      *providerError    `json:"error,omitempty"`
+}
+
+// providerError is the nested error object both vendor envelopes carry.
+type providerError struct {
+	Type    string `json:"type"`
+	Message string `json:"message,omitempty"`
+	Code    string `json:"code,omitempty"`
+}
+
+// Vendor error types keyed by HTTP status, per each provider's published
+// error reference.
+const (
+	anthropicErrInvalidRequest = "invalid_request_error"
+	anthropicErrPermission     = "permission_error"
+	anthropicErrRateLimit      = "rate_limit_error"
+	anthropicErrAPI            = "api_error"
+	openAIErrInvalidRequest    = "invalid_request_error"
+	openAIErrRateLimit         = "rate_limit_error"
+)
+
+// providerEnvelope returns the vendor-shaped mirror for a denial on the
+// given surface, or nil when the surface has no envelope we can speak.
+// message is the already-redacted public message.
+func providerEnvelope(surface, code, message string, status int) (string, *providerError) {
+	switch surface {
+	case "anthropic":
+		return "error", &providerError{
+			Type:    anthropicErrorType(status),
+			Message: message,
+		}
+	case "openai":
+		return "", &providerError{
+			Type:    openAIErrorType(status),
+			Message: message,
+			Code:    code,
+		}
+	default:
+		return "", nil
+	}
+}
+
+func anthropicErrorType(status int) string {
+	switch status {
+	case http.StatusForbidden:
+		return anthropicErrPermission
+	case http.StatusTooManyRequests:
+		return anthropicErrRateLimit
+	case http.StatusBadRequest:
+		return anthropicErrInvalidRequest
+	default:
+		return anthropicErrAPI
+	}
+}
+
+func openAIErrorType(status int) string {
+	if status == http.StatusTooManyRequests {
+		return openAIErrRateLimit
+	}
+	return openAIErrInvalidRequest
 }
 
 // RenderDenyResponse writes a structured JSON deny body. Status is
@@ -36,6 +103,7 @@ func RenderDenyResponse(w http.ResponseWriter, middlewareID string, reason *Deny
 		Message:    truncate(Scan(reason.Message), 256),
 		Middleware: truncate(Scan(middlewareID), 64),
 	}
+	resp.Type, resp.Error = providerEnvelope(reason.Surface, resp.Code, resp.Message, status)
 	if n := len(reason.Details); n > 0 {
 		resp.Details = make(map[string]string, min(n, 8))
 		for k, v := range reason.Details {
diff --git a/proxy/internal/middleware/decision_test.go b/proxy/internal/middleware/decision_test.go
new file mode 100644
index 000000000..cf14c86ff
--- /dev/null
+++ b/proxy/internal/middleware/decision_test.go
@@ -0,0 +1,92 @@
+package middleware
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// decodeDeny renders a denial and returns the parsed body plus the status.
+func decodeDeny(t *testing.T, reason *DenyReason, status int) (map[string]any, int) {
+	t.Helper()
+	rec := httptest.NewRecorder()
+	RenderDenyResponse(rec, "llm_limit_check", reason, status)
+
+	var body map[string]any
+	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body), "deny body must be valid JSON")
+	return body, rec.Code
+}
+
+// TestRenderDeny_AnthropicSurfaceMirrorsVendorShape covers a budget stop
+// reaching Claude Code. The client only parses the Anthropic envelope, so
+// without the mirror the user sees an unexplained API error instead of the
+// reason their request was refused.
+func TestRenderDeny_AnthropicSurfaceMirrorsVendorShape(t *testing.T) {
+	body, status := decodeDeny(t, &DenyReason{
+		Code:    "llm_policy.budget_cap_exceeded",
+		Message: "LLM policy limit exceeded",
+		Surface: "anthropic",
+	}, http.StatusForbidden)
+
+	assert.Equal(t, http.StatusForbidden, status)
+	assert.Equal(t, "error", body["type"], "Anthropic errors carry type=error at the top level")
+
+	errObj, ok := body["error"].(map[string]any)
+	require.True(t, ok, "error must be an object")
+	assert.Equal(t, "permission_error", errObj["type"], "403 maps to permission_error")
+	assert.Equal(t, "LLM policy limit exceeded", errObj["message"])
+
+	// The NetBird fields stay put so existing consumers keep working.
+	assert.Equal(t, "llm_policy.budget_cap_exceeded", body["code"])
+	assert.Equal(t, "LLM policy limit exceeded", body["message"])
+	assert.Equal(t, "llm_limit_check", body["middleware"])
+}
+
+// TestRenderDeny_OpenAISurfaceMirrorsVendorShape pins the OpenAI envelope,
+// which nests the code and carries no top-level type.
+func TestRenderDeny_OpenAISurfaceMirrorsVendorShape(t *testing.T) {
+	body, _ := decodeDeny(t, &DenyReason{
+		Code:    "llm_policy.model_blocked",
+		Message: "model is not in the policy allowlist",
+		Surface: "openai",
+	}, http.StatusForbidden)
+
+	assert.NotContains(t, body, "type", "OpenAI errors have no top-level type")
+
+	errObj, ok := body["error"].(map[string]any)
+	require.True(t, ok, "error must be an object")
+	assert.Equal(t, "invalid_request_error", errObj["type"])
+	assert.Equal(t, "llm_policy.model_blocked", errObj["code"], "the NetBird code rides in the vendor code field")
+	assert.Equal(t, "model is not in the policy allowlist", errObj["message"])
+}
+
+// TestRenderDeny_RateLimitStatusMapsToVendorRateLimit pins the mapping a
+// client's backoff keys on.
+func TestRenderDeny_RateLimitStatusMapsToVendorRateLimit(t *testing.T) {
+	body, status := decodeDeny(t, &DenyReason{
+		Code:    "llm_policy.token_cap_exceeded",
+		Message: "LLM policy limit exceeded",
+		Surface: "anthropic",
+	}, http.StatusTooManyRequests)
+
+	assert.Equal(t, http.StatusTooManyRequests, status, "429 must survive the status clamp")
+	errObj := body["error"].(map[string]any)
+	assert.Equal(t, "rate_limit_error", errObj["type"])
+}
+
+// TestRenderDeny_NoSurfaceKeepsLegacyShape guards non-LLM middlewares and
+// denials raised before a surface is known.
+func TestRenderDeny_NoSurfaceKeepsLegacyShape(t *testing.T) {
+	body, _ := decodeDeny(t, &DenyReason{
+		Code:    "llm_policy.model_not_routable",
+		Message: "no provider configured for model x",
+	}, http.StatusForbidden)
+
+	assert.NotContains(t, body, "type", "no surface means no vendor mirror")
+	assert.NotContains(t, body, "error", "no surface means no vendor mirror")
+	assert.Equal(t, "llm_policy.model_not_routable", body["code"])
+}
diff --git a/proxy/internal/middleware/keys.go b/proxy/internal/middleware/keys.go
index 336bed19f..eff3fa756 100644
--- a/proxy/internal/middleware/keys.go
+++ b/proxy/internal/middleware/keys.go
@@ -22,6 +22,15 @@ const (
 	// body. Empty for clients that don't send one.
 	KeyLLMSessionID = "llm.session_id"
 
+	// Sub-agent attribution (emitted by llm_request_parser from the
+	// client's request headers). A coding agent that spawns helpers
+	// stamps the spawned agent's id, and the spawning agent's id when
+	// the helper is itself nested, so cost within one session can be
+	// split across the agents that ran in parallel. These identify an
+	// agent, not a person or a device: never treat them as a user id.
+	KeyLLMAgentID       = "llm.agent_id"
+	KeyLLMParentAgentID = "llm.parent_agent_id"
+
 	// LLM response-side metadata (emitted by llm_response_parser).
 	//nolint:gosec // metadata key name, not a credential
 	KeyLLMInputTokens = "llm.input_tokens"
@@ -66,6 +75,14 @@ const (
 	// downstream gateways' spend logs.
 	KeyLLMAuthorisingGroups = "llm.authorising_groups"
 
+	// LLM non-inference marker (emitted by llm_router on the allow path
+	// for endpoints that legitimately carry no model, such as model
+	// listing). The router still authorises these against the caller's
+	// groups; the marker only tells the limits gate that a per-model
+	// allowlist has nothing to evaluate, so an empty model must not be
+	// read as an undetermined one. Never derived from client input.
+	KeyLLMNonInference = "llm.non_inference"
+
 	// LLM policy attribution (emitted by llm_limit_check on the allow
 	// path). Names the policy that paid for this request and the
 	// dimension counters the post-flight llm_limit_record middleware
diff --git a/proxy/internal/middleware/types.go b/proxy/internal/middleware/types.go
index 1ed5c9d88..3c0ac0ab6 100644
--- a/proxy/internal/middleware/types.go
+++ b/proxy/internal/middleware/types.go
@@ -179,6 +179,12 @@ type DenyReason struct {
 	Code    string
 	Message string
 	Details map[string]string
+	// Surface names the LLM API dialect the caller speaks (the
+	// llm.provider value), so the rendered body can mirror the denial in
+	// that vendor's error shape alongside the NetBird fields. Empty for
+	// non-LLM middlewares and for denials raised before a surface was
+	// resolved; the body then carries the NetBird fields alone.
+	Surface string
 }
 
 // Output is the value each middleware returns to the dispatcher. The
@@ -247,6 +253,12 @@ type UpstreamRewrite struct {
 	// without verifying its TLS certificate. Set by llm_router from the
 	// provider's skip_tls_verification for self-hosted / internal gateways.
 	SkipTLSVerify bool
+	// DiscoveryModels, when non-empty, is the set of model ids the resolved
+	// route authorises, and the proxy drops everything else from the
+	// model-listing response. Empty leaves the upstream's list untouched,
+	// which is what a route that claims every model wants. Set by
+	// llm_router on a model-listing request only.
+	DiscoveryModels []string
 }
 
 // AuthHeader is a single name/value pair the proxy injects on the
diff --git a/proxy/internal/proxy/discovery_filter.go b/proxy/internal/proxy/discovery_filter.go
new file mode 100644
index 000000000..d5502e1f1
--- /dev/null
+++ b/proxy/internal/proxy/discovery_filter.go
@@ -0,0 +1,237 @@
+package proxy
+
+import (
+	"bytes"
+	"encoding/json"
+	"io"
+	"net/http"
+	"strconv"
+	"strings"
+
+	sharedllm "github.com/netbirdio/netbird/shared/llm"
+)
+
+// maxDiscoveryBodyBytes bounds the model-listing response the filter will
+// buffer. A listing is a few kilobytes of ids; anything larger is not a
+// listing we recognise, and buffering it to rewrite would cost more than
+// the filtering is worth.
+const maxDiscoveryBodyBytes = 1 << 20
+
+// modelDiscoveryFilter returns a ModifyResponse hook that drops models the
+// caller's policy does not authorise from a model-listing response, then
+// delegates to next (which may be nil).
+//
+// Clients populate their model picker from this endpoint, so an unfiltered
+// list offers models the very next request denies. The filter is
+// best-effort: a response it cannot safely rewrite passes through
+// untouched rather than reaching the client corrupted.
+func modelDiscoveryFilter(allowed []string, next func(*http.Response) error) func(*http.Response) error {
+	permitted := make(map[string]struct{}, len(allowed)*2)
+	for _, id := range allowed {
+		permitted[id] = struct{}{}
+		permitted[sharedllm.NormalizeAnthropicModel(id)] = struct{}{}
+	}
+
+	return func(resp *http.Response) error {
+		if err := filterModelListing(resp, permitted); err != nil {
+			return err
+		}
+		if next == nil {
+			return nil
+		}
+		return next(resp)
+	}
+}
+
+// filterModelListing rewrites the response body in place, keeping only the
+// entries whose id the policy authorises. Responses that are not a plain
+// JSON listing are left alone.
+func filterModelListing(resp *http.Response, permitted map[string]struct{}) error {
+	if !isPlainJSONListing(resp) {
+		return nil
+	}
+
+	// One byte past the cap, so an oversized body is detectable without
+	// buffering all of it.
+	body, err := io.ReadAll(io.LimitReader(resp.Body, maxDiscoveryBodyBytes+1))
+	if err != nil {
+		_ = resp.Body.Close()
+		return err
+	}
+	if len(body) > maxDiscoveryBodyBytes {
+		// Too large to filter. Put the bytes already read back in front of the
+		// unread remainder and forward the response exactly as the upstream
+		// sent it, headers included. Buffering what was read and closing here
+		// would truncate the body at the cap and hand the client a short,
+		// invalid listing — worse than not filtering at all.
+		resp.Body = spliceBody(body, resp.Body)
+		return nil
+	}
+	if err := resp.Body.Close(); err != nil {
+		return err
+	}
+
+	filtered, ok := filterListingBody(body, permitted)
+	if !ok {
+		restoreBody(resp, body)
+		return nil
+	}
+	restoreBody(resp, filtered)
+	return nil
+}
+
+// isPlainJSONListing reports whether the response is a JSON body the filter
+// can parse. A content-encoded body is skipped: the transport only
+// transparently decompresses what it negotiated itself, and the client
+// negotiates its own encoding on this request.
+func isPlainJSONListing(resp *http.Response) bool {
+	if resp == nil || resp.Body == nil {
+		return false
+	}
+	if resp.StatusCode != http.StatusOK {
+		return false
+	}
+	if enc := resp.Header.Get("Content-Encoding"); enc != "" && !strings.EqualFold(enc, "identity") {
+		return false
+	}
+	return strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "application/json")
+}
+
+// listingEnvelopes maps a listing's wrapper key to the field naming the model
+// id inside it. Vendors did not converge on one shape: OpenAI's is what
+// Anthropic adopted, while Bedrock returns inference-profile summaries under a
+// key of its own. A body matching none of these is forwarded untouched.
+var listingEnvelopes = []struct {
+	key     string
+	idField string
+}{
+	{"data", "id"},
+	{"inferenceProfileSummaries", "inferenceProfileId"},
+}
+
+// filterListingBody returns the listing with unauthorised entries removed.
+// ok is false when the body is not a listing shape, in which case the
+// caller must forward the original bytes.
+func filterListingBody(body []byte, permitted map[string]struct{}) ([]byte, bool) {
+	var doc map[string]json.RawMessage
+	if err := json.Unmarshal(body, &doc); err != nil {
+		return nil, false
+	}
+	for _, envelope := range listingEnvelopes {
+		raw, present := doc[envelope.key]
+		if !present {
+			continue
+		}
+		var entries []map[string]json.RawMessage
+		if err := json.Unmarshal(raw, &entries); err != nil {
+			return nil, false
+		}
+
+		kept := make([]map[string]json.RawMessage, 0, len(entries))
+		for _, entry := range entries {
+			if entryPermitted(entry, envelope.idField, permitted) {
+				kept = append(kept, entry)
+			}
+		}
+
+		encoded, err := json.Marshal(kept)
+		if err != nil {
+			return nil, false
+		}
+		doc[envelope.key] = encoded
+		out, err := json.Marshal(doc)
+		if err != nil {
+			return nil, false
+		}
+		return out, true
+	}
+	return nil, false
+}
+
+// entryPermitted reports whether a listing entry names a model the policy
+// authorises, trying every form the same model is written in.
+func entryPermitted(entry map[string]json.RawMessage, idField string, permitted map[string]struct{}) bool {
+	raw, ok := entry[idField]
+	if !ok {
+		return false
+	}
+	var id string
+	if err := json.Unmarshal(raw, &id); err != nil {
+		return false
+	}
+	for _, candidate := range modelIDForms(id) {
+		if _, ok := permitted[candidate]; ok {
+			return true
+		}
+	}
+	return false
+}
+
+// gatewayNamespaces are the provider prefixes a gateway prepends to a model
+// it re-exports: LiteLLM lists a Bedrock model the operator registered as
+// "anthropic.claude-opus-5" under "bedrock/anthropic.claude-opus-5". Only
+// these are stripped before matching.
+//
+// A slash is not by itself a namespace separator. Self-hosted backends ship
+// ids that carry one ("Qwen/Qwen2.5-0.5B-Instruct"), and an upstream is free
+// to scope ids per tenant ("tenant-b/claude-sonnet-5"). Treating every slash
+// as a prefix let any such id match an allowed model by its tail, so the
+// picker offered models the policy never named.
+var gatewayNamespaces = map[string]struct{}{
+	"anthropic": {},
+	"azure":     {},
+	"bedrock":   {},
+	"mistral":   {},
+	"openai":    {},
+	"vertex_ai": {},
+}
+
+// modelIDForms returns the forms a single model id may be written in: the id
+// itself, its undated form, and — when the id is namespaced by a gateway we
+// recognise — the same two with that namespace removed
+// ("vertex_ai/claude-sonnet-5"). The bare id is always tried first.
+//
+// The namespace is what precedes the FIRST slash: it is a prefix the gateway
+// put in front of the whole id, and everything after it is the id the
+// operator would have registered, separators included.
+func modelIDForms(id string) []string {
+	if id == "" {
+		return nil
+	}
+	forms := []string{id, sharedllm.NormalizeAnthropicModel(id)}
+	// A Bedrock listing returns region-prefixed, version-suffixed profile ids
+	// ("eu.anthropic.claude-haiku-4-5-20251001-v1:0") while the record may
+	// register the catalog key. Stripping to the key is a no-op for ids that
+	// carry neither, so this costs nothing on the other surfaces.
+	if bedrock := sharedllm.NormalizeBedrockModel(id); bedrock != id {
+		forms = append(forms, bedrock)
+	}
+	if slash := strings.Index(id, "/"); slash > 0 {
+		if _, ok := gatewayNamespaces[id[:slash]]; ok {
+			tail := id[slash+1:]
+			forms = append(forms, tail, sharedllm.NormalizeAnthropicModel(tail))
+		}
+	}
+	return forms
+}
+
+// restoreBody puts body back on the response and fixes the length headers
+// so the client reads exactly what is there.
+// spliceBody returns a ReadCloser that yields prefix followed by whatever is
+// left in rest, closing rest when closed. It lets the filter put back bytes it
+// consumed while deciding, without owning the rest of the stream.
+func spliceBody(prefix []byte, rest io.ReadCloser) io.ReadCloser {
+	return struct {
+		io.Reader
+		io.Closer
+	}{
+		Reader: io.MultiReader(bytes.NewReader(prefix), rest),
+		Closer: rest,
+	}
+}
+
+func restoreBody(resp *http.Response, body []byte) {
+	resp.Body = io.NopCloser(bytes.NewReader(body))
+	resp.ContentLength = int64(len(body))
+	resp.Header.Set("Content-Length", strconv.Itoa(len(body)))
+}
diff --git a/proxy/internal/proxy/discovery_filter_test.go b/proxy/internal/proxy/discovery_filter_test.go
new file mode 100644
index 000000000..fd5666345
--- /dev/null
+++ b/proxy/internal/proxy/discovery_filter_test.go
@@ -0,0 +1,272 @@
+package proxy
+
+import (
+	"bytes"
+	"encoding/json"
+	"io"
+	"net/http"
+	"strconv"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// jsonListingResponse builds a 200 model-listing response with the given
+// body, as an upstream would return it.
+func jsonListingResponse(body string) *http.Response {
+	resp := &http.Response{
+		StatusCode:    http.StatusOK,
+		Header:        http.Header{},
+		Body:          io.NopCloser(strings.NewReader(body)),
+		ContentLength: int64(len(body)),
+	}
+	resp.Header.Set("Content-Type", "application/json")
+	return resp
+}
+
+// listedIDs runs the filter and returns the ids left in the response.
+func listedIDs(t *testing.T, allowed []string, body string) []string {
+	t.Helper()
+	resp := jsonListingResponse(body)                            //nolint:bodyclose // in-memory body, replaced by the filter
+	require.NoError(t, modelDiscoveryFilter(allowed, nil)(resp)) //nolint:bodyclose // in-memory body, replaced by the filter
+
+	raw, err := io.ReadAll(resp.Body)
+	require.NoError(t, err)
+
+	var doc struct {
+		Data []struct {
+			ID string `json:"id"`
+		} `json:"data"`
+	}
+	require.NoError(t, json.Unmarshal(raw, &doc), "filtered body must stay valid JSON")
+
+	ids := make([]string, 0, len(doc.Data))
+	for _, entry := range doc.Data {
+		ids = append(ids, entry.ID)
+	}
+	return ids
+}
+
+// TestModelDiscoveryFilter_KeepsOnlyAuthorisedModels covers the picker a
+// developer sees: an unfiltered upstream list offers every model the shared
+// key can reach, and each one the policy excludes is a request the chain
+// denies a moment later.
+func TestModelDiscoveryFilter_KeepsOnlyAuthorisedModels(t *testing.T) {
+	ids := listedIDs(t, []string{"claude-sonnet-5", "claude-haiku-4-5"}, `{
+		"data": [
+			{"id": "claude-opus-5", "display_name": "Claude Opus 5"},
+			{"id": "claude-sonnet-5", "display_name": "Claude Sonnet 5"},
+			{"id": "claude-haiku-4-5"}
+		],
+		"has_more": false
+	}`)
+
+	assert.Equal(t, []string{"claude-sonnet-5", "claude-haiku-4-5"}, ids,
+		"only the models the route authorises may reach the picker")
+}
+
+// TestModelDiscoveryFilter_MatchesDatedAndPrefixedIDs pins the two id forms
+// a gateway returns for a model the operator registered plainly.
+func TestModelDiscoveryFilter_MatchesDatedAndPrefixedIDs(t *testing.T) {
+	ids := listedIDs(t, []string{"claude-sonnet-4-5", "anthropic.claude-opus-5"}, `{
+		"data": [
+			{"id": "claude-sonnet-4-5-20250929"},
+			{"id": "bedrock/anthropic.claude-opus-5"},
+			{"id": "gpt-4o"}
+		]
+	}`)
+
+	assert.Equal(t, []string{"claude-sonnet-4-5-20250929", "bedrock/anthropic.claude-opus-5"}, ids,
+		"a dated or provider-prefixed id must match its registered form")
+}
+
+// TestModelDiscoveryFilter_PreservesEnvelopeFields guards the rest of the
+// document: clients read paging fields alongside data.
+func TestModelDiscoveryFilter_PreservesEnvelopeFields(t *testing.T) {
+	resp := jsonListingResponse(`{"data":[{"id":"claude-sonnet-5"}],"has_more":true,"first_id":"x"}`) //nolint:bodyclose // in-memory body, replaced by the filter
+	require.NoError(t, modelDiscoveryFilter([]string{"claude-sonnet-5"}, nil)(resp))                  //nolint:bodyclose // in-memory body, replaced by the filter
+
+	raw, err := io.ReadAll(resp.Body)
+	require.NoError(t, err)
+
+	var doc map[string]any
+	require.NoError(t, json.Unmarshal(raw, &doc))
+	assert.Equal(t, true, doc["has_more"], "paging fields must survive the rewrite")
+	assert.Equal(t, "x", doc["first_id"])
+	assert.Equal(t, strconv.Itoa(len(raw)), resp.Header.Get("Content-Length"),
+		"Content-Length must match the rewritten body")
+}
+
+// TestModelDiscoveryFilter_PassesThroughUnfilterable covers the responses
+// the filter must not touch: a compressed body it cannot parse, a non-JSON
+// body, an error status, and a document with no data array.
+func TestModelDiscoveryFilter_PassesThroughUnfilterable(t *testing.T) {
+	cases := map[string]func() *http.Response{
+		"compressed": func() *http.Response {
+			resp := jsonListingResponse(`{"data":[{"id":"gpt-4o"}]}`)
+			resp.Header.Set("Content-Encoding", "gzip")
+			return resp
+		},
+		"not json": func() *http.Response {
+			resp := jsonListingResponse(`{"data":[{"id":"gpt-4o"}]}`)
+			resp.Header.Set("Content-Type", "text/html")
+			return resp
+		},
+		"error status": func() *http.Response {
+			resp := jsonListingResponse(`{"data":[{"id":"gpt-4o"}]}`)
+			resp.StatusCode = http.StatusInternalServerError
+			return resp
+		},
+		"no data array": func() *http.Response {
+			return jsonListingResponse(`{"object":"list"}`)
+		},
+	}
+
+	for name, build := range cases {
+		t.Run(name, func(t *testing.T) {
+			resp := build() //nolint:bodyclose // in-memory body, replaced by the filter
+			original, err := io.ReadAll(resp.Body)
+			require.NoError(t, err)
+			resp.Body = io.NopCloser(bytes.NewReader(original))
+
+			require.NoError(t, modelDiscoveryFilter([]string{"claude-sonnet-5"}, nil)(resp)) //nolint:bodyclose // in-memory body, replaced by the filter
+
+			got, err := io.ReadAll(resp.Body)
+			require.NoError(t, err)
+			assert.Equal(t, string(original), string(got), "an unfilterable response must reach the client unchanged")
+		})
+	}
+}
+
+// TestModelDiscoveryFilter_RunsNextHook pins that an existing
+// ModifyResponse hook still runs after filtering.
+func TestModelDiscoveryFilter_RunsNextHook(t *testing.T) {
+	called := false
+	next := func(*http.Response) error {
+		called = true
+		return nil
+	}
+
+	resp := jsonListingResponse(`{"data":[{"id":"claude-sonnet-5"}]}`)                //nolint:bodyclose // in-memory body, replaced by the filter
+	require.NoError(t, modelDiscoveryFilter([]string{"claude-sonnet-5"}, next)(resp)) //nolint:bodyclose // in-memory body, replaced by the filter
+	assert.True(t, called, "the chained hook must still run")
+}
+
+// TestModelDiscoveryFilter_KeepsSlashBearingIDs covers self-hosted backends
+// whose model ids carry a slash of their own. Treating the slash as a
+// gateway prefix and keeping only the tail dropped every such model from
+// the picker even though the policy named it exactly.
+func TestModelDiscoveryFilter_KeepsSlashBearingIDs(t *testing.T) {
+	ids := listedIDs(t, []string{"Qwen/Qwen2.5-0.5B-Instruct"}, `{
+		"object": "list",
+		"data": [
+			{"id": "Qwen/Qwen2.5-0.5B-Instruct"},
+			{"id": "Qwen/Qwen2.5-7B-Instruct"}
+		]
+	}`)
+
+	assert.Equal(t, []string{"Qwen/Qwen2.5-0.5B-Instruct"}, ids,
+		"a slash inside the model id is part of the id, not a provider prefix")
+}
+
+// TestModelDiscoveryFilter_RejectsTailMatchOnUnknownNamespace covers the id
+// an upstream scopes with a prefix of its own. "tenant-b/claude-sonnet-5"
+// ends in a model the policy permits, but it is a different model on a
+// different tenant, and the guardrail denies that string outright — so
+// offering it hands the picker an entry the next request refuses.
+func TestModelDiscoveryFilter_RejectsTailMatchOnUnknownNamespace(t *testing.T) {
+	ids := listedIDs(t, []string{"claude-sonnet-5"}, `{
+		"data": [
+			{"id": "claude-sonnet-5"},
+			{"id": "tenant-b/claude-sonnet-5"},
+			{"id": "Qwen/claude-sonnet-5"}
+		]
+	}`)
+
+	assert.Equal(t, []string{"claude-sonnet-5"}, ids,
+		"only a namespace a gateway is known to prepend may be stripped before matching")
+}
+
+// TestModelDiscoveryFilter_ForwardsOversizedBodyIntact covers a listing past
+// the buffering cap. The filter reads one byte beyond the cap to detect the
+// size; forwarding only what it read would hand the client a body truncated
+// at exactly 1 MiB — valid-looking, short, and unparseable as JSON. The bytes
+// already read must be spliced back in front of the unread remainder so the
+// response reaches the client exactly as the upstream sent it.
+func TestModelDiscoveryFilter_ForwardsOversizedBodyIntact(t *testing.T) {
+	// A well-formed listing whose single entry pads the body past the cap.
+	padding := strings.Repeat("x", maxDiscoveryBodyBytes)
+	body := `{"object":"list","data":[{"id":"gpt-4o","note":"` + padding + `"}]}`
+	require.Greater(t, len(body), maxDiscoveryBodyBytes+1,
+		"the fixture must exceed the cap by more than the one-byte probe")
+
+	resp := jsonListingResponse(body)                        //nolint:bodyclose // in-memory body
+	require.NoError(t, modelDiscoveryFilter(nil, nil)(resp)) //nolint:bodyclose // in-memory body
+
+	got, err := io.ReadAll(resp.Body)
+	require.NoError(t, err)
+	assert.Equal(t, len(body), len(got),
+		"an oversized listing must reach the client whole, not truncated at the cap")
+	assert.Equal(t, body, string(got), "the forwarded bytes must be the upstream's own")
+
+	var doc map[string]json.RawMessage
+	assert.NoError(t, json.Unmarshal(got, &doc),
+		"the forwarded body must still parse as JSON")
+}
+
+// TestModelDiscoveryFilter_OversizedBodyKeepsUpstreamHeaders pins that the
+// oversized path leaves the response metadata alone. Rewriting Content-Length
+// to the truncated prefix is what made the corruption invisible to the client
+// until it tried to parse.
+func TestModelDiscoveryFilter_OversizedBodyKeepsUpstreamHeaders(t *testing.T) {
+	padding := strings.Repeat("x", maxDiscoveryBodyBytes)
+	body := `{"object":"list","data":[{"id":"gpt-4o","note":"` + padding + `"}]}`
+
+	resp := jsonListingResponse(body) //nolint:bodyclose // in-memory body
+	resp.Header.Set("Content-Length", strconv.Itoa(len(body)))
+	require.NoError(t, modelDiscoveryFilter(nil, nil)(resp)) //nolint:bodyclose // in-memory body
+
+	assert.Equal(t, int64(len(body)), resp.ContentLength,
+		"ContentLength must keep describing the body the client receives")
+	assert.Equal(t, strconv.Itoa(len(body)), resp.Header.Get("Content-Length"),
+		"the Content-Length header must not be rewritten to the truncated prefix")
+}
+
+// TestFilterBedrockInferenceProfiles covers the second listing envelope. AWS
+// returns inference-profile summaries under a key of its own with an id field
+// of its own, so a filter that only knew OpenAI's shape forwarded a Bedrock
+// listing whole — offering every profile in the account regardless of policy.
+func TestFilterBedrockInferenceProfiles(t *testing.T) {
+	body := []byte(`{"inferenceProfileSummaries":[
+	  {"inferenceProfileId":"eu.anthropic.claude-haiku-4-5-20251001-v1:0","status":"ACTIVE"},
+	  {"inferenceProfileId":"eu.anthropic.claude-sonnet-4-6","status":"ACTIVE"},
+	  {"inferenceProfileId":"global.cohere.embed-v4:0","status":"ACTIVE"}
+	]}`)
+
+	// The permitted set holds what the record registers. Here that is the
+	// catalog key, while the vendor answers with region-prefixed wire ids —
+	// the two must still line up.
+	permitted := map[string]struct{}{"anthropic.claude-haiku-4-5": {}}
+
+	out, ok := filterListingBody(body, permitted)
+	require.True(t, ok, "a Bedrock listing must be recognised as filterable")
+
+	var doc struct {
+		Summaries []struct {
+			ID string `json:"inferenceProfileId"`
+		} `json:"inferenceProfileSummaries"`
+	}
+	require.NoError(t, json.Unmarshal(out, &doc))
+	require.Len(t, doc.Summaries, 1)
+	assert.Equal(t, "eu.anthropic.claude-haiku-4-5-20251001-v1:0", doc.Summaries[0].ID)
+}
+
+// TestFilterLeavesUnknownEnvelopesAlone keeps the best-effort contract: a body
+// the filter cannot parse must reach the client exactly as the upstream sent
+// it, rather than being rewritten into something shorter and wrong.
+func TestFilterLeavesUnknownEnvelopesAlone(t *testing.T) {
+	_, ok := filterListingBody([]byte(`{"models":[{"name":"something"}]}`), map[string]struct{}{})
+	assert.False(t, ok)
+}
diff --git a/proxy/internal/proxy/reverseproxy.go b/proxy/internal/proxy/reverseproxy.go
index 9150c0329..7c9e21261 100644
--- a/proxy/internal/proxy/reverseproxy.go
+++ b/proxy/internal/proxy/reverseproxy.go
@@ -363,6 +363,9 @@ func (p *ReverseProxy) forwardUpstream(respWriter http.ResponseWriter, r *http.R
 	if result.rewriteRedirects {
 		rp.ModifyResponse = p.rewriteLocationFunc(effectiveURL, rewriteMatchedPath, r) //nolint:bodyclose
 	}
+	if upstreamRewrite != nil && len(upstreamRewrite.DiscoveryModels) > 0 {
+		rp.ModifyResponse = modelDiscoveryFilter(upstreamRewrite.DiscoveryModels, rp.ModifyResponse) //nolint:bodyclose // the hook replaces the body and closes the original
+	}
 	rp.ServeHTTP(respWriter, r.WithContext(ctx))
 }
 
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/proxy/server.go b/proxy/server.go
index bd70b7e70..38477fb87 100644
--- a/proxy/server.go
+++ b/proxy/server.go
@@ -20,6 +20,7 @@ import (
 	"net/url"
 	"path/filepath"
 	"reflect"
+	"slices"
 	"sync"
 	"time"
 
@@ -2062,9 +2063,7 @@ func (s *Server) updateMapping(ctx context.Context, mapping *proto.ProxyMapping)
 	if mapping.GetAuth().GetOidc() {
 		schemes = append(schemes, auth.NewOIDC(s.mgmtClient, svcID, accountID, s.ForwardedProto))
 	}
-	for _, ha := range mapping.GetAuth().GetHeaderAuths() {
-		schemes = append(schemes, auth.NewHeader(s.mgmtClient, svcID, accountID, ha.GetHeader()))
-	}
+	schemes = append(schemes, headerAuthSchemes(mapping.GetAuth().GetHeaderAuths())...)
 
 	ipRestrictions := s.parseRestrictions(mapping)
 	s.warnIfGeoUnavailable(mapping.GetDomain(), mapping.GetAccessRestrictions())
@@ -2074,12 +2073,46 @@ func (s *Server) updateMapping(ctx context.Context, mapping *proto.ProxyMapping)
 		return fmt.Errorf("auth setup for domain %s: %w", mapping.GetDomain(), err)
 	}
 	m := s.protoToMapping(ctx, mapping)
-	s.proxy.AddMapping(m)
+	// The chain is published before the route that leads to it. A request
+	// arriving at a target whose chain has not been rebuilt yet is served
+	// straight through, so a provider update that added the route first left a
+	// window in which an inference could complete unrouted and unmetered.
+	// Rebuilding first inverts that: the worst a request in the window meets is
+	// the new chain in front of the previous target, which is still counted.
+	if err := s.rebuildMiddlewareChains(svcID, m); err != nil {
+		return err
+	}
 	s.meter.AddMapping(m)
-	s.rebuildMiddlewareChains(svcID, m)
+	s.proxy.AddMapping(m)
 	return nil
 }
 
+// headerAuthSchemes builds one scheme per canonical header name, carrying every
+// hash configured for that name so any of them is accepted — the OR semantics
+// management applied while it still validated the credential itself. No entry is
+// ever dropped: a name that arrives blank, or without a hash, still yields a
+// scheme, because a mapping that lost its only scheme would fall through
+// Protect's no-schemes pass-through and serve the domain unauthenticated.
+func headerAuthSchemes(headerAuths []*proto.HeaderAuth) []auth.Scheme {
+	names := make([]string, 0, len(headerAuths))
+	hashes := make(map[string][]string, len(headerAuths))
+	for _, ha := range headerAuths {
+		name := http.CanonicalHeaderKey(ha.GetHeader())
+		if !slices.Contains(names, name) {
+			names = append(names, name)
+		}
+		if hash := ha.GetHashedValue(); hash != "" {
+			hashes[name] = append(hashes[name], hash)
+		}
+	}
+
+	schemes := make([]auth.Scheme, 0, len(names))
+	for _, name := range names {
+		schemes = append(schemes, auth.NewHeader(name, hashes[name]))
+	}
+	return schemes
+}
+
 // initMiddlewareManager wires the middleware subsystem at boot. It configures
 // the per-process FactoryContext concrete middlewares consult, installs the
 // live-service check, and binds the resolver to the registry concrete
@@ -2114,15 +2147,21 @@ func (s *Server) initMiddlewareManager(ctx context.Context) error {
 }
 
 // rebuildMiddlewareChains converts m into per-path bindings and calls
-// Manager.Rebuild. Short-circuits when the middleware manager is unset.
-func (s *Server) rebuildMiddlewareChains(svcID types.ServiceID, m proxy.Mapping) {
+// Manager.Rebuild. Short-circuits when the middleware manager is unset, which
+// is a deployment without middleware rather than a failure to install it.
+//
+// A rebuild that fails is reported rather than logged: the caller publishes
+// the route once this returns, and a route published over chains that were
+// not installed serves requests with no policy enforcement and no metering.
+func (s *Server) rebuildMiddlewareChains(svcID types.ServiceID, m proxy.Mapping) error {
 	if s.middlewareManager == nil {
-		return
+		return nil
 	}
 	bindings := buildMiddlewareBindings(svcID, m)
 	if err := s.middlewareManager.Rebuild(string(svcID), bindings); err != nil {
-		s.Logger.WithError(err).WithField("service_id", svcID).Error("failed to rebuild middleware chains")
+		return fmt.Errorf("rebuild middleware chains for service %s: %w", svcID, err)
 	}
+	return nil
 }
 
 // isLiveService reports whether svcID is currently present in the live
diff --git a/proxy/server_test.go b/proxy/server_test.go
index f0c4765db..9cef63b95 100644
--- a/proxy/server_test.go
+++ b/proxy/server_test.go
@@ -6,6 +6,8 @@ import (
 	"fmt"
 	"io"
 	"net"
+	"net/http"
+	"net/http/httptest"
 	"testing"
 	"time"
 
@@ -15,8 +17,10 @@ import (
 	"go.opentelemetry.io/otel/metric/noop"
 	"google.golang.org/grpc"
 
+	"github.com/netbirdio/netbird/proxy/internal/auth"
 	proxymetrics "github.com/netbirdio/netbird/proxy/internal/metrics"
 	"github.com/netbirdio/netbird/proxy/internal/types"
+	"github.com/netbirdio/netbird/shared/hash/argon2id"
 	"github.com/netbirdio/netbird/shared/management/proto"
 )
 
@@ -209,6 +213,62 @@ func TestRedactMappingForLog_HandlesEmptyOrNilFields(t *testing.T) {
 	assert.Empty(t, redacted.Path, "empty Path must remain empty")
 }
 
+// headerSchemeAccepts reports whether the scheme admits value for headerName.
+func headerSchemeAccepts(t *testing.T, scheme auth.Scheme, headerName, value string) bool {
+	t.Helper()
+	hdr, ok := scheme.(auth.Header)
+	require.True(t, ok, "header auths must produce Header schemes")
+
+	req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
+	req.Header.Set(headerName, value)
+	_, matched, _ := hdr.Verify(req)
+	return matched
+}
+
+func TestHeaderAuthSchemes_GroupsValuesByCanonicalHeaderName(t *testing.T) {
+	hashOf := func(v string) string {
+		hash, err := argon2id.Hash(v)
+		require.NoError(t, err)
+		return hash
+	}
+
+	schemes := headerAuthSchemes([]*proto.HeaderAuth{
+		{Header: "Authorization", HashedValue: hashOf("Bearer a")},
+		{Header: "authorization", HashedValue: hashOf("Bearer b")},
+		{Header: "X-Api-Key", HashedValue: hashOf("key-1")},
+	})
+
+	require.Len(t, schemes, 2, "entries differing only in header-name case must collapse into one scheme")
+
+	assert.True(t, headerSchemeAccepts(t, schemes[0], "Authorization", "Bearer a"), "first value for the header must be accepted")
+	assert.True(t, headerSchemeAccepts(t, schemes[0], "Authorization", "Bearer b"), "second value for the same header must be accepted")
+	assert.False(t, headerSchemeAccepts(t, schemes[0], "Authorization", "Bearer c"), "unconfigured value must be rejected")
+	assert.True(t, headerSchemeAccepts(t, schemes[1], "X-Api-Key", "key-1"), "a second header name keeps its own scheme")
+}
+
+// TestHeaderAuthSchemes_MissingHashFailsClosed covers a mapping that names a
+// header but carries no hash for it. Dropping the scheme would leave a service
+// whose only auth is that header wide open, so the scheme is kept and denies.
+func TestHeaderAuthSchemes_MissingHashFailsClosed(t *testing.T) {
+	schemes := headerAuthSchemes([]*proto.HeaderAuth{{Header: "X-Api-Key"}})
+
+	require.Len(t, schemes, 1, "a header without a hash must still register a scheme")
+	assert.False(t, headerSchemeAccepts(t, schemes[0], "X-Api-Key", "anything"),
+		"a header auth without a hash must reject every value")
+}
+
+// TestHeaderAuthSchemes_BlankNameFailsClosed covers a mapping row whose header
+// name is empty. Skipping it would leave a service whose only auth is that entry
+// with no schemes at all, which Protect treats as an unprotected domain, so the
+// entry is kept and the domain stays gated.
+func TestHeaderAuthSchemes_BlankNameFailsClosed(t *testing.T) {
+	schemes := headerAuthSchemes([]*proto.HeaderAuth{{Header: "", HashedValue: "$argon2id$not-a-real-hash"}})
+
+	require.Len(t, schemes, 1, "a blank header name must still register a scheme")
+	assert.False(t, headerSchemeAccepts(t, schemes[0], "X-Api-Key", "anything"),
+		"a blank header auth must not admit any request")
+}
+
 type statusUpdateOnlyClient struct {
 	proto.ProxyServiceClient
 }
diff --git a/shared/llm/model.go b/shared/llm/model.go
index 08e42e5a4..881097bda 100644
--- a/shared/llm/model.go
+++ b/shared/llm/model.go
@@ -10,9 +10,88 @@ import (
 	"strings"
 )
 
-// bedrockRegionPrefixes are the cross-region inference-profile prefixes that
-// front a Bedrock model id (e.g. "eu.anthropic.claude-...").
-var bedrockRegionPrefixes = []string{"us.", "eu.", "apac.", "global."}
+// bedrockVendorNamespaces are the vendor segments a Bedrock model id is
+// published under. They identify the geography in front of a cross-region
+// inference profile without knowing the geography: in
+// "eu.anthropic.claude-...", what makes "eu" a geography is that "anthropic"
+// follows it.
+//
+// A vendor missing from here is not fatal — bedrockGeographies covers the
+// same id from the other side — but it is one of the two ways an id can go
+// unrecognised, and the list needs a new entry whenever AWS onboards a
+// vendor. A live listing found "global.xai.grok-4.6" days after this was
+// first written.
+var bedrockVendorNamespaces = map[string]struct{}{
+	"ai21":       {},
+	"amazon":     {},
+	"anthropic":  {},
+	"cohere":     {},
+	"deepseek":   {},
+	"luma":       {},
+	"meta":       {},
+	"mistral":    {},
+	"openai":     {},
+	"qwen":       {},
+	"stability":  {},
+	"twelvelabs": {},
+	"writer":     {},
+	"xai":        {},
+}
+
+// bedrockGeographies are the geography segments AWS issues cross-region
+// inference profiles under. They recognise a profile whose vendor we have
+// never seen, which is the case bedrockVendorNamespaces alone gets wrong:
+// "global.xai.grok-4.6" is a geography and a model whether or not "xai" is
+// a name we know.
+//
+// Neither list is sufficient alone. A geography list on its own is what this
+// file started with, and it aged badly — it held us, eu, apac and global, so
+// every profile issued under jp, au, ca, sa or us-gov carried its prefix into
+// the pricing key, matched no catalog entry, and reported the model unpriced.
+// A vendor list on its own misses a new vendor under a known geography.
+// Together, an id has to be new on both axes at once to go unrecognised.
+var bedrockGeographies = map[string]struct{}{
+	"apac":   {},
+	"au":     {},
+	"ca":     {},
+	"eu":     {},
+	"global": {},
+	"jp":     {},
+	"sa":     {},
+	"us":     {},
+	"us-gov": {},
+}
+
+// stripBedrockGeography removes the cross-region inference-profile geography
+// from a Bedrock model id, leaving the "." form the catalog and
+// the pricing table key on.
+//
+// A leading segment counts as a geography when it is one we know, or when a
+// known vendor follows it. Either alone is enough: the id has to be new on
+// both axes before its geography survives.
+//
+// The segment has to be followed by two more, so "amazon.nova-pro" stays a
+// vendor and a model rather than becoming a geography and a model — cutting
+// its first segment would strip the vendor away. Over-stripping is the
+// dangerous direction, because the result also decides which route may claim
+// a model.
+func stripBedrockGeography(modelID string) string {
+	geo, rest, found := strings.Cut(modelID, ".")
+	if !found || geo == "" {
+		return modelID
+	}
+	vendor, _, found := strings.Cut(rest, ".")
+	if !found {
+		return modelID
+	}
+	if _, ok := bedrockGeographies[geo]; ok {
+		return rest
+	}
+	if _, ok := bedrockVendorNamespaces[vendor]; ok {
+		return rest
+	}
+	return modelID
+}
 
 // bedrockVersionSuffix matches the trailing "-vN[:N]" or "-YYYYMMDD-vN[:N]"
 // version/throughput suffix of a Bedrock model id.
@@ -37,15 +116,31 @@ func NormalizeBedrockModel(modelID string) string {
 			m = m[i+1:]
 		}
 	}
-	for _, p := range bedrockRegionPrefixes {
-		if strings.HasPrefix(m, p) {
-			m = m[len(p):]
-			break
-		}
-	}
+	m = stripBedrockGeography(m)
 	return bedrockVersionSuffix.ReplaceAllString(m, "")
 }
 
+// anthropicDatedModel matches a Claude model id carrying the trailing
+// "-YYYYMMDD" release-date suffix Anthropic appends to a pinned release,
+// capturing the id without it. The "claude" anchor is load-bearing: pricing
+// looks every model up through this helper regardless of surface, and an
+// operator may register a custom id with any shape at all, so an unanchored
+// "-\d{8}$" would let "internal-llm-20250101" silently inherit the rate
+// registered for "internal-llm". The anchor also covers the vendor-prefixed
+// forms ("anthropic.claude-...", "us.anthropic.claude-...").
+var anthropicDatedModel = regexp.MustCompile(`(?i)^(.*claude.*)-\d{8}$`)
+
+// NormalizeAnthropicModel strips the trailing release-date suffix from a
+// Claude model id, e.g. "claude-sonnet-4-5-20250929" -> "claude-sonnet-4-5",
+// so a dated id a client pins matches the undated one the operator
+// registered. Ids that are not Claude-family are returned untouched.
+// Callers try the verbatim id first and fall back to this, so two dated
+// releases of the same family stay distinct wherever both are registered
+// explicitly.
+func NormalizeAnthropicModel(modelID string) string {
+	return anthropicDatedModel.ReplaceAllString(modelID, "$1")
+}
+
 // NormalizeVertexModel strips the "@version" suffix from a Vertex AI model id
 // (e.g. "claude-sonnet-4-5@20250929" -> "claude-sonnet-4-5") so it matches
 // the catalog/pricing key. Vertex publisher models are priced under their
diff --git a/shared/llm/model_test.go b/shared/llm/model_test.go
index 42f2e9ca5..077a650fb 100644
--- a/shared/llm/model_test.go
+++ b/shared/llm/model_test.go
@@ -34,3 +34,87 @@ func TestNormalizeVertexModel(t *testing.T) {
 		require.Equal(t, want, NormalizeVertexModel(in), "normalize %q", in)
 	}
 }
+
+func TestNormalizeAnthropicModel(t *testing.T) {
+	cases := map[string]string{
+		"claude-sonnet-4-5-20250929":            "claude-sonnet-4-5",
+		"claude-3-5-haiku-20241022":             "claude-3-5-haiku",
+		"claude-sonnet-5":                       "claude-sonnet-5",
+		"claude-opus-4-8":                       "claude-opus-4-8",
+		"anthropic.claude-haiku-4-5":            "anthropic.claude-haiku-4-5",
+		"anthropic.claude-sonnet-4-5-20250929":  "anthropic.claude-sonnet-4-5",
+		"us.anthropic.claude-opus-4-8-20250101": "us.anthropic.claude-opus-4-8",
+		// Non-Claude ids must survive untouched even when they end in eight
+		// consecutive digits: an operator can register a custom model under
+		// any id, and pricing looks every one of them up through this helper.
+		"gpt-4o":                  "gpt-4o",
+		"gpt-4o-2024-08-06":       "gpt-4o-2024-08-06",
+		"gpt-4o-20240806":         "gpt-4o-20240806",
+		"internal-llm-20250101":   "internal-llm-20250101",
+		"deepseek-r1-20250120":    "deepseek-r1-20250120",
+		"Qwen/Qwen2.5-20250101":   "Qwen/Qwen2.5-20250101",
+		"gemini-2-5-pro-20250101": "gemini-2-5-pro-20250101",
+		"":                        "",
+	}
+	for in, want := range cases {
+		require.Equal(t, want, NormalizeAnthropicModel(in), "normalize %q", in)
+	}
+}
+
+// TestNormalizeBedrockModel_GeographiesBeyondTheOriginalFour covers the bug
+// that made this vendor-anchored: the geography used to be matched against a
+// list of four, so a profile issued anywhere else kept its prefix, missed the
+// catalog key it was supposed to match, and reported the model unpriced.
+func TestNormalizeBedrockModel_GeographiesBeyondTheOriginalFour(t *testing.T) {
+	for _, geo := range []string{"us", "eu", "apac", "global", "jp", "au", "ca", "sa", "us-gov", "il", "mx"} {
+		t.Run(geo, func(t *testing.T) {
+			got := NormalizeBedrockModel(geo + ".anthropic.claude-sonnet-5-20260514-v1:0")
+			require.Equal(t, "anthropic.claude-sonnet-5", got,
+				"a cross-region profile must reduce to the catalog key whatever geography issued it")
+		})
+	}
+}
+
+// TestNormalizeBedrockModel_KeepsAVendorItCannotMistakeForAGeography pins the
+// direction that must never break: a plain "." id has no
+// geography, and cutting its first segment would strip the vendor away and
+// hand the id to whichever route claims the bare model name.
+func TestNormalizeBedrockModel_KeepsAVendorItCannotMistakeForAGeography(t *testing.T) {
+	cases := map[string]string{
+		"amazon.nova-pro-v1:0":            "amazon.nova-pro",
+		"anthropic.claude-sonnet-5-v1:0":  "anthropic.claude-sonnet-5",
+		"meta.llama3-3-70b-instruct-v1:0": "meta.llama3-3-70b-instruct",
+		"cohere.command-r-plus-v1:0":      "cohere.command-r-plus",
+		// Unknown on both axes: neither the leading segment nor the one
+		// after it is a name we hold, so the id is left exactly as it came.
+		"xx.unknownvendor.some-model-v1:0": "xx.unknownvendor.some-model",
+		"Qwen/Qwen2.5-0.5B-Instruct":       "Qwen/Qwen2.5-0.5B-Instruct",
+	}
+	for in, want := range cases {
+		t.Run(in, func(t *testing.T) {
+			require.Equal(t, want, NormalizeBedrockModel(in))
+		})
+	}
+}
+
+// TestNormalizeBedrockModel_RecognisesAnIdNewOnOneAxis covers what a live
+// eu-central-1 listing returned days after the vendor list was written:
+// "global.xai.grok-4.6", a vendor the list did not hold. Anchoring only on the
+// vendor left the geography in the key, so the id matched no catalog entry and
+// the model metered at zero. Each id below is unfamiliar on one axis and
+// recognised through the other.
+func TestNormalizeBedrockModel_RecognisesAnIdNewOnOneAxis(t *testing.T) {
+	cases := map[string]string{
+		// Known geography, vendor we had never seen (the live case).
+		"global.xai.grok-4.6": "xai.grok-4.6",
+		"eu.xai.grok-4.6":     "xai.grok-4.6",
+		// Known vendor, geography outside the list.
+		"il.anthropic.claude-sonnet-5-20260514-v1:0": "anthropic.claude-sonnet-5",
+		"mx.amazon.nova-2-lite-v1:0":                 "amazon.nova-2-lite",
+	}
+	for in, want := range cases {
+		t.Run(in, func(t *testing.T) {
+			require.Equal(t, want, NormalizeBedrockModel(in))
+		})
+	}
+}
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 cd250b5f7..50bf36ac1 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/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml
index bfceadeef..3ab5a2e42 100644
--- a/shared/management/http/api/openapi.yml
+++ b/shared/management/http/api/openapi.yml
@@ -5335,6 +5335,84 @@ components:
         - input_per_1k
         - output_per_1k
         - context_window
+    AgentNetworkModelDiscoveryRequest:
+      type: object
+      properties:
+        catalog_provider_id:
+          type: string
+          description: Catalog provider to query (AgentNetworkCatalogProvider.id). Determines the listing endpoint, the auth header and the response shape.
+          example: "bedrock_api"
+        upstream_url:
+          type: string
+          description: |
+            The upstream being configured. Used to reach vendors that serve their listing from the same host as inference, and to read back the region for those whose host embeds one. Ignored when provider_id is supplied.
+          example: "https://bedrock-runtime.eu-central-1.amazonaws.com"
+        api_key:
+          type: string
+          description: Credential to query the vendor with, for a provider that has not been saved yet. Mutually exclusive with provider_id.
+          example: "sk-..."
+        provider_id:
+          type: string
+          description: Existing Agent Network provider record whose stored credential and upstream should be used. Lets the form refresh the list without the client holding the key.
+          example: "ch8i4ug6lnn4g9hqv7m0"
+      required:
+        - catalog_provider_id
+    AgentNetworkModelDiscoveryResponse:
+      type: object
+      properties:
+        models:
+          type: array
+          description: Models the credential can reach, in the order the vendor returned them.
+          items:
+            $ref: '#/components/schemas/AgentNetworkDiscoveredModel'
+      required:
+        - models
+    AgentNetworkDiscoveredModel:
+      type: object
+      properties:
+        id:
+          type: string
+          description: |
+            Identifier to register on the provider record, in the form the vendor issues it. For Bedrock this is the region-prefixed inference-profile id, which is the only form AWS accepts at invoke time.
+          example: "eu.anthropic.claude-haiku-4-5-20251001-v1:0"
+        label:
+          type: string
+          description: Vendor-supplied display name, where the vendor supplies one.
+          example: "EU Anthropic Claude Haiku 4.5"
+        pricing_known:
+          type: boolean
+          description: Whether NetBird's shipped pricing table can price this model. When false the rates below are all zero and the operator must set them, or requests to this model would record a cost of zero.
+          example: true
+        input_per_1k:
+          type: number
+          format: double
+          description: Default input token price per 1k tokens, in USD, from the same table the proxy bills with. Zero when pricing_known is false.
+          example: 0.005
+        output_per_1k:
+          type: number
+          format: double
+          description: Default output token price per 1k tokens, in USD. Zero when pricing_known is false.
+          example: 0.015
+        cached_input_per_1k:
+          type: number
+          format: double
+          description: OpenAI-shape cache rate — default cost per 1k cached prompt tokens (a subset of input tokens), in USD. Absent when the model has no cached-input discount.
+          example: 0.000075
+        cache_read_per_1k:
+          type: number
+          format: double
+          description: Anthropic-shape cache rate — default cost per 1k cache-read tokens (additive to input tokens), in USD. Absent when the model has no cache-read rate.
+          example: 0.0003
+        cache_creation_per_1k:
+          type: number
+          format: double
+          description: Anthropic-shape cache rate — default cost per 1k cache-creation tokens (additive to input tokens), in USD. Absent when the model has no cache-creation rate.
+          example: 0.00375
+      required:
+        - id
+        - pricing_known
+        - input_per_1k
+        - output_per_1k
     AgentNetworkCatalogProvider:
       type: object
       properties:
@@ -14004,6 +14082,42 @@ paths:
           "$ref": "#/components/responses/forbidden"
         '500':
           "$ref": "#/components/responses/internal_error"
+  /api/agent-network/catalog/providers/models:
+    post:
+      summary: Discover the models a provider credential can reach
+      description: |
+        Asks the vendor which models the supplied credential can actually use, so the provider form can offer a live list instead of only the static catalog. The endpoint, auth header and response shape are taken from the catalog entry, never from the request.
+
+        Supply either an api_key together with the upstream_url being configured (before the provider is saved), or a provider_id of an existing record to reuse its stored credential.
+
+        Returns 422 for a catalog provider that has no listing endpoint (most gateways); the caller should fall back to the catalog's own model list. A model whose price the shipped table does not know is returned with pricing_known false, and the operator must set rates for it.
+      tags: [ Agent Network ]
+      security:
+        - BearerAuth: [ ]
+        - TokenAuth: [ ]
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/AgentNetworkModelDiscoveryRequest'
+      responses:
+        '200':
+          description: The models the credential can reach
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/AgentNetworkModelDiscoveryResponse'
+        '400':
+          "$ref": "#/components/responses/bad_request"
+        '401':
+          "$ref": "#/components/responses/requires_authentication"
+        '403':
+          "$ref": "#/components/responses/forbidden"
+        '422':
+          "$ref": "#/components/responses/validation_failed_simple"
+        '500':
+          "$ref": "#/components/responses/internal_error"
   /api/agent-network/providers:
     get:
       summary: List all Agent Network Providers
diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go
index 04e04a24f..db5b2e18e 100644
--- a/shared/management/http/api/types.gen.go
+++ b/shared/management/http/api/types.gen.go
@@ -2120,6 +2120,33 @@ type AgentNetworkConsumption struct {
 // AgentNetworkConsumptionDimensionKind Whether this row counts a single end user or a single source group across every member.
 type AgentNetworkConsumptionDimensionKind string
 
+// AgentNetworkDiscoveredModel defines model for AgentNetworkDiscoveredModel.
+type AgentNetworkDiscoveredModel struct {
+	// CacheCreationPer1k Anthropic-shape cache rate — default cost per 1k cache-creation tokens (additive to input tokens), in USD. Absent when the model has no cache-creation rate.
+	CacheCreationPer1k *float64 `json:"cache_creation_per_1k,omitempty"`
+
+	// CacheReadPer1k Anthropic-shape cache rate — default cost per 1k cache-read tokens (additive to input tokens), in USD. Absent when the model has no cache-read rate.
+	CacheReadPer1k *float64 `json:"cache_read_per_1k,omitempty"`
+
+	// CachedInputPer1k OpenAI-shape cache rate — default cost per 1k cached prompt tokens (a subset of input tokens), in USD. Absent when the model has no cached-input discount.
+	CachedInputPer1k *float64 `json:"cached_input_per_1k,omitempty"`
+
+	// Id Identifier to register on the provider record, in the form the vendor issues it. For Bedrock this is the region-prefixed inference-profile id, which is the only form AWS accepts at invoke time.
+	Id string `json:"id"`
+
+	// InputPer1k Default input token price per 1k tokens, in USD, from the same table the proxy bills with. Zero when pricing_known is false.
+	InputPer1k float64 `json:"input_per_1k"`
+
+	// Label Vendor-supplied display name, where the vendor supplies one.
+	Label *string `json:"label,omitempty"`
+
+	// OutputPer1k Default output token price per 1k tokens, in USD. Zero when pricing_known is false.
+	OutputPer1k float64 `json:"output_per_1k"`
+
+	// PricingKnown Whether NetBird's shipped pricing table can price this model. When false the rates below are all zero and the operator must set them, or requests to this model would record a cost of zero.
+	PricingKnown bool `json:"pricing_known"`
+}
+
 // AgentNetworkGuardrail defines model for AgentNetworkGuardrail.
 type AgentNetworkGuardrail struct {
 	// Checks Guardrail check parameters. Each entry has an `enabled` flag plus per-check configuration; disabled entries are inert.
@@ -2167,6 +2194,27 @@ type AgentNetworkGuardrailRequest struct {
 	Name string `json:"name"`
 }
 
+// AgentNetworkModelDiscoveryRequest defines model for AgentNetworkModelDiscoveryRequest.
+type AgentNetworkModelDiscoveryRequest struct {
+	// ApiKey Credential to query the vendor with, for a provider that has not been saved yet. Mutually exclusive with provider_id.
+	ApiKey *string `json:"api_key,omitempty"`
+
+	// CatalogProviderId Catalog provider to query (AgentNetworkCatalogProvider.id). Determines the listing endpoint, the auth header and the response shape.
+	CatalogProviderId string `json:"catalog_provider_id"`
+
+	// ProviderId Existing Agent Network provider record whose stored credential and upstream should be used. Lets the form refresh the list without the client holding the key.
+	ProviderId *string `json:"provider_id,omitempty"`
+
+	// UpstreamUrl The upstream being configured. Used to reach vendors that serve their listing from the same host as inference, and to read back the region for those whose host embeds one. Ignored when provider_id is supplied.
+	UpstreamUrl *string `json:"upstream_url,omitempty"`
+}
+
+// AgentNetworkModelDiscoveryResponse defines model for AgentNetworkModelDiscoveryResponse.
+type AgentNetworkModelDiscoveryResponse struct {
+	// Models Models the credential can reach, in the order the vendor returned them.
+	Models []AgentNetworkDiscoveredModel `json:"models"`
+}
+
 // AgentNetworkPolicy defines model for AgentNetworkPolicy.
 type AgentNetworkPolicy struct {
 	// CreatedAt Timestamp when the policy was created.
@@ -6179,6 +6227,9 @@ type PostApiAgentNetworkBudgetRulesJSONRequestBody = AgentNetworkBudgetRuleReque
 // PutApiAgentNetworkBudgetRulesRuleIdJSONRequestBody defines body for PutApiAgentNetworkBudgetRulesRuleId for application/json ContentType.
 type PutApiAgentNetworkBudgetRulesRuleIdJSONRequestBody = AgentNetworkBudgetRuleRequest
 
+// PostApiAgentNetworkCatalogProvidersModelsJSONRequestBody defines body for PostApiAgentNetworkCatalogProvidersModels for application/json ContentType.
+type PostApiAgentNetworkCatalogProvidersModelsJSONRequestBody = AgentNetworkModelDiscoveryRequest
+
 // PostApiAgentNetworkGuardrailsJSONRequestBody defines body for PostApiAgentNetworkGuardrails for application/json ContentType.
 type PostApiAgentNetworkGuardrailsJSONRequestBody = AgentNetworkGuardrailRequest
 
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 4864a9dff..0cd45e417 100644
--- a/shared/management/networkmap/decode.go
+++ b/shared/management/networkmap/decode.go
@@ -1,18 +1,19 @@
 package networkmap
 
 import (
+	"context"
 	"encoding/base64"
 	"fmt"
 	"net"
 	"net/netip"
+	"slices"
 	"strconv"
 	"time"
 
 	log "github.com/sirupsen/logrus"
 
-	nbdns "github.com/netbirdio/netbird/dns"
-	nbroute "github.com/netbirdio/netbird/route"
 	"github.com/netbirdio/netbird/shared/management/domain"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	"github.com/netbirdio/netbird/shared/management/proto"
 	"github.com/netbirdio/netbird/shared/management/types"
 )
@@ -24,7 +25,7 @@ import (
 // ID scheme on the client side:
 //
 //	Peers              base64(wg_pub_key)          // stable across snapshots
-func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents, error) {
+func DecodeEnvelope(ctx context.Context, env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents, error) {
 	full := env.GetFull()
 	if full == nil {
 		return nil, fmt.Errorf("envelope has no Full payload")
@@ -35,28 +36,28 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents,
 		Network:             decodeAccountNetwork(full.Network),
 		AccountSettings:     decodeAccountSettings(full.AccountSettings),
 		CustomZoneDomain:    full.CustomZoneDomain,
-		Peers:               make(map[string]*types.ComponentPeer, len(full.Peers)),
-		Groups:              make(map[string]*types.ComponentGroup, len(full.Groups)),
-		Policies:            make([]*types.Policy, 0, len(full.Policies)),
-		Routes:              make([]*nbroute.Route, 0, len(full.Routes)),
-		NameServerGroups:    make([]*nbdns.NameServerGroup, 0, len(full.NameserverGroups)),
+		Peers:               make(map[string]*nmdata.Peer, len(full.Peers)),
+		Groups:              make(map[string]*nmdata.Group, len(full.Groups)),
+		Policies:            make([]*nmdata.Policy, 0, len(full.Policies)),
+		Routes:              make([]*nmdata.Route, 0, len(full.Routes)),
+		NameServerGroups:    make([]*nmdata.NameServerGroup, 0, len(full.NameserverGroups)),
 		AllDNSRecords:       decodeSimpleRecords(full.AllDnsRecords),
 		AccountZones:        decodeCustomZones(full.AccountZones),
-		ResourcePoliciesMap: make(map[string][]*types.Policy),
-		RoutersMap:          make(map[string]map[string]*types.ComponentRouter),
-		NetworkResources:    make([]*types.ComponentResource, 0, len(full.NetworkResources)),
-		RouterPeers:         make(map[string]*types.ComponentPeer),
+		ResourcePoliciesMap: make(map[string][]*nmdata.Policy),
+		RoutersMap:          make(map[string]map[string]*nmdata.NetworkRouter),
+		NetworkResources:    make([]*nmdata.NetworkResource, 0, len(full.NetworkResources)),
+		RouterPeers:         make(map[string]*nmdata.Peer),
 		AllowedUserIDs:      stringSliceToSet(full.AllowedUserIds),
 		PostureFailedPeers:  make(map[string]map[string]struct{}, len(full.PostureFailedPeers)),
 		GroupIDToUserIDs:    make(map[string][]string, len(full.GroupIdToUserIds)),
 	}
 
 	if full.DnsSettings != nil {
-		c.DNSSettings = &types.DNSSettings{
+		c.DNSSettings = &nmdata.DNSSettings{
 			DisabledManagementGroups: full.DnsSettings.DisabledManagementGroupIds,
 		}
 	} else {
-		c.DNSSettings = &types.DNSSettings{}
+		c.DNSSettings = &nmdata.DNSSettings{}
 	}
 
 	// Phase 1: peers. The envelope's peers slice is index-addressed on the
@@ -98,20 +99,36 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents,
 				log.WithField("peer idx", idx).Error("unrecognized peer idx during decoding")
 			}
 		}
-		group := &types.ComponentGroup{
-			ID:       groupID,
-			PublicID: gc.Id,
-			Peers:    peerIDs,
+
+		fromCompactResources := func() []nmdata.Resource {
+			var toret []nmdata.Resource
+
+			for _, r := range gc.Resources {
+				res := resourceFromProto(r, peerIDByIndex)
+				if res == (nmdata.Resource{}) {
+					log.WithContext(ctx).Warnf("skipping invalid resource in group compact: %s", r.String())
+					continue
+				}
+				toret = append(toret, res)
+			}
+
+			return toret
+		}
+
+		group := &nmdata.Group{
+			PublicID:  gc.Id,
+			Peers:     peerIDs,
+			Resources: fromCompactResources(),
 		}
 		if gc.IsAll {
-			group.Name = types.GroupAllName
+			group.Name = nmdata.GroupAllName
 		}
 		c.Groups[groupID] = group
 	}
 
 	// Phase 3: policies (PolicyCompact = one rule per entry; current data
 	// model is 1 rule per policy).
-	policyByID := make(map[string]*types.Policy, len(full.Policies))
+	policyByID := make(map[string]*nmdata.Policy, len(full.Policies))
 	for i, pc := range full.Policies {
 		if pc == nil {
 			return nil, fmt.Errorf("invalid envelope: policies[%d] is nil", i)
@@ -148,7 +165,7 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents,
 	// Phase 7: routers_map (outer key = network seq id, inner key = peer-id
 	// reconstructed from peer_index). Synthesized network id is "net_".
 	for networkID, list := range full.RoutersMap {
-		inner := make(map[string]*types.ComponentRouter, len(list.Entries))
+		inner := make(map[string]*nmdata.NetworkRouter, len(list.Entries))
 		for _, entry := range list.Entries {
 			if !entry.PeerIndexSet {
 				continue
@@ -158,10 +175,8 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents,
 				continue
 			}
 			peerID := peerIDByIndex[entry.PeerIndex]
-			inner[peerID] = &types.ComponentRouter{
-				NetworkID:  networkID,
+			inner[peerID] = &nmdata.NetworkRouter{
 				PublicID:   entry.Id,
-				Peer:       peerID,
 				PeerGroups: entry.PeerGroupIds,
 				Masquerade: entry.Masquerade,
 				Metric:     int(entry.Metric),
@@ -180,7 +195,7 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents,
 		if len(ids.Ids) == 0 {
 			continue
 		}
-		policies := make([]*types.Policy, 0, len(ids.Ids))
+		policies := make([]*nmdata.Policy, 0, len(ids.Ids))
 		for _, id := range ids.Ids {
 			if p, ok := policyByID[id]; ok {
 				policies = append(policies, p)
@@ -193,6 +208,15 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents,
 		}
 	}
 
+	// Phase 8: rebuild resource_policies_map
+	for _, r := range c.NetworkResources {
+		policies := policiesForNetworkResource(r.ID, c.Policies, c.Groups)
+		if len(policies) == 0 {
+			continue
+		}
+		c.ResourcePoliciesMap[r.ID] = policies
+	}
+
 	// Phase 9: group_id_to_user_ids — wire keys are seq ids, synth to strings.
 	for groupId, list := range full.GroupIdToUserIds {
 		c.GroupIDToUserIDs[groupId] = append([]string(nil), list.UserIds...)
@@ -228,17 +252,54 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents,
 	return c, nil
 }
 
+func networkResourceGroups(resourceId string, groups map[string]*nmdata.Group) []string {
+	var toret []string
+	for _, group := range groups {
+		for _, resource := range group.Resources {
+			if resource.ID == resourceId {
+				toret = append(toret, group.PublicID)
+			}
+		}
+	}
+	return toret
+}
+
+func policiesForNetworkResource(resourceId string, allPolicies []*nmdata.Policy, groups map[string]*nmdata.Group) []*nmdata.Policy {
+	var toret []*nmdata.Policy
+
+	networkResourceGroups := networkResourceGroups(resourceId, groups)
+	for _, p := range allPolicies {
+		if p == nil || !p.Enabled || len(p.Rules) == 0 {
+			continue
+		}
+
+		// there's always only one rule in each policy
+		if p.Rules[0].DestinationResource.ID == resourceId {
+			toret = append(toret, p)
+			continue
+		}
+		for _, groupId := range networkResourceGroups {
+			if slices.Contains(p.Rules[0].Destinations, groupId) {
+				toret = append(toret, p)
+				break
+			}
+		}
+	}
+
+	return toret
+}
+
 // decodeAccountNetwork never returns nil — Calculate() dereferences
 // c.Network unconditionally, and servers that predate the fix omit the field
 // entirely from the empty-components envelope.
-func decodeAccountNetwork(an *proto.AccountNetwork) *types.Network {
-	n := &types.Network{}
+func decodeAccountNetwork(an *proto.AccountNetwork) *nmdata.Network {
+	n := &nmdata.Network{}
 	if an == nil {
 		return n
 	}
 	n.Identifier = an.Identifier
 	n.Dns = an.Dns
-	n.Serial = an.Serial
+	n.Serial = int64(an.Serial)
 	if an.NetCidr != "" {
 		if _, ipnet, err := net.ParseCIDR(an.NetCidr); err == nil && ipnet != nil {
 			n.Net = *ipnet
@@ -252,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),
@@ -312,11 +392,11 @@ func decodePolicyCompact(pc *proto.PolicyCompact, policyID string, peerIDByIndex
 		SourceResource:      resourceFromProto(pc.SourceResource, peerIDByIndex),
 		DestinationResource: resourceFromProto(pc.DestinationResource, peerIDByIndex),
 	}
-	return &types.Policy{
+	return &nmdata.Policy{
 		ID:                  policyID,
 		PublicID:            pc.Id,
 		Enabled:             true,
-		Rules:               []*types.PolicyRule{rule},
+		Rules:               []*nmdata.PolicyRule{rule},
 		SourcePostureChecks: pc.SourcePostureCheckIds,
 	}
 }
@@ -324,15 +404,19 @@ func decodePolicyCompact(pc *proto.PolicyCompact, policyID string, peerIDByIndex
 // resourceFromProto rebuilds types.Resource. For peer-typed resources the
 // peer reference is reconstructed from the envelope's peer index — wire
 // format ships no xid for peers, so we use the synthesized peer id.
-func resourceFromProto(r *proto.ResourceCompact, peerIDByIndex []string) types.Resource {
-	if r == nil {
-		return types.Resource{}
+func resourceFromProto(r *proto.ResourceCompact, peerIDByIndex []string) nmdata.Resource {
+	if r == nil || !types.ResourceType(r.Type).Valid() {
+		return nmdata.Resource{}
 	}
-	out := types.Resource{Type: types.ResourceType(r.Type)}
-	if r.PeerIndexSet && int(r.PeerIndex) < len(peerIDByIndex) {
-		out.ID = peerIDByIndex[r.PeerIndex]
+
+	if r.Type == string(types.ResourceTypePeer) {
+		if !r.PeerIndexSet || int(r.PeerIndex) >= len(peerIDByIndex) {
+			return nmdata.Resource{}
+		}
+		return nmdata.Resource{Type: r.Type, ID: peerIDByIndex[int(r.PeerIndex)]}
 	}
-	return out
+
+	return nmdata.Resource{Type: r.Type, ID: r.Id}
 }
 
 // authorizedGroupsFromProto inverts encodeAuthorizedGroups: the wire form
@@ -353,15 +437,15 @@ func authorizedGroupsFromProto(m map[string]*proto.UserNameList) map[string][]st
 	return out
 }
 
-func decodeRouteRaw(rr *proto.RouteRaw, peerIDByIndex []string) *nbroute.Route {
-	r := &nbroute.Route{
-		ID:                  nbroute.ID(rr.Id),
+func decodeRouteRaw(rr *proto.RouteRaw, peerIDByIndex []string) *nmdata.Route {
+	r := &nmdata.Route{
+		ID:                  rr.Id,
 		PublicID:            rr.Id,
-		NetID:               nbroute.NetID(rr.NetId),
+		NetID:               rr.NetId,
 		Description:         rr.Description,
 		Domains:             domainsFromPunycode(rr.Domains),
 		KeepRoute:           rr.KeepRoute,
-		NetworkType:         nbroute.NetworkType(rr.NetworkType),
+		NetworkType:         int(rr.NetworkType),
 		Masquerade:          rr.Masquerade,
 		Metric:              int(rr.Metric),
 		Enabled:             rr.Enabled,
@@ -381,8 +465,8 @@ func decodeRouteRaw(rr *proto.RouteRaw, peerIDByIndex []string) *nbroute.Route {
 	return r
 }
 
-func decodeNameServerGroupRaw(nsg *proto.NameServerGroupRaw) *nbdns.NameServerGroup {
-	out := &nbdns.NameServerGroup{
+func decodeNameServerGroupRaw(nsg *proto.NameServerGroupRaw) *nmdata.NameServerGroup {
+	out := &nmdata.NameServerGroup{
 		ID:                   nsg.Id,
 		PublicID:             nsg.Id,
 		Groups:               nsg.GroupIds,
@@ -390,13 +474,13 @@ func decodeNameServerGroupRaw(nsg *proto.NameServerGroupRaw) *nbdns.NameServerGr
 		Domains:              nsg.Domains,
 		Enabled:              nsg.Enabled,
 		SearchDomainsEnabled: nsg.SearchDomainsEnabled,
-		NameServers:          make([]nbdns.NameServer, 0, len(nsg.Nameservers)),
+		NameServers:          make([]nmdata.NameServer, 0, len(nsg.Nameservers)),
 	}
 	for _, ns := range nsg.Nameservers {
 		if addr, err := netip.ParseAddr(ns.IP); err == nil {
-			out.NameServers = append(out.NameServers, nbdns.NameServer{
+			out.NameServers = append(out.NameServers, nmdata.NameServer{
 				IP:     addr,
-				NSType: nbdns.NameServerType(ns.NSType),
+				NSType: int(ns.NSType),
 				Port:   int(ns.Port),
 			})
 		}
@@ -404,14 +488,14 @@ func decodeNameServerGroupRaw(nsg *proto.NameServerGroupRaw) *nbdns.NameServerGr
 	return out
 }
 
-func decodeNetworkResource(nr *proto.NetworkResourceRaw) *types.ComponentResource {
-	out := &types.ComponentResource{
+func decodeNetworkResource(nr *proto.NetworkResourceRaw) *nmdata.NetworkResource {
+	out := &nmdata.NetworkResource{
 		ID:          nr.Id,
 		PublicID:    nr.Id,
 		NetworkID:   nr.NetworkSeq,
 		Name:        nr.Name,
 		Description: nr.Description,
-		Type:        types.ComponentResourceType(nr.Type),
+		Type:        nr.Type,
 		Address:     nr.Address,
 		Domain:      nr.DomainValue,
 		Enabled:     nr.Enabled,
@@ -424,10 +508,10 @@ func decodeNetworkResource(nr *proto.NetworkResourceRaw) *types.ComponentResourc
 	return out
 }
 
-func decodeSimpleRecords(records []*proto.SimpleRecord) []nbdns.SimpleRecord {
-	out := make([]nbdns.SimpleRecord, 0, len(records))
+func decodeSimpleRecords(records []*proto.SimpleRecord) []nmdata.SimpleRecord {
+	out := make([]nmdata.SimpleRecord, 0, len(records))
 	for _, r := range records {
-		out = append(out, nbdns.SimpleRecord{
+		out = append(out, nmdata.SimpleRecord{
 			Name:  r.Name,
 			Type:  int(r.Type),
 			Class: r.Class,
@@ -438,10 +522,10 @@ func decodeSimpleRecords(records []*proto.SimpleRecord) []nbdns.SimpleRecord {
 	return out
 }
 
-func decodeCustomZones(zones []*proto.CustomZone) []nbdns.CustomZone {
-	out := make([]nbdns.CustomZone, 0, len(zones))
+func decodeCustomZones(zones []*proto.CustomZone) []nmdata.CustomZone {
+	out := make([]nmdata.CustomZone, 0, len(zones))
 	for _, z := range zones {
-		out = append(out, nbdns.CustomZone{
+		out = append(out, nmdata.CustomZone{
 			Domain:               z.Domain,
 			Records:              decodeSimpleRecords(z.Records),
 			SearchDomainDisabled: z.SearchDomainDisabled,
@@ -462,16 +546,16 @@ func uint32SliceToStrings(ports []uint32) []string {
 	return out
 }
 
-func portRangesFromProto(ranges []*proto.PortInfo_Range) []types.RulePortRange {
+func portRangesFromProto(ranges []*proto.PortInfo_Range) []nmdata.RulePortRange {
 	if len(ranges) == 0 {
 		return nil
 	}
-	out := make([]types.RulePortRange, 0, len(ranges))
+	out := make([]nmdata.RulePortRange, 0, len(ranges))
 	for _, r := range ranges {
 		if r == nil || r.Start > 65535 || r.End > 65535 {
 			continue
 		}
-		out = append(out, types.RulePortRange{
+		out = append(out, nmdata.RulePortRange{
 			Start: uint16(r.Start),
 			End:   uint16(r.End),
 		})
diff --git a/shared/management/networkmap/decode_test.go b/shared/management/networkmap/decode_test.go
new file mode 100644
index 000000000..7e2f17c60
--- /dev/null
+++ b/shared/management/networkmap/decode_test.go
@@ -0,0 +1,61 @@
+package networkmap
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	protobuf "google.golang.org/protobuf/proto"
+
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+	"github.com/netbirdio/netbird/shared/management/proto"
+)
+
+func TestDecodePolicy(t *testing.T) {
+	assert.Equal(t,
+		nmdata.Resource{Type: "peer", ID: "valid-id"},
+		resourceFromProto(
+			&proto.ResourceCompact{Type: "peer", PeerIndexSet: true, PeerIndex: uint32(1)},
+			[]string{"invalid-id-0", "valid-id", "invalid-id-2"}))
+	// check invalid peer index returns an empty resource
+	assert.Equal(t,
+		nmdata.Resource{},
+		resourceFromProto(
+			&proto.ResourceCompact{Type: "peer", PeerIndexSet: true, PeerIndex: uint32(100)},
+			[]string{"invalid-id-0", "valid-id", "invalid-id-2"}))
+	assert.Equal(t,
+		nmdata.Resource{Type: "domain", ID: "domain"},
+		resourceFromProto(
+			&proto.ResourceCompact{Type: "domain", Id: "domain"}, []string{}))
+	assert.Equal(t,
+		nmdata.Resource{Type: "host", ID: "host"},
+		resourceFromProto(
+			&proto.ResourceCompact{Type: "host", Id: "host"}, []string{}))
+	assert.Equal(t,
+		nmdata.Resource{Type: "subnet", ID: "subnet"},
+		resourceFromProto(
+			&proto.ResourceCompact{Type: "subnet", Id: "subnet"}, []string{}))
+	// an unknown resource type return an empty resource
+	assert.Equal(t,
+		nmdata.Resource{},
+		resourceFromProto(
+			&proto.ResourceCompact{Type: "boom", Id: "boom"}, []string{}))
+}
+
+// ResourceCompact fields 1-3 are the v0.77 wire contract. Retyping any of them
+// makes peers on either side of the change silently drop policy resources, so
+// the encoding is pinned here as raw bytes: field 1 "peer" (bytes), field 2
+// true (varint), field 3 7 (varint).
+func TestResourceCompactLegacyWireFormat(t *testing.T) {
+	legacy := []byte{0x0a, 0x04, 'p', 'e', 'e', 'r', 0x10, 0x01, 0x18, 0x07}
+
+	var decoded proto.ResourceCompact
+	require.NoError(t, protobuf.Unmarshal(legacy, &decoded))
+	assert.Equal(t, "peer", decoded.Type)
+	assert.True(t, decoded.PeerIndexSet)
+	assert.Equal(t, uint32(7), decoded.PeerIndex)
+
+	encoded, err := protobuf.Marshal(&proto.ResourceCompact{Type: "peer", PeerIndexSet: true, PeerIndex: 7})
+	require.NoError(t, err)
+	assert.Equal(t, legacy, encoded)
+}
diff --git a/shared/management/networkmap/encode.go b/shared/management/networkmap/encode.go
index 7e68861dc..dfacabe18 100644
--- a/shared/management/networkmap/encode.go
+++ b/shared/management/networkmap/encode.go
@@ -17,10 +17,11 @@ import (
 	log "github.com/sirupsen/logrus"
 	goproto "google.golang.org/protobuf/proto"
 
-	nbdns "github.com/netbirdio/netbird/dns"
 	"net/netip"
 
-	nbroute "github.com/netbirdio/netbird/route"
+	nbdns "github.com/netbirdio/netbird/dns"
+
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	"github.com/netbirdio/netbird/shared/management/proto"
 	"github.com/netbirdio/netbird/shared/management/types"
 	"github.com/netbirdio/netbird/shared/netiputil"
@@ -28,7 +29,7 @@ import (
 )
 
 // ToProtocolRoutes converts a slice of typed routes to their proto form.
-func ToProtocolRoutes(routes []*nbroute.Route) []*proto.Route {
+func ToProtocolRoutes(routes []*nmdata.Route) []*proto.Route {
 	protoRoutes := make([]*proto.Route, 0, len(routes))
 	for _, r := range routes {
 		protoRoutes = append(protoRoutes, ToProtocolRoute(r))
@@ -37,7 +38,7 @@ func ToProtocolRoutes(routes []*nbroute.Route) []*proto.Route {
 }
 
 // ToProtocolRoute converts one typed route to its proto form.
-func ToProtocolRoute(route *nbroute.Route) *proto.Route {
+func ToProtocolRoute(route *nmdata.Route) *proto.Route {
 	return &proto.Route{
 		ID:            string(route.ID),
 		NetID:         string(route.NetID),
@@ -272,8 +273,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() {
@@ -284,12 +286,25 @@ 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
 }
 
+// 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 a928c5059..e7961fd7b 100644
--- a/shared/management/networkmap/envelope.go
+++ b/shared/management/networkmap/envelope.go
@@ -36,7 +36,7 @@ type EnvelopeResult struct {
 // dnsName is the account's DNS domain ("netbird.cloud" etc.); used when
 // rebuilding the per-peer FQDNs that proto.RemotePeerConfig carries.
 func EnvelopeToNetworkMap(ctx context.Context, env *proto.NetworkMapEnvelope, localPeerKey, dnsName string) (*EnvelopeResult, error) {
-	components, err := DecodeEnvelope(env)
+	components, err := DecodeEnvelope(ctx, env)
 	if err != nil {
 		return nil, fmt.Errorf("decode envelope: %w", err)
 	}
@@ -54,8 +54,8 @@ func EnvelopeToNetworkMap(ctx context.Context, env *proto.NetworkMapEnvelope, lo
 	}
 	components.PeerID = canonicalKey
 
-	includeIPv6 := localPeer.SupportsIPv6 && localPeer.IPv6.IsValid()
-	useSourcePrefixes := localPeer.SupportsSourcePrefixes
+	includeIPv6 := localPeer.SupportsIPv6() && localPeer.IPv6.IsValid()
+	useSourcePrefixes := localPeer.SupportsSourcePrefixes()
 
 	typedNM := components.Calculate(ctx)
 
@@ -74,11 +74,11 @@ func EnvelopeToNetworkMap(ctx context.Context, env *proto.NetworkMapEnvelope, lo
 	protoNM.Routes = ToProtocolRoutes(typedNM.Routes)
 	protoNM.DNSConfig = ToProtocolDNSConfig(typedNM.DNSConfig, nil, dnsFwdPort)
 
-	remotePeers := AppendRemotePeerConfig(nil, typedNM.Peers, dnsName, includeIPv6)
+	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 92e1916da..7fe2a5277 100644
--- a/shared/management/networkmap/envelope_test.go
+++ b/shared/management/networkmap/envelope_test.go
@@ -15,6 +15,7 @@ import (
 	mgmtgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
 	"github.com/netbirdio/netbird/management/server/types"
 	nbnetworkmap "github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	"github.com/netbirdio/netbird/shared/management/proto"
 )
 
@@ -55,13 +56,13 @@ func TestEnvelopeToNetworkMap_RoundTrip(t *testing.T) {
 func TestCalculate_FirewallRuleProtocol_NeverNetbirdSSH(t *testing.T) {
 	c, localPeerKey := buildSmokeComponents(t)
 	// Replace the smoke policy with a NetbirdSSH-protocol allow.
-	c.Policies = []*types.Policy{{
+	c.Policies = []*nmdata.Policy{{
 		ID: "pol-ssh", PublicID: "2", Enabled: true,
-		Rules: []*types.PolicyRule{{
+		Rules: []*nmdata.PolicyRule{{
 			ID:            "rule-ssh",
 			Enabled:       true,
-			Action:        types.PolicyTrafficActionAccept,
-			Protocol:      types.PolicyRuleProtocolNetbirdSSH,
+			Action:        string(types.PolicyTrafficActionAccept),
+			Protocol:      string(types.PolicyRuleProtocolNetbirdSSH),
 			Bidirectional: true,
 			Sources:       []string{"group-all"},
 			Destinations:  []string{"group-all"},
@@ -143,39 +144,39 @@ func TestDecodeEnvelope_MalformedWgKeyPeerSkipped(t *testing.T) {
 func TestEnvelopeRoundTrip_AllGroupShortCircuitParity(t *testing.T) {
 	ctx := context.Background()
 
-	peers := map[string]*types.ComponentPeer{}
+	peers := map[string]*nmdata.Peer{}
 	for i, id := range []string{"peer-T", "peer-S", "peer-ALL", "peer-O"} {
-		peers[id] = &types.ComponentPeer{
-			ID:           id,
-			Key:          randomWgKey(t),
-			IP:           netip.AddrFrom4([4]byte{100, 64, 0, byte(i + 1)}),
-			DNSLabel:     id,
-			AgentVersion: "0.40.0",
+		peers[id] = &nmdata.Peer{
+			ID:       id,
+			Key:      randomWgKey(t),
+			IP:       netip.AddrFrom4([4]byte{100, 64, 0, byte(i + 1)}),
+			DNSLabel: id,
+			Meta:     nmdata.PeerSystemMeta{WtVersion: "0.40.0"},
 		}
 	}
 
 	c := &types.NetworkMapComponents{
 		PeerID: "peer-T",
-		Network: &types.Network{
+		Network: &nmdata.Network{
 			Identifier: "net-all-groups",
 			Net:        net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)},
 			Serial:     1,
 		},
-		AccountSettings: &types.AccountSettingsInfo{},
-		DNSSettings:     &types.DNSSettings{},
+		AccountSettings: &nmdata.AccountSettingsInfo{},
+		DNSSettings:     &nmdata.DNSSettings{},
 		Peers:           peers,
-		Groups: map[string]*types.ComponentGroup{
-			"g-src": {ID: "g-src", PublicID: "1", Name: "staff", Peers: []string{"peer-T", "peer-S"}},
-			"g-all": {ID: "g-all", PublicID: "2", Name: "All", Peers: []string{"peer-ALL"}},
-			"g-two": {ID: "g-two", PublicID: "3", Name: "second", Peers: []string{"peer-T", "peer-O"}},
+		Groups: map[string]*nmdata.Group{
+			"g-src": {PublicID: "1", Name: "staff", Peers: []string{"peer-T", "peer-S"}},
+			"g-all": {PublicID: "2", Name: "All", Peers: []string{"peer-ALL"}},
+			"g-two": {PublicID: "3", Name: "second", Peers: []string{"peer-T", "peer-O"}},
 		},
-		Policies: []*types.Policy{{
+		Policies: []*nmdata.Policy{{
 			ID: "pol-multi-dest", PublicID: "10", Enabled: true,
-			Rules: []*types.PolicyRule{{
+			Rules: []*nmdata.PolicyRule{{
 				ID:           "rule-multi-dest",
 				Enabled:      true,
-				Action:       types.PolicyTrafficActionAccept,
-				Protocol:     types.PolicyRuleProtocolALL,
+				Action:       string(types.PolicyTrafficActionAccept),
+				Protocol:     string(types.PolicyRuleProtocolALL),
 				Sources:      []string{"g-src"},
 				Destinations: []string{"g-all", "g-two"},
 			}},
@@ -231,12 +232,12 @@ func TestEnvelopeToNetworkMap_EmptyComponents(t *testing.T) {
 	localPeerKey := randomWgKey(t)
 	c := types.EmptyNetworkMapComponents(&types.NetworkMapComponents{
 		PeerID: "peer-A",
-		Network: &types.Network{
+		Network: &nmdata.Network{
 			Identifier: "net-empty",
 			Net:        net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)},
 			Serial:     7,
 		},
-		Peers: map[string]*types.ComponentPeer{
+		Peers: map[string]*nmdata.Peer{
 			"peer-A": {ID: "peer-A", Key: localPeerKey, IP: netip.AddrFrom4([4]byte{100, 64, 0, 1})},
 		},
 	})
@@ -291,33 +292,33 @@ func buildSmokeComponents(t *testing.T) (*types.NetworkMapComponents, string) {
 	peerAKey := randomWgKey(t)
 	peerBKey := randomWgKey(t)
 
-	peerA := &types.ComponentPeer{
-		ID:           "peer-A",
-		Key:          peerAKey,
-		IP:           netip.AddrFrom4([4]byte{100, 64, 0, 1}),
-		DNSLabel:     "peerA",
-		AgentVersion: "0.40.0",
+	peerA := &nmdata.Peer{
+		ID:       "peer-A",
+		Key:      peerAKey,
+		IP:       netip.AddrFrom4([4]byte{100, 64, 0, 1}),
+		DNSLabel: "peerA",
+		Meta:     nmdata.PeerSystemMeta{WtVersion: "0.40.0"},
 	}
-	peerB := &types.ComponentPeer{
-		ID:           "peer-B",
-		Key:          peerBKey,
-		IP:           netip.AddrFrom4([4]byte{100, 64, 0, 2}),
-		DNSLabel:     "peerB",
-		AgentVersion: "0.40.0",
+	peerB := &nmdata.Peer{
+		ID:       "peer-B",
+		Key:      peerBKey,
+		IP:       netip.AddrFrom4([4]byte{100, 64, 0, 2}),
+		DNSLabel: "peerB",
+		Meta:     nmdata.PeerSystemMeta{WtVersion: "0.40.0"},
 	}
 
-	group := &types.ComponentGroup{
-		ID: "group-all", PublicID: "1", Name: "All",
+	group := &nmdata.Group{
+		PublicID: "1", Name: "All",
 		Peers: []string{"peer-A", "peer-B"},
 	}
 
-	policy := &types.Policy{
+	policy := &nmdata.Policy{
 		ID: "pol-allow", PublicID: "1", Enabled: true,
-		Rules: []*types.PolicyRule{{
+		Rules: []*nmdata.PolicyRule{{
 			ID:            "rule-allow",
 			Enabled:       true,
-			Action:        types.PolicyTrafficActionAccept,
-			Protocol:      types.PolicyRuleProtocolALL,
+			Action:        string(types.PolicyTrafficActionAccept),
+			Protocol:      string(types.PolicyRuleProtocolALL),
 			Bidirectional: true,
 			Sources:       []string{"group-all"},
 			Destinations:  []string{"group-all"},
@@ -326,21 +327,21 @@ func buildSmokeComponents(t *testing.T) (*types.NetworkMapComponents, string) {
 
 	c := &types.NetworkMapComponents{
 		PeerID: "peer-A",
-		Network: &types.Network{
+		Network: &nmdata.Network{
 			Identifier: "net-smoke",
 			Net:        net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)},
 			Serial:     1,
 		},
-		AccountSettings: &types.AccountSettingsInfo{},
-		DNSSettings:     &types.DNSSettings{},
-		Peers: map[string]*types.ComponentPeer{
+		AccountSettings: &nmdata.AccountSettingsInfo{},
+		DNSSettings:     &nmdata.DNSSettings{},
+		Peers: map[string]*nmdata.Peer{
 			"peer-A": peerA,
 			"peer-B": peerB,
 		},
-		Groups: map[string]*types.ComponentGroup{
+		Groups: map[string]*nmdata.Group{
 			"group-all": group,
 		},
-		Policies: []*types.Policy{policy},
+		Policies: []*nmdata.Policy{policy},
 	}
 	return c, peerAKey
 }
diff --git a/shared/management/networkmap/networkmapcompute.go b/shared/management/networkmap/networkmapcompute.go
new file mode 100644
index 000000000..65e76d097
--- /dev/null
+++ b/shared/management/networkmap/networkmapcompute.go
@@ -0,0 +1,815 @@
+package networkmap
+
+import (
+	"slices"
+
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+	"github.com/netbirdio/netbird/shared/management/types"
+)
+
+type sshRequirements struct {
+	neededGroupIDs     map[string]struct{}
+	needAllowedUserIDs bool
+}
+
+// GetPeerNetworkMapComponents computes the peer's NetworkMapComponents from the
+// slim twin store. It mirrors the former Account.GetPeerNetworkMapComponents
+// exactly, operating on nmdata twins throughout — no Account reference and no
+// twin↔real conversion, since the produced components hold twins.
+func (nmd *NetworkMapData) GetPeerNetworkMapComponents(peerID string, peersCustomZone nmdata.CustomZone) *types.NetworkMapComponents {
+	nmd.InjectProxyPolicies()
+
+	forceRoutingPeerDNS := nmd.forcesRoutingPeerDNSResolution(peerID)
+
+	peer := nmd.Peers[peerID]
+	if peer == nil {
+		return types.EmptyNetworkMapComponents(&types.NetworkMapComponents{
+			PeerID:                        peerID,
+			Network:                       nmd.Network,
+			Peers:                         map[string]*nmdata.Peer{peerID: peer},
+			ForceRoutingPeerDNSResolution: forceRoutingPeerDNS,
+		})
+	}
+
+	if _, ok := nmd.ValidatedPeers[peerID]; !ok {
+		return types.EmptyNetworkMapComponents(&types.NetworkMapComponents{
+			PeerID:                        peerID,
+			Network:                       nmd.Network,
+			Peers:                         map[string]*nmdata.Peer{peerID: peer},
+			ForceRoutingPeerDNSResolution: forceRoutingPeerDNS,
+		})
+	}
+
+	components := &types.NetworkMapComponents{
+		PeerID:                        peerID,
+		Network:                       nmd.Network,
+		AccountSettings:               nmd.AccountSettings,
+		DNSSettings:                   nmd.DNSSettings,
+		CustomZoneDomain:              peersCustomZone.Domain,
+		NameServerGroups:              make([]*nmdata.NameServerGroup, 0),
+		ResourcePoliciesMap:           make(map[string][]*nmdata.Policy),
+		RoutersMap:                    make(map[string]map[string]*nmdata.NetworkRouter),
+		NetworkResources:              make([]*nmdata.NetworkResource, 0),
+		PostureFailedPeers:            make(map[string]map[string]struct{}, len(nmd.PostureChecks)),
+		RouterPeers:                   make(map[string]*nmdata.Peer),
+		NetworkXIDToPublicID:          nmd.NetworkXIDToPublicID,
+		PostureCheckXIDToPublicID:     nmd.PostureCheckXIDToPublicID,
+		ForceRoutingPeerDNSResolution: forceRoutingPeerDNS,
+	}
+
+	relevantPeers, relevantGroups, relevantPolicies, relevantRoutes, sshReqs := nmd.getPeersGroupsPoliciesRoutes(peerID, peer.SSHEnabled, &components.PostureFailedPeers)
+
+	if len(sshReqs.neededGroupIDs) > 0 {
+		components.GroupIDToUserIDs = filterGroupIDToUserIDs(nmd.GroupIDToUserIDs, sshReqs.neededGroupIDs)
+	}
+	if sshReqs.needAllowedUserIDs {
+		components.AllowedUserIDs = nmd.getAllowedUserIDs()
+	}
+
+	components.Peers = relevantPeers
+	components.Groups = relevantGroups
+	components.Policies = relevantPolicies
+	components.Routes = relevantRoutes
+	components.AllDNSRecords = filterDNSRecordsByPeers(peersCustomZone.Records, relevantPeers, peer.SupportsIPv6() && peer.IPv6.IsValid())
+
+	peerGroups := nmd.GetPeerGroups(peerID)
+	components.AccountZones = nmd.appliedZones(peerGroups)
+	components.AccountZones = append(components.AccountZones, nmd.privateServiceZones(peerGroups)...)
+
+	for _, nsGroup := range nmd.NameServerGroups {
+		if nsGroup != nil && nsGroup.Enabled {
+			for _, gID := range nsGroup.Groups {
+				if _, found := relevantGroups[gID]; found {
+					components.NameServerGroups = append(components.NameServerGroups, nsGroup)
+					break
+				}
+			}
+		}
+	}
+
+	for _, resource := range nmd.NetworkResources {
+		if resource == nil || !resource.Enabled {
+			continue
+		}
+
+		policies, exists := nmd.ResourcePolicies[resource.ID]
+		if !exists {
+			continue
+		}
+
+		addSourcePeers := false
+
+		networkRoutingPeers, routerExists := nmd.Routers[resource.NetworkID]
+		if routerExists {
+			if _, ok := networkRoutingPeers[peerID]; ok {
+				addSourcePeers = true
+			}
+		}
+
+		for _, policy := range policies {
+			if policy == nil || !policy.Enabled || len(policy.Rules) == 0 || policy.Rules[0] == nil {
+				continue
+			}
+			if addSourcePeers {
+				var peers []string
+				if policy.Rules[0].SourceResource.Type == string(types.ResourceTypePeer) && policy.Rules[0].SourceResource.ID != "" {
+					peers = []string{policy.Rules[0].SourceResource.ID}
+				} else {
+					peers = nmd.getUniquePeerIDsFromGroupsIDs(policy.SourceGroups())
+				}
+				for _, pID := range nmd.getPostureValidPeersSaveFailed(peers, policy.SourcePostureChecks, &components.PostureFailedPeers) {
+					if _, exists := components.Peers[pID]; !exists {
+						components.Peers[pID] = nmd.Peers[pID]
+					}
+				}
+			} else {
+				peerInSources := false
+				if policy.Rules[0].SourceResource.Type == string(types.ResourceTypePeer) && policy.Rules[0].SourceResource.ID != "" {
+					peerInSources = policy.Rules[0].SourceResource.ID == peerID
+				} else {
+					for _, groupID := range policy.SourceGroups() {
+						if group := nmd.Groups[groupID]; group != nil && slices.Contains(group.Peers, peerID) {
+							peerInSources = true
+							break
+						}
+					}
+				}
+				if !peerInSources {
+					continue
+				}
+				isValid, pname := nmd.validatePostureChecksOnPeerGetFailed(policy.SourcePostureChecks, peerID)
+				if !isValid && len(pname) > 0 {
+					if _, ok := components.PostureFailedPeers[pname]; !ok {
+						components.PostureFailedPeers[pname] = make(map[string]struct{})
+					}
+					components.PostureFailedPeers[pname][peer.ID] = struct{}{}
+					continue
+				}
+				addSourcePeers = true
+			}
+
+			for _, rule := range policy.Rules {
+				if rule == nil || !rule.Enabled {
+					continue
+				}
+				for _, srcGroupID := range rule.Sources {
+					if g := nmd.Groups[srcGroupID]; g != nil {
+						if _, exists := components.Groups[srcGroupID]; !exists {
+							components.Groups[srcGroupID] = g
+						}
+					}
+				}
+				for _, dstGroupID := range rule.Destinations {
+					if g := nmd.Groups[dstGroupID]; g != nil {
+						if _, exists := components.Groups[dstGroupID]; !exists {
+							components.Groups[dstGroupID] = g
+						}
+					}
+				}
+			}
+			components.ResourcePoliciesMap[resource.ID] = policies
+		}
+
+		if addSourcePeers {
+			components.RoutersMap[resource.NetworkID] = networkRoutingPeers
+			for peerIDKey := range networkRoutingPeers {
+				p := nmd.Peers[peerIDKey]
+				if p == nil {
+					continue
+				}
+				// An unapproved peer must not carry traffic, so it is kept out of
+				// RouterPeers as well: the envelope encoder indexes that map into
+				// the wire peer table, from which the client restores every entry.
+				if _, validated := nmd.ValidatedPeers[peerIDKey]; !validated {
+					continue
+				}
+				if _, exists := components.RouterPeers[peerIDKey]; !exists {
+					components.RouterPeers[peerIDKey] = p
+				}
+				if _, exists := components.Peers[peerIDKey]; !exists {
+					components.Peers[peerIDKey] = p
+				}
+			}
+			components.NetworkResources = append(components.NetworkResources, resource)
+		}
+	}
+
+	filterGroupPeers(&components.Groups, components.Peers)
+	filterPostureFailedPeers(&components.PostureFailedPeers, components.Policies, components.ResourcePoliciesMap, components.Peers)
+
+	return components
+}
+
+func (nmd *NetworkMapData) getPeersGroupsPoliciesRoutes(
+	peerID string,
+	peerSSHEnabled bool,
+	postureFailedPeers *map[string]map[string]struct{},
+) (map[string]*nmdata.Peer, map[string]*nmdata.Group, []*nmdata.Policy, []*nmdata.Route, sshRequirements) {
+	relevantPeerIDs := make(map[string]*nmdata.Peer, len(nmd.Peers)/4)
+	relevantGroupIDs := make(map[string]*nmdata.Group, len(nmd.Groups)/4)
+	relevantPolicies := make([]*nmdata.Policy, 0, len(nmd.Policies))
+	relevantRoutes := make([]*nmdata.Route, 0, len(nmd.Routes))
+	sshReqs := sshRequirements{neededGroupIDs: make(map[string]struct{})}
+
+	relevantPeerIDs[peerID] = nmd.Peers[peerID]
+
+	peerGroupSet := nmd.GetPeerGroups(peerID)
+	for groupID := range peerGroupSet {
+		relevantGroupIDs[groupID] = nmd.Groups[groupID]
+	}
+
+	routeAccessControlGroups := make(map[string]struct{})
+	for _, r := range nmd.Routes {
+		if r == nil {
+			continue
+		}
+		relevant := r.Peer == peerID
+		if !relevant {
+			for _, groupID := range r.PeerGroups {
+				if _, ok := peerGroupSet[groupID]; ok {
+					relevant = true
+					break
+				}
+			}
+		}
+		if !relevant && r.Enabled {
+			for _, groupID := range r.Groups {
+				if _, ok := peerGroupSet[groupID]; ok {
+					relevant = true
+					break
+				}
+			}
+		}
+		if !relevant {
+			continue
+		}
+
+		for _, groupID := range r.PeerGroups {
+			if g := nmd.Groups[groupID]; g != nil {
+				relevantGroupIDs[groupID] = g
+			}
+		}
+		for _, groupID := range r.Groups {
+			if g := nmd.Groups[groupID]; g != nil {
+				relevantGroupIDs[groupID] = g
+			}
+		}
+		if r.Enabled {
+			for _, groupID := range r.AccessControlGroups {
+				if g := nmd.Groups[groupID]; g != nil {
+					relevantGroupIDs[groupID] = g
+				}
+				routeAccessControlGroups[groupID] = struct{}{}
+			}
+		}
+
+		if r.Peer != "" {
+			if _, ok := nmd.ValidatedPeers[r.Peer]; ok {
+				if p := nmd.Peers[r.Peer]; p != nil {
+					relevantPeerIDs[r.Peer] = p
+				}
+			}
+		}
+		for _, groupID := range r.PeerGroups {
+			g := nmd.Groups[groupID]
+			if g == nil {
+				continue
+			}
+			for _, pid := range g.Peers {
+				if _, exists := relevantPeerIDs[pid]; exists {
+					continue
+				}
+				if _, ok := nmd.ValidatedPeers[pid]; !ok {
+					continue
+				}
+				if p := nmd.Peers[pid]; p != nil {
+					relevantPeerIDs[pid] = p
+				}
+			}
+		}
+		relevantRoutes = append(relevantRoutes, r)
+	}
+
+	for _, policy := range nmd.Policies {
+		if policy == nil || !policy.Enabled {
+			continue
+		}
+
+		policyRelevant := false
+		for _, rule := range policy.Rules {
+			if rule == nil || !rule.Enabled {
+				continue
+			}
+
+			if len(routeAccessControlGroups) > 0 {
+				for _, destGroupID := range rule.Destinations {
+					if _, needed := routeAccessControlGroups[destGroupID]; needed {
+						policyRelevant = true
+						for _, srcGroupID := range rule.Sources {
+							if g := nmd.Groups[srcGroupID]; g != nil {
+								relevantGroupIDs[srcGroupID] = g
+							}
+						}
+						for _, dstGroupID := range rule.Destinations {
+							if g := nmd.Groups[dstGroupID]; g != nil {
+								relevantGroupIDs[dstGroupID] = g
+							}
+						}
+						break
+					}
+				}
+			}
+
+			var sourcePeers, destinationPeers []string
+			var peerInSources, peerInDestinations bool
+
+			if rule.SourceResource.Type == string(types.ResourceTypePeer) && rule.SourceResource.ID != "" {
+				sourcePeers, peerInSources = nmd.getPeerFromResource(rule.SourceResource, peerID, policy.SourcePostureChecks, postureFailedPeers)
+			} else {
+				sourcePeers, peerInSources = nmd.getPeersFromGroups(rule.Sources, peerID, policy.SourcePostureChecks, postureFailedPeers)
+			}
+
+			if rule.DestinationResource.Type == string(types.ResourceTypePeer) && rule.DestinationResource.ID != "" {
+				destinationPeers, peerInDestinations = nmd.getPeerFromResource(rule.DestinationResource, peerID, nil, postureFailedPeers)
+			} else {
+				destinationPeers, peerInDestinations = nmd.getPeersFromGroups(rule.Destinations, peerID, nil, postureFailedPeers)
+			}
+
+			if peerInSources {
+				policyRelevant = true
+				for _, pid := range destinationPeers {
+					relevantPeerIDs[pid] = nmd.Peers[pid]
+				}
+				for _, dstGroupID := range rule.Destinations {
+					if g := nmd.Groups[dstGroupID]; g != nil {
+						relevantGroupIDs[dstGroupID] = g
+					}
+				}
+			}
+
+			if peerInDestinations {
+				policyRelevant = true
+				for _, pid := range sourcePeers {
+					relevantPeerIDs[pid] = nmd.Peers[pid]
+				}
+				for _, srcGroupID := range rule.Sources {
+					if g := nmd.Groups[srcGroupID]; g != nil {
+						relevantGroupIDs[srcGroupID] = g
+					}
+				}
+
+				if rule.Protocol == string(types.PolicyRuleProtocolNetbirdSSH) {
+					switch {
+					case len(rule.AuthorizedGroups) > 0:
+						for groupID := range rule.AuthorizedGroups {
+							sshReqs.neededGroupIDs[groupID] = struct{}{}
+						}
+					case rule.AuthorizedUser != "":
+					default:
+						sshReqs.needAllowedUserIDs = true
+					}
+				} else if nmdata.PolicyRuleImpliesLegacySSH(rule) && peerSSHEnabled {
+					sshReqs.needAllowedUserIDs = true
+				}
+			}
+		}
+		if policyRelevant {
+			relevantPolicies = append(relevantPolicies, policy)
+		}
+	}
+
+	return relevantPeerIDs, relevantGroupIDs, relevantPolicies, relevantRoutes, sshReqs
+}
+
+func (nmd *NetworkMapData) getPeersFromGroups(groups []string, peerID string, sourcePostureChecksIDs []string,
+	postureFailedPeers *map[string]map[string]struct{}) ([]string, bool) {
+	peerInGroups := false
+	filteredPeerIDs := make([]string, 0, len(groups))
+	seenPeerIds := make(map[string]struct{}, len(groups))
+
+	for _, gid := range groups {
+		group := nmd.Groups[gid]
+		if group == nil {
+			continue
+		}
+
+		if group.IsGroupAll() || len(groups) == 1 {
+			filteredPeerIDs = make([]string, 0, len(group.Peers))
+			peerInGroups = false
+			for _, pid := range group.Peers {
+				if !nmd.admitPolicyPeer(pid, sourcePostureChecksIDs, postureFailedPeers) {
+					continue
+				}
+
+				if pid == peerID {
+					peerInGroups = true
+					continue
+				}
+
+				filteredPeerIDs = append(filteredPeerIDs, pid)
+			}
+			return filteredPeerIDs, peerInGroups
+		}
+
+		for _, pid := range group.Peers {
+			if _, seen := seenPeerIds[pid]; seen {
+				continue
+			}
+			seenPeerIds[pid] = struct{}{}
+			if !nmd.admitPolicyPeer(pid, sourcePostureChecksIDs, postureFailedPeers) {
+				continue
+			}
+
+			if pid == peerID {
+				peerInGroups = true
+				continue
+			}
+
+			filteredPeerIDs = append(filteredPeerIDs, pid)
+		}
+	}
+
+	return filteredPeerIDs, peerInGroups
+}
+
+// getPeerFromResource resolves a rule side that names a peer directly, admitting it
+// like a member of a group holding only that peer.
+func (nmd *NetworkMapData) getPeerFromResource(resource nmdata.Resource, peerID string, sourcePostureChecksIDs []string,
+	postureFailedPeers *map[string]map[string]struct{}) ([]string, bool) {
+	if !nmd.admitPolicyPeer(resource.ID, sourcePostureChecksIDs, postureFailedPeers) {
+		return nil, false
+	}
+	if resource.ID == peerID {
+		return nil, true
+	}
+	return []string{resource.ID}, false
+}
+
+// admitPolicyPeer applies the per-peer admission of a rule side: the peer must exist,
+// be validated and pass the rule's posture checks. A failed check is recorded in
+// postureFailedPeers.
+func (nmd *NetworkMapData) admitPolicyPeer(pid string, sourcePostureChecksIDs []string, postureFailedPeers *map[string]map[string]struct{}) bool {
+	peer, ok := nmd.Peers[pid]
+	if !ok || peer == nil {
+		return false
+	}
+
+	if _, ok := nmd.ValidatedPeers[pid]; !ok {
+		return false
+	}
+
+	isValid, pname := nmd.validatePostureChecksOnPeerGetFailed(sourcePostureChecksIDs, pid)
+	if !isValid && len(pname) > 0 {
+		if _, ok := (*postureFailedPeers)[pname]; !ok {
+			(*postureFailedPeers)[pname] = make(map[string]struct{})
+		}
+		(*postureFailedPeers)[pname][pid] = struct{}{}
+		return false
+	}
+	return true
+}
+
+func (nmd *NetworkMapData) validatePostureChecksOnPeerGetFailed(sourcePostureChecksID []string, peerID string) (bool, string) {
+	peer, ok := nmd.Peers[peerID]
+	if !ok || peer == nil {
+		return false, ""
+	}
+
+	for _, postureChecksID := range sourcePostureChecksID {
+		if valid, cached := nmd.cachedPostureCheckResult(postureChecksID, peerID); cached {
+			if !valid {
+				return false, postureChecksID
+			}
+			continue
+		}
+
+		postureChecks := nmd.PostureChecks[postureChecksID]
+		if postureChecks == nil {
+			continue
+		}
+		if !postureChecks.Passes(peer) {
+			return false, postureChecksID
+		}
+	}
+	return true, ""
+}
+
+func (nmd *NetworkMapData) PrecomputePostureValidation() {
+	if len(nmd.PostureChecks) == 0 {
+		nmd.PostureValidation = nil
+		return
+	}
+
+	checkPeerIDs := make(map[string]map[string]struct{})
+	for _, policy := range nmd.Policies {
+		if policy == nil || !policy.Enabled || len(policy.SourcePostureChecks) == 0 {
+			continue
+		}
+
+		groupPeerIDs := nmd.getUniquePeerIDsFromGroupsIDs(policy.SourceGroups())
+		for _, postureChecksID := range policy.SourcePostureChecks {
+			set := checkPeerIDs[postureChecksID]
+			if set == nil {
+				set = make(map[string]struct{}, len(groupPeerIDs))
+				checkPeerIDs[postureChecksID] = set
+			}
+			for _, pid := range groupPeerIDs {
+				set[pid] = struct{}{}
+			}
+			for _, rule := range policy.Rules {
+				if rule == nil {
+					continue
+				}
+				if rule.SourceResource.Type == string(types.ResourceTypePeer) && rule.SourceResource.ID != "" {
+					set[rule.SourceResource.ID] = struct{}{}
+				}
+			}
+		}
+	}
+
+	results := make(map[string]map[string]bool, len(checkPeerIDs))
+	for postureChecksID, peerIDs := range checkPeerIDs {
+		results[postureChecksID] = nmd.evaluatePostureChecksForPeers(postureChecksID, peerIDs)
+	}
+	nmd.PostureValidation = results
+}
+
+func (nmd *NetworkMapData) evaluatePostureChecksForPeers(postureChecksID string, peerIDs map[string]struct{}) map[string]bool {
+	postureChecks := nmd.PostureChecks[postureChecksID]
+	if postureChecks == nil {
+		return nil
+	}
+
+	checks := postureChecks.GetChecks()
+	results := make(map[string]bool, len(peerIDs))
+	for peerID := range peerIDs {
+		peer := nmd.Peers[peerID]
+		if peer == nil {
+			continue
+		}
+		results[peerID] = nmdata.PassesChecks(checks, peer)
+	}
+	return results
+}
+
+func (nmd *NetworkMapData) cachedPostureCheckResult(postureChecksID, peerID string) (bool, bool) {
+	results, ok := nmd.PostureValidation[postureChecksID]
+	if !ok {
+		return false, false
+	}
+	if results == nil {
+		return true, true
+	}
+	valid, found := results[peerID]
+	return valid, found
+}
+
+func (nmd *NetworkMapData) getPostureValidPeersSaveFailed(inputPeers []string, postureChecksIDs []string, postureFailedPeers *map[string]map[string]struct{}) []string {
+	var dest []string
+	for _, peerID := range inputPeers {
+		if _, validated := nmd.ValidatedPeers[peerID]; !validated {
+			continue
+		}
+		valid, pname := nmd.validatePostureChecksOnPeerGetFailed(postureChecksIDs, peerID)
+		if valid {
+			dest = append(dest, peerID)
+			continue
+		}
+		if pname == "" {
+			continue
+		}
+		if _, ok := (*postureFailedPeers)[pname]; !ok {
+			(*postureFailedPeers)[pname] = make(map[string]struct{})
+		}
+		(*postureFailedPeers)[pname][peerID] = struct{}{}
+	}
+	return dest
+}
+
+// forcesRoutingPeerDNSResolution reports whether the given peer must run
+// routing-peer DNS resolution regardless of the account-global
+// RoutingPeerDNSResolutionEnabled setting: true when the peer routes a domain
+// network resource targeted by an enabled reverse-proxy service, so the peer's
+// DNS forwarder starts and can resolve the target for the embedded proxy peers.
+func (nmd *NetworkMapData) forcesRoutingPeerDNSResolution(peerID string) bool {
+	if len(nmd.ProxyTargetedDomainResourceIDs) == 0 {
+		return false
+	}
+
+	for _, resource := range nmd.NetworkResources {
+		if resource == nil || !resource.Enabled || resource.Type != string(types.ResourceTypeDomain) {
+			continue
+		}
+		if _, ok := nmd.ProxyTargetedDomainResourceIDs[resource.ID]; !ok {
+			continue
+		}
+		if _, isRouter := nmd.Routers[resource.NetworkID][peerID]; isRouter {
+			return true
+		}
+	}
+
+	return false
+}
+
+// GetPeerGroups returns the set of group IDs the peer belongs to. The
+// underlying peer→groups index is built once per NetworkMapData and the
+// returned set is shared — callers must not mutate it.
+func (nmd *NetworkMapData) GetPeerGroups(peerID string) map[string]struct{} {
+	nmd.peerGroupsOnce.Do(func() {
+		idx := make(map[string]map[string]struct{}, len(nmd.Peers))
+		for groupID, group := range nmd.Groups {
+			if group == nil {
+				continue
+			}
+			for _, pid := range group.Peers {
+				set, ok := idx[pid]
+				if !ok {
+					set = make(map[string]struct{})
+					idx[pid] = set
+				}
+				set[groupID] = struct{}{}
+			}
+		}
+		nmd.peerGroupsIdx = idx
+	})
+
+	if set, ok := nmd.peerGroupsIdx[peerID]; ok {
+		return set
+	}
+	return map[string]struct{}{}
+}
+
+func (nmd *NetworkMapData) getUniquePeerIDsFromGroupsIDs(groups []string) []string {
+	peerIDs := make(map[string]struct{}, len(groups))
+	for _, groupID := range groups {
+		group := nmd.Groups[groupID]
+		if group == nil {
+			continue
+		}
+
+		if group.IsGroupAll() || len(groups) == 1 {
+			return group.Peers
+		}
+
+		for _, peerID := range group.Peers {
+			peerIDs[peerID] = struct{}{}
+		}
+	}
+
+	ids := make([]string, 0, len(peerIDs))
+	for peerID := range peerIDs {
+		ids = append(ids, peerID)
+	}
+
+	return ids
+}
+
+func (nmd *NetworkMapData) getAllowedUserIDs() map[string]struct{} {
+	return nmd.AllowedUserIDs
+}
+
+func (nmd *NetworkMapData) appliedZones(peerGroups map[string]struct{}) []nmdata.CustomZone {
+	if len(peerGroups) == 0 {
+		return nil
+	}
+	var out []nmdata.CustomZone
+	for _, cand := range nmd.AppliedZoneCandidates {
+		if peerInDistributionGroups(peerGroups, cand.DistributionGroups) {
+			out = append(out, cand.Zone)
+		}
+	}
+	return out
+}
+
+func (nmd *NetworkMapData) privateServiceZones(peerGroups map[string]struct{}) []nmdata.CustomZone {
+	byApex := make(map[string]*nmdata.CustomZone)
+	var order []string
+	for _, cand := range nmd.PrivateServiceCandidates {
+		if !peerInDistributionGroups(peerGroups, cand.AccessGroups) {
+			continue
+		}
+		zone, exists := byApex[cand.Zone.Domain]
+		if !exists {
+			nz := nmdata.CustomZone{
+				Domain:               cand.Zone.Domain,
+				SearchDomainDisabled: cand.Zone.SearchDomainDisabled,
+				NonAuthoritative:     cand.Zone.NonAuthoritative,
+			}
+			byApex[cand.Zone.Domain] = &nz
+			zone = &nz
+			order = append(order, cand.Zone.Domain)
+		}
+		zone.Records = append(zone.Records, cand.Zone.Records...)
+	}
+
+	var out []nmdata.CustomZone
+	for _, apex := range order {
+		zone := byApex[apex]
+		if len(zone.Records) == 0 {
+			continue
+		}
+		out = append(out, *zone)
+	}
+	return out
+}
+
+func peerInDistributionGroups(peerGroups map[string]struct{}, groups []string) bool {
+	for _, g := range groups {
+		if _, ok := peerGroups[g]; ok {
+			return true
+		}
+	}
+	return false
+}
+
+func filterGroupPeers(groups *map[string]*nmdata.Group, peers map[string]*nmdata.Peer) {
+	for groupID, groupInfo := range *groups {
+		filteredPeers := make([]string, 0, len(groupInfo.Peers))
+		for _, pid := range groupInfo.Peers {
+			if _, exists := peers[pid]; exists {
+				filteredPeers = append(filteredPeers, pid)
+			}
+		}
+
+		if len(filteredPeers) != len(groupInfo.Peers) {
+			ng := groupInfo.Copy()
+			ng.Peers = filteredPeers
+			(*groups)[groupID] = ng
+		}
+	}
+}
+
+func filterPostureFailedPeers(postureFailedPeers *map[string]map[string]struct{}, policies []*nmdata.Policy, resourcePoliciesMap map[string][]*nmdata.Policy, peers map[string]*nmdata.Peer) {
+	if len(*postureFailedPeers) == 0 {
+		return
+	}
+
+	referencedPostureChecks := make(map[string]struct{})
+	for _, policy := range policies {
+		for _, checkID := range policy.SourcePostureChecks {
+			referencedPostureChecks[checkID] = struct{}{}
+		}
+	}
+	for _, resPolicies := range resourcePoliciesMap {
+		for _, policy := range resPolicies {
+			for _, checkID := range policy.SourcePostureChecks {
+				referencedPostureChecks[checkID] = struct{}{}
+			}
+		}
+	}
+
+	for checkID, failedPeers := range *postureFailedPeers {
+		if _, referenced := referencedPostureChecks[checkID]; !referenced {
+			delete(*postureFailedPeers, checkID)
+			continue
+		}
+		for peerID := range failedPeers {
+			if _, exists := peers[peerID]; !exists {
+				delete(failedPeers, peerID)
+			}
+		}
+		if len(failedPeers) == 0 {
+			delete(*postureFailedPeers, checkID)
+		}
+	}
+}
+
+func filterDNSRecordsByPeers(records []nmdata.SimpleRecord, peers map[string]*nmdata.Peer, includeIPv6 bool) []nmdata.SimpleRecord {
+	if len(records) == 0 || len(peers) == 0 {
+		return nil
+	}
+
+	peerIPs := make(map[string]struct{}, len(peers)*2)
+	for _, peer := range peers {
+		if peer == nil {
+			continue
+		}
+		peerIPs[peer.IP.String()] = struct{}{}
+		if includeIPv6 && peer.IPv6.IsValid() {
+			peerIPs[peer.IPv6.String()] = struct{}{}
+		}
+	}
+
+	filteredRecords := make([]nmdata.SimpleRecord, 0, len(records))
+	for _, record := range records {
+		if _, exists := peerIPs[record.RData]; exists {
+			filteredRecords = append(filteredRecords, record)
+		}
+	}
+
+	return filteredRecords
+}
+
+func filterGroupIDToUserIDs(fullMap map[string][]string, neededGroupIDs map[string]struct{}) map[string][]string {
+	if len(neededGroupIDs) == 0 {
+		return nil
+	}
+
+	filtered := make(map[string][]string, len(neededGroupIDs))
+	for groupID := range neededGroupIDs {
+		if users, ok := fullMap[groupID]; ok {
+			filtered[groupID] = users
+		}
+	}
+	return filtered
+}
diff --git a/shared/management/networkmap/networkmapcompute_test.go b/shared/management/networkmap/networkmapcompute_test.go
new file mode 100644
index 000000000..ad21fd70f
--- /dev/null
+++ b/shared/management/networkmap/networkmapcompute_test.go
@@ -0,0 +1,1664 @@
+package networkmap_test
+
+import (
+	"context"
+	"fmt"
+	"net/netip"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/netbirdio/netbird/shared/management/networkmap"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+	nbtypes "github.com/netbirdio/netbird/shared/management/types"
+)
+
+const (
+	targetID          = "peer-target"
+	postureMinVersion = "0.30.0"
+	passingVersion    = "1.0.0"
+	failingVersion    = "0.1.0"
+)
+
+func newPeer(id string, hostNum byte) *nmdata.Peer {
+	return &nmdata.Peer{
+		ID:       id,
+		Key:      "key-" + id,
+		IP:       netip.AddrFrom4([4]byte{100, 64, 0, hostNum}),
+		DNSLabel: id,
+		Meta:     nmdata.PeerSystemMeta{WtVersion: passingVersion},
+	}
+}
+
+func newNMD(peers ...*nmdata.Peer) *networkmap.NetworkMapData {
+	nmd := &networkmap.NetworkMapData{
+		Peers:           make(map[string]*nmdata.Peer),
+		Groups:          make(map[string]*nmdata.Group),
+		ValidatedPeers:  make(map[string]struct{}),
+		Network:         &nmdata.Network{Identifier: "network-1", Serial: 7},
+		AccountSettings: &nmdata.AccountSettingsInfo{},
+		DNSSettings:     &nmdata.DNSSettings{},
+	}
+	for _, p := range peers {
+		nmd.Peers[p.ID] = p
+		nmd.ValidatedPeers[p.ID] = struct{}{}
+	}
+	return nmd
+}
+
+func addGroup(nmd *networkmap.NetworkMapData, id string, peerIDs ...string) *nmdata.Group {
+	g := &nmdata.Group{ID: id, Name: id, Peers: peerIDs}
+	nmd.Groups[id] = g
+	return g
+}
+
+func newRule(sources, destinations []string) *nmdata.PolicyRule {
+	return &nmdata.PolicyRule{
+		Enabled:       true,
+		Action:        string(nbtypes.PolicyTrafficActionAccept),
+		Protocol:      string(nbtypes.PolicyRuleProtocolTCP),
+		Bidirectional: true,
+		Sources:       sources,
+		Destinations:  destinations,
+	}
+}
+
+func newPolicy(id string, rules ...*nmdata.PolicyRule) *nmdata.Policy {
+	for i, r := range rules {
+		if r.ID == "" {
+			r.ID = fmt.Sprintf("%s-rule-%d", id, i)
+		}
+		r.PolicyID = id
+	}
+	return &nmdata.Policy{ID: id, Enabled: true, Rules: rules}
+}
+
+func addVersionCheck(nmd *networkmap.NetworkMapData, id, minVersion string) {
+	if nmd.PostureChecks == nil {
+		nmd.PostureChecks = make(map[string]*nmdata.PostureChecks)
+	}
+	nmd.PostureChecks[id] = &nmdata.PostureChecks{
+		ID:     id,
+		Checks: nmdata.ChecksDefinition{NBVersionCheck: &nmdata.NBVersionCheck{MinVersion: minVersion}},
+	}
+}
+
+func compute(nmd *networkmap.NetworkMapData, peerID string) *nbtypes.NetworkMapComponents {
+	return nmd.GetPeerNetworkMapComponents(peerID, nmdata.CustomZone{})
+}
+
+func peerIDSet(peers map[string]*nmdata.Peer) []string {
+	ids := make([]string, 0, len(peers))
+	for id := range peers {
+		ids = append(ids, id)
+	}
+	return ids
+}
+
+func policyIDs(policies []*nmdata.Policy) []string {
+	ids := make([]string, 0, len(policies))
+	for _, p := range policies {
+		ids = append(ids, p.ID)
+	}
+	return ids
+}
+
+func groupIDSet(groups map[string]*nmdata.Group) []string {
+	ids := make([]string, 0, len(groups))
+	for id := range groups {
+		ids = append(ids, id)
+	}
+	return ids
+}
+
+func TestGetPeerNetworkMapComponents_UnknownPeer(t *testing.T) {
+	nmd := newNMD(newPeer("peer-a", 2))
+
+	c := compute(nmd, "missing")
+
+	require.True(t, c.IsEmpty())
+	assert.Equal(t, "missing", c.PeerID)
+	assert.Same(t, nmd.Network, c.Network)
+	require.Contains(t, c.Peers, "missing")
+	assert.Nil(t, c.Peers["missing"])
+	assert.Len(t, c.Peers, 1)
+	assert.Nil(t, c.AccountSettings)
+	assert.Nil(t, c.Policies)
+	assert.False(t, c.ForceRoutingPeerDNSResolution)
+}
+
+func TestGetPeerNetworkMapComponents_UnvalidatedPeer(t *testing.T) {
+	target := newPeer(targetID, 1)
+	nmd := newNMD(target)
+	delete(nmd.ValidatedPeers, targetID)
+
+	c := compute(nmd, targetID)
+
+	require.True(t, c.IsEmpty())
+	assert.Equal(t, targetID, c.PeerID)
+	assert.Same(t, target, c.Peers[targetID])
+	assert.Len(t, c.Peers, 1)
+	assert.Nil(t, c.AccountSettings)
+	assert.Nil(t, c.Groups)
+}
+
+// The forced-DNS flag must be computed even on the empty-components early
+// exits, so an unknown or unvalidated proxy routing peer still starts its DNS
+// forwarder.
+func TestGetPeerNetworkMapComponents_EmptyComponentsKeepForcedDNSResolution(t *testing.T) {
+	build := func() *networkmap.NetworkMapData {
+		nmd := newNMD(newPeer("unval-router", 1))
+		delete(nmd.ValidatedPeers, "unval-router")
+		nmd.NetworkResources = []*nmdata.NetworkResource{
+			{ID: "res-1", NetworkID: "net-1", Type: string(nbtypes.ResourceTypeDomain), Enabled: true},
+		}
+		nmd.ProxyTargetedDomainResourceIDs = map[string]struct{}{"res-1": {}}
+		nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {"ghost-router": {}, "unval-router": {}}}
+		return nmd
+	}
+
+	t.Run("unknown peer", func(t *testing.T) {
+		c := compute(build(), "ghost-router")
+		require.True(t, c.IsEmpty())
+		assert.True(t, c.ForceRoutingPeerDNSResolution)
+	})
+
+	t.Run("unvalidated peer", func(t *testing.T) {
+		c := compute(build(), "unval-router")
+		require.True(t, c.IsEmpty())
+		assert.True(t, c.ForceRoutingPeerDNSResolution)
+	})
+}
+
+func TestGetPeerNetworkMapComponents_ForceRoutingPeerDNSResolution(t *testing.T) {
+	forced := func(mutate func(*networkmap.NetworkMapData)) bool {
+		nmd := newNMD(newPeer(targetID, 1))
+		nmd.NetworkResources = []*nmdata.NetworkResource{
+			{ID: "res-1", NetworkID: "net-1", Type: string(nbtypes.ResourceTypeDomain), Enabled: true},
+		}
+		nmd.ProxyTargetedDomainResourceIDs = map[string]struct{}{"res-1": {}}
+		nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {targetID: {}}}
+		if mutate != nil {
+			mutate(nmd)
+		}
+		return compute(nmd, targetID).ForceRoutingPeerDNSResolution
+	}
+
+	t.Run("router of targeted domain resource is forced", func(t *testing.T) {
+		assert.True(t, forced(nil))
+	})
+	t.Run("no proxy-targeted resources", func(t *testing.T) {
+		assert.False(t, forced(func(nmd *networkmap.NetworkMapData) {
+			nmd.ProxyTargetedDomainResourceIDs = nil
+		}))
+	})
+	t.Run("resource disabled", func(t *testing.T) {
+		assert.False(t, forced(func(nmd *networkmap.NetworkMapData) {
+			nmd.NetworkResources[0].Enabled = false
+		}))
+	})
+	t.Run("resource not a domain", func(t *testing.T) {
+		assert.False(t, forced(func(nmd *networkmap.NetworkMapData) {
+			nmd.NetworkResources[0].Type = string(nbtypes.ResourceTypeHost)
+		}))
+	})
+	t.Run("resource not targeted", func(t *testing.T) {
+		assert.False(t, forced(func(nmd *networkmap.NetworkMapData) {
+			nmd.ProxyTargetedDomainResourceIDs = map[string]struct{}{"res-other": {}}
+		}))
+	})
+	t.Run("peer not a router of the resource network", func(t *testing.T) {
+		assert.False(t, forced(func(nmd *networkmap.NetworkMapData) {
+			nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {"someone-else": {}}}
+		}))
+	})
+	t.Run("nil resource entry tolerated", func(t *testing.T) {
+		assert.True(t, forced(func(nmd *networkmap.NetworkMapData) {
+			nmd.NetworkResources = append([]*nmdata.NetworkResource{nil}, nmd.NetworkResources...)
+		}))
+	})
+}
+
+func TestGetPeerNetworkMapComponents_CoreFieldsPassThrough(t *testing.T) {
+	target := newPeer(targetID, 1)
+	nmd := newNMD(target)
+	nmd.NetworkXIDToPublicID = map[string]string{"net-xid": "net-pub"}
+	nmd.PostureCheckXIDToPublicID = map[string]string{"pc-xid": "pc-pub"}
+
+	c := nmd.GetPeerNetworkMapComponents(targetID, nmdata.CustomZone{Domain: "acme.netbird.cloud."})
+
+	require.False(t, c.IsEmpty())
+	assert.Equal(t, targetID, c.PeerID)
+	assert.Same(t, nmd.Network, c.Network)
+	assert.Same(t, nmd.AccountSettings, c.AccountSettings)
+	assert.Same(t, nmd.DNSSettings, c.DNSSettings)
+	assert.Equal(t, "acme.netbird.cloud.", c.CustomZoneDomain)
+	assert.Equal(t, nmd.NetworkXIDToPublicID, c.NetworkXIDToPublicID)
+	assert.Equal(t, nmd.PostureCheckXIDToPublicID, c.PostureCheckXIDToPublicID)
+
+	assert.Equal(t, map[string]*nmdata.Peer{targetID: target}, c.Peers)
+	assert.Empty(t, c.Groups)
+	assert.Empty(t, c.Policies)
+	assert.Empty(t, c.Routes)
+	assert.Empty(t, c.NameServerGroups)
+	assert.Empty(t, c.NetworkResources)
+	assert.Empty(t, c.ResourcePoliciesMap)
+	assert.Empty(t, c.RoutersMap)
+	assert.Empty(t, c.RouterPeers)
+	assert.Empty(t, c.PostureFailedPeers)
+	assert.Nil(t, c.AllDNSRecords)
+	assert.Empty(t, c.AccountZones)
+	assert.Nil(t, c.GroupIDToUserIDs)
+	assert.Nil(t, c.AllowedUserIDs)
+	assert.False(t, c.ForceRoutingPeerDNSResolution)
+}
+
+func TestGetPeerNetworkMapComponents_OwnGroupsTrimmedWithoutMutatingStore(t *testing.T) {
+	target := newPeer(targetID, 1)
+	bystander := newPeer("peer-bystander", 2)
+	nmd := newNMD(target, bystander)
+	stored := addGroup(nmd, "g-mixed", targetID, bystander.ID)
+
+	c := compute(nmd, targetID)
+
+	require.Contains(t, c.Groups, "g-mixed")
+	assert.Equal(t, []string{targetID}, c.Groups["g-mixed"].Peers)
+	assert.NotSame(t, stored, c.Groups["g-mixed"])
+	assert.Equal(t, []string{targetID, bystander.ID}, stored.Peers)
+}
+
+func TestGetPeerNetworkMapComponents_PolicyRelevance(t *testing.T) {
+	t.Run("peer in sources pulls destination peers and groups", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		srcSibling := newPeer("peer-src-sibling", 2)
+		dst := newPeer("peer-dst", 3)
+		nmd := newNMD(target, srcSibling, dst)
+		addGroup(nmd, "g-src", targetID, srcSibling.ID)
+		addGroup(nmd, "g-dst", dst.ID)
+		p := newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"}))
+		nmd.Policies = []*nmdata.Policy{p}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies))
+		assert.ElementsMatch(t, []string{targetID, dst.ID}, peerIDSet(c.Peers),
+			"source-side siblings must not be connected")
+		assert.ElementsMatch(t, []string{"g-src", "g-dst"}, groupIDSet(c.Groups))
+		assert.Equal(t, []string{targetID}, c.Groups["g-src"].Peers)
+		assert.Equal(t, []string{dst.ID}, c.Groups["g-dst"].Peers)
+	})
+
+	t.Run("peer in destinations pulls source peers and groups", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		src := newPeer("peer-src", 2)
+		nmd := newNMD(target, src)
+		addGroup(nmd, "g-src", src.ID)
+		addGroup(nmd, "g-dst", targetID)
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"}))}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies))
+		assert.ElementsMatch(t, []string{targetID, src.ID}, peerIDSet(c.Peers))
+		assert.ElementsMatch(t, []string{"g-src", "g-dst"}, groupIDSet(c.Groups))
+	})
+
+	t.Run("unrelated policy contributes nothing", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		a := newPeer("peer-a", 2)
+		b := newPeer("peer-b", 3)
+		nmd := newNMD(target, a, b)
+		addGroup(nmd, "g-own", targetID)
+		addGroup(nmd, "g-a", a.ID)
+		addGroup(nmd, "g-b", b.ID)
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-1", newRule([]string{"g-a"}, []string{"g-b"}))}
+
+		c := compute(nmd, targetID)
+
+		assert.Empty(t, c.Policies)
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+		assert.ElementsMatch(t, []string{"g-own"}, groupIDSet(c.Groups))
+	})
+
+	t.Run("disabled policy ignored", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		src := newPeer("peer-src", 2)
+		nmd := newNMD(target, src)
+		addGroup(nmd, "g-src", src.ID)
+		addGroup(nmd, "g-dst", targetID)
+		p := newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"}))
+		p.Enabled = false
+		nmd.Policies = []*nmdata.Policy{p}
+
+		c := compute(nmd, targetID)
+
+		assert.Empty(t, c.Policies)
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+	})
+
+	t.Run("disabled rule ignored", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		src := newPeer("peer-src", 2)
+		nmd := newNMD(target, src)
+		addGroup(nmd, "g-src", src.ID)
+		addGroup(nmd, "g-dst", targetID)
+		rule := newRule([]string{"g-src"}, []string{"g-dst"})
+		rule.Enabled = false
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-1", rule)}
+
+		c := compute(nmd, targetID)
+
+		assert.Empty(t, c.Policies)
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+	})
+
+	t.Run("peer on both sides pulls peers from both directions", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		x := newPeer("peer-x", 2)
+		y := newPeer("peer-y", 3)
+		nmd := newNMD(target, x, y)
+		addGroup(nmd, "g-src", targetID, x.ID)
+		addGroup(nmd, "g-dst", targetID, y.ID)
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"}))}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies))
+		assert.ElementsMatch(t, []string{targetID, x.ID, y.ID}, peerIDSet(c.Peers),
+			"both the source-side and destination-side counterparts must connect")
+	})
+
+	t.Run("rule referencing missing group tolerated", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		nmd := newNMD(target)
+		addGroup(nmd, "g-dst", targetID)
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-1", newRule([]string{"g-ghost"}, []string{"g-dst"}))}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies))
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+	})
+
+	t.Run("nil policy and rule entries tolerated", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		dst := newPeer("peer-dst", 2)
+		nmd := newNMD(target, dst)
+		addGroup(nmd, "g-src", targetID)
+		addGroup(nmd, "g-dst", dst.ID)
+		p := newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"}))
+		p.Rules = append([]*nmdata.PolicyRule{nil}, p.Rules...)
+		nmd.Policies = []*nmdata.Policy{nil, p}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies))
+		assert.ElementsMatch(t, []string{targetID, dst.ID}, peerIDSet(c.Peers))
+	})
+}
+
+func TestGetPeerNetworkMapComponents_PeerResourceRules(t *testing.T) {
+	peerResource := func(id string) nmdata.Resource {
+		return nmdata.Resource{ID: id, Type: string(nbtypes.ResourceTypePeer)}
+	}
+
+	t.Run("target as source resource", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		dst := newPeer("peer-dst", 2)
+		nmd := newNMD(target, dst)
+		addGroup(nmd, "g-dst", dst.ID)
+		rule := newRule(nil, []string{"g-dst"})
+		rule.SourceResource = peerResource(targetID)
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-1", rule)}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies))
+		assert.ElementsMatch(t, []string{targetID, dst.ID}, peerIDSet(c.Peers))
+	})
+
+	t.Run("target as destination resource", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		src := newPeer("peer-src", 2)
+		nmd := newNMD(target, src)
+		addGroup(nmd, "g-src", src.ID)
+		rule := newRule([]string{"g-src"}, nil)
+		rule.DestinationResource = peerResource(targetID)
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-1", rule)}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies))
+		assert.ElementsMatch(t, []string{targetID, src.ID}, peerIDSet(c.Peers))
+	})
+
+	t.Run("remote peer as destination resource", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		remote := newPeer("peer-remote", 2)
+		nmd := newNMD(target, remote)
+		addGroup(nmd, "g-src", targetID)
+		rule := newRule([]string{"g-src"}, nil)
+		rule.DestinationResource = peerResource(remote.ID)
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-1", rule)}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies))
+		assert.ElementsMatch(t, []string{targetID, remote.ID}, peerIDSet(c.Peers))
+	})
+
+	// A directly referenced peer is admitted like a member of a group holding only
+	// that peer: the ValidatedPeers gate and the posture checks apply equally.
+	t.Run("unvalidated source resource peer is excluded", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		unval := newPeer("peer-unval", 2)
+		nmd := newNMD(target, unval)
+		delete(nmd.ValidatedPeers, unval.ID)
+		addGroup(nmd, "g-dst", targetID)
+		rule := newRule(nil, []string{"g-dst"})
+		rule.SourceResource = peerResource(unval.ID)
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-1", rule)}
+
+		c := compute(nmd, targetID)
+
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+	})
+
+	t.Run("source resource peer failing posture checks is excluded", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		failing := newPeer("peer-failing", 2)
+		failing.Meta.WtVersion = failingVersion
+		nmd := newNMD(target, failing)
+		addVersionCheck(nmd, "pc-1", postureMinVersion)
+		addGroup(nmd, "g-dst", targetID)
+		rule := newRule(nil, []string{"g-dst"})
+		rule.SourceResource = peerResource(failing.ID)
+		p := newPolicy("p-1", rule)
+		p.SourcePostureChecks = []string{"pc-1"}
+		nmd.Policies = []*nmdata.Policy{p}
+
+		c := compute(nmd, targetID)
+
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+		assert.Empty(t, c.PostureFailedPeers)
+	})
+
+	t.Run("direct source peer failure recorded when connected via another policy", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		failing := newPeer("peer-failing", 2)
+		failing.Meta.WtVersion = failingVersion
+		nmd := newNMD(target, failing)
+		addVersionCheck(nmd, "pc-1", postureMinVersion)
+		addGroup(nmd, "g-dst", targetID)
+		checkedRule := newRule(nil, []string{"g-dst"})
+		checkedRule.SourceResource = peerResource(failing.ID)
+		checked := newPolicy("p-checked", checkedRule)
+		checked.SourcePostureChecks = []string{"pc-1"}
+		openRule := newRule(nil, []string{"g-dst"})
+		openRule.SourceResource = peerResource(failing.ID)
+		nmd.Policies = []*nmdata.Policy{checked, newPolicy("p-open", openRule)}
+
+		c := compute(nmd, targetID)
+
+		assert.ElementsMatch(t, []string{targetID, failing.ID}, peerIDSet(c.Peers))
+		assert.Equal(t, map[string]map[string]struct{}{"pc-1": {failing.ID: {}}}, c.PostureFailedPeers)
+	})
+
+	t.Run("target as source resource failing posture checks gets no policy", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		target.Meta.WtVersion = failingVersion
+		dst := newPeer("peer-dst", 2)
+		nmd := newNMD(target, dst)
+		addVersionCheck(nmd, "pc-1", postureMinVersion)
+		addGroup(nmd, "g-dst", dst.ID)
+		rule := newRule(nil, []string{"g-dst"})
+		rule.SourceResource = peerResource(targetID)
+		p := newPolicy("p-1", rule)
+		p.SourcePostureChecks = []string{"pc-1"}
+		nmd.Policies = []*nmdata.Policy{p}
+
+		c := compute(nmd, targetID)
+
+		assert.Empty(t, policyIDs(c.Policies))
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+	})
+
+	t.Run("unvalidated destination resource peer is excluded", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		unval := newPeer("peer-unval", 2)
+		nmd := newNMD(target, unval)
+		delete(nmd.ValidatedPeers, unval.ID)
+		addGroup(nmd, "g-src", targetID)
+		rule := newRule([]string{"g-src"}, nil)
+		rule.DestinationResource = peerResource(unval.ID)
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-1", rule)}
+
+		c := compute(nmd, targetID)
+
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+	})
+
+	t.Run("unrelated peer resource rule ignored", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		a := newPeer("peer-a", 2)
+		b := newPeer("peer-b", 3)
+		nmd := newNMD(target, a, b)
+		rule := newRule(nil, nil)
+		rule.SourceResource = peerResource(a.ID)
+		rule.DestinationResource = peerResource(b.ID)
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-1", rule)}
+
+		c := compute(nmd, targetID)
+
+		assert.Empty(t, c.Policies)
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+	})
+}
+
+// A destination list containing a group named "All" short-circuits peer
+// expansion to that group alone, dropping peers accumulated from earlier
+// groups. Groups themselves are still all shipped. Mirrors legacy behavior
+// that the wire encoding depends on (see
+// TestEnvelopeRoundTrip_AllGroupShortCircuitParity).
+func TestGetPeerNetworkMapComponents_AllGroupShortCircuit(t *testing.T) {
+	target := newPeer(targetID, 1)
+	first := newPeer("peer-first", 2)
+	allMember := newPeer("peer-all-member", 3)
+	nmd := newNMD(target, first, allMember)
+	addGroup(nmd, "g-src", targetID)
+	addGroup(nmd, "g-first", first.ID)
+	nmd.Groups["g-all"] = &nmdata.Group{ID: "g-all", Name: nmdata.GroupAllName, Peers: []string{targetID, allMember.ID}}
+	nmd.Policies = []*nmdata.Policy{newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-first", "g-all"}))}
+
+	c := compute(nmd, targetID)
+
+	assert.ElementsMatch(t, []string{targetID, allMember.ID}, peerIDSet(c.Peers),
+		"peers from groups before the All group must be dropped by the short-circuit")
+	assert.ElementsMatch(t, []string{"g-src", "g-first", "g-all"}, groupIDSet(c.Groups))
+	assert.Empty(t, c.Groups["g-first"].Peers)
+}
+
+func TestGetPeerNetworkMapComponents_UnvalidatedPolicyPeersExcluded(t *testing.T) {
+	target := newPeer(targetID, 1)
+	srcOK := newPeer("peer-src-ok", 2)
+	srcUnval := newPeer("peer-src-unval", 3)
+	nmd := newNMD(target, srcOK, srcUnval)
+	delete(nmd.ValidatedPeers, srcUnval.ID)
+	addGroup(nmd, "g-src", srcOK.ID, srcUnval.ID, "peer-deleted")
+	addGroup(nmd, "g-dst", targetID)
+	nmd.Policies = []*nmdata.Policy{newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"}))}
+
+	c := compute(nmd, targetID)
+
+	assert.ElementsMatch(t, []string{targetID, srcOK.ID}, peerIDSet(c.Peers),
+		"unvalidated and dangling group members must not connect")
+	assert.Equal(t, []string{srcOK.ID}, c.Groups["g-src"].Peers)
+}
+
+// Multi-group rules take the union path of getPeersFromGroups (no All-group
+// short-circuit); validation and source posture checks apply per member.
+func TestGetPeerNetworkMapComponents_MultiGroupSources(t *testing.T) {
+	target := newPeer(targetID, 1)
+	dup := newPeer("peer-dup", 2)
+	unval := newPeer("peer-unval", 3)
+	failing := newPeer("peer-failing", 4)
+	failing.Meta.WtVersion = failingVersion
+	solo := newPeer("peer-solo", 5)
+	nmd := newNMD(target, dup, unval, failing, solo)
+	delete(nmd.ValidatedPeers, unval.ID)
+	addVersionCheck(nmd, "pc-1", postureMinVersion)
+	addGroup(nmd, "g-1", targetID, dup.ID, unval.ID, "peer-deleted")
+	addGroup(nmd, "g-2", dup.ID, failing.ID, solo.ID)
+	addGroup(nmd, "g-tgt", targetID)
+	p := newPolicy("p-1", newRule([]string{"g-1", "g-2"}, []string{"g-tgt"}))
+	p.SourcePostureChecks = []string{"pc-1"}
+	nmd.Policies = []*nmdata.Policy{p}
+
+	c := compute(nmd, targetID)
+
+	assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies))
+	assert.ElementsMatch(t, []string{targetID, dup.ID, solo.ID}, peerIDSet(c.Peers))
+	assert.Empty(t, c.PostureFailedPeers,
+		"failing is not otherwise connected, so its failure record is pruned")
+}
+
+func TestGetPeerNetworkMapComponents_PostureChecks(t *testing.T) {
+	t.Run("failing source peer excluded without orphan failure record", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		failing := newPeer("peer-failing", 2)
+		failing.Meta.WtVersion = failingVersion
+		nmd := newNMD(target, failing)
+		addVersionCheck(nmd, "pc-1", postureMinVersion)
+		addGroup(nmd, "g-src", failing.ID)
+		addGroup(nmd, "g-dst", targetID)
+		p := newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"}))
+		p.SourcePostureChecks = []string{"pc-1"}
+		nmd.Policies = []*nmdata.Policy{p}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies))
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+		assert.Empty(t, c.PostureFailedPeers,
+			"failure records for peers absent from the map must be pruned")
+	})
+
+	t.Run("failure recorded when peer is connected via another policy", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		failing := newPeer("peer-failing", 2)
+		failing.Meta.WtVersion = failingVersion
+		nmd := newNMD(target, failing)
+		addVersionCheck(nmd, "pc-1", postureMinVersion)
+		addGroup(nmd, "g-src", failing.ID)
+		addGroup(nmd, "g-dst", targetID)
+		checked := newPolicy("p-checked", newRule([]string{"g-src"}, []string{"g-dst"}))
+		checked.SourcePostureChecks = []string{"pc-1"}
+		open := newPolicy("p-open", newRule([]string{"g-src"}, []string{"g-dst"}))
+		nmd.Policies = []*nmdata.Policy{checked, open}
+
+		c := compute(nmd, targetID)
+
+		assert.ElementsMatch(t, []string{targetID, failing.ID}, peerIDSet(c.Peers))
+		assert.Equal(t, map[string]map[string]struct{}{"pc-1": {failing.ID: {}}}, c.PostureFailedPeers)
+	})
+
+	t.Run("destination peers bypass source posture checks", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		failing := newPeer("peer-failing", 2)
+		failing.Meta.WtVersion = failingVersion
+		nmd := newNMD(target, failing)
+		addVersionCheck(nmd, "pc-1", postureMinVersion)
+		addGroup(nmd, "g-src", targetID)
+		addGroup(nmd, "g-dst", failing.ID)
+		p := newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"}))
+		p.SourcePostureChecks = []string{"pc-1"}
+		nmd.Policies = []*nmdata.Policy{p}
+
+		c := compute(nmd, targetID)
+
+		assert.ElementsMatch(t, []string{targetID, failing.ID}, peerIDSet(c.Peers))
+		assert.Empty(t, c.PostureFailedPeers)
+	})
+
+	t.Run("target failing its own source check drops the policy", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		target.Meta.WtVersion = failingVersion
+		dst := newPeer("peer-dst", 2)
+		nmd := newNMD(target, dst)
+		addVersionCheck(nmd, "pc-1", postureMinVersion)
+		addGroup(nmd, "g-src", targetID)
+		addGroup(nmd, "g-dst", dst.ID)
+		p := newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"}))
+		p.SourcePostureChecks = []string{"pc-1"}
+		nmd.Policies = []*nmdata.Policy{p}
+
+		c := compute(nmd, targetID)
+
+		assert.Empty(t, c.Policies)
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+		assert.Empty(t, c.PostureFailedPeers)
+	})
+
+	t.Run("failure keyed by the first failing check", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		failing := newPeer("peer-failing", 2)
+		nmd := newNMD(target, failing)
+		addVersionCheck(nmd, "pc-pass", postureMinVersion)
+		addVersionCheck(nmd, "pc-fail", "2.0.0")
+		addGroup(nmd, "g-src", failing.ID)
+		addGroup(nmd, "g-dst", targetID)
+		checked := newPolicy("p-checked", newRule([]string{"g-src"}, []string{"g-dst"}))
+		checked.SourcePostureChecks = []string{"pc-pass", "pc-fail"}
+		open := newPolicy("p-open", newRule([]string{"g-src"}, []string{"g-dst"}))
+		nmd.Policies = []*nmdata.Policy{checked, open}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, map[string]map[string]struct{}{"pc-fail": {failing.ID: {}}}, c.PostureFailedPeers,
+			"the record must be keyed by the failing check, not the first listed")
+	})
+
+	t.Run("unknown posture check id passes everyone", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		src := newPeer("peer-src", 2)
+		nmd := newNMD(target, src)
+		addGroup(nmd, "g-src", src.ID)
+		addGroup(nmd, "g-dst", targetID)
+		p := newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"}))
+		p.SourcePostureChecks = []string{"pc-ghost"}
+		nmd.Policies = []*nmdata.Policy{p}
+
+		c := compute(nmd, targetID)
+
+		assert.ElementsMatch(t, []string{targetID, src.ID}, peerIDSet(c.Peers))
+		assert.Empty(t, c.PostureFailedPeers)
+	})
+}
+
+func TestGetPeerNetworkMapComponents_Routes(t *testing.T) {
+	t.Run("owned route relevant even when disabled", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		dist := newPeer("peer-dist", 2)
+		nmd := newNMD(target, dist)
+		addGroup(nmd, "g-dist", dist.ID)
+		addGroup(nmd, "g-acl")
+		r := &nmdata.Route{ID: "r-1", Peer: targetID, Enabled: false, Groups: []string{"g-dist"}, AccessControlGroups: []string{"g-acl"}}
+		nmd.Routes = []*nmdata.Route{r}
+
+		c := compute(nmd, targetID)
+
+		require.Len(t, c.Routes, 1)
+		assert.Same(t, r, c.Routes[0])
+		assert.Contains(t, c.Groups, "g-dist")
+		assert.NotContains(t, c.Groups, "g-acl",
+			"access control groups of a disabled route must not be collected")
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers),
+			"distribution group members are not connected by the route itself")
+	})
+
+	t.Run("peer-group route disabled still ships and connects HA members", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		ha := newPeer("peer-ha", 2)
+		haUnval := newPeer("peer-ha-unval", 3)
+		nmd := newNMD(target, ha, haUnval)
+		delete(nmd.ValidatedPeers, haUnval.ID)
+		addGroup(nmd, "g-ha", targetID, ha.ID, haUnval.ID)
+		r := &nmdata.Route{ID: "r-1", PeerGroups: []string{"g-ha", "g-ghost"}, Enabled: false}
+		nmd.Routes = []*nmdata.Route{r}
+
+		c := compute(nmd, targetID)
+
+		require.Len(t, c.Routes, 1)
+		assert.ElementsMatch(t, []string{targetID, ha.ID}, peerIDSet(c.Peers))
+		assert.Equal(t, []string{targetID, ha.ID}, c.Groups["g-ha"].Peers)
+	})
+
+	t.Run("route consumer connects HA routing peers from peer groups", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		router1 := newPeer("peer-router-1", 2)
+		router2 := newPeer("peer-router-2", 3)
+		routerUnval := newPeer("peer-router-unval", 4)
+		nmd := newNMD(target, router1, router2, routerUnval)
+		delete(nmd.ValidatedPeers, routerUnval.ID)
+		addGroup(nmd, "g-ha", router1.ID, router2.ID, routerUnval.ID)
+		addGroup(nmd, "g-dist", targetID)
+		nmd.Routes = []*nmdata.Route{{ID: "r-1", PeerGroups: []string{"g-ha"}, Groups: []string{"g-dist"}, Enabled: true}}
+
+		c := compute(nmd, targetID)
+
+		require.Len(t, c.Routes, 1)
+		assert.ElementsMatch(t, []string{targetID, router1.ID, router2.ID}, peerIDSet(c.Peers),
+			"the consumer must connect to every validated HA router")
+		assert.Equal(t, []string{router1.ID, router2.ID}, c.Groups["g-ha"].Peers)
+	})
+
+	t.Run("distribution route connects routing peer", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		router := newPeer("peer-router", 2)
+		nmd := newNMD(target, router)
+		addGroup(nmd, "g-dist", targetID)
+		r := &nmdata.Route{ID: "r-1", Peer: router.ID, Enabled: true, Groups: []string{"g-dist"}}
+		nmd.Routes = []*nmdata.Route{r}
+
+		c := compute(nmd, targetID)
+
+		require.Len(t, c.Routes, 1)
+		assert.ElementsMatch(t, []string{targetID, router.ID}, peerIDSet(c.Peers))
+	})
+
+	t.Run("disabled distribution route not relevant", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		router := newPeer("peer-router", 2)
+		nmd := newNMD(target, router)
+		addGroup(nmd, "g-dist", targetID)
+		nmd.Routes = []*nmdata.Route{{ID: "r-1", Peer: router.ID, Enabled: false, Groups: []string{"g-dist"}}}
+
+		c := compute(nmd, targetID)
+
+		assert.Empty(t, c.Routes)
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+	})
+
+	t.Run("unvalidated routing peer excluded but route ships", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		router := newPeer("peer-router", 2)
+		nmd := newNMD(target, router)
+		delete(nmd.ValidatedPeers, router.ID)
+		addGroup(nmd, "g-dist", targetID)
+		nmd.Routes = []*nmdata.Route{{ID: "r-1", Peer: router.ID, Enabled: true, Groups: []string{"g-dist"}}}
+
+		c := compute(nmd, targetID)
+
+		require.Len(t, c.Routes, 1)
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+	})
+
+	t.Run("nil and unrelated routes skipped", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		other := newPeer("peer-other", 2)
+		nmd := newNMD(target, other)
+		addGroup(nmd, "g-dist", targetID)
+		addGroup(nmd, "g-foreign", other.ID)
+		owned := &nmdata.Route{ID: "r-owned", Peer: targetID, Enabled: true}
+		nmd.Routes = []*nmdata.Route{nil, {ID: "r-foreign", Peer: other.ID, Enabled: true, Groups: []string{"g-foreign"}}, owned}
+
+		c := compute(nmd, targetID)
+
+		require.Len(t, c.Routes, 1)
+		assert.Same(t, owned, c.Routes[0])
+	})
+}
+
+// A policy whose destinations hit an enabled route's access control groups is
+// shipped so the routing peer can build route firewall rules, but its peers
+// are not connected through this bridge.
+func TestGetPeerNetworkMapComponents_RouteAccessControlBridging(t *testing.T) {
+	t.Run("policy targeting route ACG becomes relevant", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		router := newPeer("peer-router", 2)
+		remote := newPeer("peer-remote", 3)
+		nmd := newNMD(target, router, remote)
+		addGroup(nmd, "g-dist", targetID)
+		addGroup(nmd, "g-acl")
+		addGroup(nmd, "g-remote", remote.ID)
+		nmd.Routes = []*nmdata.Route{{ID: "r-1", Peer: router.ID, Enabled: true, Groups: []string{"g-dist"}, AccessControlGroups: []string{"g-acl"}}}
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-acl", newRule([]string{"g-remote"}, []string{"g-acl"}))}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []string{"p-acl"}, policyIDs(c.Policies))
+		assert.ElementsMatch(t, []string{"g-dist", "g-acl", "g-remote"}, groupIDSet(c.Groups))
+		assert.ElementsMatch(t, []string{targetID, router.ID}, peerIDSet(c.Peers),
+			"the bridged policy's source peers must not be connected")
+	})
+
+	t.Run("disabled route does not bridge its ACG policies", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		remote := newPeer("peer-remote", 2)
+		nmd := newNMD(target, remote)
+		addGroup(nmd, "g-acl")
+		addGroup(nmd, "g-remote", remote.ID)
+		nmd.Routes = []*nmdata.Route{{ID: "r-1", Peer: targetID, Enabled: false, AccessControlGroups: []string{"g-acl"}}}
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-acl", newRule([]string{"g-remote"}, []string{"g-acl"}))}
+
+		c := compute(nmd, targetID)
+
+		assert.Empty(t, c.Policies)
+	})
+}
+
+func TestGetPeerNetworkMapComponents_SSHRequirements(t *testing.T) {
+	allowedUsers := map[string]struct{}{"user-1": {}, "user-2": {}}
+	groupUsers := map[string][]string{"g-auth": {"user-a"}, "g-other": {"user-b"}}
+
+	cases := []struct {
+		name          string
+		mutateRule    func(*nmdata.PolicyRule)
+		sshEnabled    bool
+		targetInSrc   bool
+		wantAllowed   bool
+		wantGroupsMap map[string][]string
+	}{
+		{
+			name: "netbird-ssh with authorized groups",
+			mutateRule: func(r *nmdata.PolicyRule) {
+				r.Protocol = string(nbtypes.PolicyRuleProtocolNetbirdSSH)
+				r.AuthorizedGroups = map[string][]string{"g-auth": nil}
+			},
+			wantGroupsMap: map[string][]string{"g-auth": {"user-a"}},
+		},
+		{
+			name: "netbird-ssh with authorized user",
+			mutateRule: func(r *nmdata.PolicyRule) {
+				r.Protocol = string(nbtypes.PolicyRuleProtocolNetbirdSSH)
+				r.AuthorizedUser = "root"
+			},
+		},
+		{
+			name: "netbird-ssh default needs allowed users",
+			mutateRule: func(r *nmdata.PolicyRule) {
+				r.Protocol = string(nbtypes.PolicyRuleProtocolNetbirdSSH)
+			},
+			wantAllowed: true,
+		},
+		{
+			name:        "legacy all-protocol with SSH enabled",
+			mutateRule:  func(r *nmdata.PolicyRule) { r.Protocol = string(nbtypes.PolicyRuleProtocolALL) },
+			sshEnabled:  true,
+			wantAllowed: true,
+		},
+		{
+			name:       "legacy all-protocol with SSH disabled",
+			mutateRule: func(r *nmdata.PolicyRule) { r.Protocol = string(nbtypes.PolicyRuleProtocolALL) },
+		},
+		{
+			name:        "tcp port 22 with SSH enabled",
+			mutateRule:  func(r *nmdata.PolicyRule) { r.Ports = []string{"22"} },
+			sshEnabled:  true,
+			wantAllowed: true,
+		},
+		{
+			name:        "tcp port range covering 22",
+			mutateRule:  func(r *nmdata.PolicyRule) { r.PortRanges = []nmdata.RulePortRange{{Start: 20, End: 30}} },
+			sshEnabled:  true,
+			wantAllowed: true,
+		},
+		{
+			name:        "tcp native ssh port 22022",
+			mutateRule:  func(r *nmdata.PolicyRule) { r.Ports = []string{"22022"} },
+			sshEnabled:  true,
+			wantAllowed: true,
+		},
+		{
+			name:        "tcp port range covering only native ssh port",
+			mutateRule:  func(r *nmdata.PolicyRule) { r.PortRanges = []nmdata.RulePortRange{{Start: 22000, End: 23000}} },
+			sshEnabled:  true,
+			wantAllowed: true,
+		},
+		{
+			name:       "tcp unrelated port",
+			mutateRule: func(r *nmdata.PolicyRule) { r.Ports = []string{"443"} },
+			sshEnabled: true,
+		},
+		{
+			name: "netbird-ssh only counts on the destination side",
+			mutateRule: func(r *nmdata.PolicyRule) {
+				r.Protocol = string(nbtypes.PolicyRuleProtocolNetbirdSSH)
+			},
+			targetInSrc: true,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			target := newPeer(targetID, 1)
+			target.SSHEnabled = tc.sshEnabled
+			admin := newPeer("peer-admin", 2)
+			nmd := newNMD(target, admin)
+			nmd.AllowedUserIDs = allowedUsers
+			nmd.GroupIDToUserIDs = groupUsers
+			addGroup(nmd, "g-adm", admin.ID)
+			addGroup(nmd, "g-tgt", targetID)
+			rule := newRule([]string{"g-adm"}, []string{"g-tgt"})
+			if tc.targetInSrc {
+				rule = newRule([]string{"g-tgt"}, []string{"g-adm"})
+			}
+			tc.mutateRule(rule)
+			nmd.Policies = []*nmdata.Policy{newPolicy("p-1", rule)}
+
+			c := compute(nmd, targetID)
+
+			if tc.wantAllowed {
+				assert.Equal(t, allowedUsers, c.AllowedUserIDs)
+			} else {
+				assert.Nil(t, c.AllowedUserIDs)
+			}
+			assert.Equal(t, tc.wantGroupsMap, c.GroupIDToUserIDs)
+		})
+	}
+}
+
+func TestGetPeerNetworkMapComponents_DNSRecordFiltering(t *testing.T) {
+	record := func(name, rdata string) nmdata.SimpleRecord {
+		return nmdata.SimpleRecord{Name: name, Type: 1, Class: "IN", TTL: 300, RData: rdata}
+	}
+
+	build := func(ipv6Target bool) (*networkmap.NetworkMapData, nmdata.CustomZone) {
+		target := newPeer(targetID, 1)
+		if ipv6Target {
+			target.IPv6 = netip.MustParseAddr("fd00::1")
+			target.Meta.Capabilities = []int32{nmdata.PeerCapabilityIPv6Overlay}
+		}
+		buddy := newPeer("peer-buddy", 2)
+		buddy.IPv6 = netip.MustParseAddr("fd00::2")
+		stranger := newPeer("peer-stranger", 3)
+		nmd := newNMD(target, buddy, stranger)
+		addGroup(nmd, "g-src", targetID)
+		addGroup(nmd, "g-dst", buddy.ID)
+		nmd.Policies = []*nmdata.Policy{newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"}))}
+		zone := nmdata.CustomZone{
+			Domain: "acme.netbird.cloud.",
+			Records: []nmdata.SimpleRecord{
+				record(targetID, "100.64.0.1"),
+				record("peer-buddy", "100.64.0.2"),
+				record("peer-stranger", "100.64.0.3"),
+				record("outsider", "9.9.9.9"),
+				record("peer-buddy-v6", "fd00::2"),
+			},
+		}
+		return nmd, zone
+	}
+
+	t.Run("records limited to relevant peers, IPv6 dropped without capability", func(t *testing.T) {
+		nmd, zone := build(false)
+
+		c := nmd.GetPeerNetworkMapComponents(targetID, zone)
+
+		assert.Equal(t, "acme.netbird.cloud.", c.CustomZoneDomain)
+		assert.Equal(t, []nmdata.SimpleRecord{
+			record(targetID, "100.64.0.1"),
+			record("peer-buddy", "100.64.0.2"),
+		}, c.AllDNSRecords)
+	})
+
+	t.Run("IPv6 records of relevant peers kept for capable target", func(t *testing.T) {
+		nmd, zone := build(true)
+
+		c := nmd.GetPeerNetworkMapComponents(targetID, zone)
+
+		assert.Equal(t, []nmdata.SimpleRecord{
+			record(targetID, "100.64.0.1"),
+			record("peer-buddy", "100.64.0.2"),
+			record("peer-buddy-v6", "fd00::2"),
+		}, c.AllDNSRecords)
+	})
+
+	t.Run("no records yields nil", func(t *testing.T) {
+		nmd, _ := build(false)
+
+		c := nmd.GetPeerNetworkMapComponents(targetID, nmdata.CustomZone{Domain: "acme.netbird.cloud."})
+
+		assert.Nil(t, c.AllDNSRecords)
+	})
+}
+
+func TestGetPeerNetworkMapComponents_AccountZones(t *testing.T) {
+	rec := func(name string) nmdata.SimpleRecord {
+		return nmdata.SimpleRecord{Name: name, Type: 1, Class: "IN", RData: "100.64.0.9"}
+	}
+
+	t.Run("applied and private service zones for peer groups", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		nmd := newNMD(target)
+		addGroup(nmd, "g-a", targetID)
+		appliedZone := nmdata.CustomZone{Domain: "zone-one.example.com.", Records: []nmdata.SimpleRecord{rec("z1")}}
+		nmd.AppliedZoneCandidates = []networkmap.AppliedZoneCandidate{
+			{DistributionGroups: []string{"g-a"}, Zone: appliedZone},
+			{DistributionGroups: []string{"g-x"}, Zone: nmdata.CustomZone{Domain: "zone-two.example.com."}},
+		}
+		nmd.PrivateServiceCandidates = []networkmap.PrivateServiceCandidate{
+			{AccessGroups: []string{"g-a"}, Zone: nmdata.CustomZone{Domain: "svc.example.com", SearchDomainDisabled: true, NonAuthoritative: true, Records: []nmdata.SimpleRecord{rec("svc-1")}}},
+			{AccessGroups: []string{"g-a"}, Zone: nmdata.CustomZone{Domain: "svc.example.com", Records: []nmdata.SimpleRecord{rec("svc-2")}}},
+			{AccessGroups: []string{"g-x"}, Zone: nmdata.CustomZone{Domain: "other.example.com", Records: []nmdata.SimpleRecord{rec("other")}}},
+			{AccessGroups: []string{"g-a"}, Zone: nmdata.CustomZone{Domain: "empty.example.com"}},
+		}
+
+		c := compute(nmd, targetID)
+
+		require.Len(t, c.AccountZones, 2)
+		assert.Equal(t, appliedZone, c.AccountZones[0])
+		assert.Equal(t, nmdata.CustomZone{
+			Domain:               "svc.example.com",
+			SearchDomainDisabled: true,
+			NonAuthoritative:     true,
+			Records:              []nmdata.SimpleRecord{rec("svc-1"), rec("svc-2")},
+		}, c.AccountZones[1], "same-apex private service candidates must merge, flags from the first")
+	})
+
+	t.Run("groupless peer receives no zones", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		nmd := newNMD(target)
+		nmd.AppliedZoneCandidates = []networkmap.AppliedZoneCandidate{
+			{DistributionGroups: []string{"g-a"}, Zone: nmdata.CustomZone{Domain: "zone-one.example.com."}},
+		}
+		nmd.PrivateServiceCandidates = []networkmap.PrivateServiceCandidate{
+			{AccessGroups: []string{"g-a"}, Zone: nmdata.CustomZone{Domain: "svc.example.com", Records: []nmdata.SimpleRecord{rec("svc")}}},
+		}
+
+		c := compute(nmd, targetID)
+
+		assert.Empty(t, c.AccountZones)
+	})
+}
+
+func TestGetPeerNetworkMapComponents_NameServerGroups(t *testing.T) {
+	target := newPeer(targetID, 1)
+	other := newPeer("peer-other", 2)
+	nmd := newNMD(target, other)
+	addGroup(nmd, "g-own", targetID)
+	addGroup(nmd, "g-dst", other.ID)
+	addGroup(nmd, "g-foreign")
+	nmd.Policies = []*nmdata.Policy{newPolicy("p-1", newRule([]string{"g-own"}, []string{"g-dst"}))}
+	nsOwn := &nmdata.NameServerGroup{ID: "ns-own", Enabled: true, Groups: []string{"g-own"}}
+	nsDst := &nmdata.NameServerGroup{ID: "ns-dst", Enabled: true, Groups: []string{"g-dst"}}
+	nsDisabled := &nmdata.NameServerGroup{ID: "ns-disabled", Enabled: false, Groups: []string{"g-own"}}
+	nsForeign := &nmdata.NameServerGroup{ID: "ns-foreign", Enabled: true, Groups: []string{"g-foreign"}}
+	nsBoth := &nmdata.NameServerGroup{ID: "ns-both", Enabled: true, Groups: []string{"g-own", "g-dst"}}
+	nmd.NameServerGroups = []*nmdata.NameServerGroup{nsOwn, nil, nsDst, nsDisabled, nsForeign, nsBoth}
+
+	c := compute(nmd, targetID)
+
+	assert.Equal(t, []*nmdata.NameServerGroup{nsOwn, nsDst, nsBoth}, c.NameServerGroups,
+		"nameserver groups attach to any relevant group and ship once even when several groups match")
+}
+
+func TestGetPeerNetworkMapComponents_NetworkResources_SourceSide(t *testing.T) {
+	target := newPeer(targetID, 1)
+	routerOK := newPeer("peer-router-ok", 2)
+	routerUnval := newPeer("peer-router-unval", 3)
+	nmd := newNMD(target, routerOK, routerUnval)
+	delete(nmd.ValidatedPeers, routerUnval.ID)
+	addGroup(nmd, "g-clients", targetID)
+	addGroup(nmd, "g-resource")
+	res := &nmdata.NetworkResource{ID: "res-1", NetworkID: "net-1", Enabled: true}
+	nmd.NetworkResources = []*nmdata.NetworkResource{res}
+	rp := newPolicy("rp-1", newRule([]string{"g-clients"}, []string{"g-resource"}))
+	nmd.Policies = []*nmdata.Policy{rp}
+	nmd.ResourcePolicies = map[string][]*nmdata.Policy{"res-1": {rp}}
+	routers := map[string]*nmdata.NetworkRouter{
+		routerOK.ID:    {Metric: 100},
+		routerUnval.ID: {Metric: 200},
+	}
+	nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": routers}
+
+	c := compute(nmd, targetID)
+
+	assert.Equal(t, []*nmdata.NetworkResource{res}, c.NetworkResources)
+	assert.Equal(t, map[string][]*nmdata.Policy{"res-1": {rp}}, c.ResourcePoliciesMap)
+	assert.Equal(t, map[string]map[string]*nmdata.NetworkRouter{"net-1": routers}, c.RoutersMap)
+	assert.ElementsMatch(t, []string{routerOK.ID}, peerIDSet(c.RouterPeers),
+		"an unvalidated routing peer is withheld from RouterPeers too, since the envelope encoder "+
+			"indexes that map into the wire peer table and the client restores every entry from it")
+	assert.ElementsMatch(t, []string{targetID, routerOK.ID}, peerIDSet(c.Peers),
+		"only validated routing peers are connected")
+	assert.ElementsMatch(t, []string{"g-clients", "g-resource"}, groupIDSet(c.Groups))
+}
+
+func TestGetPeerNetworkMapComponents_NetworkResources_RouterSide(t *testing.T) {
+	t.Run("posture-valid validated source peers connected, failures recorded", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		clientOK := newPeer("peer-client-ok", 2)
+		clientUnval := newPeer("peer-client-unval", 3)
+		clientFail := newPeer("peer-client-fail", 4)
+		clientFail.Meta.WtVersion = failingVersion
+		nmd := newNMD(target, clientOK, clientUnval, clientFail)
+		delete(nmd.ValidatedPeers, clientUnval.ID)
+		addVersionCheck(nmd, "pc-1", postureMinVersion)
+		addGroup(nmd, "g-clients", clientOK.ID, clientUnval.ID, clientFail.ID)
+		addGroup(nmd, "g-resource")
+		addGroup(nmd, "g-tgt", targetID)
+		res := &nmdata.NetworkResource{ID: "res-1", NetworkID: "net-1", Enabled: true}
+		nmd.NetworkResources = []*nmdata.NetworkResource{res}
+		rp := newPolicy("rp-1", newRule([]string{"g-clients"}, []string{"g-resource"}))
+		rp.SourcePostureChecks = []string{"pc-1"}
+		acl := newPolicy("p-acl", newRule([]string{"g-clients"}, []string{"g-tgt"}))
+		nmd.Policies = []*nmdata.Policy{acl}
+		nmd.ResourcePolicies = map[string][]*nmdata.Policy{"res-1": {rp}}
+		nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {targetID: {Metric: 100}}}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []*nmdata.NetworkResource{res}, c.NetworkResources)
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.RouterPeers))
+		assert.ElementsMatch(t, []string{targetID, clientOK.ID, clientFail.ID}, peerIDSet(c.Peers),
+			"clientFail connects via the open ACL policy, clientUnval never connects")
+		assert.Equal(t, map[string]map[string]struct{}{"pc-1": {clientFail.ID: {}}}, c.PostureFailedPeers)
+	})
+
+	t.Run("peer source resource collects exactly that peer", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		client := newPeer("peer-client", 2)
+		other := newPeer("peer-other", 3)
+		nmd := newNMD(target, client, other)
+		res := &nmdata.NetworkResource{ID: "res-1", NetworkID: "net-1", Enabled: true}
+		nmd.NetworkResources = []*nmdata.NetworkResource{res}
+		rule := newRule(nil, nil)
+		rule.SourceResource = nmdata.Resource{ID: client.ID, Type: string(nbtypes.ResourceTypePeer)}
+		nmd.ResourcePolicies = map[string][]*nmdata.Policy{"res-1": {newPolicy("rp-1", rule)}}
+		nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {targetID: {}}}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []*nmdata.NetworkResource{res}, c.NetworkResources)
+		assert.ElementsMatch(t, []string{targetID, client.ID}, peerIDSet(c.Peers))
+	})
+
+	t.Run("multiple source groups unioned, missing group tolerated", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		c1 := newPeer("peer-c1", 2)
+		c2 := newPeer("peer-c2", 3)
+		nmd := newNMD(target, c1, c2)
+		addGroup(nmd, "g-c1", c1.ID)
+		addGroup(nmd, "g-c2", c2.ID)
+		res := &nmdata.NetworkResource{ID: "res-1", NetworkID: "net-1", Enabled: true}
+		nmd.NetworkResources = []*nmdata.NetworkResource{res}
+		nmd.ResourcePolicies = map[string][]*nmdata.Policy{
+			"res-1": {newPolicy("rp-1", newRule([]string{"g-c1", "g-c2", "g-ghost"}, nil))},
+		}
+		nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {targetID: {}}}
+
+		c := compute(nmd, targetID)
+
+		assert.ElementsMatch(t, []string{targetID, c1.ID, c2.ID}, peerIDSet(c.Peers))
+	})
+}
+
+func TestGetPeerNetworkMapComponents_NetworkResources_PeerResourceSource(t *testing.T) {
+	build := func(sourcePeerID string) *networkmap.NetworkMapData {
+		target := newPeer(targetID, 1)
+		router := newPeer("peer-router", 2)
+		other := newPeer("peer-other", 3)
+		nmd := newNMD(target, router, other)
+		res := &nmdata.NetworkResource{ID: "res-1", NetworkID: "net-1", Enabled: true}
+		nmd.NetworkResources = []*nmdata.NetworkResource{res}
+		rule := newRule(nil, nil)
+		rule.SourceResource = nmdata.Resource{ID: sourcePeerID, Type: string(nbtypes.ResourceTypePeer)}
+		nmd.ResourcePolicies = map[string][]*nmdata.Policy{"res-1": {newPolicy("rp-1", rule)}}
+		nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {"peer-router": {}}}
+		return nmd
+	}
+
+	t.Run("target named as source resource gains access", func(t *testing.T) {
+		c := compute(build(targetID), targetID)
+
+		assert.Len(t, c.NetworkResources, 1)
+		assert.ElementsMatch(t, []string{targetID, "peer-router"}, peerIDSet(c.Peers))
+	})
+
+	t.Run("other peer named as source resource denies target", func(t *testing.T) {
+		c := compute(build("peer-other"), targetID)
+
+		assert.Empty(t, c.NetworkResources)
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+	})
+}
+
+func TestGetPeerNetworkMapComponents_NetworkResources_Gating(t *testing.T) {
+	build := func() (*networkmap.NetworkMapData, *nmdata.NetworkResource, *nmdata.Policy) {
+		target := newPeer(targetID, 1)
+		router := newPeer("peer-router", 2)
+		nmd := newNMD(target, router)
+		addGroup(nmd, "g-clients", targetID)
+		addGroup(nmd, "g-resource")
+		res := &nmdata.NetworkResource{ID: "res-1", NetworkID: "net-1", Enabled: true}
+		nmd.NetworkResources = []*nmdata.NetworkResource{res}
+		rp := newPolicy("rp-1", newRule([]string{"g-clients"}, []string{"g-resource"}))
+		nmd.Policies = []*nmdata.Policy{rp}
+		nmd.ResourcePolicies = map[string][]*nmdata.Policy{"res-1": {rp}}
+		nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {router.ID: {}}}
+		return nmd, res, rp
+	}
+
+	assertResourceSkipped := func(t *testing.T, c *nbtypes.NetworkMapComponents) {
+		t.Helper()
+		assert.Empty(t, c.NetworkResources)
+		assert.Empty(t, c.RoutersMap)
+		assert.Empty(t, c.RouterPeers)
+		assert.Empty(t, c.ResourcePoliciesMap)
+	}
+
+	t.Run("baseline grants access", func(t *testing.T) {
+		nmd, res, _ := build()
+		c := compute(nmd, targetID)
+		assert.Equal(t, []*nmdata.NetworkResource{res}, c.NetworkResources)
+	})
+
+	t.Run("disabled resource skipped", func(t *testing.T) {
+		nmd, res, _ := build()
+		res.Enabled = false
+		assertResourceSkipped(t, compute(nmd, targetID))
+	})
+
+	t.Run("resource without policies skipped", func(t *testing.T) {
+		nmd, _, _ := build()
+		nmd.ResourcePolicies = nil
+		assertResourceSkipped(t, compute(nmd, targetID))
+	})
+
+	t.Run("peer neither router nor in sources skipped", func(t *testing.T) {
+		nmd, _, _ := build()
+		nmd.Groups["g-clients"].Peers = []string{"peer-router"}
+		c := compute(nmd, targetID)
+		assertResourceSkipped(t, c)
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+	})
+
+	t.Run("nil and rule-less resource policy entries tolerated", func(t *testing.T) {
+		nmd, res, rp := build()
+		nmd.NetworkResources = append([]*nmdata.NetworkResource{nil}, nmd.NetworkResources...)
+		rp.Rules = append(rp.Rules, nil)
+		nmd.ResourcePolicies["res-1"] = append([]*nmdata.Policy{nil, {ID: "rp-empty", Enabled: true}}, nmd.ResourcePolicies["res-1"]...)
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []*nmdata.NetworkResource{res}, c.NetworkResources,
+			"poisoned sibling entries must not prevent the valid policy from granting access")
+		assert.NotPanics(t, func() { c.Calculate(context.Background()) },
+			"the downstream network map calculation must survive the poisoned components")
+	})
+
+	t.Run("granting policy without routers still ships the resource", func(t *testing.T) {
+		nmd, res, rp := build()
+		nmd.Routers = nil
+		c := compute(nmd, targetID)
+		assert.Equal(t, []*nmdata.NetworkResource{res}, c.NetworkResources)
+		assert.Equal(t, map[string][]*nmdata.Policy{"res-1": {rp}}, c.ResourcePoliciesMap)
+		assert.Contains(t, c.RoutersMap, "net-1")
+		assert.Empty(t, c.RoutersMap["net-1"])
+		assert.Empty(t, c.RouterPeers)
+	})
+
+	t.Run("target failing resource policy posture check skipped", func(t *testing.T) {
+		nmd, _, rp := build()
+		addVersionCheck(nmd, "pc-1", postureMinVersion)
+		rp.SourcePostureChecks = []string{"pc-1"}
+		nmd.Policies = nil
+		nmd.Peers[targetID].Meta.WtVersion = failingVersion
+		c := compute(nmd, targetID)
+		assertResourceSkipped(t, c)
+		assert.Empty(t, c.PostureFailedPeers)
+		assert.ElementsMatch(t, []string{targetID}, peerIDSet(c.Peers))
+	})
+}
+
+// Legacy parity: resource-policy access consults only Rules[0] for peer-type
+// sources, while group sources union across all rules via SourceGroups.
+func TestGetPeerNetworkMapComponents_MultiRulePolicies(t *testing.T) {
+	t.Run("policy matching via multiple rules ships once", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		a := newPeer("peer-a", 2)
+		b := newPeer("peer-b", 3)
+		nmd := newNMD(target, a, b)
+		addGroup(nmd, "g-tgt", targetID)
+		addGroup(nmd, "g-a", a.ID)
+		addGroup(nmd, "g-b", b.ID)
+		p := newPolicy("p-1",
+			newRule([]string{"g-tgt"}, []string{"g-a"}),
+			newRule([]string{"g-tgt"}, []string{"g-b"}))
+		nmd.Policies = []*nmdata.Policy{p}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, []string{"p-1"}, policyIDs(c.Policies))
+		assert.ElementsMatch(t, []string{targetID, a.ID, b.ID}, peerIDSet(c.Peers))
+	})
+
+	t.Run("resource access consults only the first rule's peer source", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		other := newPeer("peer-other", 2)
+		router := newPeer("peer-router", 3)
+		nmd := newNMD(target, other, router)
+		addGroup(nmd, "g-other", other.ID)
+		res := &nmdata.NetworkResource{ID: "res-1", NetworkID: "net-1", Enabled: true}
+		nmd.NetworkResources = []*nmdata.NetworkResource{res}
+		second := newRule(nil, nil)
+		second.SourceResource = nmdata.Resource{ID: targetID, Type: string(nbtypes.ResourceTypePeer)}
+		rp := newPolicy("rp-1", newRule([]string{"g-other"}, nil), second)
+		nmd.ResourcePolicies = map[string][]*nmdata.Policy{"res-1": {rp}}
+		nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {router.ID: {}}}
+
+		c := compute(nmd, targetID)
+
+		assert.Empty(t, c.NetworkResources,
+			"a second rule naming the target as peer source must not grant resource access")
+	})
+
+	t.Run("router-side source collection consults only the first rule's peer source", func(t *testing.T) {
+		target := newPeer(targetID, 1)
+		x := newPeer("peer-x", 2)
+		y := newPeer("peer-y", 3)
+		nmd := newNMD(target, x, y)
+		addGroup(nmd, "g-y", y.ID)
+		res := &nmdata.NetworkResource{ID: "res-1", NetworkID: "net-1", Enabled: true}
+		nmd.NetworkResources = []*nmdata.NetworkResource{res}
+		first := newRule(nil, nil)
+		first.SourceResource = nmdata.Resource{ID: x.ID, Type: string(nbtypes.ResourceTypePeer)}
+		rp := newPolicy("rp-1", first, newRule([]string{"g-y"}, nil))
+		nmd.ResourcePolicies = map[string][]*nmdata.Policy{"res-1": {rp}}
+		nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {targetID: {}}}
+
+		c := compute(nmd, targetID)
+
+		assert.ElementsMatch(t, []string{targetID, x.ID}, peerIDSet(c.Peers),
+			"second-rule group sources are not collected when the first rule names a peer")
+	})
+}
+
+// Characterization of legacy parity: once one resource policy grants the peer
+// access, the source peers of the resource's subsequent policies are collected
+// as if the peer were a router.
+func TestGetPeerNetworkMapComponents_NetworkResources_LaterPoliciesContributeSourcePeers(t *testing.T) {
+	target := newPeer(targetID, 1)
+	otherSrc := newPeer("peer-other-src", 2)
+	router := newPeer("peer-router", 3)
+	nmd := newNMD(target, otherSrc, router)
+	addGroup(nmd, "g-a", targetID)
+	addGroup(nmd, "g-b", otherSrc.ID)
+	addGroup(nmd, "g-resource")
+	res := &nmdata.NetworkResource{ID: "res-1", NetworkID: "net-1", Enabled: true}
+	nmd.NetworkResources = []*nmdata.NetworkResource{res}
+	rpA := newPolicy("rp-a", newRule([]string{"g-a"}, []string{"g-resource"}))
+	rpB := newPolicy("rp-b", newRule([]string{"g-b"}, []string{"g-resource"}))
+	nmd.ResourcePolicies = map[string][]*nmdata.Policy{"res-1": {rpA, rpB}}
+	nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {router.ID: {}}}
+
+	c := compute(nmd, targetID)
+
+	assert.Contains(t, c.Peers, otherSrc.ID)
+}
+
+func TestGetPeerNetworkMapComponents_StoreImmutableAndDeterministic(t *testing.T) {
+	zone := nmdata.CustomZone{
+		Domain:  "acme.netbird.cloud.",
+		Records: []nmdata.SimpleRecord{{Name: "peer-src", Type: 1, Class: "IN", TTL: 300, RData: "100.64.0.2"}},
+	}
+	build := func() *networkmap.NetworkMapData {
+		target := newPeer(targetID, 1)
+		src := newPeer("peer-src", 2)
+		failing := newPeer("peer-failing", 3)
+		failing.Meta.WtVersion = failingVersion
+		router := newPeer("peer-router", 4)
+		resRouter := newPeer("peer-res-router", 5)
+		nmd := newNMD(target, src, failing, router, resRouter)
+		addVersionCheck(nmd, "pc-1", postureMinVersion)
+		addGroup(nmd, "g-src", src.ID, failing.ID)
+		addGroup(nmd, "g-dst", targetID, src.ID)
+		addGroup(nmd, "g-dist", targetID, src.ID)
+		addGroup(nmd, "g-auth", src.ID)
+		addGroup(nmd, "g-clients", targetID)
+		addGroup(nmd, "g-resource")
+		nmd.AllowedUserIDs = map[string]struct{}{"user-1": {}}
+		nmd.GroupIDToUserIDs = map[string][]string{"g-auth": {"user-a"}}
+		checked := newPolicy("p-checked", newRule([]string{"g-src"}, []string{"g-dst"}))
+		checked.SourcePostureChecks = []string{"pc-1"}
+		open := newPolicy("p-open", newRule([]string{"g-src"}, []string{"g-dst"}))
+		sshAuth := newRule([]string{"g-src"}, []string{"g-dst"})
+		sshAuth.Protocol = string(nbtypes.PolicyRuleProtocolNetbirdSSH)
+		sshAuth.AuthorizedGroups = map[string][]string{"g-auth": nil}
+		sshPlain := newRule([]string{"g-src"}, []string{"g-dst"})
+		sshPlain.Protocol = string(nbtypes.PolicyRuleProtocolNetbirdSSH)
+		rp := newPolicy("rp-1", newRule([]string{"g-clients"}, []string{"g-resource"}))
+		nmd.Policies = []*nmdata.Policy{checked, open, newPolicy("p-ssh", sshAuth, sshPlain), rp}
+		nmd.Routes = []*nmdata.Route{{ID: "r-1", Peer: router.ID, Enabled: true, Groups: []string{"g-dist"}}}
+		nmd.NetworkResources = []*nmdata.NetworkResource{{ID: "res-1", NetworkID: "net-1", Enabled: true}}
+		nmd.ResourcePolicies = map[string][]*nmdata.Policy{"res-1": {rp}}
+		nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {resRouter.ID: {Metric: 100}}}
+		nmd.NameServerGroups = []*nmdata.NameServerGroup{{ID: "ns-1", Enabled: true, Groups: []string{"g-dst"}}}
+		nmd.AppliedZoneCandidates = []networkmap.AppliedZoneCandidate{
+			{DistributionGroups: []string{"g-dst"}, Zone: nmdata.CustomZone{Domain: "zone.example.com.", Records: []nmdata.SimpleRecord{{Name: "z", Type: 1, RData: "100.64.0.9"}}}},
+		}
+		nmd.PrivateServiceCandidates = []networkmap.PrivateServiceCandidate{
+			{AccessGroups: []string{"g-dst"}, Zone: nmdata.CustomZone{Domain: "svc.example.com", Records: []nmdata.SimpleRecord{{Name: "s", Type: 1, RData: "100.64.0.8"}}}},
+		}
+		return nmd
+	}
+
+	nmd := build()
+	groupSnapshots := make(map[string][]string, len(nmd.Groups))
+	for id, g := range nmd.Groups {
+		groupSnapshots[id] = append([]string(nil), g.Peers...)
+	}
+
+	first := nmd.GetPeerNetworkMapComponents(targetID, zone)
+	_ = nmd.GetPeerNetworkMapComponents("peer-src", zone)
+	second := nmd.GetPeerNetworkMapComponents(targetID, zone)
+
+	for id, g := range nmd.Groups {
+		assert.Equal(t, groupSnapshots[id], g.Peers, "group %s mutated in the store", id)
+	}
+
+	for name, field := range map[string]any{
+		"Peers":               first.Peers,
+		"PostureFailedPeers":  first.PostureFailedPeers,
+		"RoutersMap":          first.RoutersMap,
+		"RouterPeers":         first.RouterPeers,
+		"NetworkResources":    first.NetworkResources,
+		"NameServerGroups":    first.NameServerGroups,
+		"AccountZones":        first.AccountZones,
+		"AllDNSRecords":       first.AllDNSRecords,
+		"AllowedUserIDs":      first.AllowedUserIDs,
+		"GroupIDToUserIDs":    first.GroupIDToUserIDs,
+		"ResourcePoliciesMap": first.ResourcePoliciesMap,
+	} {
+		require.NotEmpty(t, field, "fixture must populate %s or the determinism check is vacuous", name)
+	}
+
+	assert.Equal(t, first.Peers, second.Peers)
+	assert.Equal(t, first.Groups, second.Groups)
+	assert.Equal(t, first.Policies, second.Policies)
+	assert.Equal(t, first.Routes, second.Routes)
+	assert.Equal(t, first.PostureFailedPeers, second.PostureFailedPeers)
+	assert.Equal(t, first.ResourcePoliciesMap, second.ResourcePoliciesMap)
+	assert.Equal(t, first.RoutersMap, second.RoutersMap)
+	assert.Equal(t, first.RouterPeers, second.RouterPeers)
+	assert.Equal(t, first.NetworkResources, second.NetworkResources)
+	assert.Equal(t, first.NameServerGroups, second.NameServerGroups)
+	assert.Equal(t, first.AccountZones, second.AccountZones)
+	assert.Equal(t, first.AllDNSRecords, second.AllDNSRecords)
+	assert.Equal(t, first.AllowedUserIDs, second.AllowedUserIDs)
+	assert.Equal(t, first.GroupIDToUserIDs, second.GroupIDToUserIDs)
+}
+
+func TestPrecomputePostureValidation(t *testing.T) {
+	newFixture := func() *networkmap.NetworkMapData {
+		target := newPeer(targetID, 1)
+		srcPass := newPeer("peer-src-pass", 2)
+		srcFail := newPeer("peer-src-fail", 3)
+		srcFail.Meta.WtVersion = failingVersion
+		other := newPeer("peer-other", 4)
+		other.Meta.WtVersion = failingVersion
+
+		nmd := newNMD(target, srcPass, srcFail, other)
+		addVersionCheck(nmd, "pc-1", postureMinVersion)
+		addGroup(nmd, "g-src", srcPass.ID, srcFail.ID)
+		addGroup(nmd, "g-dst", targetID)
+		addGroup(nmd, "g-open", srcPass.ID, srcFail.ID, other.ID)
+
+		checked := newPolicy("p-checked", newRule([]string{"g-src"}, []string{"g-dst"}))
+		checked.SourcePostureChecks = []string{"pc-1"}
+		open := newPolicy("p-open", newRule([]string{"g-open"}, []string{"g-dst"}))
+		disabled := newPolicy("p-disabled", newRule([]string{"g-open"}, []string{"g-dst"}))
+		disabled.Enabled = false
+		disabled.SourcePostureChecks = []string{"pc-1"}
+		nmd.Policies = []*nmdata.Policy{checked, open, disabled}
+
+		return nmd
+	}
+
+	type snapshot struct {
+		peers              []string
+		postureFailedPeers map[string]map[string]struct{}
+	}
+	snapshotAll := func(nmd *networkmap.NetworkMapData) map[string]snapshot {
+		out := make(map[string]snapshot, len(nmd.Peers))
+		for peerID := range nmd.Peers {
+			c := compute(nmd, peerID)
+			out[peerID] = snapshot{peers: peerIDSet(c.Peers), postureFailedPeers: c.PostureFailedPeers}
+		}
+		return out
+	}
+
+	t.Run("memoized results match direct evaluation", func(t *testing.T) {
+		nmd := newFixture()
+		direct := snapshotAll(nmd)
+
+		nmd.PrecomputePostureValidation()
+		memoized := snapshotAll(nmd)
+
+		require.Len(t, memoized, len(direct))
+		for peerID, want := range direct {
+			assert.ElementsMatch(t, want.peers, memoized[peerID].peers, "visible peers changed for %s", peerID)
+			assert.Equal(t, want.postureFailedPeers, memoized[peerID].postureFailedPeers, "posture failures changed for %s", peerID)
+		}
+	})
+
+	t.Run("only source peers of enabled checked policies are evaluated", func(t *testing.T) {
+		nmd := newFixture()
+		nmd.PrecomputePostureValidation()
+
+		assert.Equal(t, map[string]map[string]bool{
+			"pc-1": {"peer-src-pass": true, "peer-src-fail": false},
+		}, nmd.PostureValidation)
+	})
+
+	t.Run("peer source resources are evaluated", func(t *testing.T) {
+		nmd := newFixture()
+		resourcePolicy := newPolicy("p-resource", newRule(nil, []string{"g-dst"}))
+		resourcePolicy.Rules[0].SourceResource = nmdata.Resource{ID: "peer-other", Type: string(nbtypes.ResourceTypePeer)}
+		resourcePolicy.SourcePostureChecks = []string{"pc-1"}
+		nmd.Policies = append(nmd.Policies, resourcePolicy)
+
+		nmd.PrecomputePostureValidation()
+
+		assert.Equal(t, map[string]bool{"peer-src-pass": true, "peer-src-fail": false, "peer-other": false},
+			nmd.PostureValidation["pc-1"])
+	})
+
+	t.Run("memoized result wins over direct evaluation", func(t *testing.T) {
+		nmd := newFixture()
+		nmd.PostureValidation = map[string]map[string]bool{
+			"pc-1": {"peer-src-pass": false, "peer-src-fail": true},
+		}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, map[string]map[string]struct{}{"pc-1": {"peer-src-pass": {}}}, c.PostureFailedPeers)
+	})
+
+	t.Run("no posture checks clears the memo", func(t *testing.T) {
+		nmd := newFixture()
+		nmd.PrecomputePostureValidation()
+		require.NotEmpty(t, nmd.PostureValidation)
+
+		nmd.PostureChecks = nil
+		nmd.PrecomputePostureValidation()
+
+		assert.Nil(t, nmd.PostureValidation)
+	})
+
+	t.Run("unresolvable check id memoized as passing", func(t *testing.T) {
+		nmd := newFixture()
+		nmd.Policies[0].SourcePostureChecks = []string{"pc-ghost"}
+		nmd.PrecomputePostureValidation()
+
+		require.Contains(t, nmd.PostureValidation, "pc-ghost")
+		assert.Nil(t, nmd.PostureValidation["pc-ghost"])
+
+		c := compute(nmd, targetID)
+		assert.ElementsMatch(t, []string{targetID, "peer-src-pass", "peer-src-fail", "peer-other"}, peerIDSet(c.Peers))
+		assert.Empty(t, c.PostureFailedPeers)
+	})
+
+	t.Run("peers missing from the memo fall back to direct evaluation", func(t *testing.T) {
+		nmd := newFixture()
+		nmd.PostureValidation = map[string]map[string]bool{"pc-1": {"peer-src-pass": true}}
+
+		c := compute(nmd, targetID)
+
+		assert.Equal(t, map[string]map[string]struct{}{"pc-1": {"peer-src-fail": {}}}, c.PostureFailedPeers)
+	})
+}
+
+func TestNetworkMapData_GetPeerGroups(t *testing.T) {
+	target := newPeer(targetID, 1)
+	other := newPeer("peer-other", 2)
+	nmd := newNMD(target, other)
+	addGroup(nmd, "g-1", targetID, other.ID)
+	addGroup(nmd, "g-2", targetID)
+	addGroup(nmd, "g-3", other.ID)
+	nmd.Groups["g-nil"] = nil
+
+	assert.Equal(t, map[string]struct{}{"g-1": {}, "g-2": {}}, nmd.GetPeerGroups(targetID))
+	assert.Empty(t, nmd.GetPeerGroups("missing"))
+}
diff --git a/shared/management/networkmap/networkmapdata.go b/shared/management/networkmap/networkmapdata.go
new file mode 100644
index 000000000..e27605d64
--- /dev/null
+++ b/shared/management/networkmap/networkmapdata.go
@@ -0,0 +1,79 @@
+package networkmap
+
+import (
+	"sync"
+
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+// NetworkMapData is a dependency-light, slim twin of the server Account. It
+// carries only the state GetPeerNetworkMapComponents needs, expressed in the
+// fresh nmdata twin types. A builder converts an Account into a NetworkMapData
+// once per account; the per-peer components calculation then runs on this twin
+// with no reference back to the Account.
+type NetworkMapData struct { //nolint:revive // established name across the codebase
+	Peers            map[string]*nmdata.Peer
+	Groups           map[string]*nmdata.Group
+	Policies         []*nmdata.Policy
+	Routes           []*nmdata.Route
+	NameServerGroups []*nmdata.NameServerGroup
+	NetworkResources []*nmdata.NetworkResource
+
+	Network         *nmdata.Network
+	DNSSettings     *nmdata.DNSSettings
+	AccountSettings *nmdata.AccountSettingsInfo
+
+	PostureChecks map[string]*nmdata.PostureChecks
+
+	// PostureValidation holds the precomputed posture-check results, keyed by
+	// posture check ID then peer ID. Filled by PrecomputePostureValidation; a
+	// present but nil inner map marks a check ID that resolves to no posture
+	// check, which the calc treats as passing.
+	PostureValidation map[string]map[string]bool
+
+	AllowedUserIDs            map[string]struct{}
+	NetworkXIDToPublicID      map[string]string
+	PostureCheckXIDToPublicID map[string]string
+	ValidatedPeers            map[string]struct{}
+	ResourcePolicies          map[string][]*nmdata.Policy
+	Routers                   map[string]map[string]*nmdata.NetworkRouter
+	GroupIDToUserIDs          map[string][]string
+	DNSDomain                 string
+
+	// ProxyTargetedDomainResourceIDs is the account-level half of
+	// forcesRoutingPeerDNSResolution: domain network resources targeted by an
+	// enabled reverse-proxy service.
+	ProxyTargetedDomainResourceIDs map[string]struct{}
+
+	AppliedZoneCandidates    []AppliedZoneCandidate
+	PrivateServiceCandidates []PrivateServiceCandidate
+
+	// Services are the account's reverse-proxy services, persisted ones and
+	// the in-memory ones synthesised from agent-network state. They are the
+	// source of the proxy ACLs injectProxyPolicies synthesises, which no
+	// builder can load because they are never written to the database.
+	Services []*nmdata.Service
+
+	peerGroupsOnce sync.Once
+	peerGroupsIdx  map[string]map[string]struct{}
+
+	proxyPoliciesOnce sync.Once
+}
+
+// AppliedZoneCandidate is an account-level custom DNS zone reduced to the
+// per-peer decision the components calc still makes: include the zone only when
+// the peer belongs to one of its distribution groups. Record conversion is done
+// once at build time.
+type AppliedZoneCandidate struct {
+	DistributionGroups []string
+	Zone               nmdata.CustomZone
+}
+
+// PrivateServiceCandidate is a single private service's synthesized records,
+// carried per apex zone. The builder resolves proxy-cluster connectivity and
+// domain-suffix matching once; the calc merges the candidates whose AccessGroups
+// the peer belongs to, grouped by Zone.Domain.
+type PrivateServiceCandidate struct {
+	AccessGroups []string
+	Zone         nmdata.CustomZone
+}
diff --git a/shared/management/networkmap/nmdata/account_settings.go b/shared/management/networkmap/nmdata/account_settings.go
new file mode 100644
index 000000000..57e29e838
--- /dev/null
+++ b/shared/management/networkmap/nmdata/account_settings.go
@@ -0,0 +1,18 @@
+package nmdata
+
+import "time"
+
+// AccountSettingsInfo is the slim twin of types.AccountSettingsInfo.
+type AccountSettingsInfo struct {
+	PeerLoginExpirationEnabled      bool
+	PeerLoginExpiration             time.Duration
+	PeerInactivityExpirationEnabled bool
+	PeerInactivityExpiration        time.Duration
+	DNSDomain                       string
+	IPv6EnabledGroups               []string
+	RoutingPeerDNSResolutionEnabled bool
+	LazyConnectionEnabled           bool
+	AutoUpdateVersion               string
+	AutoUpdateAlways                bool
+	MetricsPushEnabled              bool
+}
diff --git a/shared/management/networkmap/nmdata/dns.go b/shared/management/networkmap/nmdata/dns.go
new file mode 100644
index 000000000..fe681af1a
--- /dev/null
+++ b/shared/management/networkmap/nmdata/dns.go
@@ -0,0 +1,18 @@
+package nmdata
+
+// SimpleRecord is the slim twin of dns.SimpleRecord.
+type SimpleRecord struct {
+	Name  string
+	Type  int
+	Class string
+	TTL   int
+	RData string
+}
+
+// CustomZone is the slim twin of dns.CustomZone.
+type CustomZone struct {
+	Domain               string
+	Records              []SimpleRecord
+	SearchDomainDisabled bool
+	NonAuthoritative     bool
+}
diff --git a/shared/management/networkmap/nmdata/dns_settings.go b/shared/management/networkmap/nmdata/dns_settings.go
new file mode 100644
index 000000000..69fd5f517
--- /dev/null
+++ b/shared/management/networkmap/nmdata/dns_settings.go
@@ -0,0 +1,6 @@
+package nmdata
+
+// DNSSettings is the slim twin of types.DNSSettings.
+type DNSSettings struct {
+	DisabledManagementGroups []string
+}
diff --git a/shared/management/networkmap/nmdata/group.go b/shared/management/networkmap/nmdata/group.go
new file mode 100644
index 000000000..1cd2cd15e
--- /dev/null
+++ b/shared/management/networkmap/nmdata/group.go
@@ -0,0 +1,30 @@
+package nmdata
+
+import "slices"
+
+// GroupAllName is the reserved name of the default group that contains every
+// peer in an account.
+const GroupAllName = "All"
+
+// Group is the slim twin of types.Group.
+type Group struct {
+	ID        string
+	Name      string
+	PublicID  string
+	Peers     []string
+	Resources []Resource
+}
+
+func (g *Group) IsGroupAll() bool {
+	return g.Name == GroupAllName
+}
+
+func (g *Group) Copy() *Group {
+	return &Group{
+		ID:        g.ID,
+		Name:      g.Name,
+		PublicID:  g.PublicID,
+		Peers:     slices.Clone(g.Peers),
+		Resources: slices.Clone(g.Resources),
+	}
+}
diff --git a/shared/management/networkmap/nmdata/group_test.go b/shared/management/networkmap/nmdata/group_test.go
new file mode 100644
index 000000000..20aaa240f
--- /dev/null
+++ b/shared/management/networkmap/nmdata/group_test.go
@@ -0,0 +1,84 @@
+package nmdata
+
+import (
+	"reflect"
+	"testing"
+)
+
+// TestGroupCopy_AllFieldsCopied fills every Group field with a unique non-zero
+// value derived from its field path, so a field added to Group but forgotten
+// in Copy fails here by name without the test needing an update. The unique
+// per-path values also catch fields swapped inside Copy.
+func TestGroupCopy_AllFieldsCopied(t *testing.T) {
+	src := &Group{}
+	seed := 0
+	fillValue(t, reflect.ValueOf(src).Elem(), "Group", &seed)
+
+	copied := src.Copy()
+
+	srcV := reflect.ValueOf(src).Elem()
+	copiedV := reflect.ValueOf(copied).Elem()
+	for i := 0; i < srcV.NumField(); i++ {
+		name := srcV.Type().Field(i).Name
+		if !reflect.DeepEqual(srcV.Field(i).Interface(), copiedV.Field(i).Interface()) {
+			t.Errorf("field %s not copied: src=%#v copy=%#v",
+				name, srcV.Field(i).Interface(), copiedV.Field(i).Interface())
+		}
+	}
+
+	for i := 0; i < srcV.NumField(); i++ {
+		f := srcV.Field(i)
+		if f.Kind() != reflect.Slice || f.Len() == 0 {
+			continue
+		}
+		name := srcV.Type().Field(i).Name
+		fillValue(t, f.Index(0), name+"-mutated", &seed)
+		if reflect.DeepEqual(f.Interface(), copiedV.Field(i).Interface()) {
+			t.Errorf("field %s shares memory with the copy", name)
+		}
+	}
+}
+
+// fillValue sets v to a deterministic non-zero value derived from its field
+// path. Kinds it does not handle fail the test loudly, so the filler is
+// extended together with the struct instead of silently under-testing new
+// fields.
+func fillValue(t *testing.T, v reflect.Value, path string, seed *int) {
+	t.Helper()
+
+	switch v.Kind() {
+	case reflect.String:
+		v.SetString(path)
+	case reflect.Bool:
+		v.SetBool(true)
+	case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+		*seed++
+		v.SetInt(int64(*seed))
+	case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
+		*seed++
+		v.SetUint(uint64(*seed))
+	case reflect.Float32, reflect.Float64:
+		*seed++
+		v.SetFloat(float64(*seed))
+	case reflect.Slice:
+		s := reflect.MakeSlice(v.Type(), 2, 2)
+		fillValue(t, s.Index(0), path+"[0]", seed)
+		fillValue(t, s.Index(1), path+"[1]", seed)
+		v.Set(s)
+	case reflect.Struct:
+		settable := 0
+		for i := 0; i < v.NumField(); i++ {
+			f := v.Field(i)
+			if !f.CanSet() {
+				continue
+			}
+			settable++
+			fillValue(t, f, path+"."+v.Type().Field(i).Name, seed)
+		}
+		if settable == 0 {
+			t.Fatalf("struct %s at %s has no settable fields — extend fillValue to construct it", v.Type(), path)
+		}
+	default:
+		t.Fatalf("unsupported kind %s at %s — extend fillValue", v.Kind(), path)
+	}
+}
diff --git a/shared/management/networkmap/nmdata/nameserver.go b/shared/management/networkmap/nmdata/nameserver.go
new file mode 100644
index 000000000..2698dd8d0
--- /dev/null
+++ b/shared/management/networkmap/nmdata/nameserver.go
@@ -0,0 +1,24 @@
+package nmdata
+
+import "net/netip"
+
+// NameServerGroup is the slim twin of dns.NameServerGroup.
+type NameServerGroup struct {
+	ID                   string
+	PublicID             string
+	Name                 string
+	Description          string
+	NameServers          []NameServer
+	Groups               []string
+	Primary              bool
+	Domains              []string
+	Enabled              bool
+	SearchDomainsEnabled bool
+}
+
+// NameServer is the slim twin of dns.NameServer.
+type NameServer struct {
+	IP     netip.Addr
+	NSType int
+	Port   int
+}
diff --git a/shared/management/networkmap/nmdata/network.go b/shared/management/networkmap/nmdata/network.go
new file mode 100644
index 000000000..72b6502ef
--- /dev/null
+++ b/shared/management/networkmap/nmdata/network.go
@@ -0,0 +1,16 @@
+package nmdata
+
+import "net"
+
+// Network is the slim twin of types.Network.
+type Network struct {
+	Identifier string
+	Net        net.IPNet
+	NetV6      net.IPNet
+	Dns        string
+	Serial     int64
+}
+
+func (n *Network) CurrentSerial() uint64 {
+	return uint64(n.Serial)
+}
diff --git a/shared/management/networkmap/nmdata/network_resource.go b/shared/management/networkmap/nmdata/network_resource.go
new file mode 100644
index 000000000..44f3c477b
--- /dev/null
+++ b/shared/management/networkmap/nmdata/network_resource.go
@@ -0,0 +1,18 @@
+package nmdata
+
+import "net/netip"
+
+// NetworkResource is the slim twin of resources/types.NetworkResource.
+type NetworkResource struct {
+	ID          string
+	NetworkID   string
+	AccountID   string
+	PublicID    string
+	Name        string
+	Description string
+	Type        string
+	Address     string // TODO: isn't persisted in the DB
+	Domain      string
+	Prefix      netip.Prefix
+	Enabled     bool
+}
diff --git a/shared/management/networkmap/nmdata/network_router.go b/shared/management/networkmap/nmdata/network_router.go
new file mode 100644
index 000000000..fd5df37c4
--- /dev/null
+++ b/shared/management/networkmap/nmdata/network_router.go
@@ -0,0 +1,10 @@
+package nmdata
+
+// NetworkRouter is the slim twin of routers/types.NetworkRouter.
+type NetworkRouter struct {
+	PublicID   string
+	PeerGroups []string
+	Masquerade bool
+	Metric     int
+	Enabled    bool
+}
diff --git a/shared/management/networkmap/nmdata/peer.go b/shared/management/networkmap/nmdata/peer.go
new file mode 100644
index 000000000..3ceb1dbc1
--- /dev/null
+++ b/shared/management/networkmap/nmdata/peer.go
@@ -0,0 +1,129 @@
+package nmdata
+
+import (
+	"net"
+	"net/netip"
+	"slices"
+	"time"
+)
+
+// Peer capability constants mirror the proto enum values.
+const (
+	PeerCapabilitySourcePrefixes      int32 = 1
+	PeerCapabilityIPv6Overlay         int32 = 2
+	PeerCapabilityComponentNetworkMap int32 = 3
+)
+
+// Peer is the slim twin of peer.Peer.
+type Peer struct {
+	ID                     string
+	Key                    string
+	SSHKey                 string
+	DNSLabel               string
+	UserID                 string
+	SSHEnabled             bool
+	LoginExpirationEnabled bool
+	LastLogin              *time.Time
+	IP                     netip.Addr
+	IPv6                   netip.Addr
+	RequiresApproval       bool
+	ExtraDNSLabels         []string
+	Meta                   PeerSystemMeta
+	ProxyMeta              ProxyMeta
+	Location               PeerLocation
+}
+
+// ProxyMeta is the slim twin of peer.ProxyMeta.
+type ProxyMeta struct {
+	Embedded bool
+	Cluster  string
+}
+
+// PeerSystemMeta is the slim twin of peer.PeerSystemMeta.
+type PeerSystemMeta struct {
+	WtVersion          string
+	GoOS               string
+	OSVersion          string
+	KernelVersion      string
+	NetworkAddresses   []NetworkAddress
+	Files              []File
+	Capabilities       []int32
+	Flags              Flags
+	SyncMessageVersion int
+}
+
+// Flags is the slim twin of peer.Flags.
+type Flags struct {
+	ServerSSHAllowed bool
+	DisableIPv6      bool
+}
+
+// NetworkAddress is the slim twin of peer.NetworkAddress.
+type NetworkAddress struct {
+	NetIP netip.Prefix
+}
+
+// File is the slim twin of peer.File.
+type File struct {
+	Path             string
+	ProcessIsRunning bool
+}
+
+// PeerLocation is the slim twin of peer.Location.
+type PeerLocation struct {
+	CountryCode  string
+	CityName     string
+	ConnectionIP net.IP
+}
+
+func (p *Peer) HasCapability(capability int32) bool {
+	return slices.Contains(p.Meta.Capabilities, capability)
+}
+
+func (p *Peer) SupportsIPv6() bool {
+	return !p.Meta.Flags.DisableIPv6 && p.HasCapability(PeerCapabilityIPv6Overlay)
+}
+
+func (p *Peer) SupportsSourcePrefixes() bool {
+	return p.HasCapability(PeerCapabilitySourcePrefixes)
+}
+
+func (p *Peer) AddedWithSSOLogin() bool {
+	return p.UserID != ""
+}
+
+func (p *Peer) FQDN(dnsDomain string) string {
+	if dnsDomain == "" {
+		return ""
+	}
+	return p.DNSLabel + "." + dnsDomain
+}
+
+func (p *Peer) GetLastLogin() time.Time {
+	if p.LastLogin != nil {
+		return *p.LastLogin
+	}
+	return time.Time{}
+}
+
+// SessionExpiresAt mirrors peer.Peer.SessionExpiresAt.
+func (p *Peer) SessionExpiresAt(accountExpirationEnabled bool, expiresIn time.Duration) time.Time {
+	if !accountExpirationEnabled || !p.AddedWithSSOLogin() || !p.LoginExpirationEnabled {
+		return time.Time{}
+	}
+	last := p.GetLastLogin()
+	if last.IsZero() {
+		return time.Time{}
+	}
+	return last.Add(expiresIn).UTC()
+}
+
+func (p *Peer) LoginExpired(expiresIn time.Duration) (bool, time.Duration) {
+	if !p.AddedWithSSOLogin() || !p.LoginExpirationEnabled {
+		return false, 0
+	}
+	expiresAt := p.GetLastLogin().Add(expiresIn)
+	now := time.Now()
+	timeLeft := expiresAt.Sub(now)
+	return timeLeft <= 0, timeLeft
+}
diff --git a/shared/management/networkmap/nmdata/policy.go b/shared/management/networkmap/nmdata/policy.go
new file mode 100644
index 000000000..df0c77518
--- /dev/null
+++ b/shared/management/networkmap/nmdata/policy.go
@@ -0,0 +1,96 @@
+package nmdata
+
+const (
+	policyRuleProtocolALL = "all"
+	policyRuleProtocolTCP = "tcp"
+
+	defaultSSHPortString        = "22"
+	nativeSSHPortString         = "22022"
+	defaultSSHPortNumber uint16 = 22
+	nativeSSHPortNumber  uint16 = 22022
+)
+
+// Policy is the slim twin of types.Policy.
+type Policy struct {
+	ID                  string
+	PublicID            string
+	Enabled             bool
+	SourcePostureChecks []string
+	Rules               []*PolicyRule
+}
+
+// PolicyRule is the slim twin of types.PolicyRule.
+type PolicyRule struct {
+	ID                  string
+	PolicyID            string
+	Enabled             bool
+	Action              string
+	Protocol            string
+	Bidirectional       bool
+	Sources             []string
+	Destinations        []string
+	SourceResource      Resource
+	DestinationResource Resource
+	Ports               []string
+	PortRanges          []RulePortRange
+	AuthorizedGroups    map[string][]string
+	AuthorizedUser      string
+}
+
+// RulePortRange is the slim twin of types.RulePortRange.
+type RulePortRange struct {
+	Start uint16
+	End   uint16
+}
+
+// Resource is the slim twin of types.Resource.
+type Resource struct {
+	ID   string
+	Type string
+}
+
+func (p *Policy) SourceGroups() []string {
+	if len(p.Rules) == 1 && p.Rules[0] != nil {
+		return p.Rules[0].Sources
+	}
+	groups := make(map[string]struct{}, len(p.Rules))
+	for _, rule := range p.Rules {
+		if rule == nil {
+			continue
+		}
+		for _, source := range rule.Sources {
+			groups[source] = struct{}{}
+		}
+	}
+
+	groupIDs := make([]string, 0, len(groups))
+	for groupID := range groups {
+		groupIDs = append(groupIDs, groupID)
+	}
+
+	return groupIDs
+}
+
+// PolicyRuleImpliesLegacySSH is the twin-typed sibling of types.PolicyRuleImpliesLegacySSH.
+func PolicyRuleImpliesLegacySSH(rule *PolicyRule) bool {
+	return rule.Protocol == policyRuleProtocolALL ||
+		(rule.Protocol == policyRuleProtocolTCP && (portsIncludesSSH(rule.Ports) || portRangeIncludesSSH(rule.PortRanges)))
+}
+
+func portRangeIncludesSSH(portRanges []RulePortRange) bool {
+	for _, pr := range portRanges {
+		if (pr.Start <= defaultSSHPortNumber && pr.End >= defaultSSHPortNumber) || (pr.Start <= nativeSSHPortNumber && pr.End >= nativeSSHPortNumber) {
+			return true
+		}
+	}
+	return false
+}
+
+func portsIncludesSSH(ports []string) bool {
+	for _, port := range ports {
+		if port == defaultSSHPortString || port == nativeSSHPortString {
+			return true
+		}
+	}
+	return false
+}
diff --git a/shared/management/networkmap/nmdata/posture.go b/shared/management/networkmap/nmdata/posture.go
new file mode 100644
index 000000000..6a6b028c7
--- /dev/null
+++ b/shared/management/networkmap/nmdata/posture.go
@@ -0,0 +1,83 @@
+package nmdata
+
+const (
+	checkActionAllow = "allow"
+	checkActionDeny  = "deny"
+)
+
+// PostureChecks is the slim twin of posture.Checks.
+type PostureChecks struct {
+	ID     string
+	Checks ChecksDefinition
+}
+
+// ChecksDefinition is the slim twin of posture.ChecksDefinition.
+type ChecksDefinition struct {
+	NBVersionCheck        *NBVersionCheck
+	OSVersionCheck        *OSVersionCheck
+	GeoLocationCheck      *GeoLocationCheck
+	PeerNetworkRangeCheck *PeerNetworkRangeCheck
+	ProcessCheck          *ProcessCheck
+}
+
+// Check is the slim twin of posture.Check. It is sealed: only the check types
+// in this package implement it.
+type Check interface {
+	check(peer *Peer) (bool, error)
+}
+
+// Passes reports whether the peer satisfies every check in this bundle. It
+// mirrors the server posture path: a check returning (false, _) — including on
+// an evaluation error — fails the bundle.
+func (pc *PostureChecks) Passes(peer *Peer) bool {
+	return PassesChecks(pc.GetChecks(), peer)
+}
+
+// PassesChecks is Passes over an already built check set, for callers that
+// evaluate many peers against the same bundle.
+func PassesChecks(checks []Check, peer *Peer) bool {
+	for _, c := range checks {
+		valid, _ := c.check(peer)
+		if !valid {
+			return false
+		}
+	}
+	return true
+}
+
+// PostureVerdictChanged reports whether any check in the bundles gives a different
+// verdict for newPeer than for oldPeer. Checks are replayed one by one, so a change
+// that moves a field but stays on the same side of a threshold does not count. An
+// evaluation error is a deny, like in PassesChecks.
+func PostureVerdictChanged(checks []*PostureChecks, oldPeer, newPeer *Peer) bool {
+	for _, pc := range checks {
+		for _, c := range pc.GetChecks() {
+			single := []Check{c}
+			if PassesChecks(single, oldPeer) != PassesChecks(single, newPeer) {
+				return true
+			}
+		}
+	}
+	return false
+}
+
+// GetChecks returns the initialized checks in the same order as posture.Checks.GetChecks.
+func (pc *PostureChecks) GetChecks() []Check {
+	var checks []Check
+	if pc.Checks.NBVersionCheck != nil {
+		checks = append(checks, pc.Checks.NBVersionCheck)
+	}
+	if pc.Checks.OSVersionCheck != nil {
+		checks = append(checks, pc.Checks.OSVersionCheck)
+	}
+	if pc.Checks.GeoLocationCheck != nil {
+		checks = append(checks, pc.Checks.GeoLocationCheck)
+	}
+	if pc.Checks.PeerNetworkRangeCheck != nil {
+		checks = append(checks, pc.Checks.PeerNetworkRangeCheck)
+	}
+	if pc.Checks.ProcessCheck != nil {
+		checks = append(checks, pc.Checks.ProcessCheck)
+	}
+	return checks
+}
diff --git a/shared/management/networkmap/nmdata/posture_geo_location.go b/shared/management/networkmap/nmdata/posture_geo_location.go
new file mode 100644
index 000000000..18b0919b2
--- /dev/null
+++ b/shared/management/networkmap/nmdata/posture_geo_location.go
@@ -0,0 +1,45 @@
+package nmdata
+
+import "fmt"
+
+// GeoLocation is the slim twin of posture.Location.
+type GeoLocation struct {
+	CountryCode string
+	CityName    string
+}
+
+// GeoLocationCheck is the slim twin of posture.GeoLocationCheck.
+type GeoLocationCheck struct {
+	Locations []GeoLocation
+	Action    string
+}
+
+func (g *GeoLocationCheck) check(peer *Peer) (bool, error) {
+	if peer.Location.CountryCode == "" && peer.Location.CityName == "" {
+		return false, fmt.Errorf("peer's location is not set")
+	}
+
+	for _, loc := range g.Locations {
+		if loc.CountryCode == peer.Location.CountryCode {
+			if loc.CityName == "" || loc.CityName == peer.Location.CityName {
+				switch g.Action {
+				case checkActionDeny:
+					return false, nil
+				case checkActionAllow:
+					return true, nil
+				default:
+					return false, fmt.Errorf("invalid geo location action: %s", g.Action)
+				}
+			}
+		}
+	}
+
+	if g.Action == checkActionDeny {
+		return true, nil
+	}
+	if g.Action == checkActionAllow {
+		return false, nil
+	}
+
+	return false, fmt.Errorf("invalid geo location action: %s", g.Action)
+}
diff --git a/shared/management/networkmap/nmdata/posture_nb_version.go b/shared/management/networkmap/nmdata/posture_nb_version.go
new file mode 100644
index 000000000..3d82a4c80
--- /dev/null
+++ b/shared/management/networkmap/nmdata/posture_nb_version.go
@@ -0,0 +1,38 @@
+package nmdata
+
+import (
+	"strings"
+
+	"github.com/hashicorp/go-version"
+)
+
+// NBVersionCheck is the slim twin of posture.NBVersionCheck.
+type NBVersionCheck struct {
+	MinVersion string
+}
+
+func (n *NBVersionCheck) check(peer *Peer) (bool, error) {
+	return meetsMinVersion(n.MinVersion, peer.Meta.WtVersion)
+}
+
+func meetsMinVersion(minVer, peerVer string) (bool, error) {
+	peerVer = sanitizeVersion(peerVer)
+	minVer = sanitizeVersion(minVer)
+
+	peerNBVer, err := version.NewVersion(peerVer)
+	if err != nil {
+		return false, err
+	}
+
+	constraints, err := version.NewConstraint(">= " + minVer)
+	if err != nil {
+		return false, err
+	}
+
+	return constraints.Check(peerNBVer), nil
+}
+
+func sanitizeVersion(v string) string {
+	parts := strings.Split(v, "-")
+	return parts[0]
+}
diff --git a/shared/management/networkmap/nmdata/posture_network.go b/shared/management/networkmap/nmdata/posture_network.go
new file mode 100644
index 000000000..d8dd2cf00
--- /dev/null
+++ b/shared/management/networkmap/nmdata/posture_network.go
@@ -0,0 +1,62 @@
+package nmdata
+
+import (
+	"fmt"
+	"net/netip"
+)
+
+// PeerNetworkRangeCheck is the slim twin of posture.PeerNetworkRangeCheck.
+type PeerNetworkRangeCheck struct {
+	Action string
+	Ranges []netip.Prefix
+}
+
+func (p *PeerNetworkRangeCheck) check(peer *Peer) (bool, error) {
+	peerPrefixes := make([]netip.Prefix, 0, len(peer.Meta.NetworkAddresses)+1)
+	for _, peerNetAddr := range peer.Meta.NetworkAddresses {
+		peerPrefixes = append(peerPrefixes, peerNetAddr.NetIP)
+	}
+	if connIP := peer.Location.ConnectionIP; len(connIP) > 0 {
+		if addr, ok := netip.AddrFromSlice(connIP); ok {
+			addr = addr.Unmap()
+			peerPrefixes = append(peerPrefixes, netip.PrefixFrom(addr, addr.BitLen()))
+		}
+	}
+
+	if len(peerPrefixes) == 0 {
+		return false, fmt.Errorf("peer's does not contain peer network range addresses")
+	}
+
+	for _, peerPrefix := range peerPrefixes {
+		for _, rangePrefix := range p.Ranges {
+			if !prefixContains(rangePrefix, peerPrefix) {
+				continue
+			}
+			switch p.Action {
+			case checkActionDeny:
+				return false, nil
+			case checkActionAllow:
+				return true, nil
+			default:
+				return false, fmt.Errorf("invalid peer network range check action: %s", p.Action)
+			}
+		}
+	}
+
+	if p.Action == checkActionDeny {
+		return true, nil
+	}
+	if p.Action == checkActionAllow {
+		return false, nil
+	}
+
+	return false, fmt.Errorf("invalid peer network range check action: %s", p.Action)
+}
+
+func prefixContains(outer, inner netip.Prefix) bool {
+	outer = outer.Masked()
+	inner = inner.Masked()
+	return outer.Bits() <= inner.Bits() &&
+		outer.Addr().BitLen() == inner.Addr().BitLen() &&
+		outer.Contains(inner.Addr())
+}
diff --git a/shared/management/networkmap/nmdata/posture_os_version.go b/shared/management/networkmap/nmdata/posture_os_version.go
new file mode 100644
index 000000000..779bd2ac3
--- /dev/null
+++ b/shared/management/networkmap/nmdata/posture_os_version.go
@@ -0,0 +1,79 @@
+package nmdata
+
+import (
+	"strings"
+
+	"github.com/hashicorp/go-version"
+)
+
+// MinVersionCheck is the slim twin of posture.MinVersionCheck.
+type MinVersionCheck struct {
+	MinVersion string
+}
+
+// MinKernelVersionCheck is the slim twin of posture.MinKernelVersionCheck.
+type MinKernelVersionCheck struct {
+	MinKernelVersion string
+}
+
+// OSVersionCheck is the slim twin of posture.OSVersionCheck.
+type OSVersionCheck struct {
+	Android *MinVersionCheck
+	Darwin  *MinVersionCheck
+	Ios     *MinVersionCheck
+	Linux   *MinKernelVersionCheck
+	Windows *MinKernelVersionCheck
+}
+
+func (c *OSVersionCheck) check(peer *Peer) (bool, error) {
+	switch peer.Meta.GoOS {
+	case "android":
+		return checkMinVersion(peer.Meta.OSVersion, c.Android)
+	case "darwin":
+		return checkMinVersion(peer.Meta.OSVersion, c.Darwin)
+	case "ios":
+		return checkMinVersion(peer.Meta.OSVersion, c.Ios)
+	case "linux":
+		kernelVersion := strings.Split(peer.Meta.KernelVersion, "-")[0]
+		return checkMinKernelVersion(kernelVersion, c.Linux)
+	case "windows":
+		return checkMinKernelVersion(peer.Meta.KernelVersion, c.Windows)
+	}
+	return true, nil
+}
+
+func checkMinVersion(peerVersion string, check *MinVersionCheck) (bool, error) {
+	if check == nil {
+		return false, nil
+	}
+
+	peerNBVersion, err := version.NewVersion(peerVersion)
+	if err != nil {
+		return false, err
+	}
+
+	constraints, err := version.NewConstraint(">= " + check.MinVersion)
+	if err != nil {
+		return false, err
+	}
+
+	return constraints.Check(peerNBVersion), nil
+}
+
+func checkMinKernelVersion(peerVersion string, check *MinKernelVersionCheck) (bool, error) {
+	if check == nil {
+		return false, nil
+	}
+
+	peerNBVersion, err := version.NewVersion(peerVersion)
+	if err != nil {
+		return false, err
+	}
+
+	constraints, err := version.NewConstraint(">= " + check.MinKernelVersion)
+	if err != nil {
+		return false, err
+	}
+
+	return constraints.Check(peerNBVersion), nil
+}
diff --git a/shared/management/networkmap/nmdata/posture_process.go b/shared/management/networkmap/nmdata/posture_process.go
new file mode 100644
index 000000000..3d35613b5
--- /dev/null
+++ b/shared/management/networkmap/nmdata/posture_process.go
@@ -0,0 +1,56 @@
+package nmdata
+
+import (
+	"fmt"
+	"slices"
+)
+
+// Process is the slim twin of posture.Process.
+type Process struct {
+	LinuxPath   string
+	MacPath     string
+	WindowsPath string
+}
+
+// ProcessCheck is the slim twin of posture.ProcessCheck.
+type ProcessCheck struct {
+	Processes []Process
+}
+
+func (p *ProcessCheck) check(peer *Peer) (bool, error) {
+	peerActiveProcesses := extractPeerActiveProcesses(peer.Meta.Files)
+
+	var pathSelector func(Process) string
+	switch peer.Meta.GoOS {
+	case "linux":
+		pathSelector = func(process Process) string { return process.LinuxPath }
+	case "darwin":
+		pathSelector = func(process Process) string { return process.MacPath }
+	case "windows":
+		pathSelector = func(process Process) string { return process.WindowsPath }
+	default:
+		return false, fmt.Errorf("unsupported peer's operating system: %s", peer.Meta.GoOS)
+	}
+
+	return p.areAllProcessesRunning(peerActiveProcesses, pathSelector), nil
+}
+
+func (p *ProcessCheck) areAllProcessesRunning(activeProcesses []string, pathSelector func(Process) string) bool {
+	for _, process := range p.Processes {
+		path := pathSelector(process)
+		if path == "" || !slices.Contains(activeProcesses, path) {
+			return false
+		}
+	}
+	return true
+}
+
+func extractPeerActiveProcesses(files []File) []string {
+	activeProcesses := make([]string, 0, len(files))
+	for _, file := range files {
+		if file.ProcessIsRunning {
+			activeProcesses = append(activeProcesses, file.Path)
+		}
+	}
+	return activeProcesses
+}
diff --git a/shared/management/networkmap/nmdata/posture_test.go b/shared/management/networkmap/nmdata/posture_test.go
new file mode 100644
index 000000000..13e5f268e
--- /dev/null
+++ b/shared/management/networkmap/nmdata/posture_test.go
@@ -0,0 +1,54 @@
+package nmdata
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func bundle(def ChecksDefinition) []*PostureChecks {
+	return []*PostureChecks{{Checks: def}}
+}
+
+func TestPostureVerdictChanged_ErrorCountsAsDeny(t *testing.T) {
+	c := bundle(ChecksDefinition{NBVersionCheck: &NBVersionCheck{MinVersion: "1.2.0"}})
+
+	tests := []struct {
+		name           string
+		oldVer, newVer string
+		want           bool
+	}{
+		{"both above min, no flip", "1.3.0", "1.4.0", false},
+		{"crosses up below->above", "1.1.0", "1.3.0", true},
+		{"unparsable old only -> flip", "garbage", "1.3.0", true},
+		{"unparsable both -> no flip", "garbage", "junk", false},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			oldPeer := &Peer{Meta: PeerSystemMeta{WtVersion: tt.oldVer}}
+			newPeer := &Peer{Meta: PeerSystemMeta{WtVersion: tt.newVer}}
+			assert.Equal(t, tt.want, PostureVerdictChanged(c, oldPeer, newPeer))
+		})
+	}
+}
+
+func TestPostureVerdictChanged_ReplaysEachCheck(t *testing.T) {
+	// Old fails the version check, new fails the kernel check: the bundle denies on
+	// both sides, yet every single check flipped, so the posture must be re-evaluated.
+	c := bundle(ChecksDefinition{
+		NBVersionCheck: &NBVersionCheck{MinVersion: "1.0.0"},
+		OSVersionCheck: &OSVersionCheck{Linux: &MinKernelVersionCheck{MinKernelVersion: "5.0.0"}},
+	})
+	oldPeer := &Peer{Meta: PeerSystemMeta{WtVersion: "0.9.0", GoOS: "linux", KernelVersion: "6.0.0"}}
+	newPeer := &Peer{Meta: PeerSystemMeta{WtVersion: "1.1.0", GoOS: "linux", KernelVersion: "4.0.0"}}
+
+	assert.False(t, c[0].Passes(oldPeer))
+	assert.False(t, c[0].Passes(newPeer))
+	assert.True(t, PostureVerdictChanged(c, oldPeer, newPeer))
+}
+
+func TestPostureVerdictChanged_NoChecks(t *testing.T) {
+	oldPeer := &Peer{Meta: PeerSystemMeta{WtVersion: "1.0.0"}}
+	newPeer := &Peer{Meta: PeerSystemMeta{WtVersion: "2.0.0"}}
+	assert.False(t, PostureVerdictChanged(nil, oldPeer, newPeer))
+}
diff --git a/shared/management/networkmap/nmdata/route.go b/shared/management/networkmap/nmdata/route.go
new file mode 100644
index 000000000..e2301f094
--- /dev/null
+++ b/shared/management/networkmap/nmdata/route.go
@@ -0,0 +1,108 @@
+package nmdata
+
+import (
+	"net/netip"
+	"slices"
+	"strings"
+
+	"github.com/netbirdio/netbird/shared/management/domain"
+)
+
+// NetworkType mirrors route.NetworkType iota values.
+const (
+	NetworkTypeInvalid = 0
+	NetworkTypeIPv4    = 1
+	NetworkTypeIPv6    = 2
+	NetworkTypeDomain  = 3
+
+	haSeparator = "|"
+)
+
+// Route is the slim twin of route.Route.
+type Route struct {
+	ID                  string
+	AccountID           string
+	PublicID            string
+	Network             netip.Prefix
+	Domains             domain.List
+	KeepRoute           bool
+	NetID               string
+	Description         string
+	Peer                string
+	PeerID              string
+	PeerGroups          []string
+	NetworkType         int
+	Masquerade          bool
+	Metric              int
+	Enabled             bool
+	Groups              []string
+	AccessControlGroups []string
+	SkipAutoApply       bool
+}
+
+func (r *Route) Equal(other *Route) bool {
+	if r == nil && other == nil {
+		return true
+	} else if r == nil || other == nil {
+		return false
+	}
+
+	return other.ID == r.ID &&
+		other.Description == r.Description &&
+		other.NetID == r.NetID &&
+		other.Network == r.Network &&
+		slices.Equal(r.Domains, other.Domains) &&
+		other.KeepRoute == r.KeepRoute &&
+		other.NetworkType == r.NetworkType &&
+		other.Peer == r.Peer &&
+		other.PeerID == r.PeerID &&
+		other.Metric == r.Metric &&
+		other.Masquerade == r.Masquerade &&
+		other.Enabled == r.Enabled &&
+		slices.Equal(r.Groups, other.Groups) &&
+		slices.Equal(r.PeerGroups, other.PeerGroups) &&
+		slices.Equal(r.AccessControlGroups, other.AccessControlGroups) &&
+		other.SkipAutoApply == r.SkipAutoApply
+}
+
+func (r *Route) IsDynamic() bool {
+	return r.NetworkType == NetworkTypeDomain
+}
+
+func (r *Route) NetString() string {
+	if r.IsDynamic() && r.Domains != nil {
+		return r.Domains.SafeString()
+	}
+	return r.Network.String()
+}
+
+func (r *Route) GetHAUniqueID() string {
+	return r.NetID + haSeparator + r.NetString()
+}
+
+func (r *Route) GetResourceID() string {
+	return strings.Split(r.ID, ":")[0]
+}
+
+func (r *Route) Copy() *Route {
+	return &Route{
+		ID:                  r.ID,
+		AccountID:           r.AccountID,
+		PublicID:            r.PublicID,
+		Network:             r.Network,
+		Domains:             slices.Clone(r.Domains),
+		KeepRoute:           r.KeepRoute,
+		NetID:               r.NetID,
+		Description:         r.Description,
+		Peer:                r.Peer,
+		PeerID:              r.PeerID,
+		PeerGroups:          slices.Clone(r.PeerGroups),
+		NetworkType:         r.NetworkType,
+		Masquerade:          r.Masquerade,
+		Metric:              r.Metric,
+		Enabled:             r.Enabled,
+		Groups:              slices.Clone(r.Groups),
+		AccessControlGroups: slices.Clone(r.AccessControlGroups),
+		SkipAutoApply:       r.SkipAutoApply,
+	}
+}
diff --git a/shared/management/networkmap/nmdata/service.go b/shared/management/networkmap/nmdata/service.go
new file mode 100644
index 000000000..63557c51e
--- /dev/null
+++ b/shared/management/networkmap/nmdata/service.go
@@ -0,0 +1,25 @@
+package nmdata
+
+// Service is the slim twin of the reverse-proxy service.Service. It carries
+// only the state proxy-policy injection reads: the persisted reverse-proxy
+// services and the in-memory ones synthesised from agent-network state, which
+// are never written to the database.
+type Service struct {
+	ID           string
+	Enabled      bool
+	Private      bool
+	Mode         string
+	ProxyCluster string
+	AccessGroups []string
+	Targets      []*ServiceTarget
+}
+
+// ServiceTarget is the slim twin of service.Target.
+type ServiceTarget struct {
+	Enabled    bool
+	Path       string
+	Port       uint16
+	Protocol   string
+	TargetID   string
+	TargetType string
+}
diff --git a/shared/management/networkmap/peers_custom_zone.go b/shared/management/networkmap/peers_custom_zone.go
new file mode 100644
index 000000000..063844358
--- /dev/null
+++ b/shared/management/networkmap/peers_custom_zone.go
@@ -0,0 +1,111 @@
+package networkmap
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	"github.com/hashicorp/go-multierror"
+	"github.com/miekg/dns"
+	log "github.com/sirupsen/logrus"
+
+	nbdns "github.com/netbirdio/netbird/dns"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+const peersZoneRecordTTL = 300
+
+// PeersCustomZone builds the peers DNS zone from twin peer rows. It is the
+// single source of the zone-record logic; Account.GetPeersCustomZone delegates
+// here via twins.
+func PeersCustomZone(ctx context.Context, accountID string, dnsDomain string, peers map[string]*nmdata.Peer, ipv6AllowedPeers map[string]struct{}) nmdata.CustomZone {
+	var merr *multierror.Error
+
+	if dnsDomain == "" {
+		log.WithContext(ctx).Error("no dns domain is set, returning empty zone")
+		return nmdata.CustomZone{}
+	}
+
+	customZone := nmdata.CustomZone{
+		Domain:  dns.Fqdn(dnsDomain),
+		Records: make([]nmdata.SimpleRecord, 0, len(peers)),
+	}
+
+	domainSuffix := "." + dnsDomain
+
+	var sb strings.Builder
+	for _, peer := range peers {
+		if peer == nil {
+			continue
+		}
+		if peer.DNSLabel == "" {
+			merr = multierror.Append(merr, fmt.Errorf("peer %s has an empty DNS label", peer.ID))
+			continue
+		}
+
+		sb.Grow(len(peer.DNSLabel) + len(domainSuffix))
+		sb.WriteString(peer.DNSLabel)
+		sb.WriteString(domainSuffix)
+
+		fqdn := sb.String()
+		customZone.Records = append(customZone.Records, nmdata.SimpleRecord{
+			Name:  fqdn,
+			Type:  int(dns.TypeA),
+			Class: nbdns.DefaultClass,
+			TTL:   peersZoneRecordTTL,
+			RData: peer.IP.String(),
+		})
+		// Only advertise AAAA for peers that have a valid IPv6, whose client supports it,
+		// and that belong to an IPv6-enabled group. Old clients don't configure v6 on their
+		// WireGuard interface, so resolving their AAAA causes connections to hang.
+		// Capability changes (client upgrade/downgrade, --disable-ipv6 toggle) propagate
+		// to other peers via SyncPeer/LoginPeer regardless of version change, so AAAA
+		// records refresh when a peer first reports the IPv6 overlay capability.
+		_, peerAllowed := ipv6AllowedPeers[peer.ID]
+		hasIPv6 := peer.IPv6.IsValid() && peer.SupportsIPv6() && peerAllowed
+		if hasIPv6 {
+			customZone.Records = append(customZone.Records, nmdata.SimpleRecord{
+				Name:  fqdn,
+				Type:  int(dns.TypeAAAA),
+				Class: nbdns.DefaultClass,
+				TTL:   peersZoneRecordTTL,
+				RData: peer.IPv6.String(),
+			})
+		}
+		sb.Reset()
+
+		for _, extraLabel := range peer.ExtraDNSLabels {
+			sb.Grow(len(extraLabel) + len(domainSuffix))
+			sb.WriteString(extraLabel)
+			sb.WriteString(domainSuffix)
+
+			extraFqdn := sb.String()
+			customZone.Records = append(customZone.Records, nmdata.SimpleRecord{
+				Name:  extraFqdn,
+				Type:  int(dns.TypeA),
+				Class: nbdns.DefaultClass,
+				TTL:   peersZoneRecordTTL,
+				RData: peer.IP.String(),
+			})
+			if hasIPv6 {
+				customZone.Records = append(customZone.Records, nmdata.SimpleRecord{
+					Name:  extraFqdn,
+					Type:  int(dns.TypeAAAA),
+					Class: nbdns.DefaultClass,
+					TTL:   peersZoneRecordTTL,
+					RData: peer.IPv6.String(),
+				})
+			}
+			sb.Reset()
+		}
+
+	}
+
+	go func() {
+		if merr != nil {
+			log.WithContext(ctx).Errorf("error generating custom zone for account %s: %v", accountID, merr)
+		}
+	}()
+
+	return customZone
+}
diff --git a/shared/management/networkmap/proxypolicies.go b/shared/management/networkmap/proxypolicies.go
new file mode 100644
index 000000000..7a7c805a6
--- /dev/null
+++ b/shared/management/networkmap/proxypolicies.go
@@ -0,0 +1,209 @@
+package networkmap
+
+import (
+	"fmt"
+	"slices"
+	"strings"
+
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+	"github.com/netbirdio/netbird/shared/management/types"
+)
+
+const (
+	serviceModeUDP = "udp"
+
+	privateServicePortHTTP  = 80
+	privateServicePortHTTPS = 443
+)
+
+// InjectProxyPolicies synthesises the in-memory ACLs that carry reverse-proxy
+// traffic and appends them to the twin's policies. They are never persisted,
+// so no builder can load them: a proxy-access policy lets a cluster's proxy
+// peers reach each enabled target of a service, and a private-access policy
+// lets a private service's AccessGroups reach those proxy peers on HTTP(S).
+//
+// GetPeerNetworkMapComponents calls it, so every caller of the twin gets the
+// same policy set no matter which builder produced it. It runs at most once
+// per twin, and is safe to call again to force the synthesis early.
+func (nmd *NetworkMapData) InjectProxyPolicies() {
+	nmd.proxyPoliciesOnce.Do(nmd.injectProxyPolicies)
+}
+
+func (nmd *NetworkMapData) injectProxyPolicies() {
+	if len(nmd.Services) == 0 {
+		return
+	}
+
+	proxyPeersByCluster := nmd.proxyPeersByCluster()
+	if len(proxyPeersByCluster) == 0 {
+		return
+	}
+
+	for _, svc := range nmd.Services {
+		if svc == nil || !svc.Enabled {
+			continue
+		}
+
+		proxyPeers := proxyPeersByCluster[svc.ProxyCluster]
+		for _, target := range svc.Targets {
+			if target == nil || !target.Enabled {
+				continue
+			}
+			port, ok := resolveTargetPort(target)
+			if !ok {
+				continue
+			}
+			for _, proxyPeer := range proxyPeers {
+				nmd.addInjectedPolicy(proxyAccessPolicy(svc, target, proxyPeer, port))
+			}
+		}
+
+		nmd.injectPrivateServicePolicies(svc, proxyPeers)
+	}
+}
+
+// injectPrivateServicePolicies synthesises AccessGroups → cluster proxy peers on TCP 80/443.
+func (nmd *NetworkMapData) injectPrivateServicePolicies(svc *nmdata.Service, proxyPeers []*nmdata.Peer) {
+	if !svc.Private || len(svc.AccessGroups) == 0 || len(proxyPeers) == 0 {
+		return
+	}
+
+	// A service's AccessGroups can name groups that no longer exist — persisted
+	// services and the agent-network synthesiser both carry the ids verbatim from
+	// their own state. An unresolvable source authorises nothing, so drop it here
+	// rather than let the network-map assembly resolve it to a nil group.
+	sources := nmd.existingGroupIDs(svc.AccessGroups)
+	if len(sources) == 0 {
+		return
+	}
+
+	for _, proxyPeer := range proxyPeers {
+		nmd.addInjectedPolicy(privateAccessPolicy(svc, proxyPeer, sources))
+	}
+}
+
+// addInjectedPolicy appends the policy to the twin's policy set, and to the
+// policies of the network resource it targets — mirroring the account path,
+// where the resource-policy map was built after injection.
+func (nmd *NetworkMapData) addInjectedPolicy(policy *nmdata.Policy) {
+	nmd.Policies = append(nmd.Policies, policy)
+
+	resourceID := policy.Rules[0].DestinationResource.ID
+	if resourceID == "" {
+		return
+	}
+	for _, resource := range nmd.NetworkResources {
+		if resource == nil || !resource.Enabled || resource.ID != resourceID {
+			continue
+		}
+		if nmd.ResourcePolicies == nil {
+			nmd.ResourcePolicies = make(map[string][]*nmdata.Policy)
+		}
+		nmd.ResourcePolicies[resourceID] = append(nmd.ResourcePolicies[resourceID], policy)
+		return
+	}
+}
+
+func proxyAccessPolicy(svc *nmdata.Service, target *nmdata.ServiceTarget, proxyPeer *nmdata.Peer, port uint16) *nmdata.Policy {
+	policyID := fmt.Sprintf("proxy-access-%s-%s-%s", svc.ID, proxyPeer.ID, target.Path)
+
+	protocol := types.PolicyRuleProtocolTCP
+	if svc.Mode == serviceModeUDP {
+		protocol = types.PolicyRuleProtocolUDP
+	}
+
+	return &nmdata.Policy{
+		ID: policyID,
+		// The envelope encoder puts public ids on the wire and degrades to an
+		// empty one when a policy has none. A synthesised policy has no
+		// persisted row to take a public id from, and its own id is already
+		// stable and unique, so it serves as both.
+		PublicID: policyID,
+		Enabled:  true,
+		Rules: []*nmdata.PolicyRule{
+			{
+				ID:                  policyID,
+				PolicyID:            policyID,
+				Enabled:             true,
+				SourceResource:      nmdata.Resource{ID: proxyPeer.ID, Type: string(types.ResourceTypePeer)},
+				DestinationResource: nmdata.Resource{ID: target.TargetID, Type: target.TargetType},
+				Bidirectional:       false,
+				Protocol:            string(protocol),
+				Action:              string(types.PolicyTrafficActionAccept),
+				PortRanges:          []nmdata.RulePortRange{{Start: port, End: port}},
+			},
+		},
+	}
+}
+
+func privateAccessPolicy(svc *nmdata.Service, proxyPeer *nmdata.Peer, accessGroups []string) *nmdata.Policy {
+	policyID := fmt.Sprintf("private-access-%s-%s", svc.ID, proxyPeer.ID)
+
+	return &nmdata.Policy{
+		ID:       policyID,
+		PublicID: policyID,
+		Enabled:  true,
+		Rules: []*nmdata.PolicyRule{
+			{
+				ID:                  policyID,
+				PolicyID:            policyID,
+				Enabled:             true,
+				Sources:             slices.Clone(accessGroups),
+				DestinationResource: nmdata.Resource{ID: proxyPeer.ID, Type: string(types.ResourceTypePeer)},
+				Bidirectional:       false,
+				Protocol:            string(types.PolicyRuleProtocolTCP),
+				Action:              string(types.PolicyTrafficActionAccept),
+				PortRanges: []nmdata.RulePortRange{
+					{Start: privateServicePortHTTP, End: privateServicePortHTTP},
+					{Start: privateServicePortHTTPS, End: privateServicePortHTTPS},
+				},
+			},
+		},
+	}
+}
+
+func resolveTargetPort(target *nmdata.ServiceTarget) (uint16, bool) {
+	if target.Port != 0 {
+		return target.Port, true
+	}
+
+	switch target.Protocol {
+	case "https", "tls":
+		return privateServicePortHTTPS, true
+	case "http":
+		return privateServicePortHTTP, true
+	default:
+		return 0, false
+	}
+}
+
+// proxyPeersByCluster groups the account's embedded proxy peers by the cluster
+// they serve. Sorted by peer ID so the synthesised policy order is stable.
+func (nmd *NetworkMapData) proxyPeersByCluster() map[string][]*nmdata.Peer {
+	var proxyPeers map[string][]*nmdata.Peer
+	for _, peer := range nmd.Peers {
+		if peer == nil || !peer.ProxyMeta.Embedded {
+			continue
+		}
+		if proxyPeers == nil {
+			proxyPeers = make(map[string][]*nmdata.Peer)
+		}
+		proxyPeers[peer.ProxyMeta.Cluster] = append(proxyPeers[peer.ProxyMeta.Cluster], peer)
+	}
+	for _, peers := range proxyPeers {
+		slices.SortFunc(peers, func(a, b *nmdata.Peer) int { return strings.Compare(a.ID, b.ID) })
+	}
+	return proxyPeers
+}
+
+// existingGroupIDs returns the subset of groupIDs that resolve to a group,
+// preserving the input order.
+func (nmd *NetworkMapData) existingGroupIDs(groupIDs []string) []string {
+	out := make([]string, 0, len(groupIDs))
+	for _, groupID := range groupIDs {
+		if _, ok := nmd.Groups[groupID]; ok {
+			out = append(out, groupID)
+		}
+	}
+	return out
+}
diff --git a/shared/management/proto/management.pb.go b/shared/management/proto/management.pb.go
index a49316e66..bd3ec7120 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 (
@@ -179,11 +232,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 {
@@ -192,7 +245,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
@@ -225,11 +278,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 {
@@ -238,7 +291,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
@@ -271,11 +324,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 {
@@ -284,7 +337,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
@@ -326,11 +379,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 {
@@ -339,7 +392,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
@@ -381,11 +434,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 {
@@ -424,11 +477,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 {
@@ -2923,6 +2976,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() {
@@ -2992,6 +3050,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
@@ -5443,6 +5508,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() {
@@ -5568,6 +5637,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
@@ -5743,8 +5819,6 @@ func (x *PolicyCompact) GetSourcePostureCheckIds() []string {
 // ResourceCompact mirrors types.Resource. Used by PolicyCompact to carry
 // rule.SourceResource / rule.DestinationResource when the rule targets a
 // specific resource (typically a peer) rather than groups.
-// peer_index_set tells whether peer_index is valid (proto3 uint32 cannot
-// disambiguate "0" from "unset"); set only when type == "peer".
 type ResourceCompact struct {
 	state         protoimpl.MessageState
 	sizeCache     protoimpl.SizeCache
@@ -5753,6 +5827,7 @@ type ResourceCompact struct {
 	Type         string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"`
 	PeerIndexSet bool   `protobuf:"varint,2,opt,name=peer_index_set,json=peerIndexSet,proto3" json:"peer_index_set,omitempty"`
 	PeerIndex    uint32 `protobuf:"varint,3,opt,name=peer_index,json=peerIndex,proto3" json:"peer_index,omitempty"`
+	Id           string `protobuf:"bytes,5,opt,name=id,proto3" json:"id,omitempty"` // public id for domain/host/subnet resources
 }
 
 func (x *ResourceCompact) Reset() {
@@ -5808,6 +5883,13 @@ func (x *ResourceCompact) GetPeerIndex() uint32 {
 	return 0
 }
 
+func (x *ResourceCompact) GetId() string {
+	if x != nil {
+		return x.Id
+	}
+	return ""
+}
+
 // UserNameList is a list of local-user names — used as the value type in
 // PolicyCompact.authorized_groups.
 type UserNameList struct {
@@ -5873,7 +5955,8 @@ type GroupCompact struct {
 	// groups exactly like the server does; without this bit the decoded
 	// groups lose that property and the two sides expand policy
 	// destinations differently.
-	IsAll bool `protobuf:"varint,3,opt,name=is_all,json=isAll,proto3" json:"is_all,omitempty"`
+	IsAll     bool               `protobuf:"varint,3,opt,name=is_all,json=isAll,proto3" json:"is_all,omitempty"`
+	Resources []*ResourceCompact `protobuf:"bytes,4,rep,name=resources,proto3" json:"resources,omitempty"`
 }
 
 func (x *GroupCompact) Reset() {
@@ -5929,6 +6012,13 @@ func (x *GroupCompact) GetIsAll() bool {
 	return false
 }
 
+func (x *GroupCompact) GetResources() []*ResourceCompact {
+	if x != nil {
+		return x.Resources
+	}
+	return nil
+}
+
 // DNSSettingsCompact mirrors types.DNSSettings.
 type DNSSettingsCompact struct {
 	state         protoimpl.MessageState
@@ -7135,7 +7225,7 @@ var file_management_proto_rawDesc = []byte{
 	0x3a, 0x02, 0x38, 0x01, 0x22, 0x2e, 0x0a, 0x12, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55,
 	0x73, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x69, 0x6e,
 	0x64, 0x65, 0x78, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x07, 0x69, 0x6e, 0x64,
-	0x65, 0x78, 0x65, 0x73, 0x22, 0xbb, 0x01, 0x0a, 0x10, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50,
+	0x65, 0x78, 0x65, 0x73, 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, 0x65, 0x64,
@@ -7147,709 +7237,724 @@ var file_management_proto_rawDesc = []byte{
 	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,
+	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, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
+	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, 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,
+	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, 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, 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, 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,
+	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, 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, 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, 0x91, 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, 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, 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, 0x91, 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, 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, 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, 0x5d, 0x0a, 0x0c, 0x52,
-	0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x0b, 0x0a, 0x07, 0x55,
-	0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x41, 0x4c, 0x4c, 0x10,
-	0x01, 0x12, 0x07, 0x0a, 0x03, 0x54, 0x43, 0x50, 0x10, 0x02, 0x12, 0x07, 0x0a, 0x03, 0x55, 0x44,
-	0x50, 0x10, 0x03, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x43, 0x4d, 0x50, 0x10, 0x04, 0x12, 0x0a, 0x0a,
-	0x06, 0x43, 0x55, 0x53, 0x54, 0x4f, 0x4d, 0x10, 0x05, 0x12, 0x0f, 0x0a, 0x0b, 0x4e, 0x45, 0x54,
-	0x42, 0x49, 0x52, 0x44, 0x5f, 0x53, 0x53, 0x48, 0x10, 0x06, 0x2a, 0x20, 0x0a, 0x0d, 0x52, 0x75,
-	0x6c, 0x65, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x06, 0x0a, 0x02, 0x49,
-	0x4e, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x4f, 0x55, 0x54, 0x10, 0x01, 0x2a, 0x22, 0x0a, 0x0a,
-	0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x0a, 0x0a, 0x06, 0x41, 0x43,
-	0x43, 0x45, 0x50, 0x54, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x44, 0x52, 0x4f, 0x50, 0x10, 0x01,
-	0x2a, 0x63, 0x0a, 0x0e, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63,
-	0x6f, 0x6c, 0x12, 0x0f, 0x0a, 0x0b, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x48, 0x54, 0x54,
-	0x50, 0x10, 0x00, 0x12, 0x10, 0x0a, 0x0c, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x48, 0x54,
-	0x54, 0x50, 0x53, 0x10, 0x01, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f,
-	0x54, 0x43, 0x50, 0x10, 0x02, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f,
-	0x55, 0x44, 0x50, 0x10, 0x03, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f,
-	0x54, 0x4c, 0x53, 0x10, 0x04, 0x32, 0xd0, 0x07, 0x0a, 0x11, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65,
-	0x6d, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x45, 0x0a, 0x05, 0x4c,
-	0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
+	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, 0x5d,
+	0x0a, 0x0c, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x0b,
+	0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x41,
+	0x4c, 0x4c, 0x10, 0x01, 0x12, 0x07, 0x0a, 0x03, 0x54, 0x43, 0x50, 0x10, 0x02, 0x12, 0x07, 0x0a,
+	0x03, 0x55, 0x44, 0x50, 0x10, 0x03, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x43, 0x4d, 0x50, 0x10, 0x04,
+	0x12, 0x0a, 0x0a, 0x06, 0x43, 0x55, 0x53, 0x54, 0x4f, 0x4d, 0x10, 0x05, 0x12, 0x0f, 0x0a, 0x0b,
+	0x4e, 0x45, 0x54, 0x42, 0x49, 0x52, 0x44, 0x5f, 0x53, 0x53, 0x48, 0x10, 0x06, 0x2a, 0x20, 0x0a,
+	0x0d, 0x52, 0x75, 0x6c, 0x65, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x06,
+	0x0a, 0x02, 0x49, 0x4e, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x4f, 0x55, 0x54, 0x10, 0x01, 0x2a,
+	0x22, 0x0a, 0x0a, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x0a, 0x0a,
+	0x06, 0x41, 0x43, 0x43, 0x45, 0x50, 0x54, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x44, 0x52, 0x4f,
+	0x50, 0x10, 0x01, 0x2a, 0x63, 0x0a, 0x0e, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x50, 0x72, 0x6f,
+	0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x0f, 0x0a, 0x0b, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f,
+	0x48, 0x54, 0x54, 0x50, 0x10, 0x00, 0x12, 0x10, 0x0a, 0x0c, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45,
+	0x5f, 0x48, 0x54, 0x54, 0x50, 0x53, 0x10, 0x01, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f,
+	0x53, 0x45, 0x5f, 0x54, 0x43, 0x50, 0x10, 0x02, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f,
+	0x53, 0x45, 0x5f, 0x55, 0x44, 0x50, 0x10, 0x03, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f,
+	0x53, 0x45, 0x5f, 0x54, 0x4c, 0x53, 0x10, 0x04, 0x32, 0xd0, 0x07, 0x0a, 0x11, 0x4d, 0x61, 0x6e,
+	0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x45,
+	0x0a, 0x05, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
+	0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65,
+	0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
+	0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73,
+	0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x46, 0x0a, 0x04, 0x53, 0x79, 0x6e, 0x63, 0x12, 0x1c, 0x2e,
+	0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79,
+	0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61,
+	0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74,
+	0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x30, 0x01, 0x12, 0x42, 0x0a,
+	0x0c, 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x12, 0x11, 0x2e,
+	0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79,
+	0x1a, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65,
+	0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22,
+	0x00, 0x12, 0x33, 0x0a, 0x09, 0x69, 0x73, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x79, 0x12, 0x11,
+	0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74,
+	0x79, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45,
+	0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x5a, 0x0a, 0x1a, 0x47, 0x65, 0x74, 0x44, 0x65, 0x76,
+	0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e,
+	0x46, 0x6c, 0x6f, 0x77, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 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,
+	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, 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,
+	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, 0x3b, 0x0a, 0x06, 0x4c, 0x6f, 0x67, 0x6f,
-	0x75, 0x74, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
+	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,
-	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,
+	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,
-	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,
+	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 (
@@ -7864,242 +7969,245 @@ 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, 83)
 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
-	(*RemotePeerConfig)(nil),               // 39: management.RemotePeerConfig
-	(*SSHConfig)(nil),                      // 40: management.SSHConfig
-	(*DeviceAuthorizationFlowRequest)(nil), // 41: management.DeviceAuthorizationFlowRequest
-	(*DeviceAuthorizationFlow)(nil),        // 42: management.DeviceAuthorizationFlow
-	(*PKCEAuthorizationFlowRequest)(nil),   // 43: management.PKCEAuthorizationFlowRequest
-	(*PKCEAuthorizationFlow)(nil),          // 44: management.PKCEAuthorizationFlow
-	(*ProviderConfig)(nil),                 // 45: management.ProviderConfig
-	(*Route)(nil),                          // 46: management.Route
-	(*DNSConfig)(nil),                      // 47: management.DNSConfig
-	(*CustomZone)(nil),                     // 48: management.CustomZone
-	(*SimpleRecord)(nil),                   // 49: management.SimpleRecord
-	(*NameServerGroup)(nil),                // 50: management.NameServerGroup
-	(*NameServer)(nil),                     // 51: management.NameServer
-	(*FirewallRule)(nil),                   // 52: management.FirewallRule
-	(*NetworkAddress)(nil),                 // 53: management.NetworkAddress
-	(*Checks)(nil),                         // 54: management.Checks
-	(*PortInfo)(nil),                       // 55: management.PortInfo
-	(*RouteFirewallRule)(nil),              // 56: management.RouteFirewallRule
-	(*ForwardingRule)(nil),                 // 57: management.ForwardingRule
-	(*ExposeServiceRequest)(nil),           // 58: management.ExposeServiceRequest
-	(*ExposeServiceResponse)(nil),          // 59: management.ExposeServiceResponse
-	(*RenewExposeRequest)(nil),             // 60: management.RenewExposeRequest
-	(*RenewExposeResponse)(nil),            // 61: management.RenewExposeResponse
-	(*StopExposeRequest)(nil),              // 62: management.StopExposeRequest
-	(*StopExposeResponse)(nil),             // 63: management.StopExposeResponse
-	(*NetworkMapEnvelope)(nil),             // 64: management.NetworkMapEnvelope
-	(*NetworkMapComponentsFull)(nil),       // 65: management.NetworkMapComponentsFull
-	(*ProxyPatch)(nil),                     // 66: management.ProxyPatch
-	(*AccountSettingsCompact)(nil),         // 67: management.AccountSettingsCompact
-	(*AccountNetwork)(nil),                 // 68: management.AccountNetwork
-	(*NetworkMapComponentsDelta)(nil),      // 69: management.NetworkMapComponentsDelta
-	(*PeerCompact)(nil),                    // 70: management.PeerCompact
-	(*PolicyCompact)(nil),                  // 71: management.PolicyCompact
-	(*ResourceCompact)(nil),                // 72: management.ResourceCompact
-	(*UserNameList)(nil),                   // 73: management.UserNameList
-	(*GroupCompact)(nil),                   // 74: management.GroupCompact
-	(*DNSSettingsCompact)(nil),             // 75: management.DNSSettingsCompact
-	(*RouteRaw)(nil),                       // 76: management.RouteRaw
-	(*NameServerGroupRaw)(nil),             // 77: management.NameServerGroupRaw
-	(*NetworkResourceRaw)(nil),             // 78: management.NetworkResourceRaw
-	(*NetworkRouterList)(nil),              // 79: management.NetworkRouterList
-	(*NetworkRouterEntry)(nil),             // 80: management.NetworkRouterEntry
-	(*PolicyIds)(nil),                      // 81: management.PolicyIds
-	(*UserIDList)(nil),                     // 82: management.UserIDList
-	(*PeerIndexSet)(nil),                   // 83: management.PeerIndexSet
-	nil,                                    // 84: management.SSHAuth.MachineUsersEntry
-	(*PortInfo_Range)(nil),                 // 85: management.PortInfo.Range
-	nil,                                    // 86: management.NetworkMapComponentsFull.RoutersMapEntry
-	nil,                                    // 87: management.NetworkMapComponentsFull.ResourcePoliciesMapEntry
-	nil,                                    // 88: management.NetworkMapComponentsFull.GroupIdToUserIdsEntry
-	nil,                                    // 89: management.NetworkMapComponentsFull.PostureFailedPeersEntry
-	nil,                                    // 90: management.PolicyCompact.AuthorizedGroupsEntry
-	(*timestamppb.Timestamp)(nil),          // 91: google.protobuf.Timestamp
-	(*durationpb.Duration)(nil),            // 92: 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
+	(*RemotePeerConfig)(nil),               // 40: management.RemotePeerConfig
+	(*SSHConfig)(nil),                      // 41: management.SSHConfig
+	(*DeviceAuthorizationFlowRequest)(nil), // 42: management.DeviceAuthorizationFlowRequest
+	(*DeviceAuthorizationFlow)(nil),        // 43: management.DeviceAuthorizationFlow
+	(*PKCEAuthorizationFlowRequest)(nil),   // 44: management.PKCEAuthorizationFlowRequest
+	(*PKCEAuthorizationFlow)(nil),          // 45: management.PKCEAuthorizationFlow
+	(*ProviderConfig)(nil),                 // 46: management.ProviderConfig
+	(*Route)(nil),                          // 47: management.Route
+	(*DNSConfig)(nil),                      // 48: management.DNSConfig
+	(*CustomZone)(nil),                     // 49: management.CustomZone
+	(*SimpleRecord)(nil),                   // 50: management.SimpleRecord
+	(*NameServerGroup)(nil),                // 51: management.NameServerGroup
+	(*NameServer)(nil),                     // 52: management.NameServer
+	(*FirewallRule)(nil),                   // 53: management.FirewallRule
+	(*NetworkAddress)(nil),                 // 54: management.NetworkAddress
+	(*Checks)(nil),                         // 55: management.Checks
+	(*PortInfo)(nil),                       // 56: management.PortInfo
+	(*RouteFirewallRule)(nil),              // 57: management.RouteFirewallRule
+	(*ForwardingRule)(nil),                 // 58: management.ForwardingRule
+	(*ExposeServiceRequest)(nil),           // 59: management.ExposeServiceRequest
+	(*ExposeServiceResponse)(nil),          // 60: management.ExposeServiceResponse
+	(*RenewExposeRequest)(nil),             // 61: management.RenewExposeRequest
+	(*RenewExposeResponse)(nil),            // 62: management.RenewExposeResponse
+	(*StopExposeRequest)(nil),              // 63: management.StopExposeRequest
+	(*StopExposeResponse)(nil),             // 64: management.StopExposeResponse
+	(*NetworkMapEnvelope)(nil),             // 65: management.NetworkMapEnvelope
+	(*NetworkMapComponentsFull)(nil),       // 66: management.NetworkMapComponentsFull
+	(*ProxyPatch)(nil),                     // 67: management.ProxyPatch
+	(*AccountSettingsCompact)(nil),         // 68: management.AccountSettingsCompact
+	(*AccountNetwork)(nil),                 // 69: management.AccountNetwork
+	(*NetworkMapComponentsDelta)(nil),      // 70: management.NetworkMapComponentsDelta
+	(*PeerCompact)(nil),                    // 71: management.PeerCompact
+	(*PolicyCompact)(nil),                  // 72: management.PolicyCompact
+	(*ResourceCompact)(nil),                // 73: management.ResourceCompact
+	(*UserNameList)(nil),                   // 74: management.UserNameList
+	(*GroupCompact)(nil),                   // 75: management.GroupCompact
+	(*DNSSettingsCompact)(nil),             // 76: management.DNSSettingsCompact
+	(*RouteRaw)(nil),                       // 77: management.RouteRaw
+	(*NameServerGroupRaw)(nil),             // 78: management.NameServerGroupRaw
+	(*NetworkResourceRaw)(nil),             // 79: management.NetworkResourceRaw
+	(*NetworkRouterList)(nil),              // 80: management.NetworkRouterList
+	(*NetworkRouterEntry)(nil),             // 81: management.NetworkRouterEntry
+	(*PolicyIds)(nil),                      // 82: management.PolicyIds
+	(*UserIDList)(nil),                     // 83: management.UserIDList
+	(*PeerIndexSet)(nil),                   // 84: management.PeerIndexSet
+	nil,                                    // 85: management.SSHAuth.MachineUsersEntry
+	(*PortInfo_Range)(nil),                 // 86: management.PortInfo.Range
+	nil,                                    // 87: management.NetworkMapComponentsFull.RoutersMapEntry
+	nil,                                    // 88: management.NetworkMapComponentsFull.ResourcePoliciesMapEntry
+	nil,                                    // 89: management.NetworkMapComponentsFull.GroupIdToUserIdsEntry
+	nil,                                    // 90: management.NetworkMapComponentsFull.PostureFailedPeersEntry
+	nil,                                    // 91: management.PolicyCompact.AuthorizedGroupsEntry
+	(*timestamppb.Timestamp)(nil),          // 92: google.protobuf.Timestamp
+	(*durationpb.Duration)(nil),            // 93: 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
-	39,  // 6: management.SyncResponse.remotePeers:type_name -> management.RemotePeerConfig
-	36,  // 7: management.SyncResponse.NetworkMap:type_name -> management.NetworkMap
-	54,  // 8: management.SyncResponse.Checks:type_name -> management.Checks
-	91,  // 9: management.SyncResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
-	64,  // 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
-	53,  // 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
+	40,  // 6: management.SyncResponse.remotePeers:type_name -> management.RemotePeerConfig
+	37,  // 7: management.SyncResponse.NetworkMap:type_name -> management.NetworkMap
+	55,  // 8: management.SyncResponse.Checks:type_name -> management.Checks
+	92,  // 9: management.SyncResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
+	65,  // 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
+	54,  // 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
-	54,  // 21: management.LoginResponse.Checks:type_name -> management.Checks
-	91,  // 22: management.LoginResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
-	21,  // 23: management.ExtendAuthSessionRequest.meta:type_name -> management.PeerSystemMeta
-	91,  // 24: management.ExtendAuthSessionResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
-	91,  // 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
-	92,  // 33: management.FlowConfig.interval:type_name -> google.protobuf.Duration
-	28,  // 34: management.ProtectedHostConfig.hostConfig:type_name -> management.HostConfig
-	40,  // 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
-	39,  // 38: management.NetworkMap.remotePeers:type_name -> management.RemotePeerConfig
-	46,  // 39: management.NetworkMap.Routes:type_name -> management.Route
-	47,  // 40: management.NetworkMap.DNSConfig:type_name -> management.DNSConfig
-	39,  // 41: management.NetworkMap.offlinePeers:type_name -> management.RemotePeerConfig
-	52,  // 42: management.NetworkMap.FirewallRules:type_name -> management.FirewallRule
-	56,  // 43: management.NetworkMap.routesFirewallRules:type_name -> management.RouteFirewallRule
-	57,  // 44: management.NetworkMap.forwardingRules:type_name -> management.ForwardingRule
-	37,  // 45: management.NetworkMap.sshAuth:type_name -> management.SSHAuth
-	84,  // 46: management.SSHAuth.machine_users:type_name -> management.SSHAuth.MachineUsersEntry
-	40,  // 47: management.RemotePeerConfig.sshConfig:type_name -> management.SSHConfig
-	32,  // 48: management.SSHConfig.jwtConfig:type_name -> management.JWTConfig
-	7,   // 49: management.DeviceAuthorizationFlow.Provider:type_name -> management.DeviceAuthorizationFlow.provider
-	45,  // 50: management.DeviceAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig
-	45,  // 51: management.PKCEAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig
-	50,  // 52: management.DNSConfig.NameServerGroups:type_name -> management.NameServerGroup
-	48,  // 53: management.DNSConfig.CustomZones:type_name -> management.CustomZone
-	49,  // 54: management.CustomZone.Records:type_name -> management.SimpleRecord
-	51,  // 55: management.NameServerGroup.NameServers:type_name -> management.NameServer
-	3,   // 56: management.FirewallRule.Direction:type_name -> management.RuleDirection
-	4,   // 57: management.FirewallRule.Action:type_name -> management.RuleAction
-	2,   // 58: management.FirewallRule.Protocol:type_name -> management.RuleProtocol
-	55,  // 59: management.FirewallRule.PortInfo:type_name -> management.PortInfo
-	85,  // 60: management.PortInfo.range:type_name -> management.PortInfo.Range
-	4,   // 61: management.RouteFirewallRule.action:type_name -> management.RuleAction
-	2,   // 62: management.RouteFirewallRule.protocol:type_name -> management.RuleProtocol
-	55,  // 63: management.RouteFirewallRule.portInfo:type_name -> management.PortInfo
-	2,   // 64: management.ForwardingRule.protocol:type_name -> management.RuleProtocol
-	55,  // 65: management.ForwardingRule.destinationPort:type_name -> management.PortInfo
-	55,  // 66: management.ForwardingRule.translatedPort:type_name -> management.PortInfo
-	5,   // 67: management.ExposeServiceRequest.protocol:type_name -> management.ExposeProtocol
-	65,  // 68: management.NetworkMapEnvelope.full:type_name -> management.NetworkMapComponentsFull
-	69,  // 69: management.NetworkMapEnvelope.delta:type_name -> management.NetworkMapComponentsDelta
-	34,  // 70: management.NetworkMapComponentsFull.peer_config:type_name -> management.PeerConfig
-	68,  // 71: management.NetworkMapComponentsFull.network:type_name -> management.AccountNetwork
-	67,  // 72: management.NetworkMapComponentsFull.account_settings:type_name -> management.AccountSettingsCompact
-	75,  // 73: management.NetworkMapComponentsFull.dns_settings:type_name -> management.DNSSettingsCompact
-	70,  // 74: management.NetworkMapComponentsFull.peers:type_name -> management.PeerCompact
-	71,  // 75: management.NetworkMapComponentsFull.policies:type_name -> management.PolicyCompact
-	74,  // 76: management.NetworkMapComponentsFull.groups:type_name -> management.GroupCompact
-	76,  // 77: management.NetworkMapComponentsFull.routes:type_name -> management.RouteRaw
-	77,  // 78: management.NetworkMapComponentsFull.nameserver_groups:type_name -> management.NameServerGroupRaw
-	49,  // 79: management.NetworkMapComponentsFull.all_dns_records:type_name -> management.SimpleRecord
-	48,  // 80: management.NetworkMapComponentsFull.account_zones:type_name -> management.CustomZone
-	78,  // 81: management.NetworkMapComponentsFull.network_resources:type_name -> management.NetworkResourceRaw
-	86,  // 82: management.NetworkMapComponentsFull.routers_map:type_name -> management.NetworkMapComponentsFull.RoutersMapEntry
-	87,  // 83: management.NetworkMapComponentsFull.resource_policies_map:type_name -> management.NetworkMapComponentsFull.ResourcePoliciesMapEntry
-	88,  // 84: management.NetworkMapComponentsFull.group_id_to_user_ids:type_name -> management.NetworkMapComponentsFull.GroupIdToUserIdsEntry
-	89,  // 85: management.NetworkMapComponentsFull.posture_failed_peers:type_name -> management.NetworkMapComponentsFull.PostureFailedPeersEntry
-	66,  // 86: management.NetworkMapComponentsFull.proxy_patch:type_name -> management.ProxyPatch
-	39,  // 87: management.ProxyPatch.peers:type_name -> management.RemotePeerConfig
-	39,  // 88: management.ProxyPatch.offline_peers:type_name -> management.RemotePeerConfig
-	52,  // 89: management.ProxyPatch.firewall_rules:type_name -> management.FirewallRule
-	46,  // 90: management.ProxyPatch.routes:type_name -> management.Route
-	56,  // 91: management.ProxyPatch.route_firewall_rules:type_name -> management.RouteFirewallRule
-	57,  // 92: management.ProxyPatch.forwarding_rules:type_name -> management.ForwardingRule
-	4,   // 93: management.PolicyCompact.action:type_name -> management.RuleAction
-	2,   // 94: management.PolicyCompact.protocol:type_name -> management.RuleProtocol
-	85,  // 95: management.PolicyCompact.port_ranges:type_name -> management.PortInfo.Range
-	90,  // 96: management.PolicyCompact.authorized_groups:type_name -> management.PolicyCompact.AuthorizedGroupsEntry
-	72,  // 97: management.PolicyCompact.source_resource:type_name -> management.ResourceCompact
-	72,  // 98: management.PolicyCompact.destination_resource:type_name -> management.ResourceCompact
-	51,  // 99: management.NameServerGroupRaw.nameservers:type_name -> management.NameServer
-	80,  // 100: management.NetworkRouterList.entries:type_name -> management.NetworkRouterEntry
-	38,  // 101: management.SSHAuth.MachineUsersEntry.value:type_name -> management.MachineUserIndexes
-	79,  // 102: management.NetworkMapComponentsFull.RoutersMapEntry.value:type_name -> management.NetworkRouterList
-	81,  // 103: management.NetworkMapComponentsFull.ResourcePoliciesMapEntry.value:type_name -> management.PolicyIds
-	82,  // 104: management.NetworkMapComponentsFull.GroupIdToUserIdsEntry.value:type_name -> management.UserIDList
-	83,  // 105: management.NetworkMapComponentsFull.PostureFailedPeersEntry.value:type_name -> management.PeerIndexSet
-	73,  // 106: management.PolicyCompact.AuthorizedGroupsEntry.value:type_name -> management.UserNameList
-	8,   // 107: management.ManagementService.Login:input_type -> management.EncryptedMessage
-	8,   // 108: management.ManagementService.Sync:input_type -> management.EncryptedMessage
-	26,  // 109: management.ManagementService.GetServerKey:input_type -> management.Empty
-	26,  // 110: management.ManagementService.isHealthy:input_type -> management.Empty
-	8,   // 111: management.ManagementService.GetDeviceAuthorizationFlow:input_type -> management.EncryptedMessage
-	8,   // 112: management.ManagementService.GetPKCEAuthorizationFlow:input_type -> management.EncryptedMessage
-	8,   // 113: management.ManagementService.SyncMeta:input_type -> management.EncryptedMessage
-	8,   // 114: management.ManagementService.Logout:input_type -> management.EncryptedMessage
-	8,   // 115: management.ManagementService.Job:input_type -> management.EncryptedMessage
-	8,   // 116: management.ManagementService.ExtendAuthSession:input_type -> management.EncryptedMessage
-	8,   // 117: management.ManagementService.CreateExpose:input_type -> management.EncryptedMessage
-	8,   // 118: management.ManagementService.RenewExpose:input_type -> management.EncryptedMessage
-	8,   // 119: management.ManagementService.StopExpose:input_type -> management.EncryptedMessage
-	8,   // 120: management.ManagementService.Login:output_type -> management.EncryptedMessage
-	8,   // 121: management.ManagementService.Sync:output_type -> management.EncryptedMessage
-	25,  // 122: management.ManagementService.GetServerKey:output_type -> management.ServerKeyResponse
-	26,  // 123: management.ManagementService.isHealthy:output_type -> management.Empty
-	8,   // 124: management.ManagementService.GetDeviceAuthorizationFlow:output_type -> management.EncryptedMessage
-	8,   // 125: management.ManagementService.GetPKCEAuthorizationFlow:output_type -> management.EncryptedMessage
-	26,  // 126: management.ManagementService.SyncMeta:output_type -> management.Empty
-	26,  // 127: management.ManagementService.Logout:output_type -> management.Empty
-	8,   // 128: management.ManagementService.Job:output_type -> management.EncryptedMessage
-	8,   // 129: management.ManagementService.ExtendAuthSession:output_type -> management.EncryptedMessage
-	8,   // 130: management.ManagementService.CreateExpose:output_type -> management.EncryptedMessage
-	8,   // 131: management.ManagementService.RenewExpose:output_type -> management.EncryptedMessage
-	8,   // 132: management.ManagementService.StopExpose:output_type -> management.EncryptedMessage
-	120, // [120:133] is the sub-list for method output_type
-	107, // [107:120] is the sub-list for method input_type
-	107, // [107:107] is the sub-list for extension type_name
-	107, // [107:107] is the sub-list for extension extendee
-	0,   // [0:107] 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
+	55,  // 21: management.LoginResponse.Checks:type_name -> management.Checks
+	92,  // 22: management.LoginResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
+	22,  // 23: management.ExtendAuthSessionRequest.meta:type_name -> management.PeerSystemMeta
+	92,  // 24: management.ExtendAuthSessionResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
+	92,  // 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
+	93,  // 33: management.FlowConfig.interval:type_name -> google.protobuf.Duration
+	29,  // 34: management.ProtectedHostConfig.hostConfig:type_name -> management.HostConfig
+	41,  // 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
+	40,  // 38: management.NetworkMap.remotePeers:type_name -> management.RemotePeerConfig
+	47,  // 39: management.NetworkMap.Routes:type_name -> management.Route
+	48,  // 40: management.NetworkMap.DNSConfig:type_name -> management.DNSConfig
+	40,  // 41: management.NetworkMap.offlinePeers:type_name -> management.RemotePeerConfig
+	53,  // 42: management.NetworkMap.FirewallRules:type_name -> management.FirewallRule
+	57,  // 43: management.NetworkMap.routesFirewallRules:type_name -> management.RouteFirewallRule
+	58,  // 44: management.NetworkMap.forwardingRules:type_name -> management.ForwardingRule
+	38,  // 45: management.NetworkMap.sshAuth:type_name -> management.SSHAuth
+	85,  // 46: management.SSHAuth.machine_users:type_name -> management.SSHAuth.MachineUsersEntry
+	41,  // 47: management.RemotePeerConfig.sshConfig:type_name -> management.SSHConfig
+	2,   // 48: management.RemotePeerConfig.lazyState:type_name -> management.LazyState
+	33,  // 49: management.SSHConfig.jwtConfig:type_name -> management.JWTConfig
+	8,   // 50: management.DeviceAuthorizationFlow.Provider:type_name -> management.DeviceAuthorizationFlow.provider
+	46,  // 51: management.DeviceAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig
+	46,  // 52: management.PKCEAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig
+	51,  // 53: management.DNSConfig.NameServerGroups:type_name -> management.NameServerGroup
+	49,  // 54: management.DNSConfig.CustomZones:type_name -> management.CustomZone
+	50,  // 55: management.CustomZone.Records:type_name -> management.SimpleRecord
+	52,  // 56: management.NameServerGroup.NameServers:type_name -> management.NameServer
+	4,   // 57: management.FirewallRule.Direction:type_name -> management.RuleDirection
+	5,   // 58: management.FirewallRule.Action:type_name -> management.RuleAction
+	3,   // 59: management.FirewallRule.Protocol:type_name -> management.RuleProtocol
+	56,  // 60: management.FirewallRule.PortInfo:type_name -> management.PortInfo
+	86,  // 61: management.PortInfo.range:type_name -> management.PortInfo.Range
+	5,   // 62: management.RouteFirewallRule.action:type_name -> management.RuleAction
+	3,   // 63: management.RouteFirewallRule.protocol:type_name -> management.RuleProtocol
+	56,  // 64: management.RouteFirewallRule.portInfo:type_name -> management.PortInfo
+	3,   // 65: management.ForwardingRule.protocol:type_name -> management.RuleProtocol
+	56,  // 66: management.ForwardingRule.destinationPort:type_name -> management.PortInfo
+	56,  // 67: management.ForwardingRule.translatedPort:type_name -> management.PortInfo
+	6,   // 68: management.ExposeServiceRequest.protocol:type_name -> management.ExposeProtocol
+	66,  // 69: management.NetworkMapEnvelope.full:type_name -> management.NetworkMapComponentsFull
+	70,  // 70: management.NetworkMapEnvelope.delta:type_name -> management.NetworkMapComponentsDelta
+	35,  // 71: management.NetworkMapComponentsFull.peer_config:type_name -> management.PeerConfig
+	69,  // 72: management.NetworkMapComponentsFull.network:type_name -> management.AccountNetwork
+	68,  // 73: management.NetworkMapComponentsFull.account_settings:type_name -> management.AccountSettingsCompact
+	76,  // 74: management.NetworkMapComponentsFull.dns_settings:type_name -> management.DNSSettingsCompact
+	71,  // 75: management.NetworkMapComponentsFull.peers:type_name -> management.PeerCompact
+	72,  // 76: management.NetworkMapComponentsFull.policies:type_name -> management.PolicyCompact
+	75,  // 77: management.NetworkMapComponentsFull.groups:type_name -> management.GroupCompact
+	77,  // 78: management.NetworkMapComponentsFull.routes:type_name -> management.RouteRaw
+	78,  // 79: management.NetworkMapComponentsFull.nameserver_groups:type_name -> management.NameServerGroupRaw
+	50,  // 80: management.NetworkMapComponentsFull.all_dns_records:type_name -> management.SimpleRecord
+	49,  // 81: management.NetworkMapComponentsFull.account_zones:type_name -> management.CustomZone
+	79,  // 82: management.NetworkMapComponentsFull.network_resources:type_name -> management.NetworkResourceRaw
+	87,  // 83: management.NetworkMapComponentsFull.routers_map:type_name -> management.NetworkMapComponentsFull.RoutersMapEntry
+	88,  // 84: management.NetworkMapComponentsFull.resource_policies_map:type_name -> management.NetworkMapComponentsFull.ResourcePoliciesMapEntry
+	89,  // 85: management.NetworkMapComponentsFull.group_id_to_user_ids:type_name -> management.NetworkMapComponentsFull.GroupIdToUserIdsEntry
+	90,  // 86: management.NetworkMapComponentsFull.posture_failed_peers:type_name -> management.NetworkMapComponentsFull.PostureFailedPeersEntry
+	67,  // 87: management.NetworkMapComponentsFull.proxy_patch:type_name -> management.ProxyPatch
+	40,  // 88: management.ProxyPatch.peers:type_name -> management.RemotePeerConfig
+	40,  // 89: management.ProxyPatch.offline_peers:type_name -> management.RemotePeerConfig
+	53,  // 90: management.ProxyPatch.firewall_rules:type_name -> management.FirewallRule
+	47,  // 91: management.ProxyPatch.routes:type_name -> management.Route
+	57,  // 92: management.ProxyPatch.route_firewall_rules:type_name -> management.RouteFirewallRule
+	58,  // 93: management.ProxyPatch.forwarding_rules:type_name -> management.ForwardingRule
+	5,   // 94: management.PolicyCompact.action:type_name -> management.RuleAction
+	3,   // 95: management.PolicyCompact.protocol:type_name -> management.RuleProtocol
+	86,  // 96: management.PolicyCompact.port_ranges:type_name -> management.PortInfo.Range
+	91,  // 97: management.PolicyCompact.authorized_groups:type_name -> management.PolicyCompact.AuthorizedGroupsEntry
+	73,  // 98: management.PolicyCompact.source_resource:type_name -> management.ResourceCompact
+	73,  // 99: management.PolicyCompact.destination_resource:type_name -> management.ResourceCompact
+	73,  // 100: management.GroupCompact.resources:type_name -> management.ResourceCompact
+	52,  // 101: management.NameServerGroupRaw.nameservers:type_name -> management.NameServer
+	81,  // 102: management.NetworkRouterList.entries:type_name -> management.NetworkRouterEntry
+	39,  // 103: management.SSHAuth.MachineUsersEntry.value:type_name -> management.MachineUserIndexes
+	80,  // 104: management.NetworkMapComponentsFull.RoutersMapEntry.value:type_name -> management.NetworkRouterList
+	82,  // 105: management.NetworkMapComponentsFull.ResourcePoliciesMapEntry.value:type_name -> management.PolicyIds
+	83,  // 106: management.NetworkMapComponentsFull.GroupIdToUserIdsEntry.value:type_name -> management.UserIDList
+	84,  // 107: management.NetworkMapComponentsFull.PostureFailedPeersEntry.value:type_name -> management.PeerIndexSet
+	74,  // 108: management.PolicyCompact.AuthorizedGroupsEntry.value:type_name -> management.UserNameList
+	9,   // 109: management.ManagementService.Login:input_type -> management.EncryptedMessage
+	9,   // 110: management.ManagementService.Sync:input_type -> management.EncryptedMessage
+	27,  // 111: management.ManagementService.GetServerKey:input_type -> management.Empty
+	27,  // 112: management.ManagementService.isHealthy:input_type -> management.Empty
+	9,   // 113: management.ManagementService.GetDeviceAuthorizationFlow:input_type -> management.EncryptedMessage
+	9,   // 114: management.ManagementService.GetPKCEAuthorizationFlow:input_type -> management.EncryptedMessage
+	9,   // 115: management.ManagementService.SyncMeta:input_type -> management.EncryptedMessage
+	9,   // 116: management.ManagementService.Logout:input_type -> management.EncryptedMessage
+	9,   // 117: management.ManagementService.Job:input_type -> management.EncryptedMessage
+	9,   // 118: management.ManagementService.ExtendAuthSession:input_type -> management.EncryptedMessage
+	9,   // 119: management.ManagementService.CreateExpose:input_type -> management.EncryptedMessage
+	9,   // 120: management.ManagementService.RenewExpose:input_type -> management.EncryptedMessage
+	9,   // 121: management.ManagementService.StopExpose:input_type -> management.EncryptedMessage
+	9,   // 122: management.ManagementService.Login:output_type -> management.EncryptedMessage
+	9,   // 123: management.ManagementService.Sync:output_type -> management.EncryptedMessage
+	26,  // 124: management.ManagementService.GetServerKey:output_type -> management.ServerKeyResponse
+	27,  // 125: management.ManagementService.isHealthy:output_type -> management.Empty
+	9,   // 126: management.ManagementService.GetDeviceAuthorizationFlow:output_type -> management.EncryptedMessage
+	9,   // 127: management.ManagementService.GetPKCEAuthorizationFlow:output_type -> management.EncryptedMessage
+	27,  // 128: management.ManagementService.SyncMeta:output_type -> management.Empty
+	27,  // 129: management.ManagementService.Logout:output_type -> management.Empty
+	9,   // 130: management.ManagementService.Job:output_type -> management.EncryptedMessage
+	9,   // 131: management.ManagementService.ExtendAuthSession:output_type -> management.EncryptedMessage
+	9,   // 132: management.ManagementService.CreateExpose:output_type -> management.EncryptedMessage
+	9,   // 133: management.ManagementService.RenewExpose:output_type -> management.EncryptedMessage
+	9,   // 134: management.ManagementService.StopExpose:output_type -> management.EncryptedMessage
+	122, // [122:135] is the sub-list for method output_type
+	109, // [109:122] is the sub-list for method input_type
+	109, // [109:109] is the sub-list for extension type_name
+	109, // [109:109] is the sub-list for extension extendee
+	0,   // [0:109] is the sub-list for field type_name
 }
 
 func init() { file_management_proto_init() }
@@ -9052,7 +9160,7 @@ func file_management_proto_init() {
 		File: protoimpl.DescBuilder{
 			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
 			RawDescriptor: file_management_proto_rawDesc,
-			NumEnums:      8,
+			NumEnums:      9,
 			NumMessages:   83,
 			NumExtensions: 0,
 			NumServices:   1,
diff --git a/shared/management/proto/management.proto b/shared/management/proto/management.proto
index 6b8556414..24ff5bf37 100644
--- a/shared/management/proto/management.proto
+++ b/shared/management/proto/management.proto
@@ -501,6 +501,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.
@@ -1016,6 +1032,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
@@ -1073,13 +1094,12 @@ message PolicyCompact {
 // ResourceCompact mirrors types.Resource. Used by PolicyCompact to carry
 // rule.SourceResource / rule.DestinationResource when the rule targets a
 // specific resource (typically a peer) rather than groups.
-// peer_index_set tells whether peer_index is valid (proto3 uint32 cannot
-// disambiguate "0" from "unset"); set only when type == "peer".
 message ResourceCompact {
   string type = 1;
   bool peer_index_set = 2;
   uint32 peer_index = 3;
-  reserved 4; // future: host/subnet/domain references when needed
+  reserved 4;
+  string id = 5; // public id for domain/host/subnet resources
 }
 
 // UserNameList is a list of local-user names — used as the value type in
@@ -1103,6 +1123,8 @@ message GroupCompact {
   // groups lose that property and the two sides expand policy
   // destinations differently.
   bool is_all = 3;
+
+  repeated ResourceCompact resources = 4;
 }
 
 // DNSSettingsCompact mirrors types.DNSSettings.
diff --git a/shared/management/types/firewall_helpers.go b/shared/management/types/firewall_helpers.go
index 6e43af33e..9357d24a9 100644
--- a/shared/management/types/firewall_helpers.go
+++ b/shared/management/types/firewall_helpers.go
@@ -3,6 +3,7 @@ package types
 import (
 	"strconv"
 
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	"github.com/netbirdio/netbird/version"
 )
 
@@ -23,31 +24,9 @@ type supportedFeatures struct {
 
 type LookupMap map[string]struct{}
 
-func PolicyRuleImpliesLegacySSH(rule *PolicyRule) bool {
-	return rule.Protocol == PolicyRuleProtocolALL || (rule.Protocol == PolicyRuleProtocolTCP && (portsIncludesSSH(rule.Ports) || portRangeIncludesSSH(rule.PortRanges)))
-}
-
-func portRangeIncludesSSH(portRanges []RulePortRange) bool {
-	for _, pr := range portRanges {
-		if (pr.Start <= defaultSSHPortNumber && pr.End >= defaultSSHPortNumber) || (pr.Start <= nativeSSHPortNumber && pr.End >= nativeSSHPortNumber) {
-			return true
-		}
-	}
-	return false
-}
-
-func portsIncludesSSH(ports []string) bool {
-	for _, port := range ports {
-		if port == defaultSSHPortString || port == nativeSSHPortString {
-			return true
-		}
-	}
-	return false
-}
-
 // ExpandPortsAndRanges expands Ports and PortRanges of a rule into individual firewall rules.
-func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *ComponentPeer) []*FirewallRule {
-	features := peerSupportedFirewallFeatures(peer.AgentVersion)
+func ExpandPortsAndRanges(base FirewallRule, rule *nmdata.PolicyRule, peer *nmdata.Peer) []*FirewallRule {
+	features := peerSupportedFirewallFeatures(peer.Meta.WtVersion)
 
 	var expanded []*FirewallRule
 
@@ -64,7 +43,7 @@ func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *ComponentPe
 		fr := base
 
 		if features.portRanges {
-			fr.PortRange = portRange
+			fr.PortRange = RulePortRange{Start: portRange.Start, End: portRange.End}
 		} else {
 			if portRange.Start != portRange.End {
 				continue
@@ -74,7 +53,7 @@ func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *ComponentPe
 		expanded = append(expanded, &fr)
 	}
 
-	if shouldCheckRulesForNativeSSH(features.nativeSSH, rule, peer) || rule.Protocol == PolicyRuleProtocolNetbirdSSH {
+	if shouldCheckRulesForNativeSSH(features.nativeSSH, rule, peer) || rule.Protocol == string(PolicyRuleProtocolNetbirdSSH) {
 		expanded = addNativeSSHRule(base, expanded)
 	}
 
@@ -104,8 +83,8 @@ func isPortInRule(portString string, portInt uint16, rule *FirewallRule) bool {
 	return rule.Port == portString || (rule.PortRange.Start <= portInt && portInt <= rule.PortRange.End)
 }
 
-func shouldCheckRulesForNativeSSH(supportsNative bool, rule *PolicyRule, peer *ComponentPeer) bool {
-	return supportsNative && peer.SSHEnabled && peer.ServerSSHAllowed && rule.Protocol == PolicyRuleProtocolTCP
+func shouldCheckRulesForNativeSSH(supportsNative bool, rule *nmdata.PolicyRule, peer *nmdata.Peer) bool {
+	return supportsNative && peer.SSHEnabled && peer.Meta.Flags.ServerSSHAllowed && rule.Protocol == string(PolicyRuleProtocolTCP)
 }
 
 func peerSupportedFirewallFeatures(peerVer string) supportedFeatures {
diff --git a/shared/management/types/firewall_rule.go b/shared/management/types/firewall_rule.go
index 67cb581a2..2efedf625 100644
--- a/shared/management/types/firewall_rule.go
+++ b/shared/management/types/firewall_rule.go
@@ -10,6 +10,7 @@ import (
 	log "github.com/sirupsen/logrus"
 
 	nbroute "github.com/netbirdio/netbird/route"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 )
 
 const (
@@ -50,7 +51,7 @@ func (r *FirewallRule) Equal(other *FirewallRule) bool {
 // For static routes, source ranges match the destination family (v4 or v6).
 // For dynamic routes (domain-based), separate v4 and v6 rules are generated
 // so the routing peer's forwarding chain allows both address families.
-func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*ComponentPeer, direction int, includeIPv6 bool) []*RouteFirewallRule {
+func GenerateRouteFirewallRules(ctx context.Context, route *nmdata.Route, rule *nmdata.PolicyRule, groupPeers []*nmdata.Peer, direction int, includeIPv6 bool) []*RouteFirewallRule {
 	rulesExists := make(map[string]struct{})
 	rules := make([]*RouteFirewallRule, 0)
 
@@ -71,11 +72,11 @@ func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule
 
 	baseRule := RouteFirewallRule{
 		PolicyID:     rule.PolicyID,
-		RouteID:      route.ID,
+		RouteID:      nbroute.ID(route.ID),
 		SourceRanges: sourceRanges,
-		Action:       string(rule.Action),
+		Action:       rule.Action,
 		Destination:  route.Network.String(),
-		Protocol:     string(rule.Protocol),
+		Protocol:     rule.Protocol,
 		Domains:      route.Domains,
 		IsDynamic:    route.IsDynamic(),
 	}
@@ -93,7 +94,7 @@ func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule
 		v6Rule.SourceRanges = v6Sources
 		if isDefaultV4 {
 			v6Rule.Destination = "::/0"
-			v6Rule.RouteID = route.ID + "-v6-default"
+			v6Rule.RouteID = nbroute.ID(route.ID + "-v6-default")
 		}
 		if len(rule.Ports) == 0 {
 			rules = append(rules, generateRulesWithPortRanges(v6Rule, rule, rulesExists)...)
@@ -106,7 +107,7 @@ func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule
 }
 
 // splitPeerSourcesByFamily separates peer IPs into v4 (/32) and v6 (/128) source ranges.
-func splitPeerSourcesByFamily(groupPeers []*ComponentPeer) (v4, v6 []string) {
+func splitPeerSourcesByFamily(groupPeers []*nmdata.Peer) (v4, v6 []string) {
 	v4 = make([]string, 0, len(groupPeers))
 	v6 = make([]string, 0, len(groupPeers))
 	for _, peer := range groupPeers {
@@ -122,7 +123,7 @@ func splitPeerSourcesByFamily(groupPeers []*ComponentPeer) (v4, v6 []string) {
 }
 
 // generateRulesForPeer generates rules for a given peer based on ports and port ranges.
-func generateRulesWithPortRanges(baseRule RouteFirewallRule, rule *PolicyRule, rulesExists map[string]struct{}) []*RouteFirewallRule {
+func generateRulesWithPortRanges(baseRule RouteFirewallRule, rule *nmdata.PolicyRule, rulesExists map[string]struct{}) []*RouteFirewallRule {
 	rules := make([]*RouteFirewallRule, 0)
 
 	ruleIDBase := generateRuleIDBase(rule, baseRule)
@@ -138,7 +139,7 @@ func generateRulesWithPortRanges(baseRule RouteFirewallRule, rule *PolicyRule, r
 				if _, ok := rulesExists[ruleID]; !ok {
 					rulesExists[ruleID] = struct{}{}
 					pr := baseRule
-					pr.PortRange = portRange
+					pr.PortRange = RulePortRange{Start: portRange.Start, End: portRange.End}
 					rules = append(rules, &pr)
 				}
 			}
@@ -150,7 +151,7 @@ func generateRulesWithPortRanges(baseRule RouteFirewallRule, rule *PolicyRule, r
 }
 
 // generateRulesWithPorts generates rules when specific ports are provided.
-func generateRulesWithPorts(ctx context.Context, baseRule RouteFirewallRule, rule *PolicyRule, rulesExists map[string]struct{}) []*RouteFirewallRule {
+func generateRulesWithPorts(ctx context.Context, baseRule RouteFirewallRule, rule *nmdata.PolicyRule, rulesExists map[string]struct{}) []*RouteFirewallRule {
 	rules := make([]*RouteFirewallRule, 0)
 	ruleIDBase := generateRuleIDBase(rule, baseRule)
 
@@ -176,6 +177,6 @@ func generateRulesWithPorts(ctx context.Context, baseRule RouteFirewallRule, rul
 }
 
 // generateRuleIDBase generates the base rule ID for checking duplicates.
-func generateRuleIDBase(rule *PolicyRule, baseRule RouteFirewallRule) string {
+func generateRuleIDBase(rule *nmdata.PolicyRule, baseRule RouteFirewallRule) string {
 	return rule.ID + strings.Join(baseRule.SourceRanges, ",") + strconv.Itoa(FirewallRuleDirectionIN) + baseRule.Protocol + baseRule.Action
 }
diff --git a/shared/management/types/firewall_rule_test.go b/shared/management/types/firewall_rule_test.go
index c21cfa2df..96fef3bd9 100644
--- a/shared/management/types/firewall_rule_test.go
+++ b/shared/management/types/firewall_rule_test.go
@@ -8,12 +8,12 @@ import (
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 
-	"github.com/netbirdio/netbird/route"
 	"github.com/netbirdio/netbird/shared/management/domain"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 )
 
 func TestSplitPeerSourcesByFamily(t *testing.T) {
-	peers := []*ComponentPeer{
+	peers := []*nmdata.Peer{
 		{
 			IP:   netip.MustParseAddr("100.64.0.1"),
 			IPv6: netip.MustParseAddr("fd00::1"),
@@ -35,7 +35,7 @@ func TestSplitPeerSourcesByFamily(t *testing.T) {
 }
 
 func TestGenerateRouteFirewallRules_V4Route(t *testing.T) {
-	peers := []*ComponentPeer{
+	peers := []*nmdata.Peer{
 		{
 			IP:   netip.MustParseAddr("100.64.0.1"),
 			IPv6: netip.MustParseAddr("fd00::1"),
@@ -45,15 +45,15 @@ func TestGenerateRouteFirewallRules_V4Route(t *testing.T) {
 		},
 	}
 
-	r := &route.Route{
+	r := &nmdata.Route{
 		ID:      "route1",
 		Network: netip.MustParsePrefix("10.0.0.0/24"),
 	}
-	rule := &PolicyRule{
+	rule := &nmdata.PolicyRule{
 		PolicyID: "policy1",
 		ID:       "rule1",
-		Action:   PolicyTrafficActionAccept,
-		Protocol: PolicyRuleProtocolALL,
+		Action:   string(PolicyTrafficActionAccept),
+		Protocol: string(PolicyRuleProtocolALL),
 	}
 
 	rules := GenerateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, true)
@@ -64,7 +64,7 @@ func TestGenerateRouteFirewallRules_V4Route(t *testing.T) {
 }
 
 func TestGenerateRouteFirewallRules_V6Route(t *testing.T) {
-	peers := []*ComponentPeer{
+	peers := []*nmdata.Peer{
 		{
 			IP:   netip.MustParseAddr("100.64.0.1"),
 			IPv6: netip.MustParseAddr("fd00::1"),
@@ -74,15 +74,15 @@ func TestGenerateRouteFirewallRules_V6Route(t *testing.T) {
 		},
 	}
 
-	r := &route.Route{
+	r := &nmdata.Route{
 		ID:      "route1",
 		Network: netip.MustParsePrefix("2001:db8::/32"),
 	}
-	rule := &PolicyRule{
+	rule := &nmdata.PolicyRule{
 		PolicyID: "policy1",
 		ID:       "rule1",
-		Action:   PolicyTrafficActionAccept,
-		Protocol: PolicyRuleProtocolALL,
+		Action:   string(PolicyTrafficActionAccept),
+		Protocol: string(PolicyRuleProtocolALL),
 	}
 
 	rules := GenerateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, true)
@@ -92,7 +92,7 @@ func TestGenerateRouteFirewallRules_V6Route(t *testing.T) {
 }
 
 func TestGenerateRouteFirewallRules_DynamicRoute_DualStack(t *testing.T) {
-	peers := []*ComponentPeer{
+	peers := []*nmdata.Peer{
 		{
 			IP:   netip.MustParseAddr("100.64.0.1"),
 			IPv6: netip.MustParseAddr("fd00::1"),
@@ -102,16 +102,16 @@ func TestGenerateRouteFirewallRules_DynamicRoute_DualStack(t *testing.T) {
 		},
 	}
 
-	r := &route.Route{
+	r := &nmdata.Route{
 		ID:          "route1",
-		NetworkType: route.DomainNetwork,
+		NetworkType: nmdata.NetworkTypeDomain,
 		Domains:     domain.List{"example.com"},
 	}
-	rule := &PolicyRule{
+	rule := &nmdata.PolicyRule{
 		PolicyID: "policy1",
 		ID:       "rule1",
-		Action:   PolicyTrafficActionAccept,
-		Protocol: PolicyRuleProtocolALL,
+		Action:   string(PolicyTrafficActionAccept),
+		Protocol: string(PolicyRuleProtocolALL),
 	}
 
 	rules := GenerateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, true)
@@ -125,21 +125,21 @@ func TestGenerateRouteFirewallRules_DynamicRoute_DualStack(t *testing.T) {
 }
 
 func TestGenerateRouteFirewallRules_DynamicRoute_NoV6Peers(t *testing.T) {
-	peers := []*ComponentPeer{
+	peers := []*nmdata.Peer{
 		{IP: netip.MustParseAddr("100.64.0.1")},
 		{IP: netip.MustParseAddr("100.64.0.2")},
 	}
 
-	r := &route.Route{
+	r := &nmdata.Route{
 		ID:          "route1",
-		NetworkType: route.DomainNetwork,
+		NetworkType: nmdata.NetworkTypeDomain,
 		Domains:     domain.List{"example.com"},
 	}
-	rule := &PolicyRule{
+	rule := &nmdata.PolicyRule{
 		PolicyID: "policy1",
 		ID:       "rule1",
-		Action:   PolicyTrafficActionAccept,
-		Protocol: PolicyRuleProtocolALL,
+		Action:   string(PolicyTrafficActionAccept),
+		Protocol: string(PolicyRuleProtocolALL),
 	}
 
 	rules := GenerateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, true)
@@ -149,7 +149,7 @@ func TestGenerateRouteFirewallRules_DynamicRoute_NoV6Peers(t *testing.T) {
 }
 
 func TestGenerateRouteFirewallRules_IncludeIPv6False(t *testing.T) {
-	peers := []*ComponentPeer{
+	peers := []*nmdata.Peer{
 		{
 			IP:   netip.MustParseAddr("100.64.0.1"),
 			IPv6: netip.MustParseAddr("fd00::1"),
@@ -161,15 +161,15 @@ func TestGenerateRouteFirewallRules_IncludeIPv6False(t *testing.T) {
 	}
 
 	t.Run("v6 route excluded", func(t *testing.T) {
-		r := &route.Route{
+		r := &nmdata.Route{
 			ID:      "route1",
 			Network: netip.MustParsePrefix("2001:db8::/32"),
 		}
-		rule := &PolicyRule{
+		rule := &nmdata.PolicyRule{
 			PolicyID: "policy1",
 			ID:       "rule1",
-			Action:   PolicyTrafficActionAccept,
-			Protocol: PolicyRuleProtocolALL,
+			Action:   string(PolicyTrafficActionAccept),
+			Protocol: string(PolicyRuleProtocolALL),
 		}
 
 		rules := GenerateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, false)
@@ -177,16 +177,16 @@ func TestGenerateRouteFirewallRules_IncludeIPv6False(t *testing.T) {
 	})
 
 	t.Run("dynamic route only v4", func(t *testing.T) {
-		r := &route.Route{
+		r := &nmdata.Route{
 			ID:          "route1",
-			NetworkType: route.DomainNetwork,
+			NetworkType: nmdata.NetworkTypeDomain,
 			Domains:     domain.List{"example.com"},
 		}
-		rule := &PolicyRule{
+		rule := &nmdata.PolicyRule{
 			PolicyID: "policy1",
 			ID:       "rule1",
-			Action:   PolicyTrafficActionAccept,
-			Protocol: PolicyRuleProtocolALL,
+			Action:   string(PolicyTrafficActionAccept),
+			Protocol: string(PolicyRuleProtocolALL),
 		}
 
 		rules := GenerateRouteFirewallRules(context.Background(), r, rule, peers, FirewallRuleDirectionIN, false)
diff --git a/shared/management/types/network.go b/shared/management/types/network.go
index 34ce60436..1269bac4c 100644
--- a/shared/management/types/network.go
+++ b/shared/management/types/network.go
@@ -1,47 +1,28 @@
 package types
 
 import (
-	"encoding/binary"
-	"fmt"
-	"math/rand"
 	"net"
-	"net/netip"
-	"slices"
-	"sync"
-	"time"
 
-	"github.com/c-robinson/iplib"
-	"github.com/rs/xid"
 	"golang.org/x/exp/maps"
 
 	nbdns "github.com/netbirdio/netbird/dns"
-	"github.com/netbirdio/netbird/route"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 	"github.com/netbirdio/netbird/shared/management/proto"
-	"github.com/netbirdio/netbird/shared/management/status"
 )
 
 const (
-	// SubnetSize is a size of the subnet of the global network, e.g.  100.77.0.0/16
-	SubnetSize = 16
-	// NetSize is a global network size 100.64.0.0/10
-	NetSize = 10
-
 	// AllowedIPsFormat generates Wireguard AllowedIPs format (e.g. 100.64.30.1/32)
 	AllowedIPsFormat = "%s/32"
 	// AllowedIPsV6Format generates AllowedIPs format for v6 (e.g. fd12:3456:7890::1/128)
 	AllowedIPsV6Format = "%s/128"
-
-	// IPv6SubnetSize is the prefix length of per-account IPv6 subnets.
-	// Each account gets a /64 from its unique /48 ULA prefix.
-	IPv6SubnetSize = 64
 )
 
 type NetworkMap struct {
-	Peers               []*ComponentPeer
-	Network             *Network
-	Routes              []*route.Route
+	Peers               []*nmdata.Peer
+	Network             *nmdata.Network
+	Routes              []*nmdata.Route
 	DNSConfig           nbdns.Config
-	OfflinePeers        []*ComponentPeer
+	OfflinePeers        []*nmdata.Peer
 	FirewallRules       []*FirewallRule
 	RoutesFirewallRules []*RouteFirewallRule
 	ForwardingRules     []*ForwardingRule
@@ -63,39 +44,8 @@ func (nm *NetworkMap) Merge(other *NetworkMap) {
 	nm.ForceRoutingPeerDNSResolution = nm.ForceRoutingPeerDNSResolution || other.ForceRoutingPeerDNSResolution
 }
 
-type comparableObject[T any] interface {
-	Equal(other T) bool
-}
-
-func mergeUnique[T comparableObject[T]](arr1, arr2 []T) []T {
-	var result []T
-
-	for _, item := range arr1 {
-		if !containsEqual(result, item) {
-			result = append(result, item)
-		}
-	}
-
-	for _, item := range arr2 {
-		if !containsEqual(result, item) {
-			result = append(result, item)
-		}
-	}
-
-	return result
-}
-
-func containsEqual[T comparableObject[T]](slice []T, element T) bool {
-	for _, item := range slice {
-		if item.Equal(element) {
-			return true
-		}
-	}
-	return false
-}
-
-func mergeUniquePeersByID(peers1, peers2 []*ComponentPeer) []*ComponentPeer {
-	result := make(map[string]*ComponentPeer)
+func mergeUniquePeersByID(peers1, peers2 []*nmdata.Peer) []*nmdata.Peer {
+	result := make(map[string]*nmdata.Peer)
 	for _, peer := range peers1 {
 		result[peer.ID] = peer
 	}
@@ -151,245 +101,33 @@ func ipToBytes(ip net.IP) []byte {
 	return ip.To16()
 }
 
-type Network struct {
-	Identifier string    `json:"id"`
-	Net        net.IPNet `gorm:"serializer:json"`
-	// NetV6 is the IPv6 ULA subnet for this account's overlay. Empty if not yet allocated.
-	NetV6 net.IPNet `gorm:"serializer:json"`
-	Dns   string
-	// Serial is an ID that increments by 1 when any change to the network happened (e.g. new peer has been added).
-	// Used to synchronize state to the client apps.
-	Serial uint64
-
-	Mu sync.Mutex `json:"-" gorm:"-"`
+type comparableObject[T any] interface {
+	Equal(other T) bool
 }
 
-// NewNetwork creates a new Network initializing it with a Serial=0
-// It takes a random /16 subnet from 100.64.0.0/10 (64 different subnets)
-// and a random /64 subnet from fd00:4e42::/32 for IPv6.
-func NewNetwork() *Network {
-	n := iplib.NewNet4(net.ParseIP("100.64.0.0"), NetSize)
-	sub, _ := n.Subnet(SubnetSize)
+func mergeUnique[T comparableObject[T]](arr1, arr2 []T) []T {
+	var result []T
 
-	s := rand.NewSource(time.Now().UnixNano())
-	r := rand.New(s)
-	intn := r.Intn(len(sub))
-
-	return &Network{
-		Identifier: xid.New().String(),
-		Net:        sub[intn].IPNet,
-		NetV6:      AllocateIPv6Subnet(r),
-		Dns:        "",
-		Serial:     0,
-	}
-}
-
-// AllocateIPv6Subnet generates a random RFC 4193 ULA /64 prefix.
-// The format follows RFC 4193 section 3.1: fd + 40-bit Global ID + 16-bit Subnet ID.
-// The Global ID and Subnet ID are randomized (simplified from the SHA-1 algorithm
-// in section 3.2.2), giving 2^56 possible /64 subnets across all accounts.
-func AllocateIPv6Subnet(r *rand.Rand) net.IPNet {
-	ip := make(net.IP, 16)
-	ip[0] = 0xfd
-	// Bytes 1-5: 40-bit random Global ID
-	ip[1] = byte(r.Intn(256))
-	ip[2] = byte(r.Intn(256))
-	ip[3] = byte(r.Intn(256))
-	ip[4] = byte(r.Intn(256))
-	ip[5] = byte(r.Intn(256))
-	// Bytes 6-7: 16-bit random Subnet ID
-	ip[6] = byte(r.Intn(256))
-	ip[7] = byte(r.Intn(256))
-
-	return net.IPNet{
-		IP:   ip,
-		Mask: net.CIDRMask(IPv6SubnetSize, 128),
-	}
-}
-
-// IncSerial increments Serial by 1 reflecting that the network state has been changed
-func (n *Network) IncSerial() {
-	n.Mu.Lock()
-	defer n.Mu.Unlock()
-	n.Serial++
-}
-
-// CurrentSerial returns the Network.Serial of the network (latest state id)
-func (n *Network) CurrentSerial() uint64 {
-	n.Mu.Lock()
-	defer n.Mu.Unlock()
-	return n.Serial
-}
-
-func (n *Network) Copy() *Network {
-	n.Mu.Lock()
-	defer n.Mu.Unlock()
-	return &Network{
-		Identifier: n.Identifier,
-		Net:        n.Net,
-		NetV6:      n.NetV6,
-		Dns:        n.Dns,
-		Serial:     n.Serial,
-	}
-}
-
-// AllocatePeerIP picks an available IP from a netip.Prefix.
-// This method considers already taken IPs and reuses IPs if there are gaps in takenIps.
-// E.g. if prefix=100.30.0.0/16 and takenIps=[100.30.0.1, 100.30.0.4] then the result would be 100.30.0.2 or 100.30.0.3.
-func AllocatePeerIP(prefix netip.Prefix, takenIps []netip.Addr) (netip.Addr, error) {
-	b := prefix.Masked().Addr().As4()
-	baseIP := binary.BigEndian.Uint32(b[:])
-	hostBits := 32 - prefix.Bits()
-	totalIPs := uint32(1 << hostBits)
-
-	taken := make(map[uint32]struct{}, len(takenIps)+1)
-	taken[baseIP] = struct{}{}            // reserve network IP
-	taken[baseIP+totalIPs-1] = struct{}{} // reserve broadcast IP
-
-	for _, ip := range takenIps {
-		ab := ip.As4()
-		taken[binary.BigEndian.Uint32(ab[:])] = struct{}{}
-	}
-
-	rng := rand.New(rand.NewSource(time.Now().UnixNano()))
-	maxAttempts := (int(totalIPs) - len(taken)) / 100
-
-	for i := 0; i < maxAttempts; i++ {
-		offset := uint32(rng.Intn(int(totalIPs-2))) + 1
-		candidate := baseIP + offset
-		if _, exists := taken[candidate]; !exists {
-			return uint32ToIP(candidate), nil
+	for _, item := range arr1 {
+		if !containsEqual(result, item) {
+			result = append(result, item)
 		}
 	}
 
-	for offset := uint32(1); offset < totalIPs-1; offset++ {
-		candidate := baseIP + offset
-		if _, exists := taken[candidate]; !exists {
-			return uint32ToIP(candidate), nil
+	for _, item := range arr2 {
+		if !containsEqual(result, item) {
+			result = append(result, item)
 		}
 	}
 
-	return netip.Addr{}, status.Errorf(status.PreconditionFailed, "network %s is out of IPs", prefix.String())
+	return result
 }
 
-// AllocateRandomPeerIP picks a random available IP from a netip.Prefix.
-func AllocateRandomPeerIP(prefix netip.Prefix) (netip.Addr, error) {
-	b := prefix.Masked().Addr().As4()
-	baseIP := binary.BigEndian.Uint32(b[:])
-	hostBits := 32 - prefix.Bits()
-	totalIPs := uint32(1 << hostBits)
-
-	rng := rand.New(rand.NewSource(time.Now().UnixNano()))
-	offset := uint32(rng.Intn(int(totalIPs-2))) + 1
-
-	candidate := baseIP + offset
-	return uint32ToIP(candidate), nil
-}
-
-// AllocateRandomPeerIPv6 picks a random host address within the given IPv6 prefix.
-// Only the host bits (after the prefix length) are randomized.
-func AllocateRandomPeerIPv6(prefix netip.Prefix) (netip.Addr, error) {
-	ones := prefix.Bits()
-	if ones == 0 || ones > 126 || !prefix.Addr().Is6() {
-		return netip.Addr{}, fmt.Errorf("invalid IPv6 subnet: %s", prefix.String())
-	}
-
-	ip := prefix.Addr().As16()
-
-	rng := rand.New(rand.NewSource(time.Now().UnixNano()))
-
-	// Determine which byte the host bits start in
-	firstHostByte := ones / 8
-	// If the prefix doesn't end on a byte boundary, handle the partial byte
-	partialBits := ones % 8
-
-	if partialBits > 0 {
-		// Keep the network bits in the partial byte, randomize the rest
-		hostMask := byte(0xff >> partialBits)
-		ip[firstHostByte] = (ip[firstHostByte] & ^hostMask) | (byte(rng.Intn(256)) & hostMask)
-		firstHostByte++
-	}
-
-	// Randomize remaining full host bytes
-	for i := firstHostByte; i < 16; i++ {
-		ip[i] = byte(rng.Intn(256))
-	}
-
-	// Avoid all-zeros and all-ones host parts by checking only host bits.
-	if isHostAllZeroOrOnes(ip[:], ones) {
-		ip = prefix.Masked().Addr().As16()
-		ip[15] |= 0x01
-	}
-
-	return netip.AddrFrom16(ip).Unmap(), nil
-}
-
-// isHostAllZeroOrOnes checks whether all host bits (after prefixLen) are zero or all ones.
-func isHostAllZeroOrOnes(ip []byte, prefixLen int) bool {
-	hostStart := prefixLen / 8
-	partialBits := prefixLen % 8
-
-	hostSlice := slices.Clone(ip[hostStart:])
-	if partialBits > 0 {
-		hostSlice[0] &= 0xff >> partialBits
-	}
-
-	allZero := !slices.ContainsFunc(hostSlice, func(v byte) bool { return v != 0 })
-	if allZero {
-		return true
-	}
-
-	// Build the all-ones mask for host bits
-	onesMask := make([]byte, len(hostSlice))
-	for i := range onesMask {
-		onesMask[i] = 0xff
-	}
-	if partialBits > 0 {
-		onesMask[0] = 0xff >> partialBits
-	}
-
-	return slices.Equal(hostSlice, onesMask)
-}
-
-func uint32ToIP(n uint32) netip.Addr {
-	var b [4]byte
-	binary.BigEndian.PutUint32(b[:], n)
-	return netip.AddrFrom4(b)
-}
-
-// generateIPs generates a list of all possible IPs of the given network excluding IPs specified in the exclusion list
-func generateIPs(ipNet *net.IPNet, exclusions map[string]struct{}) ([]net.IP, int) {
-
-	var ips []net.IP
-	for ip := ipNet.IP.Mask(ipNet.Mask); ipNet.Contains(ip); incIP(ip) {
-		if _, ok := exclusions[ip.String()]; !ok && ip[3] != 0 {
-			ips = append(ips, copyIP(ip))
-		}
-	}
-
-	// remove network address, broadcast and Fake DNS resolver address
-	lenIPs := len(ips)
-	switch {
-	case lenIPs < 2:
-		return ips, lenIPs
-	case lenIPs < 3:
-		return ips[1 : len(ips)-1], lenIPs - 2
-	default:
-		return ips[1 : len(ips)-2], lenIPs - 3
-	}
-}
-
-func copyIP(ip net.IP) net.IP {
-	dup := make(net.IP, len(ip))
-	copy(dup, ip)
-	return dup
-}
-
-func incIP(ip net.IP) {
-	for j := len(ip) - 1; j >= 0; j-- {
-		ip[j]++
-		if ip[j] > 0 {
-			break
+func containsEqual[T comparableObject[T]](slice []T, element T) bool {
+	for _, item := range slice {
+		if item.Equal(element) {
+			return true
 		}
 	}
+	return false
 }
diff --git a/shared/management/types/network_merge_test.go b/shared/management/types/network_merge_test.go
deleted file mode 100644
index a7ef24c1e..000000000
--- a/shared/management/types/network_merge_test.go
+++ /dev/null
@@ -1,41 +0,0 @@
-package types
-
-import (
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-)
-
-type testObject struct {
-	value int
-}
-
-func (t testObject) Equal(other testObject) bool {
-	return t.value == other.value
-}
-
-func Test_MergeUniqueArraysWithoutDuplicates(t *testing.T) {
-	arr1 := []testObject{{value: 1}, {value: 2}}
-	arr2 := []testObject{{value: 2}, {value: 3}}
-	result := mergeUnique(arr1, arr2)
-	assert.Len(t, result, 3)
-	assert.Contains(t, result, testObject{value: 1})
-	assert.Contains(t, result, testObject{value: 2})
-	assert.Contains(t, result, testObject{value: 3})
-}
-
-func Test_MergeUniqueHandlesEmptyArrays(t *testing.T) {
-	arr1 := []testObject{}
-	arr2 := []testObject{}
-	result := mergeUnique(arr1, arr2)
-	assert.Empty(t, result)
-}
-
-func Test_MergeUniqueHandlesOneEmptyArray(t *testing.T) {
-	arr1 := []testObject{{value: 1}, {value: 2}}
-	arr2 := []testObject{}
-	result := mergeUnique(arr1, arr2)
-	assert.Len(t, result, 2)
-	assert.Contains(t, result, testObject{value: 1})
-	assert.Contains(t, result, testObject{value: 2})
-}
diff --git a/shared/management/types/network_test.go b/shared/management/types/network_test.go
index d8a06dbbc..631f38836 100644
--- a/shared/management/types/network_test.go
+++ b/shared/management/types/network_test.go
@@ -1,264 +1,41 @@
 package types
 
 import (
-	"encoding/binary"
-	"net"
-	"net/netip"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
 )
 
-func TestNewNetwork(t *testing.T) {
-	network := NewNetwork()
-
-	// generated net should be a subnet of a larger 100.64.0.0/10 net
-	ipNet := net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.IPMask{255, 192, 0, 0}}
-	assert.Equal(t, ipNet.Contains(network.Net.IP), true)
+type mergeTestObject struct {
+	value int
 }
 
-func TestAllocatePeerIP(t *testing.T) {
-	prefix := netip.MustParsePrefix("100.64.0.0/24")
-	var ips []netip.Addr
-	for i := 0; i < 252; i++ {
-		ip, err := AllocatePeerIP(prefix, ips)
-		if err != nil {
-			t.Fatal(err)
-		}
-		ips = append(ips, ip)
-	}
-
-	assert.Len(t, ips, 252)
-
-	uniq := make(map[string]struct{})
-	for _, ip := range ips {
-		if _, ok := uniq[ip.String()]; !ok {
-			uniq[ip.String()] = struct{}{}
-		} else {
-			t.Errorf("found duplicate IP %s", ip.String())
-		}
-	}
+func (t mergeTestObject) Equal(other mergeTestObject) bool {
+	return t.value == other.value
 }
 
-func TestAllocatePeerIPSmallSubnet(t *testing.T) {
-	// Test /27 network (10.0.0.0/27) - should only have 30 usable IPs (10.0.0.1 to 10.0.0.30)
-	prefix := netip.MustParsePrefix("10.0.0.0/27")
-	var ips []netip.Addr
-
-	// Allocate all available IPs in the /27 network
-	for i := 0; i < 30; i++ {
-		ip, err := AllocatePeerIP(prefix, ips)
-		if err != nil {
-			t.Fatal(err)
-		}
-
-		// Verify IP is within the correct range
-		if !prefix.Contains(ip) {
-			t.Errorf("allocated IP %s is not within network %s", ip.String(), prefix.String())
-		}
-
-		ips = append(ips, ip)
-	}
-
-	assert.Len(t, ips, 30)
-
-	// Verify all IPs are unique
-	uniq := make(map[string]struct{})
-	for _, ip := range ips {
-		if _, ok := uniq[ip.String()]; !ok {
-			uniq[ip.String()] = struct{}{}
-		} else {
-			t.Errorf("found duplicate IP %s", ip.String())
-		}
-	}
-
-	// Try to allocate one more IP - should fail as network is full
-	_, err := AllocatePeerIP(prefix, ips)
-	if err == nil {
-		t.Error("expected error when network is full, but got none")
-	}
+func Test_MergeUniqueArraysWithoutDuplicates(t *testing.T) {
+	arr1 := []mergeTestObject{{value: 1}, {value: 2}}
+	arr2 := []mergeTestObject{{value: 2}, {value: 3}}
+	result := mergeUnique(arr1, arr2)
+	assert.Len(t, result, 3)
+	assert.Contains(t, result, mergeTestObject{value: 1})
+	assert.Contains(t, result, mergeTestObject{value: 2})
+	assert.Contains(t, result, mergeTestObject{value: 3})
 }
 
-func TestAllocatePeerIPVariousCIDRs(t *testing.T) {
-	testCases := []struct {
-		name           string
-		cidr           string
-		expectedUsable int
-	}{
-		{"/30 network", "192.168.1.0/30", 2},   // 4 total - 2 reserved = 2 usable
-		{"/29 network", "192.168.1.0/29", 6},   // 8 total - 2 reserved = 6 usable
-		{"/28 network", "192.168.1.0/28", 14},  // 16 total - 2 reserved = 14 usable
-		{"/27 network", "192.168.1.0/27", 30},  // 32 total - 2 reserved = 30 usable
-		{"/26 network", "192.168.1.0/26", 62},  // 64 total - 2 reserved = 62 usable
-		{"/25 network", "192.168.1.0/25", 126}, // 128 total - 2 reserved = 126 usable
-		{"/16 network", "10.0.0.0/16", 65534},  // 65536 total - 2 reserved = 65534 usable
-	}
-
-	for _, tc := range testCases {
-		t.Run(tc.name, func(t *testing.T) {
-			prefix, err := netip.ParsePrefix(tc.cidr)
-			require.NoError(t, err)
-			prefix = prefix.Masked()
-
-			var ips []netip.Addr
-
-			// For larger networks, test only a subset to avoid long test runs
-			testCount := tc.expectedUsable
-			if testCount > 1000 {
-				testCount = 1000
-			}
-
-			// Allocate IPs and verify they're within the correct range
-			for i := 0; i < testCount; i++ {
-				ip, err := AllocatePeerIP(prefix, ips)
-				require.NoError(t, err, "failed to allocate IP %d", i)
-
-				// Verify IP is within the correct range
-				assert.True(t, prefix.Contains(ip), "allocated IP %s is not within network %s", ip.String(), prefix.String())
-
-				// Verify IP is not network or broadcast address
-				networkAddr := prefix.Masked().Addr()
-				hostBits := 32 - prefix.Bits()
-				b := networkAddr.As4()
-				baseIP := binary.BigEndian.Uint32(b[:])
-				broadcastIP := uint32ToIP(baseIP + (1 << hostBits) - 1)
-
-				assert.NotEqual(t, networkAddr, ip, "allocated network address %s", ip.String())
-				assert.NotEqual(t, broadcastIP, ip, "allocated broadcast address %s", ip.String())
-
-				ips = append(ips, ip)
-			}
-
-			assert.Len(t, ips, testCount)
-
-			// Verify all IPs are unique
-			uniq := make(map[string]struct{})
-			for _, ip := range ips {
-				ipStr := ip.String()
-				assert.NotContains(t, uniq, ipStr, "found duplicate IP %s", ipStr)
-				uniq[ipStr] = struct{}{}
-			}
-		})
-	}
+func Test_MergeUniqueHandlesEmptyArrays(t *testing.T) {
+	arr1 := []mergeTestObject{}
+	arr2 := []mergeTestObject{}
+	result := mergeUnique(arr1, arr2)
+	assert.Empty(t, result)
 }
 
-func TestGenerateIPs(t *testing.T) {
-	ipNet := net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.IPMask{255, 255, 255, 0}}
-	ips, ipsLen := generateIPs(&ipNet, map[string]struct{}{"100.64.0.0": {}})
-	if ipsLen != 252 {
-		t.Errorf("expected 252 ips, got %d", len(ips))
-		return
-	}
-	if ips[len(ips)-1].String() != "100.64.0.253" {
-		t.Errorf("expected last ip to be: 100.64.0.253, got %s", ips[len(ips)-1].String())
-	}
-}
-
-func TestNewNetworkHasIPv6(t *testing.T) {
-	network := NewNetwork()
-
-	assert.NotNil(t, network.NetV6.IP, "v6 subnet should be allocated")
-	assert.True(t, network.NetV6.IP.To4() == nil, "v6 subnet should be IPv6")
-	assert.Equal(t, byte(0xfd), network.NetV6.IP[0], "v6 subnet should be ULA (fd prefix)")
-
-	ones, bits := network.NetV6.Mask.Size()
-	assert.Equal(t, 64, ones, "v6 subnet should be /64")
-	assert.Equal(t, 128, bits)
-}
-
-func TestAllocateIPv6SubnetUniqueness(t *testing.T) {
-	seen := make(map[string]struct{})
-	for i := 0; i < 100; i++ {
-		network := NewNetwork()
-		key := network.NetV6.IP.String()
-		_, duplicate := seen[key]
-		assert.False(t, duplicate, "duplicate v6 subnet: %s", key)
-		seen[key] = struct{}{}
-	}
-}
-
-func TestAllocateRandomPeerIPv6(t *testing.T) {
-	prefix := netip.MustParsePrefix("fd12:3456:7890:abcd::/64")
-
-	ip, err := AllocateRandomPeerIPv6(prefix)
-	require.NoError(t, err)
-
-	assert.True(t, ip.Is6(), "should be IPv6")
-	assert.True(t, prefix.Contains(ip), "should be within subnet")
-	// First 8 bytes (network prefix) should match
-	b := ip.As16()
-	prefixBytes := prefix.Addr().As16()
-	assert.Equal(t, prefixBytes[:8], b[:8], "prefix should match")
-	// Interface ID should not be all zeros
-	allZero := true
-	for _, v := range b[8:] {
-		if v != 0 {
-			allZero = false
-			break
-		}
-	}
-	assert.False(t, allZero, "interface ID should not be all zeros")
-}
-
-func TestAllocateRandomPeerIPv6_VariousPrefixes(t *testing.T) {
-	tests := []struct {
-		name   string
-		cidr   string
-		prefix int
-	}{
-		{"standard /64", "fd00:1234:5678:abcd::/64", 64},
-		{"small /112", "fd00:1234:5678:abcd::/112", 112},
-		{"large /48", "fd00:1234::/48", 48},
-		{"non-boundary /60", "fd00:1234:5670::/60", 60},
-		{"non-boundary /52", "fd00:1230::/52", 52},
-		{"minimum /120", "fd00:1234:5678:abcd::100/120", 120},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			prefix, err := netip.ParsePrefix(tt.cidr)
-			require.NoError(t, err)
-			prefix = prefix.Masked()
-
-			assert.Equal(t, tt.prefix, prefix.Bits())
-
-			for i := 0; i < 50; i++ {
-				ip, err := AllocateRandomPeerIPv6(prefix)
-				require.NoError(t, err)
-				assert.True(t, prefix.Contains(ip), "IP %s should be within %s", ip, prefix)
-			}
-		})
-	}
-}
-
-func TestAllocateRandomPeerIPv6_PreservesNetworkBits(t *testing.T) {
-	// For a /112, bytes 0-13 should be preserved, only bytes 14-15 should vary
-	prefix := netip.MustParsePrefix("fd00:1234:5678:abcd:ef01:2345:6789:0/112")
-
-	prefixBytes := prefix.Addr().As16()
-	for i := 0; i < 20; i++ {
-		ip, err := AllocateRandomPeerIPv6(prefix)
-		require.NoError(t, err)
-		// First 14 bytes (112 bits = 14 bytes) must match the network
-		b := ip.As16()
-		assert.Equal(t, prefixBytes[:14], b[:14], "network bytes should be preserved for /112")
-	}
-}
-
-func TestAllocateRandomPeerIPv6_NonByteBoundary(t *testing.T) {
-	// For a /60, the first 7.5 bytes are network, so byte 7 is partial
-	prefix := netip.MustParsePrefix("fd00:1234:5678:abc0::/60")
-
-	prefixBytes := prefix.Addr().As16()
-	for i := 0; i < 50; i++ {
-		ip, err := AllocateRandomPeerIPv6(prefix)
-		require.NoError(t, err)
-		b := ip.As16()
-		assert.True(t, prefix.Contains(ip), "IP %s should be within %s", ip, prefix)
-		// First 7 bytes must match exactly
-		assert.Equal(t, prefixBytes[:7], b[:7], "full network bytes should match for /60")
-		// Byte 7: top 4 bits (0xc = 1100) must be preserved
-		assert.Equal(t, prefixBytes[7]&0xf0, b[7]&0xf0, "partial byte network bits should be preserved for /60")
-	}
+func Test_MergeUniqueHandlesOneEmptyArray(t *testing.T) {
+	arr1 := []mergeTestObject{{value: 1}, {value: 2}}
+	arr2 := []mergeTestObject{}
+	result := mergeUnique(arr1, arr2)
+	assert.Len(t, result, 2)
+	assert.Contains(t, result, mergeTestObject{value: 1})
+	assert.Contains(t, result, mergeTestObject{value: 2})
 }
diff --git a/shared/management/types/networkmap_components.go b/shared/management/types/networkmap_components.go
index c4c437e4b..e18db4ec0 100644
--- a/shared/management/types/networkmap_components.go
+++ b/shared/management/types/networkmap_components.go
@@ -14,32 +14,33 @@ import (
 	nbdns "github.com/netbirdio/netbird/dns"
 	"github.com/netbirdio/netbird/route"
 	"github.com/netbirdio/netbird/shared/management/domain"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 )
 
 type NetworkMapComponents struct {
 	PeerID string
 
-	Network          *Network
-	AccountSettings  *AccountSettingsInfo
-	DNSSettings      *DNSSettings
+	Network          *nmdata.Network
+	AccountSettings  *nmdata.AccountSettingsInfo
+	DNSSettings      *nmdata.DNSSettings
 	CustomZoneDomain string
 
-	Peers               map[string]*ComponentPeer
-	Groups              map[string]*ComponentGroup
-	Policies            []*Policy
-	Routes              []*route.Route
-	NameServerGroups    []*nbdns.NameServerGroup
-	AllDNSRecords       []nbdns.SimpleRecord
-	AccountZones        []nbdns.CustomZone
-	ResourcePoliciesMap map[string][]*Policy
-	RoutersMap          map[string]map[string]*ComponentRouter
-	NetworkResources    []*ComponentResource
+	Peers               map[string]*nmdata.Peer
+	Groups              map[string]*nmdata.Group
+	Policies            []*nmdata.Policy
+	Routes              []*nmdata.Route
+	NameServerGroups    []*nmdata.NameServerGroup
+	AllDNSRecords       []nmdata.SimpleRecord
+	AccountZones        []nmdata.CustomZone
+	ResourcePoliciesMap map[string][]*nmdata.Policy
+	RoutersMap          map[string]map[string]*nmdata.NetworkRouter
+	NetworkResources    []*nmdata.NetworkResource
 
 	GroupIDToUserIDs   map[string][]string
 	AllowedUserIDs     map[string]struct{}
 	PostureFailedPeers map[string]map[string]struct{}
 
-	RouterPeers map[string]*ComponentPeer
+	RouterPeers map[string]*nmdata.Peer
 
 	// NetworkXIDToPublicID maps Network.ID (xid) → PublicID.
 	// Consumed by the envelope encoder to
@@ -51,20 +52,21 @@ type NetworkMapComponents struct {
 	// Same role as NetworkXIDToPublicID, used for PostureFailedPeers keys and
 	// policy SourcePostureChecks references.
 	PostureCheckXIDToPublicID map[string]string
-	routesByPeerOnce          sync.Once
-	routesByPeerIdx           map[string][]routeIndexEntry
-
-	// true when returning an empty-like map (returned instead of nil)
-	empty bool
 
 	// ForceRoutingPeerDNSResolution forces the peer to run/use routing-peer DNS
 	// resolution regardless of the account-global setting, for reverse-proxy
 	// domain targets.
 	ForceRoutingPeerDNSResolution bool
+
+	routesByPeerOnce sync.Once
+	routesByPeerIdx  map[string][]routeIndexEntry
+
+	// true when returning an empty-like map (returned instead of nil)
+	empty bool
 }
 
 type routeIndexEntry struct {
-	route    *route.Route
+	route    *nmdata.Route
 	viaGroup bool
 }
 
@@ -80,15 +82,15 @@ func EmptyNetworkMapComponents(nm *NetworkMapComponents) *NetworkMapComponents {
 	return nm
 }
 
-func (c *NetworkMapComponents) GetPeerInfo(peerID string) *ComponentPeer {
+func (c *NetworkMapComponents) GetPeerInfo(peerID string) *nmdata.Peer {
 	return c.Peers[peerID]
 }
 
-func (c *NetworkMapComponents) GetRouterPeerInfo(peerID string) *ComponentPeer {
+func (c *NetworkMapComponents) GetRouterPeerInfo(peerID string) *nmdata.Peer {
 	return c.RouterPeers[peerID]
 }
 
-func (c *NetworkMapComponents) GetGroupInfo(groupID string) *ComponentGroup {
+func (c *NetworkMapComponents) GetGroupInfo(groupID string) *nmdata.Group {
 	return c.Groups[groupID]
 }
 
@@ -143,8 +145,8 @@ func (c *NetworkMapComponents) Calculate(ctx context.Context) *NetworkMap {
 	peersToConnect, expiredPeers := c.filterPeersByLoginExpiration(aclPeers)
 
 	includeIPv6 := false
-	if p := c.Peers[targetPeerID]; p != nil {
-		includeIPv6 = p.SupportsIPv6 && p.IPv6.IsValid()
+	if p := c.GetPeerInfo(targetPeerID); p != nil {
+		includeIPv6 = p.SupportsIPv6() && p.IPv6.IsValid()
 	}
 	routesUpdate := filterAndExpandRoutes(c.getRoutesToSync(targetPeerID, peersToConnect, peerGroups), includeIPv6)
 	routesFirewallRules := c.getPeerRoutesFirewallRules(ctx, targetPeerID, includeIPv6)
@@ -175,11 +177,11 @@ func (c *NetworkMapComponents) Calculate(ctx context.Context) *NetworkMap {
 		if c.CustomZoneDomain != "" && len(c.AllDNSRecords) > 0 {
 			customZones = append(customZones, nbdns.CustomZone{
 				Domain:  c.CustomZoneDomain,
-				Records: c.AllDNSRecords,
+				Records: toRealRecords(c.AllDNSRecords),
 			})
 		}
 
-		customZones = append(customZones, c.AccountZones...)
+		customZones = append(customZones, toRealZones(c.AccountZones)...)
 
 		dnsUpdate.CustomZones = customZones
 		dnsUpdate.NameServerGroups = c.getPeerNSGroupsFromGroups(targetPeerID, peerGroups)
@@ -187,7 +189,7 @@ func (c *NetworkMapComponents) Calculate(ctx context.Context) *NetworkMap {
 
 	return &NetworkMap{
 		Peers:               peersToConnectIncludingRouters,
-		Network:             c.Network.Copy(),
+		Network:             c.Network,
 		Routes:              append(filterAndExpandRoutes(networkResourcesRoutes, includeIPv6), routesUpdate...),
 		DNSConfig:           dnsUpdate,
 		OfflinePeers:        expiredPeers,
@@ -204,7 +206,7 @@ func (c *NetworkMapComponents) IsEmpty() bool {
 	return c.empty
 }
 
-func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) ([]*ComponentPeer, []*FirewallRule, map[string]map[string]struct{}, bool) {
+func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) ([]*nmdata.Peer, []*FirewallRule, map[string]map[string]struct{}, bool) {
 	targetPeer := c.GetPeerInfo(targetPeerID)
 	if targetPeer == nil {
 		return nil, nil, nil, false
@@ -215,26 +217,26 @@ func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) (
 	sshEnabled := false
 
 	for _, policy := range c.Policies {
-		if !policy.Enabled {
+		if policy == nil || !policy.Enabled {
 			continue
 		}
 
 		for _, rule := range policy.Rules {
-			if !rule.Enabled {
+			if rule == nil || !rule.Enabled {
 				continue
 			}
 
-			var sourcePeers, destinationPeers []*ComponentPeer
+			var sourcePeers, destinationPeers []*nmdata.Peer
 			var peerInSources, peerInDestinations bool
 
-			if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" {
-				sourcePeers, peerInSources = c.getPeerFromResource(rule.SourceResource, targetPeerID)
+			if rule.SourceResource.Type == string(ResourceTypePeer) && rule.SourceResource.ID != "" {
+				sourcePeers, peerInSources = c.getPeerFromResource(rule.SourceResource, targetPeerID, policy.SourcePostureChecks)
 			} else {
 				sourcePeers, peerInSources = c.getAllPeersFromGroups(rule.Sources, targetPeerID, policy.SourcePostureChecks)
 			}
 
-			if rule.DestinationResource.Type == ResourceTypePeer && rule.DestinationResource.ID != "" {
-				destinationPeers, peerInDestinations = c.getPeerFromResource(rule.DestinationResource, targetPeerID)
+			if rule.DestinationResource.Type == string(ResourceTypePeer) && rule.DestinationResource.ID != "" {
+				destinationPeers, peerInDestinations = c.getPeerFromResource(rule.DestinationResource, targetPeerID, nil)
 			} else {
 				destinationPeers, peerInDestinations = c.getAllPeersFromGroups(rule.Destinations, targetPeerID, nil)
 			}
@@ -256,7 +258,7 @@ func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) (
 				generateResources(rule, sourcePeers, FirewallRuleDirectionIN)
 			}
 
-			if peerInDestinations && rule.Protocol == PolicyRuleProtocolNetbirdSSH {
+			if peerInDestinations && rule.Protocol == string(PolicyRuleProtocolNetbirdSSH) {
 				sshEnabled = true
 				switch {
 				case len(rule.AuthorizedGroups) > 0:
@@ -287,7 +289,7 @@ func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) (
 				default:
 					authorizedUsers[auth.Wildcard] = c.getAllowedUserIDs()
 				}
-			} else if peerInDestinations && PolicyRuleImpliesLegacySSH(rule) && targetPeer.SSHEnabled {
+			} else if peerInDestinations && nmdata.PolicyRuleImpliesLegacySSH(rule) && targetPeer.SSHEnabled {
 				sshEnabled = true
 				authorizedUsers[auth.Wildcard] = c.getAllowedUserIDs()
 			}
@@ -307,19 +309,19 @@ func (c *NetworkMapComponents) getAllowedUserIDs() map[string]struct{} {
 	return make(map[string]struct{})
 }
 
-func (c *NetworkMapComponents) connResourcesGenerator(targetPeer *ComponentPeer) (func(*PolicyRule, []*ComponentPeer, int), func() ([]*ComponentPeer, []*FirewallRule)) {
+func (c *NetworkMapComponents) connResourcesGenerator(targetPeer *nmdata.Peer) (func(*nmdata.PolicyRule, []*nmdata.Peer, int), func() ([]*nmdata.Peer, []*FirewallRule)) {
 	rulesExists := make(map[string]struct{})
 	peersExists := make(map[string]struct{})
 	rules := make([]*FirewallRule, 0)
-	peers := make([]*ComponentPeer, 0)
+	peers := make([]*nmdata.Peer, 0)
 
-	return func(rule *PolicyRule, groupPeers []*ComponentPeer, direction int) {
+	return func(rule *nmdata.PolicyRule, groupPeers []*nmdata.Peer, direction int) {
 			protocol := rule.Protocol
-			if protocol == PolicyRuleProtocolNetbirdSSH {
-				protocol = PolicyRuleProtocolTCP
+			if protocol == string(PolicyRuleProtocolNetbirdSSH) {
+				protocol = string(PolicyRuleProtocolTCP)
 			}
 
-			protocolStr := string(protocol)
+			protocolStr := protocol
 			actionStr := string(rule.Action)
 			dirStr := strconv.Itoa(direction)
 			portsJoined := strings.Join(rule.Ports, ",")
@@ -365,15 +367,28 @@ func (c *NetworkMapComponents) connResourcesGenerator(targetPeer *ComponentPeer)
 					PortsJoined: portsJoined,
 				})
 			}
-		}, func() ([]*ComponentPeer, []*FirewallRule) {
+		}, func() ([]*nmdata.Peer, []*FirewallRule) {
 			return peers, rules
 		}
 }
 
-func (c *NetworkMapComponents) getAllPeersFromGroups(groups []string, peerID string, sourcePostureChecksIDs []string) ([]*ComponentPeer, bool) {
+func (c *NetworkMapComponents) getAllPeersFromGroups(groups []string, peerID string, sourcePostureChecksIDs []string) ([]*nmdata.Peer, bool) {
+	return c.filterPolicyPeers(c.getUniquePeerIDsFromGroupsIDs(groups), peerID, sourcePostureChecksIDs)
+}
+
+// getPeerFromResource resolves a rule side that names a peer directly. The peer is
+// subject to the same admission as a group member, so a direct peer behaves exactly
+// like a group holding only that peer.
+func (c *NetworkMapComponents) getPeerFromResource(resource nmdata.Resource, peerID string, sourcePostureChecksIDs []string) ([]*nmdata.Peer, bool) {
+	return c.filterPolicyPeers([]string{resource.ID}, peerID, sourcePostureChecksIDs)
+}
+
+// filterPolicyPeers admits the peers of one rule side: known to the components and
+// passing the rule's posture checks. It reports the admitted peers other than peerID
+// and whether peerID itself is admitted on that side.
+func (c *NetworkMapComponents) filterPolicyPeers(uniquePeerIDs []string, peerID string, sourcePostureChecksIDs []string) ([]*nmdata.Peer, bool) {
 	peerInGroups := false
-	uniquePeerIDs := c.getUniquePeerIDsFromGroupsIDs(groups)
-	filteredPeers := make([]*ComponentPeer, 0, len(uniquePeerIDs))
+	filteredPeers := make([]*nmdata.Peer, 0, len(uniquePeerIDs))
 
 	for _, p := range uniquePeerIDs {
 		peerInfo := c.GetPeerInfo(p)
@@ -425,22 +440,9 @@ func (c *NetworkMapComponents) getUniquePeerIDsFromGroupsIDs(groups []string) []
 	return ids
 }
 
-func (c *NetworkMapComponents) getPeerFromResource(resource Resource, peerID string) ([]*ComponentPeer, bool) {
-	if resource.ID == peerID {
-		return []*ComponentPeer{}, true
-	}
-
-	peerInfo := c.GetPeerInfo(resource.ID)
-	if peerInfo == nil {
-		return []*ComponentPeer{}, false
-	}
-
-	return []*ComponentPeer{peerInfo}, false
-}
-
-func (c *NetworkMapComponents) filterPeersByLoginExpiration(aclPeers []*ComponentPeer) ([]*ComponentPeer, []*ComponentPeer) {
-	peersToConnect := make([]*ComponentPeer, 0, len(aclPeers))
-	var expiredPeers []*ComponentPeer
+func (c *NetworkMapComponents) filterPeersByLoginExpiration(aclPeers []*nmdata.Peer) ([]*nmdata.Peer, []*nmdata.Peer) {
+	peersToConnect := make([]*nmdata.Peer, 0, len(aclPeers))
+	var expiredPeers []*nmdata.Peer
 
 	for _, p := range aclPeers {
 		expired, _ := p.LoginExpired(c.AccountSettings.PeerLoginExpiration)
@@ -480,7 +482,7 @@ func (c *NetworkMapComponents) getPeerNSGroupsFromGroups(peerID string, groupLis
 		for _, gID := range nsGroup.Groups {
 			if _, found := groupList[gID]; found {
 				if !c.peerIsNameserver(peerIPStr, nsGroup) {
-					peerNSGroups = append(peerNSGroups, nsGroup.Copy())
+					peerNSGroups = append(peerNSGroups, toRealNSGroup(nsGroup))
 				}
 				break
 			}
@@ -490,7 +492,7 @@ func (c *NetworkMapComponents) getPeerNSGroupsFromGroups(peerID string, groupLis
 	return peerNSGroups
 }
 
-func (c *NetworkMapComponents) peerIsNameserver(peerIPStr string, nsGroup *nbdns.NameServerGroup) bool {
+func (c *NetworkMapComponents) peerIsNameserver(peerIPStr string, nsGroup *nmdata.NameServerGroup) bool {
 	for _, ns := range nsGroup.NameServers {
 		if peerIPStr == ns.IP.String() {
 			return true
@@ -502,8 +504,8 @@ func (c *NetworkMapComponents) peerIsNameserver(peerIPStr string, nsGroup *nbdns
 // filterAndExpandRoutes drops v6 routes for non-capable peers and duplicates
 // the default v4 route (0.0.0.0/0) as ::/0 for v6-capable peers.
 // TODO: the "-v6" suffix on IDs could collide with user-supplied route IDs.
-func filterAndExpandRoutes(routes []*route.Route, includeIPv6 bool) []*route.Route {
-	filtered := make([]*route.Route, 0, len(routes))
+func filterAndExpandRoutes(routes []*nmdata.Route, includeIPv6 bool) []*nmdata.Route {
+	filtered := make([]*nmdata.Route, 0, len(routes))
 	for _, r := range routes {
 		if !includeIPv6 && r.Network.Addr().Is6() {
 			continue
@@ -515,14 +517,14 @@ func filterAndExpandRoutes(routes []*route.Route, includeIPv6 bool) []*route.Rou
 			v6.ID = r.ID + "-v6-default"
 			v6.NetID = r.NetID + "-v6"
 			v6.Network = netip.MustParsePrefix("::/0")
-			v6.NetworkType = route.IPv6Network
+			v6.NetworkType = nmdata.NetworkTypeIPv6
 			filtered = append(filtered, v6)
 		}
 	}
 	return filtered
 }
 
-func (c *NetworkMapComponents) getRoutesToSync(peerID string, aclPeers []*ComponentPeer, peerGroups LookupMap) []*route.Route {
+func (c *NetworkMapComponents) getRoutesToSync(peerID string, aclPeers []*nmdata.Peer, peerGroups LookupMap) []*nmdata.Route {
 	routes, peerDisabledRoutes := c.getRoutingPeerRoutes(peerID)
 	peerRoutesMembership := make(LookupMap)
 	for _, r := range append(routes, peerDisabledRoutes...) {
@@ -539,7 +541,7 @@ func (c *NetworkMapComponents) getRoutesToSync(peerID string, aclPeers []*Compon
 	return routes
 }
 
-func (c *NetworkMapComponents) getRoutingPeerRoutes(peerID string) (enabledRoutes []*route.Route, disabledRoutes []*route.Route) {
+func (c *NetworkMapComponents) getRoutingPeerRoutes(peerID string) (enabledRoutes []*nmdata.Route, disabledRoutes []*nmdata.Route) {
 	peerInfo := c.GetPeerInfo(peerID)
 	if peerInfo == nil {
 		peerInfo = c.GetRouterPeerInfo(peerID)
@@ -548,9 +550,9 @@ func (c *NetworkMapComponents) getRoutingPeerRoutes(peerID string) (enabledRoute
 		return enabledRoutes, disabledRoutes
 	}
 
-	seenRoute := make(map[route.ID]struct{})
+	seenRoute := make(map[string]struct{})
 
-	takeRoute := func(r *route.Route) {
+	takeRoute := func(r *nmdata.Route) {
 		if _, ok := seenRoute[r.ID]; ok {
 			return
 		}
@@ -569,7 +571,7 @@ func (c *NetworkMapComponents) getRoutingPeerRoutes(peerID string) (enabledRoute
 		if entry.viaGroup {
 			newPeerRoute := entry.route.Copy()
 			newPeerRoute.PeerGroups = nil
-			newPeerRoute.ID = route.ID(string(entry.route.ID) + ":" + peerID)
+			newPeerRoute.ID = entry.route.ID + ":" + peerID
 			takeRoute(newPeerRoute)
 			continue
 		}
@@ -602,8 +604,8 @@ func (c *NetworkMapComponents) routesByPeer() map[string][]routeIndexEntry {
 	return c.routesByPeerIdx
 }
 
-func (c *NetworkMapComponents) filterRoutesByGroups(routes []*route.Route, groupListMap LookupMap) []*route.Route {
-	var filteredRoutes []*route.Route
+func (c *NetworkMapComponents) filterRoutesByGroups(routes []*nmdata.Route, groupListMap LookupMap) []*nmdata.Route {
+	var filteredRoutes []*nmdata.Route
 	for _, r := range routes {
 		for _, groupID := range r.Groups {
 			_, found := groupListMap[groupID]
@@ -616,8 +618,8 @@ func (c *NetworkMapComponents) filterRoutesByGroups(routes []*route.Route, group
 	return filteredRoutes
 }
 
-func (c *NetworkMapComponents) filterRoutesFromPeersOfSameHAGroup(routes []*route.Route, peerMemberships LookupMap) []*route.Route {
-	var filteredRoutes []*route.Route
+func (c *NetworkMapComponents) filterRoutesFromPeersOfSameHAGroup(routes []*nmdata.Route, peerMemberships LookupMap) []*nmdata.Route {
+	var filteredRoutes []*nmdata.Route
 	for _, r := range routes {
 		_, found := peerMemberships[string(r.GetHAUniqueID())]
 		if !found {
@@ -650,7 +652,7 @@ func (c *NetworkMapComponents) getPeerRoutesFirewallRules(ctx context.Context, p
 	return routesFirewallRules
 }
 
-func (c *NetworkMapComponents) getDefaultPermit(r *route.Route, includeIPv6 bool) []*RouteFirewallRule {
+func (c *NetworkMapComponents) getDefaultPermit(r *nmdata.Route, includeIPv6 bool) []*RouteFirewallRule {
 	if r.Network.Addr().Is6() && !includeIPv6 {
 		return nil
 	}
@@ -667,7 +669,7 @@ func (c *NetworkMapComponents) getDefaultPermit(r *route.Route, includeIPv6 bool
 		Protocol:     string(PolicyRuleProtocolALL),
 		Domains:      r.Domains,
 		IsDynamic:    r.IsDynamic(),
-		RouteID:      r.ID,
+		RouteID:      route.ID(r.ID),
 	}
 
 	rules := []*RouteFirewallRule{&rule}
@@ -678,7 +680,7 @@ func (c *NetworkMapComponents) getDefaultPermit(r *route.Route, includeIPv6 bool
 		ruleV6.SourceRanges = []string{"::/0"}
 		if isDefaultV4 {
 			ruleV6.Destination = "::/0"
-			ruleV6.RouteID = r.ID + "-v6-default"
+			ruleV6.RouteID = route.ID(r.ID + "-v6-default")
 		}
 		rules = append(rules, &ruleV6)
 	}
@@ -686,7 +688,7 @@ func (c *NetworkMapComponents) getDefaultPermit(r *route.Route, includeIPv6 bool
 	return rules
 }
 
-func (c *NetworkMapComponents) getDistributionGroupsPeers(r *route.Route) map[string]struct{} {
+func (c *NetworkMapComponents) getDistributionGroupsPeers(r *nmdata.Route) map[string]struct{} {
 	distPeers := make(map[string]struct{})
 	for _, id := range r.Groups {
 		group := c.GetGroupInfo(id)
@@ -701,11 +703,17 @@ func (c *NetworkMapComponents) getDistributionGroupsPeers(r *route.Route) map[st
 	return distPeers
 }
 
-func (c *NetworkMapComponents) getAllRoutePoliciesFromGroups(accessControlGroups []string) []*Policy {
-	routePolicies := make([]*Policy, 0)
+func (c *NetworkMapComponents) getAllRoutePoliciesFromGroups(accessControlGroups []string) []*nmdata.Policy {
+	routePolicies := make([]*nmdata.Policy, 0)
 	for _, groupID := range accessControlGroups {
 		for _, policy := range c.Policies {
+			if policy == nil {
+				continue
+			}
 			for _, rule := range policy.Rules {
+				if rule == nil {
+					continue
+				}
 				if slices.Contains(rule.Destinations, groupID) {
 					routePolicies = append(routePolicies, policy)
 				}
@@ -716,15 +724,15 @@ func (c *NetworkMapComponents) getAllRoutePoliciesFromGroups(accessControlGroups
 	return routePolicies
 }
 
-func (c *NetworkMapComponents) getRouteFirewallRules(ctx context.Context, peerID string, policies []*Policy, route *route.Route, distributionPeers map[string]struct{}, includeIPv6 bool) []*RouteFirewallRule {
+func (c *NetworkMapComponents) getRouteFirewallRules(ctx context.Context, peerID string, policies []*nmdata.Policy, route *nmdata.Route, distributionPeers map[string]struct{}, includeIPv6 bool) []*RouteFirewallRule {
 	var fwRules []*RouteFirewallRule
 	for _, policy := range policies {
-		if !policy.Enabled {
+		if policy == nil || !policy.Enabled {
 			continue
 		}
 
 		for _, rule := range policy.Rules {
-			if !rule.Enabled {
+			if rule == nil || !rule.Enabled {
 				continue
 			}
 
@@ -736,7 +744,7 @@ func (c *NetworkMapComponents) getRouteFirewallRules(ctx context.Context, peerID
 	return fwRules
 }
 
-func (c *NetworkMapComponents) getRulePeers(rule *PolicyRule, postureChecks []string, peerID string, distributionPeers map[string]struct{}) []*ComponentPeer {
+func (c *NetworkMapComponents) getRulePeers(rule *nmdata.PolicyRule, postureChecks []string, peerID string, distributionPeers map[string]struct{}) []*nmdata.Peer {
 	distPeersWithPolicy := make(map[string]struct{})
 	for _, id := range rule.Sources {
 		group := c.GetGroupInfo(id)
@@ -755,7 +763,7 @@ func (c *NetworkMapComponents) getRulePeers(rule *PolicyRule, postureChecks []st
 			}
 		}
 	}
-	if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" {
+	if rule.SourceResource.Type == string(ResourceTypePeer) && rule.SourceResource.ID != "" {
 		_, distPeer := distributionPeers[rule.SourceResource.ID]
 		_, valid := c.Peers[rule.SourceResource.ID]
 		if distPeer && valid && c.ValidatePostureChecksOnPeer(rule.SourceResource.ID, postureChecks) {
@@ -763,7 +771,7 @@ func (c *NetworkMapComponents) getRulePeers(rule *PolicyRule, postureChecks []st
 		}
 	}
 
-	distributionGroupPeers := make([]*ComponentPeer, 0, len(distPeersWithPolicy))
+	distributionGroupPeers := make([]*nmdata.Peer, 0, len(distPeersWithPolicy))
 	for pID := range distPeersWithPolicy {
 		peerInfo := c.GetPeerInfo(pID)
 		if peerInfo == nil {
@@ -774,9 +782,9 @@ func (c *NetworkMapComponents) getRulePeers(rule *PolicyRule, postureChecks []st
 	return distributionGroupPeers
 }
 
-func (c *NetworkMapComponents) getNetworkResourcesRoutesToSync(peerID string) (bool, []*route.Route, map[string]struct{}) {
+func (c *NetworkMapComponents) getNetworkResourcesRoutesToSync(peerID string) (bool, []*nmdata.Route, map[string]struct{}) {
 	var isRoutingPeer bool
-	var routes []*route.Route
+	var routes []*nmdata.Route
 	allSourcePeers := make(map[string]struct{})
 
 	for _, resource := range c.NetworkResources {
@@ -803,14 +811,17 @@ func (c *NetworkMapComponents) getNetworkResourcesRoutesToSync(peerID string) (b
 
 func (c *NetworkMapComponents) processResourcePolicies(
 	peerID string,
-	resource *ComponentResource,
-	networkRoutingPeers map[string]*ComponentRouter,
+	resource *nmdata.NetworkResource,
+	networkRoutingPeers map[string]*nmdata.NetworkRouter,
 	addSourcePeers bool,
 	allSourcePeers map[string]struct{},
-) []*route.Route {
-	var routes []*route.Route
+) []*nmdata.Route {
+	var routes []*nmdata.Route
 
 	for _, policy := range c.ResourcePoliciesMap[resource.ID] {
+		if policy == nil || !policy.Enabled || len(policy.Rules) == 0 || policy.Rules[0] == nil {
+			continue
+		}
 		peers := c.getResourcePolicyPeers(policy)
 		if addSourcePeers {
 			for _, pID := range c.getPostureValidPeers(peers, policy.SourcePostureChecks) {
@@ -830,17 +841,17 @@ func (c *NetworkMapComponents) processResourcePolicies(
 	return routes
 }
 
-func (c *NetworkMapComponents) getResourcePolicyPeers(policy *Policy) []string {
-	if policy.Rules[0].SourceResource.Type == ResourceTypePeer && policy.Rules[0].SourceResource.ID != "" {
+func (c *NetworkMapComponents) getResourcePolicyPeers(policy *nmdata.Policy) []string {
+	if policy.Rules[0].SourceResource.Type == string(ResourceTypePeer) && policy.Rules[0].SourceResource.ID != "" {
 		return []string{policy.Rules[0].SourceResource.ID}
 	}
 	return c.getUniquePeerIDsFromGroupsIDs(policy.SourceGroups())
 }
 
-func (c *NetworkMapComponents) getNetworkResourcesRoutes(resource *ComponentResource, peerID string, router *ComponentRouter) []*route.Route {
+func (c *NetworkMapComponents) getNetworkResourcesRoutes(resource *nmdata.NetworkResource, peerID string, router *nmdata.NetworkRouter) []*nmdata.Route {
 	resourceAppliedPolicies := c.ResourcePoliciesMap[resource.ID]
 
-	var routes []*route.Route
+	var routes []*nmdata.Route
 	if len(resourceAppliedPolicies) > 0 {
 		peerInfo := c.GetPeerInfo(peerID)
 		if peerInfo != nil {
@@ -851,9 +862,9 @@ func (c *NetworkMapComponents) getNetworkResourcesRoutes(resource *ComponentReso
 	return routes
 }
 
-func (c *NetworkMapComponents) networkResourceToRoute(resource *ComponentResource, peer *ComponentPeer, router *ComponentRouter) *route.Route {
-	r := &route.Route{
-		ID:          route.ID(resource.ID + ":" + peer.ID),
+func (c *NetworkMapComponents) networkResourceToRoute(resource *nmdata.NetworkResource, peer *nmdata.Peer, router *nmdata.NetworkRouter) *nmdata.Route {
+	r := &nmdata.Route{
+		ID:          resource.ID + ":" + peer.ID,
 		AccountID:   resource.AccountID,
 		Peer:        peer.Key,
 		PeerID:      peer.ID,
@@ -861,24 +872,24 @@ func (c *NetworkMapComponents) networkResourceToRoute(resource *ComponentResourc
 		Masquerade:  router.Masquerade,
 		Enabled:     resource.Enabled,
 		KeepRoute:   true,
-		NetID:       route.NetID(resource.Name),
+		NetID:       resource.Name,
 		Description: resource.Description,
 	}
 
-	if resource.Type == ComponentResourceHost || resource.Type == ComponentResourceSubnet {
+	if resource.Type == string(ResourceTypeHost) || resource.Type == string(ResourceTypeSubnet) {
 		r.Network = resource.Prefix
 
-		r.NetworkType = route.IPv4Network
+		r.NetworkType = nmdata.NetworkTypeIPv4
 		if resource.Prefix.Addr().Is6() {
-			r.NetworkType = route.IPv6Network
+			r.NetworkType = nmdata.NetworkTypeIPv6
 		}
 	}
 
-	if resource.Type == ComponentResourceDomain {
+	if resource.Type == string(ResourceTypeDomain) {
 		domainList, err := domain.FromStringList([]string{resource.Domain})
 		if err == nil {
 			r.Domains = domainList
-			r.NetworkType = route.DomainNetwork
+			r.NetworkType = nmdata.NetworkTypeDomain
 			r.Network = netip.PrefixFrom(netip.AddrFrom4([4]byte{192, 0, 2, 0}), 32)
 		}
 	}
@@ -896,7 +907,7 @@ func (c *NetworkMapComponents) getPostureValidPeers(inputPeers []string, posture
 	return dest
 }
 
-func (c *NetworkMapComponents) getPeerNetworkResourceFirewallRules(ctx context.Context, peerID string, routes []*route.Route, includeIPv6 bool) []*RouteFirewallRule {
+func (c *NetworkMapComponents) getPeerNetworkResourceFirewallRules(ctx context.Context, peerID string, routes []*nmdata.Route, includeIPv6 bool) []*RouteFirewallRule {
 	routesFirewallRules := make([]*RouteFirewallRule, 0)
 
 	peerInfo := c.GetPeerInfo(peerID)
@@ -924,11 +935,17 @@ func (c *NetworkMapComponents) getPeerNetworkResourceFirewallRules(ctx context.C
 	return routesFirewallRules
 }
 
-func (c *NetworkMapComponents) getPoliciesSourcePeers(policies []*Policy) map[string]struct{} {
+func (c *NetworkMapComponents) getPoliciesSourcePeers(policies []*nmdata.Policy) map[string]struct{} {
 	sourcePeers := make(map[string]struct{})
 
 	for _, policy := range policies {
+		if policy == nil {
+			continue
+		}
 		for _, rule := range policy.Rules {
+			if rule == nil {
+				continue
+			}
 			for _, sourceGroup := range rule.Sources {
 				group := c.GetGroupInfo(sourceGroup)
 				if group == nil {
@@ -940,7 +957,7 @@ func (c *NetworkMapComponents) getPoliciesSourcePeers(policies []*Policy) map[st
 				}
 			}
 
-			if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" {
+			if rule.SourceResource.Type == string(ResourceTypePeer) && rule.SourceResource.ID != "" {
 				sourcePeers[rule.SourceResource.ID] = struct{}{}
 			}
 		}
@@ -950,13 +967,13 @@ func (c *NetworkMapComponents) getPoliciesSourcePeers(policies []*Policy) map[st
 }
 
 func (c *NetworkMapComponents) addNetworksRoutingPeers(
-	networkResourcesRoutes []*route.Route,
+	networkResourcesRoutes []*nmdata.Route,
 	peerID string,
-	peersToConnect []*ComponentPeer,
-	expiredPeers []*ComponentPeer,
+	peersToConnect []*nmdata.Peer,
+	expiredPeers []*nmdata.Peer,
 	isRouter bool,
 	sourcePeers map[string]struct{},
-) []*ComponentPeer {
+) []*nmdata.Peer {
 
 	networkRoutesPeers := make(map[string]struct{}, len(networkResourcesRoutes))
 	for _, r := range networkResourcesRoutes {
@@ -1006,8 +1023,8 @@ type FirewallRuleContext struct {
 	PortsJoined string
 }
 
-func AppendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *ComponentPeer, rule *PolicyRule, rc FirewallRuleContext) []*FirewallRule {
-	if !peer.IPv6.IsValid() || !targetPeer.SupportsIPv6 || !targetPeer.IPv6.IsValid() {
+func AppendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *nmdata.Peer, rule *nmdata.PolicyRule, rc FirewallRuleContext) []*FirewallRule {
+	if !peer.IPv6.IsValid() || !targetPeer.SupportsIPv6() || !targetPeer.IPv6.IsValid() {
 		return rules
 	}
 
diff --git a/shared/management/types/networkmap_components_compact.go b/shared/management/types/networkmap_components_compact.go
index a1f53690d..b45bc3e40 100644
--- a/shared/management/types/networkmap_components_compact.go
+++ b/shared/management/types/networkmap_components_compact.go
@@ -1,8 +1,7 @@
 package types
 
 import (
-	nbdns "github.com/netbirdio/netbird/dns"
-	"github.com/netbirdio/netbird/route"
+	nmdata "github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
 )
 
 type GroupCompact struct {
@@ -13,26 +12,26 @@ type GroupCompact struct {
 type NetworkMapComponentsCompact struct {
 	PeerID string
 
-	Network          *Network
-	AccountSettings  *AccountSettingsInfo
-	DNSSettings      *DNSSettings
+	Network          *nmdata.Network
+	AccountSettings  *nmdata.AccountSettingsInfo
+	DNSSettings      *nmdata.DNSSettings
 	CustomZoneDomain string
 
-	AllPeers          []*ComponentPeer
+	AllPeers          []*nmdata.Peer
 	PeerIndexes       []int
 	RouterPeerIndexes []int
 
 	Groups              map[string]*GroupCompact
-	AllPolicies         []*Policy
+	AllPolicies         []*nmdata.Policy
 	PolicyIndexes       []int
 	ResourcePoliciesMap map[string][]int
-	Routes              []*route.Route
-	NameServerGroups    []*nbdns.NameServerGroup
-	AllDNSRecords       []nbdns.SimpleRecord
-	AccountZones        []nbdns.CustomZone
+	Routes              []*nmdata.Route
+	NameServerGroups    []*nmdata.NameServerGroup
+	AllDNSRecords       []nmdata.SimpleRecord
+	AccountZones        []nmdata.CustomZone
 
-	RoutersMap       map[string]map[string]*ComponentRouter
-	NetworkResources []*ComponentResource
+	RoutersMap       map[string]map[string]*nmdata.NetworkRouter
+	NetworkResources []*nmdata.NetworkResource
 
 	GroupIDToUserIDs   map[string][]string
 	AllowedUserIDs     map[string]struct{}
@@ -41,7 +40,7 @@ type NetworkMapComponentsCompact struct {
 
 func (c *NetworkMapComponents) ToCompact() *NetworkMapComponentsCompact {
 	peerToIndex := make(map[string]int)
-	var allPeers []*ComponentPeer
+	var allPeers []*nmdata.Peer
 
 	for id, peer := range c.Peers {
 		if _, exists := peerToIndex[id]; !exists {
@@ -81,8 +80,8 @@ func (c *NetworkMapComponents) ToCompact() *NetworkMapComponentsCompact {
 		}
 	}
 
-	policyToIndex := make(map[*Policy]int)
-	var allPolicies []*Policy
+	policyToIndex := make(map[*nmdata.Policy]int)
+	var allPolicies []*nmdata.Policy
 
 	for _, policy := range c.Policies {
 		if _, exists := policyToIndex[policy]; !exists {
@@ -147,7 +146,7 @@ func (c *NetworkMapComponents) ToCompact() *NetworkMapComponentsCompact {
 }
 
 func (c *NetworkMapComponentsCompact) ToFull() *NetworkMapComponents {
-	peers := make(map[string]*ComponentPeer, len(c.PeerIndexes))
+	peers := make(map[string]*nmdata.Peer, len(c.PeerIndexes))
 	for _, idx := range c.PeerIndexes {
 		if idx >= 0 && idx < len(c.AllPeers) {
 			peer := c.AllPeers[idx]
@@ -155,7 +154,7 @@ func (c *NetworkMapComponentsCompact) ToFull() *NetworkMapComponents {
 		}
 	}
 
-	routerPeers := make(map[string]*ComponentPeer, len(c.RouterPeerIndexes))
+	routerPeers := make(map[string]*nmdata.Peer, len(c.RouterPeerIndexes))
 	for _, idx := range c.RouterPeerIndexes {
 		if idx >= 0 && idx < len(c.AllPeers) {
 			peer := c.AllPeers[idx]
@@ -163,7 +162,7 @@ func (c *NetworkMapComponentsCompact) ToFull() *NetworkMapComponents {
 		}
 	}
 
-	groups := make(map[string]*ComponentGroup, len(c.Groups))
+	groups := make(map[string]*nmdata.Group, len(c.Groups))
 	for id, gc := range c.Groups {
 		peerIDs := make([]string, 0, len(gc.PeerIndexes))
 		for _, idx := range gc.PeerIndexes {
@@ -171,25 +170,24 @@ func (c *NetworkMapComponentsCompact) ToFull() *NetworkMapComponents {
 				peerIDs = append(peerIDs, c.AllPeers[idx].ID)
 			}
 		}
-		groups[id] = &ComponentGroup{
-			ID:    id,
+		groups[id] = &nmdata.Group{
 			Name:  gc.Name,
 			Peers: peerIDs,
 		}
 	}
 
-	policies := make([]*Policy, len(c.PolicyIndexes))
+	policies := make([]*nmdata.Policy, len(c.PolicyIndexes))
 	for i, idx := range c.PolicyIndexes {
 		if idx >= 0 && idx < len(c.AllPolicies) {
 			policies[i] = c.AllPolicies[idx]
 		}
 	}
 
-	var resourcePoliciesMap map[string][]*Policy
+	var resourcePoliciesMap map[string][]*nmdata.Policy
 	if len(c.ResourcePoliciesMap) > 0 {
-		resourcePoliciesMap = make(map[string][]*Policy, len(c.ResourcePoliciesMap))
+		resourcePoliciesMap = make(map[string][]*nmdata.Policy, len(c.ResourcePoliciesMap))
 		for resID, indexes := range c.ResourcePoliciesMap {
-			pols := make([]*Policy, 0, len(indexes))
+			pols := make([]*nmdata.Policy, 0, len(indexes))
 			for _, idx := range indexes {
 				if idx >= 0 && idx < len(c.AllPolicies) {
 					pols = append(pols, c.AllPolicies[idx])
diff --git a/shared/management/types/nmdata_convert.go b/shared/management/types/nmdata_convert.go
new file mode 100644
index 000000000..2a7998773
--- /dev/null
+++ b/shared/management/types/nmdata_convert.go
@@ -0,0 +1,70 @@
+package types
+
+import (
+	nbdns "github.com/netbirdio/netbird/dns"
+	"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
+)
+
+// This file holds the twin→real converters that survive the twin-NetworkMap
+// refactor: only the DNS materialization. NetworkMap.DNSConfig stays a real
+// nbdns.Config (the client DNS type), so Calculate converts the twin DNS
+// components to nbdns at the output boundary. Peers/Routes/Network flow as
+// twins all the way through and need no conversion.
+
+func toRealNSGroup(n *nmdata.NameServerGroup) *nbdns.NameServerGroup {
+	if n == nil {
+		return nil
+	}
+	nameServers := make([]nbdns.NameServer, 0, len(n.NameServers))
+	for _, ns := range n.NameServers {
+		nameServers = append(nameServers, nbdns.NameServer{
+			IP:     ns.IP,
+			NSType: nbdns.NameServerType(ns.NSType),
+			Port:   ns.Port,
+		})
+	}
+	return &nbdns.NameServerGroup{
+		ID:                   n.ID,
+		Name:                 n.Name,
+		Description:          n.Description,
+		NameServers:          nameServers,
+		Groups:               n.Groups,
+		Primary:              n.Primary,
+		Domains:              n.Domains,
+		Enabled:              n.Enabled,
+		SearchDomainsEnabled: n.SearchDomainsEnabled,
+	}
+}
+
+func toRealRecords(recs []nmdata.SimpleRecord) []nbdns.SimpleRecord {
+	if recs == nil {
+		return nil
+	}
+	out := make([]nbdns.SimpleRecord, len(recs))
+	for i, r := range recs {
+		out[i] = nbdns.SimpleRecord{
+			Name:  r.Name,
+			Type:  r.Type,
+			Class: r.Class,
+			TTL:   r.TTL,
+			RData: r.RData,
+		}
+	}
+	return out
+}
+
+func toRealZones(zones []nmdata.CustomZone) []nbdns.CustomZone {
+	if zones == nil {
+		return nil
+	}
+	out := make([]nbdns.CustomZone, len(zones))
+	for i, z := range zones {
+		out[i] = nbdns.CustomZone{
+			Domain:               z.Domain,
+			Records:              toRealRecords(z.Records),
+			SearchDomainDisabled: z.SearchDomainDisabled,
+			NonAuthoritative:     z.NonAuthoritative,
+		}
+	}
+	return out
+}
diff --git a/shared/management/types/policyrule.go b/shared/management/types/policyrule.go
index 52c494a6a..c951b1487 100644
--- a/shared/management/types/policyrule.go
+++ b/shared/management/types/policyrule.go
@@ -1,22 +1,39 @@
 package types
 
 import (
-	"slices"
+	"errors"
+	"fmt"
+	"strconv"
+	"strings"
 
 	"github.com/netbirdio/netbird/shared/management/proto"
 )
 
-// PolicyUpdateOperationType operation type
-type PolicyUpdateOperationType int
-
 // PolicyTrafficActionType action type for the firewall
 type PolicyTrafficActionType string
 
 // PolicyRuleProtocolType type of traffic
 type PolicyRuleProtocolType string
 
-// PolicyRuleDirection direction of traffic
-type PolicyRuleDirection string
+const (
+	// PolicyTrafficActionAccept indicates that the traffic is accepted
+	PolicyTrafficActionAccept = PolicyTrafficActionType("accept")
+	// PolicyTrafficActionDrop indicates that the traffic is dropped
+	PolicyTrafficActionDrop = PolicyTrafficActionType("drop")
+)
+
+const (
+	// PolicyRuleProtocolALL type of traffic
+	PolicyRuleProtocolALL = PolicyRuleProtocolType("all")
+	// PolicyRuleProtocolTCP type of traffic
+	PolicyRuleProtocolTCP = PolicyRuleProtocolType("tcp")
+	// PolicyRuleProtocolUDP type of traffic
+	PolicyRuleProtocolUDP = PolicyRuleProtocolType("udp")
+	// PolicyRuleProtocolICMP type of traffic
+	PolicyRuleProtocolICMP = PolicyRuleProtocolType("icmp")
+	// PolicyRuleProtocolNetbirdSSH type of traffic
+	PolicyRuleProtocolNetbirdSSH = PolicyRuleProtocolType("netbird-ssh")
+)
 
 // RulePortRange represents a range of ports for a firewall rule.
 type RulePortRange struct {
@@ -39,187 +56,84 @@ func (r *RulePortRange) Equal(other *RulePortRange) bool {
 	return r.Start == other.Start && r.End == other.End
 }
 
-// PolicyRule is the metadata of the policy
-type PolicyRule struct {
-	// ID of the policy rule
-	ID string `gorm:"primaryKey"`
+func ParseRuleString(rule string) (PolicyRuleProtocolType, RulePortRange, error) {
+	rule = strings.TrimSpace(strings.ToLower(rule))
+	if rule == "all" {
+		return PolicyRuleProtocolALL, RulePortRange{}, nil
+	}
+	if rule == "icmp" {
+		return PolicyRuleProtocolICMP, RulePortRange{}, nil
+	}
 
-	// PolicyID is a reference to Policy that this object belongs
-	PolicyID string `json:"-" gorm:"index"`
+	split := strings.Split(rule, "/")
+	if len(split) != 2 {
+		return "", RulePortRange{}, errors.New("invalid rule format: expected protocol/port or protocol/port-range")
+	}
 
-	// Name of the rule visible in the UI
-	Name string
+	protoStr := strings.TrimSpace(split[0])
+	portStr := strings.TrimSpace(split[1])
 
-	// Description of the rule visible in the UI
-	Description string
+	var protocol PolicyRuleProtocolType
+	switch protoStr {
+	case "tcp":
+		protocol = PolicyRuleProtocolTCP
+	case "udp":
+		protocol = PolicyRuleProtocolUDP
+	case "icmp":
+		return "", RulePortRange{}, errors.New("icmp does not accept ports; use 'icmp' without '/…'")
+	case "netbird-ssh":
+		return PolicyRuleProtocolNetbirdSSH, RulePortRange{Start: nativeSSHPortNumber, End: nativeSSHPortNumber}, nil
+	default:
+		return "", RulePortRange{}, fmt.Errorf("invalid protocol: %q", protoStr)
+	}
 
-	// Enabled status of rule in the system
-	Enabled bool
+	portRange, err := parsePortRange(portStr)
+	if err != nil {
+		return "", RulePortRange{}, err
+	}
 
-	// Action policy accept or drops packets
-	Action PolicyTrafficActionType
-
-	// Destinations policy destination groups
-	Destinations []string `gorm:"serializer:json"`
-
-	// DestinationResource policy destination resource that the rule is applied to
-	DestinationResource Resource `gorm:"serializer:json"`
-
-	// Sources policy source groups
-	Sources []string `gorm:"serializer:json"`
-
-	// SourceResource policy source resource that the rule is applied to
-	SourceResource Resource `gorm:"serializer:json"`
-
-	// Bidirectional define if the rule is applicable in both directions, sources, and destinations
-	Bidirectional bool
-
-	// Protocol type of the traffic
-	Protocol PolicyRuleProtocolType
-
-	// Ports or it ranges list
-	Ports []string `gorm:"serializer:json"`
-
-	// PortRanges a list of port ranges.
-	PortRanges []RulePortRange `gorm:"serializer:json"`
-
-	// AuthorizedGroups is a map of groupIDs and their respective access to local users via ssh
-	AuthorizedGroups map[string][]string `gorm:"serializer:json"`
-
-	// AuthorizedUser is a list of userIDs that are authorized to access local resources via ssh
-	AuthorizedUser string
+	return protocol, portRange, nil
 }
 
-// Copy returns a copy of a policy rule
-func (pm *PolicyRule) Copy() *PolicyRule {
-	rule := &PolicyRule{
-		ID:                  pm.ID,
-		PolicyID:            pm.PolicyID,
-		Name:                pm.Name,
-		Description:         pm.Description,
-		Enabled:             pm.Enabled,
-		Action:              pm.Action,
-		Destinations:        make([]string, len(pm.Destinations)),
-		DestinationResource: pm.DestinationResource,
-		Sources:             make([]string, len(pm.Sources)),
-		SourceResource:      pm.SourceResource,
-		Bidirectional:       pm.Bidirectional,
-		Protocol:            pm.Protocol,
-		Ports:               make([]string, len(pm.Ports)),
-		PortRanges:          make([]RulePortRange, len(pm.PortRanges)),
-		AuthorizedGroups:    make(map[string][]string, len(pm.AuthorizedGroups)),
-		AuthorizedUser:      pm.AuthorizedUser,
-	}
-	copy(rule.Destinations, pm.Destinations)
-	copy(rule.Sources, pm.Sources)
-	copy(rule.Ports, pm.Ports)
-	copy(rule.PortRanges, pm.PortRanges)
-	for k, v := range pm.AuthorizedGroups {
-		rule.AuthorizedGroups[k] = make([]string, len(v))
-		copy(rule.AuthorizedGroups[k], v)
-	}
-	return rule
-}
-
-func (pm *PolicyRule) Equal(other *PolicyRule) bool {
-	if pm == nil || other == nil {
-		return pm == other
-	}
-
-	if pm.ID != other.ID ||
-		pm.PolicyID != other.PolicyID ||
-		pm.Name != other.Name ||
-		pm.Description != other.Description ||
-		pm.Enabled != other.Enabled ||
-		pm.Action != other.Action ||
-		pm.Bidirectional != other.Bidirectional ||
-		pm.Protocol != other.Protocol ||
-		pm.SourceResource != other.SourceResource ||
-		pm.DestinationResource != other.DestinationResource ||
-		pm.AuthorizedUser != other.AuthorizedUser {
-		return false
-	}
-
-	if !stringSlicesEqualUnordered(pm.Sources, other.Sources) {
-		return false
-	}
-	if !stringSlicesEqualUnordered(pm.Destinations, other.Destinations) {
-		return false
-	}
-	if !stringSlicesEqualUnordered(pm.Ports, other.Ports) {
-		return false
-	}
-	if !portRangeSlicesEqualUnordered(pm.PortRanges, other.PortRanges) {
-		return false
-	}
-	if !authorizedGroupsEqual(pm.AuthorizedGroups, other.AuthorizedGroups) {
-		return false
-	}
-
-	return true
-}
-
-func stringSlicesEqualUnordered(a, b []string) bool {
-	if len(a) != len(b) {
-		return false
-	}
-	if len(a) == 0 {
-		return true
-	}
-	sorted1 := make([]string, len(a))
-	sorted2 := make([]string, len(b))
-	copy(sorted1, a)
-	copy(sorted2, b)
-	slices.Sort(sorted1)
-	slices.Sort(sorted2)
-	return slices.Equal(sorted1, sorted2)
-}
-
-func portRangeSlicesEqualUnordered(a, b []RulePortRange) bool {
-	if len(a) != len(b) {
-		return false
-	}
-	if len(a) == 0 {
-		return true
-	}
-	cmp := func(x, y RulePortRange) int {
-		if x.Start != y.Start {
-			if x.Start < y.Start {
-				return -1
-			}
-			return 1
+func parsePortRange(portStr string) (RulePortRange, error) {
+	if strings.Contains(portStr, "-") {
+		rangeParts := strings.Split(portStr, "-")
+		if len(rangeParts) != 2 {
+			return RulePortRange{}, fmt.Errorf("invalid port range %q", portStr)
 		}
-		if x.End != y.End {
-			if x.End < y.End {
-				return -1
-			}
-			return 1
+		start, err := parsePort(strings.TrimSpace(rangeParts[0]))
+		if err != nil {
+			return RulePortRange{}, err
 		}
-		return 0
+		end, err := parsePort(strings.TrimSpace(rangeParts[1]))
+		if err != nil {
+			return RulePortRange{}, err
+		}
+		if start > end {
+			return RulePortRange{}, fmt.Errorf("invalid port range: start %d > end %d", start, end)
+		}
+		return RulePortRange{Start: uint16(start), End: uint16(end)}, nil
 	}
-	sorted1 := make([]RulePortRange, len(a))
-	sorted2 := make([]RulePortRange, len(b))
-	copy(sorted1, a)
-	copy(sorted2, b)
-	slices.SortFunc(sorted1, cmp)
-	slices.SortFunc(sorted2, cmp)
-	return slices.EqualFunc(sorted1, sorted2, func(x, y RulePortRange) bool {
-		return x.Start == y.Start && x.End == y.End
-	})
+
+	p, err := parsePort(portStr)
+	if err != nil {
+		return RulePortRange{}, err
+	}
+
+	return RulePortRange{Start: uint16(p), End: uint16(p)}, nil
 }
 
-func authorizedGroupsEqual(a, b map[string][]string) bool {
-	if len(a) != len(b) {
-		return false
+func parsePort(portStr string) (int, error) {
+
+	if portStr == "" {
+		return 0, errors.New("empty port")
 	}
-	for k, va := range a {
-		vb, ok := b[k]
-		if !ok {
-			return false
-		}
-		if !stringSlicesEqualUnordered(va, vb) {
-			return false
-		}
+	p, err := strconv.Atoi(portStr)
+	if err != nil {
+		return 0, fmt.Errorf("invalid port %q: %w", portStr, err)
 	}
-	return true
+	if p < 1 || p > 65535 {
+		return 0, fmt.Errorf("port out of range (1–65535): %d", p)
+	}
+	return p, nil
 }
diff --git a/shared/management/types/resource.go b/shared/management/types/resource.go
index 8347d8c03..87f27db49 100644
--- a/shared/management/types/resource.go
+++ b/shared/management/types/resource.go
@@ -1,9 +1,5 @@
 package types
 
-import (
-	"github.com/netbirdio/netbird/shared/management/http/api"
-)
-
 type ResourceType string
 
 const (
@@ -13,27 +9,11 @@ const (
 	ResourceTypeSubnet ResourceType = "subnet"
 )
 
-type Resource struct {
-	ID   string
-	Type ResourceType
-}
-
-func (r *Resource) ToAPIResponse() *api.Resource {
-	if r.ID == "" && r.Type == "" {
-		return nil
-	}
-
-	return &api.Resource{
-		Id:   r.ID,
-		Type: api.ResourceType(r.Type),
+func (t ResourceType) Valid() bool {
+	switch t {
+	case ResourceTypePeer, ResourceTypeDomain, ResourceTypeHost, ResourceTypeSubnet:
+		return true
+	default:
+		return false
 	}
 }
-
-func (r *Resource) FromAPIRequest(req *api.Resource) {
-	if req == nil {
-		return
-	}
-
-	r.ID = req.Id
-	r.Type = ResourceType(req.Type)
-}
diff --git a/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)
-		})
-	}
-}